diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 6ae897f96..39a92ca75 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -30,6 +30,7 @@ COPY apps/dashboard/ dashboard/ COPY apps/libs/runner-api-client/ libs/runner-api-client/ COPY apps/libs/api-client/ libs/api-client/ COPY apps/libs/analytics-api-client/ libs/analytics-api-client/ +COPY apps/libs/box-name/ libs/box-name/ RUN yarn nx build api --configuration=production --nxBail=true --output-style=stream && node --check dist/apps/api/main.js diff --git a/apps/api/src/box/utils/box-name-generator.ts b/apps/api/src/box/utils/box-name-generator.ts index a66c8624c..b17220f1b 100644 --- a/apps/api/src/box/utils/box-name-generator.ts +++ b/apps/api/src/box/utils/box-name-generator.ts @@ -1,48 +1,14 @@ -// Default box-name generator. Shape: "{adjective}-{animal}", e.g. "cozy-otter". -// -// Names are kept clean in the common case. On the rare collision with the -// per-org @Unique(['organizationId', 'name']) constraint, the caller falls back -// to "{adjective}-{animal}-{boxId}" (see persistWithGeneratedBoxName) — the box +// Default box-name generation. The word lists and the "{adjective}-{animal}" +// generator live in the shared @boxlite-ai/box-name lib (also used by the +// dashboard's create dialog); this module adds the persistence-side collision +// handling. On the rare collision with the per-org +// @Unique(['organizationId', 'name']) constraint, the caller falls back to +// "{adjective}-{animal}-{boxId}" (see persistWithGeneratedBoxName) — the box // id is unique by construction, so that single fallback can never collide. -const ADJECTIVES = [ - 'agile', 'amber', 'astute', 'bold', 'bouncy', 'brave', 'breezy', 'brilliant', 'brisk', 'calm', - 'cheery', 'clever', 'cosmic', 'cozy', 'crimson', 'crisp', 'dapper', 'daring', 'darting', 'deft', - 'dreamy', 'dusky', 'eager', 'fierce', 'fluffy', 'fresh', 'gallant', 'gentle', 'gliding', 'glossy', - 'golden', 'hearty', 'hovering', 'hushed', 'indigo', 'intrepid', 'ivory', 'jade', 'jaunty', 'jolly', - 'jovial', 'keen', 'lively', 'lofty', 'lucid', 'lunar', 'mellow', 'mighty', 'mild', 'mystic', - 'neat', 'nebular', 'nimble', 'opal', 'perky', 'placid', 'plucky', 'polished', 'prismatic', 'quick', - 'radiant', 'savvy', 'serene', 'sharp', 'silver', 'sleek', 'snappy', 'snug', 'soaring', 'solar', - 'sparkly', 'starry', 'swift', 'tidy', 'tranquil', 'valiant', 'vibrant', 'vivid', 'witty', 'zippy', -] as const +import { generateBoxName } from '@boxlite-ai/box-name' -const ANIMALS = [ - 'alpaca', 'badger', 'bat', 'bear', 'beaver', 'bee', 'beluga', 'bluebird', 'bluejay', 'bobcat', - 'bumblebee', 'bunny', 'butterfly', 'calf', 'canary', 'capybara', 'cardinal', 'cat', 'chameleon', 'chick', - 'chickadee', 'chinchilla', 'chipmunk', 'cockatiel', 'corgi', 'cottontail', 'crab', 'cub', 'deer', 'dolphin', - 'dove', 'dragonfly', 'duck', 'duckling', 'eagle', 'falcon', 'fawn', 'ferret', 'finch', 'firefly', - 'flamingo', 'fox', 'frog', 'gecko', 'goldfinch', 'goose', 'gosling', 'guppy', 'hamster', 'hare', - 'hawk', 'hedgehog', 'heron', 'hummingbird', 'joey', 'kangaroo', 'kit', 'kitten', 'koala', 'ladybug', - 'lamb', 'lemur', 'llama', 'lovebird', 'lynx', 'magpie', 'manatee', 'marmot', 'meerkat', 'mole', - 'mouse', 'narwhal', 'nightingale', 'ocelot', 'octopus', 'otter', 'owl', 'owlet', 'panda', 'parakeet', - 'parrot', 'peacock', 'pelican', 'penguin', 'piglet', 'platypus', 'pony', 'porpoise', 'possum', 'puffin', - 'pup', 'puppy', 'quokka', 'rabbit', 'raccoon', 'raven', 'robin', 'salamander', 'seahorse', 'seal', - 'sheep', 'sloth', 'snail', 'sparrow', 'squirrel', 'starfish', 'stingray', 'swallow', 'swan', 'tadpole', - 'tortoise', 'toucan', 'turtle', 'wallaby', 'walrus', 'whale', 'wolf', 'wombat', 'woodpecker', 'wren', -] as const - -function pick(arr: readonly T[]): T { - return arr[Math.floor(Math.random() * arr.length)] -} - -/** - * Generate a default box name like `cozy-otter`. Hyphen- (not underscore-) - * separated and all-lowercase, so the clean name is a valid DNS label in case - * box names ever become hostnames. - */ -export function generateBoxName(): string { - return `${pick(ADJECTIVES)}-${pick(ANIMALS)}` -} +export { generateBoxName } // Postgres unique_violation — raised when a generated name hits the per-org // @Unique(['organizationId', 'name']) constraint. diff --git a/apps/dashboard/src/components/Box/CreateBoxDialog.tsx b/apps/dashboard/src/components/Box/CreateBoxDialog.tsx index c0953b140..5c07626fc 100644 --- a/apps/dashboard/src/components/Box/CreateBoxDialog.tsx +++ b/apps/dashboard/src/components/Box/CreateBoxDialog.tsx @@ -12,8 +12,9 @@ import { getBoxRouteId } from '@/lib/box-identity' import { handleApiError } from '@/lib/error-handling' import { cn } from '@/lib/utils' import type { Box } from '@boxlite-ai/api-client' -import { ChevronDown, Plus } from '@/components/ui/icon' +import { ChevronDown, Plus, RefreshCw } from '@/components/ui/icon' import { useEffect, useRef, useState } from 'react' +import { useGeneratedBoxName } from './useGeneratedBoxName' import { generatePath, useNavigate } from 'react-router-dom' import { toast } from 'sonner' @@ -234,10 +235,18 @@ export const CreateBoxDialog = ({ if (limit != null && v < limit) setCapped((c) => (c[key] ? { ...c, [key]: false } : c)) } + const { display: generatedName, isGenerating, generate, reset: resetName } = useGeneratedBoxName(setName) + + // Hand the user a fresh generated name each time the dialog opens (it lands + // in the editable field via the reveal animation); clear on close. useEffect(() => { const wasOpen = wasOpenRef.current wasOpenRef.current = open - if (!open || wasOpen) return + if (!open) { + resetName() + return + } + if (wasOpen) return setName('') setImageRef(defaultImage.ref) @@ -247,7 +256,8 @@ export const CreateBoxDialog = ({ setAdvancedOpen(false) setSubmitting(false) setCapped({ cpu: false, memory: false, disk: false }) - }, [open, defaultImage.ref, initialCpu, initialMemory, initialDisk]) + generate() + }, [open, defaultImage.ref, initialCpu, initialMemory, initialDisk, generate, resetName]) useEffect(() => { if (!open) return @@ -295,7 +305,14 @@ export const CreateBoxDialog = ({ navigate(generatePath(RoutePath.BOX_DETAILS, { boxId })) } } catch (error) { - handleApiError(error, 'Failed to create box') + // Name already taken in this org (client-side generation can collide) — + // deal a fresh name into the field so the next click succeeds. + if ((error as { response?: { status?: number } })?.response?.status === 409) { + toast.error('That name is already taken — here is a fresh one.') + generate() + } else { + handleApiError(error, 'Failed to create box') + } } finally { setSubmitting(false) } @@ -331,13 +348,34 @@ export const CreateBoxDialog = ({ {/* name */}
Name
- setName(e.target.value)} - placeholder="my-new-box" - aria-invalid={!nameValid} - className="w-full border border-border bg-card px-[13px] py-[11px] font-mono text-[13px] text-foreground outline-none focus:border-brand aria-[invalid=true]:border-destructive" - /> +
+ setName(e.target.value)} + readOnly={isGenerating} + placeholder="my-new-box" + aria-invalid={!nameValid} + className={cn( + 'min-w-0 flex-1 bg-transparent px-[13px] py-[11px] font-mono text-[13px] text-foreground outline-none', + isGenerating && 'text-brand', + )} + /> + +
{/* image */} @@ -440,7 +478,7 @@ export const CreateBoxDialog = ({