Files
pig/apps/web/src/components/AllocationSheet.tsx
2026-08-13 01:39:01 -07:00

568 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof formSchema>;
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<string | null>(null);
const form = useForm<AllocationForm>({
resolver: zodResolver(formSchema),
defaultValues: defaults(preferredCommitmentId, defaultGpuHours),
});
const { data: availability, isLoading: availabilityLoading } = useQuery({
queryKey: ['availability'],
queryFn: () => get<AvailabilityRow[]>('/api/capacity/availability'),
enabled: open,
});
const { data: commitments } = useQuery({
queryKey: ['commitments', 'allocation-context'],
queryFn: () => get<CommitmentRow[]>('/api/commitments'),
enabled: open,
});
const { data: demand } = useQuery({
queryKey: ['/api/deals/demand'],
queryFn: () => get<DemandBoard>('/api/deals/demand'),
enabled: open,
});
const { data: allocations } = useQuery({
queryKey: ['allocations'],
queryFn: () => get<AllocationRecord[]>('/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<AllocationRecord>('/api/allocations/holds', {
...body,
holdExpiresAt: new Date(values.holdExpiresAt).toISOString(),
})
: post<AllocationRecord>('/api/allocations', { ...body, status: values.status });
},
onSuccess: async () => {
await refresh();
onOpenChange(false);
},
});
const release = useMutation({
mutationFn: (id: string) =>
post<AllocationRecord>(`/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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex h-full w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 gap-1 px-5 pb-4 pt-5 text-left sm:px-6">
<SheetTitle>Reserve capacity</SheetTitle>
<SheetDescription>
Join committed supply to a demand deal. Availability is re-checked by the server when you save.
</SheetDescription>
</SheetHeader>
<Separator />
<Form {...form}>
<form
className="flex min-h-0 flex-1 flex-col"
onSubmit={form.handleSubmit((values) => save.mutate(values))}
>
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-5 py-5 sm:px-6">
<div className="grid grid-cols-2 rounded-lg bg-surface-2 p-1" role="group" aria-label="Reservation type">
{(['allocation', 'hold'] as const).map((value) => (
<button
key={value}
type="button"
onClick={() => form.setValue('kind', value)}
className={
kind === value
? 'tap rounded-md bg-surface px-3 text-sm font-medium text-fg shadow-sm'
: 'tap rounded-md px-3 text-sm font-medium text-muted'
}
>
{value === 'allocation' ? 'Sell allocation' : 'Timed hold'}
</button>
))}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="capacityCommitmentId"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>Capacity commitment</FormLabel>
<Select value={field.value} onValueChange={chooseCommitment}>
<FormControl>
<SelectTrigger className="h-11">
<SelectValue placeholder={availabilityLoading ? 'Loading capacity…' : 'Select capacity'} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
{options.map((row) => (
<SelectItem key={row.commitmentId} value={row.commitmentId}>
{row.name} · {compactNumber(row.availableGpuHours)} GPU-hrs free
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{matches ? <FormDescription>Limited to the capacity returned by this match.</FormDescription> : null}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="demandDealId"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>Demand deal</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger className="h-11"><SelectValue placeholder="Select the customer deal" /></SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
{(demand?.deals ?? [])
.filter((row) => row.deal.stage !== 'closed_lost')
.map((row) => (
<SelectItem key={row.deal.id} value={row.deal.id}>
{row.deal.name} · {row.accountName ?? 'No account'}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
{selected ? (
<CommitmentContext row={selected} detail={detail} match={match} quotedPrice={quotedPrice} />
) : options.length === 0 && !availabilityLoading ? (
<div role="status" className="rounded-lg border border-border bg-surface-2 p-4 text-sm text-muted">
No currently available commitment remains in this context. Run the matcher again before promising capacity.
</div>
) : null}
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold">Commercial reservation</h3>
<p className="mt-1 text-xs leading-relaxed text-muted">
GPU-hours and the window are submitted to the ledger as entered. The server checks the term, shaped capacity, holds, and concurrent writes.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<TextField control={form.control} name="gpuHours" label="GPU-hours" inputMode="decimal" placeholder="2048" />
<TextField control={form.control} name="price" label={kind === 'hold' ? 'Expected $/GPU-hr' : 'Sell $/GPU-hr'} inputMode="decimal" placeholder={kind === 'hold' ? 'Optional' : '2.75'} />
<TextField control={form.control} name="startsAt" label="Starts" type="datetime-local" />
<TextField control={form.control} name="endsAt" label="Ends" type="datetime-local" />
{kind === 'hold' ? (
<TextField control={form.control} name="holdExpiresAt" label="Hold expires" type="datetime-local" className="sm:col-span-2" />
) : (
<SelectField control={form.control} name="status" label="Allocation status" options={createStatuses} />
)}
<SelectField control={form.control} name="guaranteeType" label="Service guarantee" options={GUARANTEE_TYPES} />
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>Reservation notes</FormLabel>
<FormControl><Textarea {...field} className="min-h-24 resize-y" placeholder="Commercial assumptions, caveats, or approval context." /></FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</section>
{selected ? (
<section className="flex flex-col gap-3">
<div>
<h3 className="text-sm font-semibold">Reservations on this commitment</h3>
<p className="mt-1 text-xs text-muted">Live holds reserve capacity but remain separate from sold allocations.</p>
</div>
{reserving.length === 0 ? (
<p className="rounded-lg bg-surface-2 p-4 text-sm text-muted">No live reserving allocations.</p>
) : (
<div className="flex flex-col gap-2">
{reserving.map((allocation) => {
const deal = allocation.demandDealId ? dealsById.get(allocation.demandDealId) : undefined;
return (
<div key={allocation.id} className="flex flex-col gap-3 rounded-lg border border-border p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-medium">{deal?.deal.name ?? 'Internal allocation'}</p>
<Badge tone={allocation.status === 'planned' ? 'warning' : 'positive'}>{allocation.status === 'planned' ? 'Held' : allocation.status}</Badge>
</div>
<p className="mt-1 text-xs text-muted">
{compactNumber(Number(allocation.gpuHours))} GPU-hrs · {shortDate(allocation.startsAt)}{shortDate(allocation.endsAt)}
{allocation.holdExpiresAt ? ` · expires ${shortDate(allocation.holdExpiresAt)}` : ''}
</p>
</div>
<Button type="button" variant="outline" className="shrink-0" disabled={release.isPending} onClick={() => release.mutate(allocation.id)}>
{release.isPending && release.variables === allocation.id ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <RotateCcw data-icon="inline-start" aria-hidden />}
Release
</Button>
</div>
);
})}
<label className="flex flex-col gap-1 text-xs font-medium text-muted">
Release reason <span className="font-normal">Optional; recorded in the audit trail</span>
<Input value={releaseReason} onChange={(event) => setReleaseReason(event.target.value)} placeholder="Deal changed, hold lapsed…" />
</label>
</div>
)}
{releaseError ? <ServerError message={releaseError} /> : null}
</section>
) : null}
{save.isError ? <ServerError message={errorMessage(save.error)} /> : null}
</div>
<Separator />
<div className="flex shrink-0 flex-col-reverse gap-2 px-5 pb-[calc(1rem+var(--safe-bottom))] pt-4 sm:flex-row sm:justify-end sm:px-6">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="submit" variant="primary" disabled={save.isPending || options.length === 0}>
{save.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : kind === 'hold' ? <Clock3 data-icon="inline-start" aria-hidden /> : <ShieldCheck data-icon="inline-start" aria-hidden />}
{save.isPending ? 'Checking capacity…' : kind === 'hold' ? 'Place timed hold' : 'Create allocation'}
</Button>
</div>
</form>
</Form>
</SheetContent>
</Sheet>
);
}
function CommitmentContext({ row, detail, match, quotedPrice }: { row: AvailabilityRow; detail?: CommitmentRow; match?: MatchRow; quotedPrice: number | null }) {
const soldPct = row.totalGpuHours > 0 ? row.soldGpuHours / row.totalGpuHours : 0;
const heldPct = row.totalGpuHours > 0 ? row.heldGpuHours / row.totalGpuHours : 0;
const breakEvenDollars = row.breakEvenPriceCents == null ? null : row.breakEvenPriceCents / 100;
const delta = quotedPrice != null && Number.isFinite(quotedPrice) && quotedPrice >= 0 && breakEvenDollars != null
? quotedPrice - breakEvenDollars
: null;
const shape = detail?.commitment.shape;
return (
<section className="rounded-xl border border-border bg-surface-2 p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold">{row.name}</p>
<p className="mt-1 text-xs text-muted">{row.gpuCount}× {row.gpuType} · {row.interconnectType} · {row.securityTier.replace(/_/g, ' ')}</p>
</div>
{match ? <Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>{percent(match.score)} fit</Badge> : null}
</div>
<div className="mt-4 flex h-2 overflow-hidden rounded-full bg-surface">
<div className="bg-accent" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
<div className="bg-accent/35" style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }} />
</div>
<div className="mt-2 grid grid-cols-3 gap-2 text-xs">
<div><p className="text-muted">Sold</p><p className="nums mt-0.5 font-medium">{compactNumber(row.soldGpuHours)} hrs</p></div>
<div><p className="text-muted">Held</p><p className="nums mt-0.5 font-medium">{compactNumber(row.heldGpuHours)} hrs</p></div>
<div><p className="text-muted">Available</p><p className="nums mt-0.5 font-medium">{compactNumber(row.availableGpuHours)} hrs</p></div>
</div>
<Separator className="my-4" />
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<dt className="text-muted">Contract window</dt><dd className="text-right">{shortDate(row.startsAt)}{shortDate(row.endsAt)}</dd>
<dt className="text-muted">Capacity shape</dt><dd className="text-right">{shape ? `${shape.quantities.length} tranches · ${shape.quantities.join('→')} GPUs` : 'Flat'}{detail?.commitment.isContiguous ? ' · contiguous' : ''}</dd>
<dt className="text-muted">Our cost</dt><dd className="nums text-right">{money(row.costPerGpuHourCents)}/GPU-hr</dd>
<dt className="text-muted">Remaining-block break even</dt><dd className="nums text-right">{row.breakEvenPriceCents == null ? 'Fully sold' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${money(row.breakEvenPriceCents)}/GPU-hr`}</dd>
{Number(detail?.commitment.oversubscriptionPct ?? 0) > 0 ? <><dt className="text-muted">Recorded oversubscription</dt><dd className="nums text-right">{Number(detail?.commitment.oversubscriptionPct)}%</dd></> : null}
{delta != null ? <><dt className="text-muted">Quote vs break even</dt><dd className={delta >= 0 ? 'nums text-right text-positive' : 'nums text-right text-danger'}>{delta >= 0 ? '+' : ''}{money(Math.round(delta * 100))}/GPU-hr</dd></> : null}
</dl>
{match?.rationale.length ? <ul className="mt-4 flex flex-col gap-1 text-xs text-muted">{match.rationale.map((reason) => <li key={reason}>{reason}</li>)}</ul> : null}
<p className="mt-4 text-[11px] leading-relaxed text-muted">These figures are the latest server view, not a guarantee. Save acquires a commitment lock and re-checks the exact window, shape, hours, live holds, and oversubscription policy.</p>
</section>
);
}
function TextField<T extends FieldValues>({ control, name, label, className, ...props }: { control: Control<T>; name: FieldPath<T>; label: string; className?: string } & Omit<React.ComponentProps<typeof Input>, 'name' | 'value' | 'defaultValue'>) {
return <FormField control={control} name={name} render={({ field }) => <FormItem className={className}><FormLabel>{label}</FormLabel><FormControl><Input {...field} {...props} value={String(field.value ?? '')} /></FormControl><FormMessage /></FormItem>} />;
}
function SelectField<T extends FieldValues>({ control, name, label, options }: { control: Control<T>; name: FieldPath<T>; label: string; options: readonly string[] }) {
return <FormField control={control} name={name} render={({ field }) => <FormItem><FormLabel>{label}</FormLabel><Select value={String(field.value)} onValueChange={field.onChange}><FormControl><SelectTrigger className="h-11"><SelectValue /></SelectTrigger></FormControl><SelectContent><SelectGroup>{options.map((option) => <SelectItem key={option} value={option}>{option.replace(/_/g, ' ').replace(/^./, (letter) => letter.toUpperCase())}</SelectItem>)}</SelectGroup></SelectContent></Select><FormMessage /></FormItem>} />;
}
function ServerError({ message }: { message: string }) {
return <div role="alert" className="flex gap-3 rounded-lg border border-danger/30 bg-danger/10 p-3 text-sm text-danger"><AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden /><p>{message}</p></div>;
}
function errorMessage(error: unknown): string {
if (error instanceof ApiError) return error.message;
return error instanceof Error ? error.message : 'The reservation could not be saved.';
}