2763531ce4
CI / verify (push) Successful in 2m53s
shadcn uses `bg-accent` for its SUBTLE surfaces — dropdown item hover, command row selection, ghost and outline button hover, the dialog close affordance. The brand colour in shadcn is `primary`. PIG's Tailwind config mapped `accent` to `--accent`, which is the brand. That inverted the meaning, so every shadcn hover and selection state painted a full-strength brand block. With the monochrome "pig" palette in dark mode the brand is near-white, so a selected command row rendered as a white slab against a near-black sheet. Measured before the change: selected row rgb(250,250,250) on a rgb(9,9,11) body. `accent` now aliases `--accent-subtle` and `accent-foreground` aliases `--accent-fg`, which is what those tokens were created for. The eleven places where PIG's own components wanted a solid brand fill — filled chips, selected card borders, progress bars — move to `primary`, which still resolves to `--accent`. A `brand` alias is added for clarity. After: selected row rgb(39,39,42) in dark and rgb(244,244,245) in light, both a subtle tint above the body; the pipeline's active stage chip stays a solid rgb(250,250,250) fill, unchanged. Found by opening overlays, which earlier screenshot sweeps never did — every route had been checked, but a dropdown or a command palette only misbehaves once it is open. Worth remembering: page-level sweeps do not exercise portals. Typecheck clean, 135 unit tests and e2e green, CSP hash unchanged, 0px horizontal overflow across 12 routes at 393px and 1440px. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
397 lines
15 KiB
TypeScript
397 lines
15 KiB
TypeScript
/**
|
||
* Capacity — availability, and the matcher.
|
||
*
|
||
* The matcher is the screen that justifies the product: given what a customer
|
||
* wants, what have we already bought that could serve them, and would selling
|
||
* it make money? No generic CRM can answer either half.
|
||
*/
|
||
import { useState } from 'react';
|
||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||
import type { PermissionGrant } from '@pig/core';
|
||
import { Search, Server, ShieldCheck, Zap } from 'lucide-react';
|
||
import { compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
|
||
import { usePageTitle } from '@/lib/title';
|
||
import { can } from '@/lib/permissions';
|
||
import {
|
||
AllocationSheet,
|
||
type AvailabilityRow,
|
||
type MatchRow,
|
||
} from '@/components/AllocationSheet';
|
||
import {
|
||
Badge,
|
||
Button,
|
||
Card,
|
||
CardContent,
|
||
CardHeader,
|
||
CardTitle,
|
||
EmptyState,
|
||
Input,
|
||
Skeleton,
|
||
} from '@/components/ui';
|
||
|
||
export function Capacity() {
|
||
usePageTitle('Capacity');
|
||
const [tab, setTab] = useState<'available' | 'match'>('available');
|
||
const [allocation, setAllocation] = useState<{
|
||
open: boolean;
|
||
preferredCommitmentId?: string;
|
||
matches?: MatchRow[];
|
||
defaultGpuHours?: number;
|
||
}>({ open: false });
|
||
const { data: me } = useQuery({
|
||
queryKey: ['me'],
|
||
queryFn: () => get<{ permissions: PermissionGrant[] }>('/api/me'),
|
||
});
|
||
const writable = can(me, 'deal:write', 'demand');
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<header>
|
||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Capacity</h1>
|
||
<p className="mt-1 text-sm text-muted">
|
||
What we hold, what is sold, and what is still sellable.
|
||
</p>
|
||
</header>
|
||
|
||
{/* A segmented control rather than tabs — it reads correctly at phone
|
||
width, where a tab row would either wrap or scroll. */}
|
||
<div
|
||
role="tablist"
|
||
aria-label="Capacity views"
|
||
className="inline-flex w-full rounded-lg bg-surface-2 p-1 sm:w-auto"
|
||
>
|
||
{(['available', 'match'] as const).map((value) => (
|
||
<button
|
||
key={value}
|
||
role="tab"
|
||
aria-selected={tab === value}
|
||
onClick={() => setTab(value)}
|
||
className={[
|
||
'tap flex-1 rounded-md px-4 text-sm font-medium transition-colors sm:flex-none',
|
||
tab === value ? 'bg-surface text-fg shadow-sm' : 'text-muted',
|
||
].join(' ')}
|
||
>
|
||
{value === 'available' ? 'Availability' : 'Match a requirement'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{tab === 'available' ? (
|
||
<Availability
|
||
writable={writable}
|
||
onAllocate={(preferredCommitmentId) =>
|
||
setAllocation({ open: true, preferredCommitmentId })
|
||
}
|
||
/>
|
||
) : (
|
||
<Matcher
|
||
writable={writable}
|
||
onAllocate={(preferredCommitmentId, matches, defaultGpuHours) =>
|
||
setAllocation({ open: true, preferredCommitmentId, matches, defaultGpuHours })
|
||
}
|
||
/>
|
||
)}
|
||
<AllocationSheet
|
||
{...allocation}
|
||
onOpenChange={(open) => setAllocation((state) => ({ ...state, open }))}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Availability({ writable, onAllocate }: { writable: boolean; onAllocate(id: string): void }) {
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: ['availability'],
|
||
queryFn: () => get<AvailabilityRow[]>('/api/capacity/availability'),
|
||
});
|
||
|
||
if (isLoading) return <Skeleton className="h-64" />;
|
||
|
||
if (!data || data.length === 0) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="pt-5">
|
||
<EmptyState
|
||
icon={<Server className="h-8 w-8" />}
|
||
title="No live capacity commitments"
|
||
description="Once you record what capacity you have committed to buy, this view shows how much of each block is sold, held, and still available."
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||
{data.map((row) => (
|
||
<CapacityCard key={row.commitmentId} row={row} writable={writable} onAllocate={() => onAllocate(row.commitmentId)} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CapacityCard({ row, writable, onAllocate }: { row: AvailabilityRow; writable: boolean; onAllocate(): void }) {
|
||
const soldPct = row.totalGpuHours > 0 ? row.soldGpuHours / row.totalGpuHours : 0;
|
||
const heldPct = row.totalGpuHours > 0 ? row.heldGpuHours / row.totalGpuHours : 0;
|
||
|
||
return (
|
||
/*
|
||
* `min-w-0` is load-bearing. A grid item defaults to `min-width: auto`,
|
||
* which means it refuses to shrink below its content — and `truncate` sets
|
||
* `white-space: nowrap`, so a long title becomes unshrinkable content and
|
||
* widens the whole track. The result is a page that scrolls sideways on a
|
||
* phone. This is the fix, and it is needed on every grid child that
|
||
* truncates.
|
||
*/
|
||
<Card className="min-w-0">
|
||
<CardHeader>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<CardTitle className="min-w-0 truncate text-base">{row.name}</CardTitle>
|
||
<Badge tone={row.securityTier === 'secure_cloud' ? 'accent' : 'neutral'}>
|
||
{row.securityTier === 'secure_cloud' ? 'Secure' : 'Community'}
|
||
</Badge>
|
||
</div>
|
||
<p className="text-xs text-muted">
|
||
{row.gpuCount}× {row.gpuType} · {row.interconnectType} ·{' '}
|
||
{shortDate(row.startsAt)}–{shortDate(row.endsAt)}
|
||
</p>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{/* Sold and held are shown as separate segments, because a full-looking
|
||
bar made mostly of unconverted holds is a lie a seller would act on. */}
|
||
<div>
|
||
<div className="flex h-2 overflow-hidden rounded-full bg-surface-2">
|
||
<div className="bg-primary" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
|
||
<div
|
||
className="bg-primary/35"
|
||
style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }}
|
||
/>
|
||
</div>
|
||
<div className="mt-1.5 flex justify-between text-xs text-muted">
|
||
<span>{percent(soldPct)} sold</span>
|
||
{row.heldGpuHours > 0 ? <span>{percent(heldPct)} held</span> : null}
|
||
<span className="nums">{compactNumber(row.availableGpuHours)} hrs free</span>
|
||
</div>
|
||
</div>
|
||
|
||
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
|
||
<dt className="text-muted">Cost</dt>
|
||
<dd className="nums text-right">{money(row.costPerGpuHourCents)}/hr</dd>
|
||
<dt className="text-muted">Break even</dt>
|
||
<dd className="nums text-right">
|
||
{row.breakEvenPriceCents == null
|
||
? 'Fully sold'
|
||
: row.breakEvenPriceCents === 0
|
||
? 'Cost covered'
|
||
: `${money(row.breakEvenPriceCents)}/hr`}
|
||
</dd>
|
||
</dl>
|
||
<Button variant="outline" className="w-full" disabled={!writable || row.availableGpuHours <= 0} onClick={onAllocate} title={!writable ? 'Demand-team write permission is required' : undefined}>
|
||
<ShieldCheck data-icon="inline-start" aria-hidden />
|
||
Allocate or hold
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: string, matches: MatchRow[], defaultGpuHours?: number): void }) {
|
||
const [form, setForm] = useState({
|
||
gpuType: '',
|
||
gpuCount: '64',
|
||
totalGpuHours: '',
|
||
startsAt: '',
|
||
endsAt: '',
|
||
requiresHighSpeedInterconnect: true,
|
||
maxPrice: '',
|
||
});
|
||
|
||
const mutation = useMutation({
|
||
mutationFn: () =>
|
||
post<MatchRow[]>('/api/capacity/match', {
|
||
gpuType: form.gpuType || undefined,
|
||
gpuCount: Number(form.gpuCount) || 1,
|
||
totalGpuHours: form.totalGpuHours ? Number(form.totalGpuHours) : undefined,
|
||
startsAt: form.startsAt ? new Date(`${form.startsAt}T00:00:00`).toISOString() : undefined,
|
||
endsAt: form.endsAt ? new Date(`${form.endsAt}T23:59:59`).toISOString() : undefined,
|
||
requiresHighSpeedInterconnect: form.requiresHighSpeedInterconnect,
|
||
maxPricePerGpuHourCents: form.maxPrice
|
||
? Math.round(Number(form.maxPrice) * 100)
|
||
: undefined,
|
||
}),
|
||
});
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">What does the customer need?</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<form
|
||
className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
mutation.mutate();
|
||
}}
|
||
>
|
||
<Field label="GPU type">
|
||
<Input
|
||
value={form.gpuType}
|
||
onChange={(e) => setForm({ ...form, gpuType: e.target.value })}
|
||
placeholder="H100_80GB"
|
||
// Hardware identifiers are case-sensitive upstream; correcting
|
||
// them for the user would produce silent mismatches.
|
||
autoCapitalize="off"
|
||
autoCorrect="off"
|
||
spellCheck={false}
|
||
/>
|
||
</Field>
|
||
<Field label="GPUs">
|
||
<Input
|
||
value={form.gpuCount}
|
||
onChange={(e) => setForm({ ...form, gpuCount: e.target.value })}
|
||
// A numeric keypad on phones, without the spinner arrows and
|
||
// scroll-to-change behaviour of type="number".
|
||
inputMode="numeric"
|
||
pattern="[0-9]*"
|
||
/>
|
||
</Field>
|
||
<Field label="GPU-hours">
|
||
<Input
|
||
value={form.totalGpuHours}
|
||
onChange={(e) => setForm({ ...form, totalGpuHours: e.target.value })}
|
||
inputMode="numeric"
|
||
placeholder="Optional"
|
||
/>
|
||
</Field>
|
||
<Field label="Max $/GPU-hr">
|
||
<Input
|
||
value={form.maxPrice}
|
||
onChange={(e) => setForm({ ...form, maxPrice: e.target.value })}
|
||
inputMode="decimal"
|
||
placeholder="Optional"
|
||
/>
|
||
</Field>
|
||
<Field label="Needed from">
|
||
<Input
|
||
type="date"
|
||
value={form.startsAt}
|
||
onChange={(e) => setForm({ ...form, startsAt: e.target.value })}
|
||
/>
|
||
</Field>
|
||
<Field label="Needed until">
|
||
<Input
|
||
type="date"
|
||
value={form.endsAt}
|
||
min={form.startsAt || undefined}
|
||
onChange={(e) => setForm({ ...form, endsAt: e.target.value })}
|
||
/>
|
||
</Field>
|
||
|
||
<label className="tap flex items-center gap-2.5 text-sm sm:col-span-2 lg:col-span-3">
|
||
<input
|
||
type="checkbox"
|
||
checked={form.requiresHighSpeedInterconnect}
|
||
onChange={(e) =>
|
||
setForm({ ...form, requiresHighSpeedInterconnect: e.target.checked })
|
||
}
|
||
className="h-5 w-5 rounded border-border accent-[hsl(var(--accent))]"
|
||
/>
|
||
<span>
|
||
Needs high-speed interconnect
|
||
<span className="ml-1 text-muted">— distributed training</span>
|
||
</span>
|
||
</label>
|
||
|
||
<Button type="submit" variant="primary" disabled={mutation.isPending} className="lg:col-start-4">
|
||
<Search data-icon="inline-start" aria-hidden />
|
||
{mutation.isPending ? 'Matching…' : 'Find capacity'}
|
||
</Button>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{mutation.isSuccess ? (
|
||
mutation.data.length === 0 ? (
|
||
<Card>
|
||
<CardContent className="pt-5">
|
||
<EmptyState
|
||
icon={<Zap className="h-8 w-8" />}
|
||
title="Nothing on the book fits"
|
||
description="No committed capacity matches. Search provider inventory to find capacity to buy, which would mean opening a supply deal."
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{mutation.data.map((match) => (
|
||
<Card key={match.commitmentId}>
|
||
<CardContent className="pt-4">
|
||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<p className="font-medium">{match.name}</p>
|
||
<p className="text-xs text-muted">
|
||
{match.gpuCount}× {match.gpuType} · {match.interconnectType} ·{' '}
|
||
{compactNumber(match.availableGpuHours)} hrs free
|
||
</p>
|
||
</div>
|
||
<Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>
|
||
{percent(match.score)} fit
|
||
</Badge>
|
||
</div>
|
||
<ul className="mt-3 flex flex-col gap-1 text-sm">
|
||
{match.rationale.map((reason, i) => (
|
||
<li
|
||
key={i}
|
||
className={
|
||
reason.startsWith('⚠') ? 'text-warning' : 'text-muted'
|
||
}
|
||
>
|
||
{reason}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<div className="mt-4 flex flex-col gap-2 border-t border-border pt-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<p className="text-xs text-muted">
|
||
{shortDate(match.startsAt)}–{shortDate(match.endsAt)} · {money(match.breakEvenPriceCents)}/hr break even
|
||
</p>
|
||
<Button
|
||
variant="primary"
|
||
disabled={!writable}
|
||
onClick={() => onAllocate(
|
||
match.commitmentId,
|
||
mutation.data,
|
||
form.totalGpuHours ? Number(form.totalGpuHours) : undefined,
|
||
)}
|
||
title={!writable ? 'Demand-team write permission is required' : undefined}
|
||
>
|
||
<ShieldCheck data-icon="inline-start" aria-hidden />
|
||
Allocate this capacity
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)
|
||
) : null}
|
||
|
||
{mutation.isError ? (
|
||
<p role="alert" className="text-sm text-danger">
|
||
{mutation.error instanceof Error ? mutation.error.message : 'Match failed.'}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<label className="block">
|
||
<span className="mb-1 block text-xs font-medium text-muted">{label}</span>
|
||
{children}
|
||
</label>
|
||
);
|
||
}
|