codex-lb 0.3.1__py3-none-any.whl → 0.5.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/core/clients/proxy.py +33 -3
- app/core/config/settings.py +9 -8
- app/core/handlers/__init__.py +3 -0
- app/core/handlers/exceptions.py +39 -0
- app/core/middleware/__init__.py +9 -0
- app/core/middleware/api_errors.py +33 -0
- app/core/middleware/request_decompression.py +101 -0
- app/core/middleware/request_id.py +27 -0
- app/core/openai/chat_requests.py +172 -0
- app/core/openai/chat_responses.py +534 -0
- app/core/openai/message_coercion.py +60 -0
- app/core/openai/models_catalog.py +72 -0
- app/core/openai/requests.py +23 -5
- app/core/openai/v1_requests.py +92 -0
- app/db/models.py +3 -3
- app/db/session.py +25 -8
- app/dependencies.py +43 -16
- app/main.py +13 -67
- app/modules/accounts/repository.py +25 -10
- app/modules/proxy/api.py +94 -0
- app/modules/proxy/load_balancer.py +75 -58
- app/modules/proxy/repo_bundle.py +23 -0
- app/modules/proxy/service.py +127 -102
- app/modules/request_logs/api.py +61 -7
- app/modules/request_logs/repository.py +131 -16
- app/modules/request_logs/schemas.py +11 -2
- app/modules/request_logs/service.py +97 -20
- app/modules/usage/service.py +65 -4
- app/modules/usage/updater.py +58 -26
- app/static/index.css +378 -1
- app/static/index.html +183 -8
- app/static/index.js +308 -13
- {codex_lb-0.3.1.dist-info → codex_lb-0.5.0.dist-info}/METADATA +42 -3
- {codex_lb-0.3.1.dist-info → codex_lb-0.5.0.dist-info}/RECORD +37 -25
- {codex_lb-0.3.1.dist-info → codex_lb-0.5.0.dist-info}/WHEEL +0 -0
- {codex_lb-0.3.1.dist-info → codex_lb-0.5.0.dist-info}/entry_points.txt +0 -0
- {codex_lb-0.3.1.dist-info → codex_lb-0.5.0.dist-info}/licenses/LICENSE +0 -0
app/modules/usage/updater.py
CHANGED
|
@@ -2,8 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import math
|
|
5
|
-
from
|
|
6
|
-
from datetime import datetime
|
|
5
|
+
from datetime import datetime, timezone
|
|
7
6
|
from typing import Mapping, Protocol
|
|
8
7
|
|
|
9
8
|
from app.core.auth.refresh import RefreshError
|
|
@@ -14,8 +13,7 @@ from app.core.usage.models import UsagePayload
|
|
|
14
13
|
from app.core.utils.request_id import get_request_id
|
|
15
14
|
from app.core.utils.time import utcnow
|
|
16
15
|
from app.db.models import Account, AccountStatus, UsageHistory
|
|
17
|
-
from app.modules.accounts.auth_manager import AuthManager
|
|
18
|
-
from app.modules.accounts.repository import AccountsRepository
|
|
16
|
+
from app.modules.accounts.auth_manager import AccountsRepositoryPort, AuthManager
|
|
19
17
|
|
|
20
18
|
logger = logging.getLogger(__name__)
|
|
21
19
|
|
|
@@ -41,7 +39,7 @@ class UsageUpdater:
|
|
|
41
39
|
def __init__(
|
|
42
40
|
self,
|
|
43
41
|
usage_repo: UsageRepositoryPort,
|
|
44
|
-
accounts_repo:
|
|
42
|
+
accounts_repo: AccountsRepositoryPort | None = None,
|
|
45
43
|
) -> None:
|
|
46
44
|
self._usage_repo = usage_repo
|
|
47
45
|
self._encryptor = TokenEncryptor()
|
|
@@ -56,7 +54,6 @@ class UsageUpdater:
|
|
|
56
54
|
if not settings.usage_refresh_enabled:
|
|
57
55
|
return
|
|
58
56
|
|
|
59
|
-
shared_chatgpt_account_ids = _shared_chatgpt_account_ids(accounts)
|
|
60
57
|
now = utcnow()
|
|
61
58
|
interval = settings.usage_refresh_interval_seconds
|
|
62
59
|
for account in accounts:
|
|
@@ -65,16 +62,11 @@ class UsageUpdater:
|
|
|
65
62
|
latest = latest_usage.get(account.id)
|
|
66
63
|
if latest and (now - latest.recorded_at).total_seconds() < interval:
|
|
67
64
|
continue
|
|
68
|
-
usage_account_id = (
|
|
69
|
-
None
|
|
70
|
-
if account.chatgpt_account_id and account.chatgpt_account_id in shared_chatgpt_account_ids
|
|
71
|
-
else account.chatgpt_account_id
|
|
72
|
-
)
|
|
73
65
|
# NOTE: AsyncSession is not safe for concurrent use. Run sequentially
|
|
74
66
|
# within the request-scoped session to avoid PK collisions and
|
|
75
67
|
# flush-time warnings (SAWarning: Session.add during flush).
|
|
76
68
|
try:
|
|
77
|
-
await self._refresh_account(account, usage_account_id=
|
|
69
|
+
await self._refresh_account(account, usage_account_id=account.chatgpt_account_id)
|
|
78
70
|
except Exception as exc:
|
|
79
71
|
logger.warning(
|
|
80
72
|
"Usage refresh failed account_id=%s request_id=%s error=%s",
|
|
@@ -88,12 +80,16 @@ class UsageUpdater:
|
|
|
88
80
|
|
|
89
81
|
async def _refresh_account(self, account: Account, *, usage_account_id: str | None) -> None:
|
|
90
82
|
access_token = self._encryptor.decrypt(account.access_token_encrypted)
|
|
83
|
+
payload: UsagePayload | None = None
|
|
91
84
|
try:
|
|
92
85
|
payload = await fetch_usage(
|
|
93
86
|
access_token=access_token,
|
|
94
87
|
account_id=usage_account_id,
|
|
95
88
|
)
|
|
96
89
|
except UsageFetchError as exc:
|
|
90
|
+
if _should_deactivate_for_usage_error(exc.status_code):
|
|
91
|
+
await self._deactivate_for_client_error(account, exc)
|
|
92
|
+
return
|
|
97
93
|
if exc.status_code != 401 or not self._auth_manager:
|
|
98
94
|
return
|
|
99
95
|
try:
|
|
@@ -106,25 +102,32 @@ class UsageUpdater:
|
|
|
106
102
|
access_token=access_token,
|
|
107
103
|
account_id=usage_account_id,
|
|
108
104
|
)
|
|
109
|
-
except UsageFetchError:
|
|
105
|
+
except UsageFetchError as retry_exc:
|
|
106
|
+
if _should_deactivate_for_usage_error(retry_exc.status_code):
|
|
107
|
+
await self._deactivate_for_client_error(account, retry_exc)
|
|
110
108
|
return
|
|
111
109
|
|
|
110
|
+
if payload is None:
|
|
111
|
+
return
|
|
112
|
+
|
|
112
113
|
rate_limit = payload.rate_limit
|
|
113
|
-
|
|
114
|
+
if rate_limit is None:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
primary = rate_limit.primary_window
|
|
118
|
+
secondary = rate_limit.secondary_window
|
|
114
119
|
credits_has, credits_unlimited, credits_balance = _credits_snapshot(payload)
|
|
115
|
-
|
|
116
|
-
secondary = rate_limit.secondary_window if rate_limit else None
|
|
117
|
-
secondary_window_minutes = _window_minutes(secondary.limit_window_seconds) if secondary else None
|
|
120
|
+
now_epoch = _now_epoch()
|
|
118
121
|
|
|
119
122
|
if primary and primary.used_percent is not None:
|
|
120
123
|
await self._usage_repo.add_entry(
|
|
121
124
|
account_id=account.id,
|
|
122
|
-
used_percent=primary.used_percent,
|
|
125
|
+
used_percent=float(primary.used_percent),
|
|
123
126
|
input_tokens=None,
|
|
124
127
|
output_tokens=None,
|
|
125
128
|
window="primary",
|
|
126
|
-
reset_at=primary.reset_at,
|
|
127
|
-
window_minutes=
|
|
129
|
+
reset_at=_reset_at(primary.reset_at, primary.reset_after_seconds, now_epoch),
|
|
130
|
+
window_minutes=_window_minutes(primary.limit_window_seconds),
|
|
128
131
|
credits_has=credits_has,
|
|
129
132
|
credits_unlimited=credits_unlimited,
|
|
130
133
|
credits_balance=credits_balance,
|
|
@@ -133,14 +136,29 @@ class UsageUpdater:
|
|
|
133
136
|
if secondary and secondary.used_percent is not None:
|
|
134
137
|
await self._usage_repo.add_entry(
|
|
135
138
|
account_id=account.id,
|
|
136
|
-
used_percent=secondary.used_percent,
|
|
139
|
+
used_percent=float(secondary.used_percent),
|
|
137
140
|
input_tokens=None,
|
|
138
141
|
output_tokens=None,
|
|
139
142
|
window="secondary",
|
|
140
|
-
reset_at=secondary.reset_at,
|
|
141
|
-
window_minutes=
|
|
143
|
+
reset_at=_reset_at(secondary.reset_at, secondary.reset_after_seconds, now_epoch),
|
|
144
|
+
window_minutes=_window_minutes(secondary.limit_window_seconds),
|
|
142
145
|
)
|
|
143
146
|
|
|
147
|
+
async def _deactivate_for_client_error(self, account: Account, exc: UsageFetchError) -> None:
|
|
148
|
+
if not self._auth_manager:
|
|
149
|
+
return
|
|
150
|
+
reason = f"Usage API error: HTTP {exc.status_code} - {exc.message}"
|
|
151
|
+
logger.warning(
|
|
152
|
+
"Deactivating account due to client error account_id=%s status=%s message=%s request_id=%s",
|
|
153
|
+
account.id,
|
|
154
|
+
exc.status_code,
|
|
155
|
+
exc.message,
|
|
156
|
+
get_request_id(),
|
|
157
|
+
)
|
|
158
|
+
await self._auth_manager._repo.update_status(account.id, AccountStatus.DEACTIVATED, reason)
|
|
159
|
+
account.status = AccountStatus.DEACTIVATED
|
|
160
|
+
account.deactivation_reason = reason
|
|
161
|
+
|
|
144
162
|
|
|
145
163
|
def _credits_snapshot(payload: UsagePayload) -> tuple[bool | None, bool | None, float | None]:
|
|
146
164
|
credits = payload.credits
|
|
@@ -171,6 +189,20 @@ def _window_minutes(limit_seconds: int | None) -> int | None:
|
|
|
171
189
|
return max(1, math.ceil(limit_seconds / 60))
|
|
172
190
|
|
|
173
191
|
|
|
174
|
-
def
|
|
175
|
-
|
|
176
|
-
|
|
192
|
+
def _now_epoch() -> int:
|
|
193
|
+
return int(utcnow().replace(tzinfo=timezone.utc).timestamp())
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _reset_at(reset_at: int | None, reset_after_seconds: int | None, now_epoch: int) -> int | None:
|
|
197
|
+
if reset_at is not None:
|
|
198
|
+
return int(reset_at)
|
|
199
|
+
if reset_after_seconds is None:
|
|
200
|
+
return None
|
|
201
|
+
return now_epoch + max(0, int(reset_after_seconds))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
_DEACTIVATING_USAGE_STATUS_CODES = {402, 403, 404}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _should_deactivate_for_usage_error(status_code: int) -> bool:
|
|
208
|
+
return status_code in _DEACTIVATING_USAGE_STATUS_CODES
|
app/static/index.css
CHANGED
|
@@ -90,6 +90,14 @@ body {
|
|
|
90
90
|
-webkit-font-smoothing: antialiased;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
body[data-theme="dark"] {
|
|
94
|
+
color-scheme: dark;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
body[data-theme="light"] {
|
|
98
|
+
color-scheme: light;
|
|
99
|
+
}
|
|
100
|
+
|
|
93
101
|
.app-shell {
|
|
94
102
|
display: flex;
|
|
95
103
|
flex-direction: column;
|
|
@@ -424,6 +432,11 @@ body {
|
|
|
424
432
|
color: var(--status-error-text);
|
|
425
433
|
}
|
|
426
434
|
|
|
435
|
+
.status-pill.error {
|
|
436
|
+
background: var(--status-error-bg);
|
|
437
|
+
color: var(--status-error-text);
|
|
438
|
+
}
|
|
439
|
+
|
|
427
440
|
.status-pill.deactivated,
|
|
428
441
|
.status-pill.paused {
|
|
429
442
|
background: var(--status-paused-bg);
|
|
@@ -585,6 +598,306 @@ body {
|
|
|
585
598
|
background: var(--accent-primary-hover);
|
|
586
599
|
}
|
|
587
600
|
|
|
601
|
+
.controls-toolbar {
|
|
602
|
+
display: flex;
|
|
603
|
+
flex-wrap: wrap;
|
|
604
|
+
gap: 12px;
|
|
605
|
+
margin-bottom: 16px;
|
|
606
|
+
align-items: center;
|
|
607
|
+
justify-content: space-between;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
.controls-group {
|
|
611
|
+
display: flex;
|
|
612
|
+
flex-wrap: wrap;
|
|
613
|
+
gap: 8px;
|
|
614
|
+
align-items: center;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
.filter-input {
|
|
618
|
+
background: var(--bg-surface);
|
|
619
|
+
border: 1px solid var(--border-subtle);
|
|
620
|
+
color: var(--text-main);
|
|
621
|
+
padding: 8px 12px;
|
|
622
|
+
border-radius: 6px;
|
|
623
|
+
font-size: 13px;
|
|
624
|
+
outline: none;
|
|
625
|
+
min-width: 120px;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
.filter-input:focus {
|
|
629
|
+
border-color: var(--accent-primary);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
.filter-select {
|
|
633
|
+
background: var(--bg-surface);
|
|
634
|
+
border: 1px solid var(--border-subtle);
|
|
635
|
+
color: var(--text-main);
|
|
636
|
+
padding: 8px 32px 8px 12px;
|
|
637
|
+
border-radius: 6px;
|
|
638
|
+
font-size: 13px;
|
|
639
|
+
outline: none;
|
|
640
|
+
-webkit-appearance: none;
|
|
641
|
+
appearance: none;
|
|
642
|
+
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%2358A6FF%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E");
|
|
643
|
+
background-repeat: no-repeat;
|
|
644
|
+
background-position: right 10px top 50%;
|
|
645
|
+
background-size: 10px auto;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
.filter-select[multiple] {
|
|
649
|
+
padding-right: 12px;
|
|
650
|
+
background-image: none;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
.filter-select option {
|
|
654
|
+
background: var(--bg-surface);
|
|
655
|
+
color: var(--text-main);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
.single-select {
|
|
659
|
+
position: relative;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
.single-select-trigger {
|
|
663
|
+
display: inline-flex;
|
|
664
|
+
align-items: center;
|
|
665
|
+
justify-content: space-between;
|
|
666
|
+
gap: 12px;
|
|
667
|
+
min-width: 120px;
|
|
668
|
+
cursor: pointer;
|
|
669
|
+
position: relative;
|
|
670
|
+
background-image: none;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
.single-select-trigger::after {
|
|
674
|
+
content: "";
|
|
675
|
+
position: absolute;
|
|
676
|
+
right: 10px;
|
|
677
|
+
top: 50%;
|
|
678
|
+
width: 10px;
|
|
679
|
+
height: 10px;
|
|
680
|
+
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%2358A6FF%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E");
|
|
681
|
+
background-repeat: no-repeat;
|
|
682
|
+
background-size: contain;
|
|
683
|
+
transform: translateY(-50%) rotate(0deg);
|
|
684
|
+
transition: transform 0.25s ease;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
.single-select-trigger[aria-expanded="true"] {
|
|
688
|
+
border-color: var(--accent-primary);
|
|
689
|
+
box-shadow: 0 0 0 1px var(--accent-primary);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
.single-select-trigger[aria-expanded="true"]::after {
|
|
693
|
+
transform: translateY(-50%) rotate(180deg);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
.single-select-menu {
|
|
697
|
+
position: absolute;
|
|
698
|
+
top: calc(100% + 8px);
|
|
699
|
+
left: 0;
|
|
700
|
+
z-index: 50;
|
|
701
|
+
width: max-content;
|
|
702
|
+
min-width: 140px;
|
|
703
|
+
padding: 8px;
|
|
704
|
+
padding-top: 12px;
|
|
705
|
+
border-radius: 10px;
|
|
706
|
+
border: 1px solid var(--border-subtle);
|
|
707
|
+
background: var(--bg-panel);
|
|
708
|
+
box-shadow: var(--shadow-md);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
.single-select-menu::before {
|
|
712
|
+
content: "";
|
|
713
|
+
position: absolute;
|
|
714
|
+
top: -6px;
|
|
715
|
+
left: 16px;
|
|
716
|
+
width: 10px;
|
|
717
|
+
height: 10px;
|
|
718
|
+
background: var(--bg-panel);
|
|
719
|
+
border-left: 1px solid var(--border-subtle);
|
|
720
|
+
border-top: 1px solid var(--border-subtle);
|
|
721
|
+
transform: rotate(45deg);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.single-select-item {
|
|
725
|
+
display: flex;
|
|
726
|
+
align-items: center;
|
|
727
|
+
gap: 10px;
|
|
728
|
+
padding: 8px 10px;
|
|
729
|
+
border-radius: 8px;
|
|
730
|
+
cursor: pointer;
|
|
731
|
+
user-select: none;
|
|
732
|
+
color: var(--text-main);
|
|
733
|
+
font-size: 13px;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
.single-select-item:hover {
|
|
737
|
+
background: var(--bg-list-hover);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
.single-select-item.is-selected {
|
|
741
|
+
background: var(--bg-list-selected);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
.single-select-item input[type="radio"] {
|
|
745
|
+
display: none;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
.multi-select {
|
|
749
|
+
position: relative;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
.multi-select-trigger {
|
|
753
|
+
display: inline-flex;
|
|
754
|
+
align-items: center;
|
|
755
|
+
justify-content: space-between;
|
|
756
|
+
gap: 12px;
|
|
757
|
+
min-width: 180px;
|
|
758
|
+
cursor: pointer;
|
|
759
|
+
position: relative;
|
|
760
|
+
background-image: none;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
.multi-select-trigger::after {
|
|
764
|
+
content: "";
|
|
765
|
+
position: absolute;
|
|
766
|
+
right: 10px;
|
|
767
|
+
top: 50%;
|
|
768
|
+
width: 10px;
|
|
769
|
+
height: 10px;
|
|
770
|
+
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%2358A6FF%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E");
|
|
771
|
+
background-repeat: no-repeat;
|
|
772
|
+
background-size: contain;
|
|
773
|
+
transform: translateY(-50%) rotate(0deg);
|
|
774
|
+
transition: transform 0.25s ease;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
.multi-select-trigger:disabled {
|
|
778
|
+
opacity: 0.7;
|
|
779
|
+
cursor: not-allowed;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
.multi-select-trigger[aria-expanded="true"] {
|
|
783
|
+
border-color: var(--accent-primary);
|
|
784
|
+
box-shadow: 0 0 0 1px var(--accent-primary);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
.multi-select-trigger[aria-expanded="true"]::after {
|
|
788
|
+
transform: translateY(-50%) rotate(180deg);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
.multi-select-menu {
|
|
793
|
+
position: absolute;
|
|
794
|
+
top: calc(100% + 8px);
|
|
795
|
+
left: 0;
|
|
796
|
+
z-index: 50;
|
|
797
|
+
width: max-content;
|
|
798
|
+
min-width: 220px;
|
|
799
|
+
max-width: 360px;
|
|
800
|
+
padding: 8px;
|
|
801
|
+
padding-top: 12px;
|
|
802
|
+
border-radius: 10px;
|
|
803
|
+
border: 1px solid var(--border-subtle);
|
|
804
|
+
background: var(--bg-panel);
|
|
805
|
+
box-shadow: var(--shadow-md);
|
|
806
|
+
overflow: visible;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
.multi-select-scroller {
|
|
810
|
+
max-height: 280px;
|
|
811
|
+
overflow-y: auto;
|
|
812
|
+
overflow-x: hidden;
|
|
813
|
+
/* Add padding to ensure scrollbar doesn't overlap content tightly if needed,
|
|
814
|
+
but we removed padding from parent. Wait, parent has padding 8px.
|
|
815
|
+
The scroller is inside. */
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
.multi-select-menu::before {
|
|
819
|
+
content: "";
|
|
820
|
+
position: absolute;
|
|
821
|
+
top: -6px;
|
|
822
|
+
left: 16px;
|
|
823
|
+
width: 10px;
|
|
824
|
+
height: 10px;
|
|
825
|
+
background: var(--bg-panel);
|
|
826
|
+
border-left: 1px solid var(--border-subtle);
|
|
827
|
+
border-top: 1px solid var(--border-subtle);
|
|
828
|
+
transform: rotate(45deg);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
.multi-select-actions {
|
|
832
|
+
display: flex;
|
|
833
|
+
justify-content: flex-end;
|
|
834
|
+
margin-bottom: 6px;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
.multi-select-action {
|
|
838
|
+
background: transparent;
|
|
839
|
+
border: 1px solid var(--border-subtle);
|
|
840
|
+
color: var(--text-muted);
|
|
841
|
+
padding: 6px 10px;
|
|
842
|
+
border-radius: 8px;
|
|
843
|
+
font-size: 12px;
|
|
844
|
+
cursor: pointer;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
.multi-select-action:hover {
|
|
848
|
+
border-color: var(--border-strong);
|
|
849
|
+
color: var(--text-main);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
.multi-select-item {
|
|
853
|
+
display: flex;
|
|
854
|
+
align-items: center;
|
|
855
|
+
gap: 10px;
|
|
856
|
+
padding: 8px 10px;
|
|
857
|
+
border-radius: 8px;
|
|
858
|
+
cursor: pointer;
|
|
859
|
+
user-select: none;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
.multi-select-item:hover {
|
|
863
|
+
background: var(--bg-list-hover);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
.multi-select-item input[type="checkbox"] {
|
|
867
|
+
accent-color: var(--accent-primary);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
.multi-select-label {
|
|
871
|
+
color: var(--text-main);
|
|
872
|
+
font-size: 13px;
|
|
873
|
+
white-space: nowrap;
|
|
874
|
+
overflow: hidden;
|
|
875
|
+
text-overflow: ellipsis;
|
|
876
|
+
max-width: 280px;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
.filter-apply {
|
|
880
|
+
font-size: 13px;
|
|
881
|
+
padding: 8px 12px;
|
|
882
|
+
border-radius: 6px;
|
|
883
|
+
border: 1px solid var(--accent-primary);
|
|
884
|
+
background: var(--accent-primary);
|
|
885
|
+
color: var(--accent-primary-text);
|
|
886
|
+
cursor: pointer;
|
|
887
|
+
font-weight: 500;
|
|
888
|
+
transition: all 0.2s;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
.filter-apply:hover {
|
|
892
|
+
border-color: var(--accent-primary-hover);
|
|
893
|
+
background: var(--accent-primary-hover);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
.filter-apply:disabled {
|
|
897
|
+
opacity: 0.6;
|
|
898
|
+
cursor: not-allowed;
|
|
899
|
+
}
|
|
900
|
+
|
|
588
901
|
.table-wrap {
|
|
589
902
|
width: 100%;
|
|
590
903
|
overflow-x: auto;
|
|
@@ -592,6 +905,18 @@ body {
|
|
|
592
905
|
border-radius: 8px;
|
|
593
906
|
}
|
|
594
907
|
|
|
908
|
+
.table-wrap--requests th,
|
|
909
|
+
.table-wrap--requests td {
|
|
910
|
+
text-align: center;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
.table-wrap--requests td:nth-child(2),
|
|
914
|
+
.table-wrap--requests td:nth-child(5) {
|
|
915
|
+
text-align: left;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
|
|
595
920
|
table {
|
|
596
921
|
width: 100%;
|
|
597
922
|
border-collapse: collapse;
|
|
@@ -606,6 +931,56 @@ table {
|
|
|
606
931
|
text-overflow: ellipsis;
|
|
607
932
|
}
|
|
608
933
|
|
|
934
|
+
.error-cell {
|
|
935
|
+
display: flex;
|
|
936
|
+
align-items: flex-start;
|
|
937
|
+
gap: 6px;
|
|
938
|
+
min-width: 0;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
.error-cell.placeholder {
|
|
942
|
+
justify-content: center;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
.error-cell.placeholder .error-text {
|
|
946
|
+
text-align: center;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
.error-text {
|
|
950
|
+
flex: 1;
|
|
951
|
+
min-width: 0;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
.error-text.truncated {
|
|
955
|
+
white-space: nowrap;
|
|
956
|
+
overflow: hidden;
|
|
957
|
+
text-overflow: ellipsis;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
.cell-error {
|
|
961
|
+
vertical-align: middle;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
.error-toggle {
|
|
965
|
+
background: transparent;
|
|
966
|
+
border: none;
|
|
967
|
+
color: var(--accent-primary);
|
|
968
|
+
font-size: 11px;
|
|
969
|
+
padding: 0;
|
|
970
|
+
cursor: pointer;
|
|
971
|
+
white-space: nowrap;
|
|
972
|
+
margin-top: 3px;
|
|
973
|
+
font-weight: 500;
|
|
974
|
+
line-height: 1.2;
|
|
975
|
+
min-width: 32px;
|
|
976
|
+
text-align: left;
|
|
977
|
+
flex-shrink: 0;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
.error-toggle:hover {
|
|
981
|
+
text-decoration: underline;
|
|
982
|
+
}
|
|
983
|
+
|
|
609
984
|
/* ... existing styles ... */
|
|
610
985
|
|
|
611
986
|
.list-actions .searchbox button {
|
|
@@ -641,12 +1016,14 @@ th {
|
|
|
641
1016
|
font-size: 12px;
|
|
642
1017
|
text-transform: uppercase;
|
|
643
1018
|
border-bottom: 1px solid var(--border-subtle);
|
|
1019
|
+
vertical-align: middle;
|
|
644
1020
|
}
|
|
645
1021
|
|
|
646
1022
|
td {
|
|
647
|
-
padding:
|
|
1023
|
+
padding: 12px 20px;
|
|
648
1024
|
border-bottom: 1px solid var(--border-subtle);
|
|
649
1025
|
color: var(--text-main);
|
|
1026
|
+
vertical-align: middle;
|
|
650
1027
|
}
|
|
651
1028
|
|
|
652
1029
|
tr:last-child td {
|