codex-lb 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
app/static/index.html CHANGED
@@ -4,501 +4,713 @@
4
4
  <head>
5
5
  <meta charset="utf-8">
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1">
7
- <meta name="color-scheme" content="light">
7
+ <meta name="color-scheme" content="light dark">
8
8
  <title>Codex LB</title>
9
9
  <link rel="icon"
10
10
  href="data:image/svg+xml,%3Csvg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;32&quot; height=&quot;32&quot; fill=&quot;none&quot; viewBox=&quot;0 0 32 32&quot;%3E%3Cpath stroke=&quot;%23000&quot; stroke-linecap=&quot;round&quot; stroke-width=&quot;2.484&quot; d=&quot;M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z&quot;/%3E%3C/svg%3E"
11
11
  type="image/svg+xml">
12
- <link rel="stylesheet" href="/dashboard/7.css">
13
12
  <link rel="stylesheet" href="/dashboard/index.css">
14
13
  </head>
15
14
 
16
- <body class="has-scrollbar" x-data="feApp" x-init="init()" x-cloak>
15
+ <body class="has-scrollbar" x-data="feApp" :data-theme="theme" x-init="init()" x-cloak>
17
16
  <div class="app-shell">
18
- <div class="window glass active">
19
- <div class="title-bar">
20
- <div class="title-bar-text" x-text="currentPage.title"></div>
21
- <div class="title-bar-controls">
22
- <button aria-label="Minimize"></button>
23
- <button aria-label="Maximize"></button>
24
- <button aria-label="Close"></button>
17
+ <header class="main-header">
18
+ <div class="header-content">
19
+ <h1 class="app-logo">Codex Load Balancer</h1>
20
+ <div class="header-actions">
21
+ <button class="theme-toggle" @click="toggleTheme" aria-label="Toggle theme">
22
+ <svg x-show="theme === 'dark'" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
23
+ fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
24
+ <circle cx="12" cy="12" r="4" />
25
+ <path d="M12 2v2" />
26
+ <path d="M12 20v2" />
27
+ <path d="m4.93 4.93 1.41 1.41" />
28
+ <path d="m17.66 17.66 1.41 1.41" />
29
+ <path d="M2 12h2" />
30
+ <path d="M20 12h2" />
31
+ <path d="m6.34 17.66-1.41 1.41" />
32
+ <path d="m19.07 4.93-1.41 1.41" />
33
+ </svg>
34
+ <svg x-show="theme === 'light'" xmlns="http://www.w3.org/2000/svg" width="20" height="20"
35
+ viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
36
+ stroke-linejoin="round">
37
+ <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
38
+ </svg>
39
+ </button>
25
40
  </div>
26
41
  </div>
27
- <div class="window-body has-space">
28
- <div class="loading-overlay" x-show="isLoading" x-transition.opacity>
29
- <div class="loading-panel" role="status" aria-live="polite">
30
- <span class="spinner animate" aria-hidden="true"></span>
31
- <div>Loading dashboard...</div>
32
- </div>
42
+ </header>
43
+ <main class="main-content">
44
+ <div class="loading-overlay" x-show="isLoading" x-transition.opacity>
45
+ <div class="loading-panel" role="status" aria-live="polite">
46
+ <span class="spinner animate" aria-hidden="true"></span>
47
+ <div>Loading Dashboard...</div>
33
48
  </div>
34
- <section class="tabs" aria-label="Codex FE tabs">
35
- <menu role="tablist" aria-label="Codex FE tabs">
36
- <template x-for="page in pages" :key="page.id">
37
- <button role="tab" type="button" :aria-controls="page.tabId" :aria-selected="view === page.id"
38
- :tabindex="view === page.id ? 0 : -1" @click="setView(page.id)" @focus="setView(page.id)">
39
- <span x-text="page.label"></span>
40
- </button>
49
+ </div>
50
+ <section class="tabs" aria-label="Codex FE tabs">
51
+ <menu role="tablist" aria-label="Codex FE tabs">
52
+ <template x-for="page in pages" :key="page.id">
53
+ <button role="tab" type="button" :aria-controls="page.tabId" :aria-selected="view === page.id"
54
+ :tabindex="view === page.id ? 0 : -1" @click="setView(page.id)" @focus="setView(page.id)">
55
+ <span x-text="page.label"></span>
56
+ </button>
57
+ </template>
58
+ </menu>
59
+
60
+ <article role="tabpanel" id="tab-dashboard" :hidden="view !== 'dashboard'">
61
+ <div class="hero-banner">
62
+ <h2>Quota remaining and routing health</h2>
63
+ <p>Live summary of remaining quota for Codex traffic routed through the ChatGPT-style backend. Account
64
+ status and load
65
+ balancing signals are surfaced here.</p>
66
+ <div class="badge-row">
67
+ <template x-for="badge in dashboard.badges" :key="badge.label">
68
+ <span class="status-pill" :class="badge.status" x-text="badge.label"></span>
69
+ </template>
70
+ </div>
71
+ </div>
72
+
73
+ <div class="section-grid" style="margin-top: 12px">
74
+ <template x-for="stat in dashboard.stats" :key="stat.title">
75
+ <div class="panel">
76
+ <h3 x-text="stat.title"></h3>
77
+ <div class="stat-value" x-text="stat.value"></div>
78
+ <div class="stat-meta" x-text="stat.meta"></div>
79
+ </div>
41
80
  </template>
42
- </menu>
81
+ </div>
43
82
 
44
- <article role="tabpanel" id="tab-dashboard" :hidden="view !== 'dashboard'">
45
- <div class="hero-banner">
46
- <h2>Quota remaining and routing health</h2>
47
- <p>Live summary of remaining quota for Codex traffic routed through the ChatGPT-style backend. Account
48
- status and load
49
- balancing signals are surfaced here.</p>
50
- <div class="badge-row">
51
- <template x-for="badge in dashboard.badges" :key="badge.label">
52
- <span class="status-pill" :class="badge.status" x-text="badge.label"></span>
53
- </template>
83
+ <div class="two-column two-column--equal" style="margin-top: 12px">
84
+ <template x-for="donut in dashboard.donuts" :key="donut.title">
85
+ <div class="panel">
86
+ <h3 x-text="donut.title"></h3>
87
+ <div class="donut-wrap">
88
+ <div class="donut" :style="{ '--donut-data': donut.gradient }">
89
+ <div class="donut-center">
90
+ <strong x-text="donut.range"></strong>
91
+ <div x-text="donut.total"></div>
92
+ </div>
93
+ </div>
94
+ <ul class="legend">
95
+ <template x-for="item in donut.items" :key="item.label">
96
+ <li>
97
+ <span class="legend-label">
98
+ <i :style="{ '--legend-color': item.color }"></i>
99
+ <span class="legend-label-text" x-text="item.label" :title="item.fullLabel"></span>
100
+ </span>
101
+ <span class="legend-detail">
102
+ <span class="legend-detail-label" x-text="item.detailLabel"></span>
103
+ <span class="legend-detail-value" :class="item.valueClass" x-text="item.detailValue"></span>
104
+ </span>
105
+ </li>
106
+ </template>
107
+ </ul>
108
+ </div>
54
109
  </div>
55
- </div>
110
+ </template>
111
+ </div>
56
112
 
