Browse Source

HPCC-26594 React ECL Watch pages not refreshing data

Refreshing the Table doesn't always refresh the data underneath.
Removed superfluous overflowButtonProps={{}}.
Added missing Refresh Buttons.
Split out Summary tabs from Files and Workunits.

Signed-off-by: Gordon Smith <GordonJSmith@gmail.com>
Gordon Smith 3 years ago
parent
commit
84dc50b7b3
37 changed files with 1021 additions and 851 deletions
  1. 52 51
      esp/src/package-lock.json
  2. 50 50
      esp/src/package.json
  3. 2 2
      esp/src/src-react/components/Activities.tsx
  4. 1 1
      esp/src/src-react/components/DFUWorkunits.tsx
  5. 1 1
      esp/src/src-react/components/DataPatterns.tsx
  6. 22 14
      esp/src/src-react/components/FileBlooms.tsx
  7. 55 215
      esp/src/src-react/components/FileDetails.tsx
  8. 16 19
      esp/src/src-react/components/FileDetailsGraph.tsx
  9. 24 63
      esp/src/src-react/components/FileHistory.tsx
  10. 12 2
      esp/src/src-react/components/FileParts.tsx
  11. 194 0
      esp/src/src-react/components/FileSummary.tsx
  12. 1 1
      esp/src/src-react/components/Files.tsx
  13. 4 4
      esp/src/src-react/components/Helpers.tsx
  14. 1 1
      esp/src/src-react/components/InfoGrid.tsx
  15. 1 1
      esp/src/src-react/components/LogViewer.tsx
  16. 1 1
      esp/src/src-react/components/Menu.tsx
  17. 1 1
      esp/src/src-react/components/Metrics.tsx
  18. 23 25
      esp/src/src-react/components/ProtectedBy.tsx
  19. 1 1
      esp/src/src-react/components/Queries.tsx
  20. 3 3
      esp/src/src-react/components/Resources.tsx
  21. 1 1
      esp/src/src-react/components/Result.tsx
  22. 4 4
      esp/src/src-react/components/Results.tsx
  23. 1 1
      esp/src/src-react/components/Search.tsx
  24. 1 1
      esp/src/src-react/components/SourceEditor.tsx
  25. 6 6
      esp/src/src-react/components/SourceFiles.tsx
  26. 4 4
      esp/src/src-react/components/SuperFiles.tsx
  27. 3 3
      esp/src/src-react/components/Variables.tsx
  28. 1 1
      esp/src/src-react/components/Workflows.tsx
  29. 39 228
      esp/src/src-react/components/WorkunitDetails.tsx
  30. 209 0
      esp/src/src-react/components/WorkunitSummary.tsx
  31. 64 19
      esp/src/src-react/hooks/file.ts
  32. 6 1
      esp/src/src-react/hooks/grid.tsx
  33. 139 109
      esp/src/src-react/hooks/workunit.ts
  34. 1 0
      esp/src/src-react/routes.tsx
  35. 9 3
      esp/src/src-react/util/history.ts
  36. 12 0
      esp/src/src-react/util/throttle.ts
  37. 56 14
      esp/src/src/Memory.ts

+ 52 - 51
esp/src/package-lock.json

@@ -223,15 +223,15 @@
       }
     },
     "@fluentui/react": {
-      "version": "8.34.4",
-      "resolved": "https://registry.npmjs.org/@fluentui/react/-/react-8.34.4.tgz",
-      "integrity": "sha512-P5NHFH2lbiS6r07DXSuDCT39BGrXpALImdaL9c4EiGmQZx20Qpsvud8kVGZfl7H6lg7ukwdkmqrDOBev/LrQJg==",
+      "version": "8.35.0",
+      "resolved": "https://registry.npmjs.org/@fluentui/react/-/react-8.35.0.tgz",
+      "integrity": "sha512-mXtIVMbyIBrPCE1JZjNUQuMu29K21p9qbYDPB9Q6SeuMxgfiystH55hrA4beCKIRwiTxU50ATytUb8OTan/Hxw==",
       "requires": {
         "@fluentui/date-time-utilities": "^8.2.2",
         "@fluentui/font-icons-mdl2": "^8.1.11",
         "@fluentui/foundation-legacy": "^8.1.11",
         "@fluentui/merge-styles": "^8.1.5",
-        "@fluentui/react-focus": "^8.2.3",
+        "@fluentui/react-focus": "^8.3.0",
         "@fluentui/react-hooks": "^8.3.2",
         "@fluentui/react-window-provider": "^2.1.4",
         "@fluentui/set-version": "^8.1.4",
@@ -379,9 +379,9 @@
       }
     },
     "@fluentui/react-focus": {
-      "version": "8.2.3",
-      "resolved": "https://registry.npmjs.org/@fluentui/react-focus/-/react-focus-8.2.3.tgz",
-      "integrity": "sha512-TO0KL4tXGaNgI8Hz8pSMK/nNnXdSdNkCM4qGWySpwQA0/1+Td5rGmLEYJbPUIuKSz6eweCZlOCLM1CekSK8ncQ==",
+      "version": "8.3.0",
+      "resolved": "https://registry.npmjs.org/@fluentui/react-focus/-/react-focus-8.3.0.tgz",
+      "integrity": "sha512-i62v8iU6kJhlN3Gc93lLaru85LVZKMECYizLWrgBTCuJB4GvD0hlxJy5+hStVbaDnhWr77+I0VHfjtNIsNjNuQ==",
       "requires": {
         "@fluentui/keyboard-key": "^0.3.4",
         "@fluentui/merge-styles": "^8.1.5",
@@ -796,9 +796,9 @@
       }
     },
     "@hpcc-js/comms": {
-      "version": "2.56.0",
-      "resolved": "https://registry.npmjs.org/@hpcc-js/comms/-/comms-2.56.0.tgz",
-      "integrity": "sha512-XKgPHojRffU3pX1y1pWf3zADelS2TgRO09IZ+G4HGN+4j5AfIxkT4h3veVib7gdfAcS+VkCEtOq1Jm88flHn6Q==",
+      "version": "2.57.0",
+      "resolved": "https://registry.npmjs.org/@hpcc-js/comms/-/comms-2.57.0.tgz",
+      "integrity": "sha512-g8Ym/MIsCoCeL0uhJfOjQqS1fb/EHwRZSRKJj+Em1b9tkwgci4Z2y4peetQLOcbhdQD+XEvLJlziYcLrkqw+Yg==",
       "requires": {
         "@hpcc-js/ddl-shim": "^2.17.18",
         "@hpcc-js/util": "^2.38.0",
@@ -838,13 +838,13 @@
       "integrity": "sha512-AmOj2peJ9UjeJ29ucKGC/zB9JOZKtCtCpnELzVTgw6pxmbYBwNr0NhH9S01E+acSgq6v5oc5xam0UyLlmIhziQ=="
     },
     "@hpcc-js/eclwatch": {
-      "version": "2.56.0",
-      "resolved": "https://registry.npmjs.org/@hpcc-js/eclwatch/-/eclwatch-2.56.0.tgz",
-      "integrity": "sha512-zUv0MykBeLPntVb+/7q0efLMswOsbkI6UiOWiQ7ktGGucT8b/SO3suVyA7xZ6sA6ye3sju4BPeE9de9xWiYeCg==",
+      "version": "2.57.0",
+      "resolved": "https://registry.npmjs.org/@hpcc-js/eclwatch/-/eclwatch-2.57.0.tgz",
+      "integrity": "sha512-ZgRFyHLL1iJcnfMGFEJvScsej05b6TRvinkZ0tO7JO92tKg20RfWpSNp4wSAFafJpUYj5OHcmQnfr80rShee/w==",
       "requires": {
         "@hpcc-js/codemirror": "^2.49.0",
         "@hpcc-js/common": "^2.57.0",
-        "@hpcc-js/comms": "^2.56.0",
+        "@hpcc-js/comms": "^2.57.0",
         "@hpcc-js/dgrid": "^2.18.0",
         "@hpcc-js/graph": "^2.69.0",
         "@hpcc-js/layout": "^2.35.0",
@@ -1481,70 +1481,71 @@
       "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="
     },
     "@typescript-eslint/eslint-plugin": {
-      "version": "4.31.2",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.31.2.tgz",
-      "integrity": "sha512-w63SCQ4bIwWN/+3FxzpnWrDjQRXVEGiTt9tJTRptRXeFvdZc/wLiz3FQUwNQ2CVoRGI6KUWMNUj/pk63noUfcA==",
+      "version": "4.32.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.32.0.tgz",
+      "integrity": "sha512-+OWTuWRSbWI1KDK8iEyG/6uK2rTm3kpS38wuVifGUTDB6kjEuNrzBI1MUtxnkneuWG/23QehABe2zHHrj+4yuA==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/experimental-utils": "4.31.2",
-        "@typescript-eslint/scope-manager": "4.31.2",
+        "@typescript-eslint/experimental-utils": "4.32.0",
+        "@typescript-eslint/scope-manager": "4.32.0",
         "debug": "^4.3.1",
         "functional-red-black-tree": "^1.0.1",
+        "ignore": "^5.1.8",
         "regexpp": "^3.1.0",
         "semver": "^7.3.5",
         "tsutils": "^3.21.0"
       }
     },
     "@typescript-eslint/experimental-utils": {
-      "version": "4.31.2",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.31.2.tgz",
-      "integrity": "sha512-3tm2T4nyA970yQ6R3JZV9l0yilE2FedYg8dcXrTar34zC9r6JB7WyBQbpIVongKPlhEMjhQ01qkwrzWy38Bk1Q==",
+      "version": "4.32.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.32.0.tgz",
+      "integrity": "sha512-WLoXcc+cQufxRYjTWr4kFt0DyEv6hDgSaFqYhIzQZ05cF+kXfqXdUh+//kgquPJVUBbL3oQGKQxwPbLxHRqm6A==",
       "dev": true,
       "requires": {
         "@types/json-schema": "^7.0.7",
-        "@typescript-eslint/scope-manager": "4.31.2",
-        "@typescript-eslint/types": "4.31.2",
-        "@typescript-eslint/typescript-estree": "4.31.2",
+        "@typescript-eslint/scope-manager": "4.32.0",
+        "@typescript-eslint/types": "4.32.0",
+        "@typescript-eslint/typescript-estree": "4.32.0",
         "eslint-scope": "^5.1.1",
         "eslint-utils": "^3.0.0"
       }
     },
     "@typescript-eslint/parser": {
-      "version": "4.31.2",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.31.2.tgz",
-      "integrity": "sha512-EcdO0E7M/sv23S/rLvenHkb58l3XhuSZzKf6DBvLgHqOYdL6YFMYVtreGFWirxaU2mS1GYDby3Lyxco7X5+Vjw==",
+      "version": "4.32.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.32.0.tgz",
+      "integrity": "sha512-lhtYqQ2iEPV5JqV7K+uOVlPePjClj4dOw7K4/Z1F2yvjIUvyr13yJnDzkK6uon4BjHYuHy3EG0c2Z9jEhFk56w==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/scope-manager": "4.31.2",
-        "@typescript-eslint/types": "4.31.2",
-        "@typescript-eslint/typescript-estree": "4.31.2",
+        "@typescript-eslint/scope-manager": "4.32.0",
+        "@typescript-eslint/types": "4.32.0",
+        "@typescript-eslint/typescript-estree": "4.32.0",
         "debug": "^4.3.1"
       }
     },
     "@typescript-eslint/scope-manager": {
-      "version": "4.31.2",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.31.2.tgz",
-      "integrity": "sha512-2JGwudpFoR/3Czq6mPpE8zBPYdHWFGL6lUNIGolbKQeSNv4EAiHaR5GVDQaLA0FwgcdcMtRk+SBJbFGL7+La5w==",
+      "version": "4.32.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.32.0.tgz",
+      "integrity": "sha512-DK+fMSHdM216C0OM/KR1lHXjP1CNtVIhJ54kQxfOE6x8UGFAjha8cXgDMBEIYS2XCYjjCtvTkjQYwL3uvGOo0w==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "4.31.2",
-        "@typescript-eslint/visitor-keys": "4.31.2"
+        "@typescript-eslint/types": "4.32.0",
+        "@typescript-eslint/visitor-keys": "4.32.0"
       }
     },
     "@typescript-eslint/types": {
-      "version": "4.31.2",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.31.2.tgz",
-      "integrity": "sha512-kWiTTBCTKEdBGrZKwFvOlGNcAsKGJSBc8xLvSjSppFO88AqGxGNYtF36EuEYG6XZ9vT0xX8RNiHbQUKglbSi1w==",
+      "version": "4.32.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.32.0.tgz",
+      "integrity": "sha512-LE7Z7BAv0E2UvqzogssGf1x7GPpUalgG07nGCBYb1oK4mFsOiFC/VrSMKbZQzFJdN2JL5XYmsx7C7FX9p9ns0w==",
       "dev": true
     },
     "@typescript-eslint/typescript-estree": {
-      "version": "4.31.2",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.31.2.tgz",
-      "integrity": "sha512-ieBq8U9at6PvaC7/Z6oe8D3czeW5d//Fo1xkF/s9394VR0bg/UaMYPdARiWyKX+lLEjY3w/FNZJxitMsiWv+wA==",
+      "version": "4.32.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.32.0.tgz",
+      "integrity": "sha512-tRYCgJ3g1UjMw1cGG8Yn1KzOzNlQ6u1h9AmEtPhb5V5a1TmiHWcRyF/Ic+91M4f43QeChyYlVTcf3DvDTZR9vw==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "4.31.2",
-        "@typescript-eslint/visitor-keys": "4.31.2",
+        "@typescript-eslint/types": "4.32.0",
+        "@typescript-eslint/visitor-keys": "4.32.0",
         "debug": "^4.3.1",
         "globby": "^11.0.3",
         "is-glob": "^4.0.1",
@@ -1575,12 +1576,12 @@
       }
     },
     "@typescript-eslint/visitor-keys": {
-      "version": "4.31.2",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.31.2.tgz",
-      "integrity": "sha512-PrBId7EQq2Nibns7dd/ch6S6/M4/iwLM9McbgeEbCXfxdwRUNxJ4UNreJ6Gh3fI2GNKNrWnQxKL7oCPmngKBug==",
+      "version": "4.32.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.32.0.tgz",
+      "integrity": "sha512-e7NE0qz8W+atzv3Cy9qaQ7BTLwWsm084Z0c4nIO2l3Bp6u9WIgdqCgyPyV5oSPDMIW3b20H59OOCmVk3jw3Ptw==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "4.31.2",
+        "@typescript-eslint/types": "4.32.0",
         "eslint-visitor-keys": "^2.0.0"
       }
     },
