account: add Recent jobs table
Fetch /v1/jobs alongside the existing account data and render the last 25 in a table — model, status pill, provider, tokens, cost, timestamp. Mirrors the homelab gateway's /wallet job list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c538822e4f
commit
86e0ab356a
1 file changed
+76
-1
+76
-1
@@ -31,6 +31,21 @@ type ApiKey = {
|
|||||||
modelAllowlist: string[];
|
modelAllowlist: string[];
|
||||||
};
|
};
|
||||||
type NewApiKey = { id: string; name: string; key: string; createdAt: string };
|
type NewApiKey = { id: string; name: string; key: string; createdAt: string };
|
||||||
|
type Job = {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
modelId: string;
|
||||||
|
provider: string;
|
||||||
|
estimatedMaxXbzzWei: string;
|
||||||
|
actualXbzzWei: string | null;
|
||||||
|
promptTokens: number | null;
|
||||||
|
completionTokens: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
ackedAt: string | null;
|
||||||
|
deliveredAt: string | null;
|
||||||
|
claimedAt: string | null;
|
||||||
|
errorMessage: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return [{ title: 'Account · t4t Gateway' }];
|
return [{ title: 'Account · t4t Gateway' }];
|
||||||
@@ -75,6 +90,7 @@ export default function Account() {
|
|||||||
const [account, setAccount] = useState<Account | null>(null);
|
const [account, setAccount] = useState<Account | null>(null);
|
||||||
const [ledger, setLedger] = useState<LedgerEntry[]>([]);
|
const [ledger, setLedger] = useState<LedgerEntry[]>([]);
|
||||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||||
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [newKeyName, setNewKeyName] = useState('');
|
const [newKeyName, setNewKeyName] = useState('');
|
||||||
@@ -90,16 +106,18 @@ export default function Account() {
|
|||||||
}
|
}
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const [u, a, l, k] = await Promise.all([
|
const [u, a, l, k, j] = await Promise.all([
|
||||||
api<User>('/users/me'),
|
api<User>('/users/me'),
|
||||||
api<Account>('/accounts/me'),
|
api<Account>('/accounts/me'),
|
||||||
api<LedgerEntry[]>('/accounts/me/ledger'),
|
api<LedgerEntry[]>('/accounts/me/ledger'),
|
||||||
api<ApiKey[]>('/api-keys'),
|
api<ApiKey[]>('/api-keys'),
|
||||||
|
api<Job[]>('/v1/jobs?limit=50'),
|
||||||
]);
|
]);
|
||||||
setUser(u);
|
setUser(u);
|
||||||
setAccount(a);
|
setAccount(a);
|
||||||
setLedger(l);
|
setLedger(l);
|
||||||
setKeys(k);
|
setKeys(k);
|
||||||
|
setJobs(j);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -254,6 +272,63 @@ export default function Account() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className='mt-8'>
|
||||||
|
<h2 className='text-lg font-semibold'>Recent jobs</h2>
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<p className='mt-3 text-sm text-neutral-500'>No jobs yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className='mt-3 overflow-x-auto rounded-lg border border-neutral-200 bg-white'>
|
||||||
|
<table className='min-w-full text-sm'>
|
||||||
|
<thead className='border-b border-neutral-200 bg-neutral-50 text-left text-xs uppercase tracking-wide text-neutral-500'>
|
||||||
|
<tr>
|
||||||
|
<th className='px-4 py-2 font-medium'>Model</th>
|
||||||
|
<th className='px-4 py-2 font-medium'>Status</th>
|
||||||
|
<th className='px-4 py-2 font-medium'>Provider</th>
|
||||||
|
<th className='px-4 py-2 font-medium'>Tokens</th>
|
||||||
|
<th className='px-4 py-2 text-right font-medium'>xBZZ</th>
|
||||||
|
<th className='px-4 py-2 font-medium'>When</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className='divide-y divide-neutral-200'>
|
||||||
|
{jobs.slice(0, 25).map((j) => {
|
||||||
|
const tokens =
|
||||||
|
j.promptTokens !== null || j.completionTokens !== null
|
||||||
|
? `${j.promptTokens ?? '—'} / ${j.completionTokens ?? '—'}`
|
||||||
|
: '—';
|
||||||
|
const xbzz = j.actualXbzzWei
|
||||||
|
? formatXbzz(j.actualXbzzWei)
|
||||||
|
: `~${formatXbzz(j.estimatedMaxXbzzWei)}`;
|
||||||
|
const statusColor =
|
||||||
|
j.status === 'claimed' || j.status === 'delivered'
|
||||||
|
? 'bg-emerald-100 text-emerald-800'
|
||||||
|
: j.status === 'failed' || j.status === 'cancelled'
|
||||||
|
? 'bg-red-100 text-red-800'
|
||||||
|
: 'bg-neutral-100 text-neutral-700';
|
||||||
|
return (
|
||||||
|
<tr key={j.id}>
|
||||||
|
<td className='px-4 py-2 font-mono text-xs'>{j.modelId}</td>
|
||||||
|
<td className='px-4 py-2'>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusColor}`}>
|
||||||
|
{j.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className='px-4 py-2 font-mono text-xs text-neutral-500'>
|
||||||
|
{j.provider.slice(0, 6)}…{j.provider.slice(-4)}
|
||||||
|
</td>
|
||||||
|
<td className='px-4 py-2 font-mono text-xs'>{tokens}</td>
|
||||||
|
<td className='px-4 py-2 text-right font-mono text-xs'>{xbzz}</td>
|
||||||
|
<td className='px-4 py-2 text-xs text-neutral-500'>
|
||||||
|
{new Date(j.createdAt).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className='mt-8'>
|
<section className='mt-8'>
|
||||||
<h2 className='text-lg font-semibold'>Recent activity</h2>
|
<h2 className='text-lg font-semibold'>Recent activity</h2>
|
||||||
{ledger.length === 0 ? (
|
{ledger.length === 0 ? (
|
||||||
|
|||||||
Reference in new issue
Block a user