Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 123 additions & 19 deletions client/src/components/usage/FleetUsageCard.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,50 @@
import { useState } from 'react';
import { Network } from 'lucide-react';
import Pill from '../ui/Pill';
import ToggleSwitch from '../ToggleSwitch';
import toast from '../ui/Toast';
import * as api from '../../services/api';
import { formatCompactCountOrDash as formatNumber, formatUsd, timeAgo } from '../../utils/formatters';

/**
* Overlay the viewer's in-flight Subscriptions toggles onto a fleet payload
* and re-sum the combined total from the rows that still count. Used by the
* card so a click updates the numbers immediately, and by the tests so they
* can assert the same math the UI shows.
*/
export function applyFleetBilling(fleet, overrides = {}) {
const instances = (fleet?.instances || []).map((row) => {
const usesSubscriptions = Object.hasOwn(overrides, row.instanceId)
? overrides[row.instanceId]
: row.usesSubscriptions !== false;
return { ...row, usesSubscriptions };
});
const included = instances.filter((row) => row.usesSubscriptions);
const totals = included.reduce((acc, r) => {
for (const [field, value] of Object.entries(r.totals || {})) {
if (typeof value === 'number') acc[field] = (acc[field] || 0) + value;
}
return acc;
}, {});
totals.estimatedCost = Math.round((totals.estimatedCost || 0) * 100) / 100;
return { instances, totals, includedCount: included.length };
}

function BillingToggle({ row, disabled, onToggle }) {
const name = row.name || row.instanceId;
return (
<ToggleSwitch
size="sm"
enabled={row.usesSubscriptions}
disabled={disabled}
onChange={() => onToggle(row)}
ariaLabel={row.usesSubscriptions
? `Count ${name} toward subscription totals`
: `Exclude ${name} from subscription totals (pays API rates)`}
/>
);
}

