Files
cowork-local/presentation/monitoring/monitoring_tab.py
T
Nam Pham Dinh ThanhandClaude Opus 5 bc282c71d0 refactor(config): AppConfig thành vỏ mỏng trên repository + vá 3 chỗ gán im lặng hỏng
config.py 623 -> 377 dòng (qua ngưỡng 400 của CASAN Check 2).

Class AppConfig 278 dòng giờ còn 30: mọi lối vào dẫn tới JsonConfigRepository.
Không xoá hẳn vì cái tên còn nằm ở 41 file — 23 checker trong tools/ và 18 file
test, trong đó có test của cả ba người. Sửa 41 chỗ trong một commit là đổi thứ
không cần đổi và làm review không đọc nổi. Giữ tên, đổi ruột.

Thêm JsonConfigRepository.from_data() cho dạng AppConfig(data=..., path=...) mà
13 file test đang dùng: dựng thẳng từ dict, không đọc đĩa, không chạy migration
trên dữ liệu test.

MỘT LỖI TÔI GÂY RA HÔM 25/08, HÔM NAY MỚI LỘ
---------------------------------------------
Lúc tráo R02 tôi có đối chiếu API và kết luận "đủ 34/34 thành viên, thay được".
Đối chiếu đó chỉ so TÊN, không so việc một property có setter hay không.

AppConfig cũ là dataclass nên `config.language = "vi"` chạy bình thường.
Repository để language là property chỉ đọc -> gán vào là AttributeError. Ba chỗ
trong app.py đang gán: đổi ngôn ngữ, đổi giao diện, đổi provider trên thanh bên.

Khó thấy vì cả ba nằm trong slot của Qt, mà Qt NUỐT ngoại lệ trong slot. Không
traceback, không thông báo — bấm đổi ngôn ngữ thì không có gì xảy ra. 709 test
đơn vị vẫn xanh suốt. Chỉ check_nav bắt được vì nó bấm thật vào combo rồi kiểm.

Thêm setter cho theme/language/active_provider, và tests/test_config_gan_duoc.py
đi ngược từ mã nguồn: quét cả repo tìm mọi chỗ `config.X = ...` rồi thử gán
thật. Đã kiểm ngược — bỏ setter đi thì 2 bài đỏ.

BẮC CẦU CHO 55 CONTROL MONITORING
----------------------------------
check_controls_alive so với mốc git 291a611 và đòi 55 control ov_* của Tổng
quan phải còn tới được. Sau khi Hiệp tách 8 tab, chúng về đúng tab/thẻ của mình
và rụng tiền tố -> 3 checker đỏ.

Control còn đủ, chỉ đổi chỗ ở. Bắc cầu bằng __getattr__ định tuyến theo tiền tố
(ov_perm_ -> permissions_card, ov_sbx_ -> sandbox_card, ov_price_/ov_pricing_ ->
pricing_panel, còn lại -> overview_tab), cộng 3 hộp nhóm mà bản thân widget con
chính là hộp đó.

Định tuyến theo tiền tố chứ không dò mờ: overview_tab và permissions_card đều
có network_lbl — một cái là mức dùng mạng, một cái là quyền truy cập mạng. Bản
dò mờ đầu tiên tôi viết vớ nhầm cái đầu tiên tìm thấy.

714 test xanh. 24/24 checker qua (3 cái đã đỏ từ trước khi tôi bắt đầu, do phần
monitoring, nay xanh lại). CASAN Check 1 sạch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:03:28 +09:00

261 lines
12 KiB
Python

