From ed4516b33523c6cb9ab92d44b394d640e02a7ae9 Mon Sep 17 00:00:00 2001 From: Aaron Chong Date: Tue, 3 Sep 2024 18:41:17 +0800 Subject: [PATCH 1/2] Add troubleshooting step for levels not rendering (#1006) * Add troubleshooting step for levels not rendering Signed-off-by: Aaron Chong * Fix plural Signed-off-by: Aaron Chong --------- Signed-off-by: Aaron Chong --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index a214d1a62..9e06d8589 100644 --- a/README.md +++ b/README.md @@ -172,4 +172,6 @@ pnpm run start - Creating tasks from the web dashboard when running a simulated Open-RMF deployment will require the task start time suit simulation time, which starts from unix millis 0. Try creating the same task with a start date of before the year of 1970. +- If floorplans for map levels are not loading, please check and verify that walls have been added to the levels in `.building.yaml` using `traffic-editor` or `rmf_site`. The dashboard uses the bounding box encompassing all wall vertices to create scene boundary for rendering, therefore if no wall vertices are present, the scene boundary becomes invalid and the floor fails to render. + - Check if the issue has already been [reported or fixed](https://github.com/open-rmf/rmf-web/issues). From 63ee3a68679c6403fa79cc3cf9bc2b62da689cac Mon Sep 17 00:00:00 2001 From: Teo Koon Peng Date: Thu, 5 Sep 2024 09:48:47 +0800 Subject: [PATCH 2/2] 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 + +