import { useEffect, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { CONSUMING_ALLOCATION_STATUSES, GUARANTEE_TYPES, RESERVING_ALLOCATION_STATUSES, } from '@pig/core'; import { AlertTriangle, Clock3, LoaderCircle, RotateCcw, ShieldCheck } from 'lucide-react'; import { useForm, type Control, type FieldPath, type FieldValues } from 'react-hook-form'; import { z } from 'zod'; import { Badge, Button, Input } from '@/components/ui'; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form'; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from '@/components/ui/sheet'; import { Textarea } from '@/components/ui/textarea'; import { ApiError, compactNumber, get, money, percent, post, shortDate } from '@/lib/api'; export interface AvailabilityRow { commitmentId: string; name: string; gpuType: string; gpuCount: number; interconnectType: string; securityTier: string; startsAt: string; endsAt: string; totalGpuHours: number; soldGpuHours: number; heldGpuHours: number; availableGpuHours: number; costPerGpuHourCents: number; utilisation: number; breakEvenPriceCents: number | null; } export type MatchRow = AvailabilityRow & { score: number; rationale: string[] }; interface CommitmentRecord { id: string; shape: { intervals: string[]; quantities: number[] } | null; isContiguous: boolean; oversubscriptionPct: string | number; } interface CommitmentRow { commitment: CommitmentRecord; accountName: string | null; } interface DealRecord { id: string; name: string; stage: string; } interface DemandBoard { deals: { deal: DealRecord; accountName: string | null }[]; } interface AllocationRecord { id: string; capacityCommitmentId: string; demandDealId: string | null; gpuHours: string | number; pricePerGpuHourCents: number; startsAt: string; endsAt: string; status: string; holdExpiresAt: string | null; guaranteeType: string; } const activeReleaseStatuses = RESERVING_ALLOCATION_STATUSES.filter( (status) => status !== 'completed', ); const createStatuses = CONSUMING_ALLOCATION_STATUSES.filter((status) => status !== 'completed'); const numeric = z.string().refine( (value) => Number.isFinite(Number(value)) && Number(value) > 0, 'Enter a number greater than zero.', ); const moneyValue = z.string().refine( (value) => value === '' || (Number.isFinite(Number(value)) && Number(value) >= 0), 'Enter zero or a positive amount.', ); const formSchema = z .object({ kind: z.enum(['allocation', 'hold']), capacityCommitmentId: z.string().uuid('Select a commitment.'), demandDealId: z.string().uuid('Select a demand deal.'), gpuHours: numeric, price: moneyValue, startsAt: z.string().min(1, 'Start is required.'), endsAt: z.string().min(1, 'End is required.'), holdExpiresAt: z.string(), status: z.enum(CONSUMING_ALLOCATION_STATUSES), guaranteeType: z.enum(GUARANTEE_TYPES), notes: z.string().max(10_000, 'Keep notes under 10,000 characters.'), }) .superRefine((values, context) => { const startsAt = new Date(values.startsAt); const endsAt = new Date(values.endsAt); if (endsAt <= startsAt) { context.addIssue({ code: 'custom', path: ['endsAt'], message: 'End must be after start.' }); } if (values.kind === 'allocation' && values.price === '') { context.addIssue({ code: 'custom', path: ['price'], message: 'Sell price is required.' }); } if (values.kind === 'hold') { const expiresAt = new Date(values.holdExpiresAt); if (!values.holdExpiresAt || expiresAt <= new Date()) { context.addIssue({ code: 'custom', path: ['holdExpiresAt'], message: 'A hold must expire in the future.', }); } } }); type AllocationForm = z.infer; function localDateTime(value: string | Date): string { const date = new Date(value); const offset = date.getTimezoneOffset() * 60_000; return new Date(date.getTime() - offset).toISOString().slice(0, 16); } function localCommitmentBound(value: string | Date, bound: 'start' | 'end'): string { const timestamp = new Date(value).getTime(); const minute = 60_000; const rounded = bound === 'start' ? Math.ceil(timestamp / minute) * minute : Math.floor(timestamp / minute) * minute; return localDateTime(new Date(rounded)); } function defaults( preferredCommitmentId?: string, defaultGpuHours?: number, ): AllocationForm { return { kind: 'allocation', capacityCommitmentId: preferredCommitmentId ?? '', demandDealId: '', gpuHours: defaultGpuHours == null ? '' : String(defaultGpuHours), price: '', startsAt: '', endsAt: '', holdExpiresAt: localDateTime(new Date(Date.now() + 24 * 60 * 60 * 1_000)), status: 'committed', guaranteeType: 'committed', notes: '', }; } export function AllocationSheet({ open, onOpenChange, preferredCommitmentId, matches, defaultGpuHours, onChanged, }: { open: boolean; onOpenChange(open: boolean): void; preferredCommitmentId?: string; matches?: MatchRow[]; defaultGpuHours?: number; onChanged?(): void; }) { const queryClient = useQueryClient(); const [releaseReason, setReleaseReason] = useState(''); const [releaseError, setReleaseError] = useState(null); const form = useForm({ resolver: zodResolver(formSchema), defaultValues: defaults(preferredCommitmentId, defaultGpuHours), }); const { data: availability, isLoading: availabilityLoading } = useQuery({ queryKey: ['availability'], queryFn: () => get('/api/capacity/availability'), enabled: open, }); const { data: commitments } = useQuery({ queryKey: ['commitments', 'allocation-context'], queryFn: () => get('/api/commitments'), enabled: open, }); const { data: demand } = useQuery({ queryKey: ['/api/deals/demand'], queryFn: () => get('/api/deals/demand'), enabled: open, }); const { data: allocations } = useQuery({ queryKey: ['allocations'], queryFn: () => get('/api/allocations'), enabled: open, }); useEffect(() => { if (!open) return; form.reset(defaults(preferredCommitmentId, defaultGpuHours)); setReleaseReason(''); setReleaseError(null); }, [defaultGpuHours, form, open, preferredCommitmentId]); const contextIds = useMemo( () => (matches ? new Set(matches.map((match) => match.commitmentId)) : null), [matches], ); const options = useMemo( () => (availability ?? []).filter((row) => !contextIds || contextIds.has(row.commitmentId)), [availability, contextIds], ); const selectedId = form.watch('capacityCommitmentId'); const selected = options.find((row) => row.commitmentId === selectedId); const detail = commitments?.find((row) => row.commitment.id === selectedId); const match = matches?.find((row) => row.commitmentId === selectedId); const kind = form.watch('kind'); const quotedPriceValue = form.watch('price'); const quotedPrice = quotedPriceValue === '' ? null : Number(quotedPriceValue); const dealsById = useMemo( () => new Map((demand?.deals ?? []).map((row) => [row.deal.id, row])), [demand], ); const reserving = useMemo(() => { const now = Date.now(); return (allocations ?? []).filter( (allocation) => allocation.capacityCommitmentId === selectedId && activeReleaseStatuses.some((status) => status === allocation.status) && !( allocation.status === 'planned' && allocation.holdExpiresAt && new Date(allocation.holdExpiresAt).getTime() <= now ), ); }, [allocations, selectedId]); useEffect(() => { if (!open || !selected || form.getValues('startsAt')) return; form.setValue('startsAt', localCommitmentBound(selected.startsAt, 'start')); form.setValue('endsAt', localCommitmentBound(selected.endsAt, 'end')); }, [form, open, selected]); const refresh = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ['availability'] }), queryClient.invalidateQueries({ queryKey: ['allocations'] }), queryClient.invalidateQueries({ queryKey: ['margin'] }), queryClient.invalidateQueries({ queryKey: ['dashboard'] }), queryClient.invalidateQueries({ queryKey: ['/api/deals/demand'] }), ]); onChanged?.(); }; const save = useMutation({ mutationFn: (values: AllocationForm) => { const price = values.price === '' ? undefined : Number(values.price); const body = { capacityCommitmentId: values.capacityCommitmentId, demandDealId: values.demandDealId, gpuHours: Number(values.gpuHours), pricePerGpuHourCents: price === undefined ? undefined : Math.round(price * 100), startsAt: new Date(values.startsAt).toISOString(), endsAt: new Date(values.endsAt).toISOString(), guaranteeType: values.guaranteeType, notes: values.notes.trim() || null, }; return values.kind === 'hold' ? post('/api/allocations/holds', { ...body, holdExpiresAt: new Date(values.holdExpiresAt).toISOString(), }) : post('/api/allocations', { ...body, status: values.status }); }, onSuccess: async () => { await refresh(); onOpenChange(false); }, }); const release = useMutation({ mutationFn: (id: string) => post(`/api/allocations/${id}/release`, { reason: releaseReason.trim() || undefined, }), onMutate: () => setReleaseError(null), onSuccess: refresh, onError: (error) => setReleaseError(errorMessage(error)), }); const chooseCommitment = (id: string) => { form.setValue('capacityCommitmentId', id, { shouldValidate: true }); const row = options.find((option) => option.commitmentId === id); if (row) { form.setValue('startsAt', localCommitmentBound(row.startsAt, 'start'), { shouldValidate: true }); form.setValue('endsAt', localCommitmentBound(row.endsAt, 'end'), { shouldValidate: true }); } }; return ( Reserve capacity Join committed supply to a demand deal. Availability is re-checked by the server when you save.
save.mutate(values))} >
{(['allocation', 'hold'] as const).map((value) => ( ))}
( Capacity commitment {matches ? Limited to the capacity returned by this match. : null} )} /> ( Demand deal )} />
{selected ? ( ) : options.length === 0 && !availabilityLoading ? (
No currently available commitment remains in this context. Run the matcher again before promising capacity.
) : null}

Commercial reservation

GPU-hours and the window are submitted to the ledger as entered. The server checks the term, shaped capacity, holds, and concurrent writes.

{kind === 'hold' ? ( ) : ( )} ( Reservation notes