/**
* Per-instance AI usage across the federation, for the same report window the
* rest of the page is showing.
Expand All @@ -11,11 +54,24 @@ import { formatCompactCountOrDash as formatNumber, formatUsd, timeAgo } from '..
*
* A peer row is only as fresh as the last sync cycle, so each states when its
* digest was captured rather than implying it is live.
*
* Each row has a Subscriptions toggle. Off means this instance pays API rates
* rather than the viewer's plans: the row stays listed but drops out of the
* combined total. The choice is this install's view and is persisted locally.
*/
export default function FleetUsageCard({ fleet }) {
export default function FleetUsageCard({ fleet, onSaved }) {
const [overrides, setOverrides] = useState({});
const [pending, setPending] = useState({});

const rows = fleet?.instances || [];
if (rows.length < 2) return null;

const view = applyFleetBilling(fleet, overrides);
const excludedCount = view.instances.length - view.includedCount;
const combinedLabel = excludedCount > 0
? `${view.includedCount} of ${view.instances.length} instances on subscriptions`
: `${view.instances.length} instances combined`;

const label = (row) => (row.self
? <Pill tone="context" size="xs" className="ml-2">This machine</Pill>
: row.capturedAt && (
Expand All @@ -24,6 +80,45 @@ export default function FleetUsageCard({ fleet }) {
</span>
));

const toggleBilling = async (row) => {
const next = !row.usesSubscriptions;
setOverrides((prev) => ({ ...prev, [row.instanceId]: next }));
setPending((prev) => ({ ...prev, [row.instanceId]: true }));
const ok = await api.updateUsageFleetBilling(
{ instanceId: row.instanceId, usesSubscriptions: next },
{ silent: true },
).then(() => true).catch((err) => {
toast.error(err?.message || 'Failed to update instance billing');
return false;
});
if (!ok) {
// Revert the optimistic overlay; a successful save keeps it so a slow
// refetch can't flash the old combined total back onto the card.
setOverrides((prev) => {
const copy = { ...prev };
delete copy[row.instanceId];
return copy;
});
} else {
await onSaved?.();
}
setPending((prev) => {
const copy = { ...prev };
delete copy[row.instanceId];
return copy;
});
};

const rowClass = (row) => (row.usesSubscriptions ? 'text-white' : 'text-gray-500');

const instanceLabel = (row) => (
<>
<span className={`font-medium truncate ${rowClass(row)}`}>{row.name}</span>
{label(row)}
{!row.usesSubscriptions && <Pill tone="warning" size="xs" className="ml-2 shrink-0">API billed</Pill>}
</>
);

return (
<div className="bg-port-card border border-port-border rounded-xl p-3 sm:p-4 space-y-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2">
Expand All @@ -34,26 +129,30 @@ export default function FleetUsageCard({ fleet }) {
</h3>
<p className="text-[10px] sm:text-xs text-gray-500 mt-0.5">
Every federated instance&rsquo;s usage over this period, priced the same way.
Turn off Subscriptions on a machine that pays API rates so it stays listed but is left out of the combined total.
</p>
</div>
<div className="text-left sm:text-right">
<div className="text-xl font-bold text-port-success">{formatUsd(fleet.totals?.estimatedCost)}</div>
<div className="text-[10px] sm:text-xs text-gray-500">{rows.length} instances combined</div>
<div className="text-xl font-bold text-port-success">{formatUsd(view.totals?.estimatedCost)}</div>
<div className="text-[10px] sm:text-xs text-gray-500">{combinedLabel}</div>
</div>
</div>

{/* Mobile view (< sm): card list, so the numbers stay readable without a
horizontal scroll — same pairing the cost report uses. */}
<div className="block sm:hidden space-y-2">
{rows.map((row) => (
<div key={row.instanceId} className="bg-port-bg border border-port-border rounded-lg p-3 space-y-2">
{view.instances.map((row) => (
<div key={row.instanceId} className={`bg-port-bg border border-port-border rounded-lg p-3 space-y-2 ${row.usesSubscriptions ? '' : 'opacity-70'}`}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center min-w-0">
<span className="font-medium text-white text-sm truncate">{row.name}</span>
{label(row)}
<div className="flex items-center min-w-0 text-sm">
{instanceLabel(row)}
</div>
<span className="text-sm font-semibold text-port-success shrink-0">{formatUsd(row.totals?.estimatedCost)}</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-gray-500">Subscriptions</span>
<BillingToggle row={row} disabled={Boolean(pending[row.instanceId])} onToggle={toggleBilling} />
</div>
<div className="grid grid-cols-2 gap-2 text-xs bg-port-card/50 p-2 rounded border border-port-border/50">
<div>
<span className="text-gray-500 block text-[10px]">Sessions</span>
Expand All @@ -74,7 +173,7 @@ export default function FleetUsageCard({ fleet }) {
))}
<div className="bg-port-bg border border-port-border rounded-lg p-3 flex items-center justify-between text-xs font-semibold text-white">
<span>Fleet total</span>
<span className="text-port-success text-sm">{formatUsd(fleet.totals?.estimatedCost)}</span>
<span className="text-port-success text-sm">{formatUsd(view.totals?.estimatedCost)}</span>
</div>
</div>

Expand All @@ -84,6 +183,7 @@ export default function FleetUsageCard({ fleet }) {
<thead>
<tr className="text-left text-xs text-gray-500 border-b border-port-border">
<th className="py-2 pr-2 font-medium">Instance</th>
<th className="py-2 px-2 font-medium text-center" title="On your subscriptions. Turn off if this instance pays API rates.">Subscriptions</th>
<th className="py-2 px-2 font-medium text-right">Sessions</th>
<th className="py-2 px-2 font-medium text-right">Messages</th>
<th className="py-2 px-2 font-medium text-right">Tokens In</th>
Expand All @@ -92,11 +192,15 @@ export default function FleetUsageCard({ fleet }) {
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.instanceId} className="border-t border-port-border text-white">
{view.instances.map((row) => (
<tr key={row.instanceId} className={`border-t border-port-border ${rowClass(row)}`}>
<td className="py-2 pr-2">
<span className="font-medium truncate">{row.name}</span>
{label(row)}
{instanceLabel(row)}
</td>
<td className="py-2 px-2">
<div className="flex justify-center">
<BillingToggle row={row} disabled={Boolean(pending[row.instanceId])} onToggle={toggleBilling} />
</div>
</td>
<td className="py-2 px-2 text-right">{formatNumber(row.totals?.sessions)}</td>
<td className="py-2 px-2 text-right">{formatNumber(row.totals?.messages)}</td>
Expand All @@ -108,12 +212,12 @@ export default function FleetUsageCard({ fleet }) {
</tbody>
<tfoot>
<tr className="border-t border-port-border font-semibold text-white">
<td className="py-2 pr-2">Fleet total</td>
<td className="py-2 px-2 text-right">{formatNumber(fleet.totals?.sessions)}</td>
<td className="py-2 px-2 text-right">{formatNumber(fleet.totals?.messages)}</td>
<td className="py-2 px-2 text-right">{formatNumber(fleet.totals?.tokensIn)}</td>
<td className="py-2 px-2 text-right">{formatNumber(fleet.totals?.tokensOut)}</td>
<td className="py-2 pl-2 text-right text-port-success">{formatUsd(fleet.totals?.estimatedCost)}</td>
<td className="py-2 pr-2" colSpan={2}>Fleet total</td>
<td className="py-2 px-2 text-right">{formatNumber(view.totals?.sessions)}</td>
<td className="py-2 px-2 text-right">{formatNumber(view.totals?.messages)}</td>
<td className="py-2 px-2 text-right">{formatNumber(view.totals?.tokensIn)}</td>
<td className="py-2 px-2 text-right">{formatNumber(view.totals?.tokensOut)}</td>
<td className="py-2 pl-2 text-right text-port-success">{formatUsd(view.totals?.estimatedCost)}</td>
</tr>
</tfoot>
</table>
Expand Down
53 changes: 50 additions & 3 deletions client/src/components/usage/FleetUsageCard.test.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import FleetUsageCard from './FleetUsageCard';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import FleetUsageCard, { applyFleetBilling } from './FleetUsageCard';

const api = vi.hoisted(() => ({ updateUsageFleetBilling: vi.fn() }));
vi.mock('../../services/api', () => api);

const row = (overrides = {}) => ({
instanceId: 'inst-a',
name: 'Workshop',
self: false,
capturedAt: '2026-08-30T09:00:00.000Z',
usesSubscriptions: true,
totals: { sessions: 4, messages: 12, tokensIn: 1000, tokensOut: 2000, cacheReadTokens: 0, cacheWriteTokens: 0, estimatedCost: 3.5 },
...overrides,
});
Expand All @@ -19,6 +23,32 @@ const fleet = {
totals: { sessions: 8, messages: 24, tokensIn: 2000, tokensOut: 4000, cacheReadTokens: 0, cacheWriteTokens: 0, estimatedCost: 7 },
};

beforeEach(() => {
vi.clearAllMocks();
api.updateUsageFleetBilling.mockResolvedValue({ instanceId: 'inst-peer', usesSubscriptions: false, apiBilledInstanceIds: ['inst-peer'] });
});

describe('applyFleetBilling', () => {
it('drops an overridden API-billed row from the combined total but keeps it listed', () => {
const view = applyFleetBilling(fleet, { 'inst-peer': false });
expect(view.instances).toHaveLength(2);
expect(view.includedCount).toBe(1);
expect(view.totals.estimatedCost).toBe(3.5);
expect(view.totals.sessions).toBe(4);
});

// An older payload that predates the field must still count every row —
// missing is "on subscriptions", never "API billed".
it('treats a missing usesSubscriptions flag as included', () => {
const legacy = {
instances: [row({ usesSubscriptions: undefined }), row({ instanceId: 'inst-peer', name: 'Studio', usesSubscriptions: undefined })],
};
const view = applyFleetBilling(legacy);
expect(view.includedCount).toBe(2);
expect(view.totals.estimatedCost).toBe(7);
});
});

describe('FleetUsageCard', () => {
it('renders a row per instance plus the combined total', () => {
render(<FleetUsageCard fleet={fleet} />);
Expand All @@ -41,4 +71,21 @@ describe('FleetUsageCard', () => {
const { container: absent } = render(<FleetUsageCard fleet={undefined} />);
expect(absent).toBeEmptyDOMElement();
});

it('excludes an instance from the combined total when Subscriptions is turned off', async () => {
const onSaved = vi.fn().mockResolvedValue({});
render(<FleetUsageCard fleet={fleet} onSaved={onSaved} />);

fireEvent.click(screen.getAllByRole('switch', { name: 'Count Studio toward subscription totals' })[0]);

await waitFor(() => {
expect(api.updateUsageFleetBilling).toHaveBeenCalledWith(
{ instanceId: 'inst-peer', usesSubscriptions: false },
{ silent: true },
);
});
expect(screen.getByText('1 of 2 instances on subscriptions')).toBeInTheDocument();
expect(screen.getAllByText('API billed').length).toBeGreaterThan(0);
await waitFor(() => expect(onSaved).toHaveBeenCalled());
});
});
2 changes: 1 addition & 1 deletion client/src/pages/UsagePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -843,7 +843,7 @@ function InternalUsageMetrics() {

{/* Same window, split by machine — renders only once a peer's usage has
synced, so a single-machine install sees no change. */}
<FleetUsageCard fleet={usage.fleet} />
<FleetUsageCard fleet={usage.fleet} onSaved={fetchUsage} />

{/* Directly under the API estimate it is derived from: the estimate is the
opportunity cost, this is what the quota plans actually cost to avoid it. */}
Expand Down
2 changes: 1 addition & 1 deletion client/src/services/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ toasts on throw). **Custom catch ⇒ `silent: true`** — otherwise toasts fire
| `apiSchedules.js` | Automation schedules. |
| `apiQuotaBurn.js` | Quota Burn plan + live status, the job-type catalog its config form renders, and manual runs (`getQuotaBurn`/`getQuotaBurnCatalog`/`saveQuotaBurn`/`runQuotaBurn`), plus `rearmQuotaBurn` to put spent `run once` steps back into the rotation. |
| `apiRapidReader.js` | Rapid Reader's optional author-hosted Accelerando loader and machine-local shelf API. |
| `apiSystem.js` | System info (CPU/memory/ports/alerts/active processing and local hardware capabilities) + D&D-style character sheet getter, plus the usage cost report and explicit historical reconciliation (`getUsage`, `getProviderUsage`, `getUsageBackfillStatus`/`startUsageBackfill`, `updateSubscriptionCosts` for the subscription-vs-API savings comparison). |
| `apiSystem.js` | System info (CPU/memory/ports/alerts/active processing and local hardware capabilities) + D&D-style character sheet getter, plus the usage cost report and explicit historical reconciliation (`getUsage`, `getProviderUsage`, `getUsageBackfillStatus`/`startUsageBackfill`, `updateSubscriptionCosts` for the subscription-vs-API savings comparison, `updateUsageFleetBilling` to exclude an API-billed federated instance from Across Instances totals). |
| `apiAuth.js` | Optional login password — status, login, set/clear password. |
| `apiLoops.js` | Scheduled loops. |

Expand Down
9 changes: 9 additions & 0 deletions client/src/services/apiSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ export const getUsageBackfillStatus = (options = {}) => request('/usage/backfill
// the report (`getUsage().subscriptionSavings`), so there is no getter here.
export const updateSubscriptionCosts = (costs, options = {}) =>
request('/usage/subscriptions', { method: 'PUT', body: JSON.stringify({ costs }), ...options });
// Mark one federated instance as paying API rates (`usesSubscriptions: false`)
// or riding this install's subscriptions (`true`). The Across Instances
// combined total skips API-billed rows; the row itself stays listed.
export const updateUsageFleetBilling = ({ instanceId, usesSubscriptions }, options = {}) =>
request('/usage/fleet-billing', {
method: 'PUT',
body: JSON.stringify({ instanceId, usesSubscriptions }),
...options,
});
export const startUsageBackfill = (options = {}) => request('/usage/backfill', { method: 'POST', ...options });

// Subscription-quota status for every enabled provider family (claude, codex,
Expand Down
7 changes: 7 additions & 0 deletions docs/decisions/2026-09-01-federated-usage-metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ excluded on both privacy and payload grounds.
peer (`enabled: false`), which stops every direction — checked in
`hasAnySyncEnabled`, so it holds on the `peer:online` path too, not only in
the polling loop.
- An instance that pays API rates rather than the viewer's subscriptions can be
dropped from the Across Instances **combined total** via a per-row
Subscriptions toggle. The row stays listed (the spend is still real). The
choice is machine-local (`settings.usageApiBilledInstanceIds`) — it is this
install's view of which fleet members ride the plans, not a property of the
instance itself — and never rides the digest. Setting it on one machine does
not change the total another machine shows.
- **Removing a peer retires its digest via a tombstone**, not a plain delete.
Our snapshot forwards every digest we hold, so a surviving peer would hand a
deleted row straight back on the next cycle and a decommissioned machine's
Expand Down
12 changes: 10 additions & 2 deletions server/lib/apiRouteCatalog.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -16214,6 +16214,14 @@
"server/routes/usage.js"
]
},
{
"method": "PUT",
"path": "/api/usage/fleet-billing",
"mountPath": "/api/usage",
"sources": [
"server/routes/usage.js"
]
},
{
"method": "POST",
"path": "/api/usage/messages",
Expand Down Expand Up @@ -17417,8 +17425,8 @@
],
"stats": {
"mounts": 147,
"operations": 2157,
"declarations": 2161,
"operations": 2158,
"declarations": 2162,
"sourceFiles": 229
}
}
16 changes: 16 additions & 0 deletions server/lib/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -1522,6 +1522,22 @@ export const subscriptionCostsMapSchema = z.partialRecord(
/** Body for PUT /api/usage/subscriptions. */
export const subscriptionCostsSchema = z.object({ costs: subscriptionCostsMapSchema });

/**
* Instances that pay API rates rather than the viewer's subscriptions, so the
* Across Instances combined total can leave them out. Used by BOTH write
* paths — `PUT /api/usage/fleet-billing` (the per-row toggle) and the
* `usageApiBilledInstanceIds` slice of `PUT /api/settings` — so a restore
* dump can't write an unbounded or non-string list through the generic
* settings endpoint. Cap matches the stored peer-digest cap (64).
*/
export const usageApiBilledInstanceIdsSchema = z.array(z.string().min(1).max(200)).max(64);

/** Body for PUT /api/usage/fleet-billing. */
export const usageFleetBillingSchema = z.object({
instanceId: z.string().min(1).max(200),
usesSubscriptions: z.boolean(),
});


// =============================================================================
// PORTS
Expand Down
Loading