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 000000000..6c149bf7f Binary files /dev/null and b/packages/dashboard/examples/shared/public/favicon.ico differ 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 000000000..06ce6c02a Binary files /dev/null and b/packages/dashboard/examples/shared/public/resources/defaultLogo.png differ 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 + +