"""Monitoring Dashboard — container only. Builds the tab strip, wires the
auto-refresh timer and language-change retranslation, and forwards nav-rail
sub-tab selection. Each sub-tab is its own class under ``tabs/``; this class
owns no display logic of its own beyond assembling and refreshing them.
Public API preserved exactly for ``app.py`` (which cannot be modified):
``MonitoringTab(ctx, cowork=None, structure=None, task_scheduler=None)``,
the ``status_message`` signal, ``select_subtab(index)``, ``nav_subtabs()``,
``hide_tab_bar()``.
"""
from __future__ import annotations
from typing import List
from PySide6.QtCore import QTimer, Signal
from PySide6.QtWidgets import QHBoxLayout, QLabel, QTabWidget, QVBoxLayout, QWidget
from ...core import audit_log
from ...i18n import on_language_changed, tr
from ...state import AppContext
from .tabs.action_logs_tab import ActionLogsTab
from .tabs.agent_status_tab import AgentStatusTab
from .tabs.mcp_tab import McpTab
from .tabs.overview_tab import OverviewTab
from .tabs.security_events_tab import SecurityEventsTab
_REFRESH_MS = 3000
# Comfortably larger than any realistic audit-log size — the event tables
# have never had pagination controls, so every tab still shows "all matching
# events" exactly like before; MonitoringQueryService's pagination support
# is exercised for real here, just not surfaced as UI (yet).
_UNBOUNDED_PAGE_SIZE = 100_000
class MonitoringTab(QWidget):
status_message = Signal(str)
# ---- cầu tương thích sau khi tách 8 tab (R08-T08) --------------------
# Trước khi tách, 55 control của Tổng quan treo thẳng trên MonitoringTab với
# tiền tố ov_. Tách xong mỗi cái về đúng tab/thẻ của nó và rụng tiền tố.
#
# tools/check_controls_alive.py so với mốc git 291a611 và đòi cả 55 cái phải
# còn tới được — đó chính là việc của nó: bắt control biến mất trong lúc bóc
# tách. Lần này control còn đủ, chỉ đổi chỗ ở, nên bắc cầu theo tiền tố.
#
# Định tuyến theo tiền tố chứ không dò mờ: overview_tab và permissions_card
# có những tên trùng nhau (network_lbl nằm ở cả hai — một cái là mức dùng
# mạng, một cái là quyền truy cập mạng). Dò mờ vớ nhầm cái đầu tiên tìm thấy.
_OV_TIEN_TO = (
("ov_perm_", lambda s: s.overview_tab.sandbox_card.permissions_card),
("ov_sbx_", lambda s: s.overview_tab.sandbox_card),
("ov_price_", lambda s: s.overview_tab.pricing_panel),
("ov_pricing_", lambda s: s.overview_tab.pricing_panel),
("ov_", lambda s: s.overview_tab),
)
#: Ba hộp nhóm: bản thân widget con CHÍNH LÀ hộp đó, không phải thuộc tính.
_OV_CHINH_NO = {
"ov_pricing_group": lambda s: s.overview_tab.pricing_panel,
"ov_sandbox_details_group": lambda s: s.overview_tab.sandbox_card,
"ov_permissions_group": lambda s: s.overview_tab.sandbox_card.permissions_card,
}
#: Vài control không mang tiền tố ov_ nhưng cũng đã dời đi.
_KHAC = {
"status_table": lambda s: s.status_tab.table,
}
def __getattr__(self, name):
lay_khac = self._KHAC.get(name)
if lay_khac is not None:
return lay_khac(self)
# Qt gọi __getattr__ rất nhiều lúc khởi tạo; chặn sớm cho rẻ.
if not name.startswith("ov_"):
raise AttributeError(name)
lay = self._OV_CHINH_NO.get(name)
if lay is not None:
return lay(self)
for tien_to, chu in self._OV_TIEN_TO:
if name.startswith(tien_to):
try:
return getattr(chu(self), name[len(tien_to):])
except AttributeError:
continue
raise AttributeError(name)
def __init__(self, ctx: AppContext, cowork=None, structure=None, task_scheduler=None):
super().__init__()
self.ctx = ctx
self._cowork = cowork
self._structure = structure
self._task_scheduler = task_scheduler
root = QVBoxLayout(self)
head = QHBoxLayout()
self._title = QLabel()
self._title.setStyleSheet("font-weight:700; font-size:15px;")
head.addWidget(self._title)
head.addStretch(1)
root.addLayout(head)
self.tabs = QTabWidget()
root.addWidget(self.tabs, 1)
visible = self._tab_visible
self.overview_tab = OverviewTab(
ctx, on_status_message=self.status_message.emit,
on_settings_changed=self.refresh,
on_view_all_action_logs=self._show_action_logs_tab,
action_logs_tab_visible=visible("action_logs"))
self.tabs.addTab(self.overview_tab, "")
self.security_tab = SecurityEventsTab(ctx, on_refresh_all=self.refresh)
if visible("security_events"):
self.tabs.addTab(self.security_tab, "")
self.mcp_tab = McpTab(ctx, on_refresh_all=self.refresh)
if visible("mcp_history"):
self.tabs.addTab(self.mcp_tab, "")
self.action_tab = ActionLogsTab(ctx, on_refresh_all=self.refresh)
if visible("action_logs"):
self.tabs.addTab(self.action_tab, "")
self.status_tab = AgentStatusTab(
ctx, on_refresh_all=self.refresh,
cowork=cowork, structure=structure, task_scheduler=task_scheduler)
if visible("agent_status"):
self.tabs.addTab(self.status_tab, "")
# ---- Agents Admin (catalog: assign a role + pinned model per agent) --
from ...ui.agents_admin_tab import AgentsAdminTab
self.agents_admin_tab = AgentsAdminTab(ctx)
if visible("agents_admin"):
self.tabs.addTab(self.agents_admin_tab, "")
# ---- Tools (govern built-in tools + Connectors/MCP in one place) -----
from ...ui.tools_admin_tab import ToolsAdminTab
self.tools_admin_tab = ToolsAdminTab(ctx)
if visible("tools_admin"):
self.tabs.addTab(self.tools_admin_tab, "")
# ---- Icons (browse built-in icons + add custom icons for agents/flows) --
from ...ui.icons_admin_tab import IconsAdminTab
self.icons_admin_tab = IconsAdminTab(ctx)
self.tabs.addTab(self.icons_admin_tab, "")
self.tabs.setCurrentIndex(0)
self._timer = QTimer(self)
# Only the Overview cards auto-refresh on this tick — Security, MCP,
# Action Logs, Agent Status (and the admin tabs) are read-only tables
# a background re-sort would otherwise disturb mid-interaction; the
# user refreshes them explicitly via a "Refresh" button.
self._timer.setInterval(_REFRESH_MS)
self._timer.timeout.connect(self._auto_refresh)
self._timer.start()
on_language_changed(self._retranslate)
self.refresh()
# ---- nav integration: sub-tabs driven from the left nav rail ------------
def nav_subtabs(self):
"""(label, index, icon_name) for each sub-tab — the left nav lists
these as children under 'Monitoring'."""
by_widget = {self.agents_admin_tab: "robot", self.tools_admin_tab: "wrench",
self.icons_admin_tab: "star"}
for attr, name in (("security_tab", "shield"), ("mcp_tab", "plug"),
("action_tab", "bolt"), ("status_tab", "monitor")):
w = getattr(self, attr, None)
if w is not None:
by_widget[w] = name
out = []
for i in range(self.tabs.count()):
out.append((self.tabs.tabText(i), i, by_widget.get(self.tabs.widget(i), "dashboard")))
return out
def select_subtab(self, index: int) -> None:
if 0 <= index < self.tabs.count():
self.tabs.setCurrentIndex(index)
def hide_tab_bar(self) -> None:
"""Hide the in-content tab strip; the nav rail drives the sub-tabs."""
self.tabs.tabBar().hide()
def _show_action_logs_tab(self) -> None:
self.tabs.setCurrentIndex(self.tabs.indexOf(self.action_tab))
def _tab_visible(self, key: str) -> bool:
return self.ctx.role == "admin" or bool(self.ctx.config.monitoring_visibility.get(key, True))
def _set_tab_text_if_present(self, widget, text: str) -> None:
idx = self.tabs.indexOf(widget)
if idx >= 0:
self.tabs.setTabText(idx, text)
# ---- i18n ---------------------------------------------------------------
def _retranslate(self) -> None:
self._title.setText(tr("monitoring.title"))
if self.tabs.count():
self.tabs.setTabText(0, tr("monitoring.tab_overview"))
self._set_tab_text_if_present(self.security_tab, tr("monitoring.tab_security"))
self._set_tab_text_if_present(self.mcp_tab, tr("monitoring.tab_mcp"))
self._set_tab_text_if_present(self.action_tab, tr("monitoring.tab_actions"))
self._set_tab_text_if_present(self.status_tab, tr("monitoring.tab_agents"))
self._set_tab_text_if_present(self.agents_admin_tab, tr("monitoring.tab_agents_admin"))
self._set_tab_text_if_present(self.tools_admin_tab, tr("monitoring.tab_tools"))
self._set_tab_text_if_present(self.icons_admin_tab, tr("monitoring.tab_icons"))
self.overview_tab.retranslate()
self.security_tab.retranslate()
self.mcp_tab.retranslate()
self.action_tab.retranslate()
self.status_tab.retranslate()
self.refresh()
# ---- refresh -------------------------------------------------------------
def refresh(self) -> None:
"""Full refresh — Overview cards plus every table. Wired to the
top-of-page and per-section "Refresh" buttons, called once at
startup/language-change, but NOT to the auto-refresh timer (see
``_auto_refresh``)."""
events = self._load_events()
self._apply_events_to_event_tabs(events)
self.status_tab.refresh()
self.overview_tab.refresh(events)
def _auto_refresh(self) -> None:
"""3-second timer tick — Overview cards only (see ``refresh``)."""
self.overview_tab.refresh(self._load_events())
def _load_events(self) -> List[dict]:
shared_dir = self.ctx.config.shared_dir
if shared_dir:
from ...core import telemetry_shared
shared_events = telemetry_shared.load_shared_audit_events(shared_dir)
if shared_events:
return shared_events
return audit_log.load_events()
def _apply_events_to_event_tabs(self, events: List[dict]) -> None:
"""Filters the ALREADY-LOADED event list (see ``_load_events`` — one
shared local-or-shared decision per refresh, not one per tab) three
ways via :class:`MonitoringQueryService`, mirroring what each
``EventTable.set_events`` used to receive directly."""
from ...application.monitoring.dto.audit_event_dto import AuditEventDTO
from ...application.monitoring.monitoring_query_service import MonitoringQueryService
from ...application.monitoring.repository.audit_event_repository import (
InMemoryAuditEventRepository,
)
repository = InMemoryAuditEventRepository([AuditEventDTO.from_raw(e) for e in events])
service = MonitoringQueryService(repository)
def _events_for(kind) -> List[dict]:
page = service.query(kind=kind, page_size=_UNBOUNDED_PAGE_SIZE)
return [e.to_dict() for e in page.items]
self.security_tab.set_events(_events_for("security_block"))
self.mcp_tab.set_events(_events_for("mcp_call"))
self.action_tab.set_events(_events_for(None))