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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### English

- Refresh a single account from its card: the button re-probes that one account's health, credits, and model catalog and shows a skeleton while it runs, instead of reloading the whole page

### 中文

- 账号卡片支持单个刷新:按钮只重新探测该账号的运行状态、额度和模型目录,期间显示骨架屏,不再整页刷新

## 0.2.31 - 2026-09-01

### English
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/api/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ export function updateProviderReasoning(provider: 'trae' | 'workbuddy', modelKey
})
}

export function refreshAccount(accountId: string, options?: { quota?: boolean }) {
if (!accountId) throw new Error('account id required')
const query = options?.quota ? '?quota=1' : ''
return api(`/api/accounts/${encodeURIComponent(accountId)}/refresh${query}`, {
method: 'POST',
body: '{}',
})
}

export function rewarmWorker(accountId?: string) {
if (!accountId) throw new Error('account id required')
return api(`/api/accounts/${encodeURIComponent(accountId)}/rewarm`, { method: 'POST', body: '{}' })
Expand Down
25 changes: 24 additions & 1 deletion frontend/src/components/account/AccountCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import gsap from 'gsap'
import { ProviderMark } from '@/components/ProviderMark'
import { CompactSwitch } from '@/components/ui/CompactSwitch'
import { AccountCardSkeleton } from '@/components/ui/PageSkeletons'
import { QuotaMeter } from '@/components/account/QuotaMeter'
import { RuntimeMeter } from '@/components/account/RuntimeMeter'
import {
Expand All @@ -23,7 +24,7 @@ import {
} from '@/lib/account'
import { accountProviderLabel } from '@/lib/provider'

export type AccountBusyKind = 'create' | 'import' | 'device' | 'pat' | 'callback' | 'rewarm' | 'toggle' | 'delete' | 'export' | 'settings' | 'checkin'
export type AccountBusyKind = 'create' | 'import' | 'device' | 'pat' | 'callback' | 'rewarm' | 'refresh' | 'toggle' | 'delete' | 'export' | 'settings' | 'checkin'

type Translate = (key: string, vars?: Record<string, string | number>) => string

Expand All @@ -43,6 +44,7 @@ type Props = {
onSubmitCallback?: () => void
onExport: () => void
onRewarm: () => void
onRefresh?: () => void
onDelete: () => void
onToggle: (selected: boolean) => void
onToggleDropSystem: (selected: boolean) => void
Expand Down Expand Up @@ -77,6 +79,7 @@ export function AccountCard({
onSubmitCallback,
onExport,
onRewarm,
onRefresh,
onDelete,
onToggle,
onToggleDropSystem,
Expand Down Expand Up @@ -153,6 +156,16 @@ export function AccountCard({
return () => context.revert()
}, [authPanelOpen])

// Refreshing replaces the whole card with a skeleton so stale quota and
// status are never left on screen while the account is re-probed.
if (busyKind === 'refresh') {
return (
<div data-gsap-reveal aria-busy="true" aria-label={t('refreshingAccount')}>
<AccountCardSkeleton />
</div>
)
}

return (
<Card
data-gsap-reveal
Expand Down Expand Up @@ -326,6 +339,16 @@ export function AccountCard({
</Tooltip.Trigger>
<Tooltip.Content>{t('accountModels')}</Tooltip.Content>
</Tooltip>
{onRefresh ? (
<Tooltip>
<Tooltip.Trigger>
<Button isIconOnly size="sm" variant="secondary" onPress={onRefresh} aria-label={t('refreshAccount')}>
<ArrowClockwise size={14} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{t('refreshAccount')}</Tooltip.Content>
</Tooltip>
) : null}
<Tooltip>
<Tooltip.Trigger>
<Button isIconOnly size="sm" variant="secondary" isDisabled={!account.enabled} isPending={busyKind === 'rewarm'} onPress={onRewarm} aria-label={t('rewarm')}>
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,8 @@ export const messages: Record<Lang, Dict> = {
connectQoder: 'Connect account',
rewarm: 'Rewarm',
rewarming: 'Rewarming',
refreshAccount: 'Refresh',
refreshingAccount: 'Refreshing',
stepBrowserTitle: 'Browser device flow',
stepBrowserDesc: 'Opens account selection. Finish in the browser, then this page polls until the session is ready.',
startBrowserLogin: 'Sign in with browser',
Expand Down Expand Up @@ -724,6 +726,8 @@ export const messages: Record<Lang, Dict> = {
connectQoder: '连接账号',
rewarm: 'Rewarm',
rewarming: 'Rewarm 中',
refreshAccount: '刷新',
refreshingAccount: '刷新中',
stepBrowserTitle: '浏览器 Device Flow',
stepBrowserDesc: '打开账号选择页。浏览器完成授权后,本页会轮询直到会话就绪。',
startBrowserLogin: '用浏览器登录',
Expand Down
27 changes: 26 additions & 1 deletion frontend/src/pages/AccountsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
completeLoginCallback,
fetchLoginStatus,
loginWithPat,
refreshAccount,
rewarmWorker,
startDeviceLogin,
updateAccount,
Expand Down Expand Up @@ -253,6 +254,26 @@ export function AccountsPage() {
})
}

async function onRefreshAccount(id: string) {
setBusy({ id, kind: 'refresh' })
setNoteById((current) => {
const next = { ...current }
delete next[id]
return next
})
try {
await refreshAccount(id, { quota: true })
} catch (error) {
setNoteById((current) => ({ ...current, [id]: error instanceof Error ? error.message : String(error) }))
} finally {
setBusy(null)
// Re-read the whole pool so header counts and provider tallies stay in
// sync with the refreshed card, but keep the silent flag so the list
// never unmounts and quota meters animate instead of flashing.
await refresh(undefined, { silent: true }).catch(() => undefined)
}
}

async function onRefreshCredits() {
setQuotaRefreshing(true)
try {
Expand Down Expand Up @@ -352,7 +373,10 @@ export function AccountsPage() {
if (!confirmAccount) return
const id = confirmAccount.id
setConfirmId(null)
void run(id, 'delete', async () => { await deleteAccount(id); await refresh(undefined, { silent: true }) })
void run(id, 'delete', async () => {
await deleteAccount(id)
await refresh(undefined, { silent: true })
})
}}
/>

Expand Down Expand Up @@ -429,6 +453,7 @@ export function AccountsPage() {
onPatLogin={() => void onPat(account.id)}
onExport={() => void onExport(account.id)}
onRewarm={() => void run(account.id, 'rewarm', async () => { await rewarmWorker(account.id); await refresh(undefined, { silent: true }) })}
onRefresh={() => void onRefreshAccount(account.id)}
onDelete={() => setConfirmId(account.id)}
onToggle={(selected) => void onToggle(account.id, selected)}
onToggleDropSystem={(selected) => void onToggleDropSystem(account.id, selected)}
Expand Down
45 changes: 45 additions & 0 deletions internal/accounts/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,51 @@ func (m *Manager) Import(ctx context.Context, input ImportAccount) (Account, err
return m.store.Get(ctx, account.ID)
}

// RefreshAccount re-probes one account's health, quota, and model catalog.
// forceQuota bypasses the worker's own quota cache, matching the console's
// "Refresh credits" button. It refreshes only the requested account, so a
// single card can update without re-probing the whole pool.
func (m *Manager) RefreshAccount(ctx context.Context, id string, forceQuota bool) error {
if m == nil || m.pool == nil {
return fmt.Errorf("account manager not ready")
}
item, ok := m.pool.ByID(id)
if !ok {
return fmt.Errorf("account %s is not running", id)
}
return m.refreshOne(ctx, item, forceQuota)
}

// AccountView returns the same projection the accounts list builds, for one
// account, so a single-card refresh can update in place.
func (m *Manager) AccountView(ctx context.Context, id string) (AccountView, error) {
if m == nil || m.store == nil {
return AccountView{}, fmt.Errorf("account manager not ready")
}
account, err := m.store.Get(ctx, id)
if err != nil {
return AccountView{}, err
}
view := AccountView{Account: account}
if item, ok := m.pool.ByID(account.ID); ok {
view.Ready = item.Ready == nil || *item.Ready
view.Hot = item.Hot != nil && *item.Hot
view.InFlight = item.InFlight
view.Restarts = item.Restarts
view.Quota = item.Quota
if !item.DownUntil.IsZero() && time.Now().Before(item.DownUntil) {
view.DownUntil = item.DownUntil.UTC().Format(time.RFC3339)
}
if view.LastError == "" {
view.LastError = item.LastError
}
if view.LastErrorKind == "" {
view.LastErrorKind = item.LastKind
}
}
return view, nil
}

func (m *Manager) Accounts(ctx context.Context) ([]AccountView, error) {
stored, err := m.store.List(ctx)
if err != nil {
Expand Down
24 changes: 24 additions & 0 deletions internal/api/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,30 @@ func (s *Server) handleAccountByID(w http.ResponseWriter, r *http.Request) {
}

action := strings.Join(parts[1:], "/")
// Refresh re-probes health, quota, and the model catalog for one account.
// It must dispatch before the provider-native block below, which would
// otherwise reject it as an unknown action for non-Qoder providers.
if action == "refresh" {
if r.Method != http.MethodPost {
writeErr(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST only")
return
}
if _, err := s.manager.Store().Get(r.Context(), accountID); err != nil {
writeErr(w, http.StatusNotFound, "account_not_found", err.Error())
return
}
if err := s.manager.RefreshAccount(r.Context(), accountID, r.URL.Query().Get("quota") == "1"); err != nil {
writeErr(w, http.StatusBadGateway, "account_refresh_failed", err.Error())
return
}
view, err := s.manager.AccountView(r.Context(), accountID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "account_view_failed", err.Error())
return
}
writeJSON(w, http.StatusOK, view)
return
}
if account, err := s.manager.Store().Get(r.Context(), accountID); err == nil && action == "checkin" {
if r.Method != http.MethodPost {
writeErr(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST only")
Expand Down
74 changes: 74 additions & 0 deletions internal/api/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -535,3 +535,77 @@ func TestAPIKeysCRUDAndConsoleKeyPrefix(t *testing.T) {
t.Fatalf("delete key: %d %s", rec.Code, rec.Body.String())
}
}

func TestAccountRefreshRouteRequiresKeyAndReportsMissingAccount(t *testing.T) {
srv := New(config.Config{
Host: "127.0.0.1",
Port: 3010,
ProxyAPIKey: "secret",
QoderHome: t.TempDir(),
DataDir: t.TempDir(),
})
defer srv.Close()

req := httptest.NewRequest(http.MethodPost, "/api/accounts/missing/refresh", nil)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("refresh without key: got %d want 401", rec.Code)
}

req = httptest.NewRequest(http.MethodPost, "/api/accounts/missing/refresh", nil)
req.Header.Set("Authorization", "Bearer secret")
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("refresh unknown account: got %d want 404 (%s)", rec.Code, rec.Body.String())
}
}

func TestAccountRefreshRouteRejectsGET(t *testing.T) {
srv := New(config.Config{
Host: "127.0.0.1",
Port: 3010,
ProxyAPIKey: "secret",
QoderHome: t.TempDir(),
DataDir: t.TempDir(),
})
defer srv.Close()

body := bytes.NewBufferString(`{"name":"Refreshable","enabled":false}`)
req := httptest.NewRequest(http.MethodPost, "/api/accounts", body)
req.Header.Set("Authorization", "Bearer secret")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create account: %d %s", rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}

// A disabled account has no pool item, so refresh fails closed instead of
// silently reporting a stale view.
req = httptest.NewRequest(http.MethodPost, "/api/accounts/"+created.ID+"/refresh", nil)
req.Header.Set("Authorization", "Bearer secret")
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadGateway {
t.Fatalf("refresh disabled account: got %d want 502 (%s)", rec.Code, rec.Body.String())
}

req = httptest.NewRequest(http.MethodGet, "/api/accounts/"+created.ID+"/refresh", nil)
req.Header.Set("Authorization", "Bearer secret")
rec = httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("GET refresh: got %d want 405", rec.Code)
}
}

Large diffs are not rendered by default.

28 changes: 0 additions & 28 deletions internal/webui/static/assets/index-Dp49p5lX.js

This file was deleted.

28 changes: 28 additions & 0 deletions internal/webui/static/assets/index-_1ZKhoHi.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion internal/webui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-Dp49p5lX.js"></script>
<script type="module" crossorigin src="/assets/index-_1ZKhoHi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dk1vXeOT.css">
</head>
<body>
Expand Down
Loading