Skip to content

Commit ef448e4

Browse files
committed
fix(machine): correct usage display and runtime status
1 parent 39db878 commit ef448e4

17 files changed

Lines changed: 229 additions & 96 deletions

apps/web/server/machineRepo.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,10 @@ export async function updateMachine(db: D1, machineId: string, ownerId: string,
4848
sets.push("runtimes = ?");
4949
binds.push(JSON.stringify(normalizeMachineRuntimes(info.runtimes, now)));
5050
}
51-
if (info.usage_info) {
51+
if ("usage_info" in info) {
52+
const usageInfo = info.usage_info;
5253
sets.push("usage_info = ?");
53-
binds.push(JSON.stringify(info.usage_info));
54+
binds.push(usageInfo == null ? null : JSON.stringify(normalizeUsageInfo(usageInfo)));
5455
}
5556

5657
binds.push(machineId, ownerId);
@@ -132,9 +133,20 @@ export async function listAllMachines(db: D1): Promise<AdminMachine[]> {
132133
function parseMachine<T extends Machine>(row: T): T {
133134
const parsed = parseJsonFields(row, ["runtimes", "usage_info"]);
134135
parsed.runtimes = normalizeMachineRuntimes(parsed.runtimes ?? [], parsed.last_heartbeat_at ?? parsed.created_at);
136+
if (parsed.usage_info) parsed.usage_info = normalizeUsageInfo(parsed.usage_info);
135137
return parsed;
136138
}
137139

140+
function normalizeUsageInfo(info: UsageInfo): UsageInfo {
141+
return {
142+
...info,
143+
windows: info.windows.map((window) => ({
144+
...window,
145+
utilization: window.utilization < 1 ? window.utilization * 100 : window.utilization,
146+
})),
147+
};
148+
}
149+
138150
const RUNTIME_BY_LABEL = Object.fromEntries(Object.entries(RUNTIME_LABELS).map(([runtime, label]) => [label, runtime])) as Record<
139151
string,
140152
AgentRuntime

apps/web/server/routes.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,16 @@ import type { D1 } from "./db";
4444
import { addAgentEmail, getGithubToken, removeAgentEmail, syncGpgKey } from "./githubService";
4545
import { getArmoredPrivateKey, getRootKeyInfo, getRootPublicKey, getSubkeyIds } from "./gpgKeyRepo";
4646
import { createLogger } from "./logger";
47-
import { deleteMachine, getMachine, listAllMachines, listMachines, normalizeMachineRuntimes, updateMachine, upsertMachine } from "./machineRepo";
47+
import {
48+
deleteMachine,
49+
detectStaleMachines,
50+
getMachine,
51+
listAllMachines,
52+
listMachines,
53+
normalizeMachineRuntimes,
54+
updateMachine,
55+
upsertMachine,
56+
} from "./machineRepo";
4857
import { createMailbox, deleteMailbox, getEmail, getInbox } from "./mailsService";
4958
import { createMessage, listMessages } from "./messageRepo";
5059
import { metricsMiddleware } from "./metrics";
@@ -428,11 +437,13 @@ api.post("/api/machines/:id/heartbeat", async (c) => {
428437
});
429438

430439
api.get("/api/machines", async (c) => {
440+
await detectStaleMachines(c.env.DB);
431441
const machines = await listMachines(c.env.DB, c.get("ownerId"));
432442
return c.json(machines);
433443
});
434444

435445
api.get("/api/machines/:id", async (c) => {
446+
await detectStaleMachines(c.env.DB);
436447
const machine = await getMachine(c.env.DB, c.req.param("id"), c.get("ownerId"));
437448
if (!machine) throw new HTTPException(404, { message: "Machine not found" });
438449
return c.json(machine);

apps/web/src/components/AddMachineSteps.tsx

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { type MachineRuntime, RUNTIME_LABELS } from "@agent-kanban/shared";
21
import { useQueryClient } from "@tanstack/react-query";
32
import { useCallback, useEffect, useRef, useState } from "react";
43
import { api } from "../lib/api";
54
import { getAuthToken } from "../lib/auth-client";
5+
import { MachineRuntimeBadges } from "./MachineRuntimes";
66
import { Button } from "./ui/button";
77

88
interface AddMachineStepsProps {
@@ -12,10 +12,6 @@ interface AddMachineStepsProps {
1212
onConnected?: (machine: any) => void;
1313
}
1414

15-
function runtimeLabel(runtime: MachineRuntime): string {
16-
return `${RUNTIME_LABELS[runtime.name] ?? runtime.name}:${runtime.status}`;
17-
}
18-
1915
export function AddMachineSteps({ apiKey, apiKeyId, onDone, onConnected }: AddMachineStepsProps) {
2016
const [connected, setConnected] = useState(false);
2117
const [connectedMachine, setConnectedMachine] = useState<any>(null);
@@ -79,12 +75,8 @@ export function AddMachineSteps({ apiKey, apiKeyId, onDone, onConnected }: AddMa
7975
{connectedMachine.runtimes && (
8076
<div className="flex items-center justify-between">
8177
<span className="text-[11px] text-content-tertiary uppercase tracking-wide">Runtimes</span>
82-
<div className="flex gap-1">
83-
{connectedMachine.runtimes.map((runtime: MachineRuntime) => (
84-
<span key={runtime.name} className="text-[10px] font-mono text-accent bg-accent-soft px-1.5 py-0.5 rounded">
85-
{runtimeLabel(runtime)}
86-
</span>
87-
))}
78+
<div className="max-w-[70%]">
79+
<MachineRuntimeBadges runtimes={connectedMachine.runtimes} />
8880
</div>
8981
</div>
9082
)}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { type MachineRuntime, RUNTIME_LABELS } from "@agent-kanban/shared";
2+
import { cn } from "../lib/utils";
3+
4+
const runtimeStatusStyles: Record<MachineRuntime["status"], { dot: string; text: string; label: string }> = {
5+
ready: {
6+
dot: "bg-success",
7+
text: "text-success",
8+
label: "Ready",
9+
},
10+
limited: {
11+
dot: "bg-warning",
12+
text: "text-warning",
13+
label: "Limited",
14+
},
15+
unauthorized: {
16+
dot: "bg-error",
17+
text: "text-error",
18+
label: "Unauthorized",
19+
},
20+
unhealthy: {
21+
dot: "bg-error",
22+
text: "text-error",
23+
label: "Unhealthy",
24+
},
25+
missing: {
26+
dot: "bg-content-tertiary",
27+
text: "text-content-tertiary",
28+
label: "Missing",
29+
},
30+
};
31+
32+
function RuntimeStatus({ runtime }: { runtime: MachineRuntime }) {
33+
const style = runtimeStatusStyles[runtime.status];
34+
return (
35+
<span className={cn("inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-[10px] font-medium", style.text)}>
36+
<span className={cn("size-1.5 rounded-full", style.dot)} />
37+
{style.label}
38+
</span>
39+
);
40+
}
41+
42+
export function MachineRuntimeBadges({ runtimes }: { runtimes: MachineRuntime[] }) {
43+
if (runtimes.length === 0) {
44+
return <span className="text-[10px] font-mono text-content-tertiary">No runtimes</span>;
45+
}
46+
47+
return (
48+
<div className="flex flex-wrap justify-end gap-1.5">
49+
{runtimes.map((runtime) => (
50+
<span key={runtime.name} className="inline-flex items-center gap-1.5 rounded bg-surface-tertiary px-2 py-1">
51+
<span className="font-mono text-[10px] text-content-primary">{RUNTIME_LABELS[runtime.name] ?? runtime.name}</span>
52+
<RuntimeStatus runtime={runtime} />
53+
</span>
54+
))}
55+
</div>
56+
);
57+
}
58+
59+
export function MachineRuntimeList({ runtimes }: { runtimes: MachineRuntime[] }) {
60+
if (runtimes.length === 0) {
61+
return <span className="text-[11px] font-mono text-content-tertiary">No runtimes detected</span>;
62+
}
63+
64+
return (
65+
<div className="divide-y divide-border">
66+
{runtimes.map((runtime) => (
67+
<div key={runtime.name} className="grid gap-2 py-2 sm:grid-cols-[1fr_auto] sm:items-center">
68+
<div className="min-w-0">
69+
<div className="font-mono text-xs text-content-primary">{RUNTIME_LABELS[runtime.name] ?? runtime.name}</div>
70+
{runtime.detail && <div className="mt-0.5 truncate text-[11px] text-content-tertiary">{runtime.detail}</div>}
71+
</div>
72+
<RuntimeStatus runtime={runtime} />
73+
</div>
74+
))}
75+
</div>
76+
);
77+
}

apps/web/src/routes/MachineDetailPage.tsx

Lines changed: 21 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { type MachineRuntime, RUNTIME_LABELS, type UsageWindow } from "@agent-kanban/shared";
1+
import { RUNTIME_LABELS, type UsageWindow } from "@agent-kanban/shared";
2+
import dayjs from "dayjs";
23
import { useState } from "react";
34
import { Link, useNavigate, useParams } from "react-router-dom";
45
import { Header } from "../components/Header";
6+
import { MachineRuntimeList } from "../components/MachineRuntimes";
57
import { formatRelative } from "../components/TaskDetailFields";
68
import { Button } from "../components/ui/button";
79
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog";
@@ -13,13 +15,16 @@ function usageBarColor(pct: number): string {
1315
return "bg-success";
1416
}
1517

16-
function formatResetCountdown(resetsAt: string): string {
17-
const diff = new Date(resetsAt).getTime() - Date.now();
18-
if (diff <= 0) return "resetting...";
19-
const h = Math.floor(diff / 3600000);
20-
const m = Math.floor((diff % 3600000) / 60000);
21-
if (h > 0) return `${h}h ${m}m`;
22-
return `${m}m`;
18+
function usagePercent(window: UsageWindow): number {
19+
return Math.round(window.utilization < 1 ? window.utilization * 100 : window.utilization);
20+
}
21+
22+
function formatResetTime(resetsAt: string): string {
23+
return dayjs(resetsAt).format("MMM D, YYYY h:mm A");
24+
}
25+
26+
function isPendingReset(window: UsageWindow): boolean {
27+
return new Date(window.resets_at).getTime() > Date.now();
2328
}
2429

2530
const statusDotColors: Record<string, string> = {
@@ -33,18 +38,6 @@ const agentStatusDotColors: Record<string, string> = {
3338
offline: "bg-warning",
3439
};
3540

36-
const runtimeStatusColors: Record<string, string> = {
37-
ready: "text-accent bg-accent-soft",
38-
limited: "text-warning bg-warning/10",
39-
unauthorized: "text-error bg-error/10",
40-
unhealthy: "text-error bg-error/10",
41-
missing: "text-content-tertiary bg-surface-tertiary",
42-
};
43-
44-
function runtimeLabel(runtime: MachineRuntime): string {
45-
return `${RUNTIME_LABELS[runtime.name] ?? runtime.name}:${runtime.status}`;
46-
}
47-
4841
export function MachineDetailPage() {
4942
const { id } = useParams<{ id: string }>();
5043
const navigate = useNavigate();
@@ -85,6 +78,7 @@ export function MachineDetailPage() {
8578
const isOffline = machine.status === "offline";
8679
const apiUrl = window.location.origin;
8780
const runtimes = machine.runtimes || [];
81+
const usageWindows = ((machine.usage_info?.windows ?? []) as UsageWindow[]).filter(isPendingReset);
8882

8983
return (
9084
<div className="min-h-screen bg-surface-primary">
@@ -135,17 +129,7 @@ export function MachineDetailPage() {
135129
</div>
136130
<div>
137131
<span className="text-[11px] text-content-tertiary uppercase tracking-wide block mb-1.5">Runtimes</span>
138-
{runtimes.length > 0 ? (
139-
<div className="flex gap-1.5 flex-wrap">
140-
{runtimes.map((runtime: MachineRuntime) => (
141-
<span key={runtime.name} className={`text-[11px] font-mono px-2 py-0.5 rounded ${runtimeStatusColors[runtime.status]}`}>
142-
{runtimeLabel(runtime)}
143-
</span>
144-
))}
145-
</div>
146-
) : (
147-
<span className="text-[11px] font-mono text-content-tertiary">No runtimes detected</span>
148-
)}
132+
<MachineRuntimeList runtimes={runtimes} />
149133
</div>
150134
</div>
151135

@@ -162,7 +146,7 @@ export function MachineDetailPage() {
162146
</div>
163147

164148
{/* Usage quota */}
165-
{machine.usage_info && machine.usage_info.windows.length > 0 && (
149+
{machine.usage_info && usageWindows.length > 0 && (
166150
<div className="bg-surface-secondary border border-border rounded-lg px-5 py-4 space-y-3">
167151
<div className="flex items-center justify-between">
168152
<span className="text-[11px] font-medium text-content-tertiary uppercase tracking-wide">Usage</span>
@@ -171,7 +155,7 @@ export function MachineDetailPage() {
171155
</span>
172156
</div>
173157
<div className="space-y-2.5">
174-
{(machine.usage_info.windows as UsageWindow[]).map((w, i) => (
158+
{usageWindows.map((w, i) => (
175159
<div key={`${w.runtime}-${i}`}>
176160
<div className="flex items-center justify-between mb-1">
177161
<div className="flex items-center gap-1.5">
@@ -181,14 +165,14 @@ export function MachineDetailPage() {
181165
<span className="text-xs text-content-secondary">{w.label}</span>
182166
</div>
183167
<div className="flex items-center gap-2">
184-
<span className="font-mono text-xs text-content-primary">{Math.round(w.utilization)}%</span>
185-
<span className="text-[11px] text-content-tertiary">resets {formatResetCountdown(w.resets_at)}</span>
168+
<span className="font-mono text-xs text-content-primary">{usagePercent(w)}%</span>
169+
<span className="text-[11px] text-content-tertiary">Resets {formatResetTime(w.resets_at)}</span>
186170
</div>
187171
</div>
188172
<div className="h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
189173
<div
190-
className={`h-full rounded-full transition-all ${usageBarColor(w.utilization)}`}
191-
style={{ width: `${Math.min(w.utilization, 100)}%` }}
174+
className={`h-full rounded-full transition-all ${usageBarColor(usagePercent(w))}`}
175+
style={{ width: `${Math.min(usagePercent(w), 100)}%` }}
192176
/>
193177
</div>
194178
</div>

apps/web/src/routes/MachinesPage.tsx

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { type MachineRuntime, RUNTIME_LABELS } from "@agent-kanban/shared";
21
import { useCallback, useState } from "react";
32
import { Link } from "react-router-dom";
43
import { AddMachineSteps } from "../components/AddMachineSteps";
54
import { Header } from "../components/Header";
5+
import { MachineRuntimeBadges } from "../components/MachineRuntimes";
66
import { formatRelative } from "../components/TaskDetailFields";
77
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog";
88
import { useMachines } from "../hooks/useMachines";
@@ -13,18 +13,6 @@ const statusDotColors: Record<string, string> = {
1313
offline: "bg-content-tertiary",
1414
};
1515

16-
const runtimeStatusColors: Record<string, string> = {
17-
ready: "text-accent bg-accent-soft",
18-
limited: "text-warning bg-warning/10",
19-
unauthorized: "text-error bg-error/10",
20-
unhealthy: "text-error bg-error/10",
21-
missing: "text-content-tertiary bg-surface-tertiary",
22-
};
23-
24-
function runtimeLabel(runtime: MachineRuntime): string {
25-
return `${RUNTIME_LABELS[runtime.name] ?? runtime.name}:${runtime.status}`;
26-
}
27-
2816
type DialogStep = "choose" | "waiting";
2917

3018
function randomName() {
@@ -139,16 +127,8 @@ export function MachinesPage() {
139127
{machine.last_heartbeat_at ? formatRelative(machine.last_heartbeat_at) : "—"}
140128
</span>
141129
</div>
142-
<div className="flex gap-1 ml-auto">
143-
{machine.runtimes?.length > 0 ? (
144-
machine.runtimes.map((runtime: MachineRuntime) => (
145-
<span key={runtime.name} className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${runtimeStatusColors[runtime.status]}`}>
146-
{runtimeLabel(runtime)}
147-
</span>
148-
))
149-
) : (
150-
<span className="text-[10px] font-mono text-content-tertiary">No runtimes</span>
151-
)}
130+
<div className="ml-auto max-w-[45%]">
131+
<MachineRuntimeBadges runtimes={machine.runtimes ?? []} />
152132
</div>
153133
</div>
154134
</Link>

packages/cli/src/providers/claude.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,12 @@ export const claudeProvider: AgentProvider = {
474474
const data = (await res.json()) as Record<string, { utilization: number; resets_at: string }>;
475475
const windows: UsageWindow[] = Object.entries(CLAUDE_WINDOW_LABELS)
476476
.filter(([key]) => data[key])
477-
.map(([key, label]) => ({ runtime: "claude", label, ...data[key] }));
477+
.map(([key, label]) => ({
478+
runtime: "claude",
479+
label,
480+
resets_at: data[key].resets_at,
481+
utilization: normalizeUsagePercent(data[key].utilization),
482+
}));
478483
return { windows, updated_at: new Date().toISOString() };
479484
},
480485

@@ -498,3 +503,7 @@ export const claudeProvider: AgentProvider = {
498503
return events;
499504
},
500505
};
506+
507+
function normalizeUsagePercent(value: number): number {
508+
return value < 1 ? value * 100 : value;
509+
}

packages/cli/src/providers/codex.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,15 +289,15 @@ export const codexProvider: AgentProvider = {
289289
windows.push({
290290
runtime: "codex",
291291
label: windowLabel(rl.primary_window.limit_window_seconds),
292-
utilization: rl.primary_window.used_percent / 100,
292+
utilization: rl.primary_window.used_percent,
293293
resets_at: new Date(rl.primary_window.reset_at * 1000).toISOString(),
294294
});
295295
}
296296
if (rl?.secondary_window) {
297297
windows.push({
298298
runtime: "codex",
299299
label: windowLabel(rl.secondary_window.limit_window_seconds),
300-
utilization: rl.secondary_window.used_percent / 100,
300+
utilization: rl.secondary_window.used_percent,
301301
resets_at: new Date(rl.secondary_window.reset_at * 1000).toISOString(),
302302
});
303303
}

packages/cli/src/providers/copilot.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,7 @@ export const copilotProvider: AgentProvider = {
453453
.map(([key, label]) => ({
454454
runtime: "copilot" as const,
455455
label,
456-
utilization: 1 - snapshots[key].percent_remaining / 100,
456+
utilization: Number((100 - snapshots[key].percent_remaining).toFixed(2)),
457457
resets_at,
458458
}));
459459

0 commit comments

Comments
 (0)