269 lines
11 KiB
Python
269 lines
11 KiB
Python
"""Accounts and quota view driven exclusively by a supplied HubSnapshot."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections import defaultdict
|
||
from typing import Any, Callable, Dict, Iterable, Optional
|
||
|
||
import customtkinter as ctk
|
||
|
||
from antigravity_provider.router.state_store import HubSnapshot
|
||
from antigravity_provider.router.ui.components import (
|
||
AccountCardWidget,
|
||
ActionButton,
|
||
EmptyState,
|
||
FilterButton,
|
||
SearchField,
|
||
SectionHeader,
|
||
)
|
||
from antigravity_provider.router.ui.theme import Theme
|
||
|
||
|
||
PROVIDER_LABELS = {
|
||
"antigravity": "Google Antigravity",
|
||
"openai-codex": "OpenAI Codex",
|
||
"opencode-go": "OpenCode Go",
|
||
"claude": "Claude",
|
||
"grok": "Grok",
|
||
}
|
||
|
||
|
||
class ProviderGroup(ctk.CTkFrame):
|
||
"""Collapsible provider section that retains its account cards while hidden."""
|
||
|
||
def __init__(self, master: Any, provider: str):
|
||
super().__init__(master, fg_color="transparent")
|
||
self.provider = provider
|
||
self.collapsed = False
|
||
self.header = ctk.CTkFrame(self, fg_color=Theme.BG_HEADER, corner_radius=Theme.RADIUS_SM)
|
||
self.header.pack(fill="x", pady=(Theme.SPACE_SM, Theme.SPACE_XS))
|
||
self.toggle = ActionButton(
|
||
self.header,
|
||
text="▾",
|
||
variant="ghost",
|
||
width=34,
|
||
command=self.toggle_collapsed,
|
||
)
|
||
self.toggle.pack(side="left", padx=(Theme.SPACE_SM, 0))
|
||
self.title = ctk.CTkLabel(
|
||
self.header,
|
||
text=PROVIDER_LABELS.get(provider, provider),
|
||
font=Theme.font_heading(),
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
)
|
||
self.title.pack(side="left", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM)
|
||
self.count = ctk.CTkLabel(self.header, text="0", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED)
|
||
self.count.pack(side="right", padx=Theme.SPACE_MD)
|
||
self.body = ctk.CTkFrame(self, fg_color="transparent")
|
||
self.body.pack(fill="x")
|
||
for column in range(Theme.ACCOUNT_CARD_COLUMNS):
|
||
self.body.grid_columnconfigure(column, weight=1)
|
||
|
||
def toggle_collapsed(self) -> None:
|
||
self.collapsed = not self.collapsed
|
||
self.toggle.configure(text="▸" if self.collapsed else "▾")
|
||
if self.collapsed:
|
||
self.body.pack_forget()
|
||
else:
|
||
self.body.pack(fill="x")
|
||
|
||
|
||
class AccountsView(ctk.CTkFrame):
|
||
"""Keyed accounts view; one account delta never reconstructs another card."""
|
||
|
||
def __init__(
|
||
self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs
|
||
):
|
||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||
self.on_action = on_action
|
||
self._snapshot: Optional[HubSnapshot] = None
|
||
self._cards: Dict[str, AccountCardWidget] = {}
|
||
self._groups: Dict[str, ProviderGroup] = {}
|
||
self._search = ""
|
||
self._provider_filter = "Все провайдеры"
|
||
self._health_filter = "Все состояния"
|
||
self._role_filter = "Все роли"
|
||
self.cards_created = 0
|
||
self.cards_destroyed = 0
|
||
self._build()
|
||
|
||
def _build(self) -> None:
|
||
header = SectionHeader(
|
||
self,
|
||
title="Аккаунты и квоты",
|
||
subtitle="Реальные идентичности, независимые лимитные корзины и резервные роли",
|
||
action_text="+ Добавить аккаунт",
|
||
action_cmd=lambda: self._emit("add_account", {}),
|
||
)
|
||
header.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
||
|
||
toolbar = ctk.CTkFrame(self, fg_color="transparent")
|
||
toolbar.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(0, Theme.SPACE_SM))
|
||
self.search = SearchField(toolbar, placeholder_text="Поиск по аккаунту, модели или роли…", width=300)
|
||
self.search.pack(side="left", padx=(0, Theme.SPACE_SM))
|
||
self.search.bind("<KeyRelease>", lambda _event: self._set_search(self.search.get()))
|
||
self.provider_filter = FilterButton(
|
||
toolbar,
|
||
values=["Все провайдеры", *PROVIDER_LABELS.values()],
|
||
command=self._set_provider_filter,
|
||
width=170,
|
||
)
|
||
self.provider_filter.pack(side="left", padx=(0, Theme.SPACE_SM))
|
||
self.health_filter = FilterButton(
|
||
toolbar,
|
||
values=["Все состояния", "Работают", "Требуют входа", "Проблемные"],
|
||
command=self._set_health_filter,
|
||
width=150,
|
||
)
|
||
self.health_filter.pack(side="left", padx=(0, Theme.SPACE_SM))
|
||
self.role_filter = FilterButton(
|
||
toolbar,
|
||
values=["Все роли", "orchestrator", "coder", "reviewer", "researcher", "tester", "general", "spare"],
|
||
command=self._set_role_filter,
|
||
width=135,
|
||
)
|
||
self.role_filter.pack(side="left")
|
||
ActionButton(
|
||
toolbar,
|
||
text="Обновить все",
|
||
variant="secondary",
|
||
command=lambda: self._emit("refresh_all", {}),
|
||
).pack(side="right")
|
||
|
||
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
||
self.empty = EmptyState(
|
||
self.scroll,
|
||
title="Нет подходящих аккаунтов",
|
||
message="Измените фильтры или подключите новый аккаунт.",
|
||
action_text="Подключить аккаунт",
|
||
action_cmd=lambda: self._emit("add_account", {}),
|
||
)
|
||
|
||
def _emit(self, action: str, profile: Any) -> None:
|
||
if not self.on_action:
|
||
return
|
||
if isinstance(profile, dict):
|
||
payload = profile
|
||
else:
|
||
payload = {
|
||
"profile_id": profile.profile_id,
|
||
"provider": profile.provider,
|
||
"display_name": profile.display_name,
|
||
}
|
||
self.on_action(action, payload)
|
||
|
||
def _set_search(self, value: str) -> None:
|
||
self._search = value.strip().lower()
|
||
self._render_visibility()
|
||
|
||
def _set_provider_filter(self, value: str) -> None:
|
||
self._provider_filter = value
|
||
self._render_visibility()
|
||
|
||
def _set_health_filter(self, value: str) -> None:
|
||
self._health_filter = value
|
||
self._render_visibility()
|
||
|
||
def _set_role_filter(self, value: str) -> None:
|
||
self._role_filter = value
|
||
self._render_visibility()
|
||
|
||
def _matches(self, profile: Any) -> bool:
|
||
if self._provider_filter != "Все провайдеры":
|
||
if PROVIDER_LABELS.get(profile.provider, profile.provider) != self._provider_filter:
|
||
return False
|
||
if self._health_filter == "Работают" and profile.health_state != "healthy":
|
||
return False
|
||
if self._health_filter == "Требуют входа" and profile.auth_state not in {"AUTH_REQUIRED", "AUTH_EXPIRED"}:
|
||
return False
|
||
if self._health_filter == "Проблемные" and profile.health_state in {"healthy", "not_configured", "cold_spare"}:
|
||
return False
|
||
roles = list(getattr(profile, "assigned_roles", []) or [])
|
||
if self._role_filter != "Все роли" and not any(self._role_filter.lower() in role.lower() for role in roles):
|
||
return False
|
||
haystack = " ".join(
|
||
[
|
||
AccountCardWidget.resolve_identity(profile),
|
||
profile.display_name,
|
||
profile.provider_display_name,
|
||
*roles,
|
||
*(getattr(profile, "preferred_models", []) or []),
|
||
]
|
||
).lower()
|
||
return not self._search or self._search in haystack
|
||
|
||
def _profiles(self) -> Iterable[Any]:
|
||
if not self._snapshot:
|
||
return []
|
||
return (profile for profile in self._snapshot.all_profiles.values() if not profile.is_empty_slot)
|
||
|
||
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
||
if not isinstance(snapshot, HubSnapshot):
|
||
return
|
||
self._snapshot = snapshot
|
||
live_ids = {profile.profile_id for profile in self._profiles()}
|
||
for profile_id in list(self._cards):
|
||
if profile_id not in live_ids:
|
||
self._cards.pop(profile_id).destroy()
|
||
self.cards_destroyed += 1
|
||
|
||
for profile in self._profiles():
|
||
group = self._groups.get(profile.provider)
|
||
if group is None:
|
||
group = ProviderGroup(self.scroll, profile.provider)
|
||
self._groups[profile.provider] = group
|
||
card = self._cards.get(profile.profile_id)
|
||
if card is None:
|
||
card = AccountCardWidget(
|
||
group.body,
|
||
profile.profile_id,
|
||
AccountCardWidget.resolve_identity(profile),
|
||
profile.provider_display_name,
|
||
on_action=self._emit,
|
||
)
|
||
self._cards[profile.profile_id] = card
|
||
self.cards_created += 1
|
||
card.update_account(profile, snapshot.quotas.get(profile.profile_id))
|
||
self._render_visibility()
|
||
|
||
def _render_visibility(self) -> None:
|
||
if not self._snapshot:
|
||
self.empty.pack(fill="x", pady=Theme.SPACE_XL)
|
||
return
|
||
grouped: Dict[str, list[Any]] = defaultdict(list)
|
||
for profile in self._profiles():
|
||
if self._matches(profile):
|
||
grouped[profile.provider].append(profile)
|
||
visible_total = 0
|
||
for provider, group in self._groups.items():
|
||
profiles = grouped.get(provider, [])
|
||
if not profiles:
|
||
group.pack_forget()
|
||
continue
|
||
group.pack(fill="x")
|
||
group.count.configure(text=f"{len(profiles)} аккаунт(а)")
|
||
for card in self._cards.values():
|
||
if card.profile_model and card.profile_model.provider == provider:
|
||
card.grid_remove()
|
||
for index, profile in enumerate(profiles):
|
||
self._cards[profile.profile_id].grid(
|
||
row=index // Theme.ACCOUNT_CARD_COLUMNS,
|
||
column=index % Theme.ACCOUNT_CARD_COLUMNS,
|
||
padx=Theme.SPACE_XS,
|
||
pady=Theme.SPACE_XS,
|
||
sticky="nsew",
|
||
)
|
||
visible_total += len(profiles)
|
||
if visible_total:
|
||
self.empty.pack_forget()
|
||
else:
|
||
self.empty.pack(fill="x", pady=Theme.SPACE_XL)
|
||
|
||
def render_stats(self) -> Dict[str, int]:
|
||
return {
|
||
"cards_created": self.cards_created,
|
||
"cards_destroyed": self.cards_destroyed,
|
||
"quota_widgets_created": sum(card.widgets_created for card in self._cards.values()),
|
||
"quota_widgets_destroyed": sum(card.widgets_destroyed for card in self._cards.values()),
|
||
}
|