- ui/monitoring_tab.py (1546 dong) tach thanh presentation/monitoring/** (container + 7 tab/card + shared helper), ui/monitoring_tab.py con lai re-export shim de app.py khong doi. - infrastructure/telemetry/audit_logger.py: CanonicalAuditLogger, core/audit_log.py thanh wrapper mong, tuong thich nguoc 100% voi schema .jsonl cu. - application/monitoring/monitoring_query_service.py: MonitoringQueryService read-only, filter/sort/pagination, khong import PySide6. - Go circular import model_pricing<->usage_tracker va agent_security<-> agent_security_alert (core/agent_security_types.py moi). - infrastructure/sandbox/sandbox_capabilities.py: SandboxCapabilityMatrix theo OS (Windows/Linux/macOS), chua dau noi vao core/sandbox_manager.py. - conftest.py: sua loi checkout khong ten cowork_local khien pytest import nham thu muc khac. - 77 test moi, 167/167 pass. QA da xac nhan UI/business logic khong doi (xem evidence/report/unified_report.html). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
314 lines
14 KiB
Python
314 lines
14 KiB
Python
"""Overview tab — the card-based dashboard (Token Usage & Cost, Resource
|
|
Usage, Recent Activity, Sandbox Details + nested Permissions, Model Pricing,
|
|
Audit Log preview). Extracted from ``ui/monitoring_tab.py``'s
|
|
``_build_overview_page`` and the refresh/pricing/budget methods it wires to.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from typing import Callable, List
|
|
|
|
from PySide6.QtCore import Qt
|
|
from PySide6.QtWidgets import (
|
|
QGridLayout, QGroupBox, QHBoxLayout, QLabel, QProgressBar, QPushButton,
|
|
QScrollArea, QVBoxLayout, QWidget,
|
|
)
|
|
|
|
from ....core import usage_tracker as ut
|
|
from ....i18n import tr
|
|
from ....theme import current_palette
|
|
from ....ui.icons import DOT_AMBER, DOT_GREEN, DOT_RED, icon
|
|
from ....ui.widgets import BudgetCard, StatCard, fmt_tokens
|
|
from ..shared.formatters import fmt_bytes, relative_time
|
|
from .pricing_panel import PricingPanel
|
|
from .sandbox_tab import SandboxDetailsCard
|
|
|
|
|
|
class OverviewTab(QWidget):
|
|
def __init__(self, ctx, *, on_status_message: Callable[[str], None],
|
|
on_settings_changed: Callable[[], None],
|
|
on_view_all_action_logs: Callable[[], None],
|
|
action_logs_tab_visible: bool):
|
|
super().__init__()
|
|
self.ctx = ctx
|
|
self._on_status_message = on_status_message
|
|
self._on_view_all_action_logs = on_view_all_action_logs
|
|
self._last_io_sample = None
|
|
self._res_first = True
|
|
|
|
outer = QVBoxLayout(self)
|
|
outer.setContentsMargins(0, 0, 0, 0)
|
|
scroll = QScrollArea()
|
|
scroll.setWidgetResizable(True)
|
|
scroll.setFrameShape(QScrollArea.NoFrame)
|
|
content = QWidget()
|
|
scroll.setWidget(content)
|
|
outer.addWidget(scroll)
|
|
|
|
# ONE main column, scrolled vertically, sections in a fixed order.
|
|
root = QVBoxLayout(content)
|
|
root.setSpacing(12)
|
|
|
|
self._build_usage_section(root)
|
|
self._build_activity_section() # added to `root` further down, beside the audit log
|
|
self._build_resource_section(root)
|
|
|
|
self.sandbox_card = SandboxDetailsCard(ctx, on_settings_changed)
|
|
root.addWidget(self.sandbox_card)
|
|
|
|
self.pricing_panel = PricingPanel(ctx, on_status_message)
|
|
root.addWidget(self.pricing_panel)
|
|
root.addWidget(self.activity_group)
|
|
self._build_audit_section(root, action_logs_tab_visible)
|
|
root.addStretch(1)
|
|
|
|
# ---- Token Usage & Cost ------------------------------------------------
|
|
def _build_usage_section(self, root: QVBoxLayout) -> None:
|
|
self.usage_group = QGroupBox()
|
|
self.usage_group.setObjectName("monSection")
|
|
usage_lay = QGridLayout(self.usage_group)
|
|
usage_lay.setSpacing(8)
|
|
self.usage_total = StatCard()
|
|
self.usage_in = StatCard()
|
|
self.usage_out = StatCard()
|
|
self.usage_cache = StatCard()
|
|
self.usage_cost = StatCard()
|
|
self.usage_calls = StatCard()
|
|
for i, card in enumerate((self.usage_cost, self.usage_total,
|
|
self.usage_in, self.usage_out, self.usage_cache)):
|
|
usage_lay.addWidget(card, 0, i)
|
|
self.usage_calls.setVisible(False) # rides on the cost tile's label
|
|
self.budget_card = BudgetCard()
|
|
self.budget_card.apply_btn.setIcon(icon("check"))
|
|
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
|
|
usage_lay.addWidget(self.budget_card, 0, 3)
|
|
for col in range(4):
|
|
usage_lay.setColumnStretch(col, 1)
|
|
root.addWidget(self.usage_group)
|
|
|
|
# ---- Recent Activity ----------------------------------------------------
|
|
def _build_activity_section(self) -> None:
|
|
self.activity_group = QGroupBox()
|
|
self.activity_group.setObjectName("monSection")
|
|
act_lay = QVBoxLayout(self.activity_group)
|
|
self.activity_lbl = QLabel()
|
|
self.activity_lbl.setWordWrap(True)
|
|
self.activity_lbl.setTextFormat(Qt.RichText)
|
|
act_lay.addWidget(self.activity_lbl)
|
|
|
|
# ---- Resource Usage -------------------------------------------------------
|
|
def _build_resource_section(self, root: QVBoxLayout) -> None:
|
|
self.resource_group = QGroupBox()
|
|
self.resource_group.setObjectName("monSection")
|
|
res_lay = QHBoxLayout(self.resource_group)
|
|
res_lay.setSpacing(6)
|
|
|
|
def _pair():
|
|
if not self._res_first:
|
|
sep = QLabel(chr(0xB7))
|
|
sep.setObjectName("hint")
|
|
res_lay.addWidget(sep)
|
|
self._res_first = False
|
|
lbl = QLabel()
|
|
lbl.setObjectName("hint")
|
|
val = QLabel()
|
|
res_lay.addWidget(lbl)
|
|
res_lay.addWidget(val)
|
|
return lbl, val
|
|
|
|
def _bar_row():
|
|
lbl, val = _pair()
|
|
bar = QProgressBar()
|
|
bar.setVisible(False)
|
|
return lbl, bar, val
|
|
|
|
self.cpu_lbl, self.cpu_bar, self.cpu_val = _bar_row()
|
|
self.mem_lbl, self.mem_bar, self.mem_val = _bar_row()
|
|
self.diskfree_lbl, self.diskfree_val = _pair()
|
|
self.disk_lbl, self.disk_val = QLabel(), QLabel()
|
|
self.network_lbl, self.network_val = QLabel(), QLabel()
|
|
res_lay.addStretch(1)
|
|
root.addWidget(self.resource_group)
|
|
|
|
# ---- Audit Log preview --------------------------------------------------
|
|
def _build_audit_section(self, root: QVBoxLayout, action_logs_tab_visible: bool) -> None:
|
|
self.audit_group = QGroupBox()
|
|
self.audit_group.setObjectName("monSection")
|
|
audit_lay = QVBoxLayout(self.audit_group)
|
|
self.audit_lbl = QLabel()
|
|
self.audit_lbl.setWordWrap(True)
|
|
self.audit_lbl.setTextFormat(Qt.RichText)
|
|
audit_lay.addWidget(self.audit_lbl)
|
|
self.view_all_btn = QPushButton()
|
|
self.view_all_btn.setFlat(True)
|
|
self.view_all_btn.clicked.connect(lambda: self._on_view_all_action_logs())
|
|
audit_lay.addWidget(self.view_all_btn, 0, Qt.AlignRight)
|
|
self.audit_group.setVisible(action_logs_tab_visible)
|
|
root.addWidget(self.audit_group)
|
|
|
|
# ---- budget --------------------------------------------------------------
|
|
def _apply_budget(self) -> None:
|
|
"""Persist the spin box's value as the new budget — starts a fresh
|
|
remaining-balance window (spend before now is no longer counted)."""
|
|
ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD")
|
|
ut.set_budget(self.ctx.config, self.budget_card.budget_spin.value(), ccy)
|
|
self.ctx.save()
|
|
self._refresh_budget()
|
|
|
|
def _refresh_budget(self) -> None:
|
|
from ....core import model_pricing as mp
|
|
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
|
status = ut.budget_status(self.ctx.config)
|
|
if status is None:
|
|
self.budget_card.set(tr("usage.budget_title"), "—", tr("usage.budget_no_budget"))
|
|
self.budget_card.budget_spin.setValue(0.0)
|
|
return
|
|
amount_disp = mp.convert(status["amount_usd"], "USD",
|
|
pricing.get("currency", "USD"), self.ctx.config)
|
|
value = (f"{ut.format_cost(status['remaining_usd'], pricing, digits=2)}"
|
|
f" / {ut.format_cost(status['amount_usd'], pricing, digits=2)}")
|
|
pct = int(round(status["pct_used"] * 100))
|
|
sub = tr("usage.budget_over_warning") if status["over_85"] else tr("usage.budget_used_pct", pct=pct)
|
|
self.budget_card.set(tr("usage.budget_title"), value, sub, warn=status["over_85"])
|
|
if not self.budget_card.budget_spin.hasFocus():
|
|
self.budget_card.budget_spin.setValue(round(amount_disp, 2))
|
|
|
|
# ---- resource usage --------------------------------------------------------
|
|
def _refresh_resource_usage(self) -> None:
|
|
try:
|
|
import psutil
|
|
except ImportError:
|
|
self._set_resource_na()
|
|
return
|
|
|
|
try:
|
|
own = psutil.Process()
|
|
own_cpu = own.cpu_percent(interval=None)
|
|
own_mem = own.memory_info().rss
|
|
except Exception:
|
|
own, own_cpu, own_mem = None, 0.0, 0
|
|
|
|
self.cpu_bar.setValue(int(min(own_cpu, 100)))
|
|
self.cpu_val.setText(f"{own_cpu:.0f}%")
|
|
try:
|
|
total_mem = psutil.virtual_memory().total
|
|
mem_pct = int(own_mem * 100 / total_mem) if total_mem else 0
|
|
except Exception:
|
|
mem_pct = 0
|
|
self.mem_bar.setValue(min(mem_pct, 100))
|
|
try:
|
|
self.mem_val.setText(f"{fmt_bytes(own_mem)}/{fmt_bytes(total_mem)}")
|
|
except Exception: # noqa: BLE001
|
|
self.mem_val.setText(fmt_bytes(own_mem))
|
|
try:
|
|
free = psutil.disk_usage(str(self.ctx.config.cowork_output_dir())).free
|
|
self.diskfree_val.setText(tr("monitoring.overview_disk_free", size=fmt_bytes(free)))
|
|
except Exception: # noqa: BLE001
|
|
self.diskfree_val.setText(tr("monitoring.na"))
|
|
|
|
now = time.monotonic()
|
|
try:
|
|
io = own.io_counters() if own is not None else None
|
|
disk_bytes = (io.read_bytes + io.write_bytes) if io is not None else None
|
|
except Exception:
|
|
disk_bytes = None
|
|
try:
|
|
net = psutil.net_io_counters()
|
|
net_bytes = net.bytes_sent + net.bytes_recv
|
|
except Exception:
|
|
net_bytes = None
|
|
|
|
prev = self._last_io_sample
|
|
self._last_io_sample = (now, disk_bytes, net_bytes)
|
|
na = tr("monitoring.na")
|
|
if prev and disk_bytes is not None and prev[1] is not None and now > prev[0]:
|
|
rate = max(0.0, (disk_bytes - prev[1]) / (now - prev[0]))
|
|
self.disk_val.setText(f"{fmt_bytes(rate)}/s")
|
|
else:
|
|
self.disk_val.setText(na)
|
|
if prev and net_bytes is not None and prev[2] is not None and now > prev[0]:
|
|
rate = max(0.0, (net_bytes - prev[2]) / (now - prev[0]))
|
|
self.network_val.setText(f"{fmt_bytes(rate)}/s")
|
|
else:
|
|
self.network_val.setText(na)
|
|
|
|
def _set_resource_na(self) -> None:
|
|
na = tr("monitoring.na")
|
|
self.cpu_bar.setValue(0)
|
|
self.cpu_val.setText(na)
|
|
self.mem_bar.setValue(0)
|
|
self.mem_val.setText(na)
|
|
self.disk_val.setText(na)
|
|
self.network_val.setText(na)
|
|
|
|
# ---- usage cards -----------------------------------------------------------
|
|
def _activity_line(self, event: dict) -> str:
|
|
ok = event.get("ok", True)
|
|
if ok:
|
|
mark = f"<span style='color:{DOT_GREEN};'>✓</span>"
|
|
elif event.get("kind") == "security_block":
|
|
mark = f"<span style='color:{DOT_AMBER};'>!</span>"
|
|
else:
|
|
mark = f"<span style='color:{DOT_RED};'>✗</span>"
|
|
name = event.get("name", "") or event.get("kind", "")
|
|
rel = relative_time(event.get("ts", ""))
|
|
muted = current_palette().text_muted
|
|
suffix = f" <span style='color:{muted};'>— {rel}</span>" if rel else ""
|
|
return f"{mark} {name}{suffix}"
|
|
|
|
def _refresh_usage_cards(self) -> None:
|
|
from ....core import model_pricing as mp
|
|
mp.sync_to_usage(self.ctx.config) # cost total comes straight from the price table
|
|
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
|
events = ut.load_events()
|
|
s = ut.summarize(events)
|
|
costs = ut.cost_usd_events(events, pricing)
|
|
self.usage_total.set(tr("dashboard.card_total"), fmt_tokens(s["total"]))
|
|
self.usage_calls.set(tr("monitoring.overview_calls"), str(s["turns"]), "")
|
|
self.usage_in.set(tr("dashboard.card_in"), fmt_tokens(s["in"]),
|
|
ut.format_cost(costs["in"], pricing))
|
|
self.usage_out.set(tr("dashboard.card_out"), fmt_tokens(s["out"]),
|
|
ut.format_cost(costs["out"], pricing))
|
|
self.usage_cache.set(tr("dashboard.card_cache"), fmt_tokens(s["cache"]),
|
|
ut.format_cost(costs["cache"], pricing))
|
|
self.usage_cost.set(
|
|
f'{tr("dashboard.card_cost")} · {s["turns"]} {tr("monitoring.overview_calls")}',
|
|
ut.format_cost(sum(costs.values()), pricing, digits=2))
|
|
self._refresh_budget()
|
|
|
|
# ---- public API used by the container ---------------------------------
|
|
def refresh(self, events: List[dict]) -> None:
|
|
"""Full refresh — resource usage + usage cards + sandbox/permissions
|
|
+ recent activity + audit preview. ``events`` is the already-loaded
|
|
(local-or-shared) audit log, shared with the event-table tabs so the
|
|
decision of which source to read from is made exactly once per
|
|
refresh tick."""
|
|
self._refresh_resource_usage()
|
|
self._refresh_usage_cards()
|
|
self.sandbox_card.refresh()
|
|
|
|
recent = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)
|
|
if recent:
|
|
self.activity_lbl.setText("<br>".join(self._activity_line(e) for e in recent[:6]))
|
|
self.audit_lbl.setText("<br>".join(
|
|
f"{e.get('ts', '')} — {e.get('name', '')}" for e in recent[:4]))
|
|
else:
|
|
self.activity_lbl.setText(tr("monitoring.overview_no_activity"))
|
|
self.audit_lbl.setText(tr("monitoring.overview_no_activity"))
|
|
|
|
def retranslate(self) -> None:
|
|
self.usage_group.setTitle(tr("monitoring.overview_usage_title").upper().replace("&", "&&"))
|
|
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
|
|
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
|
|
self.activity_group.setTitle(tr("monitoring.overview_activity_title").upper())
|
|
self.resource_group.setTitle(tr("monitoring.overview_resource_title").upper())
|
|
self.cpu_lbl.setText(tr("monitoring.overview_res_cpu"))
|
|
self.mem_lbl.setText(tr("monitoring.overview_res_mem"))
|
|
self.diskfree_lbl.setText(tr("monitoring.overview_disk_label"))
|
|
self.disk_lbl.setText(tr("monitoring.overview_res_disk"))
|
|
self.network_lbl.setText(tr("monitoring.overview_res_network"))
|
|
self.pricing_panel.retranslate()
|
|
self.sandbox_card.retranslate()
|
|
self.audit_group.setTitle(tr("monitoring.overview_audit_title").upper())
|
|
self.view_all_btn.setText(tr("monitoring.overview_view_all"))
|