codex-lb 0.2.0__py3-none-any.whl → 0.3.1__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.
Files changed (44) hide show
  1. app/core/auth/__init__.py +10 -0
  2. app/core/balancer/logic.py +33 -6
  3. app/core/config/settings.py +2 -0
  4. app/core/usage/__init__.py +2 -0
  5. app/core/usage/logs.py +12 -2
  6. app/core/usage/quota.py +10 -4
  7. app/core/usage/types.py +3 -2
  8. app/db/migrations/__init__.py +14 -3
  9. app/db/migrations/versions/add_accounts_chatgpt_account_id.py +29 -0
  10. app/db/migrations/versions/add_accounts_reset_at.py +29 -0
  11. app/db/migrations/versions/add_dashboard_settings.py +31 -0
  12. app/db/migrations/versions/add_request_logs_reasoning_effort.py +21 -0
  13. app/db/models.py +33 -0
  14. app/db/session.py +71 -11
  15. app/dependencies.py +27 -1
  16. app/main.py +11 -2
  17. app/modules/accounts/auth_manager.py +44 -3
  18. app/modules/accounts/repository.py +14 -6
  19. app/modules/accounts/service.py +4 -2
  20. app/modules/oauth/service.py +4 -3
  21. app/modules/proxy/load_balancer.py +74 -5
  22. app/modules/proxy/service.py +155 -31
  23. app/modules/proxy/sticky_repository.py +56 -0
  24. app/modules/request_logs/repository.py +6 -3
  25. app/modules/request_logs/schemas.py +2 -0
  26. app/modules/request_logs/service.py +8 -1
  27. app/modules/settings/__init__.py +1 -0
  28. app/modules/settings/api.py +37 -0
  29. app/modules/settings/repository.py +40 -0
  30. app/modules/settings/schemas.py +13 -0
  31. app/modules/settings/service.py +33 -0
  32. app/modules/shared/schemas.py +16 -2
  33. app/modules/usage/schemas.py +1 -0
  34. app/modules/usage/service.py +17 -1
  35. app/modules/usage/updater.py +36 -7
  36. app/static/index.css +1024 -319
  37. app/static/index.html +461 -377
  38. app/static/index.js +327 -49
  39. {codex_lb-0.2.0.dist-info → codex_lb-0.3.1.dist-info}/METADATA +33 -7
  40. {codex_lb-0.2.0.dist-info → codex_lb-0.3.1.dist-info}/RECORD +43 -34
  41. app/static/7.css +0 -1336
  42. {codex_lb-0.2.0.dist-info → codex_lb-0.3.1.dist-info}/WHEEL +0 -0
  43. {codex_lb-0.2.0.dist-info → codex_lb-0.3.1.dist-info}/entry_points.txt +0 -0
  44. {codex_lb-0.2.0.dist-info → codex_lb-0.3.1.dist-info}/licenses/LICENSE +0 -0
app/static/index.html CHANGED
@@ -9,446 +9,530 @@
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>
41
- </template>
42
- </menu>
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>
43
59
 
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>
54
- </div>
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>
55
70
  </div>
71
+ </div>
56
72
 
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>
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>
80
+ </template>
81
+ </div>
82
+
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>
63
108
  </div>
64
- </template>
65
- </div>
109
+ </div>
110
+ </template>
111
+ </div>
66
112
 
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>
76
- </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>
77
122
  </div>
78
- <ul class="legend">
79
- <template x-for="item in donut.items" :key="item.label">
80
- <li>
81
- <span>
82
- <i :style="{ '--legend-color': item.color }"></i>
83
- <span x-text="item.label"></span>
84
- </span>
85
- <span x-text="item.detail"></span>
86
- </li>
87
- </template>
88
- </ul>
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>
89
144
  </div>
90
145
  </div>
91
146
  </template>
92
147
  </div>
148
+ </section>
93
149
 