57
- <div class="section-grid" style="margin-top: 12px">
58
- <template x-for="stat in dashboard.stats" :key="stat.title">
59
- <div class="panel">
60
- <h3 x-text="stat.title"></h3>
61
- <div class="stat-value" x-text="stat.value"></div>
62
- <div class="stat-meta" x-text="stat.meta"></div>
113
+ <section style="margin-top: 12px">
114
+ <h3>Account status</h3>
115
+ <div class="account-grid">
116
+ <template x-for="card in dashboard.accountCards" :key="card.email">
117
+ <div class="account-card">
118
+ <header>
119
+ <div>
120
+ <div x-text="`${card.email} (${card.plan})`" :title="`${card.email} (${card.plan})`"></div>
121
+ <div class="account-id" x-text="card.accountId"></div>
122
+ </div>
123
+ <span class="status-pill" :class="card.status.class" x-text="card.status.label"></span>
124
+ </header>
125
+ <div class="progress-row">
126
+ <label
127
+ x-text="`Remaining (${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`"></label>
128
+ <template x-if="card.marquee">
129
+ <div role="progressbar" class="marquee"></div>
130
+ </template>
131
+ <template x-if="!card.marquee">
132
+ <div role="progressbar" :class="card.progressClass" aria-valuemin="0" aria-valuemax="100"
133
+ :aria-valuenow="card.remaining">
134
+ <div :style="{ width: `${card.remaining}%` }"></div>
135
+ </div>
136
+ </template>
137
+ <span class="text-muted" x-text="card.remainingText"></span>
138
+ </div>
139
+ <div class="text-muted" x-text="card.meta"></div>
140
+ <div class="account-actions">
141
+ <template x-for="action in card.actions" :key="action.label">
142
+ <button x-text="action.label" @click="handleAccountAction(action, card)"></button>
143
+ </template>
144
+ </div>
63
145
  </div>
64
146
  </template>
65
147
  </div>
148
+ </section>
149
+
150
+ <div class="panel" style="margin-top: 12px">
151
+ <h3>Recent requests</h3>
66
152
 
67
- <div class="two-column two-column--equal" style="margin-top: 12px">
68
- <template x-for="donut in dashboard.donuts" :key="donut.title">
69
- <div class="panel">
70
- <h3 x-text="donut.title"></h3>
71
- <div class="donut-wrap">
72
- <div class="donut" :style="{ '--donut-data': donut.gradient }">
73
- <div class="donut-center">
74
- <strong x-text="donut.range"></strong>
75
- <div x-text="donut.total"></div>
153
+ <div class="controls-toolbar">
154
+ <div class="controls-group">
155
+ <input type="text" class="filter-input" placeholder="Search..." x-model="filtersDraft.search"
156
+ @keyup.enter="applyFilters()" style="width: 200px;">
157
+
158
+ <div class="single-select" x-data="{ open: false }" @click.outside="open = false">
159
+ <button type="button" class="filter-select single-select-trigger" @click="open = !open"
160
+ :aria-expanded="open">
161
+ <span x-text="timeframeLabel(filtersDraft.timeframe)"></span>
162
+ </button>
163
+ <div class="single-select-menu" x-show="open" x-transition.opacity>
164
+ <label class="single-select-item" :class="{ 'is-selected': filtersDraft.timeframe === 'all' }">
165
+ <input type="radio" name="timeframe" value="all" x-model="filtersDraft.timeframe"
166
+ @change="open = false">
167
+ <span>All time</span>
168
+ </label>
169
+ <label class="single-select-item" :class="{ 'is-selected': filtersDraft.timeframe === '1h' }">
170
+ <input type="radio" name="timeframe" value="1h" x-model="filtersDraft.timeframe"
171
+ @change="open = false">
172
+ <span>Last 1h</span>
173
+ </label>
174
+ <label class="single-select-item" :class="{ 'is-selected': filtersDraft.timeframe === '24h' }">
175
+ <input type="radio" name="timeframe" value="24h" x-model="filtersDraft.timeframe"
176
+ @change="open = false">
177
+ <span>Last 24h</span>
178
+ </label>
179
+ <label class="single-select-item" :class="{ 'is-selected': filtersDraft.timeframe === '7d' }">
180
+ <input type="radio" name="timeframe" value="7d" x-model="filtersDraft.timeframe"
181
+ @change="open = false">
182
+ <span>Last 7d</span>
183
+ </label>
184
+ </div>
185
+ </div>
186
+
187
+
188
+ <div class="multi-select" x-data="{ open: false }" @click.outside="open = false">
189
+ <button type="button" class="filter-select multi-select-trigger" @click="open = !open"
190
+ :aria-expanded="open" :disabled="requestLogOptions.isLoading">
191
+ <span
192
+ x-text="multiSelectSummary(filtersDraft.accountIds, 'All accounts', 'account', 'accounts')"></span>
193
+ </button>
194
+ <div class="multi-select-menu" x-show="open" x-transition.opacity>
195
+ <div class="multi-select-scroller">
196
+ <div class="multi-select-actions">
197
+ <button type="button" class="multi-select-action"
198
+ @click="filtersDraft.accountIds = []; open = false">Clear</button>
76
199
  </div>
77
- </div>
78
- <ul class="legend">
79
- <template x-for="item in donut.items" :key="item.label">
80
- <li>
81
- <span class="legend-label">
82
- <i :style="{ '--legend-color': item.color }"></i>
83
- <span class="legend-label-text" x-text="item.label" :title="item.label"></span>
84
- </span>
85
- <span class="legend-detail">
86
- <span class="legend-detail-label" x-text="item.detailLabel"></span>
87
- <span class="legend-detail-value" x-text="item.detailValue"></span>
88
- </span>
89
- </li>
200
+ <template x-for="accountId in requestLogOptions.accountIds" :key="accountId">
201
+ <label class="multi-select-item">
202
+ <input type="checkbox" :checked="filtersDraft.accountIds.includes(accountId)"
203
+ @change="toggleMultiSelectValue('accountIds', accountId)">
204
+ <span class="multi-select-label" x-text="accountFilterLabel(accountId)"></span>
205
+ </label>
90
206
  </template>
91
- </ul>
207
+ </div>
92
208
  </div>
93
209
  </div>
94
- </template>
95
- </div>
96
210
 
97
- <section style="margin-top: 12px">
98
- <h3>Account status</h3>
99
- <div class="account-grid">
100
- <template x-for="card in dashboard.accountCards" :key="card.email">
101
- <div class="account-card">
102
- <header>
103
- <div>
104
- <div x-text="`${card.email} (${card.plan})`"></div>
105
- <div class="account-id" x-text="card.accountId"></div>
211
+ <div class="multi-select" x-data="{ open: false }" @click.outside="open = false">
212
+ <button type="button" class="filter-select multi-select-trigger" @click="open = !open"
213
+ :aria-expanded="open" :disabled="requestLogOptions.isLoading">
214
+ <span
215
+ x-text="multiSelectSummary(filtersDraft.modelOptions, 'All models', 'model', 'models')"></span>
216
+ </button>
217
+ <div class="multi-select-menu" x-show="open" x-transition.opacity>
218
+ <div class="multi-select-scroller">
219
+ <div class="multi-select-actions">
220
+ <button type="button" class="multi-select-action"
221
+ @click="filtersDraft.modelOptions = []; open = false">Clear</button>
106
222
  </div>
107
- <span class="status-pill" :class="card.status.class" x-text="card.status.label"></span>
108
- </header>
109
- <div class="progress-row">
110
- <label
111
- x-text="`Remaining (${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`"></label>
112
- <template x-if="card.marquee">
113
- <div role="progressbar" class="marquee"></div>
223
+ <template x-for="modelOption in requestLogOptions.modelOptions"
224
+ :key="modelOptionValue(modelOption)">
225
+ <label class="multi-select-item">
226
+ <input type="checkbox"
227
+ :checked="filtersDraft.modelOptions.includes(modelOptionValue(modelOption))"
228
+ @change="toggleMultiSelectValue('modelOptions', modelOptionValue(modelOption))">
229
+ <span class="multi-select-label" x-text="modelOptionLabel(modelOption)"></span>
230
+ </label>
114
231
  </template>
115
- <template x-if="!card.marquee">
116
- <div role="progressbar" :class="card.progressClass" aria-valuemin="0" aria-valuemax="100"
117
- :aria-valuenow="card.remaining">
118
- <div :style="{ width: `${card.remaining}%` }"></div>
119
- </div>
120
- </template>
121
- <span class="text-muted" x-text="card.remainingText"></span>
122
232
  </div>
123
- <div class="text-muted" x-text="card.meta"></div>
124
- <div class="account-actions">
125
- <template x-for="action in card.actions" :key="action.label">
126
- <button x-text="action.label" @click="handleAccountAction(action, card)"></button>
127
- </template>
233
+ </div>
234
+ </div>
235
+
236
+ <div class="multi-select" x-data="{ open: false }" @click.outside="open = false">
237
+ <button type="button" class="filter-select multi-select-trigger" @click="open = !open"
238
+ :aria-expanded="open">
239
+ <span x-text="multiSelectSummary(filtersDraft.statuses, 'All status', 'status', 'statuses')"></span>
240
+ </button>
241
+ <div class="multi-select-menu" x-show="open" x-transition.opacity>
242
+ <div class="multi-select-scroller">
243
+ <div class="multi-select-actions">
244
+ <button type="button" class="multi-select-action"
245
+ @click="filtersDraft.statuses = []; open = false">Clear</button>
246
+ </div>
247
+ <label class="multi-select-item">
248
+ <input type="checkbox" :checked="filtersDraft.statuses.includes('ok')"
249
+ @change="toggleMultiSelectValue('statuses', 'ok')">
250
+ <span class="multi-select-label">OK</span>
251
+ </label>
252
+ <label class="multi-select-item">
253
+ <input type="checkbox" :checked="filtersDraft.statuses.includes('rate_limit')"
254
+ @change="toggleMultiSelectValue('statuses', 'rate_limit')">
255
+ <span class="multi-select-label">Rate Limit</span>
256
+ </label>
257
+ <label class="multi-select-item">
258
+ <input type="checkbox" :checked="filtersDraft.statuses.includes('quota')"
259
+ @change="toggleMultiSelectValue('statuses', 'quota')">
260
+ <span class="multi-select-label">Quota</span>
261
+ </label>
262
+ <label class="multi-select-item">
263
+ <input type="checkbox" :checked="filtersDraft.statuses.includes('error')"
264
+ @change="toggleMultiSelectValue('statuses', 'error')">
265
+ <span class="multi-select-label">Error</span>
266
+ </label>
128
267
  </div>
129
268
  </div>
130
- </template>
269
+ </div>
270
+
271
+ <input type="number" class="filter-input" placeholder="Min Cost ($)" x-model="filtersDraft.minCost"
272
+ @keyup.enter="applyFilters()" style="width: 110px;" step="0.01">
273
+
274
+ <button type="button" class="filter-apply" @click="applyFilters()"
275
+ :disabled="recentRequestsState.isLoading">Apply</button>
131
276
  </div>
132
- </section>
133
277
 
134
- <div class="panel" style="margin-top: 12px">
135
- <h3>Recent requests</h3>
136
- <div class="table-wrap table-wrap--requests table-wrap--column-lines has-scrollbar">
278
+ <div class="controls-group">
279
+ <span style="font-size: 13px; color: var(--text-muted);">Show:</span>
280
+ <div class="single-select" x-data="{ open: false }" @click.outside="open = false">
281
+ <button type="button" class="filter-select single-select-trigger" @click="open = !open"
282
+ :aria-expanded="open" style="min-width: 60px">
283
+ <span x-text="pagination.limit"></span>
284
+ </button>
285
+ <div class="single-select-menu" x-show="open" x-transition.opacity style="min-width: 80px">
286
+ <label class="single-select-item" :class="{ 'is-selected': pagination.limit == 25 }">
287
+ <input type="radio" name="limit" value="25" x-model="pagination.limit" @change="open = false">
288
+ <span>25</span>
289
+ </label>
290
+ <label class="single-select-item" :class="{ 'is-selected': pagination.limit == 50 }">
291
+ <input type="radio" name="limit" value="50" x-model="pagination.limit" @change="open = false">
292
+ <span>50</span>
293
+ </label>
294
+ <label class="single-select-item" :class="{ 'is-selected': pagination.limit == 100 }">
295
+ <input type="radio" name="limit" value="100" x-model="pagination.limit" @change="open = false">
296
+ <span>100</span>
297
+ </label>
298
+ <label class="single-select-item" :class="{ 'is-selected': pagination.limit == 250 }">
299
+ <input type="radio" name="limit" value="250" x-model="pagination.limit" @change="open = false">
300
+ <span>250</span>
301
+ </label>
302
+ </div>
303
+ </div>
304
+ </div>
305
+ </div>
306
+
307
+ <div class="table-wrap table-wrap--requests table-wrap--column-lines has-scrollbar">
308
+ <table>
309
+ <thead>
310
+ <tr>
311
+ <th class="highlighted indicator" style="width: 15%">Time</th>
312
+ <th style="width: 25%">Account</th>
313
+ <th style="width: 15%">Model</th>
314
+ <th style="width: 10%">Status</th>
315
+ <th style="width: 15%">Error</th>
316
+ <th style="width: 10%">Tokens</th>
317
+ <th style="width: 10%">Cost</th>
318
+ </tr>
319
+ </thead>
320
+ <tbody>
321
+ <template x-for="request in dashboard.requests" :key="request.key">
322
+ <tr>
323
+ <td>
324
+ <div x-text="request.time.time"></div>
325
+ <div x-text="request.time.date" class="text-muted" style="font-size: 11px;"></div>
326
+ </td>
327
+ <td class="cell-truncate" x-text="request.account" :title="request.account"></td>
328
+ <td class="cell-truncate" x-text="request.model" :title="request.model"></td>
329
+ <td><span class="status-pill" :class="request.status.class" x-text="request.status.label"></span>
330
+ </td>
331
+ <td class="cell-error">
332
+ <div class="error-cell" :class="request.isErrorPlaceholder ? 'placeholder' : ''"
333
+ x-data="{ expanded: false }">
334
+ <div class="error-text" :class="!expanded ? 'truncated' : ''"
335
+ x-text="expanded ? request.errorTitle : request.error"
336
+ :title="!expanded ? request.errorTitle : ''"></div>
337
+ <template x-if="request.isTruncated">
338
+ <button class="error-toggle" @click="expanded = !expanded"
339
+ x-text="expanded ? 'Less' : 'More'"></button>
340
+ </template>
341
+ </div>
342
+ </td>
343
+ <td>
344
+ <div x-text="request.tokens.total"></div>
345
+ <template x-if="request.tokens.cached">
346
+ <div x-text="`${request.tokens.cached} Cached`" class="text-muted" style="font-size: 11px;">
347
+ </div>
348
+ </template>
349
+ </td>
350
+ <td x-text="request.cost"></td>
351
+ </tr>
352
+ </template>
353
+ </tbody>
354
+ </table>
355
+ </div>
356
+ </div>
357
+ </article>
358
+
359
+ <article role="tabpanel" id="tab-accounts" :hidden="view !== 'accounts'">
360
+ <div class="two-column">
361
+ <div class="panel account-list-panel">
362
+ <h3>Account list</h3>
363
+ <div class="list-actions">
364
+ <div class="searchbox">
365
+ <input type="search" placeholder="Search accounts" aria-label="Search accounts" x-ref="accountSearch"
366
+ x-model="accounts.searchQuery" @keydown.escape.prevent="clearAccountSearch">
367
+ <button type="button" aria-label="search" title="Search accounts"
368
+ @click="focusAccountSearch"></button>
369
+ </div>
370
+ <label role="button" :class="{ 'is-disabled': importState.isLoading }" @click="logImportClick('label')">
371
+ <input type="file" class="file-input" accept="application/json" @click="logImportClick('input')"
372
+ @change="handleAuthImport($event)" :disabled="importState.isLoading">
373
+ Import auth.json
374
+ </label>
375
+ <button type="button" @click="openAddAccountDialog">Add account</button>
376
+ </div>
377
+ <div class="table-wrap table-wrap--column-lines has-scrollbar">
137
378
  <table>
138
379
  <thead>
139
380
  <tr>
140
- <th class="highlighted indicator">Time</th>
141
- <th>Account</th>
142
- <th>Model</th>
143
- <th>Status</th>
144
- <th>Error</th>
145
- <th style="text-align: right">Tokens</th>
146
- <th style="text-align: right">Cost</th>
381
+ <th class="highlighted indicator" style="width: 28%">Account ID</th>
382
+ <th style="width: 25%">Email</th>
383
+ <th style="width: 10%">Plan</th>
384
+ <th style="width: 12%">Status</th>
385
+ <th style="text-align: center; width: 12%">
386
+ <span style="display: inline-flex; flex-direction: column; align-items: center">
387
+ <span>Remaining</span>
388
+ <span style="font-weight: normal; font-size: 0.9em; color: var(--text-muted)"
389
+ x-text="`(${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`">
390
+ </span>
391
+ </span>
392
+ </th>
393
+ <th style="text-align: center; width: 13%">
394
+ <span style="display: inline-flex; flex-direction: column; align-items: center">
395
+ <span style="white-space: nowrap">Quota reset</span>
396
+ <span style="font-weight: normal; font-size: 0.9em; color: var(--text-muted)"
397
+ x-text="`(${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`">
398
+ </span>
399
+ </span>
400
+ </th>
147
401
  </tr>
148
402
  </thead>
149
403
  <tbody>
150
- <template x-for="request in dashboard.requests" :key="request.key">
404
+ <template x-for="account in filteredAccounts" :key="account.id">
405
+ <tr :class="{ highlighted: account.id === accounts.selectedId }"
406
+ @click="selectAccount(account.id)" style="cursor: pointer">
407
+ <td class="cell-truncate" x-text="account.id" :title="account.id"></td>
408
+ <td class="cell-truncate" x-text="account.email" :title="account.email"></td>
409
+ <td x-text="planLabel(account.plan)"></td>
410
+ <td><span class="status-pill" :class="account.status"
411
+ x-text="statusLabel(account.status)"></span>
412
+ </td>
413
+ <td style="text-align: center"
414
+ :class="calculateTextUsageTextClass(account.status, account.usage?.secondaryRemainingPercent)"
415
+ x-text="formatPercent(account.usage?.secondaryRemainingPercent)">
416
+ </td>
417
+ <td style="text-align: center" x-text="formatQuotaResetLabel(account.resetAtSecondary)"></td>
418
+ </tr>
419
+ </template>
420
+ <template x-if="filteredAccounts.length === 0">
151
421
  <tr>
152
- <td x-text="request.time"></td>
153
- <td x-text="request.account"></td>
154
- <td x-text="request.model"></td>
155
- <td><span class="status-pill" :class="request.status.class"
156
- x-text="request.status.label"></span></td>
157
- <td x-text="request.error" :title="request.errorTitle || ''"></td>
158
- <td style="text-align: right" x-text="request.tokens"></td>
159
- <td style="text-align: right" x-text="request.cost"></td>
422
+ <td colspan="6" class="text-muted" style="text-align: center">
423
+ <span
424
+ x-text="accounts.searchQuery ? 'No accounts match your search.' : 'No accounts available.'"></span>
425
+ </td>
160
426
  </tr>
161
427
  </template>
162
428
  </tbody>
163
429
  </table>
164
430
  </div>
165
431
  </div>
166
- </article>
167
432
 
168
- <article role="tabpanel" id="tab-accounts" :hidden="view !== 'accounts'">
169
- <div class="two-column">
170
- <div class="panel account-list-panel">
171
- <h3>Account list</h3>
172
- <div class="list-actions">
173
- <div class="searchbox">
174
- <input type="search" placeholder="Search accounts" aria-label="Search accounts"
175
- x-ref="accountSearch" x-model="accounts.searchQuery" @keydown.escape.prevent="clearAccountSearch">
176
- <button type="button" aria-label="search" title="Search accounts"
177
- @click="focusAccountSearch"></button>
178
- </div>
179
- <label role="button" :class="{ 'is-disabled': importState.isLoading }"
180
- @click="logImportClick('label')">
181
- <input type="file" class="file-input" accept="application/json" @click="logImportClick('input')"
182
- @change="handleAuthImport($event)" :disabled="importState.isLoading">
183
- Import auth.json
184
- </label>
185
- <button type="button" @click="openAddAccountDialog">Add account</button>
433
+ <div class="panel">
434
+ <h3>Selected account</h3>
435
+ <div class="summary-list">
436
+ <div><span>Email</span><strong x-text="selectedAccount.email" :title="selectedAccount.email"></strong>
186
437
  </div>
