99d165b5e5
Piggy answered in raw markdown, threw away every tool result it streamed, and fought the reader's scroll on every token. The three surfaces that made it worth having — what it read, how it reasoned, what it cost — were all on the wire and none of them reached the screen. The transcript is now composed of five parts under components/piggy: answers render through streamdown, the container sticks to the bottom without pinning the reader there, tool steps say what they read and link to the record, and each turn carries its model and token count. Three lifecycle bugs went with them: Stop left a permanent spinner, a truncated stream was indistinguishable from thinking, and a failed send destroyed the message it failed to send. Underneath, the inference path grew timeouts, jittered retries on 429 and 5xx, tolerance of the malformed frames a 30B model emits, and an agent_runs row per turn so chat spend is observable. The system prompt now states that a field ending in Cents is cents — without it nemotron renders costPerGpuHourCents: 189 as "$189 per GPU-hour", which is a 100x error on the most scrutinised number in the room. The demo book was arithmetically incoherent: every deal's value contradicted its own allocation revenue by up to 3.6x, nothing had ever closed, no customer had any paper, and the marketplace was empty. Deal value is now derived from the allocation, the book clears 5.3% across five blocks with one deliberately underwater, and the renewal, compliance and agent-provenance machinery finally has rows to act on. A --clear that deleted every obligation, SLA term and capacity request in the database regardless of origin is scoped to the demo's own ids. Around that: accounts have a detail page, ⌘K searches the book, Settings can mint the API keys it always claimed to, and deploy.sh actually ships the agent instead of silently skipping its compose profile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
372 lines
17 KiB
TypeScript
372 lines
17 KiB
TypeScript
/**
|
||
* Margin — the ledger, per block and in total.
|
||
*
|
||
* The table scrolls inside its own pane on narrow screens rather than making
|
||
* the page scroll sideways; a card list would lose the column comparison that
|
||
* is the entire value of this view.
|
||
*
|
||
* The page also has to say the quiet part out loud. A blended margin in the low
|
||
* single digits reads as a thin but healthy book, while one commitment sits
|
||
* barely half sold and has not paid for itself — the totals average that away
|
||
* by construction. So the blocks whose cost is still uncovered are named above
|
||
* the table with the price their remaining hours have to fetch, rather than
|
||
* left to be reconstructed by reading a percentage column against a price
|
||
* column two columns away.
|
||
*/
|
||
import { useState } from 'react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { AlertTriangle, ArrowRight } from 'lucide-react';
|
||
import { Link } from 'react-router-dom';
|
||
import { compactNumber, get, money, percent, unitPrice } from '@/lib/api';
|
||
import { PiggyAskButton } from '@/components/PiggyChat';
|
||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat, cn } from '@/components/ui';
|
||
import { usePageTitle } from '@/lib/title';
|
||
import { usePiggyContext } from '@/lib/piggy-context';
|
||
|
||
interface MarginReport {
|
||
totals: {
|
||
committedGpuHours: number;
|
||
allocatedGpuHours: number;
|
||
idleGpuHours: number;
|
||
utilisation: number;
|
||
costCents: number;
|
||
revenueCents: number;
|
||
grossMarginCents: number;
|
||
grossMarginPct: number | null;
|
||
marginPerAllocatedGpuHourCents: number | null;
|
||
};
|
||
blocks: MarginBlock[];
|
||
}
|
||
|
||
interface MarginBlock {
|
||
commitmentId: string;
|
||
name: string;
|
||
gpuType: string;
|
||
gpuCount: number;
|
||
totalGpuHours: number;
|
||
soldGpuHours: number;
|
||
availableGpuHours: number;
|
||
costPerGpuHourCents: number;
|
||
utilisation: number;
|
||
breakEvenPriceCents: number | null;
|
||
}
|
||
|
||
/**
|
||
* A block has covered its cost when there is nothing left to break even on.
|
||
*
|
||
* The colour on the sold-ratio column used to key off `utilisation < 0.5`,
|
||
* which is an arbitrary line: the block this page exists to flag is 55% sold
|
||
* and would have rendered as unremarkable, while a block 40% sold on cheap
|
||
* hours it has already earned back would have rendered as a problem. Break-even
|
||
* is the honest test — it is zero exactly when revenue has already covered the
|
||
* whole commitment, and null when there is nothing left to sell.
|
||
*/
|
||
function isUncovered(block: MarginBlock): boolean {
|
||
return block.breakEvenPriceCents != null && block.breakEvenPriceCents > 0;
|
||
}
|
||
|
||
export function Margin() {
|
||
usePageTitle('Margin');
|
||
const { data, isLoading, error, refetch } = useQuery({
|
||
queryKey: ['margin'],
|
||
queryFn: () => get<MarginReport>('/api/capacity/margin'),
|
||
});
|
||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||
const blocks = data?.blocks ?? [];
|
||
// Resolved from the current data rather than held in state, so a block that
|
||
// disappears on a refetch quietly returns Piggy to the page instead of
|
||
// leaving it pointed at a commitment nobody can see any more.
|
||
const focused = blocks.find((block) => block.commitmentId === focusedId);
|
||
|
||
// Ambient context for the dock: the block the user selected, else this page.
|
||
// Published before the early returns below, because a hook that runs only on
|
||
// the happy path is a hook that changes order the first time the query fails.
|
||
usePiggyContext(
|
||
focused
|
||
? { type: 'commitment', id: focused.commitmentId, label: focused.name }
|
||
: { type: 'page', route: '/margin', label: 'Margin' },
|
||
);
|
||
|
||
const uncovered = blocks.filter(isUncovered);
|
||
const toggleFocus = (commitmentId: string) =>
|
||
setFocusedId((current) => (current === commitmentId ? null : commitmentId));
|
||
|
||
if (isLoading) {
|
||
return <div className="flex flex-col gap-4"><Skeleton className="h-16" /><div className="grid grid-cols-2 gap-2 xl:grid-cols-4">{Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-28" />)}</div><Skeleton className="h-80" /></div>;
|
||
}
|
||
if (error || !data) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="flex flex-col items-center gap-4 pt-6">
|
||
<EmptyState title="Could not load margin" description={error instanceof Error ? error.message : 'The margin ledger is unavailable.'} />
|
||
<Button variant="outline" onClick={() => void refetch()}>Try again</Button>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
if (data.blocks.length === 0) {
|
||
return (
|
||
<EmptyState
|
||
title="No capacity to report on"
|
||
description="Margin is computed from capacity commitments and the allocations against them. Both are recorded on the capacity book."
|
||
/*
|
||
* A link rather than the commitment sheet itself, which is the opposite
|
||
* of the choice Overview makes and for a reason. Margin is a derived
|
||
* ledger with no other write path on it, and recording a block is only
|
||
* step one — the seller's next move is to match and allocate it, which
|
||
* is on /capacity too. Sending the reader there puts them in front of
|
||
* the whole job rather than dropping a sheet onto a report and
|
||
* returning them to a page that still says nothing sold. It also keeps
|
||
* the capability question in one place: the button on /capacity states
|
||
* whose authority this is, so it is not restated here.
|
||
*/
|
||
action={
|
||
<Button variant="primary" asChild>
|
||
<Link to="/capacity">
|
||
Go to the capacity book
|
||
<ArrowRight className="size-4" aria-hidden />
|
||
</Link>
|
||
</Button>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const t = data.totals;
|
||
|
||
return (
|
||
<div className="space-y-5 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
|
||
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div className="min-w-0">
|
||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Margin</h1>
|
||
<p className="mt-1 max-w-2xl text-sm text-muted">
|
||
Revenue from what we sold, against the full cost of what we bought. Every
|
||
commitment is charged in full, so a block keeps paying for the hours nobody
|
||
has bought yet.
|
||
</p>
|
||
</div>
|
||
{/*
|
||
No `context` prop, deliberately. This button asks about whatever the
|
||
page has published — the selected block, else /margin — and pinning it
|
||
to the page here would make selecting a block change the dock and not
|
||
this button, which is the one the user just pressed.
|
||
*/}
|
||
<PiggyAskButton
|
||
label={focused ? 'Ask about this block' : 'Ask Piggy'}
|
||
prompt={
|
||
focused
|
||
? 'How much of this block is still unsold, what must the rest fetch to cover it, and how much term is left to sell into?'
|
||
: 'Which commitment is furthest from covering its cost, and what would the remaining hours have to fetch?'
|
||
}
|
||
/>
|
||
</header>
|
||
|
||
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
|
||
<Stat label="Revenue" value={money(t.revenueCents)} hint={`${compactNumber(t.allocatedGpuHours)} GPU-hrs sold`} />
|
||
<Stat label="Cost" value={money(t.costCents)} hint={`${compactNumber(t.committedGpuHours)} GPU-hrs committed`} />
|
||
<Stat
|
||
label="Gross margin"
|
||
value={money(t.grossMarginCents)}
|
||
hint={`${percent(t.grossMarginPct, 1)} of revenue, after the idle hours`}
|
||
tone={t.grossMarginCents >= 0 ? 'positive' : 'danger'}
|
||
/>
|
||
<Stat
|
||
label="Per sold GPU-hour"
|
||
value={unitPrice(t.marginPerAllocatedGpuHourCents)}
|
||
hint={`${percent(t.utilisation, 1)} of committed hours sold`}
|
||
/>
|
||
</section>
|
||
|
||
{uncovered.length > 0 ? (
|
||
<Card className="border-warning/30">
|
||
<CardHeader className="space-y-0">
|
||
<div className="flex items-start gap-2">
|
||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
||
<div className="min-w-0">
|
||
<CardTitle className="text-base">
|
||
{uncovered.length} of {data.blocks.length} commitments have not covered their cost
|
||
</CardTitle>
|
||
{/*
|
||
Not "an average of the blocks": the blended figure is a ratio
|
||
of sums, and describing it as an average invites exactly the
|
||
per-block averaging `aggregateMargin` refuses to do.
|
||
*/}
|
||
<p className="mt-1 text-sm text-muted">
|
||
{t.grossMarginPct == null
|
||
? 'Nothing has sold yet, so every commitment below is still owed its whole cost.'
|
||
: `The book clears ${percent(t.grossMarginPct, 1)} blended because the blocks that have earned their money back carry the ones that have not.`}{' '}
|
||
These are the blocks still owed something, and the price the rest of each has to
|
||
fetch to get there.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2">
|
||
{uncovered.map((block) => (
|
||
<div key={block.commitmentId} className="flex flex-col gap-2 rounded-lg bg-surface-2 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="min-w-0">
|
||
<p className="break-words font-medium leading-snug">{block.name}</p>
|
||
<p className="mt-1 text-xs text-muted">
|
||
{block.gpuCount}× {block.gpuType} · <span className="nums">{percent(block.utilisation)}</span> sold ·{' '}
|
||
<span className="nums">{compactNumber(block.availableGpuHours)}</span> hrs still sellable
|
||
</p>
|
||
</div>
|
||
<p className="shrink-0 text-sm sm:text-right">
|
||
<span className="nums font-semibold text-warning">{unitPrice(block.breakEvenPriceCents)}</span>
|
||
<span className="text-muted">/GPU-hr to break even</span>
|
||
{/*
|
||
Break-even sits below cost once part of the block has sold —
|
||
the hours already invoiced have paid down some of it. Said
|
||
here because the two prices are otherwise read as a
|
||
contradiction rather than as progress.
|
||
*/}
|
||
<span className="mt-0.5 block text-xs text-muted">
|
||
against <span className="nums">{unitPrice(block.costPerGpuHourCents)}</span>/GPU-hr paid
|
||
</span>
|
||
</p>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
) : null}
|
||
|
||
<Card>
|
||
<CardHeader className="flex-row items-center justify-between gap-3 space-y-0">
|
||
<div>
|
||
<CardTitle className="text-base">By commitment</CardTitle>
|
||
<p className="mt-1 text-xs text-muted">
|
||
Sold ratio describes contracted capacity sold, not workload utilization. Select a
|
||
commitment to point Piggy at it.
|
||
</p>
|
||
</div>
|
||
<Link to="/capacity" className="tap inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-3 text-sm font-medium text-accent-fg hover:bg-surface-2">
|
||
Capacity <ArrowRight className="size-4" aria-hidden />
|
||
</Link>
|
||
</CardHeader>
|
||
<CardContent className="px-0 sm:px-0">
|
||
<div className="hidden scroll-x md:block">
|
||
<table className="w-full min-w-[720px] text-sm">
|
||
<thead>
|
||
<tr className="border-b border-border text-left text-xs uppercase tracking-wide text-muted">
|
||
<th className="px-4 pb-2 font-medium sm:px-5">Commitment</th>
|
||
<th className="px-4 pb-2 text-right font-medium">Sold</th>
|
||
<th className="px-4 pb-2 text-right font-medium">Sellable</th>
|
||
<th className="px-4 pb-2 text-right font-medium">Sold ratio</th>
|
||
<th className="px-4 pb-2 text-right font-medium">Cost/hr</th>
|
||
<th className="px-4 pb-2 text-right font-medium sm:px-5">Break even</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.blocks.map((block) => (
|
||
<tr
|
||
key={block.commitmentId}
|
||
aria-selected={block.commitmentId === focusedId}
|
||
className={cn(
|
||
'border-b border-border/60 last:border-0',
|
||
block.commitmentId === focusedId && 'bg-surface-2',
|
||
)}
|
||
>
|
||
<td className="px-4 py-3 sm:px-5">
|
||
<FocusButton
|
||
block={block}
|
||
focused={block.commitmentId === focusedId}
|
||
onToggle={() => toggleFocus(block.commitmentId)}
|
||
/>
|
||
</td>
|
||
<td className="nums px-4 py-3 text-right">
|
||
{compactNumber(block.soldGpuHours)}
|
||
</td>
|
||
<td className="nums px-4 py-3 text-right">
|
||
{compactNumber(block.availableGpuHours)}
|
||
</td>
|
||
<td
|
||
className={[
|
||
'nums px-4 py-3 text-right font-medium',
|
||
isUncovered(block) ? 'text-warning' : '',
|
||
].join(' ')}
|
||
>
|
||
{percent(block.utilisation)}
|
||
</td>
|
||
<td className="nums px-4 py-3 text-right">
|
||
{unitPrice(block.costPerGpuHourCents)}
|
||
</td>
|
||
{/*
|
||
A zero break-even means the block's cost is already
|
||
covered, so any further sale is upside. Printing "$0.00"
|
||
is technically true and reads like a bug — the same fix
|
||
already applied on the capacity cards.
|
||
*/}
|
||
<td className="px-4 py-3 text-right sm:px-5"><BreakEven value={block.breakEvenPriceCents} /></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="grid gap-3 px-4 pb-4 md:hidden">
|
||
{data.blocks.map((block) => (
|
||
<article
|
||
key={block.commitmentId}
|
||
className={cn(
|
||
'rounded-xl border border-border p-4',
|
||
// `border-brand`, not `border-accent`: `accent` is shadcn's
|
||
// subtle surface in this config, so a border in it disappears.
|
||
block.commitmentId === focusedId && 'border-brand bg-surface-2',
|
||
)}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<FocusButton
|
||
block={block}
|
||
focused={block.commitmentId === focusedId}
|
||
onToggle={() => toggleFocus(block.commitmentId)}
|
||
/>
|
||
<span className={['nums shrink-0 text-sm font-semibold', isUncovered(block) ? 'text-warning' : ''].join(' ')}>{percent(block.utilisation)} sold</span>
|
||
</div>
|
||
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||
<dt className="text-muted">Sold capacity</dt><dd className="nums text-right">{compactNumber(block.soldGpuHours)} hrs</dd>
|
||
<dt className="text-muted">Sellable capacity</dt><dd className="nums text-right">{compactNumber(block.availableGpuHours)} hrs</dd>
|
||
<dt className="text-muted">Our cost</dt><dd className="nums text-right">{unitPrice(block.costPerGpuHourCents)}/GPU-hr</dd>
|
||
<dt className="text-muted">Break even</dt><dd className="text-right"><BreakEven value={block.breakEvenPriceCents} /></dd>
|
||
</dl>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The commitment name, as the control that points Piggy at that commitment.
|
||
*
|
||
* A row is the thing a reader is already looking at when they want to ask about
|
||
* it, so the name carries the selection rather than a separate button in a
|
||
* seventh column that would push the table wider than the pane it scrolls in.
|
||
*/
|
||
function FocusButton({ block, focused, onToggle }: { block: MarginBlock; focused: boolean; onToggle(): void }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
aria-pressed={focused}
|
||
onClick={onToggle}
|
||
title={focused ? 'Stop pointing Piggy at this commitment' : 'Point Piggy at this commitment'}
|
||
className="tap -mx-2 block min-w-0 rounded-lg px-2 py-1 text-left transition-colors hover:bg-surface-2"
|
||
>
|
||
<span className="block break-words font-medium leading-snug">
|
||
{block.name}
|
||
{focused ? <Badge tone="accent" className="ml-2 align-middle">In focus</Badge> : null}
|
||
</span>
|
||
<span className="mt-0.5 block text-xs text-muted">
|
||
{block.gpuCount}× {block.gpuType}
|
||
</span>
|
||
<span className="sr-only">{focused ? 'Piggy is looking at this commitment' : 'Point Piggy at this commitment'}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function BreakEven({ value }: { value: number | null }) {
|
||
if (value == null) return <span className="text-muted">Sold out</span>;
|
||
if (value === 0) return <span className="text-positive">Cost covered</span>;
|
||
return <span className="nums">{unitPrice(value)}/GPU-hr</span>;
|
||
}
|