94
- <section style="margin-top: 12px">
95
- <h3>Account status</h3>
96
- <div class="account-grid">
97
- <template x-for="card in dashboard.accountCards" :key="card.email">
98
- <div class="account-card">
99
- <header>
100
- <div>
101
- <div x-text="`${card.email} (${card.plan})`"></div>
102
- <div class="account-id" x-text="card.accountId"></div>
103
- </div>
104
- <span class="status-pill" :class="card.status.class" x-text="card.status.label"></span>
105
- </header>
106
- <div class="progress-row">
107
- <label
108
- x-text="`Remaining (${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`"></label>
109
- <template x-if="card.marquee">
110
- <div role="progressbar" class="marquee"></div>
111
- </template>
112
- <template x-if="!card.marquee">
113
- <div role="progressbar" :class="card.progressClass" aria-valuemin="0" aria-valuemax="100"
114
- :aria-valuenow="card.remaining">
115
- <div :style="{ width: `${card.remaining}%` }"></div>
116
- </div>
117
- </template>
118
- <span class="text-muted" x-text="card.remainingText"></span>
119
- </div>
120
- <div class="text-muted" x-text="card.meta"></div>
121
- <div class="account-actions">
122
- <template x-for="action in card.actions" :key="action.label">
123
- <button x-text="action.label" @click="handleAccountAction(action, card)"></button>
124
- </template>
125
- </div>
126
- </div>
127
- </template>
128
- </div>
129
- </section>
150
+ <div class="panel" style="margin-top: 12px">
151
+ <h3>Recent requests</h3>
152
+ <div class="table-wrap table-wrap--requests table-wrap--column-lines has-scrollbar">
153
+ <table>
154
+ <thead>
155
+ <tr>
156
+ <th class="highlighted indicator" style="width: 15%">Time</th>
157
+ <th style="width: 25%">Account</th>
158
+ <th style="width: 15%">Model</th>
159
+ <th style="width: 10%">Status</th>
160
+ <th style="width: 15%">Error</th>
161
+ <th style="text-align: right; width: 10%">Tokens</th>
162
+ <th style="text-align: right; width: 10%">Cost</th>
163
+ </tr>
164
+ </thead>
165
+ <tbody>
166
+ <template x-for="request in dashboard.requests" :key="request.key">
167
+ <tr>
168
+ <td x-text="request.time"></td>
169
+ <td class="cell-truncate" x-text="request.account" :title="request.account"></td>
170
+ <td class="cell-truncate" x-text="request.model" :title="request.model"></td>
171
+ <td><span class="status-pill" :class="request.status.class" x-text="request.status.label"></span>
172
+ </td>
173
+ <td class="cell-truncate" x-text="request.error" :title="request.errorTitle || ''"></td>
174
+ <td style="text-align: right" x-text="request.tokens"></td>
175
+ <td style="text-align: right" x-text="request.cost"></td>
176
+ </tr>
177
+ </template>
178
+ </tbody>
179
+ </table>
180
+ </div>
181
+ </div>
182
+ </article>
130
183
 
131
- <div class="panel" style="margin-top: 12px">
132
- <h3>Recent requests</h3>
133
- <div class="table-wrap table-wrap--requests table-wrap--column-lines has-scrollbar">
184
+ <article role="tabpanel" id="tab-accounts" :hidden="view !== 'accounts'">
185
+ <div class="two-column">
186
+ <div class="panel account-list-panel">
187
+ <h3>Account list</h3>
188
+ <div class="list-actions">
189
+ <div class="searchbox">
190
+ <input type="search" placeholder="Search accounts" aria-label="Search accounts" x-ref="accountSearch"
191
+ x-model="accounts.searchQuery" @keydown.escape.prevent="clearAccountSearch">
192
+ <button type="button" aria-label="search" title="Search accounts"
193
+ @click="focusAccountSearch"></button>
194
+ </div>
195
+ <label role="button" :class="{ 'is-disabled': importState.isLoading }" @click="logImportClick('label')">
196
+ <input type="file" class="file-input" accept="application/json" @click="logImportClick('input')"
197
+ @change="handleAuthImport($event)" :disabled="importState.isLoading">
198
+ Import auth.json
199
+ </label>
200
+ <button type="button" @click="openAddAccountDialog">Add account</button>
201
+ </div>
202
+ <div class="table-wrap table-wrap--column-lines has-scrollbar">
134
203
  <table>
135
204
  <thead>
136
205
  <tr>
137
- <th class="highlighted indicator">Time</th>
138
- <th>Account</th>
139
- <th>Model</th>
140
- <th>Status</th>
141
- <th>Error</th>
142
- <th style="text-align: right">Tokens</th>
143
- <th style="text-align: right">Cost</th>
206
+ <th class="highlighted indicator" style="width: 28%">Account ID</th>
207
+ <th style="width: 25%">Email</th>
208
+ <th style="width: 10%">Plan</th>
209
+ <th style="width: 12%">Status</th>
210
+ <th style="text-align: center; width: 12%">
211
+ <span style="display: inline-flex; flex-direction: column; align-items: center">
212
+ <span>Remaining</span>
213
+ <span style="font-weight: normal; font-size: 0.9em; color: var(--text-muted)"
214
+ x-text="`(${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`">
215
+ </span>
216
+ </span>
217
+ </th>
218
+ <th style="text-align: center; width: 13%">
219
+ <span style="display: inline-flex; flex-direction: column; align-items: center">
220
+ <span style="white-space: nowrap">Quota reset</span>
221
+ <span style="font-weight: normal; font-size: 0.9em; color: var(--text-muted)"
222
+ x-text="`(${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`">
223
+ </span>
224
+ </span>
225
+ </th>
144
226
  </tr>
145
227
  </thead>
146
228
  <tbody>
147
- <template x-for="request in dashboard.requests" :key="request.key">
229
+ <template x-for="account in filteredAccounts" :key="account.id">
230
+ <tr :class="{ highlighted: account.id === accounts.selectedId }"
231
+ @click="selectAccount(account.id)" style="cursor: pointer">
232
+ <td class="cell-truncate" x-text="account.id" :title="account.id"></td>
233
+ <td class="cell-truncate" x-text="account.email" :title="account.email"></td>
234
+ <td x-text="planLabel(account.plan)"></td>
235
+ <td><span class="status-pill" :class="account.status"
236
+ x-text="statusLabel(account.status)"></span>
237
+ </td>
238
+ <td style="text-align: center"
239
+ :class="calculateTextUsageTextClass(account.status, account.usage?.secondaryRemainingPercent)"
240
+ x-text="formatPercent(account.usage?.secondaryRemainingPercent)">
241
+ </td>
242
+ <td style="text-align: center" x-text="formatQuotaResetLabel(account.resetAtSecondary)"></td>
243
+ </tr>
244
+ </template>
245
+ <template x-if="filteredAccounts.length === 0">
148
246
  <tr>