187
- <div class="table-wrap table-wrap--column-lines has-scrollbar">
188
- <table>
189
- <thead>
190
- <tr>
191
- <th class="highlighted indicator">Account ID</th>
192
- <th>Email</th>
193
- <th>Plan</th>
194
- <th>Status</th>
195
- <th style="text-align: right"
196
- x-text="`Remaining (${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`">
197
- </th>
198
- <th
199
- x-text="`Quota reset (${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`">
200
- </th>
201
- </tr>
202
- </thead>
203
- <tbody>
204
- <template x-for="account in filteredAccounts" :key="account.id">
205
- <tr :class="{ highlighted: account.id === accounts.selectedId }"
206
- @click="selectAccount(account.id)" style="cursor: pointer">
207
- <td x-text="account.id"></td>
208
- <td x-text="account.email"></td>
209
- <td x-text="planLabel(account.plan)"></td>
210
- <td><span class="status-pill" :class="account.status"
211
- x-text="statusLabel(account.status)"></span>
212
- </td>
213
- <td style="text-align: right"
214
- x-text="formatPercent(account.usage?.secondaryRemainingPercent)">
215
- </td>
216
- <td x-text="formatQuotaResetLabel(account.resetAtSecondary)"></td>
217
- </tr>
218
- </template>
219
- <template x-if="filteredAccounts.length === 0">
220
- <tr>
221
- <td colspan="6" class="text-muted" style="text-align: center">
222
- <span
223
- x-text="accounts.searchQuery ? 'No accounts match your search.' : 'No accounts available.'"></span>
224
- </td>
225
- </tr>
226
- </template>
227
- </tbody>
228
- </table>
438
+ <div><span>Account ID</span><strong x-text="selectedAccount.id" :title="selectedAccount.id"></strong>
229
439
  </div>
230
- </div>
231
-
232
- <div class="panel">
233
- <h3>Selected account</h3>
234
- <div class="summary-list">
235
- <div><span>Email</span><strong x-text="selectedAccount.email"></strong></div>
236
- <div><span>Account ID</span><strong x-text="selectedAccount.id"></strong></div>
237
- <div><span>Plan</span><strong x-text="planLabel(selectedAccount.plan)"></strong></div>
238
- <div>
239
- <span>Status</span>
240
- <span class="status-pill" :class="selectedAccount.status"
241
- x-text="statusLabel(selectedAccount.status)"></span>
242
- </div>
243
- <div><span
244
- x-text="`Quota reset (${formatWindowLabel('primary', dashboardData.usage.primary.windowMinutes)})`"></span><strong
245
- x-text="formatQuotaResetLabel(selectedAccount.resetAtPrimary)"></strong></div>
246
- <div><span
247
- x-text="`Quota reset (${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`"></span><strong
248
- x-text="formatQuotaResetLabel(selectedAccount.resetAtSecondary)"></strong></div>
440
+ <div><span>Plan</span><strong x-text="planLabel(selectedAccount.plan)"></strong></div>
441
+ <div>
442
+ <span>Status</span>
443
+ <span class="status-pill" :class="selectedAccount.status"
444
+ x-text="statusLabel(selectedAccount.status)"></span>
249
445
  </div>
446
+ <div><span
447
+ x-text="`Quota reset (${formatWindowLabel('primary', dashboardData.usage.primary.windowMinutes)})`"></span><strong
448
+ x-text="formatQuotaResetLabel(selectedAccount.resetAtPrimary)"></strong></div>
449
+ <div><span
450
+ x-text="`Quota reset (${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`"></span><strong
451
+ x-text="formatQuotaResetLabel(selectedAccount.resetAtSecondary)"></strong></div>
452
+ </div>
250
453
 
251
- <div class="split-stack" style="margin-top: 12px">
252
- <div class="progress-row">
253
- <label
254
- x-text="`Remaining (${formatWindowLabel('primary', dashboardData.usage.primary.windowMinutes)})`"></label>
255
- <div role="progressbar" aria-valuemin="0" aria-valuemax="100"
256
- :aria-valuenow="formatPercentValue(selectedAccount.usage?.primaryRemainingPercent)">
257
- <div :style="{ width: `${formatPercentValue(selectedAccount.usage?.primaryRemainingPercent)}%` }">
258
- </div>
454
+ <div class="split-stack" style="margin-top: 12px">
455
+ <div class="progress-row">
456
+ <label
457
+ x-text="`Remaining (${formatWindowLabel('primary', dashboardData.usage.primary.windowMinutes)})`"></label>
458
+ <div role="progressbar" aria-valuemin="0" aria-valuemax="100"
459
+ :class="calculateProgressClass(selectedAccount.status, selectedAccount.usage?.primaryRemainingPercent)"
460
+ :aria-valuenow="formatPercentValue(selectedAccount.usage?.primaryRemainingPercent)">
461
+ <div :style="{ width: `${formatPercentValue(selectedAccount.usage?.primaryRemainingPercent)}%` }">
259
462
  </div>
260
- <span class="text-muted"
261
- x-text="formatPercent(selectedAccount.usage?.primaryRemainingPercent)"></span>
262
- </div>
263
- <div class="progress-row">
264
- <label
265
- x-text="`Remaining (${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`"></label>
266
- <div role="progressbar" aria-valuemin="0" aria-valuemax="100"
267
- :aria-valuenow="formatPercentValue(selectedAccount.usage?.secondaryRemainingPercent)">
268
- <div
269
- :style="{ width: `${formatPercentValue(selectedAccount.usage?.secondaryRemainingPercent)}%` }">
270
- </div>
271
- </div>
272
- <span class="text-muted"
273
- x-text="formatPercent(selectedAccount.usage?.secondaryRemainingPercent)"></span>
274
463
  </div>
464
+ <span
465
+ :class="calculateTextUsageTextClass(selectedAccount.status, selectedAccount.usage?.primaryRemainingPercent)"
466
+ x-text="formatPercent(selectedAccount.usage?.primaryRemainingPercent)"></span>
275
467
  </div>
276
-
277
- <fieldset style="margin-top: 12px">
278
- <legend>Tokens</legend>
279
- <div class="summary-list">
280
- <div><span>Access token</span><span x-text="formatAccessTokenLabel(selectedAccount.auth)"></span>
468
+ <div class="progress-row">
469
+ <label
470
+ x-text="`Remaining (${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`"></label>
471
+ <div role="progressbar" aria-valuemin="0" aria-valuemax="100"
472
+ :class="calculateProgressClass(selectedAccount.status, selectedAccount.usage?.secondaryRemainingPercent)"
473
+ :aria-valuenow="formatPercentValue(selectedAccount.usage?.secondaryRemainingPercent)">
474
+ <div :style="{ width: `${formatPercentValue(selectedAccount.usage?.secondaryRemainingPercent)}%` }">
281
475
  </div>
282
- <div><span>Refresh token</span><span x-text="formatRefreshTokenLabel(selectedAccount.auth)"></span>
283
- </div>
284
- <div><span>Id token</span><span x-text="formatIdTokenLabel(selectedAccount.auth)"></span></div>
285
476
  </div>
286
- </fieldset>
287
-
288
- <div class="inline-actions" style="margin-top: 12px">
289
- <template x-if="selectedAccount.status === 'deactivated'">
290
- <button @click="startReauthFlow">Re-authenticate</button>
291
- </template>
292
- <template x-if="selectedAccount.status === 'paused'">
293
- <button @click="resumeSelectedAccount">Resume</button>
294
- </template>
295
- <template x-if="selectedAccount.status !== 'deactivated' && selectedAccount.status !== 'paused'">
296
- <button @click="pauseSelectedAccount">Pause</button>
297
- </template>
298
- <button @click="deleteSelectedAccount">Delete</button>
477
+ <span
478
+ :class="calculateTextUsageTextClass(selectedAccount.status, selectedAccount.usage?.secondaryRemainingPercent)"
479
+ x-text="formatPercent(selectedAccount.usage?.secondaryRemainingPercent)"></span>
299
480
  </div>
