This commit is contained in:
@@ -7,9 +7,16 @@
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Search, Server, Zap } from 'lucide-react';
|
||||
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,
|
||||
@@ -22,29 +29,20 @@ import {
|
||||
Skeleton,
|
||||
} from '@/components/ui';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
type MatchRow = AvailabilityRow & { score: number; rationale: string[] };
|
||||
|
||||
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">
|
||||
@@ -78,12 +76,30 @@ export function Capacity() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'available' ? <Availability /> : <Matcher />}
|
||||
{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() {
|
||||
function Availability({ writable, onAllocate }: { writable: boolean; onAllocate(id: string): void }) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['availability'],
|
||||
queryFn: () => get<AvailabilityRow[]>('/api/capacity/availability'),
|
||||
@@ -108,13 +124,13 @@ function Availability() {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{data.map((row) => (
|
||||
<CapacityCard key={row.commitmentId} row={row} />
|
||||
<CapacityCard key={row.commitmentId} row={row} writable={writable} onAllocate={() => onAllocate(row.commitmentId)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapacityCard({ row }: { row: AvailabilityRow }) {
|
||||
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;
|
||||
|
||||
@@ -170,16 +186,22 @@ function CapacityCard({ row }: { row: AvailabilityRow }) {
|
||||
: `${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() {
|
||||
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: '',
|
||||
});
|
||||
@@ -190,6 +212,8 @@ function Matcher() {
|
||||
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)
|
||||
@@ -249,6 +273,21 @@ function Matcher() {
|
||||
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
|
||||
@@ -265,8 +304,8 @@ function Matcher() {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button type="submit" variant="primary" disabled={mutation.isPending}>
|
||||
<Search className="h-4 w-4" aria-hidden />
|
||||
<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>
|
||||
@@ -301,7 +340,7 @@ function Matcher() {
|
||||
{percent(match.score)} fit
|
||||
</Badge>
|
||||
</div>
|
||||
<ul className="mt-3 space-y-1 text-sm">
|
||||
<ul className="mt-3 flex flex-col gap-1 text-sm">
|
||||
{match.rationale.map((reason, i) => (
|
||||
<li
|
||||
key={i}
|
||||
@@ -313,6 +352,24 @@ function Matcher() {
|
||||
</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>
|
||||
))}
|
||||
@@ -321,7 +378,7 @@ function Matcher() {
|
||||
) : null}
|
||||
|
||||
{mutation.isError ? (
|
||||
<p className="text-sm text-danger">
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{mutation.error instanceof Error ? mutation.error.message : 'Match failed.'}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user