149
- <td x-text="request.time"></td>
150
- <td x-text="request.account"></td>
151
- <td x-text="request.model"></td>
152
- <td><span class="status-pill" :class="request.status.class"
153
- x-text="request.status.label"></span></td>
154
- <td x-text="request.error" :title="request.errorTitle || ''"></td>
155
- <td style="text-align: right" x-text="request.tokens"></td>
156
- <td style="text-align: right" x-text="request.cost"></td>
247
+ <td colspan="6" class="text-muted" style="text-align: center">
248
+ <span
249
+ x-text="accounts.searchQuery ? 'No accounts match your search.' : 'No accounts available.'"></span>
250
+ </td>
157
251
  </tr>
158
252
  </template>
159
253
  </tbody>
160
254
  </table>
161
255
  </div>
162
256
  </div>
163
- </article>
164
257
 
165
- <article role="tabpanel" id="tab-accounts" :hidden="view !== 'accounts'">
166
- <div class="two-column">
167
- <div class="panel account-list-panel">
168
- <h3>Account list</h3>
169
- <div class="list-actions">
170
- <div class="searchbox">
171
- <input type="search" placeholder="Search accounts" aria-label="Search accounts"
172
- x-ref="accountSearch" x-model="accounts.searchQuery" @keydown.escape.prevent="clearAccountSearch">
173
- <button type="button" aria-label="search" title="Search accounts"
174
- @click="focusAccountSearch"></button>
175
- </div>
176
- <label role="button" :class="{ 'is-disabled': importState.isLoading }"
177
- @click="logImportClick('label')">
178
- <input type="file" class="file-input" accept="application/json" @click="logImportClick('input')"
179
- @change="handleAuthImport($event)" :disabled="importState.isLoading">
180
- Import auth.json
181
- </label>
182
- <button type="button" @click="openAddAccountDialog">Add account</button>
258
+ <div class="panel">
259
+ <h3>Selected account</h3>
260
+ <div class="summary-list">
261
+ <div><span>Email</span><strong x-text="selectedAccount.email" :title="selectedAccount.email"></strong>
183
262
  </div>
184
- <div class="table-wrap table-wrap--column-lines has-scrollbar">
185
- <table>
186
- <thead>
187
- <tr>
188
- <th class="highlighted indicator">Account ID</th>
189
- <th>Email</th>
190
- <th>Plan</th>
191
- <th>Status</th>
192
- <th style="text-align: right"
193
- x-text="`Remaining (${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`">
194
- </th>
195
- <th
196
- x-text="`Quota reset (${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`">
197
- </th>
198
- </tr>
199
- </thead>
200
- <tbody>
201
- <template x-for="account in filteredAccounts" :key="account.id">
202
- <tr :class="{ highlighted: account.id === accounts.selectedId }"
203
- @click="selectAccount(account.id)" style="cursor: pointer">
204
- <td x-text="account.id"></td>
205
- <td x-text="account.email"></td>
206
- <td x-text="planLabel(account.plan)"></td>
207
- <td><span class="status-pill" :class="account.status"
208
- x-text="statusLabel(account.status)"></span>
209
- </td>
210
- <td style="text-align: right"
211
- x-text="formatPercent(account.usage?.secondaryRemainingPercent)">
212
- </td>
213
- <td x-text="formatQuotaResetLabel(account.resetAtSecondary)"></td>
214
- </tr>
215
- </template>
216
- <template x-if="filteredAccounts.length === 0">
217
- <tr>
218
- <td colspan="6" class="text-muted" style="text-align: center">
219
- <span
220
- x-text="accounts.searchQuery ? 'No accounts match your search.' : 'No accounts available.'"></span>
221
- </td>
222
- </tr>
223
- </template>
224
- </tbody>
225
- </table>
263
+ <div><span>Account ID</span><strong x-text="selectedAccount.id" :title="selectedAccount.id"></strong>
226
264
  </div>
227
- </div>
228
-
229
- <div class="panel">
230
- <h3>Selected account</h3>
231
- <div class="summary-list">
232
- <div><span>Email</span><strong x-text="selectedAccount.email"></strong></div>
233
- <div><span>Account ID</span><strong x-text="selectedAccount.id"></strong></div>
234
- <div><span>Plan</span><strong x-text="planLabel(selectedAccount.plan)"></strong></div>
235
- <div>
236
- <span>Status</span>
237
- <span class="status-pill" :class="selectedAccount.status"
238
- x-text="statusLabel(selectedAccount.status)"></span>
239
- </div>
240
- <div><span
241
- x-text="`Quota reset (${formatWindowLabel('primary', dashboardData.usage.primary.windowMinutes)})`"></span><strong
242
- x-text="formatQuotaResetLabel(selectedAccount.resetAtPrimary)"></strong></div>
243
- <div><span
244
- x-text="`Quota reset (${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`"></span><strong
245
- x-text="formatQuotaResetLabel(selectedAccount.resetAtSecondary)"></strong></div>
265
+ <div><span>Plan</span><strong x-text="planLabel(selectedAccount.plan)"></strong></div>
266
+ <div>
267
+ <span>Status</span>
268
+ <span class="status-pill" :class="selectedAccount.status"
269
+ x-text="statusLabel(selectedAccount.status)"></span>
246
270
  </div>
271
+ <div><span
272
+ x-text="`Quota reset (${formatWindowLabel('primary', dashboardData.usage.primary.windowMinutes)})`"></span><strong
273
+ x-text="formatQuotaResetLabel(selectedAccount.resetAtPrimary)"></strong></div>
274
+ <div><span
275
+ x-text="`Quota reset (${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`"></span><strong
276
+ x-text="formatQuotaResetLabel(selectedAccount.resetAtSecondary)"></strong></div>
277
+ </div>
247
278
 
248
- <div class="split-stack" style="margin-top: 12px">
249
- <div class="progress-row">
250
- <label
251
- x-text="`Remaining (${formatWindowLabel('primary', dashboardData.usage.primary.windowMinutes)})`"></label>
252
- <div role="progressbar" aria-valuemin="0" aria-valuemax="100"
253
- :aria-valuenow="formatPercentValue(selectedAccount.usage?.primaryRemainingPercent)">
254
- <div :style="{ width: `${formatPercentValue(selectedAccount.usage?.primaryRemainingPercent)}%` }">
255
- </div>
256
- </div>
257
- <span class="text-muted"
258
- x-text="formatPercent(selectedAccount.usage?.primaryRemainingPercent)"></span>
259
- </div>
260
- <div class="progress-row">
261
- <label
262
- x-text="`Remaining (${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`"></label>
263
- <div role="progressbar" aria-valuemin="0" aria-valuemax="100"
264
- :aria-valuenow="formatPercentValue(selectedAccount.usage?.secondaryRemainingPercent)">
265
- <div
266
- :style="{ width: `${formatPercentValue(selectedAccount.usage?.secondaryRemainingPercent)}%` }">
267
- </div>
279
+ <div class="split-stack" style="margin-top: 12px">
280
+ <div class="progress-row">
281
+ <label
282
+ x-text="`Remaining (${formatWindowLabel('primary', dashboardData.usage.primary.windowMinutes)})`"></label>
283
+ <div role="progressbar" aria-valuemin="0" aria-valuemax="100"
284
+ :class="calculateProgressClass(selectedAccount.status, selectedAccount.usage?.primaryRemainingPercent)"
285
+ :aria-valuenow="formatPercentValue(selectedAccount.usage?.primaryRemainingPercent)">
286
+ <div :style="{ width: `${formatPercentValue(selectedAccount.usage?.primaryRemainingPercent)}%` }">
268
287
  </div>
269
- <span class="text-muted"
270
- x-text="formatPercent(selectedAccount.usage?.secondaryRemainingPercent)"></span>
271
288
  </div>
289
+ <span
290
+ :class="calculateTextUsageTextClass(selectedAccount.status, selectedAccount.usage?.primaryRemainingPercent)"
291
+ x-text="formatPercent(selectedAccount.usage?.primaryRemainingPercent)"></span>
272
292
  </div>
273
-
274
- <fieldset style="margin-top: 12px">
275
- <legend>Tokens</legend>
276
- <div class="summary-list">
277
- <div><span>Access token</span><span x-text="formatAccessTokenLabel(selectedAccount.auth)"></span>
293
+ <div class="progress-row">
294
+ <label
295
+ x-text="`Remaining (${formatWindowLabel('secondary', dashboardData.usage.secondary.windowMinutes)})`"></label>
296
+ <div role="progressbar" aria-valuemin="0" aria-valuemax="100"
297
+ :class="calculateProgressClass(selectedAccount.status, selectedAccount.usage?.secondaryRemainingPercent)"
298
+ :aria-valuenow="formatPercentValue(selectedAccount.usage?.secondaryRemainingPercent)">
299
+ <div :style="{ width: `${formatPercentValue(selectedAccount.usage?.secondaryRemainingPercent)}%` }">
278
300
  </div>
279
- <div><span>Refresh token</span><span x-text="formatRefreshTokenLabel(selectedAccount.auth)"></span>
280
- </div>
281
- <div><span>Id token</span><span x-text="formatIdTokenLabel(selectedAccount.auth)"></span></div>
282
301
  </div>
283
- </fieldset>
284
-
285
- <div class="inline-actions" style="margin-top: 12px">
286
- <template x-if="selectedAccount.status === 'deactivated'">
287
- <button @click="startReauthFlow">Re-authenticate</button>
288
- </template>
289
- <template x-if="selectedAccount.status === 'paused'">
290
- <button @click="resumeSelectedAccount">Resume</button>
291
- </template>
292
- <template x-if="selectedAccount.status !== 'deactivated' && selectedAccount.status !== 'paused'">
293
- <button @click="pauseSelectedAccount">Pause</button>
294
- </template>
295
- <button @click="deleteSelectedAccount">Delete</button>
302
+ <span
303
+ :class="calculateTextUsageTextClass(selectedAccount.status, selectedAccount.usage?.secondaryRemainingPercent)"
304
+ x-text="formatPercent(selectedAccount.usage?.secondaryRemainingPercent)"></span>
296
305
  </div>
297
306
  </div>
298
- </div>
299
- </article>
300
- </section>
301
- </div>
302
- <div class="status-bar">
303
- <template x-for="item in statusItems" :key="item">
304
- <p class="status-bar-field" x-text="item"></p>
305
- </template>
306
- </div>
307
- <div class="dialog-backdrop auth-dialog-backdrop" :class="{ 'is-open': authDialog.open }"
308
- :aria-hidden="!authDialog.open"></div>
309
- <div class="window active is-bright auth-dialog" role="dialog" aria-modal="true"
310
- aria-labelledby="add-account-title" :aria-hidden="!authDialog.open" :class="{ 'is-open': authDialog.open }">
311
- <div class="title-bar">
312
- <div class="title-bar-text" id="add-account-title">Add account</div>
313
- <div class="title-bar-controls">
314
- <button aria-label="Close" @click="closeAddAccountDialog"></button>
315
- </div>
316
- </div>
317
- <div class="window-body has-space auth-dialog__body">
318
- <template x-if="authDialog.stage === 'intro'">
319
- <div class="auth-dialog__intro">
320
- <p class="auth-dialog__lead">Sign in with ChatGPT OAuth. API keys are not supported.</p>
321
- <fieldset class="auth-dialog__methods">
322
- <legend>Sign-in method</legend>
323
- <div class="auth-dialog__option">
324
- <input id="auth-method-browser" type="radio" name="auth-method" value="browser"
325
- x-model="authDialog.selectedMethod">
326
- <label for="auth-method-browser">Browser login (PKCE)</label>
327
- <div class="auth-dialog__option-meta text-muted">Requires local callback on port 1455.</div>
328
- </div>
329
- <div class="auth-dialog__option">
330
- <input id="auth-method-device" type="radio" name="auth-method" value="device"
331
- x-model="authDialog.selectedMethod">
332
- <label for="auth-method-device">Device code</label>
333
- <div class="auth-dialog__option-meta text-muted">Use when running in Docker or remote environments.
307
+
308
+ <fieldset class="token-fieldset">
309
+ <legend>Tokens</legend>
310
+ <div class="summary-list">
311
+ <div><span>Access token</span><span x-text="formatAccessTokenLabel(selectedAccount.auth)"></span>
312
+ </div>
313
+ <div><span>Refresh token</span><span x-text="formatRefreshTokenLabel(selectedAccount.auth)"></span>
334
314
  </div>
315
+ <div><span>Id token</span><span x-text="formatIdTokenLabel(selectedAccount.auth)"></span></div>
335
316
  </div>
336
317
  </fieldset>
337
- </div>
338
- </template>
339
- <template x-if="authDialog.stage === 'browser'">
340
- <div class="auth-dialog__panel">
341
- <p class="auth-dialog__lead">Open the authorization page to complete sign-in.</p>
342
- <div class="auth-dialog__detail">
343
- <div class="auth-dialog__label">Authorization URL</div>
344
- <div class="auth-dialog__value auth-dialog__link" x-text="authDialog.authorizationUrl"></div>
345
- </div>
346
- <div class="auth-dialog__status">
347
- <span class="spinner animate" aria-hidden="true" x-show="authDialog.status === 'pending'"></span>
348
- <span x-text="authDialog.statusLabel"></span>
318
+
319
+ <div class="inline-actions" style="margin-top: 12px">
320
+ <template x-if="selectedAccount.status === 'deactivated'">
321
+ <button @click="startReauthFlow">Re-authenticate</button>
322
+ </template>
323
+ <template x-if="selectedAccount.status === 'paused'">
324
+ <button @click="resumeSelectedAccount">Resume</button>
325
+ </template>
326
+ <template x-if="selectedAccount.status !== 'deactivated' && selectedAccount.status !== 'paused'">
327
+ <button @click="pauseSelectedAccount">Pause</button>
328
+ </template>
329
+ <button @click="deleteSelectedAccount">Delete</button>
349
330
  </div>
350
331
  </div>