300
481
  </div>
301
- </div>
302
- </article>
303
-
304
- <article role="tabpanel" id="tab-settings" :hidden="view !== 'settings'">
305
- <div class="panel">
306
- <h3>Routing settings</h3>
307
- <p class="text-muted">Toggle routing features. When both options are off, accounts are selected by
308
- balancing usage evenly.</p>
309
482
 
310
- <fieldset style="margin-top: 12px">
311
- <legend>Sticky threads</legend>
312
- <input
313
- id="sticky-threads-toggle"
314
- type="checkbox"
315
- x-model="settings.stickyThreadsEnabled"
316
- aria-describedby="sticky-threads-help"
317
- >
318
- <label for="sticky-threads-toggle">Enable sticky threads (reuse the same upstream account per conversation)</label>
319
- <div id="sticky-threads-help" class="text-muted" style="margin-top: 6px">
320
- When enabled, requests with a prompt cache key stay pinned to the same upstream account unless the
321
- pinned account becomes unavailable.
322
- </div>
323
- </fieldset>
324
-
325
- <fieldset style="margin-top: 12px">
326
- <legend>Reset priority</legend>
327
- <input
328
- id="reset-priority-toggle"
329
- type="checkbox"
330
- x-model="settings.preferEarlierResetAccounts"
331
- aria-describedby="reset-priority-help"
332
- >
333
- <label for="reset-priority-toggle">Prefer accounts that reset earlier first</label>
334
- <div id="reset-priority-help" class="text-muted" style="margin-top: 6px">
335
- When enabled, the load balancer prefers accounts whose secondary quota resets sooner, then balances
336
- usage.
483
+ <fieldset class="token-fieldset">
484
+ <legend>Tokens</legend>
485
+ <div class="summary-list">
486
+ <div><span>Access token</span><span x-text="formatAccessTokenLabel(selectedAccount.auth)"></span>
487
+ </div>
488
+ <div><span>Refresh token</span><span x-text="formatRefreshTokenLabel(selectedAccount.auth)"></span>
489
+ </div>
490
+ <div><span>Id token</span><span x-text="formatIdTokenLabel(selectedAccount.auth)"></span></div>
337
491
  </div>
338
492
  </fieldset>
339
493
 
340
494
  <div class="inline-actions" style="margin-top: 12px">
341
- <button type="button" @click="saveSettings" :disabled="settings.isSaving">
342
- <span>Save</span>
343
- </button>
495
+ <template x-if="selectedAccount.status === 'deactivated'">
496
+ <button @click="startReauthFlow">Re-authenticate</button>
497
+ </template>
498
+ <template x-if="selectedAccount.status === 'paused'">
499
+ <button @click="resumeSelectedAccount">Resume</button>
500
+ </template>
501
+ <template x-if="selectedAccount.status !== 'deactivated' && selectedAccount.status !== 'paused'">
502
+ <button @click="pauseSelectedAccount">Pause</button>
503
+ </template>
504
+ <button @click="deleteSelectedAccount">Delete</button>
344
505
  </div>
345
506
  </div>
346
- </article>
347
- </section>
348
- </div>
349
- <div class="status-bar">
350
- <template x-for="item in statusItems" :key="item">
351
- <p class="status-bar-field" x-text="item"></p>
352
- </template>
353
- </div>
354
- <div class="dialog-backdrop auth-dialog-backdrop" :class="{ 'is-open': authDialog.open }"
355
- :aria-hidden="!authDialog.open"></div>
356
- <div class="window active is-bright auth-dialog" role="dialog" aria-modal="true"
357
- aria-labelledby="add-account-title" :aria-hidden="!authDialog.open" :class="{ 'is-open': authDialog.open }">
358
- <div class="title-bar">
359
- <div class="title-bar-text" id="add-account-title">Add account</div>
360
- <div class="title-bar-controls">
361
- <button aria-label="Close" @click="closeAddAccountDialog"></button>
362
507
  </div>
363
- </div>
364
- <div class="window-body has-space auth-dialog__body">
365
- <template x-if="authDialog.stage === 'intro'">
366
- <div class="auth-dialog__intro">
367
- <p class="auth-dialog__lead">Sign in with ChatGPT OAuth. API keys are not supported.</p>
368
- <fieldset class="auth-dialog__methods">
369
- <legend>Sign-in method</legend>
370
- <div class="auth-dialog__option">
371
- <input id="auth-method-browser" type="radio" name="auth-method" value="browser"
372
- x-model="authDialog.selectedMethod">
373
- <label for="auth-method-browser">Browser login (PKCE)</label>
374
- <div class="auth-dialog__option-meta text-muted">Requires local callback on port 1455.</div>
375
- </div>
376
- <div class="auth-dialog__option">
377
- <input id="auth-method-device" type="radio" name="auth-method" value="device"
378
- x-model="authDialog.selectedMethod">
379
- <label for="auth-method-device">Device code</label>
380
- <div class="auth-dialog__option-meta text-muted">Use when running in Docker or remote environments.
381
- </div>
382
- </div>
383
- </fieldset>
384
- </div>
385
- </template>
386
- <template x-if="authDialog.stage === 'browser'">
387
- <div class="auth-dialog__panel">
388
- <p class="auth-dialog__lead">Open the authorization page to complete sign-in.</p>
389
- <div class="auth-dialog__detail">
390
- <div class="auth-dialog__label">Authorization URL</div>
391
- <div class="auth-dialog__value auth-dialog__link" x-text="authDialog.authorizationUrl"></div>
392
- </div>
393
- <div class="auth-dialog__status">
394
- <span class="spinner animate" aria-hidden="true" x-show="authDialog.status === 'pending'"></span>
395
- <span x-text="authDialog.statusLabel"></span>
396
- </div>
397
- </div>
398
- </template>
399
- <template x-if="authDialog.stage === 'device'">
400
- <div class="auth-dialog__panel">
401
- <p class="auth-dialog__lead">Use the device code to sign in. Enter the code at the verification URL.</p>
402
- <ol class="auth-dialog__steps">
403
- <li>Open the verification URL.</li>
404
- <li>Enter the user code shown below.</li>
405
- <li>Return here to finish the sign-in.</li>
406
- </ol>
407
- <div class="auth-dialog__device-grid">
408
- <div>
409
- <div class="auth-dialog__label">User code</div>
410
- <div class="auth-dialog__code-value" x-text="authDialog.userCode"></div>
411
- </div>
412
- <div>
413
- <div class="auth-dialog__label">Verification URL</div>
414
- <div class="auth-dialog__value auth-dialog__link" x-text="authDialog.verificationUrl"></div>
415
- </div>
416
- </div>
417
- <div class="auth-dialog__device-meta text-muted">
418
- Expires in <span x-text="formatCountdown(authDialog.remainingSeconds)"></span>
508
+ </article>
509
+
510
+ <article role="tabpanel" id="tab-settings" :hidden="view !== 'settings'">
511
+ <div class="panel">
512
+ <h3>Routing settings</h3>
513
+ <p class="text-muted">Toggle routing features. When both options are off, accounts are selected by
514
+ balancing usage evenly.</p>
515
+
516
+ <fieldset class="settings-fieldset">
517
+ <legend>Sticky threads</legend>
518
+ <input id="sticky-threads-toggle" type="checkbox" x-model="settings.stickyThreadsEnabled"
519
+ aria-describedby="sticky-threads-help">
520
+ <label for="sticky-threads-toggle">Enable sticky threads (reuse the same upstream account per
521
+ conversation)</label>
522
+ <div id="sticky-threads-help" class="text-muted" style="margin-top: 6px">
523
+ When enabled, requests with a prompt cache key stay pinned to the same upstream account unless the
524
+ pinned account becomes unavailable.
419
525
  </div>
420
- <div class="auth-dialog__status">
421
- <span class="spinner animate" aria-hidden="true" x-show="authDialog.status === 'pending'"></span>
422
- <span x-text="authDialog.statusLabel"></span>
526
+ </fieldset>
527
+
528
+ <fieldset class="settings-fieldset">
529
+ <legend>Reset priority</legend>
530
+ <input id="reset-priority-toggle" type="checkbox" x-model="settings.preferEarlierResetAccounts"
531
+ aria-describedby="reset-priority-help">
532
+ <label for="reset-priority-toggle">Prefer accounts that reset earlier first</label>
533
+ <div id="reset-priority-help" class="text-muted" style="margin-top: 6px">
534
+ When enabled, the load balancer prefers accounts whose secondary quota resets sooner, then balances
535
+ usage.
423
536
  </div>
537
+ </fieldset>
538
+
539
+ <div class="inline-actions" style="margin-top: 12px">
540
+ <button type="button" @click="saveSettings" :disabled="settings.isSaving">
541
+ <span>Save</span>
542
+ </button>
424
543
  </div>
425
- </template>
426
- <template x-if="authDialog.stage === 'success'">
427
- <div class="auth-dialog__panel">
428
- <p class="auth-dialog__lead">Account linked successfully.</p>
429
- <p class="text-muted">Return to the dashboard and select the new account.</p>
544
+ </div>
545
+ </article>
546
+ </section>
547
+ </div>
548
+ <div class="status-bar">
549
+ <template x-for="item in statusItems" :key="item">
550
+ <p class="status-bar-field" x-text="item"></p>
551
+ </template>
552
+ </div>
553
+ <div class="dialog-backdrop auth-dialog-backdrop" :class="{ 'is-open': authDialog.open }"
554
+ :aria-hidden="!authDialog.open"></div>
555
+ <div class="window active is-bright auth-dialog" role="dialog" aria-modal="true" aria-labelledby="add-account-title"
556
+ :aria-hidden="!authDialog.open" :class="{ 'is-open': authDialog.open }">
557
+ <div class="title-bar">
558
+ <div class="title-bar-text" id="add-account-title">Add account</div>
559
+ <div class="title-bar-controls">
560
+ <button aria-label="Close" @click="closeAddAccountDialog"></button>
561
+ </div>
562
+ </div>
563
+ <div class="window-body has-space auth-dialog__body">
564
+ <template x-if="authDialog.stage === 'intro'">
565
+ <div class="auth-dialog__intro">
566
+ <p class="auth-dialog__lead">Sign in with ChatGPT OAuth. API keys are not supported.</p>
567
+ <fieldset class="auth-dialog__methods">
568
+ <legend>Sign-in method</legend>
569
+ <div class="auth-dialog__option" @click="authDialog.selectedMethod = 'browser'">
570
+ <input id="auth-method-browser" type="radio" name="auth-method" value="browser"
571
+ x-model="authDialog.selectedMethod">
572
+ <label for="auth-method-browser">Browser login (PKCE)</label>
573
+ <div class="auth-dialog__option-meta text-muted">Requires local callback on port 1455.</div>
430
574
  </div>
431
- </template>
432
- <template x-if="authDialog.stage === 'error'">
433
- <div class="auth-dialog__panel auth-dialog__error">
434
- <p class="auth-dialog__lead">Authorization failed.</p>
435
- <p class="text-muted" x-text="authDialog.errorMessage"></p>
575
+ <div class="auth-dialog__option" @click="authDialog.selectedMethod = 'device'">
576
+ <input id="auth-method-device" type="radio" name="auth-method" value="device"
577
+ x-model="authDialog.selectedMethod">
578
+ <label for="auth-method-device">Device code</label>
579
+ <div class="auth-dialog__option-meta text-muted">Use when running in Docker or remote environments.
580
+ </div>
436
581
  </div>
437
- </template>
582
+ </fieldset>
438
583
  </div>
439
- <footer class="auth-dialog__actions">
440
- <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'intro'">
441
- <button type="button" @click="closeAddAccountDialog">Cancel</button>
442
- <button type="button" class="default" @click="startOAuth" :disabled="authDialog.isLoading">Start sign-in
443
- </button>
444
- </div>
445
- <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'browser'">
446
- <button type="button" @click="resetAuthDialogState">Change method</button>
447
- <button type="button" class="default" @click="openAuthorizationUrl"
448
- :disabled="!authDialog.authorizationUrl">Open sign-in page</button>
449
- </div>
450
- <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'device'">
451
- <button type="button" @click="resetAuthDialogState">Change method</button>
452
- <button type="button" @click="copyToClipboard(authDialog.userCode, 'User code')"
453
- :disabled="!authDialog.userCode">Copy code</button>
454
- <button type="button" @click="openVerificationUrl" :disabled="!authDialog.verificationUrl">Open link
455
- </button>
456
- </div>
457
- <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'success'">
458
- <button type="button" class="default" @click="closeAddAccountDialog">Done</button>
459
- </div>
460
- <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'error'">
461
- <button type="button" @click="resetAuthDialogState">Try again</button>
462
- <button type="button" class="default" @click="closeAddAccountDialog">Close</button>
584
+ </template>
585
+ <template x-if="authDialog.stage === 'browser'">
586
+ <div class="auth-dialog__panel">
587
+ <p class="auth-dialog__lead">Open the authorization page to complete sign-in.</p>
588
+ <div class="auth-dialog__detail">
589
+ <div class="auth-dialog__label">Authorization URL</div>
590
+ <div class="url-copy-row">
591
+ <a class="auth-dialog__value auth-dialog__link" :href="authDialog.authorizationUrl" target="_blank"
592
+ x-text="authDialog.authorizationUrl"></a>
593
+ <button type="button" class="copy-btn"
594
+ @click="copyToClipboard(authDialog.authorizationUrl, 'Authorization URL', $event)">Copy</button>
595
+ </div>
463
596
  </div>
464
- </footer>
465
- </div>
466
- <div class="dialog-backdrop" :class="{ 'is-open': messageBox.open }" :aria-hidden="!messageBox.open"
467
- @click="closeMessageBox"></div>
468
- <div class="window active is-bright message-dialog" role="dialog" aria-modal="true"
469
- aria-labelledby="message-box-title" aria-describedby="message-box-body" :aria-hidden="!messageBox.open"
470
- :class="{ 'is-open': messageBox.open }">
471
- <div class="title-bar">
472
- <div class="title-bar-text" id="message-box-title" x-text="messageBox.title"></div>
473
- <div class="title-bar-controls">
474
- <button aria-label="Close" @click="closeMessageBox"></button>
597
+ <div class="auth-dialog__status">
598
+ <span class="auth-dialog__status-text" x-text="authDialog.statusLabel"></span>
599
+ <span class="spinner animate" aria-hidden="true" x-show="authDialog.status === 'pending'"></span>
475
600
  </div>