@@ -8071,9 +8072,9 @@
       "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="
     },
     "react-hook-form": {
-      "version": "7.15.4",
-      "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.15.4.tgz",
-      "integrity": "sha512-jEtsDBPfpkz1uuJVlTLDOg+jO3cG9pFHT3g5uayVvlNT551IetXE1iwrSaxUR/QPWyJA2FLx4Q/VjO2viZNfLg=="
+      "version": "7.16.1",
+      "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.16.1.tgz",
+      "integrity": "sha512-kcLDmSmlyLUFx2UU5bG/o4+3NeK753fhKodJa8gkplXohGkpAq0/p+TR24OWjZmkEc3ES7ppC5v5d6KUk+fJTA=="
     },
     "react-is": {
       "version": "16.13.1",

+ 50 - 50
esp/src/package.json

@@ -33,28 +33,28 @@
   },
   "main": "src/stub.js",
   "dependencies": {
-    "@fluentui/react": "^8.34.4",
+    "@fluentui/react": "8.35.0",
     "@fluentui/react-cards": "1.0.0-beta.0",
-    "@fluentui/react-hooks": "^8.3.1",
-    "@fluentui/react-icons-mdl2": "^1.2.1",
-    "@hpcc-js/chart": "^2.67.0",
-    "@hpcc-js/codemirror": "^2.49.0",
-    "@hpcc-js/common": "^2.57.0",
-    "@hpcc-js/comms": "^2.56.0",
-    "@hpcc-js/dataflow": "^3.0.1",
-    "@hpcc-js/eclwatch": "^2.56.0",
-    "@hpcc-js/graph": "^2.69.0",
-    "@hpcc-js/html": "^2.32.0",
-    "@hpcc-js/layout": "^2.35.0",
-    "@hpcc-js/map": "^2.62.0",
-    "@hpcc-js/other": "^2.13.68",
-    "@hpcc-js/phosphor": "^2.14.49",
-    "@hpcc-js/react": "^2.40.0",
-    "@hpcc-js/tree": "^2.30.0",
-    "@hpcc-js/util": "^2.38.0",
-    "@material-ui/core": "^4.12.3",
-    "@material-ui/icons": "^4.11.2",
-    "@material-ui/lab": "^4.0.0-alpha.60",
+    "@fluentui/react-hooks": "8.3.2",
+    "@fluentui/react-icons-mdl2": "1.2.2",
+    "@hpcc-js/chart": "2.67.0",
+    "@hpcc-js/codemirror": "2.49.0",
+    "@hpcc-js/common": "2.57.0",
+    "@hpcc-js/comms": "2.57.0",
+    "@hpcc-js/dataflow": "3.0.1",
+    "@hpcc-js/eclwatch": "2.57.0",
+    "@hpcc-js/graph": "2.69.0",
+    "@hpcc-js/html": "2.32.0",
+    "@hpcc-js/layout": "2.35.0",
+    "@hpcc-js/map": "2.62.0",
+    "@hpcc-js/other": "2.13.68",
+    "@hpcc-js/phosphor": "2.14.49",
+    "@hpcc-js/react": "2.40.0",
+    "@hpcc-js/tree": "2.30.0",
+    "@hpcc-js/util": "2.38.0",
+    "@material-ui/core": "4.12.3",
+    "@material-ui/icons": "4.11.2",
+    "@material-ui/lab": "4.0.0-alpha.60",
     "clipboard": "2.0.4",
     "detect-browser": "5.0.0",
     "dijit": "1.16.3",
@@ -64,40 +64,40 @@
     "dojox": "1.16.3",
     "es6-promise": "4.2.8",
     "font-awesome": "4.7.0",
-    "formik": "^2.2.9",
+    "formik": "2.2.9",
     "query-string": "6.13.2",
-    "react": "^16.12.0",
-    "react-dom": "^16.13.1",
-    "react-hook-form": "^7.15.4",
-    "react-reflex": "^4.0.3",
-    "react-sizeme": "^3.0.2",
-    "universal-router": "^9.1.0"
+    "react": "16.14.0",
+    "react-dom": "16.14.0",
+    "react-hook-form": "7.16.1",
+    "react-reflex": "4.0.3",
+    "react-sizeme": "3.0.2",
+    "universal-router": "9.1.0"
   },
   "devDependencies": {
-    "@types/dojo": "^1.9.43",
-    "@types/react": "^16.14.15",
-    "@types/react-dom": "^16.9.14",
-    "@typescript-eslint/eslint-plugin": "^4.31.2",
-    "@typescript-eslint/parser": "^4.31.2",
+    "@types/dojo": "1.9.43",
+    "@types/react": "16.14.15",
+    "@types/react-dom": "16.9.14",
+    "@typescript-eslint/eslint-plugin": "4.32.0",
+    "@typescript-eslint/parser": "4.32.0",
     "braces": ">=2.3.1",
-    "cpx": "^1.5.0",
-    "css-loader": "^3.4.2",
-    "dojo-webpack-plugin": "^2.8.20",
-    "eslint": "^7.32.0",
-    "eslint-plugin-react-hooks": "^4.2.0",
-    "file-loader": "^5.1.0",
-    "local-web-server": "^4.0.0",
+    "cpx": "1.5.0",
+    "css-loader": "3.6.0",
+    "dojo-webpack-plugin": "2.8.20",
+    "eslint": "7.32.0",
+    "eslint-plugin-react-hooks": "4.2.0",
+    "file-loader": "5.1.0",
+    "local-web-server": "4.2.1",
     "minimist": ">=1.2.2",
-    "npm-run-all": "^4.1.5",
-    "rimraf": "^3.0.2",
-    "source-map-loader": "^1.1.3",
-    "style-loader": "^1.1.3",
-    "tslib": "^2.3.1",
-    "typescript": "^4.4.3",
-    "url-loader": "^3.0.0",
-    "webpack": "^4.45.0",
-    "webpack-cli": "^4.8.0",
-    "webpack-dev-server": "^3.11.2"
+    "npm-run-all": "4.1.5",
+    "rimraf": "3.0.2",
+    "source-map-loader": "1.1.3",
+    "style-loader": "1.3.0",
+    "tslib": "2.3.1",
+    "typescript": "4.4.3",
+    "url-loader": "3.0.0",
+    "webpack": "4.46.0",
+    "webpack-cli": "4.8.0",
+    "webpack-dev-server": "3.11.2"
   },
   "author": "HPCC Systems",
   "license": "Apache-2.0",

+ 2 - 2
esp/src/src-react/components/Activities.tsx