351
- </template>
352
- <template x-if="authDialog.stage === 'device'">
353
- <div class="auth-dialog__panel">
354
- <p class="auth-dialog__lead">Use the device code to sign in. Enter the code at the verification URL.</p>
355
- <ol class="auth-dialog__steps">
356
- <li>Open the verification URL.</li>
357
- <li>Enter the user code shown below.</li>
358
- <li>Return here to finish the sign-in.</li>
359
- </ol>
360
- <div class="auth-dialog__device-grid">
361
- <div>
362
- <div class="auth-dialog__label">User code</div>
363
- <div class="auth-dialog__code-value" x-text="authDialog.userCode"></div>
364
- </div>
365
- <div>
366
- <div class="auth-dialog__label">Verification URL</div>
367
- <div class="auth-dialog__value auth-dialog__link" x-text="authDialog.verificationUrl"></div>
368
- </div>
369
- </div>
370
- <div class="auth-dialog__device-meta text-muted">
371
- Expires in <span x-text="formatCountdown(authDialog.remainingSeconds)"></span>
332
+ </div>
333
+ </article>
334
+
335
+ <article role="tabpanel" id="tab-settings" :hidden="view !== 'settings'">
336
+ <div class="panel">
337
+ <h3>Routing settings</h3>
338
+ <p class="text-muted">Toggle routing features. When both options are off, accounts are selected by
339
+ balancing usage evenly.</p>
340
+
341
+ <fieldset class="settings-fieldset">
342
+ <legend>Sticky threads</legend>
343
+ <input id="sticky-threads-toggle" type="checkbox" x-model="settings.stickyThreadsEnabled"
344
+ aria-describedby="sticky-threads-help">
345
+ <label for="sticky-threads-toggle">Enable sticky threads (reuse the same upstream account per
346
+ conversation)</label>
347
+ <div id="sticky-threads-help" class="text-muted" style="margin-top: 6px">
348
+ When enabled, requests with a prompt cache key stay pinned to the same upstream account unless the
349
+ pinned account becomes unavailable.
372
350
  </div>
373
- <div class="auth-dialog__status">
374
- <span class="spinner animate" aria-hidden="true" x-show="authDialog.status === 'pending'"></span>
375
- <span x-text="authDialog.statusLabel"></span>
351
+ </fieldset>
352
+
353
+ <fieldset class="settings-fieldset">
354
+ <legend>Reset priority</legend>
355
+ <input id="reset-priority-toggle" type="checkbox" x-model="settings.preferEarlierResetAccounts"
356
+ aria-describedby="reset-priority-help">
357
+ <label for="reset-priority-toggle">Prefer accounts that reset earlier first</label>
358
+ <div id="reset-priority-help" class="text-muted" style="margin-top: 6px">
359
+ When enabled, the load balancer prefers accounts whose secondary quota resets sooner, then balances
360
+ usage.
376
361
  </div>
362
+ </fieldset>
363
+
364
+ <div class="inline-actions" style="margin-top: 12px">
365
+ <button type="button" @click="saveSettings" :disabled="settings.isSaving">
366
+ <span>Save</span>
367
+ </button>
377
368
  </div>
378
- </template>
379
- <template x-if="authDialog.stage === 'success'">
380
- <div class="auth-dialog__panel">
381
- <p class="auth-dialog__lead">Account linked successfully.</p>
382
- <p class="text-muted">Return to the dashboard and select the new account.</p>
369
+ </div>
370
+ </article>
371
+ </section>
372
+ </div>
373
+ <div class="status-bar">
374
+ <template x-for="item in statusItems" :key="item">
375
+ <p class="status-bar-field" x-text="item"></p>
376
+ </template>
377
+ </div>
378
+ <div class="dialog-backdrop auth-dialog-backdrop" :class="{ 'is-open': authDialog.open }"
379
+ :aria-hidden="!authDialog.open"></div>
380
+ <div class="window active is-bright auth-dialog" role="dialog" aria-modal="true" aria-labelledby="add-account-title"
381
+ :aria-hidden="!authDialog.open" :class="{ 'is-open': authDialog.open }">
382
+ <div class="title-bar">
383
+ <div class="title-bar-text" id="add-account-title">Add account</div>
384
+ <div class="title-bar-controls">
385
+ <button aria-label="Close" @click="closeAddAccountDialog"></button>
386
+ </div>
387
+ </div>
388
+ <div class="window-body has-space auth-dialog__body">
389
+ <template x-if="authDialog.stage === 'intro'">
390
+ <div class="auth-dialog__intro">
391
+ <p class="auth-dialog__lead">Sign in with ChatGPT OAuth. API keys are not supported.</p>
392
+ <fieldset class="auth-dialog__methods">
393
+ <legend>Sign-in method</legend>
394
+ <div class="auth-dialog__option" @click="authDialog.selectedMethod = 'browser'">
395
+ <input id="auth-method-browser" type="radio" name="auth-method" value="browser"
396
+ x-model="authDialog.selectedMethod">
397
+ <label for="auth-method-browser">Browser login (PKCE)</label>
398
+ <div class="auth-dialog__option-meta text-muted">Requires local callback on port 1455.</div>
383
399
  </div>
384
- </template>
385
- <template x-if="authDialog.stage === 'error'">
386
- <div class="auth-dialog__panel auth-dialog__error">
387
- <p class="auth-dialog__lead">Authorization failed.</p>
388
- <p class="text-muted" x-text="authDialog.errorMessage"></p>
400
+ <div class="auth-dialog__option" @click="authDialog.selectedMethod = 'device'">
401
+ <input id="auth-method-device" type="radio" name="auth-method" value="device"
402
+ x-model="authDialog.selectedMethod">
403
+ <label for="auth-method-device">Device code</label>
404
+ <div class="auth-dialog__option-meta text-muted">Use when running in Docker or remote environments.
405
+ </div>
389
406
  </div>