476
601
  </div>
477
- <div class="window-body has-space message-dialog__body">
478
- <div class="message-dialog__content">
479
- <div class="message-dialog__icon" :class="messageBox.iconTone ? `is-${messageBox.iconTone}` : ''"
480
- aria-hidden="true" x-show="messageBox.iconTone"></div>
481
- <div class="message-dialog__copy">
482
- <p id="message-box-body" x-text="messageBox.message"></p>
483
- <template x-if="messageBox.details">
484
- <pre class="code-box message-dialog__details" x-text="messageBox.details"></pre>
485
- </template>
602
+ </template>
603
+ <template x-if="authDialog.stage === 'device'">
604
+ <div class="auth-dialog__panel">
605
+ <p class="auth-dialog__lead">Use the device code to sign in. Enter the code at the verification URL.</p>
606
+ <ol class="auth-dialog__steps">
607
+ <li>Open the verification URL.</li>
608
+ <li>Enter the user code shown below.</li>
609
+ <li>Return here to finish the sign-in.</li>
610
+ </ol>
611
+ <div class="auth-dialog__device-grid">
612
+ <div>
613
+ <div class="auth-dialog__label">User code</div>
614
+ <div class="auth-dialog__code-value">
615
+ <span x-text="authDialog.userCode"></span>
616
+ <button type="button" class="copy-btn"
617
+ @click="copyToClipboard(authDialog.userCode, 'User code', $event)">Copy</button>
618
+ </div>
619
+ </div>
620
+ <div>
621
+ <div class="auth-dialog__label">Verification URL</div>
622
+ <div class="url-copy-row">
623
+ <a class="auth-dialog__value auth-dialog__link" :href="authDialog.verificationUrl" target="_blank"
624
+ x-text="authDialog.verificationUrl"></a>
625
+ <button type="button" class="copy-btn"
626
+ @click="copyToClipboard(authDialog.verificationUrl, 'Verification URL', $event)">Copy</button>
627
+ </div>
628
+ </div>
629
+ </div>
630
+ <div class="auth-dialog__status">
631
+ <span class="auth-dialog__status-text" x-text="authDialog.statusLabel"></span>
632
+ <div class="auth-dialog__status-meta" x-show="authDialog.status === 'pending'">
633
+ <span class="spinner animate" aria-hidden="true"></span>
634
+ <span class="text-muted">Expires in <span
635
+ x-text="formatCountdown(authDialog.remainingSeconds)"></span></span>
486
636
  </div>
487
637
  </div>
488
638
  </div>
489
- <footer class="message-dialog__actions">
490
- <button x-show="messageBox.mode === 'confirm'" type="button" @click="cancelMessageBox"
491
- x-text="messageBox.cancelLabel"></button>
492
- <button x-show="messageBox.mode === 'confirm'" type="button" class="default" @click="confirmMessageBox"
493
- x-text="messageBox.confirmLabel"></button>
494
- <button x-show="messageBox.mode !== 'confirm'" type="button" class="default"
495
- @click="closeMessageBox">OK</button>
496
- </footer>
639
+ </template>
640
+ <template x-if="authDialog.stage === 'success'">
641
+ <div class="auth-dialog__panel">
642
+ <p class="auth-dialog__lead">Account linked successfully.</p>
643
+ <p class="text-muted">Return to the dashboard and select the new account.</p>
644
+ </div>
645
+ </template>
646
+ <template x-if="authDialog.stage === 'error'">
647
+ <div class="auth-dialog__panel auth-dialog__error">
648
+ <p class="auth-dialog__lead">Authorization failed.</p>
649
+ <p class="text-muted" x-text="authDialog.errorMessage"></p>
650
+ </div>
651
+ </template>
652
+ </div>
653
+ <footer class="auth-dialog__actions">
654
+ <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'intro'">
655
+ <button type="button" @click="closeAddAccountDialog">Cancel</button>
656
+ <button type="button" class="default" @click="startOAuth" :disabled="authDialog.isLoading">Start sign-in
657
+ </button>
658
+ </div>
659
+ <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'browser'">
660
+ <button type="button" @click="resetAuthDialogState">Change method</button>
661
+ <button type="button" class="default" @click="openAuthorizationUrl"
662
+ :disabled="!authDialog.authorizationUrl">Open sign-in page</button>
663
+ </div>
664
+ <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'device'">
665
+ <button type="button" @click="resetAuthDialogState">Change method</button>
666
+ <button type="button" class="default" @click="openVerificationUrl" :disabled="!authDialog.verificationUrl">Open
667
+ link</button>
668
+ </div>
669
+ <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'success'">
670
+ <button type="button" class="default" @click="closeAddAccountDialog">Done</button>
671
+ </div>
672
+ <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'error'">
673
+ <button type="button" @click="resetAuthDialogState">Try again</button>
674
+ <button type="button" class="default" @click="closeAddAccountDialog">Close</button>
675
+ </div>
676
+ </footer>
677
+ </div>
678
+ <div class="dialog-backdrop" :class="{ 'is-open': messageBox.open }" :aria-hidden="!messageBox.open"
679
+ @click="closeMessageBox"></div>
680
+ <div class="window active is-bright message-dialog" role="dialog" aria-modal="true"
681
+ aria-labelledby="message-box-title" aria-describedby="message-box-body" :aria-hidden="!messageBox.open"
682
+ :class="{ 'is-open': messageBox.open }">
683
+ <div class="title-bar">
684
+ <div class="title-bar-text" id="message-box-title" x-text="messageBox.title"></div>
685
+ <div class="title-bar-controls">
686
+ <button aria-label="Close" @click="closeMessageBox"></button>
687
+ </div>
688
+ </div>
689
+ <div class="window-body has-space message-dialog__body">
690
+ <div class="message-dialog__content">
691
+ <div class="message-dialog__icon" :class="messageBox.iconTone ? `is-${messageBox.iconTone}` : ''"
692
+ aria-hidden="true" x-show="messageBox.iconTone"></div>
693
+ <div class="message-dialog__copy">
694
+ <p id="message-box-body" x-text="messageBox.message"></p>
695
+ <template x-if="messageBox.details">
696
+ <pre class="code-box message-dialog__details" x-text="messageBox.details"></pre>
697
+ </template>
698
+ </div>
497
699
  </div>
498
700
  </div>
701
+ <footer class="message-dialog__actions">
702
+ <button x-show="messageBox.mode === 'confirm'" type="button" @click="cancelMessageBox"
703
+ x-text="messageBox.cancelLabel"></button>
704
+ <button x-show="messageBox.mode === 'confirm'" type="button" class="default" @click="confirmMessageBox"
705
+ x-text="messageBox.confirmLabel"></button>
706
+ <button x-show="messageBox.mode !== 'confirm'" type="button" class="default" @click="closeMessageBox">OK</button>
707
+ </footer>
708
+ </div>
709
+ </div>
710
+ </main>
499
711
  </div>
500
712
  <script defer src="/dashboard/index.js"></script>
501
713
  <script defer src="https://unpkg.com/alpinejs@3.13.2/dist/cdn.min.js"></script>
502
714
  </body>
503
715
 
504
- </html>
716
+ </html>