diff --git a/public/admin/app-accounts-render.js b/public/admin/app-accounts-render.js index eea8544..75a067f 100644 --- a/public/admin/app-accounts-render.js +++ b/public/admin/app-accounts-render.js @@ -134,6 +134,11 @@ function limitSummaryText(account, windows) { .join(" \u00b7 "); } +const UPDATING_HTML = + 'Updating limits\u2026'; +const UNCONFIRMED_TAG = + 'Couldn\'t confirm yet'; + function accountRowHtml(account, serving, grouped = false) { const name = accountName(account); const open = state.expandedAccountId === account.id; @@ -152,12 +157,20 @@ function accountRowHtml(account, serving, grouped = false) { const resetSeverity = reset ? limitSeverity(reset.percent) : ""; const countdown = resetCountdown(reset?.resetsAt); const usage = accountUsageForId(account.id); - const phoneParts = [ - tokenNote && `${tokenNote}`, + const wait = state.limitsUpdating.get(account.id); + const updating = wait && !wait.timedOut; + const limitParts = [ limitSummaryText(account, windows), reset && `${escapeHtml(reset.label)} resets ${escapeHtml(countdown)}`, ].filter(Boolean); + const phoneParts = [ + tokenNote && `${tokenNote}`, + updating && UPDATING_HTML, + updating + ? `${limitParts.join(" \u00b7 ")}` + : limitParts.join(" \u00b7 "), + ].filter(Boolean); const cursor = state.cursorAccountId === account.id ? " cursor" : ""; const credits = resetOfferFor(account); const expiring = serving ? expiringResets(account) : null; @@ -168,9 +181,9 @@ function accountRowHtml(account, serving, grouped = false) { : ""; return `
- ${escapeHtml(name)}${account.isPrimary ? 'Primary' : ""}${isFreeTierAccount(account) ? `${grouped ? escapeHtml(providerName(account.provider)) : "Free"}` : ""}${account.enabled === false ? 'Disabled' : ""}${creditHint}${tokenNote ? `${tokenNote}` : ""} - ${limitMetersHtml(account, windows)} - ${reset ? `${escapeHtml(reset.label)} ` : ""}${escapeHtml(countdown)} + ${escapeHtml(name)}${account.isPrimary ? 'Primary' : ""}${isFreeTierAccount(account) ? `${grouped ? escapeHtml(providerName(account.provider)) : "Free"}` : ""}${account.enabled === false ? 'Disabled' : ""}${creditHint}${wait?.timedOut ? UNCONFIRMED_TAG : ""}${tokenNote ? `${tokenNote}` : ""} + ${limitMetersHtml(account, windows)} + ${updating ? `${UPDATING_HTML}` : `${reset ? `${escapeHtml(reset.label)} ` : ""}${escapeHtml(countdown)}`} ${requestsCellHtml(usage)} ${phoneParts.join(" \u00b7 ")} @@ -189,21 +202,21 @@ function resetText(window) { function limitFacts(account) { const quota = account.quota; const windows = accountWindows(account); - const reset = resetCreditsDetail(account, windows); + const wait = state.limitsUpdating.get(account.id); + const stale = wait && !wait.timedOut ? " stale" : ""; if (!quota) return [ fact("Rate limits", '\u2014', [ "waiting for the first sync", ]), - reset?.fallback, ]; const facts = windows.map((window) => { const severity = limitSeverity(window.percent); return fact( escapeHtml(window.named ? window.label : `${window.label} window`), - `${Math.round(window.percent)}%`, - [escapeHtml(resetText(window)), reset?.window === window && reset.line], - `` + `${Math.round(window.percent)}%`, + [escapeHtml(resetText(window))], + `` ); }); if (isRateLimitedWithData(quota)) @@ -224,7 +237,6 @@ function limitFacts(account) { ); else if (!facts.length) facts.push(fact("Rate limits", 'None reported')); - if (reset?.fallback) facts.push(reset.fallback); return facts; } @@ -366,9 +378,11 @@ function accountDetailHtml(account) { account.provider === "codex" || account.provider === "claude"; const button = (label, action, shortcut, cls = "btn btn-ghost btn-sm") => ``; - const synced = account.quota?.fetchedAt - ? `${limitsSourceText(account.quota)}` + const wait = state.limitsUpdating.get(account.id); + let synced = account.quota?.fetchedAt + ? `${limitsSourceText(account.quota)}${wait?.timedOut ? " \u00b7 couldn't confirm the new limits yet" : ""}` : ""; + if (wait && !wait.timedOut) synced = UPDATING_HTML; const entering = state.enteringDetail === `account:${account.id}` ? " entering" : ""; return `
@@ -379,6 +393,7 @@ function accountDetailHtml(account) { tokensFact(accountUsageForId(account.id)), accountFact(account), ].join("")}
+ ${resetCreditsDetail(account)} ${hasDailyTokens(account.id) ? `
${dailyTokensBodyHtml(account.id)}
` : ""} ${hasTraffic(accountUsageForId(account.id)) ? `
${usageSlotHtml("account", account.id)}
` : ""}
diff --git a/public/admin/app-accounts.js b/public/admin/app-accounts.js index 0be9429..7e75e07 100644 --- a/public/admin/app-accounts.js +++ b/public/admin/app-accounts.js @@ -33,6 +33,38 @@ import { switchToTab, } from "./app-usage.js"; +const RESET_CONFIRM_RELOAD_MS = 6000; +const RESET_CONFIRM_TIMEOUT_MS = 20_000; + +// Ends the wait once a limits read newer than the one shown at the reset +// arrives. +function settleLimitsUpdating() { + for (const [id, wait] of state.limitsUpdating) { + const fetchedAt = state.accountsById.get(id)?.quota?.fetchedAt; + if (!state.accountsById.has(id) || (fetchedAt ?? 0) > wait.after) + state.limitsUpdating.delete(id); + } +} + +// Marks the account's limits as updating until a read taken after +// `respondedAt` (server time) arrives, re-reading the accounts twice and +// giving up quietly at the second read. +function awaitLimitsAfterReset(accountId, respondedAt) { + const wait = { after: respondedAt, timedOut: false }; + state.limitsUpdating.set(accountId, wait); + renderAccounts(); + const reload = () => { + loadAccounts().catch(() => undefined); + }; + setTimeout(reload, RESET_CONFIRM_RELOAD_MS); + setTimeout(() => { + if (state.limitsUpdating.get(accountId) !== wait) return; + wait.timedOut = true; + renderAccounts(); + reload(); + }, RESET_CONFIRM_TIMEOUT_MS); +} + async function loadAccounts() { const requestSeq = ++state.accountsRequestSeq; invalidateUsageDetails("account"); @@ -61,8 +93,12 @@ async function loadAccounts() { claudeResets: account.quota ? account.claudeResets : state.accountsById.get(account.id)?.claudeResets, + codexResets: account.quota + ? account.codexResets + : state.accountsById.get(account.id)?.codexResets, })); state.accountsById = usageMapFromList(state.accounts, "id"); + settleLimitsUpdating(); pruneUsageDetails("account", state.accountsById); pruneUsageDetails("daily", state.accountsById); state.providerStatuses = accountsResult.value.providers || []; @@ -118,7 +154,7 @@ async function loadMissingQuotas(requestSeq, missingQuotas) { ); if (!missing) return; try { - const { quotas, profiles, claudeResets } = await api( + const { quotas, profiles, claudeResets, codexResets } = await api( "/admin/accounts/quotas" ); if (requestSeq !== state.accountsRequestSeq) return; @@ -138,7 +174,12 @@ async function loadMissingQuotas(requestSeq, missingQuotas) { account.claudeResets = claudeResets[account.id]; changed = true; } + if (codexResets && account.id in codexResets) { + account.codexResets = codexResets[account.id]; + changed = true; + } } + settleLimitsUpdating(); if (changed) { renderAccounts(); if (state.keysLoaded) renderKeys(); @@ -289,16 +330,19 @@ async function redeemResetCreditAction(request, accountId) { if (!confirmed) return; let feedback; + let respondedAt = Date.now(); try { - const { result } = await request( + const response = await request( `/admin/accounts/${accountId}/tracking/${account.provider}/reset-credits/consume`, { method: "POST", body: JSON.stringify({ resetId: offer.resetId }) } ); - feedback = resetProviders[account.provider].resultToast(result); + feedback = resetProviders[account.provider].resultToast(response.result); + respondedAt = response.respondedAt ?? respondedAt; } catch (error) { feedback = { message: error.message, type: "error" }; } if (!request.isCurrent()) return; + if (feedback.changed) awaitLimitsAfterReset(accountId, respondedAt); const refreshed = await loadAccounts(); if (!request.isCurrent()) return; const { message, type } = resetFeedback( diff --git a/public/admin/app-claude-resets.js b/public/admin/app-claude-resets.js index aaeff7b..12c607f 100644 --- a/public/admin/app-claude-resets.js +++ b/public/admin/app-claude-resets.js @@ -31,12 +31,10 @@ function expiryText(endsAt) { } const UNAVAILABLE_TEXT = { - requires_limit: "at limit only", paused: "paused", - cooldown: "try later", - not_usable: "unavailable", - checking: "checking", - unsupported_limits: "scope not reported", + cooldown: "cooling down after a recent reset", + not_usable: "unavailable right now", + unsupported_limits: "can't confirm what it refills", }; function claudeResetStatus(resets) { @@ -63,10 +61,16 @@ function claudeResetStatus(resets) { }; } if (!resets.saved || resets.offer) return null; - const label = UNAVAILABLE_TEXT[resets.unavailable] || "waiting"; + if (resets.unavailable === "requires_limit") { + return { + label: "usable once a limit is hit", + description: + "Claude only uses this reset once you hit a limit it refills.", + }; + } return { - label, - link: label, + label: UNAVAILABLE_TEXT[resets.unavailable] || "unavailable right now", + link: "Settings", description: resets.unavailable === "cooldown" ? "Another reset was just used. Try later." @@ -91,17 +95,22 @@ function claudeResetOffer(account) { ]; const endsAt = offer?.endsAt ?? resets.nextEndsAt; const weekly = quotaTimestamp(offer?.weeklyResetsAt); + const state = offer + ? "now" + : resets.unavailable === "requires_limit" + ? "at_limit" + : "unavailable"; return { count: resets.saved, unit: "reset", noun: "saved reset", heading: "Limit reset", action: "Use reset", - canUse: Boolean(offer), - atLimitOnly: !offer && resets.unavailable === "requires_limit", + state, + canUse: state === "now", + atLimitOnly: state === "at_limit", resetId: offer?.grantId, scope: windowLabels.length ? limitsText(clears) : null, - windowLabels, expiresAt: quotaTimestamp(endsAt), expiryText: expiryText(endsAt), title: offer?.label, @@ -128,39 +137,60 @@ function claudeResetOffer(account) { }; } +const NO_RESET_SPENT = "No reset was spent."; + const CLAIM_RESULT_TOASTS = { - reset: ["Limits reset", "success"], already_used: [ - "That reset was already used \u00b7 nothing changed now", + "That reset was already used \u00b7 nothing more was spent", "info", ], - not_limited: ["Nothing was used \u00b7 no limit needed a reset", "info"], - cooldown: ["Another reset was just used \u00b7 nothing was used now", "info"], - ineligible: [ - "This reset isn't available any more \u00b7 nothing was used", + not_limited: [ + `Nothing to reset yet: no limit this reset refills is hit. ${NO_RESET_SPENT}`, "info", ], + cooldown: [ + `Another reset was used recently \u00b7 try again later. ${NO_RESET_SPENT}`, + "info", + ], + ineligible: [ + `This reset can't be used on this account any more. ${NO_RESET_SPENT}`, + "error", + ], }; +const UNCONFIRMED_REASONS = new Set([ + "stamp_indeterminate", + "reset_unconfirmed", +]); + function claimToast(claim) { + if (UNCONFIRMED_REASONS.has(claim.reason)) { + return { + message: + "Reset not confirmed yet \u00b7 the next limits check shows whether it was used", + type: "info", + }; + } if (claim.result === "unavailable") { return { message: - "Couldn't confirm the reset \u00b7 the next limits check will show whether it was used", + "Couldn't confirm the reset \u00b7 the next limits check shows whether it was used", type: "error", }; } + if (claim.result === "reset") { + return { + message: `Refilled the ${limitsText(claim.cleared)} \u00b7 1 reset used`, + type: "success", + remaining: claim.resetsLeft, + changed: true, + }; + } const [message, type] = CLAIM_RESULT_TOASTS[claim.result] || [ - "Reset finished", + `Claude answered \u201c${claim.result}\u201d \u00b7 the next limits check shows whether a reset was used`, "info", ]; - return claim.result === "reset" - ? { - message: `Refilled the ${limitsText(claim.cleared)}`, - type, - remaining: claim.resetsLeft, - } - : { message, type }; + return { message, type }; } const claudeResets = { offerFor: claudeResetOffer, resultToast: claimToast }; diff --git a/public/admin/app-codex-resets.js b/public/admin/app-codex-resets.js index 540ab66..9e621ca 100644 --- a/public/admin/app-codex-resets.js +++ b/public/admin/app-codex-resets.js @@ -38,7 +38,11 @@ function codexResetOffer(account) { const next = credits[0] || null; const expiry = quotaTimestamp(next?.expiresAt); const windows = accountWindows(account).filter((window) => !window.named); - const tightest = [...windows].sort((a, b) => b.percent - a.percent)[0]; + const state = !count + ? "unavailable" + : account.codexResets?.unavailable === "requires_limit" + ? "at_limit" + : "now"; return { count, next, @@ -46,7 +50,9 @@ function codexResetOffer(account) { noun: "reset credit", heading: "Reset credit", action: "Use credit", - canUse: Boolean(count && next), + state, + canUse: state === "now" && Boolean(next), + atLimitOnly: state === "at_limit", resetId: next?.id, scope: next?.resetType === "codex_rate_limits" @@ -54,25 +60,22 @@ function codexResetOffer(account) { ? `${windows.map((window) => window.label).join(" + ")} windows` : "Codex rate-limit windows" : null, - windowLabels: - next?.resetType === "codex_rate_limits" - ? windows.map((window) => window.label) - : [], expiresAt: expiry, expiryText: expiry ? `${count > 1 ? "next expires" : "expires"} ${untilText(expiry)}` : "expiry not reported", title: next?.title, - earlyUse: - tightest && tightest.percent < 100 + status: + state === "at_limit" ? { - scope: `${tightest.label} window`, - percentUsed: Math.round(tightest.percent), + label: "usable once a limit is hit", + description: "Codex only uses a credit once you hit a limit.", } : null, + earlyUse: null, notes: [], useful: - count > 0 && + state === "now" && !account.quota?.error && !needsReauthorization(account) && windows.some( @@ -83,33 +86,38 @@ function codexResetOffer(account) { }; } +const NO_CREDIT_SPENT = "No credit was spent."; + function codexResultToast(result) { - const windows = - typeof result?.windowsReset === "number" && result.windowsReset > 0 - ? ` (${result.windowsReset} window${result.windowsReset === 1 ? "" : "s"} reset)` - : ""; switch (result?.code) { case "reset": return { - message: `Limits restored${windows}`, + message: "Limits reset \u00b7 1 credit used", type: "success", showBalance: true, + changed: true, }; case "nothing_to_reset": return { - message: "Nothing to reset - limits are already clear", + message: `Nothing to reset yet: Codex only uses a credit once you hit a limit. ${NO_CREDIT_SPENT}`, type: "info", }; case "no_credit": return { - message: "No reset credit available on this account", + message: `That credit is no longer available. ${NO_CREDIT_SPENT}`, type: "error", }; case "already_redeemed": - return { message: `Credit was already redeemed${windows}`, type: "info" }; + return { + message: + "This credit already reset your limits \u00b7 nothing more was used", + type: "info", + showBalance: true, + changed: true, + }; default: return { - message: `Codex reset result: ${result?.code || "complete"}`, + message: `Codex answered \u201c${result?.code || "unknown"}\u201d \u00b7 the next limits check shows whether a credit was used`, type: "info", }; } diff --git a/public/admin/app-core.js b/public/admin/app-core.js index 87c485f..28eb7f6 100644 --- a/public/admin/app-core.js +++ b/public/admin/app-core.js @@ -34,6 +34,9 @@ const sessionState = () => ({ keyFilter: "", accountFilter: "", expandedAccountId: null, + // Accounts waiting for the limits read after a reset: the server time the + // reset was answered, and whether the wait timed out. + limitsUpdating: new Map(), pendingExpandedAccountId: null, showAllReauthorize: false, expandedKeyId: null, diff --git a/public/admin/app-reset-credits.js b/public/admin/app-reset-credits.js index f26c8ee..c1a69b8 100644 --- a/public/admin/app-reset-credits.js +++ b/public/admin/app-reset-credits.js @@ -4,7 +4,6 @@ import { escapeHtml, mutationAttributes } from "./app-core.js"; import { DAY_MS, accountName, - fact, kbd, needsReauthorization, providerLabel, @@ -39,62 +38,48 @@ function expiringResets(account) { }; } -function windowHours(window) { - const span = window.label.match(/(\d+)([dh])$/); - return span ? Number(span[1]) * (span[2] === "d" ? 24 : 1) : 0; -} - -function resetCreditsDetail(account, windows) { +// The account's resets on one line: the balance, what the owner can do with +// it now, and what it refills. +function resetCreditsDetail(account) { const offer = resetOfferFor(account); - if (!offer || (!offer.canUse && !offer.status)) return null; - const covered = windows.filter( - (window) => - (account.provider !== "codex" || !window.named) && - offer.windowLabels.includes(window.label) - ); - const window = covered.sort( - (a, b) => b.percent - a.percent || windowHours(b) - windowHours(a) - )[0]; - const expiring = offer.expiresAt && offer.expiresAt - Date.now() < 3 * DAY_MS; - const noun = account.provider === "codex" ? "credit" : offer.noun; - const balance = `${offer.count ?? "\u2014"} ${noun}${offer.count === 1 ? "" : "s"}`; - const text = offer.status - ? offer.status.replaceCount - ? offer.status.label - : balance - : !offer.scope - ? `${balance} \u00b7 scope not reported` - : `${balance}${account.provider === "codex" && !expiring ? " to refill" : ""}`; + if (!offer || (!offer.canUse && !offer.status)) return ""; + const { status } = offer; + const balance = `${offer.count ?? "\u2014"} ${offer.unit}${offer.count === 1 ? "" : "s"}`; + const value = status?.replaceCount ? status.label : balance; + const left = + offer.expiresAt && offer.expiresAt - Date.now() < 3 * DAY_MS + ? `${untilText(offer.expiresAt).replace(/^in /, "")} left` + : null; + const scope = offer.scope ? `refills ${offer.scope}` : "scope not reported"; const title = [ - text, - offer.status && !offer.status.replaceCount && offer.status.label, - offer.scope ? `refills ${offer.scope}` : "reset scope not reported", + value, + status && !status.replaceCount && status.label, + scope, offer.expiryText, offer.title, - offer.status?.description, + status?.description, ] .filter(Boolean) .join(" \u00b7 "); const action = offer.canUse ? `` - : offer.status?.link - ? `${escapeHtml(offer.status.link)}` + : status?.link + ? `${escapeHtml(status.link)}` : ""; - const copy = expiring - ? `${text} \u00b7 ${untilText(offer.expiresAt).replace(/^in /, "")} left` - : text; - const line = `${escapeHtml(copy)}${action ? `${action}` : ""}`; - return { - window, - line, - fallback: window - ? "" - : fact( - `${offer.heading}${offer.count === 1 ? "" : "s"}`, - `${escapeHtml(copy)}`, - [action && `${action}`] - ), - }; + const details = status?.replaceCount + ? [status.description] + : [ + status?.label, + offer.canUse && offer.earlyUse && "usable early", + left, + scope, + ]; + return `
${escapeHtml(`${offer.heading}s`)}${escapeHtml(value)}${details + .filter(Boolean) + .map((detail) => ` \u00b7 ${escapeHtml(detail)}`) + .join( + "" + )}${action ? `${action}` : ""}
`; } function earlyUseText(earlyUse) { diff --git a/public/admin/styles.css b/public/admin/styles.css index 25e5387..fc13d8b 100644 --- a/public/admin/styles.css +++ b/public/admin/styles.css @@ -188,6 +188,29 @@ code, .tag-primary { color: var(--accent); } +.stale { + opacity: 0.45; +} +.updating { + display: inline-flex; + gap: 6px; + align-items: center; + color: var(--muted); + white-space: nowrap; +} +.updating::before { + width: 6px; + height: 6px; + content: ""; + background: currentColor; + border-radius: 50%; + animation: updating-pulse 1.2s ease-in-out infinite; +} +@keyframes updating-pulse { + 50% { + opacity: 0.25; + } +} /* Login */ @@ -1796,6 +1819,32 @@ input[type="checkbox"].toggle:checked::after { .daily-tokens { padding-bottom: 20px; } +.resets { + display: flex; + gap: 4px 16px; + align-items: baseline; + padding-bottom: 20px; +} +.resets .fact-label { + flex-shrink: 0; +} +.resets-copy { + flex: 1; + min-width: 0; + font-size: 12px; + line-height: 18px; + color: var(--muted); +} +.resets-value { + color: var(--text); +} +.resets-action .link { + display: inline-flex; + gap: 6px; + align-items: center; + color: var(--text); + white-space: nowrap; +} .daily-head, .daily-axis { display: flex; diff --git a/src/http/routes/admin-accounts.ts b/src/http/routes/admin-accounts.ts index 0617051..7205d31 100644 --- a/src/http/routes/admin-accounts.ts +++ b/src/http/routes/admin-accounts.ts @@ -351,8 +351,9 @@ export const adminAccountsRoutes = new Hono() provider === "codex" ? await redeemCodexResetCredit(db, id, resetId) : await claimClaudeReset(db, id, resetId); + // Limits read after this time show the spend's effect. return result - ? context.json({ result }) + ? context.json({ result, respondedAt: Date.now() }) : context.json(accountNotFoundBody, 404); } catch (error) { if (error instanceof ResetSpendConflict) {