Update Screen: Bao Mat, Hanh Dong, MCP, Agent, Agent Admin

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 11:57:16 +09:00
co-authored by Claude Sonnet 5
parent ec2a84f38f
commit e6adfd9350
5 changed files with 916 additions and 131 deletions
+574 -47
View File
@@ -24,12 +24,15 @@ import time
from datetime import datetime
from typing import List
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QBrush, QColor
from PySide6.QtCore import Qt, QEvent, QObject, QRect, QSize, QTimer, Signal
from PySide6.QtGui import (
QBrush, QColor, QFont, QGuiApplication, QIcon, QKeySequence, QPainter,
QPixmap, QShortcut,
)
from PySide6.QtWidgets import (
QComboBox, QGridLayout, QGroupBox, QHBoxLayout,
QApplication, QComboBox, QGridLayout, QGroupBox, QHBoxLayout,
QHeaderView, QLabel, QLineEdit, QProgressBar, QPushButton, QScrollArea,
QTableWidget, QTableWidgetItem, QTabWidget, QVBoxLayout, QWidget,
QSplitter, QTableWidget, QTableWidgetItem, QTabWidget, QVBoxLayout, QWidget,
)
from ..core import agent_roles, audit_log
@@ -38,7 +41,7 @@ from ..i18n import on_language_changed, tr
from ..state import AppContext
from ..theme import current_palette
from .icons import icon, DOT_GREEN, DOT_RED, DOT_AMBER
from .widgets import BudgetCard, StatCard, fmt_tokens
from .widgets import BudgetCard, StatCard, badge_pill_widget, fmt_tokens
_REFRESH_MS = 3000
_MAX_ROWS = 300
@@ -52,6 +55,59 @@ def _fmt_bytes(n: float) -> str:
return f"{n:.1f} TB"
def _fmt_event_time(ts: str) -> str:
""""dd/MM hh:mm" for the Time column — e.g. 25/05 15:03."""
try:
dt = datetime.fromisoformat(ts)
except (TypeError, ValueError):
return ts
return dt.strftime("%d/%m %H:%M")
def _agent_initials(name: str) -> str:
"""First letter of each word, max 2 — ``ini()`` in ui-audit_v2.html."""
return "".join(w[0] for w in name.split() if w)[:2].upper()
def _agent_avatar_colour(name: str) -> str:
"""Same mapping as ``ac()`` in ui-audit_v2.html — a fixed identity colour
per agent kind, unchanged by theme (like the mockup's badge colours)."""
if "Security" in name:
return "#D13438"
if "Cowork" in name:
return "#0078D4"
if name == "schedule" or "Task" in name:
return "#FFB900"
if name == "graphrag" or "Knowledge" in name:
return "#8764B8"
if "Code" in name:
return "#107C10"
if "Planner" in name or "Reasoning" in name:
return "#8A8886"
return "#0078D4"
def _agent_avatar_icon(name: str, size: int = 20) -> QIcon:
"""A small round initials badge for the Agent column — 1:1 with the
``.wf .av`` avatar in ui-audit_v2.html (colour-coded circle + up to
2-letter initials, drawn to the left of the agent's name)."""
pm = QPixmap(size, size)
pm.fill(Qt.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
p.setBrush(QColor(_agent_avatar_colour(name)))
p.drawEllipse(0, 0, size, size)
font = QFont()
font.setPixelSize(max(7, size // 2))
font.setBold(True)
p.setFont(font)
p.setPen(QColor("#FFFFFF"))
p.drawText(pm.rect(), Qt.AlignCenter, _agent_initials(name))
p.end()
return QIcon(pm)
def _relative_time(ts: str) -> str:
"""A short "Xm ago"-style string for an audit-log ``ts`` (naive local
ISO timestamp, see ``audit_log.record``); "" if unparsable."""
@@ -69,10 +125,26 @@ def _relative_time(ts: str) -> str:
return tr("monitoring.time_days_ago", n=int(delta // 86400))
class _TimeItem(QTableWidgetItem):
"""The Time column shows "dd/MM hh:mm", which does not sort correctly as
text (day-of-month leads, not year/month) — so sorting compares the raw
ISO ``ts`` each item is built from instead of its displayed text."""
def __init__(self, raw_ts: str, display: str):
super().__init__(display)
self._raw_ts = raw_ts
def __lt__(self, other):
if isinstance(other, _TimeItem):
return self._raw_ts < other._raw_ts
return super().__lt__(other)
class _EventTable(QTableWidget):
"""A read-only table of audit-log events — newest-first by default, and
every column header is click-to-sort (ascending/descending toggle; the
Time column's ISO timestamps sort correctly as text)."""
Time column sorts by the underlying ISO timestamp, not its "dd/MM hh:mm"
display text — see :class:`_TimeItem`)."""
# What each blocked action is, as a colour. Security events all record
# ok=False, so the tick/cross column said the same thing on every row; the
@@ -93,7 +165,14 @@ class _EventTable(QTableWidget):
super().__init__(0, 7 if show_result else 6)
self.setEditTriggers(QTableWidget.NoEditTriggers)
self.setSelectionBehavior(QTableWidget.SelectRows)
self.setIconSize(QSize(20, 20)) # ~1.25x the mockup's 16x16 avatar badge
self.verticalHeader().setVisible(False)
# Fixed row height — letting Qt auto-size rows from content fought with
# the Hành động column's cell widget (its layout would settle on a
# stale, oversized geometry from an intermediate sizing pass, clipping
# the pill's text). A fixed height sidesteps that entirely.
self.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
self.verticalHeader().setDefaultSectionSize(32)
self.setSortingEnabled(True)
header = self.horizontalHeader()
header.setStretchLastSection(True)
@@ -101,13 +180,17 @@ class _EventTable(QTableWidget):
header.setSectionResizeMode(col, QHeaderView.ResizeToContents)
def retranslate(self) -> None:
cols = [tr("monitoring.col_time"), tr("monitoring.col_role"),
# Security Events (show_result=False) is the ui-audit_v2.html
# wireframe's table 1:1 — "Agent" and "Chi tiết chặn", not the
# longer generic wording MCP/Action Logs share.
cols = [tr("monitoring.col_time"),
tr("monitoring.col_agent") if not self._show_result else tr("monitoring.col_role"),
tr("monitoring.col_account"), tr("monitoring.col_machine")]
if self._show_result:
cols += [tr("monitoring.col_name"), tr("monitoring.col_result")]
else:
cols += [tr("monitoring.col_action")]
cols += [tr("monitoring.col_detail")]
cols += [tr("monitoring.col_detail_block") if not self._show_result else tr("monitoring.col_detail")]
self.setHorizontalHeaderLabels(cols)
def set_events(self, events: List[dict]) -> None:
@@ -126,16 +209,35 @@ class _EventTable(QTableWidget):
cells.append((ev.get("detail") or "")[:300])
pal = current_palette()
for col, text in enumerate(cells):
item = QTableWidgetItem(str(text))
item = (_TimeItem(str(text), _fmt_event_time(str(text))) if col == 0
else QTableWidgetItem(str(text)))
if col == 0:
# Stash the full event (untruncated detail included) on the
# Time cell, so a click-to-open detail panel survives the
# user re-sorting the table by any column.
item.setData(Qt.UserRole, ev)
if col == 1:
# Agent — colour-coded initials avatar (see ui-audit_v2.html).
item.setIcon(_agent_avatar_icon(str(text)))
if self._show_result and col == 5:
# Result — green check / red close icon (no emoji)
item.setIcon(icon("check", color=DOT_GREEN) if ev.get("ok")
else icon("close", color=DOT_RED))
if not self._show_result and col == 4:
# The action itself, tinted by which rule fired — this is
# the column the always-identical result column made way for.
# Human-readable label ("Path ngoài sandbox"), not the raw
# audit_log name ("path_outside_sandbox") — same wording as
# the detail panel's Loại field (_action_label). Tinted by
# which rule fired, via the ITEM's own colours — NOT a
# setCellWidget() pill: a cell widget is pinned to a (row,
# column) screen position, not to the item that travels
# with a sort, so the table's OWN re-sort (every refresh()
# re-applies the active sort indicator, and a user click on
# any column header does too) left a stale pill floating
# over whatever row ended up at that position instead —
# the "wrong colour/text peeking out" glitch.
tint = getattr(pal, self._ACTION_TINTS.get(
ev.get("name", ""), "text_muted"), pal.text_muted)
item.setText(_action_label(str(text)))
colour = QColor(tint)
item.setForeground(QBrush(colour))
soft = QColor(colour)
@@ -159,6 +261,322 @@ class _EventTable(QTableWidget):
for col in range(self.columnCount()))
self.setRowHidden(row, not match)
def event_at_row(self, row: int) -> dict | None:
item = self.item(row, 0)
return item.data(Qt.UserRole) if item else None
def _fmt_event_time_full(ts: str) -> str:
""""dd/MM/yyyy · HH:mm:ss" — the detail panel's Thời gian field, per
``fmtFull()`` in ui-audit_v2.html (the table's own Time column uses the
shorter ``_fmt_event_time`` instead)."""
try:
dt = datetime.fromisoformat(ts)
except (TypeError, ValueError):
return ts
return dt.strftime("%d/%m/%Y · %H:%M:%S")
def _event_id(ts: str, row: int) -> str:
"""A display-only id in the ``evt_<timestamp digits>_<row>`` shape the
mockup uses — the real audit log has no native event id, so this is
derived from the timestamp and the row's position in the currently
displayed (sorted) table, not persisted anywhere."""
digits = "".join(ch for ch in ts if ch.isdigit())[:12]
return f"evt_{digits}_{row:03d}"
# Human label for the raw ``name`` an audit event is recorded under — the
# "Loại" field in the detail panel. Anything not in this map (custom tool
# names, etc.) just shows its raw name, same as the table's Hành động column.
_ACTION_LABEL_KEYS = {
"prompt": "monitoring.action_prompt",
"dangerous_command": "monitoring.action_dangerous_command",
"run_command": "monitoring.action_dangerous_command",
"install_package": "monitoring.action_install_package",
"path_outside_sandbox": "monitoring.action_path_outside_sandbox",
"network_blocked": "monitoring.action_network_blocked",
"secret_in_output": "monitoring.action_secret_in_output",
}
def _action_label(name: str) -> str:
key = _ACTION_LABEL_KEYS.get(name)
return tr(key) if key else name
# (badge QSS object name, i18n key) for the "Trạng thái" pill — statusInfo()
# in ui-audit_v2.html, mapped onto the app's existing badge* tones (theme.py)
# rather than adding the mockup's one-off teal/orange hues.
_STATUS_INFO = {
"path_outside_sandbox": ("badgeSuccess", "monitoring.status_path"),
"network_blocked": ("badge", "monitoring.status_network"),
"secret_in_output": ("badgeWarn", "monitoring.status_secret"),
}
_STATUS_DEFAULT = ("badgePurple", "monitoring.status_blocked")
def _status_info(name: str, kind: str = "security_block", ok: bool = False) -> tuple[str, str]:
if name in _STATUS_INFO:
return _STATUS_INFO[name]
if kind == "security_block":
# Security Events rows are always ok=False (see _EventTable's
# _ACTION_TINTS comment) — an unmapped name here still means "blocked
# by some rule", never a plain failure.
return _STATUS_DEFAULT
# MCP calls / generic Action Logs rows: no fixed enforcement-rule
# vocabulary applies, so fall back to the event's own ok/fail outcome.
return ("badgeSuccess", "monitoring.status_ok") if ok else ("badgeDanger", "monitoring.status_failed")
def _severity_info(name: str, kind: str = "security_block", ok: bool = False) -> tuple[str, str]:
# Same two-tier read as ui-audit_v2.html's mock: an unapproved shell
# command is the one CRITICAL case; everything else blocked is MEDIUM.
if name in ("dangerous_command", "run_command"):
return "badgeDanger", "monitoring.severity_critical"
if kind == "security_block":
return "badgeWarn", "monitoring.severity_medium"
# A successful MCP call / action is routine (INFO); a failed one still
# deserves the same MEDIUM tone Security Events uses for a blocked rule.
return ("badge", "monitoring.severity_info") if ok else ("badgeWarn", "monitoring.severity_medium")
def _agent_badge_name(name: str) -> str:
"""Badge tone for the Agent field's pill — the same identity-colour
mapping ``_agent_avatar_colour``/``ac()`` (ui-audit_v2.html) uses,
expressed as one of the shared badge* QSS classes (theme.py) instead of a
literal hex, since this pill lives on a themed label, not a custom swatch."""
if "Security" in name:
return "badgeDanger"
if "Cowork" in name:
return "badge"
if name == "schedule":
return "badgeWarn"
if name == "graphrag":
return "badgePurple"
if "Code" in name:
return "badgeSuccess"
return "badge"
_STATIC_POLICY_LABEL = "security_policy_v2" # cosmetic label only — no real policy-versioning system exists yet
class _EventDetailPanel(QWidget):
"""Right-hand "Chi tiết sự kiện" panel — the full record behind whichever
row is selected in an :class:`_EventTable`, laid out to match the
"Đề xuất" detail panel in ui-audit_v2.html (openDetail()): three labelled
sections, a terminal-style block quote for the detail text, and a
METADATA footer, closed by the header ✕, the footer button, Esc, or a
click outside the table/panel (see :class:`_ClickOutsideCloser`)."""
closed = Signal()
def __init__(self):
super().__init__()
self.setObjectName("monSection")
self._detail_text = ""
outer = QVBoxLayout(self)
hdr = QHBoxLayout()
self._title_lbl = QLabel()
self._title_lbl.setStyleSheet("font-weight:700;")
hdr.addWidget(self._title_lbl, 1)
self._close_btn = QPushButton()
self._close_btn.setIcon(icon("close"))
self._close_btn.setFlat(True)
self._close_btn.setFixedWidth(28)
self._close_btn.setCursor(Qt.PointingHandCursor)
self._close_btn.clicked.connect(self.closed.emit)
hdr.addWidget(self._close_btn)
outer.addLayout(hdr)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
body = QWidget()
self._body_lay = QVBoxLayout(body)
self._body_lay.setContentsMargins(0, 0, 4, 0)
scroll.setWidget(body)
outer.addWidget(scroll, 1)
self._section_hdrs: list[tuple[str, QLabel]] = []
self._rows: dict[str, tuple[QLabel, QLabel]] = {}
def _section(key: str) -> None:
lbl = QLabel()
lbl.setObjectName("detailSectionHdr")
self._body_lay.addWidget(lbl)
self._section_hdrs.append((key, lbl))
def _field(key: str) -> QLabel:
r = QHBoxLayout()
lbl = QLabel()
lbl.setObjectName("hint")
val = QLabel()
r.addWidget(lbl)
r.addStretch(1)
r.addWidget(val)
self._body_lay.addLayout(r)
self._rows[key] = (lbl, val)
return val
_section("general")
_field("time")
self._agent_val = _field("agent")
_field("account")
self._machine_val = _field("machine")
self._machine_val.setObjectName("monoChip")
_section("action")
self._type_val = _field("type")
self._type_val.setObjectName("neutralTag")
self._status_val = _field("status")
_section("block")
code_box = QWidget()
code_box.setObjectName("detailCodeBlock")
code_lay = QHBoxLayout(code_box)
code_lay.setContentsMargins(8, 6, 8, 6)
self._code_text = QLabel()
self._code_text.setObjectName("detailCodeText")
self._code_text.setWordWrap(True)
self._code_text.setTextInteractionFlags(Qt.TextSelectableByMouse)
code_lay.addWidget(self._code_text, 1)
self._copy_btn = QPushButton()
self._copy_btn.setObjectName("detailCopyBtn")
self._copy_btn.setCursor(Qt.PointingHandCursor)
self._copy_btn.clicked.connect(self._copy_detail)
code_lay.addWidget(self._copy_btn, 0, Qt.AlignVCenter)
self._body_lay.addWidget(code_box)
_section("metadata")
self._event_id_val = _field("event_id")
self._event_id_val.setObjectName("monoChip")
self._policy_val = _field("policy")
self._severity_val = _field("severity")
self._body_lay.addStretch(1)
footer = QHBoxLayout()
footer.setContentsMargins(0, 6, 0, 0)
self._footer_close_btn = QPushButton()
self._footer_close_btn.setObjectName("primary")
self._footer_close_btn.setCursor(Qt.PointingHandCursor)
self._footer_close_btn.clicked.connect(self.closed.emit)
footer.addWidget(self._footer_close_btn, 1)
outer.addLayout(footer)
@staticmethod
def _apply_badge(label: QLabel, object_name: str) -> None:
label.setObjectName(object_name)
label.style().unpolish(label)
label.style().polish(label)
def retranslate(self) -> None:
self._title_lbl.setText(tr("monitoring.security_detail_title"))
self._close_btn.setToolTip(tr("monitoring.security_detail_close"))
self._footer_close_btn.setText(tr("monitoring.security_detail_close"))
self._footer_close_btn.setIcon(icon("close"))
section_keys = {
"general": "monitoring.detail_section_general",
"action": "monitoring.detail_section_action",
"block": "monitoring.col_detail_block",
"metadata": "monitoring.detail_section_metadata",
}
for key, lbl in self._section_hdrs:
lbl.setText(tr(section_keys[key]).upper())
self._rows["time"][0].setText(tr("monitoring.col_time"))
self._rows["agent"][0].setText(tr("monitoring.col_agent"))
self._rows["account"][0].setText(tr("monitoring.col_account"))
self._rows["machine"][0].setText(tr("monitoring.col_machine"))
self._rows["type"][0].setText(tr("monitoring.detail_type"))
self._rows["status"][0].setText(tr("monitoring.detail_status"))
self._rows["event_id"][0].setText(tr("monitoring.detail_event_id"))
self._rows["policy"][0].setText(tr("monitoring.detail_policy"))
self._rows["severity"][0].setText(tr("monitoring.detail_severity"))
if not self._copy_btn.text() or self._copy_btn.text() != tr("monitoring.detail_copied"):
self._reset_copy_btn()
def show_event(self, ev: dict, row: int) -> None:
na = "—"
self._rows["time"][1].setText(_fmt_event_time_full(ev.get("ts", "")) or na)
agent_label = agent_roles.label_for(ev.get("agent_role", "")) or na
self._agent_val.setText(agent_label)
self._apply_badge(self._agent_val,
_agent_badge_name(agent_label) if agent_label != na else "badge")
self._rows["account"][1].setText(ev.get("account", "") or na)
self._machine_val.setText(ev.get("machine", "") or na)
name = ev.get("name", "")
kind = ev.get("kind", "security_block")
ok = ev.get("ok", False)
self._type_val.setText(_action_label(name) or na)
status_badge, status_key = _status_info(name, kind, ok)
self._status_val.setText(tr(status_key))
self._apply_badge(self._status_val, status_badge)
self._detail_text = ev.get("detail", "") or na
self._code_text.setText(self._detail_text)
self._reset_copy_btn()
self._event_id_val.setText(_event_id(ev.get("ts", ""), row))
# The static policy label names a real enforcement ruleset — only
# meaningful for a Security Events row; MCP calls/generic actions
# were never evaluated against it.
self._policy_val.setText(_STATIC_POLICY_LABEL if kind == "security_block" else na)
severity_badge, severity_key = _severity_info(name, kind, ok)
self._severity_val.setText(tr(severity_key))
self._apply_badge(self._severity_val, severity_badge)
def _copy_detail(self) -> None:
QGuiApplication.clipboard().setText(self._detail_text)
self._copy_btn.setText(tr("monitoring.detail_copied"))
self._copy_btn.setIcon(icon("check"))
QTimer.singleShot(1500, self._reset_copy_btn)
def _reset_copy_btn(self) -> None:
self._copy_btn.setText(tr("monitoring.detail_copy"))
self._copy_btn.setIcon(icon("document"))
class _ClickOutsideCloser(QObject):
"""Closes the event-detail panel on a click anywhere outside the
table/panel splitter — a row click changes the selection instead (its
own handler), so this only needs to catch everything else: the search
box, another tab, the nav rail… mirrors the document-level "click
outside the panel" listener in ui-audit_v2.html.
Judged by screen-space GEOMETRY (is the click's global position inside
the splitter's on-screen rectangle), not by which exact widget object
received the event. Two earlier attempts both broke on the splitter's
drag handle: ``QApplication.widgetAt(globalPos)`` re-hit-tests through
the window server rather than using what Qt actually delivered the event
to, and even the delivered ``obj`` isn't reliable mid-drag — the handle
grabs the mouse and Qt's internal drag bookkeeping doesn't always hand
back the same widget identity a plain ``isAncestorOf`` check expects.
A geometric rect containment check has neither problem: it doesn't care
which sub-widget (viewport, cell widget, scrollbar, handle) the event
was actually delivered to, only whether the click landed on-screen
within the container's bounds."""
def __init__(self, table: "_EventTable", panel: "_EventDetailPanel", container: QWidget):
super().__init__(container)
self._table = table
self._panel = panel
self._container = container
def eventFilter(self, obj, event) -> bool:
if event.type() == QEvent.MouseButtonPress and self._panel.isVisible():
global_pos = event.globalPosition().toPoint()
top_left = self._container.mapToGlobal(self._container.rect().topLeft())
rect = QRect(top_left, self._container.size())
if not rect.contains(global_pos):
self._table.clearSelection()
return False
class MonitoringTab(QWidget):
status_message = Signal(str)
@@ -177,10 +595,6 @@ class MonitoringTab(QWidget):
self._title.setStyleSheet("font-weight:700; font-size:15px;")
head.addWidget(self._title)
head.addStretch(1)
self.refresh_btn = QPushButton()
self.refresh_btn.setIcon(icon("refresh"))
self.refresh_btn.clicked.connect(self.refresh)
head.addWidget(self.refresh_btn)
root.addLayout(head)
self.tabs = QTabWidget()
@@ -193,24 +607,38 @@ class MonitoringTab(QWidget):
# Security events are always ok=False, so this table trades the
# tick/cross column for a tinted Action column (see _EventTable).
self.security_table = _EventTable(show_result=False)
self.security_page = self._wrap_with_filter(self.security_table)
self.security_page = self._wrap_with_filter(
self.security_table, with_detail=True, title_key="monitoring.security_events_title")
if visible("security_events"):
self.tabs.addTab(self.security_page, "")
self.mcp_table = _EventTable()
self.mcp_page = self._wrap_with_filter(
self.mcp_table, with_detail=True, title_key="monitoring.mcp_history_title")
if visible("mcp_history"):
self.tabs.addTab(self.mcp_table, "")
self.tabs.addTab(self.mcp_page, "")
self.action_table = _EventTable()
self.action_page = self._wrap_with_filter(self.action_table)
self.action_page = self._wrap_with_filter(
self.action_table, with_detail=True, title_key="monitoring.action_logs_title")
if visible("action_logs"):
self.tabs.addTab(self.action_page, "")
# ---- Agent Status -------------------------------------------------
self.status_table = QTableWidget(0, 3)
self.status_table.setEditTriggers(QTableWidget.NoEditTriggers)
# No detail to open on click, no filtering — a plain read-only table,
# so selection is off rather than left dangling with no effect (and it
# sidesteps the Trạng thái badge's dark-theme rgba() background ever
# compositing differently selected vs not — see badge_pill_widget).
self.status_table.setSelectionMode(QTableWidget.NoSelection)
self.status_table.verticalHeader().setVisible(False)
self.status_table.horizontalHeader().setStretchLastSection(True)
self.status_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.status_table.setColumnWidth(1, 130) # Trạng thái is a cell widget — size it explicitly
self.status_table.setIconSize(QSize(20, 20))
self.status_page = self._wrap_with_filter(
self.status_table, title_key="monitoring.agent_status_title", with_search=False)
if visible("agent_status"):
self.tabs.addTab(self.status_table, "")
self.tabs.addTab(self.status_page, "")
# ---- Agents Admin (catalog: assign a role + pinned model per agent) --
# The system-management agents (Security, GraphRAG/Knowledge, Monitor…)
@@ -237,8 +665,14 @@ class MonitoringTab(QWidget):
self._timer = QTimer(self)
# (nav integration methods defined below)
# Only the Overview cards auto-refresh on this tick — Security, MCP,
# Action Logs, Agent Status (and the Agents Admin/Tools/Icons tabs,
# which were never wired to this timer) are read-only tables that a
# background re-sort would otherwise disturb mid-interaction (e.g.
# while a row is selected or the detail-panel splitter is being
# dragged); the user refreshes them explicitly via a "Làm mới" button.
self._timer.setInterval(_REFRESH_MS)
self._timer.timeout.connect(self.refresh)
self._timer.timeout.connect(self._auto_refresh)
self._timer.start()
on_language_changed(self._retranslate)
@@ -251,8 +685,8 @@ class MonitoringTab(QWidget):
they're correct in every language."""
by_widget = {self.agents_admin_tab: "robot", self.tools_admin_tab: "wrench",
self.icons_admin_tab: "star"}
for attr, name in (("security_page", "shield"), ("mcp_table", "plug"),
("action_page", "bolt"), ("status_table", "monitor")):
for attr, name in (("security_page", "shield"), ("mcp_page", "plug"),
("action_page", "bolt"), ("status_page", "monitor")):
w = getattr(self, attr, None)
if w is not None:
by_widget[w] = name
@@ -372,26 +806,94 @@ class MonitoringTab(QWidget):
self.ctx.save()
self._reload_pricing_table()
def _wrap_with_filter(self, table: "_EventTable") -> QWidget:
def _wrap_with_filter(self, table: QTableWidget, with_detail: bool = False,
title_key: str | None = None, with_search: bool = True) -> QWidget:
page = QWidget()
lay = QVBoxLayout(page)
lay.setContentsMargins(0, 0, 0, 0)
row = QHBoxLayout()
search = QLineEdit()
search.setPlaceholderText(tr("monitoring.filter_placeholder"))
search.textChanged.connect(table.apply_filter)
ai_btn = QPushButton(tr("monitoring.ai_filter_btn"))
ai_btn.setIcon(icon("sparkle"))
ai_btn.setToolTip(tr("monitoring.ai_filter_tooltip"))
ai_btn.clicked.connect(lambda: self._ai_filter(search, ai_btn))
row.addWidget(search, 1)
row.addWidget(ai_btn)
lay.addLayout(row)
lay.addWidget(table, 1)
page.filter_edit = search
page.ai_filter_btn = ai_btn
if title_key:
# The wireframe titles this tab's content on its own row — "Sự kiện
# bảo mật" + a primary Refresh button — separately from the section
# tab strip above (which just says "Bảo mật").
hdr = QHBoxLayout()
title_lbl = QLabel(tr(title_key))
title_lbl.setStyleSheet("font-weight:700; font-size:14px;")
hdr.addWidget(title_lbl)
hdr.addStretch(1)
refresh_btn = QPushButton(tr("monitoring.refresh"))
refresh_btn.setIcon(icon("refresh"))
refresh_btn.setObjectName("primary")
refresh_btn.setCursor(Qt.PointingHandCursor)
refresh_btn.clicked.connect(self.refresh)
hdr.addWidget(refresh_btn)
lay.addLayout(hdr)
page.title_lbl = title_lbl
page.title_key = title_key
page.title_refresh_btn = refresh_btn
if with_search:
row = QHBoxLayout()
search = QLineEdit()
search.setPlaceholderText(tr("monitoring.filter_placeholder"))
search.textChanged.connect(table.apply_filter)
ai_btn = QPushButton(tr("monitoring.ai_filter_btn"))
ai_btn.setIcon(icon("sparkle"))
ai_btn.setToolTip(tr("monitoring.ai_filter_tooltip"))
ai_btn.setCursor(Qt.PointingHandCursor)
ai_btn.clicked.connect(lambda: self._ai_filter(search, ai_btn))
row.addWidget(search, 1)
row.addWidget(ai_btn)
lay.addLayout(row)
page.filter_edit = search
page.ai_filter_btn = ai_btn
if with_detail:
# Click a row → its full record opens in a "Chi tiết sự kiện" panel
# on the right (the table itself clips the detail text to 300 chars).
# A pointing-hand cursor over the rows signals that they're clickable.
table.setCursor(Qt.PointingHandCursor)
detail = _EventDetailPanel()
detail.setVisible(False)
detail.closed.connect(table.clearSelection)
table.itemSelectionChanged.connect(lambda: self._sync_event_detail(table, detail))
split = QSplitter(Qt.Horizontal)
split.addWidget(table)
split.addWidget(detail)
split.setStretchFactor(0, 1)
split.setStretchFactor(1, 0)
split.setChildrenCollapsible(False)
split.setSizes([700, 320])
lay.addWidget(split, 1)
page.detail_panel = detail
# Esc, anywhere focus is inside this page, closes the panel the
# same way the close button does.
esc = QShortcut(QKeySequence(Qt.Key_Escape), page)
esc.setContext(Qt.WidgetWithChildrenShortcut)
esc.activated.connect(table.clearSelection)
page.detail_esc_shortcut = esc
# A click outside both the table and the panel also closes it —
# mirrors ui-audit_v2.html's document-level click-outside listener.
click_filter = _ClickOutsideCloser(table, detail, split)
QApplication.instance().installEventFilter(click_filter)
page.detail_click_filter = click_filter
else:
lay.addWidget(table, 1)
return page
def _sync_event_detail(self, table: "_EventTable", panel: "_EventDetailPanel") -> None:
# currentRow() alone is not enough: clearSelection() (used by the
# panel's close button) drops the selection but leaves the current
# cell in place, so a stale currentRow() would keep the panel open.
row = table.currentRow()
has_selection = bool(table.selectedItems())
ev = table.event_at_row(row) if (has_selection and row >= 0) else None
if ev:
panel.show_event(ev, row)
panel.setVisible(bool(ev))
def _ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
query = search.text().strip()
if not query or getattr(self, "_ai_filter_worker", None) is not None:
@@ -706,13 +1208,12 @@ class MonitoringTab(QWidget):
# ---- i18n ------------------------------------------------------------
def _retranslate(self) -> None:
self._title.setText(tr("monitoring.title"))
self.refresh_btn.setText(tr("monitoring.refresh"))
if self.tabs.count():
self.tabs.setTabText(0, tr("monitoring.tab_overview"))
self._set_tab_text_if_present(self.security_page, tr("monitoring.tab_security"))
self._set_tab_text_if_present(self.mcp_table, tr("monitoring.tab_mcp"))
self._set_tab_text_if_present(self.mcp_page, tr("monitoring.tab_mcp"))
self._set_tab_text_if_present(self.action_page, tr("monitoring.tab_actions"))
self._set_tab_text_if_present(self.status_table, tr("monitoring.tab_agents"))
self._set_tab_text_if_present(self.status_page, 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"))
@@ -720,7 +1221,7 @@ class MonitoringTab(QWidget):
self.mcp_table.retranslate()
self.action_table.retranslate()
self.status_table.setHorizontalHeaderLabels([
tr("monitoring.col_agent"), tr("monitoring.col_active"), tr("monitoring.col_source"),
tr("monitoring.col_agent"), tr("monitoring.detail_status"), tr("monitoring.col_source"),
])
# A QGroupBox title treats "&" as a mnemonic marker, so "token & Chi
@@ -732,8 +1233,15 @@ class MonitoringTab(QWidget):
self.ov_activity_group.setTitle(tr("monitoring.overview_activity_title").upper())
self.ov_resource_group.setTitle(tr("monitoring.overview_resource_title").upper())
self.security_page.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
self.action_page.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
for page in (self.security_page, self.mcp_page, self.action_page):
page.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
page.detail_panel.retranslate()
page.title_lbl.setText(tr(page.title_key))
page.title_refresh_btn.setText(tr("monitoring.refresh"))
# status_page has no search box and no detail panel (with_search=False,
# with_detail=False) — just the title + Làm mới header.
self.status_page.title_lbl.setText(tr(self.status_page.title_key))
self.status_page.title_refresh_btn.setText(tr("monitoring.refresh"))
self.ov_cpu_lbl.setText(tr("monitoring.overview_res_cpu"))
self.ov_mem_lbl.setText(tr("monitoring.overview_res_mem"))
self.ov_diskfree_lbl.setText(tr("monitoring.overview_disk_label"))
@@ -779,6 +1287,10 @@ class MonitoringTab(QWidget):
# ---- refresh -----------------------------------------------------------
def refresh(self) -> None:
"""Full refresh — Overview cards plus every table. Wired to the
top-of-page and per-section "Làm mới" buttons, called once at
startup/language-change, but NOT to the auto-refresh timer (see
``_auto_refresh``)."""
self._refresh_resource_usage()
events = self._load_events()
self.security_table.set_events([e for e in events if e.get("kind") == "security_block"])
@@ -787,6 +1299,11 @@ class MonitoringTab(QWidget):
self._refresh_agent_status()
self._refresh_overview(events)
def _auto_refresh(self) -> None:
"""3-second timer tick — Overview cards only (see ``refresh``)."""
self._refresh_resource_usage()
self._refresh_overview(self._load_events())
def _load_events(self) -> List[dict]:
shared_dir = self.ctx.config.shared_dir
if shared_dir:
@@ -888,12 +1405,22 @@ class MonitoringTab(QWidget):
]
self.status_table.setRowCount(len(rows))
for row, (role_key, count, source) in enumerate(rows):
self.status_table.setItem(row, 0, QTableWidgetItem(agent_roles.label_for(role_key)))
label = agent_roles.label_for(role_key)
name_item = QTableWidgetItem(label)
name_item.setIcon(_agent_avatar_icon(label))
self.status_table.setItem(row, 0, name_item)
if role_key == agent_roles.SECURITY:
active_text = tr("monitoring.on") if sec_on else tr("monitoring.off")
running = sec_on
status_text = tr("monitoring.on") if sec_on else tr("monitoring.off")
elif count is not None:
running = count > 0
status_text = tr("monitoring.active_n", n=count) if running else tr("monitoring.idle")
else:
active_text = tr("monitoring.active_n", n=count) if count is not None else "—"
self.status_table.setItem(row, 1, QTableWidgetItem(active_text))
running = False
status_text = "—"
badge_tone = "badgeSuccess" if running else "badgeNeutral"
self.status_table.setCellWidget(row, 1, badge_pill_widget(status_text, badge_tone))
self.status_table.setItem(row, 2, QTableWidgetItem(source))
def _activity_line(self, event: dict) -> str: