diff --git a/frontend/src/app/dialogs/connect-manual-serverfull-frame.tsx b/frontend/src/app/dialogs/connect-manual-serverfull-frame.tsx index ef1dbb76d5..aed970d1ff 100644 --- a/frontend/src/app/dialogs/connect-manual-serverfull-frame.tsx +++ b/frontend/src/app/dialogs/connect-manual-serverfull-frame.tsx @@ -10,6 +10,7 @@ import { useMemo, useRef } from "react"; import { useWatch } from "react-hook-form"; import z from "zod"; import * as ConnectServerfullForm from "@/app/forms/connect-manual-serverfull-form"; +import * as ConnectServerlessForm from "@/app/forms/connect-manual-serverless-form"; import type { DialogContentProps } from "@/components"; import { ActorRegion, @@ -25,6 +26,8 @@ import { type StepConfirm, StepperForm } from "../forms/stepper-form"; type FormValues = { runnerName: string; datacenter: string; + customName?: string; + customIcon?: string; }; function useStepperConfig() { @@ -81,6 +84,12 @@ function useStepperConfig() { schema: z.object({ runnerName: z.string().min(1, "Runner name is required"), datacenter: z.string().min(1, "Please select a region"), + customName: z + .string() + .trim() + .max(32, "Name is too long") + .optional(), + customIcon: z.string().optional(), }), }, { @@ -172,13 +181,25 @@ function FormStepper({ const existing: Record = runnerConfig?.datacenters || {}; + const isCustom = + provider === "custom" || provider === "custom-platform"; + const customName = isCustom + ? values.customName?.trim() || undefined + : undefined; + const customIcon = isCustom + ? values.customIcon || undefined + : undefined; await mutateAsync({ name: values.runnerName, config: { ...existing, [values.datacenter]: { normal: {}, - metadata: { provider }, + metadata: { + provider, + ...(customName ? { customName } : {}), + ...(customIcon ? { customIcon } : {}), + }, }, }, }); @@ -188,14 +209,15 @@ function FormStepper({ datacenter: defaultDatacenter, }} content={{ - "step-1": () => , + "step-1": () => , "step-2": () => , }} /> ); } -function Step1() { +function Step1({ provider }: { provider: Provider }) { + const isCustom = provider === "custom" || provider === "custom-platform"; return ( <>
@@ -203,6 +225,7 @@ function Step1() { provider of choice.
+ {isCustom ? : null} ); diff --git a/frontend/src/app/dialogs/connect-manual-serverless-frame.tsx b/frontend/src/app/dialogs/connect-manual-serverless-frame.tsx index 04c828c1e9..7c3cf3d5b0 100644 --- a/frontend/src/app/dialogs/connect-manual-serverless-frame.tsx +++ b/frontend/src/app/dialogs/connect-manual-serverless-frame.tsx @@ -121,7 +121,7 @@ function FormStepper({ ), }} content={{ - "step-1": () => , + "step-1": () => , "step-2": () => , "step-3": () => , }} @@ -189,10 +189,20 @@ export const buildServerlessConfig = async ( runnersMargin: values.runnerMargin ?? 0, minRunners: values.minRunners ?? 0, }; + const resolvedProvider = provider || "custom"; + const isCustom = + resolvedProvider === "custom" || + resolvedProvider === "custom-platform"; + const customName = isCustom + ? values.customName?.trim() || undefined + : undefined; + const customIcon = isCustom ? values.customIcon || undefined : undefined; const config = { serverless, metadata: { - provider: provider || "custom", + provider: resolvedProvider, + ...(customName ? { customName } : {}), + ...(customIcon ? { customIcon } : {}), }, }; return [dc, config]; @@ -203,9 +213,11 @@ export const buildServerlessConfig = async ( return payload; }; -function Step1() { +function Step1({ provider }: { provider: Provider }) { + const isCustom = provider === "custom" || provider === "custom-platform"; return (
+ {isCustom ? : null}
); diff --git a/frontend/src/app/forms/connect-manual-serverless-form.tsx b/frontend/src/app/forms/connect-manual-serverless-form.tsx index d1d46dcb80..86d5ccb32e 100644 --- a/frontend/src/app/forms/connect-manual-serverless-form.tsx +++ b/frontend/src/app/forms/connect-manual-serverless-form.tsx @@ -22,6 +22,7 @@ import { } from "@/components"; import { ActorRegion, useEngineCompatDataProvider } from "@/components/actors"; import { RegionSelect } from "@/components/actors/region-select"; +import { IconPicker, IconRenderer } from "@/components/ui/icon-picker"; import { defineStepper } from "@/components/ui/stepper"; export { endpointSchema }; @@ -37,6 +38,8 @@ export const configurationSchema = z.object({ headers: z.array(z.tuple([z.string(), z.string()])).default([]), requestLifespan: z.coerce.number().min(0, "Must be 0 or greater"), drainGracePeriod: z.coerce.number().min(0).optional().default(0), + customName: z.string().trim().max(32, "Name is too long").optional(), + customIcon: z.string().optional(), // Deprecated fields — only used when submitting to an old runner config (no protocolVersion). slotsPerRunner: z.coerce.number().min(1).optional(), maxRunners: z.coerce.number().min(1).optional(), @@ -92,6 +95,68 @@ export const RunnerName = function RunnerName() { ); }; +export const CustomBranding = function CustomBranding() { + const { control, watch } = useFormContext(); + const icon = watch("customIcon"); + const name = watch("customName"); + return ( +
+ +

Display

+
+ + Pick an icon and label for this provider. Shown in the provider + list. + +
+ ( + + + field.onChange(v ?? "")} + /> + + + )} + /> + ( + + + + + + + )} + /> +
+ {name || icon ? ( +
+ Preview: + + {icon ? ( + + ) : null} + {name || "Custom"} + +
+ ) : null} +
+ ); +}; + export const Datacenters = function Datacenter() { const { control, watch } = useFormContext(); const { data: datacenterCount } = useInfiniteQuery({ diff --git a/frontend/src/app/runner-config-table.tsx b/frontend/src/app/runner-config-table.tsx index 00bc98e4b0..c9fe2223d5 100644 --- a/frontend/src/app/runner-config-table.tsx +++ b/frontend/src/app/runner-config-table.tsx @@ -7,6 +7,7 @@ import { faPencil, faRailway, faRivet, + faServer, faTrash, faVercel, Icon, @@ -32,7 +33,12 @@ import { WithTooltip, } from "@/components"; import { Badge } from "@/components/ui/badge"; -import { deriveProviderFromMetadata } from "@/lib/data"; +import { IconRenderer } from "@/components/ui/icon-picker"; +import { + deriveCustomIconFromMetadata, + deriveCustomNameFromMetadata, + deriveProviderFromMetadata, +} from "@/lib/data"; import type { RivetActorError } from "@/queries/types"; import { RunnerPoolErrorPopover } from "./runner-pool-error-popover"; @@ -310,12 +316,23 @@ function ProviderSummary({ renderRegion: (regionId: string, opts: { abbreviated?: boolean }) => ReactNode; }) { const breakdown = useMemo(() => { - const rows = datacenters.map(([dc, config]) => ({ - dc, - provider: deriveProviderFromMetadata(config.metadata) || "unknown", - kind: getDatacenterKind(config), - })); - const providers = new Set(rows.map((r) => r.provider)); + const rows = datacenters.map(([dc, config]) => { + const provider = + deriveProviderFromMetadata(config.metadata) || "unknown"; + const customName = deriveCustomNameFromMetadata(config.metadata); + const customIcon = deriveCustomIconFromMetadata(config.metadata); + return { + dc, + provider, + customName, + customIcon, + kind: getDatacenterKind(config), + groupKey: isCustomProvider(provider) + ? `${provider}:${customName ?? ""}:${customIcon ?? ""}` + : provider, + }; + }); + const providers = new Set(rows.map((r) => r.groupKey)); const kinds = new Set(rows.map((r) => r.kind)); return { rows, providers, kinds }; }, [datacenters]); @@ -356,13 +373,17 @@ function ProviderSummary({ - {breakdown.rows.map(({ dc, provider, kind }) => ( + {breakdown.rows.map(({ dc, provider, customName, customIcon, kind }) => (
  • {renderRegion(dc, { abbreviated: false })} {showProviderInTooltip ? ( <> · - + ) : null} {showKindInTooltip ? ( @@ -468,7 +489,33 @@ const PROVIDER_ICONS: Record = { rivet: faRivet, }; -function ProviderInline({ provider }: { provider: string | undefined }) { +function isCustomProvider(provider: string | undefined): boolean { + return provider === "custom" || provider === "custom-platform"; +} + +function ProviderInline({ + provider, + customName, + customIcon, +}: { + provider: string | undefined; + customName?: string; + customIcon?: string; +}) { + if (isCustomProvider(provider)) { + const label = customName || getProviderLabel(provider); + return ( + + {customIcon ? ( + + ) : ( + + )} + {label} + + ); + } + const icon = provider ? PROVIDER_ICONS[provider] : undefined; const label = getProviderLabel(provider); @@ -485,7 +532,13 @@ function ProviderInline({ provider }: { provider: string | undefined }) { } function Provider({ metadata }: { metadata: unknown }) { - return ; + return ( + + ); } function Regions({ diff --git a/frontend/src/components/ui/icon-picker.stories.tsx b/frontend/src/components/ui/icon-picker.stories.tsx new file mode 100644 index 0000000000..96a72da686 --- /dev/null +++ b/frontend/src/components/ui/icon-picker.stories.tsx @@ -0,0 +1,88 @@ +import type { Story } from "@ladle/react"; +import { useState } from "react"; +import "../../../.ladle/ladle.css"; +import { TooltipProvider } from "@/components"; +import { IconPicker, IconRenderer } from "./icon-picker"; + +function Frame({ children }: { children: React.ReactNode }) { + return ( + +
    +
    + {children} +
    +
    +
    + ); +} + +export const Empty: Story = () => { + const [icon, setIcon] = useState(null); + return ( + +
    + + + Selected: {icon ?? "none"} + +
    + + ); +}; + +export const Preselected: Story = () => { + const [icon, setIcon] = useState("rocket"); + return ( + +
    + + + Selected: {icon ?? "none"} + +
    + + ); +}; + +export const InsideProviderRow: Story = () => { + const [icon, setIcon] = useState("server"); + const [name, setName] = useState("my custom cluster"); + return ( + +
    +
    Custom provider
    +
    + + setName(e.target.value)} + className="h-9 flex-1 rounded-md border bg-background px-3 text-sm" + maxLength={32} + /> +
    +
    + Preview: + + + {name || "Custom"} + +
    +
    + + ); +}; + +export const UnknownIconName: Story = () => { + const [icon, setIcon] = useState("not-a-real-icon"); + return ( + +
    + + + Value "{icon}" doesn't resolve — trigger falls back to the fa + question mark. + +
    + + ); +}; diff --git a/frontend/src/components/ui/icon-picker.tsx b/frontend/src/components/ui/icon-picker.tsx new file mode 100644 index 0000000000..7e68d0a676 --- /dev/null +++ b/frontend/src/components/ui/icon-picker.tsx @@ -0,0 +1,328 @@ +"use client"; + +import { faMagnifyingGlass, faQuestion, Icon, type IconProp } from "@rivet-gg/icons"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { + type ComponentPropsWithoutRef, + forwardRef, + type LazyExoticComponent, + lazy, + type ReactNode, + Suspense, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { cn } from "../lib/utils"; +import { Button } from "./button"; +import { Input } from "./input"; +import { Popover, PopoverContent, PopoverTrigger } from "./popover"; + +const iconModules = import.meta.glob>( + "../../../packages/icons/dist/icons/*.js", +); + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +function toExportName(iconName: string): string { + return `fa${iconName.split("-").map(capitalize).join("")}`; +} + +const lazyIconCache = new Map< + string, + LazyExoticComponent<(props: { className?: string }) => ReactNode> +>(); + +function getLazyIcon(iconName: string) { + const exportName = toExportName(iconName); + const cached = lazyIconCache.get(exportName); + if (cached) return cached; + + const loader = iconModules[`../../../packages/icons/dist/icons/${exportName}.js`]; + const component = lazy(() => + (loader ? loader() : Promise.reject()) + .then((mod) => ({ + default: ({ className }: { className?: string }) => ( + + ), + })) + .catch(() => ({ + default: ({ className }: { className?: string }) => ( + + ), + })), + ); + lazyIconCache.set(exportName, component); + return component; +} + +export function IconRenderer({ + name, + className, + fallback, +}: { + name: string | null | undefined; + className?: string; + fallback?: ReactNode; +}) { + if (!name) return <>{fallback ?? null}; + const LazyIcon = getLazyIcon(name); + return ( + }> + + + ); +} + +interface IconPickerProps { + value?: string | null; + onChange: (iconName: string | null) => void; + trigger?: ReactNode; + columns?: number; + cellSize?: number; + className?: string; +} + +export function IconPicker({ + value, + onChange, + trigger, + columns = 8, + cellSize = 36, + className, +}: IconPickerProps) { + const [open, setOpen] = useState(false); + + return ( + + + {trigger ?? } + + + {open ? ( + { + onChange(name); + setOpen(false); + }} + columns={columns} + cellSize={cellSize} + /> + ) : null} + + + ); +} + +const DefaultTrigger = forwardRef< + HTMLButtonElement, + ComponentPropsWithoutRef<"button"> & { iconName: string | null } +>(({ iconName, ...rest }, ref) => ( + +)); +DefaultTrigger.displayName = "IconPickerDefaultTrigger"; + +interface IconEntry { + key: string; + iconName: string; + def: IconProp; + searchTerms: string[]; +} + +let cachedEntriesPromise: Promise | null = null; + +function loadAllIcons(): Promise { + if (cachedEntriesPromise) return cachedEntriesPromise; + cachedEntriesPromise = import("@rivet-gg/icons").then((mod) => { + const seen = new Set(); + const out: IconEntry[] = []; + for (const value of Object.values(mod as Record)) { + if (!isIconDef(value)) continue; + const key = `${value.prefix}:${value.iconName}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ + key, + iconName: value.iconName, + def: value as unknown as IconProp, + searchTerms: value.iconName.split("-"), + }); + } + out.sort((a, b) => a.iconName.localeCompare(b.iconName)); + return out; + }); + return cachedEntriesPromise; +} + +function isIconDef(value: unknown): value is { + prefix: string; + iconName: string; + icon: unknown[]; +} { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return ( + typeof v.prefix === "string" && + typeof v.iconName === "string" && + Array.isArray(v.icon) + ); +} + +interface IconPickerBodyProps { + value: string | null; + onChange: (iconName: string | null) => void; + columns: number; + cellSize: number; +} + +function IconPickerBody({ value, onChange, columns, cellSize }: IconPickerBodyProps) { + const [query, setQuery] = useState(""); + const [entries, setEntries] = useState(null); + const inputRef = useRef(null); + const scrollRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + let cancelled = false; + loadAllIcons().then((all) => { + if (!cancelled) setEntries(all); + }); + return () => { + cancelled = true; + }; + }, []); + + const filtered = useMemo(() => { + if (!entries) return []; + const q = query.trim().toLowerCase(); + if (!q) return entries; + return entries.filter( + (e) => + e.iconName.includes(q) || + e.searchTerms.some((t) => t.includes(q)), + ); + }, [entries, query]); + + const rowCount = Math.ceil(filtered.length / columns); + const rowVirtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => scrollRef.current, + estimateSize: () => cellSize, + overscan: 4, + }); + + return ( +
    +
    + setQuery(e.target.value)} + placeholder="Search icons" + className="h-8" + /> + {value ? ( + + ) : null} +
    +
    + {entries === null + ? "Loading icons…" + : `${filtered.length} icon${filtered.length === 1 ? "" : "s"}`} +
    +
    + {entries === null ? null : filtered.length === 0 ? ( +
    + No icons match "{query}" +
    + ) : ( +
    + {rowVirtualizer.getVirtualItems().map((row) => { + const start = row.index * columns; + const rowItems = filtered.slice(start, start + columns); + return ( +
    + {rowItems.map((entry) => ( + onChange(entry.iconName)} + /> + ))} +
    + ); + })} +
    + )} +
    +
    + ); +} + +interface IconCellProps extends ComponentPropsWithoutRef<"button"> { + entry: IconEntry; + selected: boolean; + onSelect: () => void; +} + +function IconCell({ entry, selected, onSelect, className, ...rest }: IconCellProps) { + return ( + + ); +} diff --git a/frontend/src/lib/data.ts b/frontend/src/lib/data.ts index 8b64a0a606..9df8b00730 100644 --- a/frontend/src/lib/data.ts +++ b/frontend/src/lib/data.ts @@ -1,13 +1,32 @@ import z from "zod"; +const providerMetadataSchema = z + .object({ + provider: z.string().optional(), + customName: z.string().optional(), + customIcon: z.string().optional(), + }) + .partial() + .optional(); + export function deriveProviderFromMetadata( metadata: unknown, ): string | undefined { - return z - .object({ provider: z.string().optional() }) - .partial() - .optional() - .parse(metadata)?.provider; + return providerMetadataSchema.safeParse(metadata).data?.provider; +} + +export function deriveCustomNameFromMetadata( + metadata: unknown, +): string | undefined { + const v = providerMetadataSchema.safeParse(metadata).data?.customName; + return v?.trim() ? v.trim() : undefined; +} + +export function deriveCustomIconFromMetadata( + metadata: unknown, +): string | undefined { + return providerMetadataSchema.safeParse(metadata).data?.customIcon || + undefined; } const rivetkitSchema = z