Show usage per machine, and say why a machine has none

The server now reports limits per machine, so the screen has to as well:
one card per machine that offers a paid service, named by the machine
first, because these are one account's numbers and which account is decided
by which box ran the session.

It also has to say which of four things happened, and the reason for
splitting them shows up here rather than in the data. A machine nobody has
logged in on is working exactly as somebody set it up, so it reads as a
plain statement in ordinary text -- marking it would be the interface
nagging about a decision already made, and would dilute the marks that do
mean something. Only "couldn't reach it" and "the endpoint refused" are
coloured as faults, and they say different things because they need
different things done. The old screen drew all three in the error colour.

No machine offering a paid service is not an error either: it says so
instead of drawing nothing.

The app also stopped parsing: `available` no longer exists and
`getBoolean` on a missing key throws, so this had to land with the server
change rather than after it. An older backend sending no `state` is read as
"failed" rather than "ok", since an empty card drawn as healthy is the
worse failure.

Looked at running, against five machines: local reporting notLoggedIn with
the backend's HOME emptied, loopback-over-ssh returning real windows beside
it, an unreachable host showing ssh's own message in red, and a machine
with no Claude provider correctly absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-29 06:18:46 -04:00
1 parent 6166b1f626
commit 206319045d
4 files changed
+109 -17

No files matched your search