@@ -391,7 +391,7 @@ export const Activities: React.FunctionComponent<ActivitiesProps> = ({
 
     if (dojoConfig.isContainer) {
         return <HolyGrail
-            header={<CommandBar items={buttons} overflowButtonProps={{}} farItems={copyButtons} />}
+            header={<CommandBar items={buttons} farItems={copyButtons} />}
             main={
                 <Grid />
             }
@@ -406,7 +406,7 @@ export const Activities: React.FunctionComponent<ActivitiesProps> = ({
         </ReflexSplitter>
         <ReflexElement>
             <HolyGrail
-                header={<CommandBar items={buttons} overflowButtonProps={{}} farItems={copyButtons} />}
+                header={<CommandBar items={buttons} farItems={copyButtons} />}
                 main={
                     <Grid />
                 }

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

@@ -196,7 +196,7 @@ export const DFUWorkunits: React.FunctionComponent<DFUWorkunitsProps> = ({
     }, [selection]);
 
     return <HolyGrail
-        header={<CommandBar items={buttons} overflowButtonProps={{}} farItems={copyButtons} />}
+        header={<CommandBar items={buttons} farItems={copyButtons} />}
         main={
             <>
                 <Grid />

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

@@ -119,7 +119,7 @@ export const DataPatterns: React.FunctionComponent<DataPatternsProps> = ({
 
     return <ScrollablePane scrollbarVisibility={ScrollbarVisibility.auto}>
         <Sticky stickyPosition={StickyPositionType.Header}>
-            <CommandBar items={buttons} overflowButtonProps={{}} farItems={rightButtons} />
+            <CommandBar items={buttons} farItems={rightButtons} />
         </Sticky>
         {wu?.isComplete() ?
             <AutosizeHpccJSComponent widget={dpReport} /> :

+ 22 - 14
esp/src/src-react/components/FileBlooms.tsx

@@ -1,4 +1,5 @@
 import * as React from "react";
+import { CommandBar, ICommandBarItemProps } from "@fluentui/react";
 import { useConst } from "@fluentui/react-hooks";
 import * as Observable from "dojo/store/Observable";
 import { Memory } from "src/Memory";
@@ -17,11 +18,11 @@ export const FileBlooms: React.FunctionComponent<FileBloomsProps> = ({
     logicalFile
 }) => {
 
-    const [file, , _refresh] = useFile(cluster, logicalFile);
+    const [file, , , refreshData] = useFile(cluster, logicalFile);
 
     //  Grid ---
     const store = useConst(new Observable(new Memory("FieldNames")));
-    const [Grid, , refreshTable] = useGrid({
+    const [Grid, , refreshTable, copyButtons] = useGrid({
         store,
         sort: [{ attribute: "FieldNames", "descending": false }],
         filename: "fileBlooms",
@@ -33,21 +34,28 @@ export const FileBlooms: React.FunctionComponent<FileBloomsProps> = ({
     });
 
     React.useEffect(() => {
-        if (file?.Blooms) {
-            const fileBlooms = file?.Blooms?.DFUFileBloom;
-            if (fileBlooms) {
-                store.setData(fileBlooms.map(bloom => {
-                    return {
-                        ...bloom,
-                        FieldNames: bloom?.FieldNames?.Item[0] || "",
-                    };
-                }));
-                refreshTable();
-            }
+        const fileBlooms = file?.Blooms?.DFUFileBloom;
+        if (fileBlooms) {
+            store.setData(fileBlooms.map(bloom => {
+                return {
+                    ...bloom,
+                    FieldNames: bloom?.FieldNames?.Item[0] || "",
+                };
+            }));
+            refreshTable();
         }
-    }, [file?.Blooms, store, refreshTable]);
+    }, [file?.Blooms?.DFUFileBloom, refreshTable, store]);
+
+    //  Command Bar  ---
+    const buttons = React.useMemo((): ICommandBarItemProps[] => [
+        {
+            key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
+            onClick: () => refreshData()
+        },
+    ], [refreshData]);
 
     return <HolyGrail
+        header={<CommandBar items={buttons} farItems={copyButtons} />}
         main={
             <Grid />
         }

+ 55 - 215
esp/src/src-react/components/FileDetails.tsx

@@ -1,28 +1,20 @@
 import * as React from "react";
-import { CommandBar, ContextualMenuItemType, ICommandBarItemProps, Pivot, PivotItem, ScrollablePane, ScrollbarVisibility, Sticky, StickyPositionType } from "@fluentui/react";
+import { Pivot, PivotItem } from "@fluentui/react";
 import { SizeMe } from "react-sizeme";
 import nlsHPCC from "src/nlsHPCC";
 import { FileParts } from "./FileParts";
-import * as WsDfu from "src/WsDfu";
-import * as Utility from "src/Utility";
-import { getStateImageName, IFile } from "src/ESPLogicalFile";
 import { useFile, useDefFile } from "../hooks/file";
 import { pivotItemStyle } from "../layouts/pivot";
 import { DojoAdapter } from "../layouts/DojoAdapter";
-import { pushUrl } from "../util/history";
+import { pushUrl, replaceUrl } from "../util/history";
 import { FileBlooms } from "./FileBlooms";
 import { FileHistory } from "./FileHistory";
 import { ProtectedBy } from "./ProtectedBy";
 import { SuperFiles } from "./SuperFiles";
 import { ECLSourceEditor, XMLSourceEditor } from "./SourceEditor";
-import { ShortVerticalDivider } from "./Common";
 import { FileDetailsGraph } from "./FileDetailsGraph";
-import { TableGroup } from "./forms/Groups";
-import { CopyFile } from "./forms/CopyFile";
+import { FileSummary } from "./FileSummary";
 import { DataPatterns } from "./DataPatterns";
-import { DesprayFile } from "./forms/DesprayFile";
-import { RenameFile } from "./forms/RenameFile";
-import { ReplicateFile } from "./forms/ReplicateFile";
 import { Result } from "./Result";
 import { Queries } from "./Queries";
 import { WorkunitDetails } from "./WorkunitDetails";
@@ -41,213 +33,61 @@ export const FileDetails: React.FunctionComponent<FileDetailsProps> = ({
     tab = "summary"
 }) => {
 
-    const [file, , refresh] = useFile(cluster, logicalFile);
+    const [file] = useFile(cluster, logicalFile);
+    React.useEffect(() => {
+        if (file?.NodeGroup && cluster === undefined) {
+            replaceUrl(`/files/${file.NodeGroup}/${logicalFile}`);
+        }
+    }, [cluster, file?.NodeGroup, logicalFile]);
     const [defFile] = useDefFile(cluster, logicalFile, "def");
     const [xmlFile] = useDefFile(cluster, logicalFile, "xml");
-    const [description, setDescription] = React.useState("");
-    const [_protected, setProtected] = React.useState(false);
-    const [restricted, setRestricted] = React.useState(false);
-    const [canReplicateFlag, setCanReplicateFlag] = React.useState(false);
-    const [replicateFlag, setReplicateFlag] = React.useState(false);
-    const [showCopyFile, setShowCopyFile] = React.useState(false);
-    const [showRenameFile, setShowRenameFile] = React.useState(false);
-    const [showDesprayFile, setShowDesprayFile] = React.useState(false);
-    const [showReplicateFile, setShowReplicateFile] = React.useState(false);
 
     const isDFUWorkunit = file?.Wuid?.length && file?.Wuid[0] === "D";
-    const isProtected = file?.ProtectList?.DFUFileProtect?.length > 0 || false;
-
-    React.useEffect(() => {
-        setDescription(description || file?.Description);
-        setProtected(_protected || isProtected);
-        setRestricted(restricted || file?.IsRestricted);
-
-        if (file?.DFUFilePartsOnClusters?.DFUFilePartsOnCluster?.length > 0) {
-            let _canReplicate = false;
-            let _replicate = false;
-            file?.DFUFilePartsOnClusters?.DFUFilePartsOnCluster.forEach(part => {
-                _canReplicate = _canReplicate && part.CanReplicate;
-                _replicate = _replicate && part.Replicate;
-            });
-            setCanReplicateFlag(_canReplicate);
-            setReplicateFlag(_replicate);
-        }
-
-    }, [_protected, description, file?.DFUFilePartsOnClusters?.DFUFilePartsOnCluster, file?.Description, file?.IsRestricted, isProtected, restricted]);
-
-    const canSave = file && (
-        description !== file.Description ||
-        _protected !== isProtected ||
-        restricted !== file?.IsRestricted
-    );
-
-    const buttons = React.useMemo((): ICommandBarItemProps[] => [
-        {
-            key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
-            onClick: () => {
-                refresh();
-            }
-        },
-        {
-            key: "copyFilename", text: nlsHPCC.CopyLogicalFilename, iconProps: { iconName: "Copy" },
-            onClick: () => {
-                navigator?.clipboard?.writeText(logicalFile);
-            }
-        },
-        { key: "divider_1", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
-        {
-            key: "save", text: nlsHPCC.Save, iconProps: { iconName: "Save" }, disabled: !canSave,
-            onClick: () => {
-                file?.update({
-                    UpdateDescription: true,
-                    FileDesc: description,
-                    Protect: _protected ? "1" : "2",
-                    Restrict: restricted ? "1" : "2",
-                });
-            }
-        },
-        {
-            key: "delete", text: nlsHPCC.Delete, iconProps: { iconName: "Delete" }, disabled: !file,
-            onClick: () => {
-                if (confirm(nlsHPCC.YouAreAboutToDeleteThisFile)) {
-                    WsDfu.DFUArrayAction([file], "Delete").then(response => {
-                        const actionInfo = response?.DFUArrayActionResponse?.ActionResults?.DFUActionInfo;
-                        if (actionInfo && actionInfo.length && !actionInfo[0].Failed) {
-                            window.history.back();
-                        }
-                    });
-                }
-            }
-        },
-        { key: "divider_2", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
-        {
-            key: "copyFile", text: nlsHPCC.Copy, disabled: !file,
-            onClick: () => setShowCopyFile(true)
-        },
-        {
-            key: "rename", text: nlsHPCC.Rename, disabled: !file,
-            onClick: () => setShowRenameFile(true)
-        },
-        {
-            key: "despray", text: nlsHPCC.Despray, disabled: !file,
-            onClick: () => setShowDesprayFile(true)
-        },
-        { key: "divider_3", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
-        {
-            key: "replicate", text: nlsHPCC.Replicate, disabled: !canReplicateFlag || !replicateFlag,
-            onClick: () => setShowReplicateFile(true)
-        },
-    ], [_protected, canReplicateFlag, canSave, description, file, logicalFile, refresh, replicateFlag, restricted]);
-
-    const protectedImage = _protected ? Utility.getImageURL("locked.png") : Utility.getImageURL("unlocked.png");
-    const stateImage = Utility.getImageURL(getStateImageName(file as unknown as IFile));
-    const compressedImage = file?.IsCompressed ? Utility.getImageURL("compressed.png") : "";
 
-    return <>
-        <SizeMe monitorHeight>{({ size }) =>
-            <Pivot overflowBehavior="menu" style={{ height: "100%" }} selectedKey={tab} onLinkClick={evt => pushUrl(`/files/${cluster}/${logicalFile}/${evt.props.itemKey}`)}>
-                <PivotItem headerText={nlsHPCC.Summary} itemKey="summary" style={pivotItemStyle(size)}>
-                    <ScrollablePane scrollbarVisibility={ScrollbarVisibility.auto}>
-                        <Sticky stickyPosition={StickyPositionType.Header}>
-                            <CommandBar items={buttons} />
-                        </Sticky>
-                        <Sticky stickyPosition={StickyPositionType.Header}>
-                            <div style={{ display: "inline-block" }}>
-                                <h2>
-                                    <img src={compressedImage} />&nbsp;
-                                    <img src={protectedImage} />&nbsp;
-                                    <img src={stateImage} />&nbsp;
-                                    {file?.Name}
-                                </h2>
-                            </div>
-                        </Sticky>
-                        <TableGroup fields={{
-                            "Wuid": { label: nlsHPCC.Workunit, type: "link", value: file?.Wuid, href: `#/${isDFUWorkunit ? "dfu" : ""}workunits/${file?.Wuid}`, readonly: true, },
-                            "Owner": { label: nlsHPCC.Owner, type: "string", value: file?.Owner, readonly: true },
-                            "SuperOwner": { label: nlsHPCC.SuperFile, type: "links", links: file?.Superfiles?.DFULogicalFile?.map(row => ({ label: "", type: "link", value: row.Name, href: `#/superfiles/${row.Name}` })) },
-                            "NodeGroup": { label: nlsHPCC.ClusterName, type: "string", value: file?.NodeGroup, readonly: true },
-                            "Description": { label: nlsHPCC.Description, type: "string", value: description },
-                            "JobName": { label: nlsHPCC.JobName, type: "string", value: file?.JobName, readonly: true },
-                            "isProtected": { label: nlsHPCC.Protected, type: "checkbox", value: _protected },
-                            "isRestricted": { label: nlsHPCC.Restricted, type: "checkbox", value: restricted },
-                            "ContentType": { label: nlsHPCC.ContentType, type: "string", value: file?.ContentType, readonly: true },
-                            "KeyType": { label: nlsHPCC.KeyType, type: "string", value: file?.KeyType, readonly: true },
-                            "Filesize": { label: nlsHPCC.FileSize, type: "string", value: file?.Filesize, readonly: true },
-                            "Format": { label: nlsHPCC.Format, type: "string", value: file?.Format, readonly: true },
-                            "IsCompressed": { label: nlsHPCC.IsCompressed, type: "checkbox", value: file?.IsCompressed, readonly: true },
-                            "CompressedFileSizeString": { label: nlsHPCC.CompressedFileSize, type: "string", value: file?.CompressedFileSize ? file?.CompressedFileSize.toString() : "", readonly: true },
-                            "PercentCompressed": { label: nlsHPCC.PercentCompressed, type: "string", value: file?.PercentCompressed, readonly: true },
-                            "Modified": { label: nlsHPCC.Modified, type: "string", value: file?.Modified, readonly: true },
-                            "ExpireDays": { label: nlsHPCC.ExpireDays, type: "string", value: file?.ExpireDays ? file?.ExpireDays.toString() : "", readonly: true },
-                            "Directory": { label: nlsHPCC.Directory, type: "string", value: file?.Dir, readonly: true },
-                            "PathMask": { label: nlsHPCC.PathMask, type: "string", value: file?.PathMask, readonly: true },
-                            "RecordSize": { label: nlsHPCC.RecordSize, type: "string", value: file?.RecordSize, readonly: true },
-                            "RecordCount": { label: nlsHPCC.RecordCount, type: "string", value: file?.RecordCount, readonly: true },
-                            "IsReplicated": { label: nlsHPCC.IsReplicated, type: "checkbox", value: file?.DFUFilePartsOnClusters?.DFUFilePartsOnCluster?.length > 0, readonly: true },
-                            "NumParts": { label: nlsHPCC.FileParts, type: "number", value: file?.NumParts, readonly: true },
-                            "MinSkew": { label: nlsHPCC.MinSkew, type: "string", value: file?.Stat?.MinSkew, readonly: true },
-                            "MaxSkew": { label: nlsHPCC.MaxSkew, type: "string", value: file?.Stat?.MaxSkew, readonly: true },
-                            "MinSkewPart": { label: nlsHPCC.MinSkewPart, type: "string", value: file?.Stat?.MinSkewPart === undefined ? "" : file?.Stat?.MinSkewPart?.toString(), readonly: true },
-                            "MaxSkewPart": { label: nlsHPCC.MaxSkewPart, type: "string", value: file?.Stat?.MaxSkewPart === undefined ? "" : file?.Stat?.MaxSkewPart?.toString(), readonly: true },
-                        }} onChange={(id, value) => {
-                            switch (id) {
-                                case "Description":
-                                    setDescription(value);
-                                    break;
-                                case "isProtected":
-                                    setProtected(value);
-                                    break;
-                                case "isRestricted":
-                                    setRestricted(value);
-                                    break;
-                            }
-                        }} />
-                    </ScrollablePane>
-                </PivotItem>
-                <PivotItem headerText={nlsHPCC.Contents} itemKey="Contents" style={pivotItemStyle(size, 0)}>
-                    <Result cluster={cluster} logicalFile={logicalFile} />
-                </PivotItem>
-                <PivotItem headerText={nlsHPCC.DataPatterns} itemKey="DataPatterns" style={pivotItemStyle(size, 0)}>
-                    <DataPatterns cluster={cluster} logicalFile={logicalFile} />
-                </PivotItem>
-                <PivotItem headerText={nlsHPCC.ECL} itemKey="ECL" style={pivotItemStyle(size, 0)}>
-                    <ECLSourceEditor text={file?.Ecl} readonly={true} />
-                </PivotItem>
-                <PivotItem headerText={nlsHPCC.DEF} itemKey="DEF" style={pivotItemStyle(size, 0)}>
-                    <XMLSourceEditor text={defFile} readonly={true} />
-                </PivotItem>
-                <PivotItem headerText={nlsHPCC.XML} itemKey="XML" style={pivotItemStyle(size, 0)}>
-                    <XMLSourceEditor text={xmlFile} readonly={true} />
-                </PivotItem>
-                <PivotItem headerText={nlsHPCC.Superfiles} itemKey="superfiles" itemCount={file?.Superfiles?.DFULogicalFile.length || 0} style={pivotItemStyle(size, 0)}>
-                    <SuperFiles cluster={cluster} logicalFile={logicalFile} />
-                </PivotItem>
-                <PivotItem headerText={nlsHPCC.FileParts} itemKey="FileParts" style={pivotItemStyle(size, 0)}>
-                    <FileParts cluster={cluster} logicalFile={logicalFile} />
-                </PivotItem>
-                <PivotItem headerText={nlsHPCC.Queries} itemKey="queries" style={pivotItemStyle(size, 0)}>
-                    <Queries filter={{ FileName: logicalFile }} />
-                </PivotItem>
-                <PivotItem headerText={nlsHPCC.Graphs} itemKey="Graphs" itemCount={file?.Graphs?.ECLGraph?.length} headerButtonProps={{ disabled: isDFUWorkunit }} style={pivotItemStyle(size, 0)}>
-                    <FileDetailsGraph cluster={cluster} logicalFile={logicalFile} />
-                </PivotItem>
-                <PivotItem headerText={nlsHPCC.Workunit} itemKey="Workunit" style={pivotItemStyle(size, 0)}>
-                    {isDFUWorkunit ? <DojoAdapter widgetClassID="DFUWUDetailsWidget" params={{ Wuid: file?.Wuid }} /> : <WorkunitDetails wuid={file?.Wuid} />}
-                </PivotItem>
-                <PivotItem headerText={nlsHPCC.History} itemKey="History" style={pivotItemStyle(size, 0)}>
-                    <FileHistory cluster={cluster} logicalFile={logicalFile} />
-                </PivotItem>
-                <PivotItem headerText={nlsHPCC.Blooms} itemKey="Blooms" itemCount={file?.Blooms?.DFUFileBloom?.length} style={pivotItemStyle(size, 0)}>
-                    <FileBlooms cluster={cluster} logicalFile={logicalFile} />
-                </PivotItem>
-                <PivotItem headerText={nlsHPCC.ProtectBy} itemKey="ProtectBy" style={pivotItemStyle(size, 0)}>
-                    <ProtectedBy cluster={cluster} logicalFile={logicalFile} />
-                </PivotItem>
-            </Pivot>
-        }</SizeMe>
-        <CopyFile cluster={cluster} logicalFile={logicalFile} showForm={showCopyFile} setShowForm={setShowCopyFile} />
-        <DesprayFile cluster={cluster} logicalFile={logicalFile} showForm={showDesprayFile} setShowForm={setShowDesprayFile} />
-        <RenameFile cluster={cluster} logicalFile={logicalFile} showForm={showRenameFile} setShowForm={setShowRenameFile} />
-        <ReplicateFile cluster={cluster} logicalFile={logicalFile} showForm={showReplicateFile} setShowForm={setShowReplicateFile} />
-    </>;
+    return <SizeMe monitorHeight>{({ size }) =>
+        <Pivot overflowBehavior="menu" style={{ height: "100%" }} selectedKey={tab} onLinkClick={evt => pushUrl(`/files/${cluster}/${logicalFile}/${evt.props.itemKey}`)}>
+            <PivotItem headerText={nlsHPCC.Summary} itemKey="summary" style={pivotItemStyle(size)}>
+                <FileSummary cluster={cluster} logicalFile={logicalFile} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Contents} itemKey="Contents" style={pivotItemStyle(size, 0)}>
+                <Result cluster={cluster} logicalFile={logicalFile} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.DataPatterns} itemKey="DataPatterns" style={pivotItemStyle(size, 0)}>
+                <DataPatterns cluster={cluster} logicalFile={logicalFile} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.ECL} itemKey="ECL" style={pivotItemStyle(size, 0)}>
+                <ECLSourceEditor text={file?.Ecl} readonly={true} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.DEF} itemKey="DEF" style={pivotItemStyle(size, 0)}>
+                <XMLSourceEditor text={defFile} readonly={true} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.XML} itemKey="XML" style={pivotItemStyle(size, 0)}>
+                <XMLSourceEditor text={xmlFile} readonly={true} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Superfiles} itemKey="superfiles" itemCount={file?.Superfiles?.DFULogicalFile.length || 0} style={pivotItemStyle(size, 0)}>
+                <SuperFiles cluster={cluster} logicalFile={logicalFile} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.FileParts} itemKey="FileParts" style={pivotItemStyle(size, 0)}>
+                <FileParts cluster={cluster} logicalFile={logicalFile} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Queries} itemKey="queries" style={pivotItemStyle(size, 0)}>
+                <Queries filter={{ FileName: logicalFile }} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Graphs} itemKey="Graphs" itemCount={file?.Graphs?.ECLGraph?.length} headerButtonProps={{ disabled: isDFUWorkunit }} style={pivotItemStyle(size, 0)}>
+                <FileDetailsGraph cluster={cluster} logicalFile={logicalFile} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Workunit} itemKey="Workunit" style={pivotItemStyle(size, 0)}>
+                {isDFUWorkunit ? <DojoAdapter widgetClassID="DFUWUDetailsWidget" params={{ Wuid: file?.Wuid }} /> : <WorkunitDetails wuid={file?.Wuid} />}
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.History} itemKey="History" style={pivotItemStyle(size, 0)}>
+                <FileHistory cluster={cluster} logicalFile={logicalFile} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Blooms} itemKey="Blooms" itemCount={file?.Blooms?.DFUFileBloom?.length} style={pivotItemStyle(size, 0)}>
+                <FileBlooms cluster={cluster} logicalFile={logicalFile} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.ProtectBy} itemKey="ProtectBy" style={pivotItemStyle(size, 0)}>
+                <ProtectedBy cluster={cluster} logicalFile={logicalFile} />
+            </PivotItem>
+        </Pivot>
+    }</SizeMe>;
 };

+ 16 - 19
esp/src/src-react/components/FileDetailsGraph.tsx

@@ -1,8 +1,7 @@
 import * as React from "react";
 import { CommandBar, ContextualMenuItemType, ICommandBarItemProps } from "@fluentui/react";
 import { useConst } from "@fluentui/react-hooks";
-import * as Observable from "dojo/store/Observable";
-import { Memory } from "src/Memory";
+import { Memory, Observable } from "src/Memory";
 import * as Utility from "src/Utility";
 import nlsHPCC from "src/nlsHPCC";
 import { useGrid } from "../hooks/grid";
@@ -36,7 +35,7 @@ export const FileDetailsGraph: React.FunctionComponent<FileDetailsGraphProps> =
     logicalFile
 }) => {
 
-    const [file, , _refresh] = useFile(cluster, logicalFile);
+    const [file, , , refreshData] = useFile(cluster, logicalFile);
     const [uiState, setUIState] = React.useState({ ...defaultUIState });
 
     //  Grid ---
@@ -62,7 +61,7 @@ export const FileDetailsGraph: React.FunctionComponent<FileDetailsGraphProps> =
     const buttons = React.useMemo((): ICommandBarItemProps[] => [
         {
             key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
-            onClick: () => refreshTable()
+            onClick: () => refreshData()
         },
         { key: "divider_1", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
         {
@@ -77,7 +76,7 @@ export const FileDetailsGraph: React.FunctionComponent<FileDetailsGraphProps> =
                 }
             }
         }
-    ], [file?.Wuid, refreshTable, selection, uiState.hasSelection]);
+    ], [file?.Wuid, refreshData, selection, uiState.hasSelection]);
 
     //  Selection  ---
     React.useEffect(() => {
@@ -87,22 +86,20 @@ export const FileDetailsGraph: React.FunctionComponent<FileDetailsGraphProps> =
     }, [selection]);
 
     React.useEffect(() => {
-        if (file?.Graphs?.ECLGraph) {
-            store.setData(file?.Graphs?.ECLGraph.map(item => {
-                return {
-                    Name: item,
-                    Label: "",
-                    Completed: "",
-                    Time: 0,
-                    Type: ""
-                };
-            }));
-            refreshTable();
-        }
-    }, [file?.Graphs?.ECLGraph, refreshTable, store]);
+        store.setData((file?.Graphs?.ECLGraph || []).map(item => {
+            return {
+                Name: item,
+                Label: "",
+                Completed: "",
+                Time: 0,
+                Type: ""
+            };
+        }));
+        refreshTable();
+    }, [store, file?.Graphs?.ECLGraph, refreshTable]);
 
     return <HolyGrail
