Files
cowork-local/presentation/monitoring/tabs/overview_tab.py
T
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-08-31 05:15:13 +00:00

351 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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):
"""Tab "Tổng quan" của màn Giám sát: tài nguyên máy, thẻ token/chi phí, ngân
sách, trạng thái sandbox, hoạt động gần đây và trích nhật ký kiểm toán.
Một cột dọc duy nhất, mỗi phần là một mục có tiêu đề — bố cục này là chủ ý
để mắt quét từ trên xuống thay vì phải nhảy giữa hai cột.
"""
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):
"""Tab Tổng quan.
Mọi việc ra ngoài tab (báo trạng thái, mở màn khác) đều đi qua callback
truyền vào, nên tab này không cần biết cửa sổ chính.
"""
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:
"""Dựng mục thẻ token/chi phí kèm thẻ ngân sách."""
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:
"""Dựng mục "Hoạt động gần đây" (thêm vào cột ở dưới, cạnh nhật ký kiểm toán)."""
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:
"""Dựng mục tài nguyên: CPU, bộ nhớ, đĩa, mạng và trạng thái sandbox."""
self.resource_group = QGroupBox()
self.resource_group.setObjectName("monSection")
res_lay = QHBoxLayout(self.resource_group)
res_lay.setSpacing(6)
def _pair():
"""Thêm một cặp nhãn–giá trị vào hàng tài nguyên, tự chèn dấu phân cách giữa
các cặp.
"""
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():
"""Thêm một cặp nhãn–giá trị kèm thanh tiến độ (CPU, bộ nhớ)."""
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:
"""Dựng mục trích nhật ký kiểm toán; ẩn hẳn khi tab Nhật ký hành động bị tắt."""
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:
"""Cập nhật thẻ ngân sách: còn lại / tổng, phần trăm đã dùng, cảnh báo khi vượt 85%.
Không ghi đè ô nhập khi người dùng đang gõ trong đó.
"""
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:
"""Đọc CPU/RAM/đĩa/mạng của CHÍNH tiến trình app.
Đĩa và mạng là tốc độ tức thời, tính bằng hiệu hai lần lấy mẫu chia cho
khoảng thời gian giữa chúng — nên lần đo đầu tiên luôn hiện "—". Không có
``psutil`` thì cả mục chuyển sang "—" chứ không làm vỡ màn hình.
"""
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:
"""Đặt mọi chỉ số tài nguyên về "—" (khi thiếu ``psutil``)."""
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:
"""Một dòng hoạt động: dấu ✓/!/✗ theo kết quả, tên việc, và thời gian tương đối."""
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:
"""Cập nhật các thẻ token và chi phí.
Đồng bộ bảng đơn giá trước rồi mới tính tiền, để con số chi phí luôn khớp
với bảng giá người dùng vừa sửa.
"""
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:
"""Áp lại chữ theo ngôn ngữ đang chọn cho mọi tiêu đề mục và nhãn."""
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"))