From 63ee3a68679c6403fa79cc3cf9bc2b62da689cac Mon Sep 17 00:00:00 2001 From: Teo Koon Peng Date: Thu, 5 Sep 2024 09:48:47 +0800 Subject: [PATCH] Dashboard: microapp v2 (#999) Signed-off-by: Teo Koon Peng --- package.json | 5 +- packages/api-client/package.json | 2 +- packages/dashboard-e2e/package.json | 2 +- packages/dashboard/app-config.schema.json | 55 +- .../examples/custom-theme/index.html | 18 + .../dashboard/examples/custom-theme/index.tsx | 127 ++ packages/dashboard/examples/demo/index.html | 18 + packages/dashboard/examples/demo/index.tsx | 114 ++ packages/dashboard/examples/shared/app.css | 8 + packages/dashboard/examples/shared/index.tsx | 115 ++ .../examples/shared/public/favicon.ico | Bin 0 -> 9294 bytes .../shared/public/resources/defaultLogo.png | Bin 0 -> 10639 bytes .../examples/shared/public/robots.txt | 2 + .../shared/public/silent-check-sso.html | 7 + .../dashboard/examples/shared/vite.config.ts | 14 + packages/dashboard/package.json | 6 +- packages/dashboard/src/app-config.ts | 85 +- packages/dashboard/src/app.tsx | 276 ++--- .../admin/add-permission-dialog.test.tsx | 7 +- .../admin/add-permission-dialog.tsx | 4 +- .../admin/create-role-dialog.test.tsx | 7 +- .../components/admin/create-role-dialog.tsx | 4 +- .../admin/create-user-dialog.test.tsx | 7 +- .../components/admin/create-user-dialog.tsx | 4 +- .../dashboard/src/components/admin/drawer.tsx | 55 +- .../admin/manage-roles-dialog.test.tsx | 7 +- .../components/admin/manage-roles-dialog.tsx | 4 +- .../admin/permissions-card.test.tsx | 7 +- .../src/components/admin/permissions-card.tsx | 4 +- .../components/admin/role-list-card.test.tsx | 7 +- .../src/components/admin/role-list-card.tsx | 4 +- .../src/components/admin/role-list-page.tsx | 8 +- .../dashboard/src/components/admin/router.tsx | 24 +- .../components/admin/user-list-card.test.tsx | 7 +- .../src/components/admin/user-list-card.tsx | 5 +- .../src/components/admin/user-list-page.tsx | 8 +- .../components/admin/user-profile-page.tsx | 4 +- .../components/admin/user-profile.test.tsx | 8 +- .../src/components/admin/user-profile.tsx | 9 +- .../src/components/alert-manager.tsx | 51 +- .../dashboard/src/components/app-base.tsx | 122 -- .../dashboard/src/components/app-contexts.tsx | 26 - .../dashboard/src/components/app-registry.ts | 23 - .../dashboard/src/components/appbar.test.tsx | 102 +- packages/dashboard/src/components/appbar.tsx | 1067 ++++++++--------- .../{beacons-app.tsx => beacons-table.tsx} | 21 +- .../src/components/delivery-alert-store.tsx | 47 +- .../dashboard/src/components/door-summary.tsx | 12 +- .../{doors-app.tsx => doors-table.tsx} | 63 +- packages/dashboard/src/components/index.ts | 8 +- .../dashboard/src/components/lift-summary.tsx | 12 +- .../{lifts-app.tsx => lifts-table.tsx} | 37 +- packages/dashboard/src/components/map-app.tsx | 736 ------------ packages/dashboard/src/components/map.tsx | 712 +++++++++++ .../dashboard/src/components/micro-app.tsx | 67 +- .../src/components/private-route.test.tsx | 35 - .../src/components/private-route.tsx | 20 - .../components/react-three-fiber-hack.d.ts | 10 + packages/dashboard/src/components/rmf-app.tsx | 34 - .../src/components/rmf-dashboard.tsx | 342 ++++++ .../components/robots/robot-decommission.tsx | 24 +- ...robot-info-app.tsx => robot-info-card.tsx} | 20 +- ...up-app.tsx => robot-mutex-group-table.tsx} | 34 +- .../src/components/robots/robot-summary.tsx | 15 +- .../{robots-app.tsx => robots-table.tsx} | 24 +- .../components/tasks/task-cancellation.tsx | 26 +- .../src/components/tasks/task-details-app.tsx | 29 +- .../src/components/tasks/task-inspector.tsx | 12 +- .../src/components/tasks/task-logs-app.tsx | 18 +- .../src/components/tasks/task-schedule.tsx | 60 +- .../src/components/tasks/task-summary.tsx | 10 +- .../tasks/{tasks-app.tsx => tasks-window.tsx} | 268 ++--- packages/dashboard/src/components/theme.ts | 8 + .../src/components/three-fiber/door-three.tsx | 18 +- .../src/components/three-fiber/lift-three.tsx | 11 +- .../src/components/user-profile-provider.tsx | 69 -- .../dashboard/src/components/workspace.tsx | 336 ++++-- .../dashboard/src/hooks/deferred-context.ts | 27 + .../dashboard/src/hooks/use-app-controller.ts | 12 + .../dashboard/src/hooks/use-authenticator.ts | 4 + ...eTaskForm.tsx => use-create-task-form.tsx} | 10 +- packages/dashboard/src/hooks/use-resources.ts | 33 + packages/dashboard/src/hooks/use-rmf-api.ts | 4 + packages/dashboard/src/hooks/use-settings.ts | 4 + .../dashboard/src/hooks/use-task-registry.ts | 21 + .../dashboard/src/hooks/use-user-profile.tsx | 4 + packages/dashboard/src/hooks/useFetchUser.tsx | 28 - packages/dashboard/src/index.tsx | 10 +- .../dashboard/src/micro-apps/doors-app.ts | 8 + .../dashboard/src/micro-apps/lifts-app.ts | 8 + packages/dashboard/src/micro-apps/map-app.ts | 11 + .../src/micro-apps/robot-mutex-groups-app.ts | 8 + .../dashboard/src/micro-apps/robots-app.ts | 8 + .../dashboard/src/micro-apps/tasks-app.ts | 9 + .../src/pages/login-page.stories.tsx | 3 +- .../dashboard/src/services/authenticator.ts | 4 +- packages/dashboard/src/services/keycloak.ts | 3 +- packages/dashboard/src/services/rmf-api.ts | 331 +++++ .../dashboard/src/services/rmf-ingress.ts | 264 ---- packages/dashboard/src/services/settings.ts | 14 +- .../dashboard/src/utils/test-utils.test.tsx | 163 ++- packages/dashboard/tsconfig.app.json | 8 +- .../lib/doors/door-table-datagrid.tsx | 50 +- .../lib/lifts/lift-table-datagrid.tsx | 49 +- .../lib/react-three-fiber-hack.d.ts | 22 + .../lib/robots/mutex-group-table.tsx | 1 - .../lib/robots/robot-table-datagrid.tsx | 1 - .../lib/tasks/task-table-datagrid.tsx | 1 - .../react-components/lib/transfer-list.tsx | 2 +- .../lib/window/demo.stories.tsx | 7 +- .../lib/window/no-rgl-animations.css | 3 + .../lib/window/window-container.tsx | 87 +- .../lib/window/window-toolbar.tsx | 34 +- .../react-components/lib/window/window.tsx | 39 +- packages/react-components/package.json | 2 +- packages/rmf-models/package.json | 2 +- packages/ros-translator/package.json | 2 +- patches/@react-three__fiber.patch | 22 + pnpm-lock.yaml | 1023 ++++++++++++---- 119 files changed, 4507 insertions(+), 3416 deletions(-) create mode 100644 packages/dashboard/examples/custom-theme/index.html create mode 100644 packages/dashboard/examples/custom-theme/index.tsx create mode 100644 packages/dashboard/examples/demo/index.html create mode 100644 packages/dashboard/examples/demo/index.tsx create mode 100644 packages/dashboard/examples/shared/app.css create mode 100644 packages/dashboard/examples/shared/index.tsx create mode 100644 packages/dashboard/examples/shared/public/favicon.ico create mode 100644 packages/dashboard/examples/shared/public/resources/defaultLogo.png create mode 100644 packages/dashboard/examples/shared/public/robots.txt create mode 100644 packages/dashboard/examples/shared/public/silent-check-sso.html create mode 100644 packages/dashboard/examples/shared/vite.config.ts delete mode 100644 packages/dashboard/src/components/app-base.tsx delete mode 100644 packages/dashboard/src/components/app-contexts.tsx delete mode 100644 packages/dashboard/src/components/app-registry.ts rename packages/dashboard/src/components/{beacons-app.tsx => beacons-table.tsx} (63%) rename packages/dashboard/src/components/{doors-app.tsx => doors-table.tsx} (62%) rename packages/dashboard/src/components/{lifts-app.tsx => lifts-table.tsx} (82%) delete mode 100644 packages/dashboard/src/components/map-app.tsx create mode 100644 packages/dashboard/src/components/map.tsx delete mode 100644 packages/dashboard/src/components/private-route.test.tsx delete mode 100644 packages/dashboard/src/components/private-route.tsx create mode 100644 packages/dashboard/src/components/react-three-fiber-hack.d.ts delete mode 100644 packages/dashboard/src/components/rmf-app.tsx create mode 100644 packages/dashboard/src/components/rmf-dashboard.tsx rename packages/dashboard/src/components/robots/{robot-info-app.tsx => robot-info-card.tsx} (87%) rename packages/dashboard/src/components/robots/{robot-mutex-group-app.tsx => robot-mutex-group-table.tsx} (87%) rename packages/dashboard/src/components/robots/{robots-app.tsx => robots-table.tsx} (86%) rename packages/dashboard/src/components/tasks/{tasks-app.tsx => tasks-window.tsx} (66%) create mode 100644 packages/dashboard/src/components/theme.ts delete mode 100644 packages/dashboard/src/components/user-profile-provider.tsx create mode 100644 packages/dashboard/src/hooks/deferred-context.ts create mode 100644 packages/dashboard/src/hooks/use-app-controller.ts create mode 100644 packages/dashboard/src/hooks/use-authenticator.ts rename packages/dashboard/src/hooks/{useCreateTaskForm.tsx => use-create-task-form.tsx} (89%) create mode 100644 packages/dashboard/src/hooks/use-resources.ts create mode 100644 packages/dashboard/src/hooks/use-rmf-api.ts create mode 100644 packages/dashboard/src/hooks/use-settings.ts create mode 100644 packages/dashboard/src/hooks/use-task-registry.ts create mode 100644 packages/dashboard/src/hooks/use-user-profile.tsx delete mode 100644 packages/dashboard/src/hooks/useFetchUser.tsx create mode 100644 packages/dashboard/src/micro-apps/doors-app.ts create mode 100644 packages/dashboard/src/micro-apps/lifts-app.ts create mode 100644 packages/dashboard/src/micro-apps/map-app.ts create mode 100644 packages/dashboard/src/micro-apps/robot-mutex-groups-app.ts create mode 100644 packages/dashboard/src/micro-apps/robots-app.ts create mode 100644 packages/dashboard/src/micro-apps/tasks-app.ts create mode 100644 packages/dashboard/src/services/rmf-api.ts delete mode 100644 packages/dashboard/src/services/rmf-ingress.ts create mode 100644 packages/react-components/lib/react-three-fiber-hack.d.ts create mode 100644 packages/react-components/lib/window/no-rgl-animations.css create mode 100644 patches/@react-three__fiber.patch diff --git a/package.json b/package.json index 6fdfea9d4..5665b5a25 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "lint-staged": "^15.2.2", "prettier": "^3.2.5", "pyright": "1.1.369", - "typescript": "~5.4.3", + "typescript": "~5.5.4", "typescript-eslint": "^7.5.0" }, "lint-staged": { @@ -34,6 +34,9 @@ "pnpm": { "overrides": { "typescript-json-schema>@types/node": "*" + }, + "patchedDependencies": { + "@react-three/fiber": "patches/@react-three__fiber.patch" } }, "overrides": { diff --git a/packages/api-client/package.json b/packages/api-client/package.json index f812c8f40..82088eac4 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -23,7 +23,7 @@ "@typescript-eslint/parser": "^7.5.0", "axios": "1.7.4", "eslint": "^8.57.0", - "typescript": "~5.4.3", + "typescript": "~5.5.4", "vitest": "^2.0.4" }, "files": [ diff --git a/packages/dashboard-e2e/package.json b/packages/dashboard-e2e/package.json index d56aaf376..2f462572d 100644 --- a/packages/dashboard-e2e/package.json +++ b/packages/dashboard-e2e/package.json @@ -23,6 +23,6 @@ "rmf-dashboard": "workspace:*", "serve": "^11.3.2", "ts-node": "^9.1.1", - "typescript": "~5.4.3" + "typescript": "~5.5.4" } } diff --git a/packages/dashboard/app-config.schema.json b/packages/dashboard/app-config.schema.json index 6a54b9ff0..08d4ddfd0 100644 --- a/packages/dashboard/app-config.schema.json +++ b/packages/dashboard/app-config.schema.json @@ -1,6 +1,32 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AllowedTask": { + "properties": { + "displayName": { + "description": "Configure the display name for the task definition.", + "type": "string" + }, + "scheduleEventColor": { + "description": "The color of the event when rendered on the task scheduler in the form of a CSS color string.", + "type": "string" + }, + "taskDefinitionId": { + "description": "The task definition to configure.", + "enum": [ + "compose-clean", + "custom_compose", + "delivery", + "patrol" + ], + "type": "string" + } + }, + "required": [ + "taskDefinitionId" + ], + "type": "object" + }, "BuildConfig": { "description": "These will be injected at build time, they CANNOT be changed after the bundle is built.", "properties": { @@ -105,33 +131,6 @@ }, "StubAuthConfig": { "type": "object" - }, - "TaskResource": { - "description": "Configuration for task definitions.", - "properties": { - "displayName": { - "description": "Configure the display name for the task definition.", - "type": "string" - }, - "scheduleEventColor": { - "description": "The color of the event when rendered on the task scheduler in the form of a CSS color string.", - "type": "string" - }, - "taskDefinitionId": { - "description": "The task definition to configure.", - "enum": [ - "compose-clean", - "custom_compose", - "delivery", - "patrol" - ], - "type": "string" - } - }, - "required": [ - "taskDefinitionId" - ], - "type": "object" } }, "properties": { @@ -142,7 +141,7 @@ "allowedTasks": { "description": "List of allowed tasks that can be requested", "items": { - "$ref": "#/definitions/TaskResource" + "$ref": "#/definitions/AllowedTask" }, "type": "array" }, diff --git a/packages/dashboard/examples/custom-theme/index.html b/packages/dashboard/examples/custom-theme/index.html new file mode 100644 index 000000000..f13043862 --- /dev/null +++ b/packages/dashboard/examples/custom-theme/index.html @@ -0,0 +1,18 @@ + + + + + + + + + RMF Dashboard + + +
+ + + diff --git a/packages/dashboard/examples/custom-theme/index.tsx b/packages/dashboard/examples/custom-theme/index.tsx new file mode 100644 index 000000000..eaa95fcf0 --- /dev/null +++ b/packages/dashboard/examples/custom-theme/index.tsx @@ -0,0 +1,127 @@ +import '@fontsource/roboto/300.css'; +import '@fontsource/roboto/400.css'; +import '@fontsource/roboto/500.css'; +import '@fontsource/roboto/700.css'; + +import { createTheme } from '@mui/material'; +import ReactDOM from 'react-dom/client'; +import { LocallyPersistentWorkspace, RmfDashboard } from 'rmf-dashboard/components'; +import { MicroAppManifest } from 'rmf-dashboard/components/micro-app'; +import doorsApp from 'rmf-dashboard/micro-apps/doors-app'; +import liftsApp from 'rmf-dashboard/micro-apps/lifts-app'; +import createMapApp from 'rmf-dashboard/micro-apps/map-app'; +import robotMutexGroupsApp from 'rmf-dashboard/micro-apps/robot-mutex-groups-app'; +import robotsApp from 'rmf-dashboard/micro-apps/robots-app'; +import tasksApp from 'rmf-dashboard/micro-apps/tasks-app'; +import StubAuthenticator from 'rmf-dashboard/services/stub-authenticator'; + +/* eslint-disable @typescript-eslint/no-unused-vars,@typescript-eslint/ban-ts-comment */ +// Polar Night +const nord0 = '#2e3440'; // @ts-ignore +const nord1 = '#3b4252'; // @ts-ignore +const nord2 = '#434c5e'; // @ts-ignore +const nord3 = '#4c566a'; // @ts-ignore + +// Snow Storm +const nord4 = '#d8dee9'; // @ts-ignore +const nord5 = '#e5e9f0'; // @ts-ignore +const nord6 = '#eceff4'; // @ts-ignore + +// Frost +const nord7 = '#8fbcbb'; // @ts-ignore +const nord8 = '#88c0d0'; // @ts-ignore +const nord9 = '#81a1c1'; // @ts-ignore +const nord10 = '#5e81ac'; // @ts-ignore + +// Aurora +const nord11 = '#bf616a'; // @ts-ignore +const nord12 = '#d08770'; // @ts-ignore +const nord13 = '#ebcb8b'; // @ts-ignore +const nord14 = '#a3be8c'; // @ts-ignore +const nord15 = '#b48ead'; // @ts-ignore +/* eslint-enable @typescript-eslint/no-unused-vars,@typescript-eslint/ban-ts-comment */ + +const nordTheme = createTheme({ + palette: { + mode: 'dark', + primary: { + main: nord8, + contrastText: nord1, + }, + secondary: { + main: nord9, + }, + text: { + primary: nord4, + secondary: nord6, + disabled: nord5, + }, + error: { + main: nord11, + }, + warning: { + main: nord13, + }, + success: { + main: nord14, + }, + background: { default: nord0, paper: nord1 }, + }, +}); + +const mapApp = createMapApp({ + attributionPrefix: 'Open-RMF', + defaultMapLevel: 'L1', + defaultRobotZoom: 20, + defaultZoom: 6, +}); + +const appRegistry: MicroAppManifest[] = [ + mapApp, + doorsApp, + liftsApp, + robotsApp, + robotMutexGroupsApp, + tasksApp, +]; + +export default function App() { + return ( + + ), + }, + ]} + /> + ); +} + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +root.render(); diff --git a/packages/dashboard/examples/demo/index.html b/packages/dashboard/examples/demo/index.html new file mode 100644 index 000000000..f13043862 --- /dev/null +++ b/packages/dashboard/examples/demo/index.html @@ -0,0 +1,18 @@ + + + + + + + + + RMF Dashboard + + +
+ + + diff --git a/packages/dashboard/examples/demo/index.tsx b/packages/dashboard/examples/demo/index.tsx new file mode 100644 index 000000000..360f838e1 --- /dev/null +++ b/packages/dashboard/examples/demo/index.tsx @@ -0,0 +1,114 @@ +import '@fontsource/roboto/300.css'; +import '@fontsource/roboto/400.css'; +import '@fontsource/roboto/500.css'; +import '@fontsource/roboto/700.css'; + +import ReactDOM from 'react-dom/client'; +import { + InitialWindow, + LocallyPersistentWorkspace, + RmfDashboard, + Workspace, +} from 'rmf-dashboard/components'; +import { MicroAppManifest } from 'rmf-dashboard/components/micro-app'; +import doorsApp from 'rmf-dashboard/micro-apps/doors-app'; +import liftsApp from 'rmf-dashboard/micro-apps/lifts-app'; +import createMapApp from 'rmf-dashboard/micro-apps/map-app'; +import robotMutexGroupsApp from 'rmf-dashboard/micro-apps/robot-mutex-groups-app'; +import robotsApp from 'rmf-dashboard/micro-apps/robots-app'; +import tasksApp from 'rmf-dashboard/micro-apps/tasks-app'; +import StubAuthenticator from 'rmf-dashboard/services/stub-authenticator'; + +const mapApp = createMapApp({ + attributionPrefix: 'Open-RMF', + defaultMapLevel: 'L1', + defaultRobotZoom: 20, + defaultZoom: 6, +}); + +const appRegistry: MicroAppManifest[] = [ + mapApp, + doorsApp, + liftsApp, + robotsApp, + robotMutexGroupsApp, + tasksApp, +]; + +const homeWorkspace: InitialWindow[] = [ + { + layout: { x: 0, y: 0, w: 12, h: 6 }, + microApp: mapApp, + }, +]; + +const robotsWorkspace: InitialWindow[] = [ + { + layout: { x: 0, y: 0, w: 7, h: 4 }, + microApp: robotsApp, + }, + { layout: { x: 8, y: 0, w: 5, h: 8 }, microApp: mapApp }, + { layout: { x: 0, y: 0, w: 7, h: 4 }, microApp: doorsApp }, + { layout: { x: 0, y: 0, w: 7, h: 4 }, microApp: liftsApp }, + { layout: { x: 8, y: 0, w: 5, h: 4 }, microApp: robotMutexGroupsApp }, +]; + +const tasksWorkspace: InitialWindow[] = [ + { layout: { x: 0, y: 0, w: 7, h: 8 }, microApp: tasksApp }, + { layout: { x: 8, y: 0, w: 5, h: 8 }, microApp: mapApp }, +]; + +export default function App() { + return ( + , + }, + { + name: 'Robots', + route: 'robots', + element: , + }, + { + name: 'Tasks', + route: 'tasks', + element: , + }, + { + name: 'Custom', + route: 'custom', + element: ( + + ), + }, + ]} + /> + ); +} + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +root.render(); diff --git a/packages/dashboard/examples/shared/app.css b/packages/dashboard/examples/shared/app.css new file mode 100644 index 000000000..2319b13e4 --- /dev/null +++ b/packages/dashboard/examples/shared/app.css @@ -0,0 +1,8 @@ +html, +body, +#root { + height: 100%; + width: 100%; + margin: 0; + padding: 0; +} diff --git a/packages/dashboard/examples/shared/index.tsx b/packages/dashboard/examples/shared/index.tsx new file mode 100644 index 000000000..1324d9cc5 --- /dev/null +++ b/packages/dashboard/examples/shared/index.tsx @@ -0,0 +1,115 @@ +import '@fontsource/roboto/300.css'; +import '@fontsource/roboto/400.css'; +import '@fontsource/roboto/500.css'; +import '@fontsource/roboto/700.css'; +import './app.css'; + +import ReactDOM from 'react-dom/client'; +import { + InitialWindow, + LocallyPersistentWorkspace, + RmfDashboard, + Workspace, +} from 'rmf-dashboard/components'; +import { MicroAppManifest } from 'rmf-dashboard/components/micro-app'; +import doorsApp from 'rmf-dashboard/micro-apps/doors-app'; +import liftsApp from 'rmf-dashboard/micro-apps/lifts-app'; +import createMapApp from 'rmf-dashboard/micro-apps/map-app'; +import robotMutexGroupsApp from 'rmf-dashboard/micro-apps/robot-mutex-groups-app'; +import robotsApp from 'rmf-dashboard/micro-apps/robots-app'; +import tasksApp from 'rmf-dashboard/micro-apps/tasks-app'; +import StubAuthenticator from 'rmf-dashboard/services/stub-authenticator'; + +const mapApp = createMapApp({ + attributionPrefix: 'Open-RMF', + defaultMapLevel: 'L1', + defaultRobotZoom: 20, + defaultZoom: 6, +}); + +const appRegistry: MicroAppManifest[] = [ + mapApp, + doorsApp, + liftsApp, + robotsApp, + robotMutexGroupsApp, + tasksApp, +]; + +const homeWorkspace: InitialWindow[] = [ + { + layout: { x: 0, y: 0, w: 12, h: 6 }, + microApp: mapApp, + }, +]; + +const robotsWorkspace: InitialWindow[] = [ + { + layout: { x: 0, y: 0, w: 7, h: 4 }, + microApp: robotsApp, + }, + { layout: { x: 8, y: 0, w: 5, h: 8 }, microApp: mapApp }, + { layout: { x: 0, y: 0, w: 7, h: 4 }, microApp: doorsApp }, + { layout: { x: 0, y: 0, w: 7, h: 4 }, microApp: liftsApp }, + { layout: { x: 8, y: 0, w: 5, h: 4 }, microApp: robotMutexGroupsApp }, +]; + +const tasksWorkspace: InitialWindow[] = [ + { layout: { x: 0, y: 0, w: 7, h: 8 }, microApp: tasksApp }, + { layout: { x: 8, y: 0, w: 5, h: 8 }, microApp: mapApp }, +]; + +export default function App() { + return ( + , + }, + { + name: 'Robots', + route: 'robots', + element: , + }, + { + name: 'Tasks', + route: 'tasks', + element: , + }, + { + name: 'Custom', + route: 'custom', + element: ( + + ), + }, + ]} + /> + ); +} + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +root.render(); diff --git a/packages/dashboard/examples/shared/public/favicon.ico b/packages/dashboard/examples/shared/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..6c149bf7f4a0f470043668b89e1425a0f32e5fb1 GIT binary patch literal 9294 zcmeHLYm6Jk9UtccmqNfP;uQ$7gCbInobT*?cy~>E5h|^OP!tJLQKez91B@qhemWKcBblLW68wVf49+Y-g1Qq-@nTH5_G`1I=4!=7V&Rx6n&CpGQd zFwU)-maaL*mtM&{vDGjx48uYD7ts1NT*r8GK3vt*D?ofXzQ)2t^F%Z7d$)WVUd>BXRKd&N(} zu%zdyeEuTT^aoKxp59*4Rwm(x;huKcxi!mppla#QOlhMOj>>kpIh$QlDdw+7K7WK9 zJ63V@9d*WVo2q|YJAWhU#(T)?PSw<_JJzf@OOZOYwJ@?8_48$_4|IQcCH>!3v+#38 zdWQNoiu$sj*Dajk&^AjeCf&2df9uG|Qo1`p^Hsk-Ip%TC*8g0~<<3viVRHHMGhp+( zK>uN%p3dQD_wT%N^rs2*shX2pi+lE6k+!>rEiLVq1bS96wGDJnhwoj_G5_b8guGB*@ZvNmyP@1F;5~EU#uA8<54=e#@b78PU;uFl<|NK_f_qYb7b`78}cz^ z4A?Ei=M&G?cGq$joGIf`EgEIm{b-0L=v`?UqzCl5 zGY%iId&0ByM`V7JD3b!e-=lx~G*Uk0qpd%Qnzm>PJsr;WQ7o=wegePUC6OiFa!vh4 z^eg{~;|bVtx1_J1&gE7>{_!|mz}u)Pe~=23Y(oAf$UhRtlVDYQbhM6K2=`(Vx>?xk zuXHa+#Le464)1~eKbPCC)EV-eNkiu;WgD$)Q_jOj7Hb+sS@sNTYm!lqw|>TZW+7y*|`qr_s@oppAO3v zpTW09?fy>i+53 zGPP}N>Td+B}wTP=M^=@IDvXx&?Jj8STxE{2CANtJIyWiOfd^4Tk z+fL&<+)lUf+e&wao0-l~BhyJWGPA+JbBgu=$7l_=7qfJG5leR#F<=B^VvaTHG<>#h zKq9>uuEMY|`2Q5G$PRN*ckxNJwum(1`uo!3f5Uaik*3IVp=;#u9^`Ump>!M{x&9W_ z2i!|H^Sy=4!5MvRtJ)5=GPB zSBxphkjJ!ertn#0x!9L0(5dE@DnIf)_X+7FlPT^c#S-Vm=fv2iv-Rv6my4nf9G}Zb znq@U=fM3tP6hEqVqq>_USA?tBmUQAdrV`5mzT!Iq=|aAfomFMM13MXD0hgz^bA6dMfDUaKExTH5zj2HsZOiNkBcpe4;3C{v%ubTH^D!k z*@Kbj|GsU6x*(o`uT^r2&Y_x5eYxM|Vx%3p4n5w9XCgZ&)psEVNFSbyTwc_ibpH}B z@|mi>eOoBz71v9x)}mj7Z~HPrXBPLl&YZu>OgSdvUFc4>cw_&Gc-lJq&nimd&vy d^I4YFn->DQm`!w2*lSE+FXK0tW&HNi%(sUmd-(tW literal 0 HcmV?d00001 diff --git a/packages/dashboard/examples/shared/public/resources/defaultLogo.png b/packages/dashboard/examples/shared/public/resources/defaultLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..06ce6c02a54da7128fb42539fc1e974625d7e97c GIT binary patch literal 10639 zcmeHtXH-+$w{JkCN)rJU2uM{aK~YL*N;xzE5duh+F1>{i2oV&JrXWq4fFMPJfCwZ= zsL}*P5S3~uVki<4dg#2(IcMB+--q|c_;1V(z`Gtx>w9QcY4HM4}@aR?Uj3-EPy_i_<}hxoY&xggzL zK_KKfEU|~RQbvcG-k6pNwZ zqye1~UGiD?4NNMICH?35sdG`KZ%^qQu)+Kb;-YOU$-{4~=L6t7p}_CsW{9xRpDJ)qRbflR+d`VY0WLz;7AAzomyW4%>N&c@c#sTgw00zfT3rK_^$4El{h7wpA$bT)( zF+TtE*S~}OM;QML*S~Q6M+p2!%KyGy|HAbjA@Cn5|ND0R?}Cf@A9Cm712DQ^fVz=7 zV=4gJM;EA}XT}IzVT{hPAP_iDPwS=`a(p?L;6I^T^LWYy4Qo_!rYjM#yi9A#X&K*Y zM#m%a?Ce7#qppq+l(vmCHu1(NV??pi;tykENGrOWg6wwjkZIo+0e(; z)4{Vd_ih@{0HK+}yCjf(}!$+f@Z2GcQ8tQ{0!HmyBZUot&nBAA$`N$MG^ zp2i0kiJky@o5s{SJ9LoiFe}j}2Dl&ky<){FhoQt4Q+L*5>unR33r1%Y!Y(MR?y_(m z4<9k@ZB6Q0`z@aGKwm$$>Exf|AuhmmQpnuHuxJ;L6K(3Q>c$i7}DaD~40|41cD80`-5%Ne0G_B^w!NGElR0&pBP$@odMSf15^O87_0Fx;&%dl=<_nn=!~() z^L46^3cnQc7~hC#ZIV1E-V=82?Aqf2+2oDyQDo7VA#P^gf1*cJzt*N6R(=V=Yp8iv zs++LN&c;mw!Ckl)f?gaMU*N-tqbv zWtncRRQ}?ptVJH;zNU!C)g9_NwTsDk1TM<*R;gw;BiZ53>X(g|9!{Gk}fV`)~$O0`V(qvr~Fa zNiygTvYpq8)U%HVGM8>nA1~#%*&>xjo?bu7sh-o&#<@uugi33#`{n)RjI2P zq6aOUF2^X{shd>@l_&d(5xbw+U6t(3Iv5yZS)VY!Gu|0;h6VB?oMEqMD|jWaR~bQ{ zgMt-ns$zDiuWPPZc7IgqSkTGf^`c!jd9V}a3G+k|CwWuU?z-hKZ0mWw-7UTh;}<>) zYRI!kIIX(v5N(Y`557G#Yrl5ksM8D1ZTbGt`r@VR+&ScUUCWY9rWy*{IKQkup0kc7 zdG&tDy#htFp(KOZDMQy03X;7H12mP%qXd?|?Eqog5tq#!fmIfez$eLdRhqNTQtK7J z#!^0JmqSScj-SUebM;&=&#*Gv!Xajajn{GqZ#y z^B_ieY3rYYqp2uLf>SjLN#Gy~U#v*cWeG}4 zC1~*eb``*g$nb*Sljqm^u;1>>tbY1-CbZiBE6(?x3O|3exr%Gs(s{%*Fe|D}knL{e zXn$nb*AFr1Cq?piMX#L%(Piq$+@ixOfpK8vvE)I0@5CR?t2QE@N7$y^IF)$glU8N~ zTOf7VMMCvMc6TcWC?)2yIDXYhM@&Y>-& zN(y{xSn)YGQJ;Y1mPi5jqxP~#(YF<>_vG}9=Qa}who&&)ZqsHcov%Qc+OxV@N*H}q zhns0wW zd|?5B*Sl>FPewZi9*kp;l4Ez#UzAOc_;27jQcYU71O-}u819w1csUjzvuxUAz2)Cw(j^ky#icrCY!MVyIo(Xn6&<0Urc_n6Vy{O-Ku)^U##Otz72QpY!*n-;Vn zWLAUI8gRlWbp8C5_4?2%P|8{l>ghN=`NK5$^A7neJ<Ti+q%W>Mm8@QYb|PIdxbMwt)^J70)W$_>!y7s|BxOhxsk}*x@=C z!%d3aVKT7PrzBVmod>;!*d5vUw0J}bVMN{;Kz1I=Dv(5B)H(9X>Gnwb>v8` zk|ohb`uX)gR={(u*Hf(Tg^}yEr(Z0$RXgp*zByBEZLraBwXKf@6u@rnDMHg?96Rhb z;E9i~xaPY3qTfAe!e2m6GPz&GAW5l1i98bMP$&9?rfm;gnL7{^q!hTH>xqm?YIO{< z@O>NK0N|$+6F6$+#p&qO?1_A_v|l@6`$F+hB@`yjRnh#Rt|Hz(7@WYC&%rrzQeR*E z0-djJ+n(ggrVX|rtb?4ANfU(hWCnUe=4AaQNB?7FW*w^1-X`A!GDpKA&xZm<))744d z>j@I&%3sLw?zgD){TaJ9?{K;knBL8>w|PfR@1`Uia8E_#U@`8&uoL-7rq(cUaRo*{ zj%gyRVutkXe)-YUm`D}L-lsnrW8}J5`!;2RtsN}@e%lj&H61^{qmyvpD2?OjxEs}s zovYDWd2HidOrz}f^P-PU6;3p*VD&~re|&(nLe=h@iurLC&z+xN1YHO65V%|(l#wVPNmct5%H24cWWEH?ai4gs+BCZaXVTL3o zu6@7FFl-acuZEBQL2HUn{RPs>j+VFaMUl-jyPq{5cZ!9jFiyTK59;Pl^OQiG%Y1=L zwIrOhE|37H{&D7tgYZI5+{+MA(|HuF-?tcSxH#NtRCT;&&w2zwVX~b%U96^Y)D}gF zs!~SU|IVF$Dx_jNS0ghA9zhr?Be7Eg8#PD7Q~=U5jRjop_*1uc&X4@4+3O^LBcJW~3Ax=8x6=!4aG}Nyjx7bG)D-5Hm!owA0WyW! zuV*&Ph`aQZq+K84^7Dz3`)<*`3Ek>j*6jy2+V(k<{Ue#>BrscIV;@*36;%y$(1aWOhRaHt zL!v-5#8MDg6SDt_?4M79JN&O z-w*jvPUAP9dAZ_!niE_=XqYYaM^YXx#yE_upy!sXMS)88R#h<;*)Uk?kBOH+vt;gY z?Ra*dZ0|a(ycI_HsQzRc(69%BQ?6EWLN@JNNqqQlzo$n+7qn0*W5aeRx6i>0)vPu2 zVVvsuH2RyAWbXt(=lW`SqN_YgWuGn88^S7QXEl@JKNWRb5?3?nC`00LBiOBBFxS*j zM_{aH&|G@##spMqV~uD1CkMlF)An~LdZV(kUz}&;dBS&U>&#`z-XcBJNUgF$fi=p( zDWT-4wCwLuwE0(Z`hE@4OVsu{vYStj&j3A_GfNeh)bHi&b^w@IQ0fCxeI}e+g{kV- zLT0(nJl&fYdxh=m*V~k`pV6Z(&4(&gx+{U%Dpe}e$Ls9Qn~}pRzh6)TFBtv`PHpr{ zsz|_9mDYFsq0Wn*2dIsFRI37HGHV~Zr{iJA*sd_(lY# zo}D4%;pA#=lhag>t5|HO;hPhWusPG~a*}$;K|$t)tZ~`wGwaE zvPYoo{uPPij@|;BOCY+YFocu;Wxp=q%*;i?@{c^UCF=mut) zrndRlEP6DXQ1G>1%#|7$vwfJ&U0mgMV9h@1-{4kWKA7#It)v_w@LJQk$fH~6ehcl#o>I|d^_UHsIU%8^qsWG5MDX!Wse?PjyB=*rM zmcZN5Tuu;yUC{T`p}Cro&v110r}Agd*h#)>qCkS}IKGdgu*S73Rji7}%K3rRql{W- z(1UGcFvJOxN!ra&i_Gw4mfSn|F`NIQdy?r29!>9%Z-cQ{d4P{yHmt)ors=rDF+tkA zO-Y7w3Zpc|62{dz(@hLFR%o?4#T6~8kt4$$&QyO^gRu~0^vce`y9!%Q65WfJx>4rc+&zYG^BO~@=4tEz<7+{@?$;s%9*zNK zM}Sh?LT*XS&D_&X?0j~#B16+CuE5)jL)U+CRBh?29@Q1aTPI0h!w2r5db8vAf^>d5 zEp8u*1A(RDk8kH|2&kU;I~X=^@~%tlCpPN&#p#9wPv)LVQl_h(9J#bIv#9vml;KSO?!$f}J1JQ20zbjKFg3QyP7ryWn z`?0iM(mVwrA#ZPIwh)Hraady{=3+S43KyeW~g>_tCRKaY=QECaM z!G2*?Djn`(M~JGV2QqTV$dF28-8)m0y@O|QS7>nDr{DUNpB`s=!inZ|w2e7c0kAV2 zSaIG+0Ay?JH}yK0P3<^$)pSZohTkeOoIZfBwSdM}Vh>j_-%PDDoOa}d_;(Nc`PrCG zb_!b4Dg~1n&a?=?Qw~oiSHb@Mh3Qu-yths6{{YB;guh;$N;?d+DzAD*ZvkvtG z;B*_L-6X`+>%z%K;lS?vquBjjUq@fV@lCX&+md;c^;Qp=fd3pH@c3fHiJ3bOl=bnS zSV{ZG06(TAw&dtGB#Oc3#^J~Z~AMO8CFC>afe#>QmFigyuqi%IOh zNY(u1=i4<)yHOlPK?a%QEe3mz!RBekDd370RTu4@RvuaYi1k_h2Qi~19_lL0Kt8)} z40$R*;evz@mw>UjysgT~a(Zx$=lkN$?95!_hu%%k_l6$~IWkSd%+qd4NL$NpclF8e zO(Mol;4NIK>uS)NlYE7|yHcUJwW_O%61Nxuwx}zK67+R^=KV(1e;QTM;9eJYM9>;? zVl;lqBp~R&*+lg5^L7xZd5rk{t}rYuocV20WpHqnU=>j?=Ijue@L~D-ADFdF+Kmvk z(b%#_?ggcP(7HM*3*3-Ibp0#}AF0|de!u~57(d!z5RdqoqCOn>~E5&24ka?* zvn^Ot*a(+~dFHj3a92GT-Ve6^PGzHLRRv0itTTtNpHYmfdY$;OW4AqQpk$C*?|Hs= z<UG#s|lCb;v1@aG2_U5 zqWPuoWzf`Sg!OP7YNuNw=Et}?-SG1xxj*ciy$6jQQ>gcEo|wKWu^m5hccJ|yh|wqd#TiS{sXGL%7)N7~hOA#$`S{zpbt z=*3W8TE#a?1x9m^mpDSc(r`2mb`{x_7APJy0c zttYq~iuc@GsfrI24}g$3>`FZNnv9;zK{tjUB;D=e!2@*Wodm)@cToB9?CB=YoeaT4 zjXs)s%~QS#k=8)ORE+7^31TPOOkB1{6%M&|wwQ5GW$CU}jaU!3W2f;WJ#GgOt0U}S&Ec^Hw zz&d@RIbO&aSEGPq-)924s0?Ns0=&p2>M|uR+f961hlJ@o0T!26$K+o2*%QU6Y~&>F z23jC|MbW23f#@Ne;kH{Fe?$1@^;eqta)wc1@?9)+5XkUqf&Lo--Pp^G)ngCq6F zbkm&p$^g+K5yw_GhMWuvTa&{(v9HCAq0g-x93tE)c|}nHJw@+0`OWx3p9>x?u2yUq zrH$=pP*=x(f#Y$k+xwUDMS-$|7Ugb&;AWD{$>e>f(4}`|4U$@o!Z~&ZL0E{l#zgMx zwQu313G?PLG*1t?Kd;r8R-HONqfF{6sIQB(+?G+Smxrr#%%vK-E&5ed?)IVvu*;4k zy_0RI*#uMFmrBCJY$F;g!boF{jS@_w;}Vb0$fnLFJ{lzjq}*ZlDK$WztskkVXi-OO zxi3V^wMP{+T&X}!RymCdD4SD%vwp#oE<689@0_*9Yg%+%6ZGM;QpbmCzJx;NsC4kd zuDYQ_QsdX9m`y8I9n?b{52^W*;MDme4QB;fhQpUkyV1ow2_f61UsFlu;s~C_HR~GR ziSL2HsOl<}N~Y<5_=qk>9tzaF(BJJ7FIv75$6JGP3_p4mUOKc_=N3MmPI!JuNmZSN z@nCe zCY%R@eWY0xpjaGT`&}8P;$_m6P8mRjUmp32Euu*p2k|i1^Ui;IxoQC$?bdRe zSe-`~ww3Sg6EZUc)V=^hg#Ss^7|Iua;Pz0aDS1OxOC>q|=-{;O^c?1A$%C?2TzJOg zq9Gk*LzB_i5TwA$cOU**TC!K?`npCb#^_8b8j6gZ223g1#LlRm1{P~L%42Bdj-+zG zwa%GIOvuO1p|$#wzYAj@4C>7nLlUZ_kMII|Q-OWQg_cLW&@Po%hUGWEZw|@#2)roQ z-#9Kr0Sqcwn_@-DC?Rp#rAGoSfHxd8+ZZ;4Js)ly2_bFV|4VC@yU8t&Rz72+X-*og zd5O&NSCK8cq%auRRiDj5P*Ysb-(zZ>-TP7Aw>p}Glj<J^kH}9j^fq6`{2<@mAK*o;&Xf=VH;W+-6BLnk=KP#C)=m6XS z82(v<6hhLl@nm?NF0ug1ct9J!l+63M@WESv#z7<(5tIm=t|`~TiRuzS#Q4JexKD&8 vZ_^0iIsmtcJ8%J_lA+@|+yCd1Xg#7~p^-;Jzew{OtLbSQX;o_6d-Q(*_7q@3 literal 0 HcmV?d00001 diff --git a/packages/dashboard/examples/shared/public/robots.txt b/packages/dashboard/examples/shared/public/robots.txt new file mode 100644 index 000000000..01b0f9a10 --- /dev/null +++ b/packages/dashboard/examples/shared/public/robots.txt @@ -0,0 +1,2 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * diff --git a/packages/dashboard/examples/shared/public/silent-check-sso.html b/packages/dashboard/examples/shared/public/silent-check-sso.html new file mode 100644 index 000000000..20ad2098d --- /dev/null +++ b/packages/dashboard/examples/shared/public/silent-check-sso.html @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/dashboard/examples/shared/vite.config.ts b/packages/dashboard/examples/shared/vite.config.ts new file mode 100644 index 000000000..e6394d559 --- /dev/null +++ b/packages/dashboard/examples/shared/vite.config.ts @@ -0,0 +1,14 @@ +import react from '@vitejs/plugin-react-swc'; +import path from 'path'; +import { defineConfig } from 'vite'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + publicDir: path.resolve(__dirname, 'public'), + resolve: { + alias: { + 'rmf-dashboard': path.resolve(__dirname, '../../src'), + }, + }, +}); diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index f3476cd79..080d3c260 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -11,6 +11,7 @@ "start": "concurrently npm:start:rmf-server npm:start:react", "start:airport": "RMF_DASHBOARD_DEMO_MAP=airport_terminal.launch.xml pnpm run start:sim", "start:clinic": "RMF_DASHBOARD_DEMO_MAP=clinic.launch.xml pnpm run start:sim", + "start:example": "vite -c examples/shared/vite.config.ts", "start:react": "pnpm run --filter {.}^... build && vite", "start:rmf": "node scripts/start-rmf.js", "start:rmf-server": "RMF_SERVER_USE_SIM_TIME=true npm --prefix ../api-server start", @@ -42,7 +43,6 @@ "date-fns": "^2.30.0", "debug": "^4.2.0", "eventemitter3": "^4.0.7", - "jsdom": "^24.1.1", "keycloak-js": "^25.0.2", "react": "^18.2.0", "react-components": "workspace:*", @@ -67,15 +67,15 @@ "@testing-library/dom": "^9.3.4", "@testing-library/react": "^14.2.2", "@testing-library/user-event": "^14.5.2", - "@types/history": "^5.0.0", "@vitejs/plugin-react-swc": "^3.7.0", "@vitest/coverage-v8": "^2.0.4", "api-server": "file:../api-server", "concurrently": "^8.2.2", "eslint": "^8.57.0", "history": "^5.3.0", + "jsdom": "^24.1.1", "storybook": "^8.0.5", - "typescript": "~5.4.3", + "typescript": "~5.5.4", "typescript-json-schema": "^0.64.0", "vite": "^5.3.5", "vitest": "^2.0.4" diff --git a/packages/dashboard/src/app-config.ts b/packages/dashboard/src/app-config.ts index ae232f7a3..311b1884b 100644 --- a/packages/dashboard/src/app-config.ts +++ b/packages/dashboard/src/app-config.ts @@ -1,61 +1,10 @@ -import React from 'react'; -import { getDefaultTaskDefinition, TaskDefinition } from 'react-components'; - import testConfig from '../app-config.json'; +import { AllowedTask } from './components'; +import { Resources } from './hooks/use-resources'; import { Authenticator } from './services/authenticator'; import { KeycloakAuthenticator } from './services/keycloak'; import { StubAuthenticator } from './services/stub-authenticator'; -export interface RobotResource { - /** - * Path to an image to be used as the robot's icon. - */ - icon?: string; - - /** - * Scale of the image to match the robot's dimensions. - */ - scale?: number; -} - -export interface FleetResource { - // TODO(koonpeng): configure robot resources based on robot model, this will require https://github.com/open-rmf/rmf_api_msgs/blob/main/rmf_api_msgs/schemas/robot_state.json to expose the robot model. - // [robotModel: string]: RobotResource; - default: RobotResource; -} - -export interface LogoResource { - /** - * Path to an image to be used as the logo on the app bar. - */ - header: string; -} - -export interface Resources { - fleets: { [fleetName: string]: FleetResource }; - logos: LogoResource; -} - -/** - * Configuration for task definitions. - */ -export interface TaskResource { - /** - * The task definition to configure. - */ - taskDefinitionId: 'patrol' | 'delivery' | 'compose-clean' | 'custom_compose'; - - /** - * Configure the display name for the task definition. - */ - displayName?: string; - - /** - * The color of the event when rendered on the task scheduler in the form of a CSS color string. - */ - scheduleEventColor?: string; -} - export interface StubAuthConfig {} export interface KeycloakAuthConfig { @@ -123,7 +72,7 @@ export interface RuntimeConfig { /** * List of allowed tasks that can be requested */ - allowedTasks: TaskResource[]; + allowedTasks: AllowedTask[]; /** * Url to a file to be played when an alert occurs on the dashboard. @@ -135,8 +84,6 @@ export interface RuntimeConfig { */ resources: { [theme: string]: Resources; default: Resources }; - // FIXME(koonpeng): this is used for very specific tasks, should be removed when mission - // system is implemented. cartIds: string[]; } @@ -168,7 +115,7 @@ export interface AppConfig extends RuntimeConfig { declare const APP_CONFIG: AppConfig; -const appConfig: AppConfig = (() => { +export const appConfig: AppConfig = (() => { if (import.meta.env.PROD) { return APP_CONFIG; } else { @@ -178,9 +125,7 @@ const appConfig: AppConfig = (() => { } })(); -export const AppConfigContext = React.createContext(appConfig); - -const authenticator: Authenticator = (() => { +export const authenticator: Authenticator = (() => { // must use if statement instead of switch for vite tree shaking to work if (APP_CONFIG_AUTH_PROVIDER === 'keycloak') { return new KeycloakAuthenticator( @@ -193,23 +138,3 @@ const authenticator: Authenticator = (() => { throw new Error('unknown auth provider'); } })(); - -export const AuthenticatorContext = React.createContext(authenticator); - -export const ResourcesContext = React.createContext(appConfig.resources.default); - -// FIXME(koonepng): This should be fully definition in app config when the dashboard actually -// supports configurating all the fields. -export const allowedTasks: TaskDefinition[] = appConfig.allowedTasks.map((taskResource) => { - const taskDefinition = getDefaultTaskDefinition(taskResource.taskDefinitionId); - if (!taskDefinition) { - throw Error(`Invalid tasks configured for dashboard: [${taskResource.taskDefinitionId}]`); - } - if (taskResource.displayName !== undefined) { - taskDefinition.taskDisplayName = taskResource.displayName; - } - if (taskResource.scheduleEventColor !== undefined) { - taskDefinition.scheduleEventColor = taskResource.scheduleEventColor; - } - return taskDefinition; -}); diff --git a/packages/dashboard/src/app.tsx b/packages/dashboard/src/app.tsx index 51d1983e4..bd80c7b9b 100644 --- a/packages/dashboard/src/app.tsx +++ b/packages/dashboard/src/app.tsx @@ -2,187 +2,101 @@ import '@fontsource/roboto/300.css'; import '@fontsource/roboto/400.css'; import '@fontsource/roboto/500.css'; import '@fontsource/roboto/700.css'; -import 'react-grid-layout/css/styles.css'; import './app.css'; -import React from 'react'; -import { Navigate, Route, Routes } from 'react-router-dom'; - -import { AppConfigContext, AuthenticatorContext, ResourcesContext } from './app-config'; -import { - AdminRouter, - AppBase, - AppEvents, - ManagedWorkspace, - PrivateRoute, - RmfApp, - SettingsContext, - Workspace, - WorkspaceState, -} from './components'; -import { LoginPage } from './pages'; -import { - AdminRoute, - CustomRoute1, - CustomRoute2, - DashboardRoute, - LoginRoute, - RobotsRoute, - TasksRoute, -} from './utils/url'; - -const dashboardWorkspace: WorkspaceState = { - layout: [{ i: 'map', x: 0, y: 0, w: 12, h: 12 }], - windows: [{ key: 'map', appName: 'Map' }], -}; - -const robotsWorkspace: WorkspaceState = { - layout: [ - { i: 'robots', x: 0, y: 0, w: 7, h: 4 }, - { i: 'map', x: 8, y: 0, w: 5, h: 8 }, - { i: 'doors', x: 0, y: 0, w: 7, h: 4 }, - { i: 'lifts', x: 0, y: 0, w: 7, h: 4 }, - { i: 'mutexGroups', x: 8, y: 0, w: 5, h: 4 }, - ], - windows: [ - { key: 'robots', appName: 'Robots' }, - { key: 'map', appName: 'Map' }, - { key: 'doors', appName: 'Doors' }, - { key: 'lifts', appName: 'Lifts' }, - { key: 'mutexGroups', appName: 'Mutex Groups' }, - ], -}; - -const tasksWorkspace: WorkspaceState = { - layout: [ - { i: 'tasks', x: 0, y: 0, w: 7, h: 12 }, - { i: 'map', x: 8, y: 0, w: 5, h: 12 }, - ], - windows: [ - { key: 'tasks', appName: 'Tasks' }, - { key: 'map', appName: 'Map' }, - ], -}; - -export default function App(): JSX.Element | null { - const authenticator = React.useContext(AuthenticatorContext); - const [authInitialized, setAuthInitialized] = React.useState(!!authenticator.user); - const [user, setUser] = React.useState(authenticator.user || null); - - React.useEffect(() => { - let cancel = false; - const onUserChanged = (newUser: string | null) => { - setUser(newUser); - AppEvents.justLoggedIn.next(true); - }; - authenticator.on('userChanged', onUserChanged); - (async () => { - await authenticator.init(); - if (cancel) { - return; - } - setUser(authenticator.user || null); - setAuthInitialized(true); - })(); - return () => { - cancel = true; - authenticator.off('userChanged', onUserChanged); - }; - }, [authenticator]); - - const appConfig = React.useContext(AppConfigContext); - const settings = React.useContext(SettingsContext); - const resources = appConfig.resources[settings.themeMode] || appConfig.resources.default; - - const loginRedirect = React.useMemo(() => , []); - - return authInitialized ? ( - - {user ? ( - - - - } /> - - - - - } - /> - - - - - } - /> - - - - - } - /> - - {APP_CONFIG_ENABLE_CUSTOM_TABS && ( - - - - } - /> - )} - - {APP_CONFIG_ENABLE_CUSTOM_TABS && ( - - - - } - /> - )} - - {APP_CONFIG_ENABLE_ADMIN_TAB && ( - - - - } - /> - )} - - - - ) : ( - - - authenticator.login(`${window.location.origin}${DashboardRoute}`) - } - /> - } - /> - } /> - - )} - - ) : null; +import { appConfig } from './app-config'; +import { InitialWindow, LocallyPersistentWorkspace, RmfDashboard, Workspace } from './components'; +import { MicroAppManifest } from './components/micro-app'; +import doorsApp from './micro-apps/doors-app'; +import liftsApp from './micro-apps/lifts-app'; +import createMapApp from './micro-apps/map-app'; +import robotMutexGroupsApp from './micro-apps/robot-mutex-groups-app'; +import robotsApp from './micro-apps/robots-app'; +import tasksApp from './micro-apps/tasks-app'; +import StubAuthenticator from './services/stub-authenticator'; + +const mapApp = createMapApp({ + attributionPrefix: appConfig.attributionPrefix, + defaultMapLevel: appConfig.defaultMapLevel, + defaultRobotZoom: appConfig.defaultRobotZoom, + defaultZoom: appConfig.defaultZoom, +}); + +const appRegistry: MicroAppManifest[] = [ + mapApp, + doorsApp, + liftsApp, + robotsApp, + robotMutexGroupsApp, + tasksApp, +]; + +const homeWorkspace: InitialWindow[] = [ + { + layout: { x: 0, y: 0, w: 12, h: 6 }, + microApp: mapApp, + }, +]; + +const robotsWorkspace: InitialWindow[] = [ + { + layout: { x: 0, y: 0, w: 7, h: 4 }, + microApp: robotsApp, + }, + { layout: { x: 8, y: 0, w: 5, h: 8 }, microApp: mapApp }, + { layout: { x: 0, y: 0, w: 7, h: 4 }, microApp: doorsApp }, + { layout: { x: 0, y: 0, w: 7, h: 4 }, microApp: liftsApp }, + { layout: { x: 8, y: 0, w: 5, h: 4 }, microApp: robotMutexGroupsApp }, +]; + +const tasksWorkspace: InitialWindow[] = [ + { layout: { x: 0, y: 0, w: 7, h: 8 }, microApp: tasksApp }, + { layout: { x: 8, y: 0, w: 5, h: 8 }, microApp: mapApp }, +]; + +export default function App() { + return ( + , + }, + { + name: 'Robots', + route: 'robots', + element: , + }, + { + name: 'Tasks', + route: 'tasks', + element: , + }, + { + name: 'Custom', + route: 'custom', + element: ( + + ), + }, + ]} + /> + ); } diff --git a/packages/dashboard/src/components/admin/add-permission-dialog.test.tsx b/packages/dashboard/src/components/admin/add-permission-dialog.test.tsx index 6d5448207..73702b052 100644 --- a/packages/dashboard/src/components/admin/add-permission-dialog.test.tsx +++ b/packages/dashboard/src/components/admin/add-permission-dialog.test.tsx @@ -1,10 +1,15 @@ -import { render } from '@testing-library/react'; +import { render as render_ } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; +import { AppControllerProvider } from '../../hooks/use-app-controller'; import { RmfAction } from '../../services/permissions'; +import { makeMockAppController } from '../../utils/test-utils.test'; import { AddPermissionDialog } from './add-permission-dialog'; +const render = (ui: React.ReactNode) => + render_({ui}); + describe('AddPermissionDialog', () => { it('calls savePermission when form is submitted', async () => { const savePermission = vi.fn(); diff --git a/packages/dashboard/src/components/admin/add-permission-dialog.tsx b/packages/dashboard/src/components/admin/add-permission-dialog.tsx index ff5ab4541..51061556e 100644 --- a/packages/dashboard/src/components/admin/add-permission-dialog.tsx +++ b/packages/dashboard/src/components/admin/add-permission-dialog.tsx @@ -3,8 +3,8 @@ import { Permission } from 'api-client'; import React from 'react'; import { ConfirmationDialog, useAsync } from 'react-components'; +import { useAppController } from '../../hooks/use-app-controller'; import { getActionText, RmfAction } from '../../services/permissions'; -import { AppControllerContext } from '../app-contexts'; export interface AddPermissionDialogProps { open: boolean; @@ -23,7 +23,7 @@ export function AddPermissionDialog({ const [actionError, setActionError] = React.useState(false); const [authzGrpError, setAuthzGrpError] = React.useState(false); const [saving, setSaving] = React.useState(false); - const { showAlert } = React.useContext(AppControllerContext); + const { showAlert } = useAppController(); const validateForm = () => { let error = false; diff --git a/packages/dashboard/src/components/admin/create-role-dialog.test.tsx b/packages/dashboard/src/components/admin/create-role-dialog.test.tsx index de606ddb9..8353642cc 100644 --- a/packages/dashboard/src/components/admin/create-role-dialog.test.tsx +++ b/packages/dashboard/src/components/admin/create-role-dialog.test.tsx @@ -1,9 +1,14 @@ -import { render } from '@testing-library/react'; +import { render as render_ } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; +import { AppControllerProvider } from '../../hooks/use-app-controller'; +import { makeMockAppController } from '../../utils/test-utils.test'; import { CreateRoleDialog } from './create-role-dialog'; +const render = (ui: React.ReactNode) => + render_({ui}); + describe('CreateRoleDialog', () => { it('calls createRole when form is submitted', async () => { const createRole = vi.fn(); diff --git a/packages/dashboard/src/components/admin/create-role-dialog.tsx b/packages/dashboard/src/components/admin/create-role-dialog.tsx index a921c633e..97b94e1fe 100644 --- a/packages/dashboard/src/components/admin/create-role-dialog.tsx +++ b/packages/dashboard/src/components/admin/create-role-dialog.tsx @@ -2,7 +2,7 @@ import { TextField } from '@mui/material'; import React from 'react'; import { ConfirmationDialog, useAsync } from 'react-components'; -import { AppControllerContext } from '../app-contexts'; +import { useAppController } from '../../hooks/use-app-controller'; export interface CreateRoleDialogProps { open: boolean; @@ -19,7 +19,7 @@ export function CreateRoleDialog({ const [creating, setCreating] = React.useState(false); const [role, setRole] = React.useState(''); const [roleError, setRoleError] = React.useState(false); - const { showAlert } = React.useContext(AppControllerContext); + const { showAlert } = useAppController(); const validateForm = () => { let error = false; diff --git a/packages/dashboard/src/components/admin/create-user-dialog.test.tsx b/packages/dashboard/src/components/admin/create-user-dialog.test.tsx index 603ac7085..e24070aff 100644 --- a/packages/dashboard/src/components/admin/create-user-dialog.test.tsx +++ b/packages/dashboard/src/components/admin/create-user-dialog.test.tsx @@ -1,9 +1,14 @@ -import { render } from '@testing-library/react'; +import { render as render_ } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; +import { AppControllerProvider } from '../../hooks/use-app-controller'; +import { makeMockAppController } from '../../utils/test-utils.test'; import { CreateUserDialog } from './create-user-dialog'; +const render = (ui: React.ReactNode) => + render_({ui}); + describe('CreateUserDialog', () => { it('calls createUser when form is submitted', async () => { const createUser = vi.fn(); diff --git a/packages/dashboard/src/components/admin/create-user-dialog.tsx b/packages/dashboard/src/components/admin/create-user-dialog.tsx index 5601ab6a4..f49b283b3 100644 --- a/packages/dashboard/src/components/admin/create-user-dialog.tsx +++ b/packages/dashboard/src/components/admin/create-user-dialog.tsx @@ -2,7 +2,7 @@ import { TextField } from '@mui/material'; import React from 'react'; import { ConfirmationDialog, useAsync } from 'react-components'; -import { AppControllerContext } from '../app-contexts'; +import { useAppController } from '../../hooks/use-app-controller'; export interface CreateUserDialogProps { open: boolean; @@ -19,7 +19,7 @@ export function CreateUserDialog({ const [creating, setCreating] = React.useState(false); const [username, setUsername] = React.useState(''); const [usernameError, setUsernameError] = React.useState(false); - const { showAlert } = React.useContext(AppControllerContext); + const { showAlert } = useAppController(); const validateForm = () => { let error = false; diff --git a/packages/dashboard/src/components/admin/drawer.tsx b/packages/dashboard/src/components/admin/drawer.tsx index 2d87ed573..38cbb81f3 100644 --- a/packages/dashboard/src/components/admin/drawer.tsx +++ b/packages/dashboard/src/components/admin/drawer.tsx @@ -3,13 +3,12 @@ import AccountIcon from '@mui/icons-material/AccountCircle'; import SecurityIcon from '@mui/icons-material/Security'; import { Drawer, - DrawerProps, List, ListItem, ListItemIcon, ListItemText, - styled, Toolbar, + useTheme, } from '@mui/material'; import React from 'react'; import { RouteProps, useLocation, useNavigate } from 'react-router'; @@ -21,31 +20,6 @@ const drawerValuesRoutesMap: Record = { Roles: { path: '/roles' }, }; -const prefix = 'drawer'; -const classes = { - drawerPaper: `${prefix}-paper`, - drawerContainer: `${prefix}-container`, - itemIcon: `${prefix}-itemicon`, - activeItem: `${prefix}-active-item`, -}; -const StyledDrawer = styled((props: DrawerProps) => )(({ theme }) => ({ - [`& .${classes.drawerPaper}`]: { - backgroundColor: theme.palette.primary.dark, - color: theme.palette.getContrastText(theme.palette.primary.dark), - minWidth: 240, - width: '16%', - }, - [`& .${classes.drawerContainer}`]: { - overflow: 'auto', - }, - [`& .${classes.itemIcon}`]: { - color: theme.palette.getContrastText(theme.palette.primary.dark), - }, - [`& .${classes.activeItem}`]: { - backgroundColor: `${theme.palette.primary.light} !important`, - }, -})); - export function AdminDrawer(): JSX.Element { const location = useLocation(); const navigate = useNavigate(); @@ -56,36 +30,49 @@ export function AdminDrawer(): JSX.Element { return matched ? (matched[0] as AdminDrawerValues) : 'Users'; }, [location.pathname]); + const theme = useTheme(); const DrawerItem = React.useCallback( ({ Icon, text, route }: { Icon: SvgIconComponent; text: AdminDrawerValues; route: string }) => { return ( { navigate(route); }} > - + {text} ); }, - [activeItem, navigate], + [activeItem, navigate, theme], ); return ( - + -
+
- + ); } diff --git a/packages/dashboard/src/components/admin/manage-roles-dialog.test.tsx b/packages/dashboard/src/components/admin/manage-roles-dialog.test.tsx index c99567e81..ded944bc6 100644 --- a/packages/dashboard/src/components/admin/manage-roles-dialog.test.tsx +++ b/packages/dashboard/src/components/admin/manage-roles-dialog.test.tsx @@ -1,9 +1,14 @@ -import { render, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import { render as render_, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; +import { AppControllerProvider } from '../../hooks/use-app-controller'; +import { makeMockAppController } from '../../utils/test-utils.test'; import { ManageRolesCard, ManageRolesDialog } from './manage-roles-dialog'; +const render = (ui: React.ReactNode) => + render_({ui}); + describe('ManageRolesCard', () => { it('shows dialog when add/remove is clicked', async () => { const root = render( diff --git a/packages/dashboard/src/components/admin/manage-roles-dialog.tsx b/packages/dashboard/src/components/admin/manage-roles-dialog.tsx index 6de457a5b..984b4e0f5 100644 --- a/packages/dashboard/src/components/admin/manage-roles-dialog.tsx +++ b/packages/dashboard/src/components/admin/manage-roles-dialog.tsx @@ -18,7 +18,7 @@ import { import React from 'react'; import { Loading, TransferList, useAsync } from 'react-components'; -import { AppControllerContext } from '../app-contexts'; +import { useAppController } from '../../hooks/use-app-controller'; const prefix = 'manage-roles-dialog'; const classes = { @@ -63,7 +63,7 @@ export function ManageRolesDialog({ const [assignedRoles, setAssignedRoles] = React.useState([]); const [loading, setLoading] = React.useState(false); const [saving, setSaving] = React.useState(false); - const { showAlert } = React.useContext(AppControllerContext); + const { showAlert } = useAppController(); React.useEffect(() => { if (!open || !getAllRoles) return; diff --git a/packages/dashboard/src/components/admin/permissions-card.test.tsx b/packages/dashboard/src/components/admin/permissions-card.test.tsx index af1b32e67..6bd4ccc5f 100644 --- a/packages/dashboard/src/components/admin/permissions-card.test.tsx +++ b/packages/dashboard/src/components/admin/permissions-card.test.tsx @@ -1,10 +1,15 @@ -import { fireEvent, render, waitFor } from '@testing-library/react'; +import { fireEvent, render as render_, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { AppControllerProvider } from '../../hooks/use-app-controller'; import { getActionText, RmfAction } from '../../services/permissions'; +import { makeMockAppController } from '../../utils/test-utils.test'; import { PermissionsCard } from './permissions-card'; +const render = (ui: React.ReactNode) => + render_({ui}); + // TODO(AA): To remove after // https://github.com/testing-library/react-testing-library/issues/1216 // has been resolved. diff --git a/packages/dashboard/src/components/admin/permissions-card.tsx b/packages/dashboard/src/components/admin/permissions-card.tsx index c6265007b..a996143ba 100644 --- a/packages/dashboard/src/components/admin/permissions-card.tsx +++ b/packages/dashboard/src/components/admin/permissions-card.tsx @@ -19,8 +19,8 @@ import { Permission } from 'api-client'; import React from 'react'; import { Loading, useAsync } from 'react-components'; +import { useAppController } from '../../hooks/use-app-controller'; import { getActionText } from '../../services/permissions'; -import { AppControllerContext } from '../app-contexts'; import { AddPermissionDialog, AddPermissionDialogProps } from './add-permission-dialog'; const prefix = 'permissions-card'; @@ -60,7 +60,7 @@ export function PermissionsCard({ const [loading, setLoading] = React.useState(false); const [permissions, setPermissions] = React.useState([]); const [openDialog, setOpenDialog] = React.useState(false); - const { showAlert } = React.useContext(AppControllerContext); + const { showAlert } = useAppController(); const refresh = React.useCallback(async () => { if (!getPermissions) return; diff --git a/packages/dashboard/src/components/admin/role-list-card.test.tsx b/packages/dashboard/src/components/admin/role-list-card.test.tsx index 90099709b..5dd417803 100644 --- a/packages/dashboard/src/components/admin/role-list-card.test.tsx +++ b/packages/dashboard/src/components/admin/role-list-card.test.tsx @@ -1,9 +1,14 @@ -import { render, waitFor } from '@testing-library/react'; +import { render as render_, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; +import { AppControllerProvider } from '../../hooks/use-app-controller'; +import { makeMockAppController } from '../../utils/test-utils.test'; import { RoleListCard } from './role-list-card'; +const render = (ui: React.ReactNode) => + render_({ui}); + describe('Role List', () => { it('renders list of roles', async () => { const root = render( ['role1', 'role2']} />); diff --git a/packages/dashboard/src/components/admin/role-list-card.tsx b/packages/dashboard/src/components/admin/role-list-card.tsx index 25e66fa0a..21a282082 100644 --- a/packages/dashboard/src/components/admin/role-list-card.tsx +++ b/packages/dashboard/src/components/admin/role-list-card.tsx @@ -20,7 +20,7 @@ import { Permission } from 'api-client'; import React from 'react'; import { ConfirmationDialog, Loading, useAsync } from 'react-components'; -import { AppControllerContext } from '../app-contexts'; +import { useAppController } from '../../hooks/use-app-controller'; import { CreateRoleDialog, CreateRoleDialogProps } from './create-role-dialog'; import { PermissionsCard, PermissionsCardProps } from './permissions-card'; @@ -105,7 +105,7 @@ export function RoleListCard({ const [openDialog, setOpenDialog] = React.useState(false); const [selectedDeleteRole, setSelectedDeleteRole] = React.useState(null); const [deleting, setDeleting] = React.useState(false); - const { showAlert } = React.useContext(AppControllerContext); + const { showAlert } = useAppController(); const refresh = React.useCallback(async () => { if (!getRoles) return; diff --git a/packages/dashboard/src/components/admin/role-list-page.tsx b/packages/dashboard/src/components/admin/role-list-page.tsx index 3334090c8..b4ba154ac 100644 --- a/packages/dashboard/src/components/admin/role-list-page.tsx +++ b/packages/dashboard/src/components/admin/role-list-page.tsx @@ -1,13 +1,11 @@ -import React from 'react'; - +import { useRmfApi } from '../../hooks/use-rmf-api'; import { getApiErrorMessage } from '../../utils/api'; -import { RmfAppContext } from '../rmf-app'; import { adminPageClasses, AdminPageContainer } from './page-css'; import { RoleListCard } from './role-list-card'; export function RoleListPage(): JSX.Element | null { - const rmfIngress = React.useContext(RmfAppContext); - const adminApi = rmfIngress?.adminApi; + const rmfApi = useRmfApi(); + const adminApi = rmfApi.adminApi; if (!adminApi) return null; diff --git a/packages/dashboard/src/components/admin/router.tsx b/packages/dashboard/src/components/admin/router.tsx index f749f3242..8bfb538f8 100644 --- a/packages/dashboard/src/components/admin/router.tsx +++ b/packages/dashboard/src/components/admin/router.tsx @@ -1,22 +1,14 @@ -import { Navigate, Outlet, Route, Routes } from 'react-router-dom'; +import { Route } from 'react-router-dom'; import { AdminDrawer } from './drawer'; import { RoleListPage } from './role-list-page'; import { UserListPage } from './user-list-page'; import { UserProfilePage } from './user-profile-page'; -export function AdminRouter(): JSX.Element { - return ( - <> - - - } /> - } /> - } /> - } /> - } /> - - - - ); -} +export const adminRoutes = ( + }> + } /> + } /> + } /> + +); diff --git a/packages/dashboard/src/components/admin/user-list-card.test.tsx b/packages/dashboard/src/components/admin/user-list-card.test.tsx index 2d5c9c6e1..06f3865d5 100644 --- a/packages/dashboard/src/components/admin/user-list-card.test.tsx +++ b/packages/dashboard/src/components/admin/user-list-card.test.tsx @@ -1,10 +1,15 @@ -import { render, waitFor } from '@testing-library/react'; +import { render as render_, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router'; import { describe, expect, it, vi } from 'vitest'; +import { AppControllerProvider } from '../../hooks/use-app-controller'; +import { makeMockAppController } from '../../utils/test-utils.test'; import { UserListCard } from './user-list-card'; +const render = (ui: React.ReactNode) => + render_({ui}); + describe('UserListCard', () => { it('opens delete dialog when button is clicked', async () => { const root = render( diff --git a/packages/dashboard/src/components/admin/user-list-card.tsx b/packages/dashboard/src/components/admin/user-list-card.tsx index cf18b62bc..bca044b52 100644 --- a/packages/dashboard/src/components/admin/user-list-card.tsx +++ b/packages/dashboard/src/components/admin/user-list-card.tsx @@ -24,7 +24,7 @@ import React from 'react'; import { ConfirmationDialog, Loading, useAsync } from 'react-components'; import { useNavigate } from 'react-router'; -import { AppControllerContext } from '../app-contexts'; +import { useAppController } from '../../hooks/use-app-controller'; import { CreateUserDialog, CreateUserDialogProps } from './create-user-dialog'; const ItemsPerPage = 20; @@ -69,7 +69,7 @@ export function UserListCard({ const [openDeleteDialog, setOpenDeleteDialog] = React.useState(false); const [deleting, setDeleting] = React.useState(false); const [openCreateDialog, setOpenCreateDialog] = React.useState(false); - const { showAlert } = React.useContext(AppControllerContext); + const { showAlert } = useAppController(); const refresh = React.useCallback(async () => { if (!searchUsers) return; @@ -159,7 +159,6 @@ export function UserListCard({ {users.length === 0 && searching &&
} (undefined); const [notFound, setNotFound] = React.useState(false); diff --git a/packages/dashboard/src/components/admin/user-profile.test.tsx b/packages/dashboard/src/components/admin/user-profile.test.tsx index 9c4a5919e..2c6e4ef1b 100644 --- a/packages/dashboard/src/components/admin/user-profile.test.tsx +++ b/packages/dashboard/src/components/admin/user-profile.test.tsx @@ -1,9 +1,15 @@ -import { render, waitFor } from '@testing-library/react'; +import { render as render_, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import React from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { AppControllerProvider } from '../../hooks/use-app-controller'; +import { makeMockAppController } from '../../utils/test-utils.test'; import { UserProfileCard } from './user-profile'; +const render = (ui: React.ReactNode) => + render_({ui}); + describe('UserProfileCard', () => { it('renders username', () => { const root = render( diff --git a/packages/dashboard/src/components/admin/user-profile.tsx b/packages/dashboard/src/components/admin/user-profile.tsx index 8d5dba9df..510788884 100644 --- a/packages/dashboard/src/components/admin/user-profile.tsx +++ b/packages/dashboard/src/components/admin/user-profile.tsx @@ -15,7 +15,7 @@ import { User } from 'api-client'; import React from 'react'; import { useAsync } from 'react-components'; -import { AppControllerContext } from '../app-contexts'; +import { useAppController } from '../../hooks/use-app-controller'; const classes = { avatar: 'user-profile-action', @@ -36,7 +36,7 @@ export function UserProfileCard({ user, makeAdmin }: UserProfileCardProps): JSX. const safeAsync = useAsync(); const [anchorEl, setAnchorEl] = React.useState(null); const [disableAdminCheckbox, setDisableAdminCheckbox] = React.useState(false); - const { showAlert } = React.useContext(AppControllerContext); + const { showAlert } = useAppController(); return ( @@ -46,7 +46,10 @@ export function UserProfileCard({ user, makeAdmin }: UserProfileCardProps): JSX. subheader={user.is_admin ? 'Admin' : 'User'} avatar={} action={ - setAnchorEl(ev.currentTarget)} aria-label="more actions"> + setAnchorEl(ev.currentTarget as HTMLElement)} + aria-label="more actions" + > } diff --git a/packages/dashboard/src/components/alert-manager.tsx b/packages/dashboard/src/components/alert-manager.tsx index 1a37c09e8..a6b930890 100644 --- a/packages/dashboard/src/components/alert-manager.tsx +++ b/packages/dashboard/src/components/alert-manager.tsx @@ -20,10 +20,9 @@ import React from 'react'; import { base } from 'react-components'; import { Subscription } from 'rxjs'; -import { AppConfigContext } from '../app-config'; -import { AppControllerContext } from './app-contexts'; +import { useAppController } from '../hooks/use-app-controller'; +import { useRmfApi } from '../hooks/use-rmf-api'; import { AppEvents } from './app-events'; -import { RmfAppContext } from './rmf-app'; import { TaskCancelButton } from './tasks/task-cancellation'; interface AlertDialogProps { @@ -34,19 +33,15 @@ interface AlertDialogProps { const AlertDialog = React.memo((props: AlertDialogProps) => { const { alertRequest, onDismiss } = props; const [isOpen, setIsOpen] = React.useState(true); - const { showAlert } = React.useContext(AppControllerContext); - const rmf = React.useContext(RmfAppContext); + const { showAlert } = useAppController(); + const rmfApi = useRmfApi(); const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); const [additionalAlertMessage, setAdditionalAlertMessage] = React.useState(null); const respondToAlert = async (alert_id: string, response: string) => { - if (!rmf) { - return; - } - try { const resp = ( - await rmf.alertsApi.respondToAlertAlertsRequestAlertIdRespondPost(alert_id, response) + await rmfApi.alertsApi.respondToAlertAlertsRequestAlertIdRespondPost(alert_id, response) ).data; console.log( `Alert [${alertRequest.id}]: responded with [${resp.response}] at ${resp.unix_millis_response_time}`, @@ -90,9 +85,6 @@ const AlertDialog = React.memo((props: AlertDialogProps) => { if (alertRequest.tier === ApiServerModelsAlertsAlertRequestTier.Info || !alertRequest.task_id) { return; } - if (!rmf) { - return; - } (async () => { if (!alertRequest.task_id) { @@ -102,7 +94,7 @@ const AlertDialog = React.memo((props: AlertDialogProps) => { let logs: TaskEventLog | null = null; try { logs = ( - await rmf.tasksApi.getTaskLogTasksTaskIdLogGet( + await rmfApi.tasksApi.getTaskLogTasksTaskIdLogGet( alertRequest.task_id, `0,${Number.MAX_SAFE_INTEGER}`, ) @@ -124,7 +116,7 @@ const AlertDialog = React.memo((props: AlertDialogProps) => { setAdditionalAlertMessage(consolidatedErrorMessages); } })(); - }, [rmf, alertRequest.id, alertRequest.task_id, alertRequest.tier]); + }, [rmfApi, alertRequest.id, alertRequest.task_id, alertRequest.tier]); const theme = useTheme(); @@ -254,25 +246,20 @@ const AlertDialog = React.memo((props: AlertDialogProps) => { ); }); -export const AlertManager = React.memo(() => { - const rmf = React.useContext(RmfAppContext); +export interface AlertManagerProps { + alertAudioPath?: string; +} + +export const AlertManager = React.memo(({ alertAudioPath }: AlertManagerProps) => { + const rmfApi = useRmfApi(); const [openAlerts, setOpenAlerts] = React.useState>({}); - const appConfig = React.useContext(AppConfigContext); const alertAudio: HTMLAudioElement | undefined = React.useMemo( - () => (appConfig.alertAudioPath ? new Audio(appConfig.alertAudioPath) : undefined), - [appConfig.alertAudioPath], + () => (alertAudioPath ? new Audio(alertAudioPath) : undefined), + [alertAudioPath], ); React.useEffect(() => { - if (!rmf) { - return; - } - const pushAlertsToBeDisplayed = async (alertRequest: AlertRequest) => { - if (!rmf) { - console.error('Alerts API not available'); - return; - } if (!alertRequest.display) { setOpenAlerts((prev) => { const filteredAlerts = Object.fromEntries( @@ -285,7 +272,7 @@ export const AlertManager = React.memo(() => { try { const resp = ( - await rmf.alertsApi.getAlertResponseAlertsRequestAlertIdResponseGet(alertRequest.id) + await rmfApi.alertsApi.getAlertResponseAlertsRequestAlertIdResponseGet(alertRequest.id) ).data; console.log( `Alert [${alertRequest.id}]: was responded with [${resp.response}] at ${resp.unix_millis_response_time}`, @@ -312,7 +299,7 @@ export const AlertManager = React.memo(() => { const subs: Subscription[] = []; subs.push( - rmf.alertRequestsObsStore.subscribe((alertRequest) => { + rmfApi.alertRequestsObsStore.subscribe((alertRequest) => { if (!alertRequest.display) { setOpenAlerts((prev) => { const filteredAlerts = Object.fromEntries( @@ -327,7 +314,7 @@ export const AlertManager = React.memo(() => { ); subs.push( - rmf.alertResponsesObsStore.subscribe((alertResponse) => { + rmfApi.alertResponsesObsStore.subscribe((alertResponse) => { setOpenAlerts((prev) => { return Object.fromEntries( Object.entries(prev).filter(([key]) => key !== alertResponse.id), @@ -350,7 +337,7 @@ export const AlertManager = React.memo(() => { sub.unsubscribe(); } }; - }, [rmf, alertAudio]); + }, [rmfApi, alertAudio]); const removeOpenAlert = (id: string) => { const filteredAlerts = Object.fromEntries( diff --git a/packages/dashboard/src/components/app-base.tsx b/packages/dashboard/src/components/app-base.tsx deleted file mode 100644 index 76ff446cc..000000000 --- a/packages/dashboard/src/components/app-base.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { - Alert, - AlertProps, - Backdrop, - CircularProgress, - createTheme, - CssBaseline, - Grid, - Snackbar, -} from '@mui/material'; -import { ThemeProvider } from '@mui/material/styles'; -import React from 'react'; - -import { loadSettings, saveSettings, Settings } from '../services/settings'; -import { AlertManager } from './alert-manager'; -import { AppController, AppControllerContext, SettingsContext } from './app-contexts'; -import { AppEvents } from './app-events'; -import AppBar from './appbar'; -import { DeliveryAlertStore } from './delivery-alert-store'; - -const DefaultAlertDuration = 2000; -const defaultTheme = createTheme({ - typography: { - fontSize: 16, - }, -}); - -/** - * Contains various components that are essential to the app and provides contexts to control them. - * Components include: - * - * - Settings - * - Alerts - * - * Also provides `AppControllerContext` to allow children components to control them. - */ -export function AppBase({ children }: React.PropsWithChildren<{}>): JSX.Element | null { - const [settings, setSettings] = React.useState(() => loadSettings()); - const [showAlert, setShowAlert] = React.useState(false); - const [alertSeverity, setAlertSeverity] = React.useState('error'); - const [alertMessage, setAlertMessage] = React.useState(''); - const [alertDuration, setAlertDuration] = React.useState(DefaultAlertDuration); - const [extraAppbarIcons, setExtraAppbarIcons] = React.useState(null); - const [openLoadingBackdrop, setOpenLoadingBackdrop] = React.useState(false); - - const theme = React.useMemo(() => { - switch (settings.themeMode) { - default: - return defaultTheme; - } - }, [settings.themeMode]); - - const updateSettings = React.useCallback((newSettings: Settings) => { - saveSettings(newSettings); - setSettings(newSettings); - }, []); - - const appController = React.useMemo( - () => ({ - updateSettings, - showAlert: (severity, message, autoHideDuration) => { - setAlertSeverity(severity); - setAlertMessage(message); - setShowAlert(true); - setAlertDuration(autoHideDuration || DefaultAlertDuration); - }, - setExtraAppbarIcons, - }), - [updateSettings], - ); - - React.useEffect(() => { - const sub = AppEvents.loadingBackdrop.subscribe((value) => { - setOpenLoadingBackdrop(value); - }); - return () => sub.unsubscribe(); - }, []); - - return ( - - - {openLoadingBackdrop && ( - theme.zIndex.drawer + 1 }} - open={openLoadingBackdrop} - > - - - )} - - - - - - - {children} - {/* TODO: Support stacking of alerts */} - setShowAlert(false)} - autoHideDuration={alertDuration} - > - setShowAlert(false)} - severity={alertSeverity} - sx={{ width: '100%' }} - > - {alertMessage} - - - - - - - ); -} diff --git a/packages/dashboard/src/components/app-contexts.tsx b/packages/dashboard/src/components/app-contexts.tsx deleted file mode 100644 index c48a7df99..000000000 --- a/packages/dashboard/src/components/app-contexts.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { AlertProps } from '@mui/material'; -import React from 'react'; - -import { defaultSettings, Settings } from '../services/settings'; - -export const SettingsContext = React.createContext(defaultSettings()); - -export interface AppController { - updateSettings: (settings: Settings) => void; - showAlert: (severity: AlertProps['severity'], message: string, autoHideDuration?: number) => void; - setExtraAppbarIcons: (node: React.ReactNode) => void; -} - -export interface Tooltips { - showTooltips: boolean; -} - -export const TooltipsContext = React.createContext({ - showTooltips: true, -}); - -export const AppControllerContext = React.createContext({ - updateSettings: () => {}, - showAlert: () => {}, - setExtraAppbarIcons: () => {}, -}); diff --git a/packages/dashboard/src/components/app-registry.ts b/packages/dashboard/src/components/app-registry.ts deleted file mode 100644 index 172895efb..000000000 --- a/packages/dashboard/src/components/app-registry.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { BeaconsApp } from './beacons-app'; -import { DoorsApp } from './doors-app'; -import { LiftsApp } from './lifts-app'; -import { MapApp } from './map-app'; -import { RobotInfoApp } from './robots/robot-info-app'; -import { MutexGroupsApp } from './robots/robot-mutex-group-app'; -import { RobotsApp } from './robots/robots-app'; -import { TaskDetailsApp } from './tasks/task-details-app'; -import { TaskLogsApp } from './tasks/task-logs-app'; -import { TasksApp } from './tasks/tasks-app'; - -export const AppRegistry = { - Beacons: BeaconsApp, - Doors: DoorsApp, - Lifts: LiftsApp, - Map: MapApp, - 'Mutex Groups': MutexGroupsApp, - Tasks: TasksApp, - 'Task Details': TaskDetailsApp, - 'Task Logs': TaskLogsApp, - Robots: RobotsApp, - 'Robot Info': RobotInfoApp, -}; diff --git a/packages/dashboard/src/components/appbar.test.tsx b/packages/dashboard/src/components/appbar.test.tsx index 6f4e546d6..ab5212a91 100644 --- a/packages/dashboard/src/components/appbar.test.tsx +++ b/packages/dashboard/src/components/appbar.test.tsx @@ -1,57 +1,58 @@ +import { Tab } from '@mui/material'; import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -import { AuthenticatorContext, Resources, ResourcesContext } from '../app-config'; -import { UserProfile } from '../services/authenticator'; +import { AuthenticatorProvider } from '../hooks/use-authenticator'; +import { RmfApiProvider } from '../hooks/use-rmf-api'; +import { RmfApi } from '../services/rmf-api'; import { StubAuthenticator } from '../services/stub-authenticator'; -import { render } from '../utils/test-utils.test'; -import { AppController, AppControllerContext } from './app-contexts'; +import { MockRmfApi, render, TestProviders } from '../utils/test-utils.test'; import AppBar from './appbar'; -import { UserProfileContext } from './user-profile-provider'; - -function makeMockAppController(): AppController { - return { - updateSettings: vi.fn(), - showAlert: vi.fn(), - setExtraAppbarIcons: vi.fn(), - }; -} describe('AppBar', () => { - let appController: AppController; const Base = (props: React.PropsWithChildren<{}>) => { + const rmfApi = React.useMemo(() => { + const mockRmfApi = new MockRmfApi(); + // mock out some api calls so they never resolves + mockRmfApi.tasksApi.getFavoritesTasksFavoriteTasksGet = () => new Promise(() => {}); + mockRmfApi.alertsApi.getUnrespondedAlertsAlertsUnrespondedRequestsGet = () => + new Promise(() => {}); + mockRmfApi.buildingApi.getPreviousFireAlarmTriggerBuildingMapPreviousFireAlarmTriggerGet = + () => new Promise(() => {}); + return mockRmfApi; + }, []); return ( - - {props.children} - + + {props.children} + ); }; - beforeEach(() => { - appController = makeMockAppController(); - }); - it('renders with navigation bar', () => { const root = render( - + ]} + tabValue="test" + helpLink="" + reportIssueLink="" + /> , ); expect(root.getAllByRole('tablist').length > 0).toBeTruthy(); }); it('user button is shown when there is an authenticated user', () => { - const profile: UserProfile = { - user: { username: 'test', is_admin: false, roles: [] }, - permissions: [], - }; const root = render( - - - + ]} + tabValue="test" + helpLink="" + reportIssueLink="" + /> , ); expect(root.getByLabelText('user-btn')).toBeTruthy(); @@ -60,18 +61,17 @@ describe('AppBar', () => { it('logout is triggered when logout button is clicked', async () => { const authenticator = new StubAuthenticator('test'); const spy = vi.spyOn(authenticator, 'logout').mockImplementation(() => undefined as any); - const profile: UserProfile = { - user: { username: 'test', is_admin: false, roles: [] }, - permissions: [], - }; const root = render( - - - - - - - , + + + ]} + tabValue="test" + helpLink="" + reportIssueLink="" + /> + + , ); userEvent.click(root.getByLabelText('user-btn')); await expect(waitFor(() => root.getByText('Logout'))).resolves.not.toThrow(); @@ -80,19 +80,15 @@ describe('AppBar', () => { }); it('uses headerLogo from logo resources manager', async () => { - const resources: Resources = { - fleets: {}, - logos: { - header: '/test-logo.png', - }, - }; - const root = render( - - - - - , + + ]} + tabValue="test" + helpLink="" + reportIssueLink="" + /> + , ); await expect( waitFor(() => { diff --git a/packages/dashboard/src/components/appbar.tsx b/packages/dashboard/src/components/appbar.tsx index 610381eb4..534b8a3c5 100644 --- a/packages/dashboard/src/components/appbar.tsx +++ b/packages/dashboard/src/components/appbar.tsx @@ -1,17 +1,19 @@ import { AccountCircle, - AddOutlined, AdminPanelSettings, Help, LocalFireDepartment, Logout, Notifications, Report, - // Settings, Warning as Issue, } from '@mui/icons-material'; +import Brightness4Icon from '@mui/icons-material/Brightness4'; +import Brightness7Icon from '@mui/icons-material/Brightness7'; import { + AppBar as MuiAppBar, Badge, + Box, Button, CardContent, Chip, @@ -22,6 +24,7 @@ import { FormControl, FormControlLabel, FormLabel, + Grid, IconButton, ListItemIcon, ListItemText, @@ -30,70 +33,41 @@ import { Radio, RadioGroup, Stack, + Tab, + Tabs, Toolbar, Tooltip, Typography, - useMediaQuery, + useTheme, } from '@mui/material'; -import { styled } from '@mui/system'; import { AlertRequest, FireAlarmTriggerState, TaskFavorite } from 'api-client'; import { formatDistance } from 'date-fns'; import React from 'react'; -import { - AppBarTab, - ConfirmationDialog, - CreateTaskForm, - CreateTaskFormProps, - HeaderBar, - LogoButton, - NavigationBar, -} from 'react-components'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { ConfirmationDialog, CreateTaskForm, CreateTaskFormProps } from 'react-components'; import { Subscription } from 'rxjs'; -import { - allowedTasks, - AppConfigContext, - AuthenticatorContext, - ResourcesContext, -} from '../app-config'; -import { useCreateTaskFormData } from '../hooks/useCreateTaskForm'; -import useGetUsername from '../hooks/useFetchUser'; -import { - AdminRoute, - CustomRoute1, - CustomRoute2, - DashboardRoute, - RobotsRoute, - TasksRoute, -} from '../utils/url'; -import { AppControllerContext, SettingsContext } from './app-contexts'; +import { useAppController } from '../hooks/use-app-controller'; +import { useAuthenticator } from '../hooks/use-authenticator'; +import { useCreateTaskFormData } from '../hooks/use-create-task-form'; +import { useResources } from '../hooks/use-resources'; +import { useRmfApi } from '../hooks/use-rmf-api'; +import { useSettings } from '../hooks/use-settings'; +import { useTaskRegistry } from '../hooks/use-task-registry'; +import { useUserProfile } from '../hooks/use-user-profile'; import { AppEvents } from './app-events'; -import { RmfAppContext } from './rmf-app'; import { toApiSchedule } from './tasks/utils'; -import { UserProfileContext } from './user-profile-provider'; - -const StyledIconButton = styled(IconButton)(({ theme }) => ({ - fontSize: theme.spacing(4), // spacing = 8 -})); +import { DashboardThemes } from './theme'; -export type TabValue = 'infrastructure' | 'robots' | 'tasks'; +export const APP_BAR_HEIGHT = '3.5rem'; -const locationToTabValue = (pathname: string): TabValue | undefined => { - const routes: { prefix: string; tabValue: TabValue }[] = [ - { prefix: RobotsRoute, tabValue: 'robots' }, - { prefix: TasksRoute, tabValue: 'tasks' }, - { prefix: DashboardRoute, tabValue: 'infrastructure' }, - ]; - - // `DashboardRoute` being the root, it is a prefix to all routes, so we need to check exactly. - const matchingRoute = routes.find((route) => pathname.startsWith(route.prefix)); - return matchingRoute?.tabValue; -}; +const ToolbarIconButton = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>((props, ref) => ); function AppSettings() { - const settings = React.useContext(SettingsContext); - const appController = React.useContext(AppControllerContext); + const settings = useSettings(); + const appController = useAppController(); return ( Theme @@ -111,555 +85,494 @@ function AppSettings() { } export interface AppBarProps { + tabs: React.ReactElement>[]; + tabValue: string; + themes?: DashboardThemes; + helpLink: string; + reportIssueLink: string; extraToolbarItems?: React.ReactNode; - - // TODO: change the alarm status to required when we have an alarm - // service working properly in the backend - alarmState?: boolean | null; } -export const AppBar = React.memo(({ extraToolbarItems }: AppBarProps): React.ReactElement => { - const appConfig = React.useContext(AppConfigContext); - const authenticator = React.useContext(AuthenticatorContext); - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); - - const [largerResolution, setLargerResolution] = React.useState(false); - - const StyledAppBarTab = styled(AppBarTab)(({ theme }) => ({ - fontSize: theme.spacing(largerResolution ? 2 : 4), - })); - - const StyledAppBarButton = styled(Button)(({ theme }) => ({ - fontSize: theme.spacing(largerResolution ? 1.5 : 4), // spacing = 8 - paddingTop: 0, - paddingBottom: 0, - })); - - React.useEffect(() => { - setLargerResolution(isScreenHeightLessThan800); - }, [isScreenHeightLessThan800]); - - const rmf = React.useContext(RmfAppContext); - const resources = React.useContext(ResourcesContext); - const { showAlert } = React.useContext(AppControllerContext); - const navigate = useNavigate(); - const location = useLocation(); - const tabValue = React.useMemo(() => locationToTabValue(location.pathname), [location]); - const [anchorEl, setAnchorEl] = React.useState(null); - const profile = React.useContext(UserProfileContext); - const [settingsAnchor, setSettingsAnchor] = React.useState(null); - const [openCreateTaskForm, setOpenCreateTaskForm] = React.useState(false); - const [favoritesTasks, setFavoritesTasks] = React.useState([]); - const [alertListAnchor, setAlertListAnchor] = React.useState(null); - const [unacknowledgedAlertList, setUnacknowledgedAlertList] = React.useState([]); - const [openAdminActionsDialog, setOpenAdminActionsDialog] = React.useState(false); - const [openFireAlarmTriggerResetDialog, setOpenFireAlarmTriggerResetDialog] = - React.useState(false); - const [fireAlarmPreviousTrigger, setFireAlarmPreviousTrigger] = React.useState< - FireAlarmTriggerState | undefined - >(undefined); - - const { waypointNames, pickupPoints, dropoffPoints, cleaningZoneNames } = - useCreateTaskFormData(rmf); - const username = useGetUsername(rmf); - - async function handleLogout(): Promise { - try { - await authenticator.logout(); - } catch (e) { - console.error(`error logging out: ${(e as Error).message}`); - } - } - - React.useEffect(() => { - if (!rmf) { - return; - } - - const updateUnrespondedAlerts = async () => { - const { data: alerts } = - await rmf.alertsApi.getUnrespondedAlertsAlertsUnrespondedRequestsGet(); - // alert.display is checked to verify that the dashboard should display it - // in the first place - const alertsToBeDisplayed = alerts.filter((alert) => alert.display); - setUnacknowledgedAlertList(alertsToBeDisplayed.reverse()); - }; - - const subs: Subscription[] = []; - subs.push(rmf.alertRequestsObsStore.subscribe(updateUnrespondedAlerts)); - subs.push(rmf.alertResponsesObsStore.subscribe(updateUnrespondedAlerts)); - - // Get the initial number of unacknowledged alerts - updateUnrespondedAlerts(); - return () => subs.forEach((s) => s.unsubscribe()); - }, [rmf]); - - const submitTasks = React.useCallback['submitTasks']>( - async (taskRequests, schedule) => { - if (!rmf) { - throw new Error('tasks api not available'); - } - if (!schedule) { - await Promise.all( - taskRequests.map((request) => { - console.debug('submitTask:'); - console.debug(request); - return rmf.tasksApi.postDispatchTaskTasksDispatchTaskPost({ - type: 'dispatch_task_request', - request, - }); - }), - ); - } else { - const scheduleRequests = taskRequests.map((req) => { - console.debug('schedule task:'); - console.debug(req); - console.debug(schedule); - return toApiSchedule(req, schedule); - }); - await Promise.all( - scheduleRequests.map((req) => rmf.tasksApi.postScheduledTaskScheduledTasksPost(req)), - ); +export const AppBar = React.memo( + ({ tabs, tabValue, themes, helpLink, reportIssueLink, extraToolbarItems }: AppBarProps) => { + const authenticator = useAuthenticator(); + const rmfApi = useRmfApi(); + const resources = useResources(); + const taskRegistry = useTaskRegistry(); + const { showAlert } = useAppController(); + const [anchorEl, setAnchorEl] = React.useState(null); + const profile = useUserProfile(); + const [settingsAnchor, setSettingsAnchor] = React.useState(null); + const [openCreateTaskForm, setOpenCreateTaskForm] = React.useState(false); + const [favoritesTasks, setFavoritesTasks] = React.useState([]); + const [alertListAnchor, setAlertListAnchor] = React.useState(null); + const [unacknowledgedAlertList, setUnacknowledgedAlertList] = React.useState( + [], + ); + const [openAdminActionsDialog, setOpenAdminActionsDialog] = React.useState(false); + const [openFireAlarmTriggerResetDialog, setOpenFireAlarmTriggerResetDialog] = + React.useState(false); + const [fireAlarmPreviousTrigger, setFireAlarmPreviousTrigger] = React.useState< + FireAlarmTriggerState | undefined + >(undefined); + + const { waypointNames, pickupPoints, dropoffPoints, cleaningZoneNames } = + useCreateTaskFormData(rmfApi); + const username = profile.user.username; + + async function handleLogout(): Promise { + try { + await authenticator.logout(); + } catch (e) { + console.error(`error logging out: ${(e as Error).message}`); } - AppEvents.refreshTaskApp.next(); - }, - [rmf], - ); - - //#region 'Favorite Task' - React.useEffect(() => { - if (!rmf) { - return; } - const getFavoriteTasks = async () => { - const resp = await rmf.tasksApi.getFavoritesTasksFavoriteTasksGet(); - const results = resp.data as TaskFavorite[]; - setFavoritesTasks(results); + React.useEffect(() => { + const updateUnrespondedAlerts = async () => { + const { data: alerts } = + await rmfApi.alertsApi.getUnrespondedAlertsAlertsUnrespondedRequestsGet(); + // alert.display is checked to verify that the dashboard should display it + // in the first place + const alertsToBeDisplayed = alerts.filter((alert) => alert.display); + setUnacknowledgedAlertList(alertsToBeDisplayed.reverse()); + }; + + const subs: Subscription[] = []; + subs.push(rmfApi.alertRequestsObsStore.subscribe(updateUnrespondedAlerts)); + subs.push(rmfApi.alertResponsesObsStore.subscribe(updateUnrespondedAlerts)); + + // Get the initial number of unacknowledged alerts + updateUnrespondedAlerts(); + return () => subs.forEach((s) => s.unsubscribe()); + }, [rmfApi]); + + const submitTasks = React.useCallback['submitTasks']>( + async (taskRequests, schedule) => { + if (!schedule) { + await Promise.all( + taskRequests.map((request) => { + console.debug('submitTask:'); + console.debug(request); + return rmfApi.tasksApi.postDispatchTaskTasksDispatchTaskPost({ + type: 'dispatch_task_request', + request, + }); + }), + ); + } else { + const scheduleRequests = taskRequests.map((req) => { + console.debug('schedule task:'); + console.debug(req); + console.debug(schedule); + return toApiSchedule(req, schedule); + }); + await Promise.all( + scheduleRequests.map((req) => rmfApi.tasksApi.postScheduledTaskScheduledTasksPost(req)), + ); + } + AppEvents.refreshTaskApp.next(); + }, + [rmfApi], + ); + + //#region 'Favorite Task' + React.useEffect(() => { + const getFavoriteTasks = async () => { + const resp = await rmfApi.tasksApi.getFavoritesTasksFavoriteTasksGet(); + const results = resp.data as TaskFavorite[]; + setFavoritesTasks(results); + }; + getFavoriteTasks(); + + const sub = AppEvents.refreshFavoriteTasks.subscribe({ next: getFavoriteTasks }); + return () => sub.unsubscribe(); + }, [rmfApi]); + + const submitFavoriteTask = React.useCallback< + Required['submitFavoriteTask'] + >( + async (taskFavoriteRequest) => { + await rmfApi.tasksApi.postFavoriteTaskFavoriteTasksPost(taskFavoriteRequest); + AppEvents.refreshFavoriteTasks.next(); + }, + [rmfApi], + ); + + const deleteFavoriteTask = React.useCallback< + Required['deleteFavoriteTask'] + >( + async (favoriteTask) => { + if (!favoriteTask.id) { + throw new Error('Id is needed'); + } + + await rmfApi.tasksApi.deleteFavoriteTaskFavoriteTasksFavoriteTaskIdDelete(favoriteTask.id); + AppEvents.refreshFavoriteTasks.next(); + }, + [rmfApi], + ); + //#endregion 'Favorite Task' + + const handleOpenAlertList = (event: React.MouseEvent) => { + setAlertListAnchor(event.currentTarget); }; - getFavoriteTasks(); - - const sub = AppEvents.refreshFavoriteTasks.subscribe({ next: getFavoriteTasks }); - return () => sub.unsubscribe(); - }, [rmf]); - - const submitFavoriteTask = React.useCallback['submitFavoriteTask']>( - async (taskFavoriteRequest) => { - if (!rmf) { - throw new Error('tasks api not available'); - } - await rmf.tasksApi.postFavoriteTaskFavoriteTasksPost(taskFavoriteRequest); - AppEvents.refreshFavoriteTasks.next(); - }, - [rmf], - ); - - const deleteFavoriteTask = React.useCallback['deleteFavoriteTask']>( - async (favoriteTask) => { - if (!rmf) { - throw new Error('tasks api not available'); - } - if (!favoriteTask.id) { - throw new Error('Id is needed'); - } - - await rmf.tasksApi.deleteFavoriteTaskFavoriteTasksFavoriteTaskIdDelete(favoriteTask.id); - AppEvents.refreshFavoriteTasks.next(); - }, - [rmf], - ); - //#endregion 'Favorite Task' - - const handleOpenAlertList = (event: React.MouseEvent) => { - if (!rmf) { - return; - } - setAlertListAnchor(event.currentTarget); - }; - const openAlertDialog = (alert: AlertRequest) => { - AppEvents.pushAlert.next(alert); - }; + const openAlertDialog = (alert: AlertRequest) => { + AppEvents.pushAlert.next(alert); + }; - const timeDistance = (time: number) => { - return formatDistance(new Date(), new Date(time)); - }; + const timeDistance = (time: number) => { + return formatDistance(new Date(), new Date(time)); + }; - React.useEffect(() => { - if (!rmf) { - return; - } - (async () => { + React.useEffect(() => { + (async () => { + try { + const resp = + await rmfApi.buildingApi.getPreviousFireAlarmTriggerBuildingMapPreviousFireAlarmTriggerGet(); + setFireAlarmPreviousTrigger(resp.data); + } catch (e) { + console.error(`Failed to get previous fire alarm trigger: ${(e as Error).message}`); + } + })(); + }, [rmfApi, openAdminActionsDialog]); + + const handleResetFireAlarmTrigger = React.useCallback(async () => { try { const resp = - await rmf.buildingApi.getPreviousFireAlarmTriggerBuildingMapPreviousFireAlarmTriggerGet(); - setFireAlarmPreviousTrigger(resp.data); + await rmfApi.buildingApi.resetFireAlarmTriggerBuildingMapResetFireAlarmTriggerPost(); + if (!resp.data.trigger) { + showAlert('success', 'Requested to reset fire alarm trigger'); + } else { + showAlert('error', 'Failed to reset fire alarm trigger'); + } } catch (e) { - console.error(`Failed to get previous fire alarm trigger: ${(e as Error).message}`); - } - })(); - }, [rmf, openAdminActionsDialog]); - - const handleResetFireAlarmTrigger = React.useCallback(async () => { - try { - if (!rmf) { - throw new Error('building map api not available'); - } - - const resp = - await rmf.buildingApi.resetFireAlarmTriggerBuildingMapResetFireAlarmTriggerPost(); - if (!resp.data.trigger) { - showAlert('success', 'Requested to reset fire alarm trigger'); - } else { - showAlert('error', 'Failed to reset fire alarm trigger'); + showAlert('error', `Failed to reset fire alarm trigger: ${(e as Error).message}`); } - } catch (e) { - showAlert('error', `Failed to reset fire alarm trigger: ${(e as Error).message}`); - } - - setOpenFireAlarmTriggerResetDialog(false); - setOpenAdminActionsDialog(false); - }, [rmf, showAlert]); - return ( - <> - - - - navigate(DashboardRoute)} - /> - navigate(RobotsRoute)} - /> - navigate(TasksRoute)} - /> - {APP_CONFIG_ENABLE_CUSTOM_TABS && ( - <> - navigate(CustomRoute1)} - /> - navigate(CustomRoute2)} - /> - - )} - {APP_CONFIG_ENABLE_ADMIN_TAB && profile?.user.is_admin && ( - navigate(AdminRoute)} - /> - )} - - - setOpenCreateTaskForm(true)} - > - - New Task - - - + + + logo + + - - - - - - setAlertListAnchor(null)} - transformOrigin={{ horizontal: 'right', vertical: 'top' }} - anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} - PaperProps={{ - style: { - maxHeight: '20rem', - maxWidth: '30rem', - }, - }} - > - {unacknowledgedAlertList.length === 0 ? ( - - - No unacknowledged alerts - - - ) : ( - unacknowledgedAlertList.map((alert) => ( - - Alert - ID: {alert.id} - Title: {alert.title} - - Created: {new Date(alert.unix_millis_alert_time).toLocaleString()} + {tabs} + + + + + Powered by Open-RMF + + + + + + + + + + + setAlertListAnchor(null)} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} + anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} + slotProps={{ + paper: { + style: { + maxHeight: '20rem', + maxWidth: '30rem', + }, + }, + }} + > + {unacknowledgedAlertList.length === 0 ? ( + + + No unacknowledged alerts + + + ) : ( + unacknowledgedAlertList.map((alert) => ( + + Alert + ID: {alert.id} + Title: {alert.title} + + Created: {new Date(alert.unix_millis_alert_time).toLocaleString()} + + + } + placement="right" + > + { + openAlertDialog(alert); + setAlertListAnchor(null); + }} + divider + > + + + {alert.task_id ? `Task ${alert.task_id} had an alert ` : 'Alert occured '} + {timeDistance(alert.unix_millis_alert_time)} ago - + + + )) + )} + + {extraToolbarItems} + {themes?.dark && ( + + + appController.updateSettings({ + ...settings, + themeMode: settings.themeMode === 'default' ? 'dark' : 'default', + }) } - placement="right" > - { - openAlertDialog(alert); - setAlertListAnchor(null); - }} - divider - > - - - {alert.task_id ? `Task ${alert.task_id} had an alert ` : 'Alert occured '} - {timeDistance(alert.unix_millis_alert_time)} ago - - - - )) + {settings.themeMode === 'dark' ? : } + + )} - - - {/* - Powered by Open-RMF - */} - {extraToolbarItems} - {/* - setSettingsAnchor(ev.currentTarget)} - > - - - */} - - window.open(appConfig.helpLink, '_blank')} - > - - - - - window.open(appConfig.reportIssue, '_blank')} + + window.open(helpLink, '_blank')} + > + + + + + window.open(reportIssueLink, '_blank')} + > + + + + + setAnchorEl(event.currentTarget)} + > + + + + setAnchorEl(null)} > - - - - {profile && ( - <> - - setAnchorEl(event.currentTarget)} - > - - - - + {`Logged in as ${username ?? 'unknown user'}`} + + + + + + + { + setOpenAdminActionsDialog(true); + setAnchorEl(null); }} - open={!!anchorEl} - onClose={() => setAnchorEl(null)} > - - {`Logged in as ${username ?? 'unknown user'}`} - - - - - - - { - setOpenAdminActionsDialog(true); - setAnchorEl(null); - }} - > - - - - Admin actions - - - - - - - Logout - - - - )} - - - setSettingsAnchor(null)} - > - - - - - {openCreateTaskForm && ( - setOpenCreateTaskForm(false)} - submitTasks={submitTasks} - submitFavoriteTask={submitFavoriteTask} - deleteFavoriteTask={deleteFavoriteTask} - onSuccess={() => { - console.log('Dispatch task requested'); - setOpenCreateTaskForm(false); - showAlert('success', 'Dispatch task requested'); - }} - onFail={(e) => { - console.error(`Failed to dispatch task: ${e.message}`); - showAlert('error', `Failed to dispatch task: ${e.message}`); - }} - onSuccessFavoriteTask={(message) => { - console.log(`Created favorite task: ${message}`); - showAlert('success', message); - }} - onFailFavoriteTask={(e) => { - console.error(`Failed to create favorite task: ${e.message}`); - showAlert('error', `Failed to create or delete favorite task: ${e.message}`); - }} - onSuccessScheduling={() => { - console.log('Create schedule requested'); - setOpenCreateTaskForm(false); - showAlert('success', 'Create schedule requested'); - }} - onFailScheduling={(e) => { - console.error(`Failed to create schedule: ${e.message}`); - showAlert('error', `Failed to submit schedule: ${e.message}`); - }} - /> - )} - {openAdminActionsDialog && ( - setOpenAdminActionsDialog(false)} open={openAdminActionsDialog}> - Admin actions - - - - {fireAlarmPreviousTrigger && fireAlarmPreviousTrigger.trigger ? ( -
- - Last fire alarm triggered on: - - - {new Date(fireAlarmPreviousTrigger.unix_millis_time).toLocaleString()} - -
- ) : fireAlarmPreviousTrigger && !fireAlarmPreviousTrigger.trigger ? ( -
- - Last fire alarm reset on: - - - {new Date(fireAlarmPreviousTrigger.unix_millis_time).toLocaleString()} - -
- ) : ( -
- - Last fire alarm triggered on: - - - n/a - -
- )} - -
-
-
-
- )} - {openFireAlarmTriggerResetDialog && ( - setOpenFireAlarmTriggerResetDialog(false)} - onSubmit={handleResetFireAlarmTrigger} + + + + Admin actions + + + + + + + Logout + +
+
+ + + setSettingsAnchor(null)} > - - Warning: Please ensure that all other systems are back online and that it is safe to - resume robot operations. - - - )} - - ); -}); + + + + + + {openCreateTaskForm && ( + setOpenCreateTaskForm(false)} + submitTasks={submitTasks} + submitFavoriteTask={submitFavoriteTask} + deleteFavoriteTask={deleteFavoriteTask} + onSuccess={() => { + console.log('Dispatch task requested'); + setOpenCreateTaskForm(false); + showAlert('success', 'Dispatch task requested'); + }} + onFail={(e) => { + console.error(`Failed to dispatch task: ${e.message}`); + showAlert('error', `Failed to dispatch task: ${e.message}`); + }} + onSuccessFavoriteTask={(message) => { + console.log(`Created favorite task: ${message}`); + showAlert('success', message); + }} + onFailFavoriteTask={(e) => { + console.error(`Failed to create favorite task: ${e.message}`); + showAlert('error', `Failed to create or delete favorite task: ${e.message}`); + }} + onSuccessScheduling={() => { + console.log('Create schedule requested'); + setOpenCreateTaskForm(false); + showAlert('success', 'Create schedule requested'); + }} + onFailScheduling={(e) => { + console.error(`Failed to create schedule: ${e.message}`); + showAlert('error', `Failed to submit schedule: ${e.message}`); + }} + /> + )} + + {openAdminActionsDialog && ( + setOpenAdminActionsDialog(false)} open={openAdminActionsDialog}> + Admin actions + + + + {fireAlarmPreviousTrigger && fireAlarmPreviousTrigger.trigger ? ( +
+ + Last fire alarm triggered on: + + + {new Date(fireAlarmPreviousTrigger.unix_millis_time).toLocaleString()} + +
+ ) : fireAlarmPreviousTrigger && !fireAlarmPreviousTrigger.trigger ? ( +
+ + Last fire alarm reset on: + + + {new Date(fireAlarmPreviousTrigger.unix_millis_time).toLocaleString()} + +
+ ) : ( +
+ + Last fire alarm triggered on: + + + n/a + +
+ )} + +
+
+
+
+ )} + {openFireAlarmTriggerResetDialog && ( + setOpenFireAlarmTriggerResetDialog(false)} + onSubmit={handleResetFireAlarmTrigger} + > + + Warning: Please ensure that all other systems are back online and that it is safe to + resume robot operations. + + + )} + + ); + }, +); export default AppBar; diff --git a/packages/dashboard/src/components/beacons-app.tsx b/packages/dashboard/src/components/beacons-table.tsx similarity index 63% rename from packages/dashboard/src/components/beacons-app.tsx rename to packages/dashboard/src/components/beacons-table.tsx index 470f9f426..b0ea68a1b 100644 --- a/packages/dashboard/src/components/beacons-app.tsx +++ b/packages/dashboard/src/components/beacons-table.tsx @@ -2,20 +2,15 @@ import { BeaconState } from 'api-client'; import React from 'react'; import { BeaconDataGridTable } from 'react-components'; -import { createMicroApp } from './micro-app'; -import { RmfAppContext } from './rmf-app'; +import { useRmfApi } from '../hooks/use-rmf-api'; -export const BeaconsApp = createMicroApp('Beacons', () => { - const rmf = React.useContext(RmfAppContext); +export const BeaconsTable = () => { + const rmfApi = useRmfApi(); const [beacons, setBeacons] = React.useState>({}); React.useEffect(() => { - if (!rmf) { - return; - } - (async () => { - const { data } = await rmf.beaconsApi.getBeaconsBeaconsGet(); + const { data } = await rmfApi.beaconsApi.getBeaconsBeaconsGet(); for (const state of data) { setBeacons((prev) => { return { @@ -26,7 +21,7 @@ export const BeaconsApp = createMicroApp('Beacons', () => { } })(); - const sub = rmf.beaconsObsStore.subscribe(async (beaconState) => { + const sub = rmfApi.beaconsObsStore.subscribe(async (beaconState) => { setBeacons((prev) => { return { ...prev, @@ -35,7 +30,9 @@ export const BeaconsApp = createMicroApp('Beacons', () => { }); }); return () => sub.unsubscribe(); - }, [rmf]); + }, [rmfApi]); return ; -}); +}; + +export default BeaconsTable; diff --git a/packages/dashboard/src/components/delivery-alert-store.tsx b/packages/dashboard/src/components/delivery-alert-store.tsx index c4282914e..468ab08dc 100644 --- a/packages/dashboard/src/components/delivery-alert-store.tsx +++ b/packages/dashboard/src/components/delivery-alert-store.tsx @@ -13,8 +13,8 @@ import { import React from 'react'; import { base } from 'react-components'; -import { AppControllerContext } from './app-contexts'; -import { RmfAppContext } from './rmf-app'; +import { useAppController } from '../hooks/use-app-controller'; +import { useRmfApi } from '../hooks/use-rmf-api'; import { TaskCancelButton } from './tasks/task-cancellation'; import { TaskInspector } from './tasks/task-inspector'; @@ -52,8 +52,8 @@ const DeliveryWarningDialog = React.memo((props: DeliveryWarningDialogProps) => const [actionTaken, setActionTaken] = React.useState(!onOverride && !onResume); const [newTaskState, setNewTaskState] = React.useState(null); const [openTaskInspector, setOpenTaskInspector] = React.useState(false); - const appController = React.useContext(AppControllerContext); - const rmf = React.useContext(RmfAppContext); + const appController = useAppController(); + const rmfApi = useRmfApi(); const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); React.useEffect(() => { @@ -63,16 +63,11 @@ const DeliveryWarningDialog = React.memo((props: DeliveryWarningDialogProps) => }, [deliveryAlert]); React.useEffect(() => { - if (!rmf) { - console.error('Tasks api not available.'); - setNewTaskState(null); - return; - } if (!taskState) { setNewTaskState(null); return; } - const sub = rmf.getTaskStateObs(taskState.booking.id).subscribe((taskStateUpdate) => { + const sub = rmfApi.getTaskStateObs(taskState.booking.id).subscribe((taskStateUpdate) => { setNewTaskState(taskStateUpdate); if ( deliveryAlert.action === DeliveryAlertAction.Waiting && @@ -81,7 +76,7 @@ const DeliveryWarningDialog = React.memo((props: DeliveryWarningDialogProps) => ) { (async () => { try { - await rmf.deliveryAlertsApi.respondToDeliveryAlertDeliveryAlertsDeliveryAlertIdResponsePost( + await rmfApi.deliveryAlertsApi.respondToDeliveryAlertDeliveryAlertsDeliveryAlertIdResponsePost( deliveryAlert.id, deliveryAlert.category, deliveryAlert.tier, @@ -100,7 +95,7 @@ const DeliveryWarningDialog = React.memo((props: DeliveryWarningDialogProps) => } }); return () => sub.unsubscribe(); - }, [rmf, deliveryAlert, taskState, appController]); + }, [rmfApi, deliveryAlert, taskState, appController]); const titleUpdateText = (action: DeliveryAlertAction) => { switch (action) { @@ -457,9 +452,9 @@ interface DeliveryAlertData { } export const DeliveryAlertStore = React.memo(() => { - const rmf = React.useContext(RmfAppContext); + const rmfApi = useRmfApi(); const [alerts, setAlerts] = React.useState>({}); - const appController = React.useContext(AppControllerContext); + const appController = useAppController(); const filterAndPushDeliveryAlert = (deliveryAlert: DeliveryAlert, taskState?: TaskState) => { // Check if a delivery alert for a task is already open, if so, replace it @@ -493,10 +488,7 @@ export const DeliveryAlertStore = React.memo(() => { }; React.useEffect(() => { - if (!rmf) { - return; - } - const sub = rmf.deliveryAlertObsStore.subscribe(async (deliveryAlert) => { + const sub = rmfApi.deliveryAlertObsStore.subscribe(async (deliveryAlert) => { // DEBUG console.log( `Got a delivery alert [${deliveryAlert.id}] with action [${deliveryAlert.action}]`, @@ -505,7 +497,8 @@ export const DeliveryAlertStore = React.memo(() => { let state: TaskState | undefined = undefined; if (deliveryAlert.task_id) { try { - state = (await rmf.tasksApi.getTaskStateTasksTaskIdStateGet(deliveryAlert.task_id)).data; + state = (await rmfApi.tasksApi.getTaskStateTasksTaskIdStateGet(deliveryAlert.task_id)) + .data; } catch { console.error(`Failed to fetch task state for ${deliveryAlert.task_id}`); } @@ -513,15 +506,12 @@ export const DeliveryAlertStore = React.memo(() => { filterAndPushDeliveryAlert(deliveryAlert, state); }); return () => sub.unsubscribe(); - }, [rmf]); + }, [rmfApi]); const onOverride = React.useCallback['onOverride']>( async (delivery_alert) => { try { - if (!rmf) { - throw new Error('delivery alert api not available'); - } - await rmf.deliveryAlertsApi.respondToDeliveryAlertDeliveryAlertsDeliveryAlertIdResponsePost( + await rmfApi.deliveryAlertsApi.respondToDeliveryAlertDeliveryAlertsDeliveryAlertIdResponsePost( delivery_alert.id, delivery_alert.category, delivery_alert.tier, @@ -549,7 +539,7 @@ export const DeliveryAlertStore = React.memo(() => { ); } }, - [rmf, appController], + [rmfApi, appController], ); const removeDeliveryAlertDialog = (id: string) => { @@ -559,10 +549,7 @@ export const DeliveryAlertStore = React.memo(() => { const onResume = React.useCallback['onResume']>( async (delivery_alert) => { try { - if (!rmf) { - throw new Error('delivery alert api not available'); - } - await rmf.deliveryAlertsApi.respondToDeliveryAlertDeliveryAlertsDeliveryAlertIdResponsePost( + await rmfApi.deliveryAlertsApi.respondToDeliveryAlertDeliveryAlertsDeliveryAlertIdResponsePost( delivery_alert.id, delivery_alert.category, delivery_alert.tier, @@ -586,7 +573,7 @@ export const DeliveryAlertStore = React.memo(() => { ); } }, - [rmf, appController], + [rmfApi, appController], ); return ( diff --git a/packages/dashboard/src/components/door-summary.tsx b/packages/dashboard/src/components/door-summary.tsx index a4d40d651..cd9c177fd 100644 --- a/packages/dashboard/src/components/door-summary.tsx +++ b/packages/dashboard/src/components/door-summary.tsx @@ -5,8 +5,8 @@ import { doorModeToOpModeString } from 'react-components'; import { base, doorModeToString, DoorTableData, doorTypeToString } from 'react-components'; import { Door as DoorModel } from 'rmf-models/ros/rmf_building_map_msgs/msg'; +import { useRmfApi } from '../hooks/use-rmf-api'; import { getApiErrorMessage } from '../utils/api'; -import { RmfAppContext } from './rmf-app'; interface DoorSummaryProps { onClose: () => void; @@ -15,7 +15,7 @@ interface DoorSummaryProps { } export const DoorSummary = ({ onClose, door, level }: DoorSummaryProps): JSX.Element => { - const rmf = React.useContext(RmfAppContext); + const rmfApi = useRmfApi(); const [doorData, setDoorData] = React.useState({ index: 0, doorName: '', @@ -25,13 +25,9 @@ export const DoorSummary = ({ onClose, door, level }: DoorSummaryProps): JSX.Ele }); React.useEffect(() => { - if (!rmf) { - return; - } - const fetchDataForDoor = async () => { try { - const sub = rmf.getDoorStateObs(door.name).subscribe((doorState) => { + const sub = rmfApi.getDoorStateObs(door.name).subscribe((doorState) => { setDoorData({ index: 0, doorName: door.name, @@ -47,7 +43,7 @@ export const DoorSummary = ({ onClose, door, level }: DoorSummaryProps): JSX.Ele }; fetchDataForDoor(); - }, [rmf, level, door]); + }, [rmfApi, level, door]); const [isOpen, setIsOpen] = React.useState(true); diff --git a/packages/dashboard/src/components/doors-app.tsx b/packages/dashboard/src/components/doors-table.tsx similarity index 62% rename from packages/dashboard/src/components/doors-app.tsx rename to packages/dashboard/src/components/doors-table.tsx index 320cbfdf8..8a50b847e 100644 --- a/packages/dashboard/src/components/doors-app.tsx +++ b/packages/dashboard/src/components/doors-table.tsx @@ -1,37 +1,30 @@ +import { TableContainer } from '@mui/material'; import { BuildingMap } from 'api-client'; import React from 'react'; import { DoorDataGridTable, DoorTableData } from 'react-components'; import { DoorMode as RmfDoorMode } from 'rmf-models/ros/rmf_door_msgs/msg/DoorMode'; import { throttleTime } from 'rxjs'; +import { useRmfApi } from '../hooks/use-rmf-api'; import { getApiErrorMessage } from '../utils/api'; import { AppEvents } from './app-events'; -import { createMicroApp } from './micro-app'; -import { RmfAppContext } from './rmf-app'; -export const DoorsApp = createMicroApp('Doors', () => { - const rmf = React.useContext(RmfAppContext); +export const DoorsTable = () => { + const rmfApi = useRmfApi(); const [buildingMap, setBuildingMap] = React.useState(null); const [doorTableData, setDoorTableData] = React.useState>({}); React.useEffect(() => { - if (!rmf) { - return; - } - const sub = rmf.buildingMapObs.subscribe(setBuildingMap); + const sub = rmfApi.buildingMapObs.subscribe(setBuildingMap); return () => sub.unsubscribe(); - }, [rmf]); + }, [rmfApi]); React.useEffect(() => { - if (!rmf) { - return; - } - let doorIndex = 0; buildingMap?.levels.map((level) => level.doors.map(async (door) => { try { - const sub = rmf + const sub = rmfApi .getDoorStateObs(door.name) .pipe(throttleTime(3000, undefined, { leading: true, trailing: true })) .subscribe((doorState) => { @@ -45,11 +38,11 @@ export const DoorsApp = createMicroApp('Doors', () => { doorType: door.door_type, doorState: doorState, onClickOpen: () => - rmf?.doorsApi.postDoorRequestDoorsDoorNameRequestPost(door.name, { + rmfApi?.doorsApi.postDoorRequestDoorsDoorNameRequestPost(door.name, { mode: RmfDoorMode.MODE_OPEN, }), onClickClose: () => - rmf?.doorsApi.postDoorRequestDoorsDoorNameRequestPost(door.name, { + rmfApi?.doorsApi.postDoorRequestDoorsDoorNameRequestPost(door.name, { mode: RmfDoorMode.MODE_CLOSED, }), }, @@ -62,26 +55,30 @@ export const DoorsApp = createMicroApp('Doors', () => { } }), ); - }, [rmf, buildingMap]); + }, [rmfApi, buildingMap]); return ( - { - if (!buildingMap) { - AppEvents.doorSelect.next(null); - return; - } + + { + if (!buildingMap) { + AppEvents.doorSelect.next(null); + return; + } - for (const level of buildingMap.levels) { - for (const door of level.doors) { - if (door.name === doorData.doorName) { - AppEvents.doorSelect.next([level.name, door]); - return; + for (const level of buildingMap.levels) { + for (const door of level.doors) { + if (door.name === doorData.doorName) { + AppEvents.doorSelect.next([level.name, door]); + return; + } } } - } - }} - /> + }} + /> + ); -}); +}; + +export default DoorsTable; diff --git a/packages/dashboard/src/components/index.ts b/packages/dashboard/src/components/index.ts index 26bcfb24b..cf5662be4 100644 --- a/packages/dashboard/src/components/index.ts +++ b/packages/dashboard/src/components/index.ts @@ -1,8 +1,6 @@ -export * from './admin'; -export * from './app-base'; -export * from './app-contexts'; +export * from '../micro-apps/map-app'; export * from './app-events'; export * from './login-card'; -export * from './private-route'; -export * from './rmf-app'; +export * from './rmf-dashboard'; +export * from './theme'; export * from './workspace'; diff --git a/packages/dashboard/src/components/lift-summary.tsx b/packages/dashboard/src/components/lift-summary.tsx index b7bbc2e5f..42f04180c 100644 --- a/packages/dashboard/src/components/lift-summary.tsx +++ b/packages/dashboard/src/components/lift-summary.tsx @@ -11,8 +11,8 @@ import { Lift } from 'api-client'; import React from 'react'; import { base, doorStateToString, liftModeToString, LiftTableData } from 'react-components'; +import { useRmfApi } from '../hooks/use-rmf-api'; import { getApiErrorMessage } from '../utils/api'; -import { RmfAppContext } from './rmf-app'; interface LiftSummaryProps { onClose: () => void; @@ -21,7 +21,7 @@ interface LiftSummaryProps { export const LiftSummary = ({ onClose, lift }: LiftSummaryProps): JSX.Element => { const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); - const rmf = React.useContext(RmfAppContext); + const rmfApi = useRmfApi(); const [liftData, setLiftData] = React.useState({ index: 0, name: '', @@ -35,13 +35,9 @@ export const LiftSummary = ({ onClose, lift }: LiftSummaryProps): JSX.Element => }); React.useEffect(() => { - if (!rmf) { - return; - } - const fetchDataForLift = async () => { try { - const sub = rmf.getLiftStateObs(lift.name).subscribe((liftState) => { + const sub = rmfApi.getLiftStateObs(lift.name).subscribe((liftState) => { setLiftData({ index: -1, name: lift.name, @@ -60,7 +56,7 @@ export const LiftSummary = ({ onClose, lift }: LiftSummaryProps): JSX.Element => }; fetchDataForLift(); - }, [rmf, lift]); + }, [rmfApi, lift]); const [isOpen, setIsOpen] = React.useState(true); diff --git a/packages/dashboard/src/components/lifts-app.tsx b/packages/dashboard/src/components/lifts-table.tsx similarity index 82% rename from packages/dashboard/src/components/lifts-app.tsx rename to packages/dashboard/src/components/lifts-table.tsx index 3aa0d4dfd..c9970c36a 100644 --- a/packages/dashboard/src/components/lifts-app.tsx +++ b/packages/dashboard/src/components/lifts-table.tsx @@ -5,38 +5,29 @@ import { LiftDataGridTable, LiftTableData } from 'react-components'; import { LiftRequest as RmfLiftRequest } from 'rmf-models/ros/rmf_lift_msgs/msg'; import { throttleTime } from 'rxjs'; +import { useRmfApi } from '../hooks/use-rmf-api'; import { getApiErrorMessage } from '../utils/api'; import { AppEvents } from './app-events'; import { LiftSummary } from './lift-summary'; -import { createMicroApp } from './micro-app'; -import { RmfAppContext } from './rmf-app'; -export const LiftsApp = createMicroApp('Lifts', () => { - const rmf = React.useContext(RmfAppContext); +export const LiftsTable = () => { + const rmfApi = useRmfApi(); const [buildingMap, setBuildingMap] = React.useState(null); const [liftTableData, setLiftTableData] = React.useState>({}); const [openLiftSummary, setOpenLiftSummary] = React.useState(false); const [selectedLift, setSelectedLift] = React.useState(null); React.useEffect(() => { - if (!rmf) { - return; - } - - const sub = rmf.buildingMapObs.subscribe((newMap) => { + const sub = rmfApi.buildingMapObs.subscribe((newMap) => { setBuildingMap(newMap); }); return () => sub.unsubscribe(); - }, [rmf]); + }, [rmfApi]); React.useEffect(() => { - if (!rmf) { - return; - } - buildingMap?.lifts.map(async (lift, i) => { try { - const sub = rmf + const sub = rmfApi .getLiftStateObs(lift.name) .pipe(throttleTime(3000, undefined, { leading: true, trailing: true })) .subscribe((liftState) => { @@ -56,13 +47,9 @@ export const LiftsApp = createMicroApp('Lifts', () => { lift: lift, liftState: liftState, onRequestSubmit: async (_ev, doorState, requestType, destination) => { - if (!rmf) { - console.error('rmf ingress is undefined'); - return; - } const fleet_session_ids: string[] = []; if (requestType === RmfLiftRequest.REQUEST_END_SESSION) { - const fleets = (await rmf.fleetsApi.getFleetsFleetsGet()).data; + const fleets = (await rmfApi.fleetsApi.getFleetsFleetsGet()).data; for (const fleet of fleets) { if (!fleet.robots) { continue; @@ -73,7 +60,7 @@ export const LiftsApp = createMicroApp('Lifts', () => { } } - return rmf?.liftsApi.postLiftRequestLiftsLiftNameRequestPost(lift.name, { + return rmfApi?.liftsApi.postLiftRequestLiftsLiftNameRequestPost(lift.name, { destination, door_mode: doorState, request_type: requestType, @@ -90,10 +77,10 @@ export const LiftsApp = createMicroApp('Lifts', () => { console.error(`Failed to get lift state: ${getApiErrorMessage(error)}`); } }); - }, [rmf, buildingMap]); + }, [rmfApi, buildingMap]); return ( - + l)} onLiftClick={(_ev, liftData) => { @@ -117,4 +104,6 @@ export const LiftsApp = createMicroApp('Lifts', () => { )} ); -}); +}; + +export default LiftsTable; diff --git a/packages/dashboard/src/components/map-app.tsx b/packages/dashboard/src/components/map-app.tsx deleted file mode 100644 index a3ef33ebf..000000000 --- a/packages/dashboard/src/components/map-app.tsx +++ /dev/null @@ -1,736 +0,0 @@ -import type { StyledComponent } from '@emotion/styled'; -import { Box, styled, Typography, useMediaQuery } from '@mui/material'; -import type { Theme } from '@mui/material/styles'; -import type { MUIStyledCommonProps } from '@mui/system'; -import { Line } from '@react-three/drei'; -import { Canvas, useLoader } from '@react-three/fiber'; -import { BuildingMap, FleetState, Level, Lift } from 'api-client'; -import Debug from 'debug'; -import React, { ChangeEvent, Suspense } from 'react'; -import { - ColorManager, - findSceneBoundingBoxFromThreeFiber, - getPlaces, - Place, - ReactThreeFiberImageMaker, - RobotData, - RobotTableData, - ShapeThreeRendering, - TextThreeRendering, -} from 'react-components'; -import { ErrorBoundary } from 'react-error-boundary'; -import { Door as DoorModel } from 'rmf-models/ros/rmf_building_map_msgs/msg'; -import { EMPTY, merge, scan, Subscription, switchMap, throttleTime } from 'rxjs'; -import { Box3, TextureLoader, Vector3 } from 'three'; - -import { - AppConfigContext, - AuthenticatorContext, - FleetResource, - ResourcesContext, -} from '../app-config'; -import { TrajectoryData } from '../services/robot-trajectory-manager'; -import { AppControllerContext } from './app-contexts'; -import { AppEvents } from './app-events'; -import { DoorSummary } from './door-summary'; -import { LiftSummary } from './lift-summary'; -import { createMicroApp, MicroAppProps } from './micro-app'; -import { RmfAppContext } from './rmf-app'; -import { RobotSummary } from './robots/robot-summary'; -import { CameraControl, Door, LayersController, Lifts, RobotThree } from './three-fiber'; - -const debug = Debug('MapApp'); - -const TrajectoryUpdateInterval = 2000; -// schedule visualizer manages it's own settings so that it doesn't cause a re-render -// of the whole app when it changes. -const colorManager = new ColorManager(); - -const DEFAULT_ROBOT_SCALE = 0.003; - -function getRobotId(fleetName: string, robotName: string): string { - return `${fleetName}/${robotName}`; -} - -export const MapApp: StyledComponent, {}, {}> = styled( - createMicroApp('Map', () => { - const appConfig = React.useContext(AppConfigContext); - const authenticator = React.useContext(AuthenticatorContext); - const resources = React.useContext(ResourcesContext); - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); - const rmf = React.useContext(RmfAppContext); - const resourceManager = React.useContext(ResourcesContext); - const { showAlert } = React.useContext(AppControllerContext); - const [currentLevel, setCurrentLevel] = React.useState(undefined); - const [disabledLayers, setDisabledLayers] = React.useState>({ - 'Pickup & Dropoff waypoints': false, - 'Pickup & Dropoff labels': true, - Waypoints: true, - 'Waypoint labels': true, - 'Doors & Lifts': false, - 'Doors labels': true, - Robots: false, - 'Robots labels': true, - Trajectories: false, - }); - const [openRobotSummary, setOpenRobotSummary] = React.useState(false); - const [openDoorSummary, setOpenDoorSummary] = React.useState(false); - const [openLiftSummary, setOpenLiftSummary] = React.useState(false); - const [selectedRobot, setSelectedRobot] = React.useState(); - const [selectedDoor, setSelectedDoor] = React.useState(); - const [selectedLift, setSelectedLift] = React.useState(); - - const [buildingMap, setBuildingMap] = React.useState(null); - - const [fleets, setFleets] = React.useState([]); - - const [waypoints, setWaypoints] = React.useState([]); - const [currentLevelOfRobots, setCurrentLevelOfRobots] = React.useState<{ - [key: string]: string; - }>({}); - - const [trajectories, setTrajectories] = React.useState([]); - const trajectoryTime = 300000; - const trajectoryAnimScale = trajectoryTime / (0.9 * TrajectoryUpdateInterval); - const trajManager = rmf?.trajectoryManager; - React.useEffect(() => { - if (!currentLevel) { - return; - } - - let cancel = false; - - const updateTrajectory = async () => { - debug('updating trajectories'); - - if (cancel || !trajManager) return; - - const resp = await trajManager.latestTrajectory({ - request: 'trajectory', - param: { - map_name: currentLevel.name, - duration: trajectoryTime, - trim: true, - }, - token: authenticator.token, - }); - const flatConflicts = resp.conflicts.flatMap((c) => c); - - debug('set trajectories'); - const trajectories = resp.values.map((v) => ({ - trajectory: v, - color: 'green', - conflict: flatConflicts.includes(v.id), - animationScale: trajectoryAnimScale, - loopAnimation: false, - })); - - // Filter trajectory due to https://github.com/open-rmf/rmf_visualization/issues/65 - for (const t of trajectories) { - if (t.trajectory.segments.length === 0) { - continue; - } - - const knot = t.trajectory.segments[0]; - if ((knot.x[0] < 1e-9 && knot.x[0] > -1e-9) || (knot.x[1] < 1e-9 && knot.x[1] > -1e-9)) { - t.trajectory.segments.shift(); - } - } - setTrajectories(trajectories); - }; - - updateTrajectory(); - const interval = window.setInterval(updateTrajectory, TrajectoryUpdateInterval); - debug(`created trajectory update interval ${interval}`); - - return () => { - cancel = true; - clearInterval(interval); - debug(`cleared interval ${interval}`); - }; - }, [trajManager, currentLevel, trajectoryTime, trajectoryAnimScale, authenticator.token]); - - React.useEffect(() => { - if (!rmf) { - return; - } - - const levelByName = (map: BuildingMap, levelName?: string) => { - if (!levelName) { - return null; - } - for (const l of map.levels) { - if (l.name === levelName) { - return l; - } - } - return null; - }; - - const handleBuildingMap = (newMap: BuildingMap) => { - setBuildingMap(newMap); - const loggedInDisplayLevel = AppEvents.justLoggedIn.value - ? levelByName(newMap, appConfig.defaultMapLevel) - : undefined; - const currentLevel = - loggedInDisplayLevel || AppEvents.levelSelect.value || newMap.levels[0]; - AppEvents.levelSelect.next(currentLevel); - setWaypoints( - getPlaces(newMap).filter( - (p) => p.level === currentLevel.name && p.vertex.name.length > 0, - ), - ); - AppEvents.justLoggedIn.next(false); - }; - - (async () => { - try { - const newMap = (await rmf.buildingApi.getBuildingMapBuildingMapGet()).data; - handleBuildingMap(newMap); - } catch (e) { - console.log(`failed to get building map: ${(e as Error).message}`); - } - })(); - - const subs: Subscription[] = []; - subs.push(rmf.buildingMapObs.subscribe((newMap) => handleBuildingMap(newMap))); - subs.push(rmf.fleetsObs.subscribe(setFleets)); - - return () => { - for (const sub of subs) { - sub.unsubscribe(); - } - }; - }, [rmf, resourceManager, appConfig.defaultMapLevel]); - - const [imageUrl, setImageUrl] = React.useState(null); - // Since the configurable zoom level is for supporting the lowest resolution - // settings, we will double it for anything that is operating within modern - // resolution settings. - const defaultZoom = isScreenHeightLessThan800 - ? appConfig.defaultZoom - : appConfig.defaultZoom * 2; - const [zoom, setZoom] = React.useState(defaultZoom); - const [sceneBoundingBox, setSceneBoundingBox] = React.useState(undefined); - const [distance, setDistance] = React.useState(0); - - React.useEffect(() => { - const subs: Subscription[] = []; - subs.push( - AppEvents.zoom.subscribe((currentValue) => { - setZoom(currentValue || defaultZoom); - }), - ); - subs.push( - AppEvents.levelSelect.subscribe((currentValue) => { - const newSceneBoundingBox = currentValue - ? findSceneBoundingBoxFromThreeFiber(currentValue) - : undefined; - if (newSceneBoundingBox) { - const center = newSceneBoundingBox.getCenter(new Vector3()); - const size = newSceneBoundingBox.getSize(new Vector3()); - const distance = Math.max(size.x, size.y, size.z) * 0.7; - const newZoom = defaultZoom; - AppEvents.resetCamera.next([center.x, center.y, center.z + distance, newZoom]); - } - setCurrentLevel(currentValue ?? undefined); - setSceneBoundingBox(newSceneBoundingBox); - }), - ); - return () => { - for (const sub of subs) { - sub.unsubscribe(); - } - }; - }, [defaultZoom]); - - React.useEffect(() => { - if (!currentLevel?.images[0]) { - setImageUrl(null); - return; - } - - (async () => { - useLoader.preload(TextureLoader, currentLevel.images[0].data); - setImageUrl(currentLevel.images[0].data); - })(); - - buildingMap && - setWaypoints( - getPlaces(buildingMap).filter( - (p) => p.level === currentLevel.name && p.vertex.name.length > 0, - ), - ); - }, [buildingMap, currentLevel]); - - const [robots, setRobots] = React.useState([]); - const { current: robotsStore } = React.useRef>({}); - React.useEffect(() => { - (async () => { - if (!currentLevel) { - return; - } - const promises = Object.values(fleets).flatMap((fleetState) => { - if (!fleetState.name || !fleetState.robots) { - return null; - } - const robotKey = Object.keys(fleetState.robots); - const fleetName = fleetState.name; - return robotKey.map(async (r) => { - const robotId = getRobotId(fleetName, r); - const fleetResource: FleetResource | undefined = resources.fleets[fleetName]; - if (robotId in robotsStore) return; - robotsStore[robotId] = { - fleet: fleetName, - name: r, - // no model name - model: '', - scale: fleetResource?.default.scale || DEFAULT_ROBOT_SCALE, - footprint: 0.5, - color: await colorManager.robotPrimaryColor(fleetName, r, ''), - iconPath: fleetResource?.default.icon || undefined, - }; - }); - }); - await Promise.all(promises); - const newRobots = Object.values(fleets).flatMap((fleetState) => { - const robotKey = fleetState.robots ? Object.keys(fleetState.robots) : []; - return robotKey - ?.filter( - (r) => - fleetState.robots && - r in currentLevelOfRobots && - currentLevelOfRobots[r] === currentLevel.name && - `${fleetState.name}/${r}` in robotsStore, - ) - .map((r) => robotsStore[`${fleetState.name}/${r}`]); - }); - setRobots(newRobots); - })(); - }, [ - fleets, - robotsStore, - resourceManager, - currentLevel, - currentLevelOfRobots, - resources.fleets, - ]); - - const { current: robotLocations } = React.useRef< - Record - >({}); - // updates the robot location - React.useEffect(() => { - if (!rmf) { - return; - } - const sub = rmf.fleetsObs - .pipe( - switchMap((fleets) => - merge( - ...fleets.map((f) => - f.name - ? rmf - .getFleetStateObs(f.name) - .pipe(throttleTime(500, undefined, { leading: true, trailing: true })) - : EMPTY, - ), - ), - ), - ) - .subscribe((fleetState) => { - const fleetName = fleetState.name; - if (!fleetName || !fleetState.robots) { - console.warn('Map: Fail to update robot location (missing fleet name or robots)'); - return; - } - Object.entries(fleetState.robots).forEach(([robotName, robotState]) => { - const robotId = getRobotId(fleetName, robotName); - if (!robotState.location) { - console.warn(`Map: Fail to update robot location for ${robotId} (missing location)`); - return; - } - robotLocations[robotId] = [ - robotState.location.x, - robotState.location.y, - robotState.location.yaw, - robotState.location.map, - ]; - - setCurrentLevelOfRobots((prevState) => { - if (!robotState.location?.map && prevState.robotName) { - console.warn(`Map: Fail to update robot level for ${robotId} (missing map)`); - const updatedState = { ...prevState }; - delete updatedState[robotName]; - return updatedState; - } - - return { - ...prevState, - [robotName]: robotState.location?.map || '', - }; - }); - }); - }); - return () => sub.unsubscribe(); - }, [rmf, robotLocations]); - - //Accumulate values over time to persist between tabs - React.useEffect(() => { - const sub = AppEvents.disabledLayers - .pipe(scan((acc, value) => ({ ...acc, ...value }), {})) - .subscribe((layers) => { - setDisabledLayers(layers); - }); - return () => sub.unsubscribe(); - }, []); - - // zoom to robot on select - React.useEffect(() => { - const subs: Subscription[] = []; - - // Centering on robot - subs.push( - AppEvents.robotSelect.subscribe((data) => { - if (!data || !sceneBoundingBox) { - return; - } - const [fleetName, robotName] = data; - const robotId = getRobotId(fleetName, robotName); - const robotLocation = robotLocations[robotId]; - if (!robotLocation) { - console.warn(`Map: Failed to zoom to robot ${robotId} (robot location was not found)`); - return; - } - - const mapName = robotLocation[3]; - let newSceneBoundingBox = sceneBoundingBox; - if ( - AppEvents.levelSelect.value && - AppEvents.levelSelect.value.name !== mapName && - buildingMap - ) { - const robotLevel = - buildingMap.levels.find((l: Level) => l.name === mapName) || buildingMap.levels[0]; - AppEvents.levelSelect.next(robotLevel); - - const robotLevelSceneBoundingBox = findSceneBoundingBoxFromThreeFiber(robotLevel); - if (!robotLevelSceneBoundingBox) { - return; - } - newSceneBoundingBox = robotLevelSceneBoundingBox; - setSceneBoundingBox(newSceneBoundingBox); - } - - const size = newSceneBoundingBox.getSize(new Vector3()); - const distance = Math.max(size.x, size.y, size.z) * 0.7; - const newZoom = appConfig.defaultRobotZoom; - AppEvents.resetCamera.next([ - robotLocation[0], - robotLocation[1], - robotLocation[2] + distance, - newZoom, - ]); - }), - ); - - // Centering on door - subs.push( - AppEvents.doorSelect.subscribe((door) => { - if (!door || !sceneBoundingBox) { - return; - } - - const [mapName, doorInfo] = door; - - let newSceneBoundingBox = sceneBoundingBox; - if ( - AppEvents.levelSelect.value && - AppEvents.levelSelect.value.name !== mapName && - buildingMap - ) { - const doorLevel = - buildingMap.levels.find((l: Level) => l.name === mapName) || buildingMap.levels[0]; - AppEvents.levelSelect.next(doorLevel); - - const doorLevelSceneBoundingBox = findSceneBoundingBoxFromThreeFiber(doorLevel); - if (!doorLevelSceneBoundingBox) { - return; - } - newSceneBoundingBox = doorLevelSceneBoundingBox; - setSceneBoundingBox(newSceneBoundingBox); - } - - const size = newSceneBoundingBox.getSize(new Vector3()); - const distance = Math.max(size.x, size.y, size.z) * 0.7; - const newZoom = appConfig.defaultRobotZoom; - AppEvents.resetCamera.next([ - (doorInfo.v1_x + doorInfo.v2_x) / 2, - (doorInfo.v1_y + doorInfo.v2_y) / 2, - distance, - newZoom, - ]); - }), - ); - - // Centering on lift - subs.push( - AppEvents.liftSelect.subscribe((lift) => { - if (!lift || !sceneBoundingBox) { - return; - } - - const size = sceneBoundingBox.getSize(new Vector3()); - const distance = Math.max(size.x, size.y, size.z) * 0.7; - const newZoom = appConfig.defaultRobotZoom; - AppEvents.resetCamera.next([lift.ref_x, lift.ref_y, distance, newZoom]); - }), - ); - - return () => { - for (const sub of subs) { - sub.unsubscribe(); - } - }; - }, [robotLocations, sceneBoundingBox, buildingMap, appConfig.defaultRobotZoom]); - - React.useEffect(() => { - if (!sceneBoundingBox) { - return; - } - - const size = sceneBoundingBox.getSize(new Vector3()); - setDistance(Math.max(size.x, size.y, size.z) * 0.7); - }, [sceneBoundingBox]); - - return buildingMap && currentLevel && robotLocations ? ( - - , value: string) => { - AppEvents.levelSelect.next( - buildingMap.levels.find((l: Level) => l.name === value) || buildingMap.levels[0], - ); - }} - handleFullView={() => { - if (!sceneBoundingBox) { - return; - } - const center = sceneBoundingBox.getCenter(new Vector3()); - const size = sceneBoundingBox.getSize(new Vector3()); - const distance = Math.max(size.x, size.y, size.z) * 0.7; - const newZoom = defaultZoom; - AppEvents.resetCamera.next([center.x, center.y, center.z + distance, newZoom]); - }} - handleZoomIn={() => AppEvents.zoomIn.next()} - handleZoomOut={() => AppEvents.zoomOut.next()} - /> - - - {appConfig.attributionPrefix} - - - { - if (!sceneBoundingBox) { - return; - } - const center = sceneBoundingBox.getCenter(new Vector3()); - camera.position.set(center.x, center.y, center.z + distance); - camera.zoom = zoom; - camera.updateProjectionMatrix(); - }} - orthographic={true} - > - - {!disabledLayers['Pickup & Dropoff waypoints'] && - waypoints - .filter((waypoint) => waypoint.pickupHandler || waypoint.dropoffHandler) - .map((place, index) => ( - - ))} - {!disabledLayers['Pickup & Dropoff labels'] && - waypoints - .filter((waypoint) => waypoint.pickupHandler || waypoint.dropoffHandler) - .map((place, index) => ( - - ))} - {!disabledLayers['Waypoints'] && - waypoints - .filter((waypoint) => !waypoint.pickupHandler && !waypoint.dropoffHandler) - .map((place, index) => ( - - ))} - {!disabledLayers['Waypoint labels'] && - waypoints - .filter((waypoint) => !waypoint.pickupHandler && !waypoint.dropoffHandler) - .map((place, index) => ( - - ))} - {buildingMap.lifts.length > 0 - ? buildingMap.lifts.map((lift) => - lift.doors.map((door, i) => ( - - {!disabledLayers['Doors labels'] && ( - - )} - {!disabledLayers['Doors & Lifts'] && ( - { - setOpenLiftSummary(true); - setSelectedLift(lift); - }} - /> - )} - - )), - ) - : null} - {!disabledLayers['Doors & Lifts'] && buildingMap.lifts.length > 0 - ? buildingMap.lifts.map((lift) => - lift.doors.map(() => ( - { - setOpenLiftSummary(true); - setSelectedLift(lift); - }} - /> - )), - ) - : null} - {currentLevel.doors.length > 0 - ? currentLevel.doors.map((door, i) => ( - - {!disabledLayers['Doors labels'] && ( - - )} - {!disabledLayers['Doors'] && ( - { - setOpenDoorSummary(true); - setSelectedDoor(door); - }} - /> - )} - - )) - : null} - {currentLevel.images.length > 0 && imageUrl && ( - } - onError={(error, info) => { - console.error(error); - console.log(info); - showAlert( - 'error', - 'Unable to retrieve building map images. Please ensure that the building map server is operational and without issues.', - 20000, - ); - }} - > - - - )} - {!disabledLayers['Robots'] && - robots.map((robot) => { - const robotId = `${robot.fleet}/${robot.name}`; - if (robotId in robotLocations) { - const location = robotLocations[robotId]; - const position: [number, number, number] = [location[0], location[1], location[2]]; - return ( - { - setOpenRobotSummary(true); - setSelectedRobot(robot); - }} - robotLabel={!disabledLayers['Robots labels']} - /> - ); - } - return null; - })} - {!disabledLayers['Trajectories'] && - trajectories.map((trajData) => ( - new Vector3(seg.x[0], seg.x[1], 4), - )} - color={trajData.color} - linewidth={5} - /> - ))} - - - {openRobotSummary && selectedRobot && ( - setOpenRobotSummary(false)} /> - )} - {openDoorSummary && selectedDoor && ( - setOpenDoorSummary(false)} - door={selectedDoor} - level={currentLevel} - /> - )} - - {openLiftSummary && selectedLift && ( - setOpenLiftSummary(false)} lift={selectedLift} /> - )} - - ) : null; - }), -)({ - // This ensures that the resize handle is above the map. - '& > .react-resizable-handle': { - zIndex: 1001, - }, -}); diff --git a/packages/dashboard/src/components/map.tsx b/packages/dashboard/src/components/map.tsx new file mode 100644 index 000000000..2198c2b15 --- /dev/null +++ b/packages/dashboard/src/components/map.tsx @@ -0,0 +1,712 @@ +import { Box, styled, Typography, useMediaQuery } from '@mui/material'; +import { Line } from '@react-three/drei'; +import { Canvas, useLoader } from '@react-three/fiber'; +import { BuildingMap, FleetState, Level, Lift } from 'api-client'; +import Debug from 'debug'; +import React, { ChangeEvent, Suspense } from 'react'; +import { + ColorManager, + findSceneBoundingBoxFromThreeFiber, + getPlaces, + Place, + ReactThreeFiberImageMaker, + RobotData, + RobotTableData, + ShapeThreeRendering, + TextThreeRendering, +} from 'react-components'; +import { ErrorBoundary } from 'react-error-boundary'; +import { Door as DoorModel } from 'rmf-models/ros/rmf_building_map_msgs/msg'; +import { EMPTY, merge, scan, Subscription, switchMap, throttleTime } from 'rxjs'; +import { Box3, TextureLoader, Vector3 } from 'three'; + +import { useAppController } from '../hooks/use-app-controller'; +import { useAuthenticator } from '../hooks/use-authenticator'; +import { FleetResource, useResources } from '../hooks/use-resources'; +import { useRmfApi } from '../hooks/use-rmf-api'; +import { TrajectoryData } from '../services/robot-trajectory-manager'; +import { AppEvents } from './app-events'; +import { DoorSummary } from './door-summary'; +import { LiftSummary } from './lift-summary'; +import { RobotSummary } from './robots/robot-summary'; +import { CameraControl, Door, LayersController, Lifts, RobotThree } from './three-fiber'; + +const debug = Debug('MapApp'); + +const TrajectoryUpdateInterval = 2000; +// schedule visualizer manages it's own settings so that it doesn't cause a re-render +// of the whole app when it changes. +const colorManager = new ColorManager(); + +const DEFAULT_ROBOT_SCALE = 0.003; + +function getRobotId(fleetName: string, robotName: string): string { + return `${fleetName}/${robotName}`; +} + +export interface MapProps { + defaultMapLevel: string; + defaultZoom: number; + defaultRobotZoom: number; + attributionPrefix: string; +} + +export const Map = styled((props: MapProps) => { + const authenticator = useAuthenticator(); + const { fleets: fleetResources } = useResources(); + const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); + const rmfApi = useRmfApi(); + const { showAlert } = useAppController(); + const [currentLevel, setCurrentLevel] = React.useState(undefined); + const [disabledLayers, setDisabledLayers] = React.useState>({ + 'Pickup & Dropoff waypoints': false, + 'Pickup & Dropoff labels': true, + Waypoints: true, + 'Waypoint labels': true, + 'Doors & Lifts': false, + 'Doors labels': true, + Robots: false, + 'Robots labels': true, + Trajectories: false, + }); + const [openRobotSummary, setOpenRobotSummary] = React.useState(false); + const [openDoorSummary, setOpenDoorSummary] = React.useState(false); + const [openLiftSummary, setOpenLiftSummary] = React.useState(false); + const [selectedRobot, setSelectedRobot] = React.useState(); + const [selectedDoor, setSelectedDoor] = React.useState(); + const [selectedLift, setSelectedLift] = React.useState(); + + const [buildingMap, setBuildingMap] = React.useState(null); + + const [fleets, setFleets] = React.useState([]); + + const [waypoints, setWaypoints] = React.useState([]); + const [currentLevelOfRobots, setCurrentLevelOfRobots] = React.useState<{ + [key: string]: string; + }>({}); + + const [trajectories, setTrajectories] = React.useState([]); + const trajectoryTime = 300000; + const trajectoryAnimScale = trajectoryTime / (0.9 * TrajectoryUpdateInterval); + const trajManager = rmfApi?.trajectoryManager; + React.useEffect(() => { + if (!currentLevel) { + return; + } + + let cancel = false; + + const updateTrajectory = async () => { + debug('updating trajectories'); + + if (cancel || !trajManager) return; + + const resp = await trajManager.latestTrajectory({ + request: 'trajectory', + param: { + map_name: currentLevel.name, + duration: trajectoryTime, + trim: true, + }, + token: authenticator.token, + }); + const flatConflicts = resp.conflicts.flatMap((c) => c); + + debug('set trajectories'); + const trajectories = resp.values.map((v) => ({ + trajectory: v, + color: 'green', + conflict: flatConflicts.includes(v.id), + animationScale: trajectoryAnimScale, + loopAnimation: false, + })); + + // Filter trajectory due to https://github.com/open-rmf/rmf_visualization/issues/65 + for (const t of trajectories) { + if (t.trajectory.segments.length === 0) { + continue; + } + + const knot = t.trajectory.segments[0]; + if ((knot.x[0] < 1e-9 && knot.x[0] > -1e-9) || (knot.x[1] < 1e-9 && knot.x[1] > -1e-9)) { + t.trajectory.segments.shift(); + } + } + setTrajectories(trajectories); + }; + + updateTrajectory(); + const interval = window.setInterval(updateTrajectory, TrajectoryUpdateInterval); + debug(`created trajectory update interval ${interval}`); + + return () => { + cancel = true; + clearInterval(interval); + debug(`cleared interval ${interval}`); + }; + }, [trajManager, currentLevel, trajectoryTime, trajectoryAnimScale, authenticator.token]); + + React.useEffect(() => { + const levelByName = (map: BuildingMap, levelName?: string) => { + if (!levelName) { + return null; + } + for (const l of map.levels) { + if (l.name === levelName) { + return l; + } + } + return null; + }; + + const handleBuildingMap = (newMap: BuildingMap) => { + setBuildingMap(newMap); + const loggedInDisplayLevel = AppEvents.justLoggedIn.value + ? levelByName(newMap, props.defaultMapLevel) + : undefined; + const currentLevel = loggedInDisplayLevel || AppEvents.levelSelect.value || newMap.levels[0]; + AppEvents.levelSelect.next(currentLevel); + setWaypoints( + getPlaces(newMap).filter((p) => p.level === currentLevel.name && p.vertex.name.length > 0), + ); + AppEvents.justLoggedIn.next(false); + }; + + (async () => { + try { + const newMap = (await rmfApi.buildingApi.getBuildingMapBuildingMapGet()).data; + handleBuildingMap(newMap); + } catch (e) { + console.log(`failed to get building map: ${(e as Error).message}`); + } + })(); + + const subs: Subscription[] = []; + subs.push(rmfApi.buildingMapObs.subscribe((newMap) => handleBuildingMap(newMap))); + subs.push(rmfApi.fleetsObs.subscribe(setFleets)); + + return () => { + for (const sub of subs) { + sub.unsubscribe(); + } + }; + }, [rmfApi, props.defaultMapLevel]); + + const [imageUrl, setImageUrl] = React.useState(null); + // Since the configurable zoom level is for supporting the lowest resolution + // settings, we will double it for anything that is operating within modern + // resolution settings. + const defaultZoom = isScreenHeightLessThan800 ? props.defaultZoom : props.defaultZoom * 2; + const [zoom, setZoom] = React.useState(defaultZoom); + const [sceneBoundingBox, setSceneBoundingBox] = React.useState(undefined); + const [distance, setDistance] = React.useState(0); + + React.useEffect(() => { + const subs: Subscription[] = []; + subs.push( + AppEvents.zoom.subscribe((currentValue) => { + setZoom(currentValue || defaultZoom); + }), + ); + subs.push( + AppEvents.levelSelect.subscribe((currentValue) => { + const newSceneBoundingBox = currentValue + ? findSceneBoundingBoxFromThreeFiber(currentValue) + : undefined; + if (newSceneBoundingBox) { + const center = newSceneBoundingBox.getCenter(new Vector3()); + const size = newSceneBoundingBox.getSize(new Vector3()); + const distance = Math.max(size.x, size.y, size.z) * 0.7; + const newZoom = defaultZoom; + AppEvents.resetCamera.next([center.x, center.y, center.z + distance, newZoom]); + } + setCurrentLevel(currentValue ?? undefined); + setSceneBoundingBox(newSceneBoundingBox); + }), + ); + return () => { + for (const sub of subs) { + sub.unsubscribe(); + } + }; + }, [defaultZoom]); + + React.useEffect(() => { + if (!currentLevel?.images[0]) { + setImageUrl(null); + return; + } + + (async () => { + useLoader.preload(TextureLoader, currentLevel.images[0].data); + setImageUrl(currentLevel.images[0].data); + })(); + + buildingMap && + setWaypoints( + getPlaces(buildingMap).filter( + (p) => p.level === currentLevel.name && p.vertex.name.length > 0, + ), + ); + }, [buildingMap, currentLevel]); + + const [robots, setRobots] = React.useState([]); + const { current: robotsStore } = React.useRef>({}); + React.useEffect(() => { + (async () => { + if (!currentLevel) { + return; + } + const promises = Object.values(fleets).flatMap((fleetState) => { + if (!fleetState.name || !fleetState.robots) { + return null; + } + const robotKey = Object.keys(fleetState.robots); + const fleetName = fleetState.name; + return robotKey.map(async (r) => { + const robotId = getRobotId(fleetName, r); + const fleetResource: FleetResource | undefined = fleetResources[fleetName]; + if (robotId in robotsStore) return; + robotsStore[robotId] = { + fleet: fleetName, + name: r, + // no model name + model: '', + scale: fleetResource?.default.scale || DEFAULT_ROBOT_SCALE, + footprint: 0.5, + color: await colorManager.robotPrimaryColor(fleetName, r, ''), + iconPath: fleetResource?.default.icon || undefined, + }; + }); + }); + await Promise.all(promises); + const newRobots = Object.values(fleets).flatMap((fleetState) => { + const robotKey = fleetState.robots ? Object.keys(fleetState.robots) : []; + return robotKey + ?.filter( + (r) => + fleetState.robots && + r in currentLevelOfRobots && + currentLevelOfRobots[r] === currentLevel.name && + `${fleetState.name}/${r}` in robotsStore, + ) + .map((r) => robotsStore[`${fleetState.name}/${r}`]); + }); + setRobots(newRobots); + })(); + }, [fleets, fleetResources, robotsStore, currentLevel, currentLevelOfRobots]); + + const { current: robotLocations } = React.useRef< + Record + >({}); + // updates the robot location + React.useEffect(() => { + const sub = rmfApi.fleetsObs + .pipe( + switchMap((fleets) => + merge( + ...fleets.map((f) => + f.name + ? rmfApi + .getFleetStateObs(f.name) + .pipe(throttleTime(500, undefined, { leading: true, trailing: true })) + : EMPTY, + ), + ), + ), + ) + .subscribe((fleetState) => { + const fleetName = fleetState.name; + if (!fleetName || !fleetState.robots) { + console.warn('Map: Fail to update robot location (missing fleet name or robots)'); + return; + } + Object.entries(fleetState.robots).forEach(([robotName, robotState]) => { + const robotId = getRobotId(fleetName, robotName); + if (!robotState.location) { + console.warn(`Map: Fail to update robot location for ${robotId} (missing location)`); + return; + } + robotLocations[robotId] = [ + robotState.location.x, + robotState.location.y, + robotState.location.yaw, + robotState.location.map, + ]; + + setCurrentLevelOfRobots((prevState) => { + if (!robotState.location?.map && prevState.robotName) { + console.warn(`Map: Fail to update robot level for ${robotId} (missing map)`); + const updatedState = { ...prevState }; + delete updatedState[robotName]; + return updatedState; + } + + return { + ...prevState, + [robotName]: robotState.location?.map || '', + }; + }); + }); + }); + return () => sub.unsubscribe(); + }, [rmfApi, robotLocations]); + + //Accumulate values over time to persist between tabs + React.useEffect(() => { + const sub = AppEvents.disabledLayers + .pipe(scan((acc, value) => ({ ...acc, ...value }), {})) + .subscribe((layers) => { + setDisabledLayers(layers); + }); + return () => sub.unsubscribe(); + }, []); + + // zoom to robot on select + React.useEffect(() => { + const subs: Subscription[] = []; + + // Centering on robot + subs.push( + AppEvents.robotSelect.subscribe((data) => { + if (!data || !sceneBoundingBox) { + return; + } + const [fleetName, robotName] = data; + const robotId = getRobotId(fleetName, robotName); + const robotLocation = robotLocations[robotId]; + if (!robotLocation) { + console.warn(`Map: Failed to zoom to robot ${robotId} (robot location was not found)`); + return; + } + + const mapName = robotLocation[3]; + let newSceneBoundingBox = sceneBoundingBox; + if ( + AppEvents.levelSelect.value && + AppEvents.levelSelect.value.name !== mapName && + buildingMap + ) { + const robotLevel = + buildingMap.levels.find((l: Level) => l.name === mapName) || buildingMap.levels[0]; + AppEvents.levelSelect.next(robotLevel); + + const robotLevelSceneBoundingBox = findSceneBoundingBoxFromThreeFiber(robotLevel); + if (!robotLevelSceneBoundingBox) { + return; + } + newSceneBoundingBox = robotLevelSceneBoundingBox; + setSceneBoundingBox(newSceneBoundingBox); + } + + const size = newSceneBoundingBox.getSize(new Vector3()); + const distance = Math.max(size.x, size.y, size.z) * 0.7; + const newZoom = props.defaultRobotZoom; + AppEvents.resetCamera.next([ + robotLocation[0], + robotLocation[1], + robotLocation[2] + distance, + newZoom, + ]); + }), + ); + + // Centering on door + subs.push( + AppEvents.doorSelect.subscribe((door) => { + if (!door || !sceneBoundingBox) { + return; + } + + const [mapName, doorInfo] = door; + + let newSceneBoundingBox = sceneBoundingBox; + if ( + AppEvents.levelSelect.value && + AppEvents.levelSelect.value.name !== mapName && + buildingMap + ) { + const doorLevel = + buildingMap.levels.find((l: Level) => l.name === mapName) || buildingMap.levels[0]; + AppEvents.levelSelect.next(doorLevel); + + const doorLevelSceneBoundingBox = findSceneBoundingBoxFromThreeFiber(doorLevel); + if (!doorLevelSceneBoundingBox) { + return; + } + newSceneBoundingBox = doorLevelSceneBoundingBox; + setSceneBoundingBox(newSceneBoundingBox); + } + + const size = newSceneBoundingBox.getSize(new Vector3()); + const distance = Math.max(size.x, size.y, size.z) * 0.7; + const newZoom = props.defaultRobotZoom; + AppEvents.resetCamera.next([ + (doorInfo.v1_x + doorInfo.v2_x) / 2, + (doorInfo.v1_y + doorInfo.v2_y) / 2, + distance, + newZoom, + ]); + }), + ); + + // Centering on lift + subs.push( + AppEvents.liftSelect.subscribe((lift) => { + if (!lift || !sceneBoundingBox) { + return; + } + + const size = sceneBoundingBox.getSize(new Vector3()); + const distance = Math.max(size.x, size.y, size.z) * 0.7; + const newZoom = props.defaultRobotZoom; + AppEvents.resetCamera.next([lift.ref_x, lift.ref_y, distance, newZoom]); + }), + ); + + return () => { + for (const sub of subs) { + sub.unsubscribe(); + } + }; + }, [robotLocations, sceneBoundingBox, buildingMap, props.defaultRobotZoom]); + + React.useEffect(() => { + if (!sceneBoundingBox) { + return; + } + + const size = sceneBoundingBox.getSize(new Vector3()); + setDistance(Math.max(size.x, size.y, size.z) * 0.7); + }, [sceneBoundingBox]); + + return buildingMap && currentLevel && robotLocations ? ( + + , value: string) => { + AppEvents.levelSelect.next( + buildingMap.levels.find((l: Level) => l.name === value) || buildingMap.levels[0], + ); + }} + handleFullView={() => { + if (!sceneBoundingBox) { + return; + } + const center = sceneBoundingBox.getCenter(new Vector3()); + const size = sceneBoundingBox.getSize(new Vector3()); + const distance = Math.max(size.x, size.y, size.z) * 0.7; + const newZoom = defaultZoom; + AppEvents.resetCamera.next([center.x, center.y, center.z + distance, newZoom]); + }} + handleZoomIn={() => AppEvents.zoomIn.next()} + handleZoomOut={() => AppEvents.zoomOut.next()} + /> + + + {props.attributionPrefix} + + + { + if (!sceneBoundingBox) { + return; + } + const center = sceneBoundingBox.getCenter(new Vector3()); + camera.position.set(center.x, center.y, center.z + distance); + camera.zoom = zoom; + camera.updateProjectionMatrix(); + }} + orthographic={true} + > + + {!disabledLayers['Pickup & Dropoff waypoints'] && + waypoints + .filter((waypoint) => waypoint.pickupHandler || waypoint.dropoffHandler) + .map((place, index) => ( + + ))} + {!disabledLayers['Pickup & Dropoff labels'] && + waypoints + .filter((waypoint) => waypoint.pickupHandler || waypoint.dropoffHandler) + .map((place, index) => ( + + ))} + {!disabledLayers['Waypoints'] && + waypoints + .filter((waypoint) => !waypoint.pickupHandler && !waypoint.dropoffHandler) + .map((place, index) => ( + + ))} + {!disabledLayers['Waypoint labels'] && + waypoints + .filter((waypoint) => !waypoint.pickupHandler && !waypoint.dropoffHandler) + .map((place, index) => ( + + ))} + {buildingMap.lifts.length > 0 + ? buildingMap.lifts.map((lift) => + lift.doors.map((door, i) => ( + + {!disabledLayers['Doors labels'] && ( + + )} + {!disabledLayers['Doors & Lifts'] && ( + { + setOpenLiftSummary(true); + setSelectedLift(lift); + }} + /> + )} + + )), + ) + : null} + {!disabledLayers['Doors & Lifts'] && buildingMap.lifts.length > 0 + ? buildingMap.lifts.map((lift) => + lift.doors.map(() => ( + { + setOpenLiftSummary(true); + setSelectedLift(lift); + }} + /> + )), + ) + : null} + {currentLevel.doors.length > 0 + ? currentLevel.doors.map((door, i) => ( + + {!disabledLayers['Doors labels'] && ( + + )} + {!disabledLayers['Doors'] && ( + { + setOpenDoorSummary(true); + setSelectedDoor(door); + }} + /> + )} + + )) + : null} + {currentLevel.images.length > 0 && imageUrl && ( + } + onError={(error, info) => { + console.error(error); + console.log(info); + showAlert( + 'error', + 'Unable to retrieve building map images. Please ensure that the building map server is operational and without issues.', + 20000, + ); + }} + > + + + )} + {!disabledLayers['Robots'] && + robots.map((robot) => { + const robotId = `${robot.fleet}/${robot.name}`; + if (robotId in robotLocations) { + const location = robotLocations[robotId]; + const position: [number, number, number] = [location[0], location[1], location[2]]; + return ( + { + setOpenRobotSummary(true); + setSelectedRobot(robot); + }} + robotLabel={!disabledLayers['Robots labels']} + /> + ); + } + return null; + })} + {!disabledLayers['Trajectories'] && + trajectories.map((trajData) => ( + new Vector3(seg.x[0], seg.x[1], 4))} + color={trajData.color} + linewidth={5} + /> + ))} + + + {openRobotSummary && selectedRobot && ( + setOpenRobotSummary(false)} /> + )} + {openDoorSummary && selectedDoor && ( + setOpenDoorSummary(false)} + door={selectedDoor} + level={currentLevel} + /> + )} + + {openLiftSummary && selectedLift && ( + setOpenLiftSummary(false)} lift={selectedLift} /> + )} + + ) : null; +})({ + // This ensures that the resize handle is above the map. + '& > .react-resizable-handle': { + zIndex: 1001, + }, +}); + +export default Map; diff --git a/packages/dashboard/src/components/micro-app.tsx b/packages/dashboard/src/components/micro-app.tsx index 245edc3ec..8dd68d855 100644 --- a/packages/dashboard/src/components/micro-app.tsx +++ b/packages/dashboard/src/components/micro-app.tsx @@ -1,26 +1,49 @@ -import React from 'react'; -import { Window } from 'react-components'; +import React, { Suspense } from 'react'; +import { Window, WindowProps } from 'react-components'; -export interface MicroAppProps { - key: string; - onClose?: () => void; +import { useSettings } from '../hooks/use-settings'; +import { Settings } from '../services/settings'; + +export type MicroAppProps = Omit; + +export interface MicroAppManifest { + appId: string; + displayName: string; + Component: React.ComponentType; } -export function createMicroApp( - title: string, - Component: React.ComponentType<{}>, -): React.ComponentType { - return React.memo( - React.forwardRef( - ( - { children, ...otherProps }: React.PropsWithChildren, - ref: React.Ref, - ) => ( - - - {children} - - ), - ), - ); +/** + * Creates a micro app from a component. The component must be loaded using dynamic import. + * Note that the map should be created in a different module than the component. + * + * Example: + * ```ts + * createMicroApp('Map', 'Map', () => import('./map'), config); + * ``` + */ +export function createMicroApp

( + appId: string, + displayName: string, + loadComponent: () => Promise<{ default: React.ComponentType

}>, + props: (settings: Settings) => React.PropsWithoutRef

& React.Attributes, +): MicroAppManifest { + const LazyComponent = React.lazy(loadComponent); + return { + appId, + displayName, + Component: React.forwardRef( + ({ children, ...otherProps }: React.PropsWithChildren, ref) => { + const settings = useSettings(); + return ( + + + + + {/* this contains the resize handle */} + {children} + + ); + }, + ) as React.ComponentType, + }; } diff --git a/packages/dashboard/src/components/private-route.test.tsx b/packages/dashboard/src/components/private-route.test.tsx deleted file mode 100644 index 23706687c..000000000 --- a/packages/dashboard/src/components/private-route.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { render } from '@testing-library/react'; -import { createMemoryHistory, MemoryHistory } from 'history'; -import { Router } from 'react-router-dom'; -import { beforeEach, describe, expect, it } from 'vitest'; - -import { PrivateRoute } from './private-route'; - -describe('PrivateRoute', () => { - let history: MemoryHistory; - - beforeEach(() => { - history = createMemoryHistory(); - history.push('/private'); - }); - - it('renders unauthorizedComponent when unauthenticated', () => { - const root = render( - - - , - ); - expect(() => root.getByText('test')).not.toThrow(); - }); - - it('renders children when authenticated', () => { - const root = render( - - - authorized - - , - ); - expect(() => root.getByText('authorized')).not.toThrow(); - }); -}); diff --git a/packages/dashboard/src/components/private-route.tsx b/packages/dashboard/src/components/private-route.tsx deleted file mode 100644 index 81cf292aa..000000000 --- a/packages/dashboard/src/components/private-route.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import { RouteProps } from 'react-router-dom'; - -export interface PrivateRouteProps { - user: string | null; - /** - * Component to render if `user` is undefined. - */ - unauthorizedComponent?: React.ReactNode; -} - -export const PrivateRoute = ({ - user, - unauthorizedComponent = 'Unauthorized', - children, -}: React.PropsWithChildren): JSX.Element => { - return <>{user ? children : unauthorizedComponent}; -}; - -export default PrivateRoute; diff --git a/packages/dashboard/src/components/react-three-fiber-hack.d.ts b/packages/dashboard/src/components/react-three-fiber-hack.d.ts new file mode 100644 index 000000000..5887002ee --- /dev/null +++ b/packages/dashboard/src/components/react-three-fiber-hack.d.ts @@ -0,0 +1,10 @@ +import '../../../react-components/lib/react-three-fiber-hack'; + +// hack to export only the intrinsic elements that we use +import { ThreeElements } from '@react-three/fiber'; + +declare global { + namespace JSX { + interface IntrinsicElements extends Pick {} + } +} diff --git a/packages/dashboard/src/components/rmf-app.tsx b/packages/dashboard/src/components/rmf-app.tsx deleted file mode 100644 index 61113a742..000000000 --- a/packages/dashboard/src/components/rmf-app.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; - -import { AppConfigContext, AuthenticatorContext } from '../app-config'; -import { RmfIngress } from '../services/rmf-ingress'; -import { UserProfileProvider } from './user-profile-provider'; - -export * from '../services/rmf-ingress'; - -export const RmfAppContext = React.createContext(undefined); - -export interface RmfAppProps extends React.PropsWithChildren<{}> {} - -export function RmfApp(props: RmfAppProps): JSX.Element { - const appConfig = React.useContext(AppConfigContext); - const authenticator = React.useContext(AuthenticatorContext); - const [rmfIngress, setRmfIngress] = React.useState(undefined); - - React.useEffect(() => { - if (authenticator.user) { - return setRmfIngress(new RmfIngress(appConfig, authenticator)); - } else { - authenticator.once('userChanged', () => - setRmfIngress(new RmfIngress(appConfig, authenticator)), - ); - return undefined; - } - }, [authenticator, appConfig]); - - return ( - - {props.children} - - ); -} diff --git a/packages/dashboard/src/components/rmf-dashboard.tsx b/packages/dashboard/src/components/rmf-dashboard.tsx new file mode 100644 index 000000000..feb40bd8a --- /dev/null +++ b/packages/dashboard/src/components/rmf-dashboard.tsx @@ -0,0 +1,342 @@ +import { + Alert, + AlertProps, + Container, + CssBaseline, + Snackbar, + Tab, + ThemeProvider, + Typography, +} from '@mui/material'; +import React, { useTransition } from 'react'; +import { getDefaultTaskDefinition, LocalizationProvider } from 'react-components'; +import { matchPath, Navigate, Outlet, Route, Routes, useLocation, useNavigate } from 'react-router'; +import { BrowserRouter } from 'react-router-dom'; + +import { AppController, AppControllerProvider } from '../hooks/use-app-controller'; +import { AuthenticatorProvider } from '../hooks/use-authenticator'; +import { Resources, ResourcesProvider } from '../hooks/use-resources'; +import { RmfApiProvider } from '../hooks/use-rmf-api'; +import { SettingsProvider } from '../hooks/use-settings'; +import { TaskRegistry, TaskRegistryProvider } from '../hooks/use-task-registry'; +import { UserProfileProvider, useUserProfile } from '../hooks/use-user-profile'; +import { LoginPage } from '../pages'; +import { Authenticator, UserProfile } from '../services/authenticator'; +import { DefaultRmfApi } from '../services/rmf-api'; +import { loadSettings, saveSettings, Settings } from '../services/settings'; +import { AlertManager } from './alert-manager'; +import AppBar, { APP_BAR_HEIGHT } from './appbar'; +import { DeliveryAlertStore } from './delivery-alert-store'; +import { DashboardThemes } from './theme'; + +const DefaultAlertDuration = 2000; + +export interface DashboardHome {} + +export interface DashboardTab { + name: string; + route: string; + element: React.ReactNode; +} + +export interface AllowedTask { + /** + * The task definition to configure. + */ + taskDefinitionId: 'patrol' | 'delivery' | 'compose-clean' | 'custom_compose'; + + /** + * Configure the display name for the task definition. + */ + displayName?: string; + + /** + * The color of the event when rendered on the task scheduler in the form of a CSS color string. + */ + scheduleEventColor?: string; +} + +export interface TaskRegistryInput extends Omit { + allowedTasks: AllowedTask[]; +} + +export interface RmfDashboardProps { + /** + * Url of the RMF api server. + */ + apiServerUrl: string; + + /** + * Url of the RMF trajectory server. + */ + trajectoryServerUrl: string; + + authenticator: Authenticator; + + /** + * Url to be linked for the "help" button. + */ + helpLink: string; + + /** + * Url to be linked for the "report issue" button. + */ + reportIssueLink: string; + + themes?: DashboardThemes; + + /** + * Set various resources (icons, logo etc) used. Different resource can be used based on the theme, `default` is always required. + */ + resources: Resources; + + /** + * List of allowed tasks that can be requested + */ + tasks: TaskRegistryInput; + + /** + * List of tabs on the app bar. + */ + tabs: DashboardTab[]; + + /** + * Prefix where other routes will be based on, defaults to `import.meta.env.BASE_URL`. + * Must end with a slash + */ + baseUrl?: string; + + /** + * Url to a file to be played when an alert occurs on the dashboard. + */ + alertAudioPath?: string; +} + +export function RmfDashboard(props: RmfDashboardProps) { + const { + apiServerUrl, + trajectoryServerUrl, + authenticator, + themes, + resources, + tasks, + alertAudioPath, + } = props; + + const rmfApi = React.useMemo( + () => new DefaultRmfApi(apiServerUrl, trajectoryServerUrl, authenticator), + [apiServerUrl, trajectoryServerUrl, authenticator], + ); + + // FIXME(koonepng): This should be fully definition in tasks resources when the dashboard actually + // supports configuring all the fields. + const taskRegistry = React.useMemo( + () => ({ + taskDefinitions: tasks.allowedTasks.map((t) => { + const defaultTaskDefinition = getDefaultTaskDefinition(t.taskDefinitionId); + if (!defaultTaskDefinition) { + throw Error(`Invalid tasks configured for dashboard: [${t.taskDefinitionId}]`); + } + const taskDefinition = { ...defaultTaskDefinition }; + if (t.displayName !== undefined) { + taskDefinition.taskDisplayName = t.displayName; + } + if (t.scheduleEventColor !== undefined) { + taskDefinition.scheduleEventColor = t.scheduleEventColor; + } + return taskDefinition; + }), + pickupZones: tasks.pickupZones, + cartIds: tasks.cartIds, + }), + [tasks.allowedTasks, tasks.pickupZones, tasks.cartIds], + ); + + const [userProfile, setUserProfile] = React.useState(null); + React.useEffect(() => { + (async () => { + await authenticator.init(); + const user = (await rmfApi.defaultApi.getUserUserGet()).data; + const perm = (await rmfApi.defaultApi.getEffectivePermissionsPermissionsGet()).data; + setUserProfile({ user, permissions: perm }); + })(); + }, [authenticator, rmfApi]); + + const [settings, setSettings] = React.useState(() => loadSettings()); + const updateSettings = React.useCallback((newSettings: Settings) => { + saveSettings(newSettings); + setSettings(newSettings); + }, []); + + const [showAlert, setShowAlert] = React.useState(false); + const [alertSeverity, setAlertSeverity] = React.useState('error'); + const [alertMessage, setAlertMessage] = React.useState(''); + const [alertDuration, setAlertDuration] = React.useState(DefaultAlertDuration); + const [extraAppbarItems, setExtraAppbarItems] = React.useState(null); + const appController = React.useMemo( + () => ({ + updateSettings, + showAlert: (severity, message, autoHideDuration) => { + setAlertSeverity(severity); + setAlertMessage(message); + setShowAlert(true); + setAlertDuration(autoHideDuration || DefaultAlertDuration); + }, + setExtraAppbarItems, + }), + [updateSettings], + ); + + const theme = React.useMemo(() => { + if (!themes) { + return null; + } + return themes[settings.themeMode] || themes.default; + }, [themes, settings.themeMode]); + + const providers = userProfile && ( + + + + + + + + + + + + + + {/* TODO: Support stacking of alerts */} + setShowAlert(false)} + autoHideDuration={alertDuration} + > + setShowAlert(false)} + severity={alertSeverity} + sx={{ width: '100%' }} + > + {alertMessage} + + + + + + + + + + + + ); + + return theme ? {providers} : providers; +} + +interface RequireAuthProps { + redirectTo: string; + children: React.ReactNode; +} + +function RequireAuth({ redirectTo, children }: RequireAuthProps) { + const userProfile = useUserProfile(); + return userProfile ? children : ; +} + +function NotFound() { + return ( + + + 404 - Not Found + + + The page you're looking for doesn't exist. + + + ); +} + +interface DashboardContentsProps extends RmfDashboardProps { + extraAppbarItems: React.ReactNode; +} + +function DashboardContents({ + authenticator, + helpLink, + reportIssueLink, + themes, + resources, + tabs, + baseUrl = import.meta.env.BASE_URL, + extraAppbarItems, +}: DashboardContentsProps) { + const location = useLocation(); + const currentTab = tabs.find((t) => matchPath(t.route, location.pathname)); + + const [pendingTransition, startTransition] = useTransition(); + const navigate = useNavigate(); + + // TODO(koonpeng): enable admin tab when authz is implemented. + const allTabs = tabs; + // const allTabs = React.useMemo( + // () => [...tabs, { name: 'Admin', route: 'admin', element: }], + // [tabs], + // ); + + return ( + + + authenticator.login(`${window.location.origin}${baseUrl}`)} + /> + } + /> + + ( + {t.name}} + value={t.name} + onClick={() => { + startTransition(() => { + navigate(`${baseUrl}${t.route}`); + }); + }} + /> + ))} + tabValue={currentTab!.name} + themes={themes} + helpLink={helpLink} + reportIssueLink={reportIssueLink} + extraToolbarItems={extraAppbarItems} + /> + {!pendingTransition && } + + } + > + {allTabs.map((t) => ( + {t.element}} + /> + ))} + + + } /> + + ); +} diff --git a/packages/dashboard/src/components/robots/robot-decommission.tsx b/packages/dashboard/src/components/robots/robot-decommission.tsx index bb9de0a13..854e88233 100644 --- a/packages/dashboard/src/components/robots/robot-decommission.tsx +++ b/packages/dashboard/src/components/robots/robot-decommission.tsx @@ -11,9 +11,9 @@ import { RobotState } from 'api-client'; import React from 'react'; import { ConfirmationDialog } from 'react-components'; -import { AppControllerContext } from '../app-contexts'; +import { useAppController } from '../../hooks/use-app-controller'; +import { useRmfApi } from '../../hooks/use-rmf-api'; import { AppEvents } from '../app-events'; -import { RmfAppContext } from '../rmf-app'; export interface RobotDecommissionButtonProp extends Omit { fleet: string; @@ -24,9 +24,9 @@ export function RobotDecommissionButton({ fleet, robotState, ...otherProps -}: RobotDecommissionButtonProp): JSX.Element { - const rmf = React.useContext(RmfAppContext); - const appController = React.useContext(AppControllerContext); +}: RobotDecommissionButtonProp) { + const rmfApi = useRmfApi(); + const appController = useAppController(); const [reassignTasks, setReassignTasks] = React.useState(true); const [allowIdleBehavior, setAllowIdleBehavior] = React.useState(false); const [openConfirmDialog, setOpenConfirmDialog] = React.useState(false); @@ -60,10 +60,7 @@ export function RobotDecommissionButton({ return; } try { - if (!rmf) { - throw new Error('fleets api not available'); - } - const resp = await rmf.fleetsApi?.decommissionRobotFleetsNameDecommissionPost( + const resp = await rmfApi.fleetsApi?.decommissionRobotFleetsNameDecommissionPost( fleet, robotState.name, reassignTasks, @@ -109,17 +106,14 @@ export function RobotDecommissionButton({ } resetDecommissionConfiguration(); AppEvents.refreshRobotApp.next(); - }, [appController, fleet, robotState, reassignTasks, allowIdleBehavior, rmf]); + }, [appController, fleet, robotState, reassignTasks, allowIdleBehavior, rmfApi]); const handleRecommission = React.useCallback(async () => { if (!robotState || !robotState.name) { return; } try { - if (!rmf) { - throw new Error('fleets api not available'); - } - const resp = await rmf.fleetsApi?.recommissionRobotFleetsNameRecommissionPost( + const resp = await rmfApi.fleetsApi?.recommissionRobotFleetsNameRecommissionPost( fleet, robotState.name, ); @@ -141,7 +135,7 @@ export function RobotDecommissionButton({ } resetDecommissionConfiguration(); AppEvents.refreshRobotApp.next(); - }, [appController, fleet, robotState, rmf]); + }, [appController, fleet, robotState, rmfApi]); return ( <> diff --git a/packages/dashboard/src/components/robots/robot-info-app.tsx b/packages/dashboard/src/components/robots/robot-info-card.tsx similarity index 87% rename from packages/dashboard/src/components/robots/robot-info-app.tsx rename to packages/dashboard/src/components/robots/robot-info-card.tsx index 400f5815b..d534852f6 100644 --- a/packages/dashboard/src/components/robots/robot-info-app.tsx +++ b/packages/dashboard/src/components/robots/robot-info-card.tsx @@ -4,19 +4,15 @@ import React from 'react'; import { RobotInfo } from 'react-components'; import { combineLatest, EMPTY, mergeMap, of, switchMap, throttleTime } from 'rxjs'; +import { useRmfApi } from '../../hooks/use-rmf-api'; import { AppEvents } from '../app-events'; -import { createMicroApp } from '../micro-app'; -import { RmfAppContext } from '../rmf-app'; -export const RobotInfoApp = createMicroApp('Robot Info', () => { - const rmf = React.useContext(RmfAppContext); +export const RobotInfoCard = () => { + const rmfApi = useRmfApi(); const [robotState, setRobotState] = React.useState(null); const [taskState, setTaskState] = React.useState(null); React.useEffect(() => { - if (!rmf) { - return; - } const sub = AppEvents.robotSelect .pipe( switchMap((data) => { @@ -24,12 +20,12 @@ export const RobotInfoApp = createMicroApp('Robot Info', () => { return of([null, null]); } const [fleet, name] = data; - return rmf.getFleetStateObs(fleet).pipe( + return rmfApi.getFleetStateObs(fleet).pipe( throttleTime(3000, undefined, { leading: true, trailing: true }), mergeMap((fleetState) => { const robotState = fleetState?.robots?.[name]; const taskObs = robotState?.task_id - ? rmf.getTaskStateObs(robotState.task_id) + ? rmfApi.getTaskStateObs(robotState.task_id) : of(null); return robotState ? combineLatest([of(robotState), taskObs]) : EMPTY; }), @@ -41,7 +37,7 @@ export const RobotInfoApp = createMicroApp('Robot Info', () => { setTaskState(taskState); }); return () => sub.unsubscribe(); - }, [rmf]); + }, [rmfApi]); const taskProgress = React.useMemo(() => { if ( @@ -87,4 +83,6 @@ export const RobotInfoApp = createMicroApp('Robot Info', () => { )} ); -}); +}; + +export default RobotInfoCard; diff --git a/packages/dashboard/src/components/robots/robot-mutex-group-app.tsx b/packages/dashboard/src/components/robots/robot-mutex-group-table.tsx similarity index 87% rename from packages/dashboard/src/components/robots/robot-mutex-group-app.tsx rename to packages/dashboard/src/components/robots/robot-mutex-group-table.tsx index 67301c66e..7fbfe6c2e 100644 --- a/packages/dashboard/src/components/robots/robot-mutex-group-app.tsx +++ b/packages/dashboard/src/components/robots/robot-mutex-group-table.tsx @@ -2,15 +2,14 @@ import { TableContainer, Typography } from '@mui/material'; import React from 'react'; import { ConfirmationDialog, MutexGroupData, MutexGroupTable } from 'react-components'; -import { AppControllerContext } from '../app-contexts'; -import { createMicroApp } from '../micro-app'; -import { RmfAppContext } from '../rmf-app'; +import { useAppController } from '../../hooks/use-app-controller'; +import { useRmfApi } from '../../hooks/use-rmf-api'; const RefreshMutexGroupTableInterval = 5000; -export const MutexGroupsApp = createMicroApp('Mutex Groups', () => { - const rmf = React.useContext(RmfAppContext); - const appController = React.useContext(AppControllerContext); +export const RobotMutexGroupsTable = () => { + const rmfApi = useRmfApi(); + const appController = useAppController(); const [mutexGroups, setMutexGroups] = React.useState>({}); const [selectedMutexGroup, setSelectedMutexGroup] = React.useState(null); @@ -40,13 +39,8 @@ export const MutexGroupsApp = createMicroApp('Mutex Groups', () => { }; React.useEffect(() => { - if (!rmf) { - console.error('Unable to get latest robot information, fleets API unavailable'); - return; - } - const refreshMutexGroupTable = async () => { - const fleets = (await rmf.fleetsApi.getFleetsFleetsGet()).data; + const fleets = (await rmfApi.fleetsApi.getFleetsFleetsGet()).data; const updatedMutexGroups: Record = {}; for (const fleet of fleets) { if (!fleet.name || !fleet.robots) { @@ -112,7 +106,7 @@ export const MutexGroupsApp = createMicroApp('Mutex Groups', () => { return () => { clearInterval(refreshInterval); }; - }, [rmf]); + }, [rmfApi]); const handleUnlockMutexGroup = React.useCallback(async () => { if (!selectedMutexGroup || !selectedMutexGroup.lockedBy) { @@ -125,11 +119,7 @@ export const MutexGroupsApp = createMicroApp('Mutex Groups', () => { } try { - if (!rmf) { - throw new Error('fleets api not available'); - } - - await rmf.fleetsApi?.unlockMutexGroupFleetsNameUnlockMutexGroupPost( + await rmfApi.fleetsApi?.unlockMutexGroupFleetsNameUnlockMutexGroupPost( fleet, robot, selectedMutexGroup.name, @@ -147,10 +137,10 @@ export const MutexGroupsApp = createMicroApp('Mutex Groups', () => { ); } setSelectedMutexGroup(null); - }, [selectedMutexGroup, rmf, appController]); + }, [selectedMutexGroup, rmfApi, appController]); return ( - + { @@ -177,4 +167,6 @@ export const MutexGroupsApp = createMicroApp('Mutex Groups', () => { ); -}); +}; + +export default RobotMutexGroupsTable; diff --git a/packages/dashboard/src/components/robots/robot-summary.tsx b/packages/dashboard/src/components/robots/robot-summary.tsx index d7ab60381..e0071d59c 100644 --- a/packages/dashboard/src/components/robots/robot-summary.tsx +++ b/packages/dashboard/src/components/robots/robot-summary.tsx @@ -34,7 +34,7 @@ import React from 'react'; import { base, RobotTableData } from 'react-components'; import { combineLatest, EMPTY, mergeMap, of } from 'rxjs'; -import { RmfAppContext } from '../rmf-app'; +import { useRmfApi } from '../../hooks/use-rmf-api'; import { TaskCancelButton } from '../tasks/task-cancellation'; import { TaskInspector } from '../tasks/task-inspector'; import { RobotDecommissionButton } from './robot-decommission'; @@ -104,7 +104,7 @@ const showBatteryIcon = (robot: RobotState, robotBattery: number) => { export const RobotSummary = React.memo(({ onClose, robot }: RobotSummaryProps) => { const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); - const rmf = React.useContext(RmfAppContext); + const rmfApi = useRmfApi(); const [isOpen, setIsOpen] = React.useState(true); const [robotState, setRobotState] = React.useState(null); @@ -114,15 +114,14 @@ export const RobotSummary = React.memo(({ onClose, robot }: RobotSummaryProps) = const [navigationDestination, setNavigationDestination] = React.useState(null); React.useEffect(() => { - if (!rmf) { - return; - } - const sub = rmf + const sub = rmfApi .getFleetStateObs(robot.fleet) .pipe( mergeMap((fleetState) => { const robotState = fleetState?.robots?.[robot.name]; - const taskObs = robotState?.task_id ? rmf.getTaskStateObs(robotState.task_id) : of(null); + const taskObs = robotState?.task_id + ? rmfApi.getTaskStateObs(robotState.task_id) + : of(null); return robotState ? combineLatest([of(robotState), taskObs]) : EMPTY; }), ) @@ -131,7 +130,7 @@ export const RobotSummary = React.memo(({ onClose, robot }: RobotSummaryProps) = setTaskState(taskState); }); return () => sub.unsubscribe(); - }, [rmf, robot.fleet, robot.name]); + }, [rmfApi, robot.fleet, robot.name]); const taskProgress = React.useMemo(() => { if ( diff --git a/packages/dashboard/src/components/robots/robots-app.tsx b/packages/dashboard/src/components/robots/robots-table.tsx similarity index 86% rename from packages/dashboard/src/components/robots/robots-app.tsx rename to packages/dashboard/src/components/robots/robots-table.tsx index 14ba43c5d..0bc99c56c 100644 --- a/packages/dashboard/src/components/robots/robots-app.tsx +++ b/packages/dashboard/src/components/robots/robots-table.tsx @@ -3,28 +3,22 @@ import { TaskStateOutput as TaskState } from 'api-client'; import React from 'react'; import { RobotDataGridTable, RobotTableData } from 'react-components'; +import { useRmfApi } from '../../hooks/use-rmf-api'; import { AppEvents } from '../app-events'; -import { createMicroApp } from '../micro-app'; -import { RmfAppContext } from '../rmf-app'; import { RobotSummary } from './robot-summary'; const RefreshRobotTableInterval = 10000; -export const RobotsApp = createMicroApp('Robots', () => { - const rmf = React.useContext(RmfAppContext); +export const RobotsTable = () => { + const rmfApi = useRmfApi(); const [robots, setRobots] = React.useState>({}); const [openRobotSummary, setOpenRobotSummary] = React.useState(false); const [selectedRobot, setSelectedRobot] = React.useState(); React.useEffect(() => { - if (!rmf) { - console.error('Unable to get latest robot information, fleets API unavailable'); - return; - } - const refreshRobotTable = async () => { - const fleets = (await rmf.fleetsApi.getFleetsFleetsGet()).data; + const fleets = (await rmfApi.fleetsApi.getFleetsFleetsGet()).data; for (const fleet of fleets) { // fetch active tasks const taskIds = fleet.robots @@ -38,7 +32,7 @@ export const RobotsApp = createMicroApp('Robots', () => { const tasks = taskIds.length > 0 - ? (await rmf.tasksApi.queryTaskStatesTasksGet(taskIds.join(','))).data.reduce( + ? (await rmfApi.tasksApi.queryTaskStatesTasksGet(taskIds.join(','))).data.reduce( (acc, task) => { acc[task.booking.id] = task; return acc; @@ -93,10 +87,10 @@ export const RobotsApp = createMicroApp('Robots', () => { clearInterval(refreshInterval); sub.unsubscribe(); }; - }, [rmf]); + }, [rmfApi]); return ( - + r)} onRobotClick={(_ev, robot) => { @@ -110,4 +104,6 @@ export const RobotsApp = createMicroApp('Robots', () => { )} ); -}); +}; + +export default RobotsTable; diff --git a/packages/dashboard/src/components/tasks/task-cancellation.tsx b/packages/dashboard/src/components/tasks/task-cancellation.tsx index a8b253f71..2c078b254 100644 --- a/packages/dashboard/src/components/tasks/task-cancellation.tsx +++ b/packages/dashboard/src/components/tasks/task-cancellation.tsx @@ -3,12 +3,11 @@ import { TaskStateOutput as TaskState } from 'api-client'; import React from 'react'; import { ConfirmationDialog } from 'react-components'; -import { UserProfile } from '../../services/authenticator'; +import { useAppController } from '../../hooks/use-app-controller'; +import { useRmfApi } from '../../hooks/use-rmf-api'; +import { useUserProfile } from '../../hooks/use-user-profile'; import { Enforcer } from '../../services/permissions'; -import { AppControllerContext } from '../app-contexts'; import { AppEvents } from '../app-events'; -import { RmfAppContext } from '../rmf-app'; -import { UserProfileContext } from '../user-profile-provider'; export interface TaskCancelButtonProp extends ButtonProps { taskId: string | null; @@ -20,22 +19,22 @@ export function TaskCancelButton({ buttonText, ...otherProps }: TaskCancelButtonProp): JSX.Element { - const rmf = React.useContext(RmfAppContext); - const appController = React.useContext(AppControllerContext); - const profile: UserProfile | null = React.useContext(UserProfileContext); + const rmfApi = useRmfApi(); + const appController = useAppController(); + const profile = useUserProfile(); const [taskState, setTaskState] = React.useState(null); const [openConfirmDialog, setOpenConfirmDialog] = React.useState(false); React.useEffect(() => { - if (!rmf || !taskId) { + if (!taskId) { return; } - const sub = rmf.getTaskStateObs(taskId).subscribe((state) => { + const sub = rmfApi.getTaskStateObs(taskId).subscribe((state) => { setTaskState(state); }); return () => sub.unsubscribe(); - }, [rmf, taskId]); + }, [rmfApi, taskId]); const isTaskCancellable = (state: TaskState | null) => { return ( @@ -54,10 +53,7 @@ export function TaskCancelButton({ return; } try { - if (!rmf) { - throw new Error('tasks api not available'); - } - await rmf.tasksApi?.postCancelTaskTasksCancelTaskPost({ + await rmfApi.tasksApi?.postCancelTaskTasksCancelTaskPost({ type: 'cancel_task_request', task_id: taskState.booking.id, labels: profile ? [profile.user.username] : undefined, @@ -69,7 +65,7 @@ export function TaskCancelButton({ appController.showAlert('error', `Failed to cancel task: ${(e as Error).message}`); } setOpenConfirmDialog(false); - }, [appController, taskState, rmf, profile]); + }, [appController, taskState, rmfApi, profile]); return ( <> diff --git a/packages/dashboard/src/components/tasks/task-details-app.tsx b/packages/dashboard/src/components/tasks/task-details-app.tsx index e4331017d..55c429276 100644 --- a/packages/dashboard/src/components/tasks/task-details-app.tsx +++ b/packages/dashboard/src/components/tasks/task-details-app.tsx @@ -5,31 +5,27 @@ import { TaskInfo } from 'react-components'; // import { UserProfileContext } from 'rmf-auth'; import { of, switchMap } from 'rxjs'; -import { AppControllerContext } from '../app-contexts'; +import { useAppController } from '../../hooks/use-app-controller'; +import { useRmfApi } from '../../hooks/use-rmf-api'; import { AppEvents } from '../app-events'; -import { createMicroApp } from '../micro-app'; // import { Enforcer } from '../permissions'; -import { RmfAppContext } from '../rmf-app'; -export const TaskDetailsApp = createMicroApp('Task Details', () => { +export const TaskDetailsCard = () => { const theme = useTheme(); - const rmf = React.useContext(RmfAppContext); - const appController = React.useContext(AppControllerContext); + const rmfApi = useRmfApi(); + const appController = useAppController(); const [taskState, setTaskState] = React.useState(null); React.useEffect(() => { - if (!rmf) { - return; - } const sub = AppEvents.taskSelect .pipe( switchMap((selectedTask) => - selectedTask ? rmf.getTaskStateObs(selectedTask.booking.id) : of(null), + selectedTask ? rmfApi.getTaskStateObs(selectedTask.booking.id) : of(null), ), ) .subscribe(setTaskState); return () => sub.unsubscribe(); - }, [rmf]); + }, [rmfApi]); // const profile = React.useContext(UserProfileContext); const taskCancellable = @@ -43,10 +39,7 @@ export const TaskDetailsApp = createMicroApp('Task Details', () => { return; } try { - if (!rmf) { - throw new Error('tasks api not available'); - } - await rmf.tasksApi?.postCancelTaskTasksCancelTaskPost({ + await rmfApi.tasksApi?.postCancelTaskTasksCancelTaskPost({ type: 'cancel_task_request', task_id: taskState.booking.id, }); @@ -55,7 +48,7 @@ export const TaskDetailsApp = createMicroApp('Task Details', () => { } catch (e) { appController.showAlert('error', `Failed to cancel task: ${(e as Error).message}`); } - }, [appController, taskState, rmf]); + }, [appController, taskState, rmfApi]); return ( @@ -89,4 +82,6 @@ export const TaskDetailsApp = createMicroApp('Task Details', () => { )} ); -}); +}; + +export default TaskDetailsCard; diff --git a/packages/dashboard/src/components/tasks/task-inspector.tsx b/packages/dashboard/src/components/tasks/task-inspector.tsx index 777565943..8ac0ea7d0 100644 --- a/packages/dashboard/src/components/tasks/task-inspector.tsx +++ b/packages/dashboard/src/components/tasks/task-inspector.tsx @@ -4,7 +4,7 @@ import { TaskEventLog, TaskStateOutput as TaskState } from 'api-client'; import React from 'react'; import { TaskInfo } from 'react-components'; -import { RmfAppContext } from '../rmf-app'; +import { useRmfApi } from '../../hooks/use-rmf-api'; import { TaskCancelButton } from './task-cancellation'; import { TaskLogs } from './task-logs'; @@ -15,23 +15,23 @@ export interface TableDataGridState { export function TaskInspector({ task, onClose }: TableDataGridState): JSX.Element { const theme = useTheme(); - const rmf = React.useContext(RmfAppContext); + const rmfApi = useRmfApi(); const [taskState, setTaskState] = React.useState(null); const [taskLogs, setTaskLogs] = React.useState(null); const [isOpen, setIsOpen] = React.useState(true); React.useEffect(() => { - if (!rmf || !task) { + if (!task) { setTaskState(null); setTaskLogs(null); return; } - const sub = rmf.getTaskStateObs(task.booking.id).subscribe((subscribedTask) => { + const sub = rmfApi.getTaskStateObs(task.booking.id).subscribe((subscribedTask) => { (async () => { try { const logs = ( - await rmf.tasksApi.getTaskLogTasksTaskIdLogGet( + await rmfApi.tasksApi.getTaskLogTasksTaskIdLogGet( subscribedTask.booking.id, `0,${Number.MAX_SAFE_INTEGER}`, ) @@ -45,7 +45,7 @@ export function TaskInspector({ task, onClose }: TableDataGridState): JSX.Elemen })(); }); return () => sub.unsubscribe(); - }, [rmf, task]); + }, [rmfApi, task]); return ( <> diff --git a/packages/dashboard/src/components/tasks/task-logs-app.tsx b/packages/dashboard/src/components/tasks/task-logs-app.tsx index 7ff8044ba..ff2b6dd61 100644 --- a/packages/dashboard/src/components/tasks/task-logs-app.tsx +++ b/packages/dashboard/src/components/tasks/task-logs-app.tsx @@ -2,19 +2,15 @@ import { CardContent } from '@mui/material'; import { TaskEventLog, TaskStateOutput as TaskState } from 'api-client'; import React from 'react'; +import { useRmfApi } from '../../hooks/use-rmf-api'; import { AppEvents } from '../app-events'; -import { createMicroApp } from '../micro-app'; -import { RmfAppContext } from '../rmf-app'; import { TaskLogs } from './task-logs'; -export const TaskLogsApp = createMicroApp('Task Logs', () => { - const rmf = React.useContext(RmfAppContext); +export const TaskLogsCard = () => { + const rmfApi = useRmfApi(); const [taskState, setTaskState] = React.useState(null); const [taskLogs, setTaskLogs] = React.useState(null); React.useEffect(() => { - if (!rmf) { - return; - } const sub = AppEvents.taskSelect.subscribe((task) => { if (!task) { setTaskState(null); @@ -26,7 +22,7 @@ export const TaskLogsApp = createMicroApp('Task Logs', () => { // Unlike with state events, we can't just subscribe to logs updates. try { const logs = ( - await rmf.tasksApi.getTaskLogTasksTaskIdLogGet( + await rmfApi.tasksApi.getTaskLogTasksTaskIdLogGet( task.booking.id, `0,${Number.MAX_SAFE_INTEGER}`, ) @@ -40,11 +36,13 @@ export const TaskLogsApp = createMicroApp('Task Logs', () => { })(); }); return () => sub.unsubscribe(); - }, [rmf]); + }, [rmfApi]); return ( ); -}); +}; + +export default TaskLogsCard; diff --git a/packages/dashboard/src/components/tasks/task-schedule.tsx b/packages/dashboard/src/components/tasks/task-schedule.tsx index 89e419704..2b72d95a5 100644 --- a/packages/dashboard/src/components/tasks/task-schedule.tsx +++ b/packages/dashboard/src/components/tasks/task-schedule.tsx @@ -8,7 +8,7 @@ import { import { DayProps } from '@aldabil/react-scheduler/views/Day'; import { MonthProps } from '@aldabil/react-scheduler/views/Month'; import { WeekProps } from '@aldabil/react-scheduler/views/Week'; -import { Button, Typography } from '@mui/material'; +import { Button, Theme, Typography, useTheme } from '@mui/material'; import { ScheduledTask, ScheduledTaskScheduleOutput as ApiSchedule } from 'api-client'; import React from 'react'; import { @@ -19,12 +19,12 @@ import { Schedule, } from 'react-components'; -import { allowedTasks } from '../../app-config'; -import { useCreateTaskFormData } from '../../hooks/useCreateTaskForm'; -import useGetUsername from '../../hooks/useFetchUser'; -import { AppControllerContext } from '../app-contexts'; +import { useAppController } from '../../hooks/use-app-controller'; +import { useCreateTaskFormData } from '../../hooks/use-create-task-form'; +import { useRmfApi } from '../../hooks/use-rmf-api'; +import { useTaskRegistry } from '../../hooks/use-task-registry'; +import { useUserProfile } from '../../hooks/use-user-profile'; import { AppEvents } from '../app-events'; -import { RmfAppContext } from '../rmf-app'; import { apiScheduleToSchedule, getScheduledTaskColor, @@ -49,6 +49,7 @@ interface CustomCalendarEditorProps { const disablingCellsWithoutEvents = ( events: ProcessedEvent[], { start, ...props }: CellRenderedProps, + theme: Theme, ): React.ReactElement => { const filteredEvents = events.filter((event) => start.getTime() !== event.start.getTime()); const disabled = filteredEvents.length > 0 || events.length === 0; @@ -57,7 +58,7 @@ const disablingCellsWithoutEvents = ( - -

+ + + + + { + exportTasksToCsv(true); + handleCloseExportMenu(); + }} + disableRipple + > + Export Minimal + + { + exportTasksToCsv(false); + handleCloseExportMenu(); }} - anchorEl={anchorExportElement} - open={openExportMenu} - onClose={handleCloseExportMenu} + disableRipple > - { - exportTasksToCsv(true); - handleCloseExportMenu(); - }} - disableRipple - > - Export Minimal - - { - exportTasksToCsv(false); - handleCloseExportMenu(); - }} - disableRipple - > - Export Full - - -
+ Export Full + +