"""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 datetime import date, timedelta 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 ...performance import span 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 for the WINDOW of # events _load_events() now actually reads (see _LOG_WINDOW_DAYS below) — this # is MonitoringQueryService's query-side page size, kept unbounded so it # always returns every matching event within the window; the user-facing # "Số dòng/trang" control (DF-006 — see shared/event_table.py::set_page_size, # shared/filter_scaffold.py::build_filter_scaffold's with_page_size) trims # that down for DISPLAY, client-side, per event tab. _UNBOUNDED_PAGE_SIZE = 100_000 # _load_events() re-reads the audit log from disk every _REFRESH_MS (3s) via # _auto_refresh(), and audit_log.load_events()/load_shared_audit_events() are # day-sharded JSONL — unbounded start/end means EVERY day file ever written # gets re-read and re-parsed on EVERY tick, which is what actually made # Monitoring "gây nặng khi log lớn" (see DF-006): the slowness was never in # rendering (EventTable paginates client-side, 5-100 rows/page — see # shared/event_table.py::_DEFAULT_PAGE_SIZE), it was this repeated full-history # read. # 30 days is a live-monitoring window, not a hard retention limit — nothing # is deleted, older days are simply not re-read on every 3s tick. _LOG_WINDOW_DAYS = 30 class MonitoringTab(QWidget): """Vỏ màn Giám sát: dải 8 tab và phần dữ liệu dùng chung cho chúng. Nhật ký kiểm toán được đọc MỘT lần mỗi nhịp làm mới rồi chia cho các tab, nên quyết định "đọc từ nguồn cục bộ hay nguồn chia sẻ" chỉ xảy ra đúng một lần chứ không phải ở từng tab. """ 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): """Cầu tương thích cho 55 control cũ mang tiền tố ``ov_``. R08-T08 chuyển chúng về đúng tab/thẻ của mình và bỏ tiền tố, nhưng các checker trong ``tools/`` vẫn hỏi theo tên cũ. Định tuyến theo TIỀN TỐ chứ không dò mờ: ``overview_tab`` và ``permissions_card`` có tên trùng nhau (``network_lbl`` ở 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ờ sẽ vớ nhầm cái tìm thấy trước. """ 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): """Ghép các tab con của màn Giám sát. Các widget được truyền vào đều có thể là None (test dựng tab lẻ) — mọi chỗ dùng phải kiểm trước. """ 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: """Chuyển sang tab con thứ ``index`` (thanh menu bên trái gọi vào đây).""" 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: """Nhảy tới tab Nhật ký hành động (dùng khi bấm vào phần trích ở Tổng quan).""" self.tabs.setCurrentIndex(self.tabs.indexOf(self.action_tab)) def _tab_visible(self, key: str) -> bool: """Tab này có được hiện không: admin thấy hết, còn lại theo cấu hình ``monitoring_visibility``. """ 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: """Đổi nhãn một tab, bỏ qua nếu tab đó đang bị ẩn theo phân quyền.""" idx = self.tabs.indexOf(widget) if idx >= 0: self.tabs.setTabText(idx, text) # ---- i18n --------------------------------------------------------------- def _retranslate(self) -> None: """Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và nhãn từng tab.""" 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]: """Đọc nhật ký kiểm toán, ưu tiên nguồn chia sẻ rồi mới tới nguồn cục bộ. Có cấu hình thư mục chia sẻ VÀ đọc ra được dữ liệu thì dùng nó, để cả đội nhìn chung một bức tranh; rỗng thì rơi về nhật ký của máy này. Chỉ đọc ``_LOG_WINDOW_DAYS`` ngày gần nhất — cả hai nguồn đều lưu theo file JSONL từng ngày, nên bounding ở đây tránh việc đọc lại TOÀN BỘ lịch sử mỗi 3 giây (xem ``_auto_refresh``), là nguyên nhân thật của DF-006 (gây nặng khi log lớn). """ start = date.today() - timedelta(days=_LOG_WINDOW_DAYS) shared_dir = self.ctx.config.shared_dir with span("monitoring.load_events", window_days=_LOG_WINDOW_DAYS): if shared_dir: from ...core import telemetry_shared shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start) if shared_events: return shared_events return audit_log.load_events(start=start) 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]: """Lấy toàn bộ sự kiện của một loại, không phân trang.""" 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))