From d9361c11fc06b434d2848214d6b64ecc57012853 Mon Sep 17 00:00:00 2001 From: Teo Koon Peng Date: Fri, 6 Sep 2024 15:13:44 +0800 Subject: [PATCH 1/3] dashboard: fix route path (#1009) * fix route path Signed-off-by: Teo Koon Peng * show not found page when page not found under baseurl Signed-off-by: Teo Koon Peng --------- Signed-off-by: Teo Koon Peng --- packages/dashboard/src/components/rmf-dashboard.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/dashboard/src/components/rmf-dashboard.tsx b/packages/dashboard/src/components/rmf-dashboard.tsx index feb40bd8a..471e713ad 100644 --- a/packages/dashboard/src/components/rmf-dashboard.tsx +++ b/packages/dashboard/src/components/rmf-dashboard.tsx @@ -275,7 +275,7 @@ function DashboardContents({ extraAppbarItems, }: DashboardContentsProps) { const location = useLocation(); - const currentTab = tabs.find((t) => matchPath(t.route, location.pathname)); + const currentTab = tabs.find((t) => matchPath(`${baseUrl}${t.route}`, location.pathname)); const [pendingTransition, startTransition] = useTransition(); const navigate = useNavigate(); @@ -335,6 +335,7 @@ function DashboardContents({ /> ))} + } /> } /> From 89c9d7222811da5cd8711f6f8f02c050e29f72e1 Mon Sep 17 00:00:00 2001 From: Teo Koon Peng Date: Fri, 6 Sep 2024 15:15:18 +0800 Subject: [PATCH 2/3] api-server: remove stub authenticator (#1008) * remove stub auth Signed-off-by: Teo Koon Peng * comments Signed-off-by: Teo Koon Peng * fix lint Signed-off-by: Teo Koon Peng * fix lint Signed-off-by: Teo Koon Peng * fix tests Signed-off-by: Teo Koon Peng * add user token; add FIXME to stop using preferred_username to id users Signed-off-by: Teo Koon Peng --------- Signed-off-by: Teo Koon Peng --- packages/api-server/api_server/app_config.py | 3 +- .../api-server/api_server/authenticator.py | 78 +++++++------------ .../api-server/api_server/default_config.py | 5 +- packages/api-server/api_server/models/user.py | 3 + packages/api-server/scripts/test_config.py | 1 + .../dashboard/src/components/appbar.test.tsx | 2 +- .../src/services/stub-authenticator.ts | 33 +++++++- pnpm-lock.yaml | 29 +++---- 8 files changed, 84 insertions(+), 70 deletions(-) diff --git a/packages/api-server/api_server/app_config.py b/packages/api-server/api_server/app_config.py index 6a6618465..40841997f 100644 --- a/packages/api-server/api_server/app_config.py +++ b/packages/api-server/api_server/app_config.py @@ -17,9 +17,10 @@ class AppConfig: log_level: str builtin_admin: str jwt_public_key: str | None + jwt_secret: str | None oidc_url: str | None aud: str - iss: str | None + iss: str ros_args: list[str] timezone: str diff --git a/packages/api-server/api_server/authenticator.py b/packages/api-server/api_server/authenticator.py index 536beaf6e..16fff7802 100644 --- a/packages/api-server/api_server/authenticator.py +++ b/packages/api-server/api_server/authenticator.py @@ -1,10 +1,8 @@ -import base64 -import json -import logging from typing import Any, Callable, Coroutine, Protocol import jwt -from fastapi import Depends, Header, HTTPException +import jwt.algorithms +from fastapi import Depends, HTTPException from fastapi.security import OpenIdConnect from .app_config import app_config @@ -22,9 +20,12 @@ def fastapi_dep(self) -> Callable[..., Coroutine[Any, Any, User] | User]: ... class JwtAuthenticator: + _algorithms = jwt.algorithms.get_default_algorithms() + del _algorithms["none"] + def __init__( self, - pem_file: str, + key_or_secret: "jwt.algorithms.AllowedPublicKeys | str | bytes", aud: str, iss: str, *, @@ -38,8 +39,7 @@ def __init__( self.aud = aud self.iss = iss self.oidc_url = oidc_url - with open(pem_file, "r", encoding="utf8") as f: - self._public_key = f.read() + self._key_or_secret = key_or_secret async def _get_user(self, claims: dict) -> User: if not "preferred_username" in claims: @@ -48,18 +48,10 @@ async def _get_user(self, claims: dict) -> User: ) username = claims["preferred_username"] + # FIXME(koonpeng): We should use the "userId" as the identifier. Some idP may allow + # duplicated usernames. user = await User.load_or_create_from_db(username) - is_admin = False - if "realm_access" in claims: - if "roles" in claims["realm_access"]: - roles = claims["realm_access"]["roles"] - if "superuser" in roles: - is_admin = True - - if user.is_admin != is_admin: - await user.update_admin(is_admin) - return user async def verify_token(self, token: str | None) -> User: @@ -68,8 +60,8 @@ async def verify_token(self, token: str | None) -> User: try: claims = jwt.decode( token, - self._public_key, - algorithms=["RS256"], + self._key_or_secret, + algorithms=list(self._algorithms), audience=self.aud, issuer=self.iss, ) @@ -77,6 +69,7 @@ async def verify_token(self, token: str | None) -> User: return user except jwt.InvalidTokenError as e: + print(e) raise AuthenticationError(str(e)) from e def fastapi_dep(self) -> Callable[..., Coroutine[Any, Any, User] | User]: @@ -94,45 +87,30 @@ async def dep( return dep -class StubAuthenticator(Authenticator): - """ - StubAuthenticator will authenticate as an admin user called "stub" if no tokens are - present. If there is a bearer token in the `Authorization` header, then it decodes the jwt - WITHOUT verifying the signature and authenticated as the user given. - """ - - async def verify_token(self, token: str | None): - if not token: - return User(username="stub", is_admin=True) - # decode the jwt without verifying signature - parts = token.split(".") - # add padding to ignore incorrect padding errors - payload = base64.b64decode(parts[1] + "==") - username = json.loads(payload)["preferred_username"] - return await User.load_or_create_from_db(username) - - def fastapi_dep(self): - async def dep(authorization: str | None = Header(None)): - if not authorization: - return await self.verify_token(None) - token = authorization.split(" ")[1] - return await self.verify_token(token) - - return dep - +if app_config.jwt_public_key and app_config.jwt_secret: + raise ValueError("only one of jwt_public_key or jwt_secret must be set") +if not app_config.iss: + raise ValueError("iss is required") +if not app_config.aud: + raise ValueError("aud is required") if app_config.jwt_public_key: - if app_config.iss is None: - raise ValueError("iss is required") + with open(app_config.jwt_public_key, "br") as f: + authenticator = JwtAuthenticator( + f.read(), + app_config.aud, + app_config.iss, + oidc_url=app_config.oidc_url or "", + ) +elif app_config.jwt_secret: authenticator = JwtAuthenticator( - app_config.jwt_public_key, + app_config.jwt_secret, app_config.aud, app_config.iss, oidc_url=app_config.oidc_url or "", ) else: - authenticator = StubAuthenticator() - logging.warning("authentication is disabled") + raise ValueError("either jwt_public_key or jwt_secret is required") user_dep = authenticator.fastapi_dep() diff --git a/packages/api-server/api_server/default_config.py b/packages/api-server/api_server/default_config.py index 76780d80d..f7ea6e466 100644 --- a/packages/api-server/api_server/default_config.py +++ b/packages/api-server/api_server/default_config.py @@ -16,6 +16,8 @@ "builtin_admin": "admin", # path to a PEM encoded RSA public key which is used to verify JWT tokens, if the path is relative, it is based on the working dir. "jwt_public_key": None, + # jwt secret, this is mutually exclusive with `jwt_public_key`. + "jwt_secret": "rmfisawesome", # url to the oidc endpoint, used to authenticate rest requests, it should point to the well known endpoint, e.g. # http://localhost:8080/auth/realms/rmf-web/.well-known/openid-configuration. # NOTE: This is ONLY used for documentation purposes, the "jwt_public_key" will be the @@ -26,8 +28,7 @@ "aud": "rmf_api_server", # url or string that identifies the entity that issued the jwt token # Used to verify the "iss" claim - # If iss is set to None, it means that authentication should be disabled - "iss": None, + "iss": "stub", # list of arguments passed to the ros node, "--ros-args" is automatically prepended to the list. # e.g. # Run with sim time: ["-p", "use_sim_time:=true"] diff --git a/packages/api-server/api_server/models/user.py b/packages/api-server/api_server/models/user.py index 59d7d17b2..b5c630fa8 100644 --- a/packages/api-server/api_server/models/user.py +++ b/packages/api-server/api_server/models/user.py @@ -5,6 +5,9 @@ class User(PydanticModel): + # FIXME(koonpeng): We should use the "userId" as the identifier. Some idP may allow + # duplicated usernames. + # userId: str username: str is_admin: bool = False roles: list[str] = [] diff --git a/packages/api-server/scripts/test_config.py b/packages/api-server/scripts/test_config.py index ada8fd2c3..ece999981 100644 --- a/packages/api-server/scripts/test_config.py +++ b/packages/api-server/scripts/test_config.py @@ -11,6 +11,7 @@ "port": int(test_port), "log_level": "ERROR", "jwt_public_key": f"{here}/test.pub", + "jwt_secret": None, "iss": "test", "db_url": os.environ.get("RMF_API_SERVER_TEST_DB_URL", "sqlite://:memory:"), "timezone": "Asia/Singapore", diff --git a/packages/dashboard/src/components/appbar.test.tsx b/packages/dashboard/src/components/appbar.test.tsx index ab5212a91..c18015df5 100644 --- a/packages/dashboard/src/components/appbar.test.tsx +++ b/packages/dashboard/src/components/appbar.test.tsx @@ -59,7 +59,7 @@ describe('AppBar', () => { }); it('logout is triggered when logout button is clicked', async () => { - const authenticator = new StubAuthenticator('test'); + const authenticator = new StubAuthenticator(); const spy = vi.spyOn(authenticator, 'logout').mockImplementation(() => undefined as any); const root = render( diff --git a/packages/dashboard/src/services/stub-authenticator.ts b/packages/dashboard/src/services/stub-authenticator.ts index 30e6898db..f787dedc2 100644 --- a/packages/dashboard/src/services/stub-authenticator.ts +++ b/packages/dashboard/src/services/stub-authenticator.ts @@ -2,6 +2,33 @@ import EventEmitter from 'eventemitter3'; import { Authenticator, AuthenticatorEventType } from './authenticator'; +/** + * Hardcoded token using the secret 'rmfisawesome', expires in 2035-01-01. + * To update the token, use https://jwt.io and paste in the payload, also remember + * to set the secret to `rmfisawesome`. + * + * header: + * { + * "alg": "HS256", + * "typ": "JWT" + * } + * payload: + * { + * "sub": "stub", + * "preferred_username": "admin", + * "iat": 1516239022, + * "aud": "rmf_api_server", + * "iss": "stub", + * "exp": 2051222400 + * } + */ +const ADMIN_TOKEN = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzdHViIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiYWRtaW4iLCJpYXQiOjE1MTYyMzkwMjIsImF1ZCI6InJtZl9hcGlfc2VydmVyIiwiaXNzIjoic3R1YiIsImV4cCI6MjA1MTIyMjQwMH0.zzX3zXp467ldkzmLVIadQ_AHr8M5uWVV43n4wEB0OhE'; + +// same as the admin token, except the `preferred_username` is "user". +const USER_TOKEN = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzdHViIiwicHJlZmVycmVkX3VzZXJuYW1lIjoidXNlciIsImlhdCI6MTUxNjIzOTAyMiwiYXVkIjoicm1mX2FwaV9zZXJ2ZXIiLCJpc3MiOiJzdHViIiwiZXhwIjoyMDUxMjIyNDAwfQ.vK3n4FbshCykQ9BW49w_7AfqKgbN9j2R3-Qh-rIOt_g'; + export class StubAuthenticator extends EventEmitter implements Authenticator @@ -10,10 +37,10 @@ export class StubAuthenticator readonly token?: string; - constructor(user = 'stub', token: string | undefined = undefined) { + constructor(isAdmin = true) { super(); - this.user = user; - this.token = token; + this.user = isAdmin ? 'admin' : 'user'; + this.token = isAdmin ? ADMIN_TOKEN : USER_TOKEN; } init(): Promise { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9568a5a43..d550aa5b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,7 +230,7 @@ importers: version: 2.0.4(vitest@2.0.4(@types/node@20.14.12)(jsdom@24.1.1(canvas@2.11.2))(terser@5.31.6)) api-server: specifier: file:../api-server - version: link:../api-server + version: file:packages/api-server concurrently: specifier: ^8.2.2 version: 8.2.2 @@ -3226,6 +3226,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + api-server@file:packages/api-server: + resolution: {directory: packages/api-server, type: directory} + aproba@2.0.0: resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} @@ -8218,7 +8221,6 @@ snapshots: '@babel/parser@7.25.3': dependencies: '@babel/types': 7.25.2 - optional: true '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.24.7(@babel/core@7.24.8)': dependencies: @@ -8987,7 +8989,6 @@ snapshots: '@babel/helper-string-parser': 7.24.8 '@babel/helper-validator-identifier': 7.24.7 to-fast-properties: 2.0.0 - optional: true '@base2/pretty-print-object@1.0.1': {} @@ -9461,7 +9462,7 @@ snapshots: '@jest/schemas': 28.1.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.14.10 + '@types/node': 22.2.0 '@types/yargs': 17.0.32 chalk: 4.1.2 @@ -10741,24 +10742,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.24.8 - '@babel/types': 7.24.9 + '@babel/parser': 7.25.3 + '@babel/types': 7.25.2 '@types/babel__generator': 7.6.8 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.20.6 '@types/babel__generator@7.6.8': dependencies: - '@babel/types': 7.24.9 + '@babel/types': 7.25.2 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.24.8 - '@babel/types': 7.24.9 + '@babel/parser': 7.25.3 + '@babel/types': 7.25.2 '@types/babel__traverse@7.20.6': dependencies: - '@babel/types': 7.24.9 + '@babel/types': 7.25.2 '@types/body-parser@1.19.5': dependencies: @@ -10774,7 +10775,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 20.14.10 + '@types/node': 22.2.0 '@types/crc@3.8.3': dependencies: @@ -10995,7 +10996,7 @@ snapshots: '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 - '@types/node': 20.14.10 + '@types/node': 22.2.0 '@types/serve-static@1.15.7': dependencies: @@ -11618,6 +11619,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + api-server@file:packages/api-server: {} + aproba@2.0.0: {} arch@2.2.0: {} @@ -12183,7 +12186,7 @@ snapshots: chrome-launcher@0.14.2: dependencies: - '@types/node': 15.14.9 + '@types/node': 22.2.0 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 From 5d670b50f914c610c05683a2fd9e60f64645dc90 Mon Sep 17 00:00:00 2001 From: Aaron Chong Date: Thu, 19 Sep 2024 02:42:53 +0800 Subject: [PATCH 3/3] Direct dispatch to fleet or robot (#1004) * Basic implementation of priority based on legacy implementation Signed-off-by: Aaron Chong * Use icons for favorite Signed-off-by: Aaron Chong * Fix warning time, use switch and made buttons nicer Signed-off-by: Aaron Chong * To display priority Signed-off-by: Aaron Chong * Lint Signed-off-by: Aaron Chong * Helper function to handle null and undefined priority, clean up creation Signed-off-by: Aaron Chong * Use switch for priority, flip low priority icon Signed-off-by: Aaron Chong * Remove getDefaultTaskPriorty Signed-off-by: Aaron Chong * Basic implementation working, still with frontend errors Signed-off-by: Aaron Chong * Fix component render sequence issues Signed-off-by: Aaron Chong * Lint Signed-off-by: Aaron Chong * Fix capitalization, disable scheuling for robot dispatch, allow edit scheduled fleet task Signed-off-by: Aaron Chong * Comments and consolidate logs Signed-off-by: Aaron Chong * Slight tree view for robots, change dropdown size, use const enum Signed-off-by: Aaron Chong * Refactor dispatch and schedule callbacks, fix edit schedule event, add comments Signed-off-by: Aaron Chong * Remove stale print Signed-off-by: Aaron Chong * lint Signed-off-by: Aaron Chong * Make task form more generically named, new callback for schedule editing Signed-off-by: Aaron Chong * Document props Signed-off-by: Aaron Chong --------- Signed-off-by: Aaron Chong --- packages/dashboard/src/components/appbar.tsx | 64 ++- .../src/components/tasks/task-schedule.tsx | 82 ++-- .../dashboard/src/components/tasks/utils.ts | 82 +++- .../src/hooks/use-create-task-form.tsx | 17 +- packages/dashboard/src/services/rmf-api.ts | 1 + packages/react-components/lib/tasks/index.ts | 2 +- ...task.stories.tsx => task-form.stories.tsx} | 13 +- .../tasks/{create-task.tsx => task-form.tsx} | 444 +++++++++++++----- .../lib/tasks/types/compose-clean.tsx | 2 +- .../lib/tasks/types/custom-compose.tsx | 2 +- .../lib/tasks/types/delivery-custom.tsx | 2 +- .../lib/tasks/types/delivery.tsx | 2 +- .../lib/tasks/types/patrol.tsx | 3 +- .../react-components/lib/tasks/types/utils.ts | 2 +- 14 files changed, 514 insertions(+), 204 deletions(-) rename packages/react-components/lib/tasks/{create-task.stories.tsx => task-form.stories.tsx} (53%) rename packages/react-components/lib/tasks/{create-task.tsx => task-form.tsx} (73%) diff --git a/packages/dashboard/src/components/appbar.tsx b/packages/dashboard/src/components/appbar.tsx index 534b8a3c5..f97f5112c 100644 --- a/packages/dashboard/src/components/appbar.tsx +++ b/packages/dashboard/src/components/appbar.tsx @@ -43,19 +43,19 @@ import { import { AlertRequest, FireAlarmTriggerState, TaskFavorite } from 'api-client'; import { formatDistance } from 'date-fns'; import React from 'react'; -import { ConfirmationDialog, CreateTaskForm, CreateTaskFormProps } from 'react-components'; +import { ConfirmationDialog, TaskForm, TaskFormProps } from 'react-components'; import { Subscription } from 'rxjs'; import { useAppController } from '../hooks/use-app-controller'; import { useAuthenticator } from '../hooks/use-authenticator'; -import { useCreateTaskFormData } from '../hooks/use-create-task-form'; +import { useTaskFormData } 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 { toApiSchedule } from './tasks/utils'; +import { dispatchTask, scheduleTask } from './tasks/utils'; import { DashboardThemes } from './theme'; export const APP_BAR_HEIGHT = '3.5rem'; @@ -116,8 +116,8 @@ export const AppBar = React.memo( FireAlarmTriggerState | undefined >(undefined); - const { waypointNames, pickupPoints, dropoffPoints, cleaningZoneNames } = - useCreateTaskFormData(rmfApi); + const { waypointNames, pickupPoints, dropoffPoints, cleaningZoneNames, fleets } = + useTaskFormData(rmfApi); const username = profile.user.username; async function handleLogout(): Promise { @@ -147,30 +147,23 @@ export const AppBar = React.memo( 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)), - ); + const dispatchTaskCallback = React.useCallback['onDispatchTask']>( + async (taskRequest, robotDispatchTarget) => { + if (!rmfApi) { + throw new Error('tasks api not available'); + } + await dispatchTask(rmfApi, taskRequest, robotDispatchTarget); + AppEvents.refreshTaskApp.next(); + }, + [rmfApi], + ); + + const scheduleTaskCallback = React.useCallback['onScheduleTask']>( + async (taskRequest, schedule) => { + if (!rmfApi) { + throw new Error('tasks api not available'); } + await scheduleTask(rmfApi, taskRequest, schedule); AppEvents.refreshTaskApp.next(); }, [rmfApi], @@ -189,9 +182,7 @@ export const AppBar = React.memo( return () => sub.unsubscribe(); }, [rmfApi]); - const submitFavoriteTask = React.useCallback< - Required['submitFavoriteTask'] - >( + const submitFavoriteTask = React.useCallback['submitFavoriteTask']>( async (taskFavoriteRequest) => { await rmfApi.tasksApi.postFavoriteTaskFavoriteTasksPost(taskFavoriteRequest); AppEvents.refreshFavoriteTasks.next(); @@ -199,9 +190,7 @@ export const AppBar = React.memo( [rmfApi], ); - const deleteFavoriteTask = React.useCallback< - Required['deleteFavoriteTask'] - >( + const deleteFavoriteTask = React.useCallback['deleteFavoriteTask']>( async (favoriteTask) => { if (!favoriteTask.id) { throw new Error('Id is needed'); @@ -464,8 +453,9 @@ export const AppBar = React.memo( {openCreateTaskForm && ( - setOpenCreateTaskForm(false)} - submitTasks={submitTasks} + onDispatchTask={dispatchTaskCallback} + onScheduleTask={scheduleTaskCallback} + onEditScheduleTask={undefined} submitFavoriteTask={submitFavoriteTask} deleteFavoriteTask={deleteFavoriteTask} onSuccess={() => { diff --git a/packages/dashboard/src/components/tasks/task-schedule.tsx b/packages/dashboard/src/components/tasks/task-schedule.tsx index 2b72d95a5..65413e5f1 100644 --- a/packages/dashboard/src/components/tasks/task-schedule.tsx +++ b/packages/dashboard/src/components/tasks/task-schedule.tsx @@ -13,14 +13,14 @@ import { ScheduledTask, ScheduledTaskScheduleOutput as ApiSchedule } from 'api-c import React from 'react'; import { ConfirmationDialog, - CreateTaskForm, - CreateTaskFormProps, EventEditDeletePopup, Schedule, + TaskForm, + TaskFormProps, } from 'react-components'; import { useAppController } from '../../hooks/use-app-controller'; -import { useCreateTaskFormData } from '../../hooks/use-create-task-form'; +import { useTaskFormData } 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'; @@ -33,7 +33,7 @@ import { scheduleWithSelectedDay, toISOStringWithTimezone, } from './task-schedule-utils'; -import { toApiSchedule } from './utils'; +import { dispatchTask, editScheduledTaskEvent, editScheduledTaskSchedule } from './utils'; enum EventScopes { ALL = 'all', @@ -71,8 +71,8 @@ export const TaskSchedule = () => { const rmfApi = useRmfApi(); const { showAlert } = useAppController(); - const { waypointNames, pickupPoints, dropoffPoints, cleaningZoneNames } = - useCreateTaskFormData(rmfApi); + const { waypointNames, pickupPoints, dropoffPoints, cleaningZoneNames, fleets } = + useTaskFormData(rmfApi); const username = useUserProfile().user.username; const taskRegistry = useTaskRegistry(); const [eventScope, setEventScope] = React.useState(EventScopes.CURRENT); @@ -191,30 +191,51 @@ export const TaskSchedule = () => { ); }; - const submitTasks = React.useCallback['submitTasks']>( - async (taskRequests, schedule) => { - if (!schedule || !currentScheduleTask) { - throw new Error('No schedule or task selected for submission.'); + const dispatchTaskCallback = React.useCallback['onDispatchTask']>( + async (taskRequest, robotDispatchTarget) => { + if (!rmfApi) { + throw new Error('tasks api not available'); } + await dispatchTask(rmfApi, taskRequest, robotDispatchTarget); + AppEvents.refreshTaskApp.next(); + }, + [rmfApi], + ); - const scheduleRequests = taskRequests.map((req) => toApiSchedule(req, schedule)); + const editScheduledTaskCallback = React.useCallback< + Required['onEditScheduleTask'] + >( + async (taskRequest, schedule) => { + if (!rmfApi) { + throw new Error('tasks api not available'); + } - let exceptDate: string | undefined = undefined; - if (eventScope === EventScopes.CURRENT) { - exceptDate = toISOStringWithTimezone(exceptDateRef.current); - console.debug(`Editing schedule id ${currentScheduleTask.id}, event date ${exceptDate}`); - } else { - console.debug(`Editing schedule id ${currentScheduleTask.id}`); + if (!currentScheduleTask) { + throw new Error('No schedule task selected for submission.'); } - await Promise.all( - scheduleRequests.map((req) => - rmfApi.tasksApi.updateScheduleTaskScheduledTasksTaskIdUpdatePost( - currentScheduleTask.id, - req, - exceptDate, - ), - ), + // Edit entire schedule + if (eventScope !== EventScopes.CURRENT) { + console.debug( + `Editing schedule id [${currentScheduleTask.id}] with new schedule: ${schedule}`, + ); + await editScheduledTaskSchedule(rmfApi, taskRequest, schedule, currentScheduleTask.id); + + setEventScope(EventScopes.CURRENT); + AppEvents.refreshTaskSchedule.next(); + return; + } + + // Edit a single event + console.debug( + `Editing schedule id [${currentScheduleTask.id}] event [${exceptDateRef.current}] with new schedule: ${schedule}`, + ); + await editScheduledTaskEvent( + rmfApi, + taskRequest, + schedule, + exceptDateRef.current, + currentScheduleTask.id, ); setEventScope(EventScopes.CURRENT); @@ -316,22 +337,25 @@ export const TaskSchedule = () => { onSelectedDateChange={setSelectedDate} /> {openCreateTaskForm && ( - { setOpenCreateTaskForm(false); setEventScope(EventScopes.CURRENT); AppEvents.refreshTaskSchedule.next(); }} - submitTasks={submitTasks} + onDispatchTask={dispatchTaskCallback} + onScheduleTask={undefined} + onEditScheduleTask={editScheduledTaskCallback} onSuccess={() => { setOpenCreateTaskForm(false); showAlert('success', 'Successfully created task'); diff --git a/packages/dashboard/src/components/tasks/utils.ts b/packages/dashboard/src/components/tasks/utils.ts index be652ff28..3de09be91 100644 --- a/packages/dashboard/src/components/tasks/utils.ts +++ b/packages/dashboard/src/components/tasks/utils.ts @@ -1,5 +1,14 @@ -import { PostScheduledTaskRequest, TaskRequest, TaskStateOutput as TaskState } from 'api-client'; -import { getTaskBookingLabelFromTaskState, Schedule } from 'react-components'; +import { + AddExceptDateRequest, + PostScheduledTaskRequest, + RobotTaskRequest, + TaskRequest, + TaskStateOutput as TaskState, +} from 'api-client'; +import { getTaskBookingLabelFromTaskState, RobotDispatchTarget, Schedule } from 'react-components'; + +import { RmfApi } from '../../services/rmf-api'; +import { toISOStringWithTimezone } from './task-schedule-utils'; export function exportCsvFull(timestamp: Date, allTasks: TaskState[]) { const columnSeparator = ';'; @@ -115,3 +124,72 @@ export const toApiSchedule = ( schedules: apiSchedules, }; }; + +export async function dispatchTask( + rmf: RmfApi, + taskRequest: TaskRequest, + robotDispatchTarget: RobotDispatchTarget | null, +) { + if (robotDispatchTarget) { + const robotTask: RobotTaskRequest = { + type: 'robot_task_request', + robot: robotDispatchTarget.robot, + fleet: robotDispatchTarget.fleet, + request: taskRequest, + }; + console.debug(`dispatch robot task: ${robotTask}`); + await rmf.tasksApi.postRobotTaskTasksRobotTaskPost(robotTask); + return; + } + + console.debug(`dispatch task: ${taskRequest}`); + await rmf.tasksApi.postDispatchTaskTasksDispatchTaskPost({ + type: 'dispatch_task_request', + request: taskRequest, + }); +} + +export async function scheduleTask(rmf: RmfApi, taskRequest: TaskRequest, schedule: Schedule) { + console.debug(`schedule task: ${taskRequest}\nschedule: ${schedule}`); + const scheduleRequest = toApiSchedule(taskRequest, schedule); + await rmf.tasksApi.postScheduledTaskScheduledTasksPost(scheduleRequest); +} + +export async function editScheduledTaskEvent( + rmf: RmfApi, + taskRequest: TaskRequest, + newEventSchedule: Schedule, + newEventDate: Date, + originalScheduleTaskId: number, +) { + const addExceptDateRequest: AddExceptDateRequest = { + except_date: toISOStringWithTimezone(newEventDate), + }; + console.debug( + `Adding [${addExceptDateRequest.except_date}] to except date of scheduled task [${originalScheduleTaskId}]`, + ); + await rmf.tasksApi.addExceptDateScheduledTasksTaskIdExceptDatePost( + originalScheduleTaskId, + addExceptDateRequest, + ); + + console.debug( + `creating new schedule for edited event: ${taskRequest}\nschedule: ${newEventSchedule}`, + ); + const newScheduleRequest = toApiSchedule(taskRequest, newEventSchedule); + await rmf.tasksApi.postScheduledTaskScheduledTasksPost(newScheduleRequest); +} + +export async function editScheduledTaskSchedule( + rmf: RmfApi, + taskRequest: TaskRequest, + newSchedule: Schedule, + scheduleTaskId: number, +) { + const scheduleRequest = toApiSchedule(taskRequest, newSchedule); + + await rmf.tasksApi.updateScheduleTaskScheduledTasksTaskIdUpdatePost( + scheduleTaskId, + scheduleRequest, + ); +} diff --git a/packages/dashboard/src/hooks/use-create-task-form.tsx b/packages/dashboard/src/hooks/use-create-task-form.tsx index 3bb5c66d5..fa9d221ce 100644 --- a/packages/dashboard/src/hooks/use-create-task-form.tsx +++ b/packages/dashboard/src/hooks/use-create-task-form.tsx @@ -4,11 +4,12 @@ import { Subscription } from 'rxjs'; import { RmfApi } from '../services/rmf-api'; -export const useCreateTaskFormData = (rmfApi: RmfApi | undefined) => { +export const useTaskFormData = (rmfApi: RmfApi | undefined) => { const [waypointNames, setWaypointNames] = React.useState([]); const [cleaningZoneNames, setCleaningZoneNames] = React.useState([]); const [pickupPoints, setPickupPoints] = React.useState>({}); const [dropoffPoints, setDropoffPoints] = React.useState>({}); + const [fleets, setFleets] = React.useState>({}); React.useEffect(() => { if (!rmfApi) { @@ -42,9 +43,21 @@ export const useCreateTaskFormData = (rmfApi: RmfApi | undefined) => { setWaypointNames(waypointNames); }), ); + subs.push( + rmfApi.fleetsObs.subscribe((fleetStates) => { + const result: Record = {}; + for (const fleet of fleetStates) { + if (!fleet.name || !fleet.robots) { + continue; + } + result[fleet.name] = Object.keys(fleet.robots); + } + setFleets(result); + }), + ); return () => subs.forEach((s) => s.unsubscribe()); }, [rmfApi]); - return { waypointNames, pickupPoints, dropoffPoints, cleaningZoneNames }; + return { waypointNames, pickupPoints, dropoffPoints, cleaningZoneNames, fleets }; }; diff --git a/packages/dashboard/src/services/rmf-api.ts b/packages/dashboard/src/services/rmf-api.ts index fce9028a3..57b78047e 100644 --- a/packages/dashboard/src/services/rmf-api.ts +++ b/packages/dashboard/src/services/rmf-api.ts @@ -304,6 +304,7 @@ export class DefaultRmfApi implements RmfApi { return this._ingestorStateObsStore[guid]; } + // NOTE: This only emits once and doesn't update when the fleet changes. fleetsObs: Observable; private _fleetStateObsStore: Record> = {}; getFleetStateObs(name: string): Observable { diff --git a/packages/react-components/lib/tasks/index.ts b/packages/react-components/lib/tasks/index.ts index 8c767d92e..003071b36 100644 --- a/packages/react-components/lib/tasks/index.ts +++ b/packages/react-components/lib/tasks/index.ts @@ -1,6 +1,6 @@ export * from './booking-label'; -export * from './create-task'; export * from './task-booking-label-utils'; +export * from './task-form'; export * from './task-info'; export * from './task-logs'; export * from './task-schedule-event-edit-delete-popup'; diff --git a/packages/react-components/lib/tasks/create-task.stories.tsx b/packages/react-components/lib/tasks/task-form.stories.tsx similarity index 53% rename from packages/react-components/lib/tasks/create-task.stories.tsx rename to packages/react-components/lib/tasks/task-form.stories.tsx index f8afe7123..b8bbffe6c 100644 --- a/packages/react-components/lib/tasks/create-task.stories.tsx +++ b/packages/react-components/lib/tasks/task-form.stories.tsx @@ -1,23 +1,24 @@ import { Meta, StoryObj } from '@storybook/react'; -import { CreateTaskForm } from './create-task'; +import { TaskForm } from './task-form'; export default { title: 'Tasks/Create Task', - component: CreateTaskForm, + component: TaskForm, } satisfies Meta; -type Story = StoryObj; +type Story = StoryObj; -export const CreateTask: Story = { +export const OpenTaskForm: Story = { args: { - submitTasks: async () => new Promise((res) => setTimeout(res, 500)), + onDispatchTask: async () => new Promise((res) => setTimeout(res, 500)), + onScheduleTask: async () => new Promise((res) => setTimeout(res, 500)), cleaningZones: ['test_zone_0', 'test_zone_1'], patrolWaypoints: ['test_waypoint_0', 'test_waypoint_1'], pickupPoints: { test_waypoint_0: 'test_waypoint_0' }, dropoffPoints: { test_waypoint_1: 'test_waypoint_1' }, }, render: (args) => { - return ; + return ; }, }; diff --git a/packages/react-components/lib/tasks/create-task.tsx b/packages/react-components/lib/tasks/task-form.tsx similarity index 73% rename from packages/react-components/lib/tasks/create-task.tsx rename to packages/react-components/lib/tasks/task-form.tsx index 549ad08c1..9139aaee6 100644 --- a/packages/react-components/lib/tasks/create-task.tsx +++ b/packages/react-components/lib/tasks/task-form.tsx @@ -106,24 +106,16 @@ export type TaskDescription = const classes = { title: 'dialogue-info-value', - selectFileBtn: 'create-task-selected-file-btn', taskList: 'create-task-task-list', - selectedTask: 'create-task-selected-task', actionBtn: 'dialogue-action-button', }; -const StyledDialog = styled((props: DialogProps) => )(({ theme }) => ({ - [`& .${classes.selectFileBtn}`]: { - marginBottom: theme.spacing(1), - }, +const StyledDialog = styled((props: DialogProps) => )(() => ({ [`& .${classes.taskList}`]: { flex: '1 1 auto', minHeight: 400, maxHeight: '50vh', overflow: 'auto', }, - [`& .${classes.selectedTask}`]: { - background: theme.palette.action.focus, - }, [`& .${classes.title}`]: { flex: '1 1 auto', }, @@ -291,14 +283,15 @@ const DaySelectorSwitch: React.VFC = ({ disabled, onChan ); }; -export interface CreateTaskFormProps - extends Omit { - /** - * Shows extra UI elements suitable for submittng batched tasks. Default to 'false'. - */ +export interface RobotDispatchTarget { + fleet: string; + robot: string; +} + +export interface TaskFormProps extends Omit { user: string; + fleets?: Record; tasksToDisplay?: TaskDefinition[]; - allowBatch?: boolean; cleaningZones?: string[]; patrolWaypoints?: string[]; pickupZones?: string[]; @@ -306,12 +299,18 @@ export interface CreateTaskFormProps pickupPoints?: Record; dropoffPoints?: Record; favoritesTasks?: TaskFavorite[]; - scheduleToEdit?: Schedule; - // requestTask is provided only when editing a schedule - requestTask?: TaskRequest; - submitTasks?(tasks: TaskRequest[], schedule: Schedule | null): Promise; - onSuccess?(tasks: TaskRequest[]): void; - onFail?(error: Error, tasks: TaskRequest[]): void; + schedule?: Schedule; + taskRequest?: TaskRequest; + onDispatchTask?( + task: TaskRequest, + robotDispatchTarget: RobotDispatchTarget | null, + ): Promise; + /** If provided, the button Schedule Task will be rendered and clicking it will call this callback */ + onScheduleTask?(task: TaskRequest, schedule: Schedule): Promise; + /** If provided, the button Edit Schedule will be rendered and clicking it will call this callback */ + onEditScheduleTask?(task: TaskRequest, schedule: Schedule): Promise; + onSuccess?(task: TaskRequest): void; + onFail?(error: Error, task?: TaskRequest): void; onSuccessFavoriteTask?(message: string, favoriteTask: TaskFavorite): void; onFailFavoriteTask?(error: Error, favoriteTask: TaskFavorite): void; submitFavoriteTask?(favoriteTask: TaskFavorite): Promise; @@ -320,8 +319,9 @@ export interface CreateTaskFormProps onFailScheduling?(error: Error): void; } -export function CreateTaskForm({ +export function TaskForm({ user, + fleets, tasksToDisplay = [ PatrolTaskDefinition, DeliveryTaskDefinition, @@ -337,9 +337,11 @@ export function CreateTaskForm({ pickupPoints = {}, dropoffPoints = {}, favoritesTasks = [], - scheduleToEdit, - requestTask, - submitTasks, + schedule, + taskRequest, + onDispatchTask, + onScheduleTask, + onEditScheduleTask, onClose, onSuccess, onFail, @@ -350,7 +352,7 @@ export function CreateTaskForm({ onSuccessScheduling, onFailScheduling, ...otherProps -}: CreateTaskFormProps): JSX.Element { +}: TaskFormProps): JSX.Element { const theme = useTheme(); const [openFavoriteDialog, setOpenFavoriteDialog] = React.useState(false); @@ -395,7 +397,7 @@ export function CreateTaskForm({ if (!defaultTaskDescription || !defaultTaskRequest) { // We should never reach this state unless a misconfiguration happened. const err = Error('Default task could not be generated, this might be a configuration error'); - onFail && onFail(err, []); + onFail && onFail(err); console.error(err.message); throw new TypeError(err.message); } @@ -413,24 +415,24 @@ export function CreateTaskForm({ const [favoriteTaskTitleError, setFavoriteTaskTitleError] = React.useState(false); const [savingFavoriteTask, setSavingFavoriteTask] = React.useState(false); - const [taskRequest, setTaskRequest] = React.useState( - requestTask ?? defaultTaskRequest, + const [currentTaskRequest, setCurrentTaskRequest] = React.useState( + taskRequest ?? defaultTaskRequest, ); - const initialBookingLabel = requestTask ? getTaskBookingLabelFromTaskRequest(requestTask) : null; + const initialBookingLabel = taskRequest ? getTaskBookingLabelFromTaskRequest(taskRequest) : null; const [taskDefinitionId, setTaskDefinitionId] = React.useState(() => { const fromLabel = initialBookingLabel && getTaskDefinitionId(initialBookingLabel); return fromLabel || tasksToDisplay[0].taskDefinitionId; }); const [submitting, setSubmitting] = React.useState(false); - const [formFullyFilled, setFormFullyFilled] = React.useState(requestTask !== undefined || false); + const [formFullyFilled, setFormFullyFilled] = React.useState(taskRequest !== undefined || false); const [openSchedulingDialog, setOpenSchedulingDialog] = React.useState(false); const defaultScheduleDate = new Date(); defaultScheduleDate.setSeconds(0); defaultScheduleDate.setMilliseconds(0); - const [schedule, setSchedule] = React.useState( - scheduleToEdit ?? { + const [currentSchedule, setCurrentSchedule] = React.useState( + schedule ?? { startOn: defaultScheduleDate, days: [true, true, true, true, true, true, true], until: undefined, @@ -438,7 +440,7 @@ export function CreateTaskForm({ }, ); const [scheduleUntilValue, setScheduleUntilValue] = React.useState( - scheduleToEdit?.until ? ScheduleUntilValue.ON : ScheduleUntilValue.NEVER, + schedule?.until ? ScheduleUntilValue.ON : ScheduleUntilValue.NEVER, ); const handleScheduleUntilValue = (event: React.ChangeEvent) => { @@ -450,15 +452,15 @@ export function CreateTaskForm({ const date = new Date(); date.setHours(23); date.setMinutes(59); - setSchedule((prev) => ({ ...prev, until: date })); + setCurrentSchedule((prev) => ({ ...prev, until: date })); } else { - setSchedule((prev) => ({ ...prev, until: undefined })); + setCurrentSchedule((prev) => ({ ...prev, until: undefined })); } setScheduleUntilValue(event.target.value); }; - const existingBookingLabel = requestTask - ? getTaskBookingLabelFromTaskRequest(requestTask) + const existingBookingLabel = taskRequest + ? getTaskBookingLabelFromTaskRequest(taskRequest) : undefined; let existingWarnTime: Date | null = null; if (existingBookingLabel && 'unix_millis_warn_time' in existingBookingLabel) { @@ -477,7 +479,7 @@ export function CreateTaskForm({ }; const handleTaskDescriptionChange = (newCategory: string, newDesc: TaskDescription) => { - setTaskRequest((prev) => { + setCurrentTaskRequest((prev) => { return { ...prev, category: newCategory, @@ -490,7 +492,7 @@ export function CreateTaskForm({ // FIXME: Favorite tasks are disabled for custom compose tasks for now, as it // will require a re-write of FavoriteTask's pydantic model with better typing. const handleCustomComposeTaskDescriptionChange = (newDesc: CustomComposeTaskDescription) => { - setTaskRequest((prev) => { + setCurrentTaskRequest((prev) => { return { ...prev, category: CustomComposeTaskDefinition.requestCategory, @@ -508,7 +510,7 @@ export function CreateTaskForm({ case PatrolTaskDefinition.taskDefinitionId: return ( handleTaskDescriptionChange(PatrolTaskDefinition.requestCategory, desc) @@ -519,7 +521,7 @@ export function CreateTaskForm({ case DeliveryTaskDefinition.taskDefinitionId: return ( @@ -531,10 +533,10 @@ export function CreateTaskForm({ case ComposeCleanTaskDefinition.taskDefinitionId: return ( { - desc.category = taskRequest.description.category; + desc.category = currentTaskRequest.description.category; handleTaskDescriptionChange(ComposeCleanTaskDefinition.requestCategory, desc); }} onValidate={onValidate} @@ -543,14 +545,14 @@ export function CreateTaskForm({ case DeliveryPickupTaskDefinition.taskDefinitionId: return ( { - desc.category = taskRequest.description.category; + desc.category = currentTaskRequest.description.category; desc.phases[0].activity.description.activities[1].description.category = - taskRequest.description.category; + currentTaskRequest.description.category; handleTaskDescriptionChange(DeliveryPickupTaskDefinition.requestCategory, desc); }} onValidate={onValidate} @@ -559,14 +561,14 @@ export function CreateTaskForm({ case DeliverySequentialLotPickupTaskDefinition.taskDefinitionId: return ( { - desc.category = taskRequest.description.category; + desc.category = currentTaskRequest.description.category; desc.phases[0].activity.description.activities[1].description.category = - taskRequest.description.category; + currentTaskRequest.description.category; handleTaskDescriptionChange( DeliverySequentialLotPickupTaskDefinition.requestCategory, desc, @@ -578,14 +580,14 @@ export function CreateTaskForm({ case DeliveryAreaPickupTaskDefinition.taskDefinitionId: return ( { - desc.category = taskRequest.description.category; + desc.category = currentTaskRequest.description.category; desc.phases[0].activity.description.activities[1].description.category = - taskRequest.description.category; + currentTaskRequest.description.category; handleTaskDescriptionChange(DeliveryAreaPickupTaskDefinition.requestCategory, desc); }} onValidate={onValidate} @@ -594,7 +596,7 @@ export function CreateTaskForm({ case CustomComposeTaskDefinition.taskDefinitionId: return ( { handleCustomComposeTaskDescriptionChange(desc); }} @@ -613,13 +615,13 @@ export function CreateTaskForm({ `Failed to retrieve task request category for task [${newTaskDefinitionId}], there might be a misconfiguration.`, ); console.error(err.message); - onFail && onFail(err, []); + onFail && onFail(err); return; } - taskRequest.category = category; + currentTaskRequest.category = category; const description = getDefaultTaskDescription(newTaskDefinitionId) ?? ''; - taskRequest.description = description; + currentTaskRequest.description = description; if ( newTaskDefinitionId !== CustomComposeTaskDefinition.taskDefinitionId && @@ -629,14 +631,8 @@ export function CreateTaskForm({ } }; - // no memo because deps would likely change - const handleSubmit = async (scheduling: boolean) => { - if (!submitTasks) { - onSuccess && onSuccess([taskRequest]); - return; - } - - const request = { ...taskRequest }; + const configureTaskRequest = (scheduling: boolean): TaskRequest | null => { + const request = { ...currentTaskRequest }; request.requester = user; request.unix_millis_request_time = Date.now(); @@ -647,8 +643,8 @@ export function CreateTaskForm({ request.description = obj; } catch (e) { console.error('Invalid custom compose task description'); - onFail && onFail(e as Error, [request]); - return; + onFail && onFail(e as Error, request); + return null; } } @@ -681,8 +677,8 @@ export function CreateTaskForm({ const error = Error( `Failed to generate booking label for task request of definition ID: ${taskDefinitionId}`, ); - onFail && onFail(error, [request]); - return; + onFail && onFail(error, request); + return null; } if (warnTime !== null) { @@ -697,36 +693,81 @@ export function CreateTaskForm({ console.log(`labels: ${request.labels}`); } catch (e) { console.error('Failed to generate string for task request label'); + onFail && onFail(e as Error, request); + return null; + } + return request; + }; + + const handleSubmitNow: React.MouseEventHandler = async (ev) => { + ev.preventDefault(); + if (!onDispatchTask) { + return; + } + + const request = configureTaskRequest(false); + if (!request) { + return; } try { setSubmitting(true); - await submitTasks([request], scheduling ? schedule : null); - setSubmitting(false); - - if (scheduling) { - onSuccessScheduling && onSuccessScheduling(); + if (dispatchType === DispatchType.Robot) { + await onDispatchTask(request, robotDispatchTarget); } else { - onSuccess && onSuccess([request]); + await onDispatchTask(request, null); } + setSubmitting(false); + + onSuccess && onSuccess(request); } catch (e) { setSubmitting(false); - if (scheduling) { - onFailScheduling && onFailScheduling(e as Error); - } else { - onFail && onFail(e as Error, [request]); - } + onFail && onFail(e as Error, request); } }; - const handleSubmitNow: React.MouseEventHandler = async (ev) => { + const handleSubmitSchedule: React.FormEventHandler = async (ev) => { ev.preventDefault(); - await handleSubmit(false); + if (!onScheduleTask) { + return; + } + + const request = configureTaskRequest(false); + if (!request) { + return; + } + + try { + setSubmitting(true); + await onScheduleTask(request, currentSchedule); + setSubmitting(false); + onSuccessScheduling && onSuccessScheduling(); + } catch (e) { + setSubmitting(false); + onFailScheduling && onFailScheduling(e as Error); + } }; - const handleSubmitSchedule: React.FormEventHandler = async (ev) => { + const handleEditSchedule: React.FormEventHandler = async (ev) => { ev.preventDefault(); - await handleSubmit(true); + if (!onEditScheduleTask) { + return; + } + + const request = configureTaskRequest(false); + if (!request) { + return; + } + + try { + setSubmitting(true); + await onEditScheduleTask(request, currentSchedule); + setSubmitting(false); + onSuccessScheduling && onSuccessScheduling(); + } catch (e) { + setSubmitting(false); + onFailScheduling && onFailScheduling(e as Error); + } }; const handleSubmitFavoriteTask: React.MouseEventHandler = async (ev) => { @@ -784,7 +825,7 @@ export function CreateTaskForm({ return; } - setTaskRequest(defaultTaskRequest); + setCurrentTaskRequest(defaultTaskRequest); setOpenFavoriteDialog(false); setCallToDeleteFavoriteTask(false); setCallToUpdateFavoriteTask(false); @@ -795,7 +836,7 @@ export function CreateTaskForm({ }; const handlePrioritySwitchChange = (event: React.ChangeEvent) => { - setTaskRequest((prev) => { + setCurrentTaskRequest((prev) => { return { ...prev, priority: createTaskPriority(event.target.checked), @@ -803,19 +844,73 @@ export function CreateTaskForm({ }); }; + const enum DispatchType { + Automatic = 'Automatic', + Fleet = 'Fleet', + Robot = 'Robot', + } + + const [dispatchType, setDispatchType] = React.useState( + taskRequest && taskRequest.fleet_name ? DispatchType.Fleet : DispatchType.Automatic, + ); + const [robotDispatchTarget, setRobotDispatchTarget] = React.useState( + null, + ); + + const handleChangeDispatchType = (ev: React.ChangeEvent) => { + setDispatchType(ev.target.value as DispatchType); + setCurrentTaskRequest((prev) => { + return { + ...prev, + fleet_name: undefined, + }; + }); + setRobotDispatchTarget(null); + }; + + const handleDispatchFleetTargetChange = (ev: React.ChangeEvent) => { + setCurrentTaskRequest((prev) => { + return { + ...prev, + fleet_name: ev.target.value.length > 0 ? ev.target.value : undefined, + }; + }); + }; + + const handleDispatchRobotTargetChange = (ev: React.ChangeEvent) => { + if (!fleets) { + setRobotDispatchTarget(null); + return; + } + let robotFleet: string | null = null; + for (const fleetName of Object.keys(fleets)) { + if (fleets[fleetName].includes(ev.target.value)) { + robotFleet = fleetName; + break; + } + } + if (robotFleet === null) { + // Technically this will never happen, as users can only select robots + // that have fleets already registered. + console.error(`Failed to find fleet name for robot [${ev.target.value}]`); + return; + } + setRobotDispatchTarget({ fleet: robotFleet, robot: ev.target.value }); + }; + return ( <> -
+ - Create Task + {taskRequest ? 'Edit Schedule' : 'Create Task'} @@ -839,7 +934,7 @@ export function CreateTaskForm({ setOpenDialog={setOpenFavoriteDialog} listItemClick={() => { setFavoriteTaskBuffer(favoriteTask); - setTaskRequest({ + setCurrentTaskRequest({ category: favoriteTask.category, description: favoriteTask.description, unix_millis_earliest_start_time: 0, @@ -911,13 +1006,13 @@ export function CreateTaskForm({ } label="Prioritize" sx={{ - color: parseTaskPriority(taskRequest.priority) + color: parseTaskPriority(currentTaskRequest.priority) ? undefined : theme.palette.action.disabled, }} @@ -930,6 +1025,92 @@ export function CreateTaskForm({ flexItem style={{ marginTop: theme.spacing(2), marginBottom: theme.spacing(2) }} /> + + + + {DispatchType.Automatic} + {DispatchType.Fleet} + {DispatchType.Robot} + + + + {dispatchType === DispatchType.Fleet ? ( + + {fleets && + Object.keys(fleets).map((fleetName) => { + return ( + + {fleetName} + + ); + })} + + ) : dispatchType === DispatchType.Robot ? ( + + {fleets && + Object.keys(fleets).flatMap((fleetName) => { + const fleetRobots = [ + , + + {fleetName} + , + ]; + return fleetRobots.concat( + fleets[fleetName].map((robotName) => ( + + {robotName} + + )), + ); + })} + + ) : null} + + + {renderTaskDescriptionForm(taskDefinitionId)} @@ -964,22 +1145,37 @@ export function CreateTaskForm({ > Cancel - + {onScheduleTask ? ( + + ) : null} + {onEditScheduleTask ? ( + + ) : null}