diff --git a/apps/web-ui/vite.config.ts b/apps/web-ui/vite.config.ts index 50a04a6..1dfb14a 100644 --- a/apps/web-ui/vite.config.ts +++ b/apps/web-ui/vite.config.ts @@ -2,7 +2,7 @@ import { vitePlugin as remix } from '@remix-run/dev'; import { defineConfig, loadEnv } from 'vite'; import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; -const BASE_URL = '/ui/'; +const BASE_URL = '/ui'; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); diff --git a/libs/data-access/admin-api-fixtures/src/lib/adminApiDb.ts b/libs/data-access/admin-api-fixtures/src/lib/adminApiDb.ts index 15008a7..0ae9b7f 100644 --- a/libs/data-access/admin-api-fixtures/src/lib/adminApiDb.ts +++ b/libs/data-access/admin-api-fixtures/src/lib/adminApiDb.ts @@ -2,10 +2,16 @@ import { factory, manyOf, oneOf, primaryKey } from '@mswjs/data'; import { faker } from '@faker-js/faker'; faker.seed(Date.now()); +const names = faker.helpers.uniqueArray(faker.word.noun, 1000); + +let index = 0; +export function getName() { + return names[index++]; +} export const adminApiDb = factory({ handler: { - name: primaryKey(() => `${faker.hacker.noun()}`), + name: primaryKey(() => `${getName()}`), ty: () => faker.helpers.arrayElement(['Exclusive', 'Shared', 'Workflow'] as const), input_description: () => @@ -13,7 +19,7 @@ export const adminApiDb = factory({ output_description: () => "value of content-type 'application/json'", }, service: { - name: primaryKey(() => `${faker.hacker.noun()}Service`), + name: primaryKey(() => `${getName()}Service`), handlers: manyOf('handler'), deployment: oneOf('deployment'), ty: () => @@ -30,23 +36,21 @@ export const adminApiDb = factory({ deployment: { id: primaryKey(() => `dp_${faker.string.nanoid(27)}`), services: manyOf('service'), + dryRun: Boolean, + endpoint: () => faker.internet.url(), }, }); const isE2E = process.env['SCENARIO'] === 'E2E'; if (!isE2E) { - const services = Array(3) - .fill(null) - .map(() => adminApiDb.service.create()); Array(30) .fill(null) - .map(() => - adminApiDb.deployment.create({ - services: services.slice( - 0, - Math.floor(Math.random() * services.length + 1) - ), - }) - ); + .map(() => { + const deployment = adminApiDb.deployment.create(); + Array(Math.floor(Math.random() * 3 + 1)) + .fill(null) + .map(() => adminApiDb.service.create({ deployment })); + return deployment; + }); } diff --git a/libs/data-access/admin-api-fixtures/src/lib/adminApiMockHandlers.ts b/libs/data-access/admin-api-fixtures/src/lib/adminApiMockHandlers.ts index 9086f0a..8a6c27f 100644 --- a/libs/data-access/admin-api-fixtures/src/lib/adminApiMockHandlers.ts +++ b/libs/data-access/admin-api-fixtures/src/lib/adminApiMockHandlers.ts @@ -1,7 +1,6 @@ import * as adminApi from '@restate/data-access/admin-api/spec'; import { http, HttpResponse } from 'msw'; -import { adminApiDb } from './adminApiDb'; -import { faker } from '@faker-js/faker'; +import { adminApiDb, getName } from './adminApiDb'; type FormatParameterWithColon = S extends `${infer A}{${infer P}}${infer B}` ? `${A}:${P}${B}` : S; @@ -15,12 +14,26 @@ const listDeploymentsHandler = http.get< adminApi.operations['list_deployments']['responses']['200']['content']['application/json'], GetPath<'/deployments'> >('/deployments', async () => { - const deployments = adminApiDb.deployment.getAll(); + const deployments = adminApiDb.deployment + .getAll() + .filter(({ dryRun }) => !dryRun); return HttpResponse.json({ deployments: deployments.map((deployment) => ({ id: deployment.id, - services: deployment.services, - uri: faker.internet.url(), + services: adminApiDb.service + .findMany({ + where: { deployment: { id: { equals: deployment.id } } }, + }) + .map((service) => ({ + name: service.name, + deployment_id: deployment.id, + public: service.public, + revision: service.revision, + ty: service.ty, + idempotency_retention: service.idempotency_retention, + workflow_completion_retention: service.idempotency_retention, + })), + uri: deployment.endpoint, protocol_type: 'RequestResponse', created_at: new Date().toISOString(), http_version: 'HTTP/2.0', @@ -37,10 +50,68 @@ const registerDeploymentHandler = http.post< GetPath<'/deployments'> >('/deployments', async ({ request }) => { const requestBody = await request.json(); - const newDeployment = adminApiDb.deployment.create({}); + const requestEndpoint = + 'uri' in requestBody ? requestBody.uri : requestBody.arn; + const existingDeployment = adminApiDb.deployment.findFirst({ + where: { + endpoint: { + equals: requestEndpoint, + }, + dryRun: { + equals: true, + }, + }, + }); + + if (existingDeployment) { + adminApiDb.deployment.update({ + where: { + id: { + equals: existingDeployment.id, + }, + }, + data: { dryRun: false }, + }); + + return HttpResponse.json({ + id: existingDeployment.id, + services: adminApiDb.service + .findMany({ + where: { deployment: { id: { equals: existingDeployment.id } } }, + }) + .map((service) => ({ + name: service.name, + deployment_id: service.deployment!.id, + public: service.public, + revision: service.revision, + ty: service.ty, + idempotency_retention: service.idempotency_retention, + workflow_completion_retention: service.idempotency_retention, + handlers: service.handlers.map((handler) => ({ + name: handler.name, + ty: handler.ty, + input_description: handler.input_description, + output_description: handler.output_description, + })), + })), + }); + } + + const newDeployment = adminApiDb.deployment.create({ + dryRun: requestBody.dry_run, + endpoint: requestEndpoint, + }); const services = Array(3) .fill(null) - .map(() => adminApiDb.service.create({ deployment: newDeployment })); + .map(() => + adminApiDb.service.create({ + deployment: newDeployment, + name: `${getName()}Service`, + handlers: Array(Math.floor(Math.random() * 6)) + .fill(null) + .map(() => adminApiDb.handler.create({ name: getName() })), + }) + ); return HttpResponse.json({ id: newDeployment.id, diff --git a/libs/features/explainers/src/index.ts b/libs/features/explainers/src/index.ts index 513a74e..bf436f7 100644 --- a/libs/features/explainers/src/index.ts +++ b/libs/features/explainers/src/index.ts @@ -1,2 +1,3 @@ export * from './lib/ServiceDeployment'; export * from './lib/Service'; +export * from './lib/ServiceType'; diff --git a/libs/features/explainers/src/lib/ServiceDeployment.tsx b/libs/features/explainers/src/lib/ServiceDeployment.tsx index 4da0439..ef446fc 100644 --- a/libs/features/explainers/src/lib/ServiceDeployment.tsx +++ b/libs/features/explainers/src/lib/ServiceDeployment.tsx @@ -3,9 +3,11 @@ import { PropsWithChildren } from 'react'; export function ServiceDeploymentExplainer({ children, -}: PropsWithChildren) { + className, +}: PropsWithChildren<{ className?: string }>) { return ( diff --git a/libs/features/explainers/src/lib/ServiceType.tsx b/libs/features/explainers/src/lib/ServiceType.tsx new file mode 100644 index 0000000..fc0391c --- /dev/null +++ b/libs/features/explainers/src/lib/ServiceType.tsx @@ -0,0 +1,47 @@ +import { InlineTooltip } from '@restate/ui/tooltip'; +import { ComponentProps, PropsWithChildren } from 'react'; +import * as adminApi from '@restate/data-access/admin-api/spec'; +type ServiceType = adminApi.components['schemas']['ServiceMetadata']['ty']; + +const TITLES: Record = { + Service: 'Service', + VirtualObject: 'Virtual object', + Workflow: 'Workflow', +}; + +const DESCRIPTIONS: Record = { + Service: + 'Services expose a collection of durably executed handlers. They do not have any concurrency limits nor K/V store.', + VirtualObject: + 'Virtual objects expose a set of durably executed handlers with access to K/V state stored in Restate. To ensure consistent writes to the state, Restate provides concurrency guarantees for Virtual Objects.', + Workflow: + 'A workflow is a special type of Virtual Object that can be used to implement a set of steps that need to be executed durably. Workflows have additional capabilities such as signaling, querying, additional invocation options', +}; + +const LEARN_MORE: Record = { + Service: 'https://docs.restate.dev/concepts/services/#services-1', + VirtualObject: 'https://docs.restate.dev/concepts/services/#virtual-objects', + Workflow: 'https://docs.restate.dev/concepts/services/#workflows', +}; +export function ServiceTypeExplainer({ + children, + className, + variant, + type, +}: PropsWithChildren<{ + className?: string; + variant?: ComponentProps['variant']; + type: ServiceType; +}>) { + return ( + {DESCRIPTIONS[type]}

} + learnMoreHref={LEARN_MORE[type]} + > + {children} +
+ ); +} diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/AdditionalHeaders.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/AdditionalHeaders.tsx index 3421e84..ae4305c 100644 --- a/libs/features/overview-route/src/lib/RegisterDeployment/AdditionalHeaders.tsx +++ b/libs/features/overview-route/src/lib/RegisterDeployment/AdditionalHeaders.tsx @@ -5,13 +5,10 @@ import { FormFieldInput, } from '@restate/ui/form-field'; import { IconName, Icon } from '@restate/ui/icons'; -import { useListData } from 'react-stately'; +import { useRegisterDeploymentContext } from './Context'; export function AdditionalHeaders() { - const list = useListData<{ key: string; value: string; index: number }>({ - initialItems: [{ key: '', value: '', index: 0 }], - getKey: (item) => item.index, - }); + const { additionalHeaders: list } = useRegisterDeploymentContext(); return ( @@ -23,7 +20,7 @@ export function AdditionalHeaders() { Headers added to the discover/invoke requests to the deployment. - {list.items.map((item) => ( + {list?.items.map((item) => (
- list.append({ + list?.append({ key: '', value: '', index: Math.floor(Math.random() * 1000000), diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/AssumeARNRole.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/AssumeARNRole.tsx index 3b30764..357f62c 100644 --- a/libs/features/overview-route/src/lib/RegisterDeployment/AssumeARNRole.tsx +++ b/libs/features/overview-route/src/lib/RegisterDeployment/AssumeARNRole.tsx @@ -10,8 +10,7 @@ export function AssumeARNRole() { <> Assume role ARN - Optional ARN of a role to assume when invoking the addressed Lambda, - to support role chaining + ARN of a role to use when invoking the Lambda } diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/Context.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/Context.tsx new file mode 100644 index 0000000..7c63e45 --- /dev/null +++ b/libs/features/overview-route/src/lib/RegisterDeployment/Context.tsx @@ -0,0 +1,342 @@ +import { Form } from '@remix-run/react'; +import { + createContext, + FormEvent, + PropsWithChildren, + useCallback, + useContext, + useId, + useReducer, + useRef, +} from 'react'; +import { ListData, useListData } from 'react-stately'; +import * as adminApi from '@restate/data-access/admin-api/spec'; +import { + useListDeployments, + useRegisterDeployment, +} from '@restate/data-access/admin-api'; +import { useDialog } from '@restate/ui/dialog'; +import { getEndpoint } from '../types'; + +type NavigateToAdvancedAction = { + type: 'NavigateToAdvancedAction'; +}; + +type NavigateToConfirmAction = { + type: 'NavigateToConfirmAction'; +}; + +type NavigateToEndpointAction = { + type: 'NavigateToEndpointAction'; +}; +type UpdateEndpointAction = { + type: 'UpdateEndpointAction'; + payload: Pick< + DeploymentRegistrationContextInterface, + 'endpoint' | 'isLambda' | 'isDuplicate' + >; +}; +type UpdateRoleArnAction = { + type: 'UpdateRoleArnAction'; + payload: Pick; +}; +type UpdateUseHttp11Action = { + type: 'UpdateUseHttp11Action'; + payload: Pick; +}; +type UpdateServicesActions = { + type: 'UpdateServicesActions'; + payload: Pick; +}; +type UpdateShouldForce = { + type: 'UpdateShouldForce'; + payload: Pick; +}; + +type Action = + | NavigateToAdvancedAction + | NavigateToConfirmAction + | NavigateToEndpointAction + | UpdateEndpointAction + | UpdateRoleArnAction + | UpdateUseHttp11Action + | UpdateServicesActions + | UpdateShouldForce; + +interface DeploymentRegistrationContextInterface { + endpoint?: string; + stage: 'endpoint' | 'advanced' | 'confirm'; + assumeRoleArn?: string; + useHttp11?: boolean; + isLambda: boolean; + formId?: string; + additionalHeaders?: ListData<{ + key: string; + value: string; + index: number; + }>; + isPending?: boolean; + isDuplicate?: boolean; + shouldForce?: boolean; + services?: adminApi.components['schemas']['ServiceMetadata'][]; + goToEndpoint?: VoidFunction; + goToAdvanced?: VoidFunction; + goToConfirm?: VoidFunction; + updateEndpoint?: (value: UpdateEndpointAction['payload']) => void; + register?: (isDryRun: boolean) => void; + updateAssumeRoleArn?: (value: string) => void; + updateUseHttp11Arn?: (value: boolean) => void; + updateShouldForce?: (value: boolean) => void; + error: + | { + message: string; + restate_code?: string | null; + } + | null + | undefined; +} + +type State = Pick< + DeploymentRegistrationContextInterface, + | 'endpoint' + | 'isLambda' + | 'stage' + | 'services' + | 'assumeRoleArn' + | 'useHttp11' + | 'shouldForce' + | 'isDuplicate' +>; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'NavigateToAdvancedAction': + return { ...state, stage: 'advanced' }; + case 'NavigateToConfirmAction': + return { ...state, stage: 'confirm' }; + case 'NavigateToEndpointAction': + return { ...state, stage: 'endpoint' }; + case 'UpdateEndpointAction': + return { ...state, ...action.payload }; + case 'UpdateRoleArnAction': + return { ...state, assumeRoleArn: action.payload.assumeRoleArn }; + case 'UpdateUseHttp11Action': + return { ...state, useHttp11: action.payload.useHttp11 }; + case 'UpdateServicesActions': + return { ...state, services: action.payload.services }; + case 'UpdateShouldForce': + return { ...state, shouldForce: action.payload.shouldForce }; + + default: + return state; + } +} + +const initialState: DeploymentRegistrationContextInterface = { + stage: 'endpoint', + isLambda: false, + error: null, +}; +const DeploymentRegistrationContext = + createContext(initialState); + +function withoutTrailingSlash(url?: string) { + return url?.endsWith('/') ? url.slice(0, -1) : url; +} + +export function DeploymentRegistrationState(props: PropsWithChildren) { + const id = useId(); + const formRef = useRef(null); + const [state, dispatch] = useReducer(reducer, initialState); + const additionalHeaders = useListData<{ + key: string; + value: string; + index: number; + }>({ + initialItems: [{ key: '', value: '', index: 0 }], + getKey: (item) => item.index, + }); + const { close } = useDialog(); + const goToAdvanced = useCallback(() => { + dispatch({ type: 'NavigateToAdvancedAction' }); + }, []); + const goToEndpoint = useCallback(() => { + dispatch({ type: 'NavigateToEndpointAction' }); + }, []); + const goToConfirm = useCallback(() => { + dispatch({ type: 'NavigateToConfirmAction' }); + }, []); + const updateAssumeRoleArn = useCallback((assumeRoleArn: string) => { + dispatch({ type: 'UpdateRoleArnAction', payload: { assumeRoleArn } }); + }, []); + const updateUseHttp11Arn = useCallback((useHttp11: boolean) => { + dispatch({ type: 'UpdateUseHttp11Action', payload: { useHttp11 } }); + }, []); + const updateServices = useCallback( + (services: DeploymentRegistrationContextInterface['services']) => { + dispatch({ type: 'UpdateServicesActions', payload: { services } }); + }, + [] + ); + const updateShouldForce = useCallback( + (shouldForce: DeploymentRegistrationContextInterface['shouldForce']) => { + dispatch({ type: 'UpdateShouldForce', payload: { shouldForce } }); + }, + [] + ); + const { refetch, data: listDeployments } = useListDeployments(); + const updateEndpoint = useCallback( + (value: UpdateEndpointAction['payload']) => { + if (state.isLambda !== value.isLambda) { + formRef.current?.reset(); + } + const isDuplicate = listDeployments?.deployments.some( + (deployment) => + withoutTrailingSlash(getEndpoint(deployment)) === + withoutTrailingSlash(value.endpoint) + ); + dispatch({ + type: 'UpdateEndpointAction', + payload: { + isLambda: value.isLambda, + endpoint: value.endpoint, + isDuplicate, + }, + }); + }, + [listDeployments?.deployments, state.isLambda] + ); + const { mutate, isPending, error } = useRegisterDeployment({ + onSuccess(data) { + updateServices(data?.services); + + if (state.stage === 'confirm') { + refetch(); + close(); + } else { + goToConfirm(); + } + }, + }); + + const submitHandler = (event: FormEvent) => { + event.preventDefault(); + const submitter = (event.nativeEvent as SubmitEvent) + .submitter as HTMLButtonElement; + const action = submitter.value; + const { + endpoint = '', + isLambda, + assumeRoleArn, + useHttp11, + shouldForce, + } = state; + + if (action === 'advanced') { + goToAdvanced(); + return; + } + + const additional_headers: Record = + additionalHeaders.items.reduce((result, { key, value }) => { + if (typeof key === 'string' && typeof value === 'string' && key) { + return { ...result, [key]: value }; + } + return result; + }, {}); + + mutate({ + body: { + ...(isLambda + ? { arn: endpoint, assume_role_arn: assumeRoleArn } + : { + uri: endpoint, + use_http_11: Boolean(useHttp11), + }), + force: Boolean(shouldForce), + dry_run: action === 'dryRun', + additional_headers, + }, + }); + }; + + return ( + +
+ {props.children} +
+
+ ); +} + +export function useRegisterDeploymentContext() { + const { + stage, + endpoint, + isLambda, + goToAdvanced, + goToConfirm, + goToEndpoint, + updateEndpoint, + formId, + additionalHeaders, + services, + updateAssumeRoleArn, + updateUseHttp11Arn, + updateShouldForce, + shouldForce, + useHttp11, + assumeRoleArn, + error, + isPending, + isDuplicate, + } = useContext(DeploymentRegistrationContext); + const isEndpoint = stage === 'endpoint'; + const isAdvanced = stage === 'advanced'; + const isConfirm = stage === 'confirm'; + + return { + isAdvanced, + isEndpoint, + isConfirm, + isLambda, + isDuplicate, + endpoint, + goToAdvanced, + goToConfirm, + goToEndpoint, + updateEndpoint, + isPending, + formId, + additionalHeaders, + services, + updateAssumeRoleArn, + updateUseHttp11Arn, + updateShouldForce, + shouldForce, + useHttp11, + assumeRoleArn, + error, + }; +} diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/Dialog.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/Dialog.tsx index bf3d240..180241a 100644 --- a/libs/features/overview-route/src/lib/RegisterDeployment/Dialog.tsx +++ b/libs/features/overview-route/src/lib/RegisterDeployment/Dialog.tsx @@ -12,53 +12,81 @@ import { ErrorBanner } from '@restate/ui/error'; import { RegistrationForm } from './Form'; import { REGISTER_DEPLOYMENT_QUERY } from './constant'; import { Link } from '@restate/ui/link'; +import { + DeploymentRegistrationState, + useRegisterDeploymentContext, +} from './Context'; -function RegisterDeploymentFooter({ - isDryRun, - setIsDryRun, - error, - isPending, - formId, -}: { - isDryRun: boolean; - formId: string; - isPending: boolean; - setIsDryRun: (value: boolean) => void; - error?: { - message: string; - restate_code?: string | null; - } | null; -}) { +function RegisterDeploymentFooter() { + const { + isAdvanced, + isEndpoint, + isConfirm, + isPending, + goToEndpoint, + error, + formId, + } = useRegisterDeploymentContext(); return (
{error && }
- {isDryRun ? ( - + + + +
+ {(isEndpoint || isAdvanced) && ( + + Next + + + )} + {isConfirm && ( + + Confirm + + )} + {isEndpoint && ( + + Advanced + + + )} + {isAdvanced && ( - - ) : ( - - )} - - {isDryRun ? 'Next' : 'Confirm'} - + )} +
@@ -80,8 +108,11 @@ export function TriggerRegisterDeploymentDialog({ {children} - - {RegisterDeploymentFooter} + + + + + ); diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/Form.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/Form.tsx index 51ba79b..5953f0e 100644 --- a/libs/features/overview-route/src/lib/RegisterDeployment/Form.tsx +++ b/libs/features/overview-route/src/lib/RegisterDeployment/Form.tsx @@ -1,26 +1,15 @@ -import { Form } from '@remix-run/react'; -import { - useListDeployments, - useRegisterDeployment, -} from '@restate/data-access/admin-api'; -import { useDialog } from '@restate/ui/dialog'; import { FormFieldCheckbox, FormFieldInput } from '@restate/ui/form-field'; import { Icon, IconName } from '@restate/ui/icons'; -import { - FormEvent, - PropsWithChildren, - ReactNode, - useEffect, - useId, - useState, -} from 'react'; +import { PropsWithChildren, ReactNode } from 'react'; import { Radio } from 'react-aria-components'; import { RadioGroup } from '@restate/ui/radio-group'; import { RegisterDeploymentResults } from './Results'; import { AdditionalHeaders } from '../RegisterDeployment/AdditionalHeaders'; -import { DeploymentType } from '../types'; import { UseHTTP11 } from '../RegisterDeployment/UseHTTP11'; import { AssumeARNRole } from '../RegisterDeployment/AssumeARNRole'; +import { useRegisterDeploymentContext } from './Context'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@restate/ui/tooltip'; +import { ServiceDeploymentExplainer } from '@restate/features/explainers'; function CustomRadio({ value, @@ -33,7 +22,12 @@ function CustomRadio({ return ( `${className} + className={({ + isFocusVisible, + isSelected, + isPressed, + isDisabled, + }) => `${className} group relative flex cursor-default rounded-lg shadow-none outline-none bg-clip-padding border ${ isFocusVisible @@ -49,210 +43,198 @@ function CustomRadio({ } ${isPressed && !isSelected ? 'bg-gray-100' : ''} ${!isSelected && !isPressed ? 'bg-white/50' : ''} + ${isDisabled ? 'opacity-50' : ''} `} > {children} ); } -// TODO: change type on paste -// fix autofocus -function RegistrationFormFields({ + +function Container({ + title, + description, children, - className = '', -}: PropsWithChildren<{ className?: string }>) { - const [type, setType] = useState('uri'); - const isURI = type === 'uri'; - const isLambda = type === 'arn'; +}: PropsWithChildren<{ + title: ReactNode; + description?: ReactNode; +}>) { + return ( +
+

{title}

+ {description ? ( +

{description}

+ ) : ( +
+ )} +
{children}
+
+ ); +} + +export function RegistrationForm() { + const { isEndpoint, isAdvanced, isConfirm } = useRegisterDeploymentContext(); return ( <> -
-

- Register deployment -

-

- Point Restate to your deployed services so Restate can discover and - register your services and handlers -

-
-
- - Please specify the HTTP endpoint or Lambda identifier: - - } - /> - - Please specify the HTTP endpoint or Lambda identifier: - - } - /> -
- setType(value as 'uri' | 'arn')} - > + {isEndpoint && ( + + Register{' '} + + service deployment + + + } + description="Please provide the HTTP endpoint or Lambda ARN where your service is running:" + > + + + )} + {isAdvanced && ( + + + + )} + {isConfirm && ( + + + + )} + + ); +} + +function EndpointForm() { + const { + isLambda, + updateEndpoint, + endpoint, + isPending, + isDuplicate, + shouldForce, + updateShouldForce, + } = useRegisterDeploymentContext(); + return ( + <> + { + updateEndpoint?.({ + isLambda: value.startsWith('arn') + ? true + : value.startsWith('http') + ? false + : isLambda, + endpoint: value, + }); + }} + > +
+ + updateEndpoint?.({ + isLambda: value === 'true', + endpoint: '', + }) + } + disabled={isPending} + > + + + + + HTTP endpoint + + + + - -
+ + + AWS Lambda + + +
+
+
+ {isDuplicate && ( + +
+ + Override existing deployments
- - - Override existing deployments - + An existing deployment with the same {isLambda ? 'ARN' : 'URL'}{' '} + already exists. Would you like to override it?
- - If selected, it will override any existing deployment with the - same URI/identifier, potentially causing unrecoverable errors in - active invocations. - -
- {isURI && } - {isLambda && } - -
-
- {children} + Please note that this may cause{' '} + unrecoverable errors in active invocations. + + + )} ); } -export function RegistrationForm({ - children, -}: { - children: (props: { - isDryRun: boolean; - isPending: boolean; - formId: string; - setIsDryRun: (value: boolean) => void; - error?: { - message: string; - restate_code?: string | null; - } | null; - }) => ReactNode; -}) { - const formId = useId(); - const { close } = useDialog(); - const { refetch } = useListDeployments(); - const [isDryRun, setIsDryRun] = useState(true); - const { mutate, isPending, error, data, reset } = useRegisterDeployment({ - onSuccess: (data, variables) => { - setIsDryRun(false); - if (variables.body?.dry_run === false) { - refetch(); - close(); - } - }, - }); - - useEffect(() => { - return () => { - reset(); - }; - }, [reset]); - - function handleSubmit(event: FormEvent) { - event.preventDefault(); - const formData = new FormData(event.currentTarget); - const uri = String(formData.get('uri')); - const arn = String(formData.get('arn')); - const type = String(formData.get('type')); - const force = formData.get('force') === 'true'; - const use_http_11 = formData.get('use_http_11') === 'true'; - const assume_role_arn = - formData.get('assume_role_arn')?.toString() || undefined; - const keys = formData.getAll('key'); - const values = formData.getAll('value'); - const additional_headers: Record = keys.reduce( - (result, key, index) => { - const value = values.at(index); - if (typeof key === 'string' && typeof value === 'string' && key) { - return { ...result, [key]: value }; - } - return result; - }, - {} - ); - - mutate({ - body: { - ...(type === 'uri' ? { uri, use_http_11 } : { arn, assume_role_arn }), - force, - dry_run: isDryRun, - additional_headers, - }, - }); - } +function AdvancedForm() { + const { isLambda } = useRegisterDeploymentContext(); return ( -
- - {!isDryRun && data?.services && ( -
-

- Deployment {data.id} -

-

- Below, you will find the list of services and handlers included in - this deployment. Please confirm. -

- -
- )} -
- {children({ isDryRun, isPending, setIsDryRun, error, formId })} -
+ <> + {isLambda ? : } + + ); } diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/Results.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/Results.tsx index 2ffe28a..e4ed8db 100644 --- a/libs/features/overview-route/src/lib/RegisterDeployment/Results.tsx +++ b/libs/features/overview-route/src/lib/RegisterDeployment/Results.tsx @@ -1,11 +1,16 @@ import * as adminApi from '@restate/data-access/admin-api/spec'; import { Icon, IconName } from '@restate/ui/icons'; +import { useRegisterDeploymentContext } from './Context'; +import { + UNSTABLE_Disclosure as Disclosure, + UNSTABLE_DisclosurePanel as DisclosurePanel, +} from 'react-aria-components'; +import { Button } from '@restate/ui/button'; +import { InlineTooltip } from '@restate/ui/tooltip'; +import { ServiceTypeExplainer } from '@restate/features/explainers'; -export function RegisterDeploymentResults({ - services, -}: { - services: adminApi.components['schemas']['ServiceMetadata'][]; -}) { +export function RegisterDeploymentResults() { + const { services = [] } = useRegisterDeploymentContext(); if (services.length === 0) { return (
@@ -19,7 +24,11 @@ export function RegisterDeploymentResults({ return (
{services.map((service) => ( - + ))}
); @@ -27,33 +36,71 @@ export function RegisterDeploymentResults({ function Service({ service, + defaultExpanded, }: { service: adminApi.components['schemas']['ServiceMetadata']; + defaultExpanded?: boolean; }) { return ( -
-
-
-
- +
+ + isExpanded + ? '[&_.disclosure-icon]:rotate-180 [&>button]:shadow-lg [&>button]:shadow-zinc-800/5' + : '' + } + > +
-
{service.name}
-
- rev. {service.revision} -
-
- {service.ty} -
-
-
-
- Handlers -
- {service.handlers.map((handler) => ( - - ))} -
+
+
+
{service.name}
+
+ + {service.ty} + +
+
+
+
+ rev. {service.revision} +
+ + + + + {service.handlers.length > 0 && ( +
+
+ Handlers +
+ {service.handlers.map((handler) => ( + + ))} +
+ )} +
+
); } @@ -64,14 +111,20 @@ function ServiceHandler({ handler: adminApi.components['schemas']['ServiceMetadata']['handlers'][number]; }) { return ( -
-
-
- +
+
+
+
+ +
-
{handler.name}
-
+ +
{handler.name}
+
{handler.ty}
diff --git a/libs/features/overview-route/src/lib/RegisterDeployment/UseHTTP11.tsx b/libs/features/overview-route/src/lib/RegisterDeployment/UseHTTP11.tsx index 712d697..a3832d6 100644 --- a/libs/features/overview-route/src/lib/RegisterDeployment/UseHTTP11.tsx +++ b/libs/features/overview-route/src/lib/RegisterDeployment/UseHTTP11.tsx @@ -1,26 +1,28 @@ import { FormFieldCheckbox } from '@restate/ui/form-field'; +import { useRegisterDeploymentContext } from './Context'; export function UseHTTP11() { + const { updateUseHttp11Arn, useHttp11 } = useRegisterDeploymentContext(); + return ( - - +
+
Use HTTP1.1 - -
- + - If selected, discovery will use a client defaulting to{' '} - HTTP1.1. HTTP2 may be used for{' '} - TLS servers advertising HTTP2 support via - ALPN. HTTP1.1 will work only in request-response mode. - - + + HTTP1.1 will be used for service registration. + + +
); } diff --git a/libs/features/overview-route/src/lib/types.ts b/libs/features/overview-route/src/lib/types.ts index dfb0293..3aa2a4f 100644 --- a/libs/features/overview-route/src/lib/types.ts +++ b/libs/features/overview-route/src/lib/types.ts @@ -13,3 +13,10 @@ export function isLambdaDeployment( ): deployment is LambdaDeployment { return 'arn' in deployment; } +export function getEndpoint(deployment: Deployment) { + if (isHttpDeployment(deployment)) { + return deployment.uri; + } else { + return deployment.arn; + } +} diff --git a/libs/ui/button/src/lib/Button.tsx b/libs/ui/button/src/lib/Button.tsx index 6e1c6e7..028f7de 100644 --- a/libs/ui/button/src/lib/Button.tsx +++ b/libs/ui/button/src/lib/Button.tsx @@ -20,6 +20,7 @@ export interface ButtonProps { variant?: 'primary' | 'secondary' | 'destructive' | 'icon'; className?: string; form?: string; + slot?: string; } const styles = tv({ diff --git a/libs/ui/button/src/lib/SubmitButton.tsx b/libs/ui/button/src/lib/SubmitButton.tsx index d657f65..4e75e73 100644 --- a/libs/ui/button/src/lib/SubmitButton.tsx +++ b/libs/ui/button/src/lib/SubmitButton.tsx @@ -4,7 +4,7 @@ import { useDeferredValue, ComponentProps, } from 'react'; -import { useFetchers } from '@remix-run/react'; +import { useFetchers, useHref } from '@remix-run/react'; import { Button } from './Button'; import { tv } from 'tailwind-variants'; import { useIsMutating } from '@tanstack/react-query'; @@ -17,6 +17,8 @@ export interface SubmitButtonProps { value?: string; variant?: 'primary' | 'secondary' | 'destructive' | 'icon'; className?: string; + autoFocus?: boolean; + hideSpinner?: boolean; } const spinnerStyles = tv({ @@ -57,22 +59,23 @@ const styles = tv({ }); function useIsSubmitting(action?: string) { + const basename = useHref('/'); let actionUrl: URL | null = null; try { actionUrl = new URL(String(action)); } catch { actionUrl = null; } - + const formActionPathname = actionUrl?.pathname.split(basename).at(-1); const fetchers = useFetchers(); const submitFetcher = fetchers.find( - (fetcher) => fetcher.formAction === actionUrl?.pathname + (fetcher) => fetcher.formAction === formActionPathname ); const isMutating = useIsMutating({ predicate: (mutation) => { const [pathName] = mutation.options.mutationKey ?? []; - return actionUrl?.pathname === pathName; + return formActionPathname === pathName; }, }); @@ -82,6 +85,7 @@ function useIsSubmitting(action?: string) { export function SubmitButton({ disabled, children, + hideSpinner = false, ...props }: PropsWithChildren) { const ref = useRef(null); @@ -96,7 +100,7 @@ export function SubmitButton({ ref={ref} disabled={deferredIsSubmitting || disabled} > - {deferredIsSubmitting ? ( + {deferredIsSubmitting && !hideSpinner ? (
{children} diff --git a/libs/ui/dialog/src/lib/DialogContent.tsx b/libs/ui/dialog/src/lib/DialogContent.tsx index 9f8a336..6daa76b 100644 --- a/libs/ui/dialog/src/lib/DialogContent.tsx +++ b/libs/ui/dialog/src/lib/DialogContent.tsx @@ -48,7 +48,7 @@ export function DialogContent({ modalStyles({ ...renderProps, className }) )} > - +
{children} diff --git a/libs/ui/error/src/lib/ErrorBanner.tsx b/libs/ui/error/src/lib/ErrorBanner.tsx index da60110..78c7cd0 100644 --- a/libs/ui/error/src/lib/ErrorBanner.tsx +++ b/libs/ui/error/src/lib/ErrorBanner.tsx @@ -44,7 +44,7 @@ function SingleError({ name={IconName.CircleX} />
- + {typeof error === 'string' ? error : error.message} {children &&
{children}
} @@ -81,7 +81,7 @@ export function ErrorBanner({

There were {errors.length} errors:

- +
    {errors.map((error) => (
  • diff --git a/libs/ui/form-field/src/lib/FormFieldCheckbox.tsx b/libs/ui/form-field/src/lib/FormFieldCheckbox.tsx index 3d7aa2a..5996f96 100644 --- a/libs/ui/form-field/src/lib/FormFieldCheckbox.tsx +++ b/libs/ui/form-field/src/lib/FormFieldCheckbox.tsx @@ -19,6 +19,7 @@ interface FormFieldCheckboxProps errorMessage?: ComponentProps['children']; slot?: string; checked?: boolean; + onChange?: (checked: boolean) => void; direction?: 'left' | 'right'; } @@ -28,6 +29,7 @@ const styles = tv({ container: 'grid gap-x-2 items-center', input: 'disabled:text-gray-100 hover:disabled:text-gray-100 focus:disabled:text-gray-100 disabled:bg-gray-100 disabled:border-gray-100 disabled:shadow-none invalid:bg-red-100 invalid:border-red-600 text-blue-600 checked:focus:text-blue-800 bg-gray-100 row-start-1 min-w-0 rounded-md w-5 h-5 border-gray-200 focus:bg-gray-300 hover:bg-gray-300 shadow-[inset_0_0.5px_0.5px_0px_rgba(0,0,0,0.08)]', + error: 'error row-start-2 px-0', }, variants: { direction: { @@ -35,11 +37,13 @@ const styles = tv({ container: 'grid-cols-[1.25rem_1fr]', input: 'col-start-1', label: 'col-start-2', + error: 'col-start-2', }, right: { container: 'grid-cols-[1fr_1.25rem]', - input: 'col-start-2', + input: 'self-baseline col-start-2', label: 'col-start-1', + error: 'col-start-1', }, }, }, @@ -49,23 +53,28 @@ export const FormFieldCheckbox = forwardRef< PropsWithChildren >( ( - { className, errorMessage, children, direction = 'left', ...props }, + { + onChange, + className, + errorMessage, + children, + direction = 'left', + ...props + }, ref ) => { - const { input, container, label } = styles({ direction }); + const { input, container, label, error } = styles({ direction }); return ( - + onChange?.(event.currentTarget.checked)} /> - + ); } diff --git a/libs/ui/form-field/src/lib/FormFieldError.tsx b/libs/ui/form-field/src/lib/FormFieldError.tsx index 430834d..05761cd 100644 --- a/libs/ui/form-field/src/lib/FormFieldError.tsx +++ b/libs/ui/form-field/src/lib/FormFieldError.tsx @@ -9,7 +9,7 @@ interface FormFieldErrorProps extends Pick { } const styles = tv({ - base: 'text-xs px-1 pt-0.5 text-red-600 forced-colors:text-[Mark]', + base: 'error text-xs px-1 pt-0.5 text-red-600 forced-colors:text-[Mark]', }); export function FormFieldError({ className, ...props }: FormFieldErrorProps) { return ; diff --git a/libs/ui/form-field/src/lib/FormFieldInput.tsx b/libs/ui/form-field/src/lib/FormFieldInput.tsx index 9e0e2b3..96c931d 100644 --- a/libs/ui/form-field/src/lib/FormFieldInput.tsx +++ b/libs/ui/form-field/src/lib/FormFieldInput.tsx @@ -6,7 +6,12 @@ import { } from 'react-aria-components'; import { tv } from 'tailwind-variants'; import { FormFieldError } from './FormFieldError'; -import { ComponentProps, ReactNode } from 'react'; +import { + ComponentProps, + forwardRef, + PropsWithChildren, + ReactNode, +} from 'react'; import { FormFieldLabel } from './FormFieldLabel'; const inputStyles = tv({ @@ -38,34 +43,47 @@ interface InputProps label?: ReactNode; errorMessage?: ComponentProps['children']; } -export function FormFieldInput({ - className, - required, - disabled, - autoComplete = 'off', - placeholder, - errorMessage, - label, - readonly, - ...props -}: InputProps) { - return ( - - {!label && } - {label && {label}} - - - - ); -} +export const FormFieldInput = forwardRef< + HTMLInputElement, + PropsWithChildren +>( + ( + { + className, + required, + disabled, + autoComplete = 'off', + placeholder, + errorMessage, + label, + readonly, + children, + ...props + }, + ref + ) => { + return ( + + {!label && } + {label && {label}} +
    + + {children} +
    + +
    + ); + } +); diff --git a/libs/ui/icons/src/lib/Icons.tsx b/libs/ui/icons/src/lib/Icons.tsx index d245de6..3a92852 100644 --- a/libs/ui/icons/src/lib/Icons.tsx +++ b/libs/ui/icons/src/lib/Icons.tsx @@ -26,6 +26,9 @@ import { Box, SquareFunction, Info, + ArrowRight, + ArrowLeft, + ChevronLeft, } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { tv } from 'tailwind-variants'; @@ -38,6 +41,8 @@ import { Github } from './custom-icons/Github'; import { Discord } from './custom-icons/Discord'; import { SupportTicket } from './custom-icons/SupportTicket'; import { Help } from './custom-icons/Help'; +import { Question } from './custom-icons/Question'; +import { Function } from './custom-icons/Function'; export const enum IconName { ChevronDown = 'ChevronDown', @@ -76,6 +81,10 @@ export const enum IconName { Box = 'Box', Function = 'SquareFunction', Info = 'Info', + ArrowRight = 'ArrowRight', + ArrowLeft = 'ArrowLeft', + ChevronLeft = 'ChevronLeft', + Question = 'Question', } export interface IconsProps { name: IconName; @@ -118,8 +127,12 @@ const ICONS: Record = { [IconName.Help]: Help, [IconName.Lambda]: Lambda, [IconName.Box]: Box, - [IconName.Function]: SquareFunction, + [IconName.Function]: Function, [IconName.Info]: Info, + [IconName.ArrowLeft]: ArrowLeft, + [IconName.ArrowRight]: ArrowRight, + [IconName.ChevronLeft]: ChevronLeft, + [IconName.Question]: Question, }; const styles = tv({ diff --git a/libs/ui/icons/src/lib/custom-icons/Function.tsx b/libs/ui/icons/src/lib/custom-icons/Function.tsx new file mode 100644 index 0000000..69aa526 --- /dev/null +++ b/libs/ui/icons/src/lib/custom-icons/Function.tsx @@ -0,0 +1,21 @@ +import { LucideProps } from 'lucide-react'; +import { forwardRef } from 'react'; + +export const Function = forwardRef((props, ref) => { + return ( + + + + + ); +}); diff --git a/libs/ui/icons/src/lib/custom-icons/Question.tsx b/libs/ui/icons/src/lib/custom-icons/Question.tsx new file mode 100644 index 0000000..1d17d1b --- /dev/null +++ b/libs/ui/icons/src/lib/custom-icons/Question.tsx @@ -0,0 +1,21 @@ +import { LucideProps } from 'lucide-react'; +import { forwardRef } from 'react'; + +export const Question = forwardRef((props, ref) => { + return ( + + + + + ); +}); diff --git a/libs/ui/radio-group/src/lib/RadioGroup.tsx b/libs/ui/radio-group/src/lib/RadioGroup.tsx index 20fd54f..cb19de9 100644 --- a/libs/ui/radio-group/src/lib/RadioGroup.tsx +++ b/libs/ui/radio-group/src/lib/RadioGroup.tsx @@ -12,6 +12,7 @@ interface RadioGroupProps { className?: string; defaultValue?: string; value?: string; + disabled?: boolean; onChange?: AriaRadioGroupProps['onChange']; } @@ -22,6 +23,7 @@ export function RadioGroup({ children, required, className, + disabled, ...props }: PropsWithChildren) { return ( @@ -29,6 +31,7 @@ export function RadioGroup({ {...props} isRequired={required} className={styles({ className })} + isDisabled={disabled} > {children} diff --git a/libs/ui/tooltip/src/lib/InlineTooltip.tsx b/libs/ui/tooltip/src/lib/InlineTooltip.tsx index 7c3fad0..a79e6b7 100644 --- a/libs/ui/tooltip/src/lib/InlineTooltip.tsx +++ b/libs/ui/tooltip/src/lib/InlineTooltip.tsx @@ -5,11 +5,14 @@ import { Icon, IconName } from '@restate/ui/icons'; import { Button } from '@restate/ui/button'; import { TooltipTrigger as AriaTooltip } from 'react-aria-components'; import { useFocusable, useObjectRef } from 'react-aria'; +import { tv } from 'tailwind-variants'; interface InlineTooltipProps { title: ReactNode; description: ReactNode; learnMoreHref?: string; + className?: string; + variant?: 'inline-help' | 'indicator-button'; } export function InlineTooltip({ @@ -17,12 +20,18 @@ export function InlineTooltip({ title, description, learnMoreHref, + className, + variant = 'inline-help', }: PropsWithChildren) { const triggerRef = useRef(null); + const Trigger = + variant === 'inline-help' ? HelpTooltipTrigger : InfoTooltipTrigger; return ( - {children} + + {children} +
    {title}
    @@ -45,29 +54,62 @@ export function InlineTooltip({ ); } -const TooltipTrigger = forwardRef>( - ({ children }, ref) => { - const refObject = useObjectRef(ref); - const { focusableProps } = useFocusable({}, refObject); +const helpStyles = tv({ + base: 'cursor-help group underline-offset-4 decoration-from-font decoration-dashed underline inline-flex items-center', +}); - return ( - - - {children}{' '} - - - - +const HelpTooltipTrigger = forwardRef< + HTMLElement, + PropsWithChildren<{ className?: string }> +>(({ children, className }, ref) => { + const refObject = useObjectRef(ref); + const { focusableProps } = useFocusable({}, refObject); + + return ( + + + {children}{' '} - ); - } -); + + + + + ); +}); + +const infoStyles = tv({ + base: 'group inline-flex items-center gap-1', +}); + +const InfoTooltipTrigger = forwardRef< + HTMLElement, + PropsWithChildren<{ className?: string }> +>(({ children, className }, ref) => { + const refObject = useObjectRef(ref); + const { focusableProps } = useFocusable({}, refObject); + + return ( + + {children} + + + ); +}); diff --git a/libs/ui/tooltip/src/lib/TooltipContent.tsx b/libs/ui/tooltip/src/lib/TooltipContent.tsx index e22da44..3505fe2 100644 --- a/libs/ui/tooltip/src/lib/TooltipContent.tsx +++ b/libs/ui/tooltip/src/lib/TooltipContent.tsx @@ -2,16 +2,19 @@ import { ComponentProps, PropsWithChildren } from 'react'; import { Tooltip as AriaTooltip, composeRenderProps, + OverlayArrow, Tooltip, } from 'react-aria-components'; import { tv } from 'tailwind-variants'; interface TooltipContentProps { className?: string; + small?: boolean; + offset?: number; } const styles = tv({ - base: 'max-w-sm p-4 group bg-zinc-800/90 backdrop-blur-xl border border-zinc-900/80 shadow-[inset_0_1px_0_0_theme(colors.gray.500)] text-gray-300 text-sm rounded-xl drop-shadow-xl will-change-transform', + base: 'max-w-sm group border border-zinc-900/80 text-gray-300 drop-shadow-xl will-change-transform', variants: { isEntering: { true: 'animate-in fade-in placement-bottom:slide-in-from-top-0.5 placement-top:slide-in-from-bottom-0.5 placement-left:slide-in-from-right-0.5 placement-right:slide-in-from-left-0.5 ease-out duration-200', @@ -19,21 +22,45 @@ const styles = tv({ isExiting: { true: 'animate-out fade-out placement-bottom:slide-out-to-top-0.5 placement-top:slide-out-to-bottom-0.5 placement-left:slide-out-to-right-0.5 placement-right:slide-out-to-left-0.5 ease-in duration-150', }, + small: { + true: 'text-xs px-2 py-1 rounded-md shadow-[inset_0_0.5px_0_0_theme(colors.gray.500)] bg-zinc-800', + false: + 'text-sm p-4 rounded-xl shadow-[inset_0_1px_0_0_theme(colors.gray.500)] bg-zinc-800/90 backdrop-blur-xl', + }, + }, + defaultVariants: { + small: false, }, }); export function InternalTooltipContent({ children, + small, + offset = 10, ...props -}: PropsWithChildren>) { +}: PropsWithChildren< + ComponentProps & Pick +>) { return ( - styles({ ...renderProps, className }) + styles({ ...renderProps, className, small }) )} > + {small && ( + + + + + + )} {children} ); diff --git a/libs/ui/tooltip/src/lib/TooltipTrigger.tsx b/libs/ui/tooltip/src/lib/TooltipTrigger.tsx index 864fd7a..fb7090c 100644 --- a/libs/ui/tooltip/src/lib/TooltipTrigger.tsx +++ b/libs/ui/tooltip/src/lib/TooltipTrigger.tsx @@ -1,5 +1,5 @@ import { ComponentProps, useContext, type PropsWithChildren } from 'react'; -import { Pressable, PressResponder } from '@react-aria/interactions'; +import { Pressable, PressResponder, useHover } from '@react-aria/interactions'; import { TooltipTriggerStateContext } from 'react-aria-components'; type TooltipTriggerProps = Pick, 'children'>; @@ -7,12 +7,17 @@ export function TooltipTrigger({ children, }: PropsWithChildren) { const state = useContext(TooltipTriggerStateContext); - + const { hoverProps } = useHover({ + onHoverChange(isHovering) { + isHovering ? state.open(true) : state.close(); + }, + }); return ( { state.open(); }} + {...hoverProps} > {children}