+7
View File
@@ -57,6 +57,13 @@ repo is in PLAN.md's "Backend layout" section.
status. Status is the obvious signal and is wrong: a turn that starts and status. Status is the obvious signal and is wrong: a turn that starts and
finishes between two polls reads as idle at both, and its own output gets finishes between two polls reads as idle at both, and its own output gets
replayed on top of itself. That bug was visible on screen as `donedone`. replayed on top of itself. That bug was visible on screen as `donedone`.
- `server/src/usage.rs` — rate-limit windows, asked **of each machine that
can run Claude**, not of the backend. Credentials are read through the
session `Transport`, so a remote setup is an ssh round trip and the local
one is unchanged; the HTTP call stays here. A machine with no Claude
provider is never asked. The four states (`ok`, `notLoggedIn`,
`unreachable`, `failed`) exist because a machine nobody logged in on is a
choice rather than a fault, and one `error` string made it look like one.
- `server/src/models.rs` — downloaded GGUF models and the HuggingFace - `server/src/models.rs` — downloaded GGUF models and the HuggingFace
browsing behind them. Downloads are keyed by the model rather than by browsing behind them. Downloads are keyed by the model rather than by
who asked, so any device can watch one; they resume through HTTP Range, who asked, so any device can watch one; they resume through HTTP Range,
+21 -2
View File
@@ -386,10 +386,29 @@ credential store (`~/.claude/.credentials.json`), headers
Poll at ≥180 s, only while any Claude session exists or the usage screen is Poll at ≥180 s, only while any Claude session exists or the usage screen is
open, cache the last answer. Surface: 5-hour and weekly window utilization % open, cache the last answer. Surface: 5-hour and weekly window utilization %
and reset times. It's undocumented, so `usage.rs` treats every field as and reset times. It's undocumented, so `usage.rs` treats every field as
optional and degrades to "unavailable" rather than erroring. Structure it as optional and degrades rather than erroring. Structure it as one
one `UsageProvider` per paid service so a second service later is a new impl, `UsageProvider` per paid service so a second service later is a new impl,
not a parallel screen (rule 9). not a parallel screen (rule 9).
**Per machine, not per backend (decided 2026-08-29).** The credential store
that matters is the one on the machine the session runs on, because that is
the account being billed. Reading this machine's was right only while the
backend and the CLI were the same box — and in the layout this is aiming
at they are not: `ai-server` belongs on the host, the host has no `claude`
CLI, and the CLI machine is a remote. So credentials are read through the
session `Transport` (`ssh host sh -c 'cat $HOME/…'`, `$HOME` expanded by
the far shell because a path built locally is the wrong home), one snapshot
per setup that offers Claude, cached per machine. The HTTP call stays on
the backend rather than running remotely, so the far end needs nothing but
a shell.
The snapshot says which of four things happened rather than carrying a flag
and a message: `ok`, `notLoggedIn`, `unreachable`, `failed`. The one that
matters is `notLoggedIn` — a machine nobody put an account on is working as
configured, and collapsing it into an error string made a healthy setup
read as broken. A machine with no Claude provider is not asked and gets no
row at all.
### HTTP surface (phone ⇄ backend) ### HTTP surface (phone ⇄ backend)
REST for actions, one SSE stream per open session screen for events, all over REST for actions, one SSE stream per open session screen for events, all over
@@ -418,9 +418,21 @@ data class UsageWindow(
data class UsageSnapshot( data class UsageSnapshot(
val provider: String, val provider: String,
val available: Boolean, /** Stable id of the machine these numbers belong to. */
val setup: String,
/** That machine's current label. */
val setupName: String,
/**
* What came back: "ok", "notLoggedIn", "unreachable" or "failed".
*
* Four rather than a flag, because the screen has to treat them differently. "notLoggedIn" is a
* machine somebody chose not to put an account on -- a fact, not a fault -- while the other two
* are faults worth chasing. Collapsing them made a healthy setup read as broken.
*/
val state: String,
/** Why, for the two states that are faults. Absent otherwise. */
val detail: String?,
val windows: List<UsageWindow>, val windows: List<UsageWindow>,
val error: String?,
) )
/** The backend caches; refreshing more often than its poll interval just re-reads the cache. */ /** The backend caches; refreshing more often than its poll interval just re-reads the cache. */
@@ -429,8 +441,12 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
connection.jsonObjects { snapshot -> connection.jsonObjects { snapshot ->
UsageSnapshot( UsageSnapshot(
provider = snapshot.getString("provider"), provider = snapshot.getString("provider"),
available = snapshot.getBoolean("available"), setup = snapshot.optString("setup"),
error = snapshot.optString("error").ifEmpty { null }, setupName = snapshot.optString("setupName"),
// Unknown to an older backend, and unknown is not "fine": defaulting to "ok"
// would draw an empty card as a healthy one.
state = snapshot.optString("state").ifEmpty { "failed" },
detail = snapshot.optString("detail").ifEmpty { null },
windows = windows =
snapshot.getJSONArray("windows").mapObjects { window -> snapshot.getJSONArray("windows").mapObjects { window ->
UsageWindow( UsageWindow(
@@ -53,10 +53,11 @@ fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
// Deliberately not subtitled with the provider this was opened from. These numbers // Deliberately not subtitled with the provider this was opened from. These numbers
// are the *account's*, reported by whichever paid service answered -- naming the // belong to an account on a particular machine, reported by whichever paid service
// session's provider here made an echo session's screen read "echo" above a card // answered there -- naming the session's provider here made an echo session's
// reading "claude", which is a claim about echo that nothing measured. Each card // screen read "echo" above a card reading "claude", which is a claim about echo
// names the service it came from, which is the true scope. // that nothing measured. Each card names the machine and the service it came from,
// which is the true scope.
Text( Text(
"Usage", "Usage",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
@@ -71,18 +72,31 @@ fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) {
is LoadState.Loading -> CircularProgressIndicator() is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> is LoadState.Loaded ->
if (current.value.isEmpty()) {
// Not an error and not a blank screen: no machine offers a paid service,
// so there is genuinely nothing to report and saying so is the answer.
Text(
"No machine here runs anything with usage limits.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
current.value.forEach { snapshot -> current.value.forEach { snapshot ->
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) { Column(Modifier.padding(16.dp)) {
Text(snapshot.provider, style = MaterialTheme.typography.titleMedium) // The machine first: these are one account's numbers, and
Spacer(Modifier.height(8.dp)) // which account is decided by which machine ran the session.
if (!snapshot.available) {
Text( Text(
snapshot.error ?: "Unavailable", snapshot.setupName.ifEmpty { snapshot.setup },
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.bodyMedium,
) )
} Text(
snapshot.provider,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
SnapshotState(snapshot)
snapshot.windows.forEach { window -> snapshot.windows.forEach { window ->
WindowBar(window) WindowBar(window)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
@@ -93,6 +107,42 @@ fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) {
} }
} }
} }
}
}
/**
* Anything other than numbers: why this machine has none.
*
* The distinction the old single message could not draw. A machine nobody has logged in on is
* working exactly as somebody set it up, so it reads as a plain statement -- marking it would be
* the interface nagging about a decision already made, and would dilute the marks that do mean
* something. Only the two faults are coloured as faults.
*/
@Composable
private fun SnapshotState(snapshot: UsageSnapshot) {
when (snapshot.state) {
"ok" -> {}
"notLoggedIn" ->
Text(
"No Claude account on this machine.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Reached but refused, versus never reached at all: different things to go and do,
// so they say different things rather than sharing one "unavailable".
"failed" ->
Text(
snapshot.detail ?: "Couldn't read the limits from this machine.",
style = MaterialTheme.typography.bodyMedium,
color = failedColor,
)
else ->
Text(
snapshot.detail ?: "Couldn't reach this machine.",
style = MaterialTheme.typography.bodyMedium,
color = failedColor,
)
}
} }
@Composable @Composable