Files
cowork-local/presentation/monitoring/tabs/overview_tab.py
T
Hiep Ha VanandClaude Sonnet 5 40b12ecb15 refactor(monitoring): N2 - tach monitoring_tab.py, CanonicalAuditLogger, MonitoringQueryService, go circular import, sandbox matrix
- 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>
2026-08-25 23:52:36 +09:00

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"))