390
- </template>
407
+ </fieldset>
391
408
  </div>
392
- <footer class="auth-dialog__actions">
393
- <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'intro'">
394
- <button type="button" @click="closeAddAccountDialog">Cancel</button>
395
- <button type="button" class="default" @click="startOAuth" :disabled="authDialog.isLoading">Start sign-in
396
- </button>
397
- </div>
398
- <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'browser'">
399
- <button type="button" @click="resetAuthDialogState">Change method</button>
400
- <button type="button" class="default" @click="openAuthorizationUrl"
401
- :disabled="!authDialog.authorizationUrl">Open sign-in page</button>
402
- </div>
403
- <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'device'">
404
- <button type="button" @click="resetAuthDialogState">Change method</button>
405
- <button type="button" @click="copyToClipboard(authDialog.userCode, 'User code')"
406
- :disabled="!authDialog.userCode">Copy code</button>
407
- <button type="button" @click="openVerificationUrl" :disabled="!authDialog.verificationUrl">Open link
408
- </button>
409
- </div>
410
- <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'success'">
411
- <button type="button" class="default" @click="closeAddAccountDialog">Done</button>
412
- </div>
413
- <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'error'">
414
- <button type="button" @click="resetAuthDialogState">Try again</button>
415
- <button type="button" class="default" @click="closeAddAccountDialog">Close</button>
409
+ </template>
410
+ <template x-if="authDialog.stage === 'browser'">
411
+ <div class="auth-dialog__panel">
412
+ <p class="auth-dialog__lead">Open the authorization page to complete sign-in.</p>
413
+ <div class="auth-dialog__detail">
414
+ <div class="auth-dialog__label">Authorization URL</div>
415
+ <div class="url-copy-row">
416
+ <a class="auth-dialog__value auth-dialog__link" :href="authDialog.authorizationUrl" target="_blank"
417
+ x-text="authDialog.authorizationUrl"></a>
418
+ <button type="button" class="copy-btn"
419
+ @click="copyToClipboard(authDialog.authorizationUrl, 'Authorization URL', $event)">Copy</button>
420
+ </div>
416
421
  </div>
417
- </footer>
418
- </div>
419
- <div class="dialog-backdrop" :class="{ 'is-open': messageBox.open }" :aria-hidden="!messageBox.open"
420
- @click="closeMessageBox"></div>
421
- <div class="window active is-bright message-dialog" role="dialog" aria-modal="true"
422
- aria-labelledby="message-box-title" aria-describedby="message-box-body" :aria-hidden="!messageBox.open"
423
- :class="{ 'is-open': messageBox.open }">
424
- <div class="title-bar">
425
- <div class="title-bar-text" id="message-box-title" x-text="messageBox.title"></div>
426
- <div class="title-bar-controls">
427
- <button aria-label="Close" @click="closeMessageBox"></button>
422
+ <div class="auth-dialog__status">
423
+ <span class="auth-dialog__status-text" x-text="authDialog.statusLabel"></span>
424
+ <span class="spinner animate" aria-hidden="true" x-show="authDialog.status === 'pending'"></span>
428
425
  </div>
429
426
  </div>
430
- <div class="window-body has-space message-dialog__body">
431
- <div class="message-dialog__content">
432
- <div class="message-dialog__icon" :class="messageBox.iconTone ? `is-${messageBox.iconTone}` : ''"
433
- aria-hidden="true" x-show="messageBox.iconTone"></div>
434
- <div class="message-dialog__copy">
435
- <p id="message-box-body" x-text="messageBox.message"></p>
436
- <template x-if="messageBox.details">
437
- <pre class="code-box message-dialog__details" x-text="messageBox.details"></pre>
438
- </template>
427
+ </template>
428
+ <template x-if="authDialog.stage === 'device'">
429
+ <div class="auth-dialog__panel">
430
+ <p class="auth-dialog__lead">Use the device code to sign in. Enter the code at the verification URL.</p>
431
+ <ol class="auth-dialog__steps">
432
+ <li>Open the verification URL.</li>
433
+ <li>Enter the user code shown below.</li>
434
+ <li>Return here to finish the sign-in.</li>
435
+ </ol>
436
+ <div class="auth-dialog__device-grid">
437
+ <div>
438
+ <div class="auth-dialog__label">User code</div>
439
+ <div class="auth-dialog__code-value">
440
+ <span x-text="authDialog.userCode"></span>
441
+ <button type="button" class="copy-btn"
442
+ @click="copyToClipboard(authDialog.userCode, 'User code', $event)">Copy</button>
443
+ </div>
444
+ </div>
445
+ <div>
446
+ <div class="auth-dialog__label">Verification URL</div>
447
+ <div class="url-copy-row">
448
+ <a class="auth-dialog__value auth-dialog__link" :href="authDialog.verificationUrl" target="_blank"
449
+ x-text="authDialog.verificationUrl"></a>
450
+ <button type="button" class="copy-btn"
451
+ @click="copyToClipboard(authDialog.verificationUrl, 'Verification URL', $event)">Copy</button>
452
+ </div>
439
453
  </div>
440
454
  </div>