-        header={<CommandBar items={buttons} overflowButtonProps={{}} farItems={copyButtons} />}
+        header={<CommandBar items={buttons} farItems={copyButtons} />}
         main={
             <Grid />
         }

+ 24 - 63
esp/src/src-react/components/FileHistory.tsx

@@ -1,18 +1,13 @@
 import * as React from "react";
 import { CommandBar, ContextualMenuItemType, ICommandBarItemProps } from "@fluentui/react";
 import { useConst } from "@fluentui/react-hooks";
-import { scopedLogger } from "@hpcc-js/util";
-import * as Observable from "dojo/store/Observable";
-import { Memory } from "src/Memory";
+import { Memory, Observable } from "src/Memory";
 import nlsHPCC from "src/nlsHPCC";
+import { useFileHistory } from "../hooks/file";
 import { useGrid } from "../hooks/grid";
-import { useFile } from "../hooks/file";
 import { HolyGrail } from "../layouts/HolyGrail";
-import * as WsDfu from "../../src/WsDfu";
 import { ShortVerticalDivider } from "./Common";
 
-const logger = scopedLogger("../components/FileHistory.tsx");
-
 interface FileHistoryProps {
     cluster: string;
     logicalFile: string;
@@ -23,38 +18,9 @@ export const FileHistory: React.FunctionComponent<FileHistoryProps> = ({
     logicalFile
 }) => {
 
-    const [file, , _refresh] = useFile(cluster, logicalFile);
-
-    //  Command Bar  ---
-    const buttons: ICommandBarItemProps[] = [
-        {
-            key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
-            onClick: () => refreshTable()
-        },
-        { key: "divider_1", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
-        {
-            key: "erase", text: nlsHPCC.EraseHistory,
-            onClick: () => {
-                if (confirm(nlsHPCC.EraseHistoryQ + "\n" + file?.Name + "?")) {
-                    WsDfu.EraseHistory({
-                        request: {
-                            Name: file?.Name
-                        }
-                    })
-                        .then(response => {
-                            if (response) {
-                                store.setData([]);
-                                refreshTable();
-                            }
-                        })
-                        .catch(logger.error)
-                        ;
-                }
-            }
-        },
-    ];
-
     //  Grid ---
+    const [history, eraseHistory, refreshData] = useFileHistory(cluster, logicalFile);
+
     const store = useConst(new Observable(new Memory("Name")));
     const [Grid, _selection, refreshTable, copyButtons] = useGrid({
         store,
@@ -72,36 +38,31 @@ export const FileHistory: React.FunctionComponent<FileHistoryProps> = ({
     });
 
     React.useEffect(() => {
-        WsDfu.ListHistory({
-            request: {
-                Name: file?.Name
-            }
-        }).then(response => {
-            const results = response?.ListHistoryResponse?.History?.Origin;
+        store.setData(history);
+        refreshTable();
+    }, [history, refreshTable, store]);
 
-            if (results) {
-                store.setData(results.map(row => {
-                    return {
-                        Name: row.Name,
-                        IP: row.IP,
-                        Operation: row.Operation,
-                        Owner: row.Owner,
-                        Path: row.Path,
-                        Timestamp: row.Timestamp,
-                        Workunit: row.Workunit
-                    };
-                }));
-                refreshTable();
+    //  Command Bar  ---
+    const buttons: ICommandBarItemProps[] = React.useMemo(() => [
+        {
+            key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
+            onClick: () => refreshData()
+        },
+        { key: "divider_1", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+        {
+            key: "erase", text: nlsHPCC.EraseHistory, disabled: history?.length === 0,
+            onClick: () => {
+                if (confirm(nlsHPCC.EraseHistoryQ + "\n" + logicalFile + "?")) {
+                    eraseHistory();
+                }
             }
-        }).catch(logger.error)
-            ;
-        // eslint-disable-next-line react-hooks/exhaustive-deps
-    }, [store, file?.Name]);
+        },
+    ], [eraseHistory, history?.length, logicalFile, refreshData]);
 
     return <HolyGrail
         header={<CommandBar items={buttons} farItems={copyButtons} />}
         main={
-            < Grid />
+            <Grid />
         }
     />;
-};
+};

+ 12 - 2
esp/src/src-react/components/FileParts.tsx

@@ -1,4 +1,5 @@
 import * as React from "react";
+import { ICommandBarItemProps, CommandBar } from "@fluentui/react";
 import { useConst } from "@fluentui/react-hooks";
 import { format as d3Format } from "@hpcc-js/common";
 import * as Observable from "dojo/store/Observable";
@@ -20,11 +21,11 @@ export const FileParts: React.FunctionComponent<FilePartsProps> = ({
     logicalFile
 }) => {
 
-    const [file, , _refresh] = useFile(cluster, logicalFile);
+    const [file, , , refreshData] = useFile(cluster, logicalFile);
 
     //  Grid ---
     const store = useConst(new Observable(new Memory("Id")));
-    const [Grid, _selection, refreshTable, _copyButtons] = useGrid({
+    const [Grid, _selection, refreshTable, copyButtons] = useGrid({
         store,
         sort: [{ attribute: "Id", "descending": false }],
         filename: "fileParts",
@@ -57,7 +58,16 @@ export const FileParts: React.FunctionComponent<FilePartsProps> = ({
         }
     }, [cluster, file?.DFUFilePartsOnClusters, store, refreshTable]);
 
+    //  Command Bar  ---
+    const buttons = React.useMemo((): ICommandBarItemProps[] => [
+        {
+            key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
+            onClick: () => refreshData()
+        }
+    ], [refreshData]);
+
     return <HolyGrail
+        header={<CommandBar items={buttons} farItems={copyButtons} />}
         main={
             <Grid />
         }

+ 194 - 0
esp/src/src-react/components/FileSummary.tsx

@@ -0,0 +1,194 @@
+import * as React from "react";
+import { CommandBar, ContextualMenuItemType, ICommandBarItemProps, ScrollablePane, ScrollbarVisibility, Sticky, StickyPositionType } from "@fluentui/react";
+import nlsHPCC from "src/nlsHPCC";
+import * as WsDfu from "src/WsDfu";
+import * as Utility from "src/Utility";
+import { getStateImageName, IFile } from "src/ESPLogicalFile";
+import { useFile } from "../hooks/file";
+import { ShortVerticalDivider } from "./Common";
+import { TableGroup } from "./forms/Groups";
+import { CopyFile } from "./forms/CopyFile";
+import { DesprayFile } from "./forms/DesprayFile";
+import { RenameFile } from "./forms/RenameFile";
+import { ReplicateFile } from "./forms/ReplicateFile";
+
+import "react-reflex/styles.css";
+
+interface FileSummaryProps {
+    cluster?: string;
+    logicalFile: string;
+    tab?: string;
+}
+
+export const FileSummary: React.FunctionComponent<FileSummaryProps> = ({
+    cluster,
+    logicalFile,
+    tab = "summary"
+}) => {
+
+    const [file, isProtected, , refresh] = useFile(cluster, logicalFile);
+    const [description, setDescription] = React.useState("");
+    const [_protected, setProtected] = React.useState(false);
+    const [restricted, setRestricted] = React.useState(false);
+    const [canReplicateFlag, setCanReplicateFlag] = React.useState(false);
+    const [replicateFlag, setReplicateFlag] = React.useState(false);
+    const [showCopyFile, setShowCopyFile] = React.useState(false);
+    const [showRenameFile, setShowRenameFile] = React.useState(false);
+    const [showDesprayFile, setShowDesprayFile] = React.useState(false);
+    const [showReplicateFile, setShowReplicateFile] = React.useState(false);
+
+    const isDFUWorkunit = React.useMemo(() => {
+        return file?.Wuid?.length && file?.Wuid[0] === "D";
+    }, [file?.Wuid]);
+
+    React.useEffect(() => {
+        setDescription(file?.Description || "");
+        setProtected(file?.ProtectList?.DFUFileProtect?.length > 0 || false);
+        setRestricted(file?.IsRestricted || false);
+
+        if (file?.DFUFilePartsOnClusters?.DFUFilePartsOnCluster?.length > 0) {
+            let _canReplicate = false;
+            let _replicate = false;
+            file?.DFUFilePartsOnClusters?.DFUFilePartsOnCluster.forEach(part => {
+                _canReplicate = _canReplicate && part.CanReplicate;
+                _replicate = _replicate && part.Replicate;
+            });
+            setCanReplicateFlag(_canReplicate);
+            setReplicateFlag(_replicate);
+        }
+
+    }, [file?.DFUFilePartsOnClusters?.DFUFilePartsOnCluster, file?.Description, file?.IsRestricted, file?.ProtectList?.DFUFileProtect?.length]);
+
+    const canSave = React.useMemo(() => {
+        return file && (
+            description !== file?.Description ||
+            _protected !== isProtected ||
+            restricted !== file?.IsRestricted
+        );
+    }, [_protected, description, file, isProtected, restricted]);
+
+    const buttons = React.useMemo((): ICommandBarItemProps[] => [
+        {
+            key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
+            onClick: () => {
+                refresh();
+            }
+        },
+        {
+            key: "copyFilename", text: nlsHPCC.CopyLogicalFilename, iconProps: { iconName: "Copy" },
+            onClick: () => {
+                navigator?.clipboard?.writeText(logicalFile);
+            }
+        },
+        { key: "divider_1", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+        {
+            key: "save", text: nlsHPCC.Save, iconProps: { iconName: "Save" }, disabled: !canSave,
+            onClick: () => {
+                file?.update({
+                    UpdateDescription: true,
+                    FileDesc: description,
+                    Protect: _protected ? "1" : "2",
+                    Restrict: restricted ? "1" : "2",
+                });
+            }
+        },
+        {
+            key: "delete", text: nlsHPCC.Delete, iconProps: { iconName: "Delete" }, disabled: !file,
+            onClick: () => {
+                if (confirm(nlsHPCC.YouAreAboutToDeleteThisFile)) {
+                    WsDfu.DFUArrayAction([file], "Delete").then(response => {
+                        const actionInfo = response?.DFUArrayActionResponse?.ActionResults?.DFUActionInfo;
+                        if (actionInfo && actionInfo.length && !actionInfo[0].Failed) {
+                            window.history.back();
+                        }
+                    });
+                }
+            }
+        },
+        { key: "divider_2", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+        {
+            key: "copyFile", text: nlsHPCC.Copy, disabled: !file,
+            onClick: () => setShowCopyFile(true)
+        },
+        {
+            key: "rename", text: nlsHPCC.Rename, disabled: !file,
+            onClick: () => setShowRenameFile(true)
+        },
+        {
+            key: "despray", text: nlsHPCC.Despray, disabled: !file,
+            onClick: () => setShowDesprayFile(true)
+        },
+        { key: "divider_3", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+        {
+            key: "replicate", text: nlsHPCC.Replicate, disabled: !canReplicateFlag || !replicateFlag,
+            onClick: () => setShowReplicateFile(true)
+        },
+    ], [_protected, canReplicateFlag, canSave, description, file, logicalFile, refresh, replicateFlag, restricted]);
+
+    const protectedImage = _protected ? Utility.getImageURL("locked.png") : Utility.getImageURL("unlocked.png");
+    const stateImage = Utility.getImageURL(getStateImageName(file as unknown as IFile));
+    const compressedImage = file?.IsCompressed ? Utility.getImageURL("compressed.png") : "";
+
+    return <>
+        <ScrollablePane scrollbarVisibility={ScrollbarVisibility.auto}>
+            <Sticky stickyPosition={StickyPositionType.Header}>
+                <CommandBar items={buttons} />
+            </Sticky>
+            <Sticky stickyPosition={StickyPositionType.Header}>
+                <div style={{ display: "inline-block" }}>
+                    <h2>
+                        <img src={compressedImage} />&nbsp;
+                        <img src={protectedImage} />&nbsp;
+                        <img src={stateImage} />&nbsp;
+                        {file?.Name}
+                    </h2>
+                </div>
+            </Sticky>
+            <TableGroup fields={{
+                "Wuid": { label: nlsHPCC.Workunit, type: "link", value: file?.Wuid, href: `#/${isDFUWorkunit ? "dfu" : ""}workunits/${file?.Wuid}`, readonly: true, },
+                "Owner": { label: nlsHPCC.Owner, type: "string", value: file?.Owner, readonly: true },
+                "SuperOwner": { label: nlsHPCC.SuperFile, type: "links", links: file?.Superfiles?.DFULogicalFile?.map(row => ({ label: "", type: "link", value: row.Name, href: `#/superfiles/${row.Name}` })) },
+                "NodeGroup": { label: nlsHPCC.ClusterName, type: "string", value: file?.NodeGroup, readonly: true },
+                "Description": { label: nlsHPCC.Description, type: "string", value: description },
+                "JobName": { label: nlsHPCC.JobName, type: "string", value: file?.JobName, readonly: true },
+                "isProtected": { label: nlsHPCC.Protected, type: "checkbox", value: _protected },
+                "isRestricted": { label: nlsHPCC.Restricted, type: "checkbox", value: restricted },
+                "ContentType": { label: nlsHPCC.ContentType, type: "string", value: file?.ContentType, readonly: true },
+                "KeyType": { label: nlsHPCC.KeyType, type: "string", value: file?.KeyType, readonly: true },
+                "Filesize": { label: nlsHPCC.FileSize, type: "string", value: file?.Filesize, readonly: true },
+                "Format": { label: nlsHPCC.Format, type: "string", value: file?.Format, readonly: true },
+                "IsCompressed": { label: nlsHPCC.IsCompressed, type: "checkbox", value: file?.IsCompressed, readonly: true },
+                "CompressedFileSizeString": { label: nlsHPCC.CompressedFileSize, type: "string", value: file?.CompressedFileSize ? file?.CompressedFileSize.toString() : "", readonly: true },
+                "PercentCompressed": { label: nlsHPCC.PercentCompressed, type: "string", value: file?.PercentCompressed, readonly: true },
+                "Modified": { label: nlsHPCC.Modified, type: "string", value: file?.Modified, readonly: true },
+                "ExpireDays": { label: nlsHPCC.ExpireDays, type: "string", value: file?.ExpireDays ? file?.ExpireDays.toString() : "", readonly: true },
+                "Directory": { label: nlsHPCC.Directory, type: "string", value: file?.Dir, readonly: true },
+                "PathMask": { label: nlsHPCC.PathMask, type: "string", value: file?.PathMask, readonly: true },
+                "RecordSize": { label: nlsHPCC.RecordSize, type: "string", value: file?.RecordSize, readonly: true },
+                "RecordCount": { label: nlsHPCC.RecordCount, type: "string", value: file?.RecordCount, readonly: true },
+                "IsReplicated": { label: nlsHPCC.IsReplicated, type: "checkbox", value: file?.DFUFilePartsOnClusters?.DFUFilePartsOnCluster?.length > 0, readonly: true },
+                "NumParts": { label: nlsHPCC.FileParts, type: "number", value: file?.NumParts, readonly: true },
+                "MinSkew": { label: nlsHPCC.MinSkew, type: "string", value: file?.Stat?.MinSkew, readonly: true },
+                "MaxSkew": { label: nlsHPCC.MaxSkew, type: "string", value: file?.Stat?.MaxSkew, readonly: true },
+                "MinSkewPart": { label: nlsHPCC.MinSkewPart, type: "string", value: file?.Stat?.MinSkewPart === undefined ? "" : file?.Stat?.MinSkewPart?.toString(), readonly: true },
+                "MaxSkewPart": { label: nlsHPCC.MaxSkewPart, type: "string", value: file?.Stat?.MaxSkewPart === undefined ? "" : file?.Stat?.MaxSkewPart?.toString(), readonly: true },
+            }} onChange={(id, value) => {
+                switch (id) {
+                    case "Description":
+                        setDescription(value);
+                        break;
+                    case "isProtected":
+                        setProtected(value);
+                        break;
+                    case "isRestricted":
+                        setRestricted(value);
+                        break;
+                }
+            }} />
+        </ScrollablePane>
+        <CopyFile cluster={cluster} logicalFile={logicalFile} showForm={showCopyFile} setShowForm={setShowCopyFile} />
+        <DesprayFile cluster={cluster} logicalFile={logicalFile} showForm={showDesprayFile} setShowForm={setShowDesprayFile} />
+        <RenameFile cluster={cluster} logicalFile={logicalFile} showForm={showRenameFile} setShowForm={setShowRenameFile} />
+        <ReplicateFile cluster={cluster} logicalFile={logicalFile} showForm={showReplicateFile} setShowForm={setShowReplicateFile} />
+    </>;
+};

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

@@ -214,7 +214,7 @@ export const Files: React.FunctionComponent<FilesProps> = ({
     }, [selection]);
 
     return <HolyGrail
-        header={<CommandBar items={buttons} overflowButtonProps={{}} farItems={copyButtons} />}
+        header={<CommandBar items={buttons} farItems={copyButtons} />}
         main={
             <>
                 <Grid />

+ 4 - 4
esp/src/src-react/components/Helpers.tsx

@@ -102,7 +102,7 @@ export const Helpers: React.FunctionComponent<HelpersProps> = ({
 }) => {
 
     const [uiState, setUIState] = React.useState({ ...defaultUIState });
-    const [helpers] = useWorkunitHelpers(wuid);
+    const [helpers, refreshData] = useWorkunitHelpers(wuid);
 
     //  Grid ---
     const store = useConst(new Observable(new Memory("id")));
@@ -143,7 +143,7 @@ export const Helpers: React.FunctionComponent<HelpersProps> = ({
     const buttons = React.useMemo((): ICommandBarItemProps[] => [
         {
             key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
-            onClick: () => refreshTable()
+            onClick: () => refreshData()
         },
         { key: "divider_1", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
         {
@@ -190,7 +190,7 @@ export const Helpers: React.FunctionComponent<HelpersProps> = ({
             }
         }
 
-    ], [refreshTable, selection, uiState.canShowContent, uiState.hasSelection]);
+    ], [refreshData, selection, uiState.canShowContent, uiState.hasSelection]);
 
     //  Selection  ---
     React.useEffect(() => {
@@ -211,7 +211,7 @@ export const Helpers: React.FunctionComponent<HelpersProps> = ({
     }, [store, helpers, refreshTable]);
 
     return <HolyGrail
-        header={<CommandBar items={buttons} overflowButtonProps={{}} farItems={copyButtons} />}
+        header={<CommandBar items={buttons} farItems={copyButtons} />}
         main={
             <Grid />
         }

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

@@ -165,7 +165,7 @@ export const InfoGrid: React.FunctionComponent<InfoGridProps> = ({
     }, [errorChecked, exceptions, store, infoChecked, otherChecked, refreshTable, warningChecked]);
 
     return <HolyGrail
-        header={<CommandBar items={buttons} overflowButtonProps={{}} farItems={copyButtons} />}
+        header={<CommandBar items={buttons} farItems={copyButtons} />}
         main={
             <Grid />
         }

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

@@ -89,7 +89,7 @@ export const LogViewer: React.FunctionComponent<LogViewerProps> = ({
     }, [errorChecked, store, infoChecked, log, otherChecked, refreshTable, warningChecked, lastUpdate]);
 
     return <HolyGrail
-        header={<CommandBar items={buttons} overflowButtonProps={{}} farItems={copyButtons} />}
+        header={<CommandBar items={buttons} farItems={copyButtons} />}
         main={
             <Grid />
         }

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

@@ -118,7 +118,7 @@ const subMenuItems: SubMenuItems = {
     ],
     "workunits": [
         { headerText: nlsHPCC.Workunits, itemKey: "/workunits" },
-        { headerText: nlsHPCC.Dashboard, itemKey: "/workunits/dashboard" },
+        // TODO: Post Tech Preview { headerText: nlsHPCC.Dashboard, itemKey: "/workunits/dashboard" },
         { headerText: nlsHPCC.Playground, itemKey: "/play" },
     ],
     "files": [

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

@@ -331,7 +331,7 @@ export const Metrics: React.FunctionComponent<MetricsProps> = ({
 
     return <HolyGrail
         header={<>
-            <CommandBar items={buttons} overflowButtonProps={{}} farItems={rightButtons} />
+            <CommandBar items={buttons} farItems={rightButtons} />
             <AutosizeHpccJSComponent widget={timeline} fixedHeight={"160px"} padding={4} />
         </>}
         main={

+ 23 - 25
esp/src/src-react/components/ProtectedBy.tsx

@@ -1,15 +1,12 @@
 import * as React from "react";
+import { CommandBar, ICommandBarItemProps } from "@fluentui/react";
 import { useConst } from "@fluentui/react-hooks";
-import { scopedLogger } from "@hpcc-js/util";
 import * as Observable from "dojo/store/Observable";
 import { Memory } from "src/Memory";
 import nlsHPCC from "src/nlsHPCC";
 import { useGrid } from "../hooks/grid";
 import { useFile } from "../hooks/file";
 import { HolyGrail } from "../layouts/HolyGrail";
-import * as WsDfu from "../../src/WsDfu";
-
-const logger = scopedLogger("../components/ProtectedBy.tsx");
 
 interface ProtectedByProps {
     cluster: string;
@@ -21,11 +18,11 @@ export const ProtectedBy: React.FunctionComponent<ProtectedByProps> = ({
     logicalFile
 }) => {
 
-    const [file, , _refresh] = useFile(cluster, logicalFile);
+    const [file, , , refreshData] = useFile(cluster, logicalFile);
 
     //  Grid ---
     const store = useConst(new Observable(new Memory("Owner")));
-    const [Grid, _selection, refreshTable, _copyButtons] = useGrid({
+    const [Grid, _selection, refreshTable, copyButtons] = useGrid({
         store,
         sort: [{ attribute: "Owner", "descending": false }],
         filename: "protectedBy",
@@ -36,28 +33,29 @@ export const ProtectedBy: React.FunctionComponent<ProtectedByProps> = ({
     });
 
     React.useEffect(() => {
-        WsDfu.DFUInfo({
-            request: {
-                Name: file?.Name
-            }
-        }).then(response => {
-            const results = response?.DFUInfoResponse?.FileDetail.ProtectList.DFUFileProtect;
+        const results = file?.ProtectList?.DFUFileProtect;
+
+        if (results) {
+            store.setData(file?.ProtectList?.DFUFileProtect?.map(row => {
+                return {
+                    Owner: row.Owner,
+                    Modified: row.Modified
+                };
+            }));
+            refreshTable();
+        }
+    }, [store, file?.ProtectList?.DFUFileProtect, refreshTable]);
 
-            if (results) {
-                store.setData(results.map(row => {
-                    return {
-                        Owner: row.Owner,
-                        Modified: row.Modified
-                    };
-                }));
-                refreshTable();
-            }
-        })
-            .catch(logger.error)
-            ;
-    }, [store, file?.Name, refreshTable]);
+    //  Command Bar  ---
+    const buttons = React.useMemo((): ICommandBarItemProps[] => [
+        {
+            key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
+            onClick: () => refreshData()
+        },
+    ], [refreshData]);
 
     return <HolyGrail
+        header={<CommandBar items={buttons} farItems={copyButtons} />}
         main={
             <Grid />
         }

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

@@ -258,7 +258,7 @@ export const Queries: React.FunctionComponent<QueriesProps> = ({
     }, [selection]);
 
     return <HolyGrail
-        header={<CommandBar items={buttons} overflowButtonProps={{}} farItems={copyButtons} />}
+        header={<CommandBar items={buttons} farItems={copyButtons} />}
         main={
             <>
                 <Grid />

+ 3 - 3
esp/src/src-react/components/Resources.tsx

@@ -23,7 +23,7 @@ export const Resources: React.FunctionComponent<ResourcesProps> = ({
 }) => {
 
     const [uiState, setUIState] = React.useState({ ...defaultUIState });
-    const [resources] = useWorkunitResources(wuid);
+    const [resources, , , refreshData] = useWorkunitResources(wuid);
 
     //  Grid ---
     const store = useConst(new Observable(new AlphaNumSortMemory("DisplayPath", { Name: true, Value: true })));
@@ -49,7 +49,7 @@ export const Resources: React.FunctionComponent<ResourcesProps> = ({
     const buttons = React.useMemo((): ICommandBarItemProps[] => [
         {
             key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
-            onClick: () => refreshTable()
+            onClick: () => refreshData()
         },
         { key: "divider_1", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
         {
@@ -76,7 +76,7 @@ export const Resources: React.FunctionComponent<ResourcesProps> = ({
                 }
             }
         },
-    ], [refreshTable, selection, uiState.hasSelection]);
+    ], [refreshData, selection, uiState.hasSelection]);
 
     //  Selection  ---
     React.useEffect(() => {

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

@@ -300,7 +300,7 @@ export const Result: React.FunctionComponent<ResultProps> = ({
     ];
 
     return <HolyGrail
-        header={<CommandBar items={buttons} overflowButtonProps={{}} farItems={rightButtons} />}
+        header={<CommandBar items={buttons} farItems={rightButtons} />}
         main={
             <>
                 <AutosizeHpccJSComponent widget={resultTable} />

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

@@ -23,7 +23,7 @@ export const Results: React.FunctionComponent<ResultsProps> = ({
 }) => {
 
     const [uiState, setUIState] = React.useState({ ...defaultUIState });
-    const [results] = useWorkunitResults(wuid);
+    const [results, , , refreshData] = useWorkunitResults(wuid);
 
     //  Grid ---
     const store = useConst(new Observable(new AlphaNumSortMemory("__hpcc_id", { Name: true, Value: true })));
@@ -44,7 +44,7 @@ export const Results: React.FunctionComponent<ResultsProps> = ({
             },
             FileName: {
                 label: nlsHPCC.FileName, sortable: true,
-                formatter: function (FileName, idx) {
+                formatter: function (FileName, row) {
                     return `<a href='#/files/${FileName}' class='dgrid-row-url2'>${FileName}</a>`;
                 }
             },
@@ -70,7 +70,7 @@ export const Results: React.FunctionComponent<ResultsProps> = ({
     const buttons = React.useMemo((): ICommandBarItemProps[] => [
         {
             key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
-            onClick: () => refreshTable()
+            onClick: () => refreshData()
         },
         { key: "divider_1", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
         {
@@ -97,7 +97,7 @@ export const Results: React.FunctionComponent<ResultsProps> = ({
                 }
             }
         },
-    ], [refreshTable, selection, uiState.hasSelection, wuid]);
+    ], [refreshData, selection, uiState.hasSelection, wuid]);
 
     //  Selection  ---
     React.useEffect(() => {

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

@@ -126,7 +126,7 @@ export const Search: React.FunctionComponent<SearchProps> = ({
         </Pivot >}
         main={selectedKey === "all" ? <HolyGrail
             header={<>
-                <CommandBar items={buttons} overflowButtonProps={{}} farItems={copyButtons} />
+                <CommandBar items={buttons} farItems={copyButtons} />
                 <ProgressIndicator progressHidden={searchCount === 0} percentComplete={searchCount === 0 ? 0 : progress.value / searchCount} />
             </>}
             main={<Grid />}

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

@@ -61,7 +61,7 @@ const SourceEditor: React.FunctionComponent<SourceEditorProps> = ({
     useOnEvent(document, "eclwatch-theme-toggle", handleThemeToggle);
 
     return <HolyGrail
-        header={<CommandBar items={buttons} overflowButtonProps={{}} />}
+        header={<CommandBar items={buttons} />}
         main={
             <AutosizeHpccJSComponent widget={editor} padding={4} />
         }

+ 6 - 6
esp/src/src-react/components/SourceFiles.tsx

@@ -36,7 +36,7 @@ export const SourceFiles: React.FunctionComponent<SourceFilesProps> = ({
 }) => {
 
     const [uiState, setUIState] = React.useState({ ...defaultUIState });
-    const [variables] = useWorkunitSourceFiles(wuid);
+    const [sourceFiles, , , refreshData] = useWorkunitSourceFiles(wuid);
 
     //  Grid ---
     const store = useConst(new Observable(new TreeStore("Name", { Name: true, Value: true })));
@@ -53,7 +53,7 @@ export const SourceFiles: React.FunctionComponent<SourceFilesProps> = ({
             Name: tree({
                 label: "Name", sortable: true,
                 formatter: function (Name, row) {
-                    return Utility.getImageHTML(row.IsSuperFile ? "folder_table.png" : "file.png") + "&nbsp;<a href='#' onClick='return false;' class='dgrid-row-url'>" + Name + "</a>";
+                    return `${Utility.getImageHTML(row.IsSuperFile ? "folder_table.png" : "file.png")}&nbsp;<a href='#/files/${row.FileCluster}/${Name}' class='dgrid-row-url2'>${Name}</a>`;
                 }
             }),
             FileCluster: { label: nlsHPCC.FileCluster, width: 300, sortable: false },
@@ -71,7 +71,7 @@ export const SourceFiles: React.FunctionComponent<SourceFilesProps> = ({
     const buttons = React.useMemo((): ICommandBarItemProps[] => [
         {
             key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
-            onClick: () => refreshTable()
+            onClick: () => refreshData()
         },
         { key: "divider_1", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
         {
@@ -86,7 +86,7 @@ export const SourceFiles: React.FunctionComponent<SourceFilesProps> = ({
                 }
             }
         },
-    ], [refreshTable, selection, uiState.hasSelection]);
+    ], [refreshData, selection, uiState.hasSelection]);
 
     //  Selection  ---
     React.useEffect(() => {
@@ -100,9 +100,9 @@ export const SourceFiles: React.FunctionComponent<SourceFilesProps> = ({
     }, [selection]);
 
     React.useEffect(() => {
-        store.setData(variables);
+        store.setData(sourceFiles);
         refreshTable();
-    }, [store, refreshTable, variables]);
+    }, [store, refreshTable, sourceFiles]);
 
     return <HolyGrail
         header={<CommandBar items={buttons} farItems={copyButtons} />}

+ 4 - 4
esp/src/src-react/components/SuperFiles.tsx

@@ -24,7 +24,7 @@ export const SuperFiles: React.FunctionComponent<SuperFilesProps> = ({
     logicalFile
 }) => {
 
-    const [file, , _refresh] = useFile(cluster, logicalFile);
+    const [file, , , refreshData] = useFile(cluster, logicalFile);
     const [uiState, setUIState] = React.useState({ ...defaultUIState });
 
     //  Grid ---
@@ -46,7 +46,7 @@ export const SuperFiles: React.FunctionComponent<SuperFilesProps> = ({
     const buttons = React.useMemo((): ICommandBarItemProps[] => [
         {
             key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
-            onClick: () => refreshTable()
+            onClick: () => refreshData()
         },
         { key: "divider_1", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
         {
@@ -61,7 +61,7 @@ export const SuperFiles: React.FunctionComponent<SuperFilesProps> = ({
                 }
             }
         },
-    ], [cluster, refreshTable, selection, uiState.hasSelection]);
+    ], [cluster, refreshData, selection, uiState.hasSelection]);
 
     //  Selection  ---
     React.useEffect(() => {
@@ -81,7 +81,7 @@ export const SuperFiles: React.FunctionComponent<SuperFilesProps> = ({
     }, [file, store, refreshTable]);
 
     return <HolyGrail
-        header={<CommandBar items={buttons} overflowButtonProps={{}} farItems={copyButtons} />}
+        header={<CommandBar items={buttons} farItems={copyButtons} />}
         main={
             <Grid />
         }

+ 3 - 3
esp/src/src-react/components/Variables.tsx

@@ -17,7 +17,7 @@ export const Variables: React.FunctionComponent<VariablesProps> = ({
     wuid
 }) => {
 
-    const [variables] = useWorkunitVariables(wuid);
+    const [variables, , , refreshData] = useWorkunitVariables(wuid);
 
     //  Grid ---
     const store = useConst(new Observable(new AlphaNumSortMemory("__hpcc_id", { Name: true, Value: true })));
@@ -46,10 +46,10 @@ export const Variables: React.FunctionComponent<VariablesProps> = ({
     const buttons = React.useMemo((): ICommandBarItemProps[] => [
         {
             key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
-            onClick: () => refreshTable()
+            onClick: () => refreshData()
         },
         { key: "divider_1", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
-    ], [refreshTable]);
+    ], [refreshData]);
 
     return <HolyGrail
         header={<CommandBar items={buttons} farItems={copyButtons} />}

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

@@ -71,7 +71,7 @@ export const Workflows: React.FunctionComponent<WorkflowsProps> = ({
     ], [refreshWorkflow]);
 
     return <HolyGrail
-        header={<CommandBar items={buttons} overflowButtonProps={{}} farItems={copyButtons} />}
+        header={<CommandBar items={buttons} farItems={copyButtons} />}
         main={
             <Grid />
         }

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

@@ -1,32 +1,21 @@
 import * as React from "react";
-import { CommandBar, ContextualMenuItemType, ICommandBarItemProps, Pivot, PivotItem, ScrollablePane, ScrollbarVisibility, Sticky, StickyPositionType } from "@fluentui/react";
-import { scopedLogger } from "@hpcc-js/util";
+import { Pivot, PivotItem } from "@fluentui/react";
 import { SizeMe } from "react-sizeme";
 import nlsHPCC from "src/nlsHPCC";
-import { WUStatus } from "src/react/index";
 import { useWorkunit } from "../hooks/workunit";
 import { DojoAdapter } from "../layouts/DojoAdapter";
 import { pivotItemStyle } from "../layouts/pivot";
-import { ReflexContainer, ReflexElement, ReflexSplitter, classNames, styles } from "../layouts/react-reflex";
 import { pushUrl } from "../util/history";
-import { ShortVerticalDivider } from "./Common";
 import { Results } from "./Results";
 import { Variables } from "./Variables";
 import { SourceFiles } from "./SourceFiles";
-import { TableGroup } from "./forms/Groups";
-import { PublishQueryForm } from "./forms/PublishQuery";
-import { SlaveLogs } from "./forms/SlaveLogs";
-import { ZAPDialog } from "./forms/ZAPDialog";
 import { Helpers } from "./Helpers";
-import { InfoGrid } from "./InfoGrid";
 import { Queries } from "./Queries";
 import { Resources } from "./Resources";
 import { WUXMLSourceEditor } from "./SourceEditor";
 import { Workflows } from "./Workflows";
 import { Metrics } from "./Metrics";
-import { WorkunitPersona } from "./controls/StateIcon";
-
-const logger = scopedLogger("../components/WorkunitDetails.tsx");
+import { WorkunitSummary } from "./WorkunitSummary";
 
 interface WorkunitDetailsProps {
     wuid: string;
@@ -39,222 +28,44 @@ export const WorkunitDetails: React.FunctionComponent<WorkunitDetailsProps> = ({
 }) => {
 
     const [workunit] = useWorkunit(wuid, true);
-    const [jobname, setJobname] = React.useState("");
-    const [description, setDescription] = React.useState("");
-    const [_protected, setProtected] = React.useState(false);
-    const [showPublishForm, setShowPublishForm] = React.useState(false);
-    const [showZapForm, setShowZapForm] = React.useState(false);
-    const [showThorSlaveLogs, setShowThorSlaveLogs] = React.useState(false);
-
-    React.useEffect(() => {
-        setJobname(workunit?.Jobname);
-        setDescription(workunit?.Description);
-        setProtected(workunit?.Protected);
-    }, [workunit?.Description, workunit?.Jobname, workunit?.Protected]);
-
-    const canSave = workunit && (
-        jobname !== workunit.Jobname ||
-        description !== workunit.Description ||
-        _protected !== workunit.Protected
-    );
-    const canDelete = workunit && (
-        _protected !== workunit.Protected ||
-        999 !== workunit.StateID ||
-        workunit.Archived
-    );
-    const canDeschedule = workunit && workunit?.EventSchedule === 2;
-    const canReschedule = workunit && workunit?.EventSchedule === 1;
-
-    const buttons = React.useMemo((): ICommandBarItemProps[] => [
-        {
-            key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
-            onClick: () => {
-                workunit.refresh();
-            }
-        },
-        {
-            key: "copy", text: nlsHPCC.CopyWUID, iconProps: { iconName: "Copy" },
-            onClick: () => {
-                navigator?.clipboard?.writeText(wuid);
-            }
-        },
-        { 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
-                }).catch(logger.error);
-            }
-        },
-        {
-            key: "delete", text: nlsHPCC.Delete, iconProps: { iconName: "Delete" }, disabled: !canDelete,
-            onClick: () => {
-                if (confirm(nlsHPCC.YouAreAboutToDeleteThisWorkunit)) {
-                    workunit?.delete().catch(logger.error);
-                    pushUrl("/workunits");
-                }
-            }
-        },
-        {
-            key: "restore", text: nlsHPCC.Restore, disabled: !workunit?.Archived,
-            onClick: () => workunit?.restore().catch(logger.error)
-
-        },
-        { key: "divider_2", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
-        {
-            key: "reschedule", text: nlsHPCC.Reschedule, disabled: !canReschedule,
-            onClick: () => workunit?.reschedule().catch(logger.error)
-        },
-        {
-            key: "deschedule", text: nlsHPCC.Deschedule, disabled: !canDeschedule,
-            onClick: () => workunit?.deschedule().catch(logger.error)
-        },
-        { key: "divider_3", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
-        {
-            key: "setToFailed", text: nlsHPCC.SetToFailed, disabled: workunit?.Archived || workunit?.isComplete() || workunit?.isDeleted(),
-            onClick: () => workunit?.setToFailed().catch(logger.error)
-        },
-        {
-            key: "abort", text: nlsHPCC.Abort, disabled: workunit?.Archived || workunit?.isComplete() || workunit?.isDeleted(),
-            onClick: () => workunit?.abort().catch(logger.error)
-        },
-        { key: "divider_4", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
-        {
-            key: "recover", text: nlsHPCC.Recover, disabled: workunit?.Archived || !workunit?.isComplete() || workunit?.isDeleted(),
-            onClick: () => workunit?.resubmit().catch(logger.error)
-        },
-        {
-            key: "resubmit", text: nlsHPCC.Resubmit, disabled: workunit?.Archived || !workunit?.isComplete() || workunit?.isDeleted(),
-            onClick: () => workunit?.resubmit().catch(logger.error)
-        },
-        {
-            key: "clone", text: nlsHPCC.Clone, disabled: workunit?.Archived || !workunit?.isComplete() || workunit?.isDeleted(),
-            onClick: () => {
-                workunit?.clone().then(wu => {
-                    if (wu && wu.Wuid) {
-                        pushUrl(`/workunits/${wu?.Wuid}`);
-                    }
-                }).catch(logger.error);
-            }
-        },
-        { key: "divider_5", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
-        {
-            key: "publish", text: nlsHPCC.Publish,
-            onClick: () => setShowPublishForm(true)
-        },
-        { key: "divider_6", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
-        {
-            key: "zap", text: nlsHPCC.ZAP, disabled: !canDelete,
-            onClick: () => setShowZapForm(true)
-        },
-        { key: "divider_7", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
-        {
-            key: "slaveLogs", text: nlsHPCC.SlaveLogs, disabled: !workunit?.ThorLogList,
-            onClick: () => setShowThorSlaveLogs(true)
-        },
-    ], [_protected, canDelete, canDeschedule, canReschedule, canSave, description, jobname, workunit, wuid]);
-
-    const rightButtons = React.useMemo((): ICommandBarItemProps[] => [
-    ], []);
 
-    const serviceNames = workunit?.ServiceNames?.Item?.join("\n") || "";
     const resourceCount = workunit?.ResourceURLCount > 1 ? workunit?.ResourceURLCount - 1 : undefined;
 
-    return <>
-        <SizeMe monitorHeight>{({ size }) =>
-            <Pivot overflowBehavior="menu" style={{ height: "100%" }} selectedKey={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} farItems={rightButtons} />
-                                        </Sticky>
-                                        <Sticky stickyPosition={StickyPositionType.Header}>
-                                            <WorkunitPersona wuid={wuid} />
-                                            <div style={{ width: "512px", height: "64px", float: "right" }}>
-                                                <WUStatus wuid={wuid}></WUStatus>
-                                            </div>
-                                        </Sticky>
-                                        <TableGroup 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:
-                                                    logger.debug(`${id}:  ${value}`);
-                                            }
-                                        }} />
-                                    </ScrollablePane>
-                                </div>
-                            </ReflexElement>
-                            <ReflexSplitter style={styles.reflexSplitter}>
-                                <div className={classNames.reflexSplitterDiv}></div>
-                            </ReflexSplitter>
-                            <ReflexElement propagateDimensions={true} className={classNames.reflexPane} style={{ overflow: "hidden" }}>
-                                <InfoGrid wuid={wuid}></InfoGrid>
-                            </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.Metrics} itemKey="metrics" itemCount={workunit?.GraphCount} style={pivotItemStyle(size, 0)}>
-                    <Metrics wuid={wuid} filter={{}} />
-                </PivotItem>
-                <PivotItem headerText={nlsHPCC.Workflows} itemKey="workflows" itemCount={workunit?.WorkflowCount} style={pivotItemStyle(size, 0)}>
-                    <Workflows wuid={wuid} />
-                </PivotItem>
-                <PivotItem headerText={nlsHPCC.Queries} itemIcon="Search" itemKey="queries" style={pivotItemStyle(size, 0)}>
-                    <Queries filter={{ WUID: wuid }} />
-                </PivotItem>
-                <PivotItem headerText={nlsHPCC.Resources} itemKey="resources" itemCount={resourceCount} style={pivotItemStyle(size, 0)}>
-                    <Resources wuid={wuid} />
-                </PivotItem>
-                <PivotItem headerText={nlsHPCC.Helpers} itemKey="helpers" itemCount={workunit?.HelpersCount} style={pivotItemStyle(size, 0)}>
-                    <Helpers 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)}>
-                    <WUXMLSourceEditor wuid={wuid} />
-                </PivotItem>
-            </Pivot>
-        }</SizeMe>
-        <PublishQueryForm wuid={wuid} showForm={showPublishForm} setShowForm={setShowPublishForm} />
-        <ZAPDialog wuid={wuid} showForm={showZapForm} setShowForm={setShowZapForm} />
-        <SlaveLogs wuid={wuid} showForm={showThorSlaveLogs} setShowForm={setShowThorSlaveLogs} />
-    </>;
+    return <SizeMe monitorHeight>{({ size }) =>
+        <Pivot overflowBehavior="menu" style={{ height: "100%" }} selectedKey={tab} onLinkClick={evt => pushUrl(`/workunits/${wuid}/${evt.props.itemKey}`)}>
+            <PivotItem headerText={wuid} itemKey="summary" style={pivotItemStyle(size)} >
+                <WorkunitSummary wuid={wuid} />
+            </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.Metrics} itemKey="metrics" itemCount={workunit?.GraphCount} style={pivotItemStyle(size, 0)}>
+                <Metrics wuid={wuid} filter={{}} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Workflows} itemKey="workflows" itemCount={workunit?.WorkflowCount} style={pivotItemStyle(size, 0)}>
+                <Workflows wuid={wuid} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Queries} itemIcon="Search" itemKey="queries" style={pivotItemStyle(size, 0)}>
+                <Queries filter={{ WUID: wuid }} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Resources} itemKey="resources" itemCount={resourceCount} style={pivotItemStyle(size, 0)}>
+                <Resources wuid={wuid} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Helpers} itemKey="helpers" itemCount={workunit?.HelpersCount} style={pivotItemStyle(size, 0)}>
+                <Helpers 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)}>
+                <WUXMLSourceEditor wuid={wuid} />
+            </PivotItem>
+        </Pivot>
+    }</SizeMe>;
 };

+ 209 - 0
esp/src/src-react/components/WorkunitSummary.tsx

@@ -0,0 +1,209 @@
+import * as React from "react";
+import { CommandBar, ContextualMenuItemType, ICommandBarItemProps, ScrollablePane, ScrollbarVisibility, Sticky, StickyPositionType } from "@fluentui/react";
+import { scopedLogger } from "@hpcc-js/util";
+import nlsHPCC from "src/nlsHPCC";
+import { WUStatus } from "src/react/index";
+import { useWorkunit } from "../hooks/workunit";
+import { ReflexContainer, ReflexElement, ReflexSplitter, classNames, styles } from "../layouts/react-reflex";
+import { pushUrl } from "../util/history";
+import { ShortVerticalDivider } from "./Common";
+import { TableGroup } from "./forms/Groups";
+import { PublishQueryForm } from "./forms/PublishQuery";
+import { SlaveLogs } from "./forms/SlaveLogs";
+import { ZAPDialog } from "./forms/ZAPDialog";
+import { InfoGrid } from "./InfoGrid";
+import { WorkunitPersona } from "./controls/StateIcon";
+
+const logger = scopedLogger("../components/WorkunitDetails.tsx");
+
+interface WorkunitSummaryProps {
+    wuid: string;
+}
+
+export const WorkunitSummary: React.FunctionComponent<WorkunitSummaryProps> = ({
+    wuid
+}) => {
+
+    const [workunit, , , , refresh] = useWorkunit(wuid, true);
+    const [jobname, setJobname] = React.useState("");
+    const [description, setDescription] = React.useState("");
+    const [_protected, setProtected] = React.useState(false);
+    const [showPublishForm, setShowPublishForm] = React.useState(false);
+    const [showZapForm, setShowZapForm] = React.useState(false);
+    const [showThorSlaveLogs, setShowThorSlaveLogs] = React.useState(false);
+
+    React.useEffect(() => {
+        setJobname(workunit?.Jobname);
+        setDescription(workunit?.Description);
+        setProtected(workunit?.Protected);
+    }, [workunit?.Description, workunit?.Jobname, workunit?.Protected]);
+
+    const canSave = workunit && (
+        jobname !== workunit.Jobname ||
+        description !== workunit.Description ||
+        _protected !== workunit.Protected
+    );
+    const canDelete = workunit && (
+        _protected !== workunit.Protected ||
+        999 !== workunit.StateID ||
+        workunit.Archived
+    );
+    const canDeschedule = workunit && workunit?.EventSchedule === 2;
+    const canReschedule = workunit && workunit?.EventSchedule === 1;
+
+    const buttons = React.useMemo((): ICommandBarItemProps[] => [
+        {
+            key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
+            onClick: () => {
+                refresh(true);
+            }
+        },
+        {
+            key: "copy", text: nlsHPCC.CopyWUID, iconProps: { iconName: "Copy" },
+            onClick: () => {
+                navigator?.clipboard?.writeText(wuid);
+            }
+        },
+        { 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
+                }).catch(logger.error);
+            }
+        },
+        {
+            key: "delete", text: nlsHPCC.Delete, iconProps: { iconName: "Delete" }, disabled: !canDelete,
+            onClick: () => {
+                if (confirm(nlsHPCC.YouAreAboutToDeleteThisWorkunit)) {
+                    workunit?.delete().catch(logger.error);
+                    pushUrl("/workunits");
+                }
+            }
+        },
+        {
+            key: "restore", text: nlsHPCC.Restore, disabled: !workunit?.Archived,
+            onClick: () => workunit?.restore().catch(logger.error)
+
+        },
+        { key: "divider_2", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+        {
+            key: "reschedule", text: nlsHPCC.Reschedule, disabled: !canReschedule,
+            onClick: () => workunit?.reschedule().catch(logger.error)
+        },
+        {
+            key: "deschedule", text: nlsHPCC.Deschedule, disabled: !canDeschedule,
+            onClick: () => workunit?.deschedule().catch(logger.error)
+        },
+        { key: "divider_3", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+        {
+            key: "setToFailed", text: nlsHPCC.SetToFailed, disabled: workunit?.Archived || workunit?.isComplete() || workunit?.isDeleted(),
+            onClick: () => workunit?.setToFailed().catch(logger.error)
+        },
+        {
+            key: "abort", text: nlsHPCC.Abort, disabled: workunit?.Archived || workunit?.isComplete() || workunit?.isDeleted(),
+            onClick: () => workunit?.abort().catch(logger.error)
+        },
+        { key: "divider_4", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+        {
+            key: "recover", text: nlsHPCC.Recover, disabled: workunit?.Archived || !workunit?.isComplete() || workunit?.isDeleted(),
+            onClick: () => workunit?.resubmit().catch(logger.error)
+        },
+        {
+            key: "resubmit", text: nlsHPCC.Resubmit, disabled: workunit?.Archived || !workunit?.isComplete() || workunit?.isDeleted(),
+            onClick: () => workunit?.resubmit().catch(logger.error)
+        },
+        {
+            key: "clone", text: nlsHPCC.Clone, disabled: workunit?.Archived || !workunit?.isComplete() || workunit?.isDeleted(),
+            onClick: () => {
+                workunit?.clone().then(wu => {
+                    if (wu && wu.Wuid) {
+                        pushUrl(`/workunits/${wu?.Wuid}`);
+                    }
+                }).catch(logger.error);
+            }
+        },
+        { key: "divider_5", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+        {
+            key: "publish", text: nlsHPCC.Publish,
+            onClick: () => setShowPublishForm(true)
+        },
+        { key: "divider_6", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+        {
+            key: "zap", text: nlsHPCC.ZAP, disabled: !canDelete,
+            onClick: () => setShowZapForm(true)
+        },
+        { key: "divider_7", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+        {
+            key: "slaveLogs", text: nlsHPCC.SlaveLogs, disabled: !workunit?.ThorLogList,
+            onClick: () => setShowThorSlaveLogs(true)
+        },
+    ], [_protected, canDelete, canDeschedule, canReschedule, canSave, description, jobname, refresh, workunit, wuid]);
+
+    const rightButtons = React.useMemo((): ICommandBarItemProps[] => [
+    ], []);
+
+    const serviceNames = workunit?.ServiceNames?.Item?.join("\n") || "";
+
+    return <>
+        <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} farItems={rightButtons} />
+                            </Sticky>
+                            <Sticky stickyPosition={StickyPositionType.Header}>
+                                <WorkunitPersona wuid={wuid} />
+                                <div style={{ width: "512px", height: "64px", float: "right" }}>
+                                    <WUStatus wuid={wuid}></WUStatus>
+                                </div>
+                            </Sticky>
+                            <TableGroup 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:
+                                        logger.debug(`${id}:  ${value}`);
+                                }
+                            }} />
+                        </ScrollablePane>
+                    </div>
+                </ReflexElement>
+                <ReflexSplitter style={styles.reflexSplitter}>
+                    <div className={classNames.reflexSplitterDiv}></div>
+                </ReflexSplitter>
+                <ReflexElement propagateDimensions={true} className={classNames.reflexPane} style={{ overflow: "hidden" }}>
+                    <InfoGrid wuid={wuid}></InfoGrid>
+                </ReflexElement>
+            </ReflexContainer>
+        </div>
+        <PublishQueryForm wuid={wuid} showForm={showPublishForm} setShowForm={setShowPublishForm} />
+        <ZAPDialog wuid={wuid} showForm={showZapForm} setShowForm={setShowZapForm} />
+        <SlaveLogs wuid={wuid} showForm={showThorSlaveLogs} setShowForm={setShowThorSlaveLogs} />
+    </>;
+};

+ 64 - 19
esp/src/src-react/hooks/file.ts

@@ -1,40 +1,85 @@
 import * as React from "react";
-import { LogicalFile } from "@hpcc-js/comms";
+import { LogicalFile, WsDfu } from "@hpcc-js/comms";
 import { scopedLogger } from "@hpcc-js/util";
-import * as WsDfu from "src/WsDfu";
+import { singletonDebounce } from "../util/throttle";
 import { useCounter } from "./workunit";
 
 const logger = scopedLogger("../hooks/file.ts");
 
-export function useFile(cluster: string, name: string): [LogicalFile, number, () => void] {
+export function useFile(cluster: string, name: string): [LogicalFile, boolean, number, () => void] {
 
     const [file, setFile] = React.useState<LogicalFile>();
+    const [isProtected, setIsProtected] = React.useState(false);
     const [lastUpdate, setLastUpdate] = React.useState(Date.now());
     const [count, increment] = useCounter();
 
     React.useEffect(() => {
         const file = LogicalFile.attach({ baseUrl: "" }, cluster, name);
-        file.fetchInfo().then(response => {
-            setFile(file);
-            setLastUpdate(Date.now());
-        }).catch(logger.error);
-    }, [cluster, name, count]);
+        let active = true;
+        let handle;
+        const fetchInfo = singletonDebounce(file, "fetchInfo");
+        fetchInfo().then(() => {
+            if (active) {
+                setFile(file);
+                setIsProtected(file.ProtectList?.DFUFileProtect?.length > 0 || false);
+                setLastUpdate(Date.now());
+                handle = file.watch(() => {
+                    setIsProtected(file.ProtectList?.DFUFileProtect?.length > 0 || false);
+                    setLastUpdate(Date.now());
+                });
+            }
+        });
+        return () => {
+            active = false;
+            handle?.release();
+        };
+    }, [cluster, count, name]);
 
-    return [file, lastUpdate, increment];
+    return [file, isProtected, lastUpdate, increment];
 }
 
-export function useDefFile(cluster: string, name: string, format: "def" | "xml"): [string] {
-    const [file, setFile] = React.useState("");
+export function useDefFile(cluster: string, name: string, format: "def" | "xml"): [string, () => void] {
+    const [file] = useFile(cluster, name);
+    const [defFile, setDefFile] = React.useState("");
+    const [count, increment] = useCounter();
+
+    React.useEffect(() => {
+        if (file) {
+            file.fetchDefFile(format)
+                .then(setDefFile)
+                .catch(logger.error)
+                ;
+        }
+    }, [file, format, count]);
+
+    return [defFile, increment];
+}
+
+export function useFileHistory(cluster: string, name: string): [WsDfu.Origin2[], () => void, () => void] {
+
+    const [file] = useFile(cluster, name);
+    const [history, setHistory] = React.useState<WsDfu.Origin2[]>([]);
+    const [count, increment] = useCounter();
+
+    const eraseHistory = React.useCallback(() => {
+        file?.eraseHistory()
+            .then(response => {
+                setHistory(response);
+            })
+            .catch(logger.error)
+            ;
+    }, [file]);
 
     React.useEffect(() => {
-        if (name) {
-            WsDfu.DFUDefFile(
-                { "request": { "Name": name, "Format": format }
-            }).then(response => {
-                setFile(response);
-            }).catch(logger.error);
+        if (file) {
+            file.fetchListHistory()
+                .then(response => {
+                    setHistory(response);
+                })
+                .catch(logger.error)
+                ;
         }
-    }, [cluster, format, name]);
+    }, [file, count]);
 
-    return [file];
+    return [history, eraseHistory, increment];
 }

+ 6 - 1
esp/src/src-react/hooks/grid.tsx

@@ -43,7 +43,7 @@ export function useGrid({ store, query = {}, sort = [], columns, getSelected, fi
 
     useDeepEffect(() => {
         refreshTable();
-    }, [refreshTable], [query]);
+    }, [], [query]);
 
     const copyButtons = React.useMemo((): ICommandBarItemProps[] => [
         ...createCopyDownloadSelection(grid, selection, `${filename}.csv`)
@@ -51,3 +51,8 @@ export function useGrid({ store, query = {}, sort = [], columns, getSelected, fi
 
     return [Grid, selection, refreshTable, copyButtons];
 }
+
+// export function useMemoryGrid({ query = {}, sort = [], columns, getSelected, filename }: useGridProps): [React.FunctionComponent, any[], (clearSelection?: boolean) => void, ICommandBarItemProps[]] {
+//     const [Grid, selection, refreshTable, copyButtons] = useGrid(params);
+//     return [Grid, selection, refreshTable, copyButtons];
+// };

+ 139 - 109
esp/src/src-react/hooks/workunit.ts

@@ -4,6 +4,7 @@ import { Workunit, Result, WUStateID, WUInfo, WorkunitsService } from "@hpcc-js/
 import { scopedLogger } from "@hpcc-js/util";
 import nlsHPCC from "src/nlsHPCC";
 import * as Utility from "src/Utility";
+import { singletonDebounce } from "../util/throttle";
 
 const logger = scopedLogger("../hooks/workunit.ts");
 
@@ -14,47 +15,55 @@ export function useCounter(): [number, () => void] {
     return [counter, () => setCounter(counter + 1)];
 }
 
-export function useWorkunit(wuid: string, full: boolean = false): [Workunit, WUStateID, number, boolean] {
+export function useWorkunit(wuid: string, full: boolean = false): [Workunit, WUStateID, number, boolean, (full?: boolean) => Promise<Workunit>] {
 
-    const [retVal, setRetVal] = React.useState<{ workunit: Workunit, state: number, lastUpdate: number, isComplete: boolean }>();
+    // eslint-disable-next-line func-call-spacing
+    const [retVal, setRetVal] = React.useState<{ workunit: Workunit, state: number, lastUpdate: number, isComplete: boolean, refresh: (full?: boolean) => Promise<Workunit> }>();
 
     React.useEffect(() => {
         if (!wuid) {
-            setRetVal({ workunit: undefined, state: WUStateID.NotFound, lastUpdate: Date.now(), isComplete: undefined });
+            setRetVal({ workunit: undefined, state: WUStateID.NotFound, lastUpdate: Date.now(), isComplete: undefined, refresh: () => Promise.resolve(undefined) });
             return;
         }
         const wu = Workunit.attach({ baseUrl: "" }, wuid);
         let active = true;
         let handle;
-        wu.refresh(full).then(() => {
-            if (active) {
-                setRetVal({ workunit: wu, state: wu.StateID, lastUpdate: Date.now(), isComplete: wu.isComplete() });
-                handle = wu.watch(() => {
-                    setRetVal({ workunit: wu, state: wu.StateID, lastUpdate: Date.now(), isComplete: wu.isComplete() });
-                });
-            }
-                    }).catch(logger.error);
+        const refresh = singletonDebounce(wu, "refresh");
+        refresh(full)
+            .then(() => {
+                if (active) {
+                    setRetVal({ workunit: wu, state: wu.StateID, lastUpdate: Date.now(), isComplete: wu.isComplete(), refresh });
+                    handle = wu.watch(() => {
+                        setRetVal({ workunit: wu, state: wu.StateID, lastUpdate: Date.now(), isComplete: wu.isComplete(), refresh });
+                    });
+                }
+            }).catch(logger.error);
+
         return () => {
             active = false;
             handle?.release();
         };
     }, [wuid, full]);
 
-    return [retVal?.workunit, retVal?.state, retVal?.lastUpdate, retVal?.isComplete];
+    return [retVal?.workunit, retVal?.state, retVal?.lastUpdate, retVal?.isComplete, retVal?.refresh];
 }
 
-export function useWorkunitResults(wuid: string): [Result[], Workunit, WUStateID] {
+export function useWorkunitResults(wuid: string): [Result[], Workunit, WUStateID, () => void] {
 
     const [workunit, state] = useWorkunit(wuid);
     const [results, setResults] = React.useState<Result[]>([]);
+    const [count, inc] = useCounter();
 
     React.useEffect(() => {
-        workunit?.fetchResults().then(results => {
-            setResults(results);
-        }).catch(logger.error);
-    }, [workunit, state]);
+        if (workunit) {
+            const fetchResults = singletonDebounce(workunit, "fetchResults");
+            fetchResults().then(results => {
+                setResults(results);
+            }).catch(logger.error);
+        }
+    }, [workunit, state, count]);
 
-    return [results, workunit, state];
+    return [results, workunit, state, inc];
 }
 
 export function useWorkunitResult(wuid: string, resultName: string): [Result, Workunit, WUStateID] {
@@ -75,76 +84,84 @@ export interface Variable {
     Value: string;
 }
 
-export function useWorkunitVariables(wuid: string): [Variable[], Workunit, WUStateID] {
+export function useWorkunitVariables(wuid: string): [Variable[], Workunit, WUStateID, () => void] {
 
     const [workunit, state] = useWorkunit(wuid);
     const [variables, setVariables] = React.useState<Variable[]>([]);
+    const [count, inc] = useCounter();
 
     React.useEffect(() => {
-        workunit?.fetchInfo({
-            IncludeVariables: true,
-            IncludeApplicationValues: true,
-            IncludeDebugValues: true
-        }).then(response => {
-            const vars: Variable[] = response?.Workunit?.Variables?.ECLResult?.map(row => {
-                return {
-                    Type: nlsHPCC.ECL,
-                    Name: row.Name,
-                    Value: row.Value
-                };
-            }) || [];
-            const appData: Variable[] = response?.Workunit?.ApplicationValues?.ApplicationValue.map(row => {
-                return {
-                    Type: row.Application,
-                    Name: row.Name,
-                    Value: row.Value
-                };
-            }) || [];
-            const debugData: Variable[] = response?.Workunit?.DebugValues?.DebugValue.map(row => {
-                return {
-                    Type: nlsHPCC.Debug,
-                    Name: row.Name,
-                    Value: row.Value
-                };
-            }) || [];
-            setVariables([...vars, ...appData, ...debugData]);
-        }).catch(logger.error);
-    }, [workunit, state]);
+        if (workunit) {
+            const fetchInfo = singletonDebounce(workunit, "fetchInfo");
+            fetchInfo({
+                IncludeVariables: true,
+                IncludeApplicationValues: true,
+                IncludeDebugValues: true
+            }).then(response => {
+                const vars: Variable[] = response?.Workunit?.Variables?.ECLResult?.map(row => {
+                    return {
+                        Type: nlsHPCC.ECL,
+                        Name: row.Name,
+                        Value: row.Value
+                    };
+                }) || [];
+                const appData: Variable[] = response?.Workunit?.ApplicationValues?.ApplicationValue.map(row => {
+                    return {
+                        Type: row.Application,
+                        Name: row.Name,
+                        Value: row.Value
+                    };
+                }) || [];
+                const debugData: Variable[] = response?.Workunit?.DebugValues?.DebugValue.map(row => {
+                    return {
+                        Type: nlsHPCC.Debug,
+                        Name: row.Name,
+                        Value: row.Value
+                    };
+                }) || [];
+                setVariables([...vars, ...appData, ...debugData]);
+            }).catch(logger.error);
+        }
+    }, [workunit, state, count]);
 
-    return [variables, workunit, state];
+    return [variables, workunit, state, inc];
 }
 
 export interface SourceFile extends WUInfo.ECLSourceFile {
     __hpcc_parentName: string;
 }
 
-export function useWorkunitSourceFiles(wuid: string): [SourceFile[], Workunit, WUStateID] {
+export function useWorkunitSourceFiles(wuid: string): [SourceFile[], Workunit, WUStateID, () => void] {
 
     const [workunit, state] = useWorkunit(wuid);
     const [sourceFiles, setSourceFiles] = React.useState<SourceFile[]>([]);
+    const [count, inc] = useCounter();
 
     React.useEffect(() => {
-        workunit?.fetchInfo({
-            IncludeSourceFiles: true
-        }).then(response => {
-            const sourceFiles: SourceFile[] = [];
-            response?.Workunit?.SourceFiles?.ECLSourceFile.forEach(sourceFile => {
-                sourceFiles.push({
-                    __hpcc_parentName: "",
-                    ...sourceFile
-                });
-                sourceFile?.ECLSourceFiles?.ECLSourceFile.forEach(childSourceFile => {
+        if (workunit) {
+            const fetchInfo = singletonDebounce(workunit, "fetchInfo");
+            fetchInfo({
+                IncludeSourceFiles: true
+            }).then(response => {
+                const sourceFiles: SourceFile[] = [];
+                response?.Workunit?.SourceFiles?.ECLSourceFile.forEach(sourceFile => {
                     sourceFiles.push({
-                        __hpcc_parentName: sourceFile.Name,
-                        ...childSourceFile
+                        __hpcc_parentName: "",
+                        ...sourceFile
+                    });
+                    sourceFile?.ECLSourceFiles?.ECLSourceFile.forEach(childSourceFile => {
+                        sourceFiles.push({
+                            __hpcc_parentName: sourceFile.Name,
+                            ...childSourceFile
+                        });
                     });
                 });
-            });
-            setSourceFiles(sourceFiles);
-        }).catch(logger.error);
-    }, [workunit, state]);
+                setSourceFiles(sourceFiles);
+            }).catch(logger.error);
+        }
+    }, [workunit, state, count]);
 
-    return [sourceFiles, workunit, state];
+    return [sourceFiles, workunit, state, inc];
 }
 
 export function useWorkunitWorkflows(wuid: string): [WUInfo.ECLWorkflow[], Workunit, () => void] {
@@ -154,11 +171,14 @@ export function useWorkunitWorkflows(wuid: string): [WUInfo.ECLWorkflow[], Worku
     const [count, increment] = useCounter();
 
     React.useEffect(() => {
-        workunit?.fetchInfo({
-            IncludeWorkflows: true
-        }).then(response => {
-            setWorkflows(response?.Workunit?.Workflows?.ECLWorkflow || []);
-        }).catch(logger.error);
+        if (workunit) {
+            const fetchInfo = singletonDebounce(workunit, "fetchInfo");
+            fetchInfo({
+                IncludeWorkflows: true
+            }).then(response => {
+                setWorkflows(response?.Workunit?.Workflows?.ECLWorkflow || []);
+            }).catch(logger.error);
+        }
     }, [workunit, state, count]);
 
     return [workflows, workunit, increment];
@@ -189,31 +209,37 @@ export function useWorkunitExceptions(wuid: string): [WUInfo.ECLException[], Wor
     const [count, increment] = useCounter();
 
     React.useEffect(() => {
-        if (!workunit) return;
-        workunit?.fetchInfo({
-            IncludeExceptions: true
-        }).then(response => {
-            setExceptions(response?.Workunit?.Exceptions?.ECLException || []);
-        }).catch(logger.error);
+        if (workunit) {
+            const fetchInfo = singletonDebounce(workunit, "fetchInfo");
+            fetchInfo({
+                IncludeExceptions: true
+            }).then(response => {
+                setExceptions(response?.Workunit?.Exceptions?.ECLException || []);
+            }).catch(logger.error);
+        }
     }, [workunit, state, count]);
 
     return [exceptions, workunit, increment];
 }
 
-export function useWorkunitResources(wuid: string): [string[], Workunit, WUStateID] {
+export function useWorkunitResources(wuid: string): [string[], Workunit, WUStateID, () => void] {
 
     const [workunit, state] = useWorkunit(wuid);
     const [resources, setResources] = React.useState<string[]>([]);
+    const [count, increment] = useCounter();
 
     React.useEffect(() => {
-        workunit?.fetchInfo({
-            IncludeResourceURLs: true
-        }).then(response => {
-            setResources(response?.Workunit?.ResourceURLs?.URL || []);
-        }).catch(logger.error);
-    }, [workunit, state]);
+        if (workunit) {
+            const fetchInfo = singletonDebounce(workunit, "fetchInfo");
+            fetchInfo({
+                IncludeResourceURLs: true
+            }).then(response => {
+                setResources(response?.Workunit?.ResourceURLs?.URL || []);
+            }).catch(logger.error);
+        }
+    }, [workunit, state, count]);
 
-    return [resources, workunit, state];
+    return [resources, workunit, state, increment];
 }
 
 export interface HelperRow {
@@ -257,33 +283,37 @@ function mapThorLogInfo(workunit: Workunit, thorLogInfo: WUInfo.ThorLogInfo[] =
     return retVal;
 }
 
-export function useWorkunitHelpers(wuid: string): [HelperRow[]] {
+export function useWorkunitHelpers(wuid: string): [HelperRow[], () => void] {
 
     const [workunit, state] = useWorkunit(wuid);
+    const [counter, incCounter] = useCounter();
     const [helpers, setHelpers] = React.useState<HelperRow[]>([]);
 
     React.useEffect(() => {
-        workunit?.fetchInfo({
-            IncludeHelpers: true
-        }).then(response => {
-            setHelpers([{
-                id: "E:0",
-                Type: "ECL",
-                workunit
-            }, {
-                id: "X:0",
-                Type: "Workunit XML",
-                workunit
-            }, ...(workunit.HasArchiveQuery ? [{
-                id: "A:0",
-                Type: "Archive Query",
-                workunit
-            }] : []),
-            ...mapHelpers(workunit, response?.Workunit?.Helpers?.ECLHelpFile),
-            ...mapThorLogInfo(workunit, response?.Workunit?.ThorLogList?.ThorLogInfo)
-            ]);
-        }).catch(logger.error);
-    }, [workunit, state]);
+        if (workunit) {
+            const fetchInfo = singletonDebounce(workunit, "fetchInfo");
+            fetchInfo({
+                IncludeHelpers: true
+            }).then(response => {
+                setHelpers([{
+                    id: "E:0",
+                    Type: "ECL",
+                    workunit
+                }, {
+                    id: "X:0",
+                    Type: "Workunit XML",
+                    workunit
+                }, ...(workunit.HasArchiveQuery ? [{
+                    id: "A:0",
+                    Type: "Archive Query",
+                    workunit
+                }] : []),
+                ...mapHelpers(workunit, response?.Workunit?.Helpers?.ECLHelpFile),
+                ...mapThorLogInfo(workunit, response?.Workunit?.ThorLogList?.ThorLogInfo)
+                ]);
+            }).catch(logger.error);
+        }
+    }, [counter, workunit, state]);
 
-    return [helpers];
+    return [helpers, incCounter];
 }

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

@@ -101,6 +101,7 @@ export const routes: RoutesEx = [
         path: "/files",
         children: [
             { path: "", action: (context) => import("./components/Files").then(_ => <_.Files filter={parseSearch(context.search) as any} />) },
+            { path: "/:Name", action: (ctx, params) => import("./components/FileDetails").then(_ => <_.FileDetails cluster={undefined} logicalFile={params.Name as string} />) },
             { path: "/:NodeGroup/:Name", action: (ctx, params) => import("./components/FileDetails").then(_ => <_.FileDetails cluster={params.NodeGroup as string} logicalFile={params.Name as string} />) },
             { path: "/:NodeGroup/:Name/:Tab", action: (ctx, params) => import("./components/FileDetails").then(_ => <_.FileDetails cluster={params.NodeGroup as string} logicalFile={params.Name as string} tab={params.Tab as string} />) },
         ]

+ 9 - 3
esp/src/src-react/util/history.ts

@@ -150,16 +150,22 @@ export function pushSearch(_: object, state?: any) {
     }, state);
 }
 
+export function updateSearch(_: object, state?: any) {
+    const search = stringify(_ as any);
+    hashHistory.replace({
+        search: search ? "?" + search : ""
+    }, state);
+}
+
 export function pushUrl(_: string, state?: any) {
     hashHistory.push({
         pathname: _
     }, state);
 }
 
-export function updateSearch(_: object, state?: any) {
-    const search = stringify(_ as any);
+export function replaceUrl(_: string, state?: any) {
     hashHistory.replace({
-        search: search ? "?" + search : ""
+        pathname: _
     }, state);
 }
 

+ 12 - 0
esp/src/src-react/util/throttle.ts

@@ -0,0 +1,12 @@
+import { debounce } from "@hpcc-js/util";
+
+// eslint-disable-next-line @typescript-eslint/ban-types
+type TypeOfClassMethod<T, M extends keyof T> = T[M] extends Function ? T[M] : never;
+
+export function singletonDebounce<T, M extends keyof T>(obj: T, method: M): TypeOfClassMethod<T, M> {
+    const __lazy__ = Symbol.for(`__lazy__${method}`);
+    if (!obj[__lazy__]) {
+        obj[__lazy__] = debounce((...args: any[]) => (obj[method] as any)(...args), 1000);
+    }
+    return obj[__lazy__];
+}

+ 56 - 14
esp/src/src/Memory.ts

@@ -1,7 +1,13 @@
+import * as Deferred from "dojo/Deferred";
+import * as Observable from "dojo/store/Observable";
 import * as QueryResults from "dojo/store/util/QueryResults";
 import * as SimpleQueryEngine from "dojo/store/util/SimpleQueryEngine";
 import { alphanumSort } from "./Utility";
 
+export {
+    Observable
+};
+
 //  Replacement for "dojo/store/Memory"
 export interface MemoryOptions {
     data?: any,
@@ -10,26 +16,51 @@ export interface MemoryOptions {
     queryEngine?: any
 }
 
-export class Memory {
+type FetchDataResponse = Promise<any[]>;
 
-    protected data = null;
-    private idProperty: string;
-    private index = null;
+export class BaseStore {
+
+    protected idProperty: string;
+    protected index = null;
     protected queryEngine = SimpleQueryEngine;
 
     constructor(idProperty: string = "id") {
         this.idProperty = idProperty;
-        this.setData(this.data || []);
-    }
-
-    get(id) {
-        return this.data[this.index[id]];
     }
 
     getIdentity(object) {
         return object[this.idProperty];
     }
 
+    protected fetchData(): FetchDataResponse {
+        return Promise.resolve([]);
+    }
+
+    query(query, options) {
+        const retVal = new Deferred();
+        this.fetchData().then(response => {
+            const data = this.queryEngine(query, options)(response);
+            retVal.resolve(data);
+        });
+        return QueryResults(retVal.then(response => response), {
+            totalLength: retVal.then(response => response.length)
+        });
+    }
+}
+
+export class Memory extends BaseStore {
+
+    protected data = null;
+
+    constructor(idProperty: string = "id") {
+        super(idProperty);
+        this.setData(this.data || []);
+    }
+
+    get(id: string) {
+        return this.data[this.index[id]];
+    }
+
     put(row, options) {
         const data = this.data;
         const index = this.index;
@@ -61,10 +92,6 @@ export class Memory {
         }
     }
 
-    query(query, options) {
-        return QueryResults(this.queryEngine(query, options)(this.data));
-    }
-
     setData(data) {
         if (data.items) {
             this.idProperty = data.identifier || this.idProperty;
@@ -77,6 +104,10 @@ export class Memory {
             this.index[data[i][this.idProperty]] = i;
         }
     }
+
+    protected fetchData(): FetchDataResponse {
+        return Promise.resolve(this.data);
+    }
 }
 
 export class AlphaNumSortMemory extends Memory {
@@ -92,4 +123,15 @@ export class AlphaNumSortMemory extends Memory {
         }
         return retVal;
     }
-}
+}
+
+export class ASyncStore extends BaseStore {
+
+    constructor(idProperty: string = "id", protected _fetchData: () => Promise<void | any[]>) {
+        super(idProperty);
+    }
+
+    fetchData(): FetchDataResponse {
+        return this._fetchData().then(data => !!data ? data : []);
+    }
+}