455
+ <div class="auth-dialog__status">
456
+ <span class="auth-dialog__status-text" x-text="authDialog.statusLabel"></span>
457
+ <div class="auth-dialog__status-meta" x-show="authDialog.status === 'pending'">
458
+ <span class="spinner animate" aria-hidden="true"></span>
459
+ <span class="text-muted">Expires in <span
460
+ x-text="formatCountdown(authDialog.remainingSeconds)"></span></span>
461
+ </div>
462
+ </div>
463
+ </div>
464
+ </template>
465
+ <template x-if="authDialog.stage === 'success'">
466
+ <div class="auth-dialog__panel">
467
+ <p class="auth-dialog__lead">Account linked successfully.</p>
468
+ <p class="text-muted">Return to the dashboard and select the new account.</p>
469
+ </div>
470
+ </template>
471
+ <template x-if="authDialog.stage === 'error'">
472
+ <div class="auth-dialog__panel auth-dialog__error">
473
+ <p class="auth-dialog__lead">Authorization failed.</p>
474
+ <p class="text-muted" x-text="authDialog.errorMessage"></p>
475
+ </div>
476
+ </template>
477
+ </div>
478
+ <footer class="auth-dialog__actions">
479
+ <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'intro'">
480
+ <button type="button" @click="closeAddAccountDialog">Cancel</button>
481
+ <button type="button" class="default" @click="startOAuth" :disabled="authDialog.isLoading">Start sign-in
482
+ </button>
483
+ </div>
484
+ <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'browser'">
485
+ <button type="button" @click="resetAuthDialogState">Change method</button>
486
+ <button type="button" class="default" @click="openAuthorizationUrl"
487
+ :disabled="!authDialog.authorizationUrl">Open sign-in page</button>
488
+ </div>
489
+ <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'device'">
490
+ <button type="button" @click="resetAuthDialogState">Change method</button>
491
+ <button type="button" class="default" @click="openVerificationUrl" :disabled="!authDialog.verificationUrl">Open
492
+ link</button>
493
+ </div>
494
+ <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'success'">
495
+ <button type="button" class="default" @click="closeAddAccountDialog">Done</button>
496
+ </div>
497
+ <div class="auth-dialog__actions-row" x-show="authDialog.stage === 'error'">
498
+ <button type="button" @click="resetAuthDialogState">Try again</button>
499
+ <button type="button" class="default" @click="closeAddAccountDialog">Close</button>
500
+ </div>
501
+ </footer>
502
+ </div>
503
+ <div class="dialog-backdrop" :class="{ 'is-open': messageBox.open }" :aria-hidden="!messageBox.open"
504
+ @click="closeMessageBox"></div>
505
+ <div class="window active is-bright message-dialog" role="dialog" aria-modal="true"
506
+ aria-labelledby="message-box-title" aria-describedby="message-box-body" :aria-hidden="!messageBox.open"
507
+ :class="{ 'is-open': messageBox.open }">
508
+ <div class="title-bar">
509
+ <div class="title-bar-text" id="message-box-title" x-text="messageBox.title"></div>
510
+ <div class="title-bar-controls">
511
+ <button aria-label="Close" @click="closeMessageBox"></button>
512
+ </div>
513
+ </div>
514
+ <div class="window-body has-space message-dialog__body">
515
+ <div class="message-dialog__content">
516
+ <div class="message-dialog__icon" :class="messageBox.iconTone ? `is-${messageBox.iconTone}` : ''"
517
+ aria-hidden="true" x-show="messageBox.iconTone"></div>
518
+ <div class="message-dialog__copy">
519
+ <p id="message-box-body" x-text="messageBox.message"></p>
520
+ <template x-if="messageBox.details">
521
+ <pre class="code-box message-dialog__details" x-text="messageBox.details"></pre>
522
+ </template>
441
523
  </div>
442
- <footer class="message-dialog__actions">
443
- <button x-show="messageBox.mode === 'confirm'" type="button" @click="cancelMessageBox"
444
- x-text="messageBox.cancelLabel"></button>
445
- <button x-show="messageBox.mode === 'confirm'" type="button" class="default" @click="confirmMessageBox"
446
- x-text="messageBox.confirmLabel"></button>
447
- <button x-show="messageBox.mode !== 'confirm'" type="button" class="default"
448
- @click="closeMessageBox">OK</button>
449
- </footer>
450
524
  </div>
451
525
  </div>
526
+ <footer class="message-dialog__actions">
527
+ <button x-show="messageBox.mode === 'confirm'" type="button" @click="cancelMessageBox"
528
+ x-text="messageBox.cancelLabel"></button>
529
+ <button x-show="messageBox.mode === 'confirm'" type="button" class="default" @click="confirmMessageBox"
530
+ x-text="messageBox.confirmLabel"></button>
531
+ <button x-show="messageBox.mode !== 'confirm'" type="button" class="default" @click="closeMessageBox">OK</button>
532
+ </footer>
533
+ </div>
534
+ </div>
535
+ </main>
452
536
  </div>
453
537
  <script defer src="/dashboard/index.js"></script>
454
538
  <script defer src="https://unpkg.com/alpinejs@3.13.2/dist/cdn.min.js"></script>