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
+62 -4
View File
@@ -2411,9 +2411,11 @@ STRINGS: Dict[str, Dict[str, str]] = {
"monitoring.col_account": {"en": "Account", "ja": "アカウント", "vi": "Tài khoản"},
"monitoring.col_machine": {"en": "Machine", "ja": "マシン", "vi": "Máy"},
"monitoring.col_agent": {"en": "Agent", "ja": "エージェント", "vi": "Agent"},
"monitoring.col_active": {"en": "Active", "ja": "稼働中", "vi": "Đang chạy"},
"monitoring.col_source": {"en": "Source", "ja": "ソース", "vi": "Nguồn"},
"monitoring.active_n": {"en": "{n} running", "ja": "{n} 件実行中", "vi": "{n} đang chạy"},
"monitoring.idle": {"en": "Idle", "ja": "アイドル", "vi": "Rảnh"},
"monitoring.agent_status_title": {
"en": "Agent Status", "ja": "エージェント状態", "vi": "Trạng thái Agent"},
"monitoring.source_cowork": {
"en": "Cowork tab's active turns", "ja": "Cowork タブの実行中ターン",
"vi": "Lượt đang chạy của tab Cowork"},
@@ -2816,6 +2818,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Không tìm thấy agent '{name}'."},
# ---- agents_admin_tab.py — Admin-only agent catalog -------------------
"agents_admin.page_title": {"en": "Agents Admin", "ja": "Agents Admin", "vi": "Agents Admin"},
"agents_admin.edit_row_tooltip": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
"agents_admin.delete_row_tooltip": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"agents_admin.hint": {
"en": "System-management agents shared across every machine (stored in the shared accounts folder): the help agent and Schedule Task executors. These are NOT the agents you pick in Cowork or Co4E.",
"ja": "全マシンで共有されるシステム管理用エージェント(共有フォルダーに保存):ヘルプエージェントやスケジュールタスクの実行エージェントなど。CoworkやCo4Eで選択するエージェントではありません。",
@@ -2827,8 +2832,6 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "Delete agent \"{name}\"?", "ja": "エージェント「{name}」を削除しますか?",
"vi": "Xóa agent \"{name}\"?"},
"agents_admin.add_btn": {"en": "Add", "ja": "追加", "vi": "Thêm"},
"agents_admin.edit_btn": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
"agents_admin.delete_btn": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"agents_admin.f_name": {"en": "Name", "ja": "名前", "vi": "Tên"},
"agents_admin.f_kind": {"en": "App function", "ja": "アプリ機能", "vi": "Chức năng App"},
"agents_admin.f_prompt": {"en": "Instructions", "ja": "指示", "vi": "Chỉ dẫn"},
@@ -2860,7 +2863,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"agents_admin.col_enabled": {"en": "Enabled", "ja": "有効", "vi": "Kích hoạt"},
"agents_admin.col_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"},
"agents_admin.col_updated": {"en": "Updated", "ja": "更新", "vi": "Cập nhật"},
"agents_admin.check_btn": {"en": "Check", "ja": "チェック", "vi": "Kiểm tra"},
"agents_admin.check_btn": {"en": "Check all", "ja": "すべてチェック", "vi": "Kiểm tra tất cả"},
"agents_admin.check_tooltip": {
"en": "Check each agent's effective provider/model connectivity",
"ja": "各エージェントの実効プロバイダ/モデルの接続性を確認",
@@ -2908,6 +2911,61 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "AI turns your question into a filter keyword (e.g. \"which commands failed today?\").",
"ja": "質問をAIがフィルターキーワードに変換します。",
"vi": "AI chuyển câu hỏi thành từ khóa lọc (vd: \"hôm nay lệnh nào bị lỗi?\")."},
"monitoring.security_detail_title": {
"en": "Event details", "ja": "イベント詳細", "vi": "Chi tiết sự kiện"},
"monitoring.security_detail_close": {
"en": "Close", "ja": "閉じる", "vi": "Đóng"},
"monitoring.security_events_title": {
"en": "Security Events", "ja": "セキュリティイベント", "vi": "Sự kiện bảo mật"},
"monitoring.mcp_history_title": {
"en": "MCP Call History", "ja": "MCP 呼び出し履歴", "vi": "Lịch sử gọi MCP"},
"monitoring.action_logs_title": {
"en": "Action Logs", "ja": "アクションログ", "vi": "Nhật ký hành động"},
"monitoring.col_detail_block": {
"en": "Block detail", "ja": "ブロック詳細", "vi": "Chi tiết chặn"},
# ---- event-detail panel (ui-audit_v2.html openDetail()) --------------
"monitoring.detail_section_general": {
"en": "General info", "ja": "基本情報", "vi": "Thông tin chung"},
"monitoring.detail_section_action": {
"en": "Action", "ja": "アクション", "vi": "Hành động"},
"monitoring.detail_section_metadata": {
"en": "Metadata", "ja": "メタデータ", "vi": "Metadata"},
"monitoring.detail_type": {"en": "Type", "ja": "種類", "vi": "Loại"},
"monitoring.detail_status": {"en": "Status", "ja": "ステータス", "vi": "Trạng thái"},
"monitoring.detail_event_id": {"en": "Event ID", "ja": "イベントID", "vi": "Event ID"},
"monitoring.detail_policy": {"en": "Policy", "ja": "ポリシー", "vi": "Policy"},
"monitoring.detail_severity": {"en": "Severity", "ja": "重大度", "vi": "Severity"},
"monitoring.detail_copy": {"en": "Copy", "ja": "コピー", "vi": "Copy"},
"monitoring.detail_copied": {"en": "Copied", "ja": "コピー済み", "vi": "Đã copy"},
# Trạng thái pill — which rule fired, phrased as the enforcement outcome
# (distinct wording from the Loại/action_* labels below, matching
# ui-audit_v2.html's statusInfo() vs actionLabel).
"monitoring.status_blocked": {"en": "Blocked", "ja": "ブロック済み", "vi": "Đã chặn"},
"monitoring.status_path": {"en": "Path blocked", "ja": "パスをブロック", "vi": "Path chặn"},
"monitoring.status_network": {"en": "Network blocked", "ja": "ネットワークをブロック", "vi": "Mạng chặn"},
"monitoring.status_secret": {"en": "Secret leaked", "ja": "シークレット漏洩", "vi": "Bí mật lộ"},
"monitoring.status_ok": {"en": "Succeeded", "ja": "成功", "vi": "Thành công"},
"monitoring.status_failed": {"en": "Failed", "ja": "失敗", "vi": "Thất bại"},
"monitoring.severity_critical": {"en": "CRITICAL", "ja": "CRITICAL", "vi": "CRITICAL"},
"monitoring.severity_medium": {"en": "MEDIUM", "ja": "MEDIUM", "vi": "MEDIUM"},
"monitoring.severity_info": {"en": "INFO", "ja": "INFO", "vi": "INFO"},
# Loại field — a human label for the raw event name (audit_log ``name``).
"monitoring.action_prompt": {"en": "Risky prompt", "ja": "危険なプロンプト", "vi": "Prompt rủi ro"},
"monitoring.action_dangerous_command": {
"en": "Dangerous command", "ja": "危険なコマンド", "vi": "Lệnh nguy hiểm"},
"monitoring.action_install_package": {
"en": "Package install", "ja": "パッケージインストール", "vi": "Cài đặt gói"},
"monitoring.action_path_outside_sandbox": {
"en": "Path outside sandbox", "ja": "サンドボックス外のパス", "vi": "Path ngoài sandbox"},
"monitoring.action_network_blocked": {
"en": "Network blocked", "ja": "ネットワークブロック", "vi": "Mạng bị chặn"},
"monitoring.action_secret_in_output": {
"en": "Secret disclosed", "ja": "シークレット漏洩", "vi": "Tiết lộ bí mật"},
"monitoring.overview_activity_title": {
"en": "Recent log", "ja": "最近のログ", "vi": "Nhật ký gần đây"},
"monitoring.overview_no_activity": {
+48 -4
View File
@@ -565,7 +565,7 @@ QPlainTextEdit, QTextEdit, QTextBrowser, QLineEdit, QSpinBox, QDoubleSpinBox,
QTreeView, QListView, QTableView, QTreeWidget, QListWidget, QTableWidget {
background: $surface_raised; color: $text; border: 1px solid $border;
border-radius: ${radius}px; selection-background-color: $selection_bg;
selection-color: $selection_fg;
selection-color: $selection_fg; outline: 0;
}
QPlainTextEdit, QTextEdit, QLineEdit, QSpinBox, QDoubleSpinBox { padding: 6px 8px; }
QPlainTextEdit:hover, QTextEdit:hover, QLineEdit:hover { border-color: $border_strong; }
@@ -580,6 +580,10 @@ QTreeView::item:hover, QListView::item:hover { background: $hover; }
QTreeView::item:selected, QListView::item:selected, QTableView::item:selected {
background: $accent_soft; color: $text;
}
/* The platform style draws its own dotted/solid focus rect on the current
cell on top of the selection tint above — visible as a stray light border
on a click. The selection tint already marks "current row"; drop the rect. */
QTreeView::item:focus, QListView::item:focus, QTableView::item:focus { outline: none; border: none; }
QHeaderView::section {
background: $bg; color: $text_muted; border: none;
border-bottom: 1px solid $border; padding: 7px 6px; font-weight: 600;
@@ -695,10 +699,15 @@ QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; border: none;
QScrollBar::add-page, QScrollBar::sub-page { background: none; }
/* ---- badges & inline text tones ---------------------------------------
One shape, six tones. Pick by meaning: badgeSuccess for a finished run,
badgeDanger for a failed one — not by which colour looks nice. */
One shape, seven tones. Pick by meaning: badgeSuccess for a finished run,
badgeDanger for a failed one — not by which colour looks nice. badgeNeutral
is the odd one out: it deliberately uses OPAQUE tokens ($active/$text_muted,
not an rgba() *_soft one) since it renders inside table cells that can sit
over a selection tint — an rgba() background there would composite
differently selected vs not (see Monitoring ▸ Sự kiện bảo mật's Hành động
pill, which hit exactly this). */
QLabel#badge, QLabel#badgeSuccess, QLabel#badgeWarn, QLabel#badgeDanger,
QLabel#badgePurple, QLabel#badgePink {
QLabel#badgePurple, QLabel#badgePink, QLabel#badgeNeutral {
border-radius: ${radius_sm}px; padding: 2px 8px; font-size: 12px; font-weight: 600;
}
QLabel#badge { background: $info_soft; color: $info; }
@@ -707,6 +716,41 @@ QLabel#badgeWarn { background: $warning_soft; color: $warning; }
QLabel#badgeDanger { background: $danger_soft; color: $danger; }
QLabel#badgePurple { background: $purple_soft; color: $purple; }
QLabel#badgePink { background: $pink_soft; color: $pink; }
QLabel#badgeNeutral { background: $active; color: $text_muted; }
/* ---- event-detail panel (Monitoring ▸ Sự kiện bảo mật) -----------------
A neutral, low-emphasis tag — the "Loại" chip: a category label with no
colour coding of its own (colour is reserved for the Trạng thái badge
beside it). */
QLabel#neutralTag {
background: $surface_raised; border: 1px solid $border; border-radius: ${radius_sm}px;
padding: 2px 8px; font-size: 12px;
}
/* A short identifier shown as a bordered monospace chip (machine name,
event id). */
QLabel#monoChip {
font-family: "Cascadia Code", Consolas, monospace; background: $surface_raised;
border: 1px solid $border; border-radius: ${radius_sm}px; padding: 1px 6px; font-size: 12px;
}
/* Section caption inside the panel — the same quiet caps heading as
Monitoring ▸ Overview's group titles (monSection::title above), with a
hairline under it since the panel has no group-box border of its own. */
QLabel#detailSectionHdr {
color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
padding-bottom: 4px; margin-top: 6px; border-bottom: 1px solid $border;
}
/* The blocked-detail text renders as a fixed dark "terminal" block — the
same look in both themes, like a code snippet, so it reads consistently
against whichever tint the row around it happens to carry. */
QWidget#detailCodeBlock { background: #1B1A19; border-radius: ${radius}px; }
QLabel#detailCodeText {
color: #CCFF00; font-family: "Cascadia Code", Consolas, monospace; font-size: 12px;
}
QPushButton#detailCopyBtn {
background: rgba(255,255,255,0.15); color: #FFFFFF; border: none;
border-radius: ${radius_sm}px; padding: 3px 10px; font-size: 11px;
}
QPushButton#detailCopyBtn:hover { background: rgba(255,255,255,0.25); }
QLabel { background: transparent; }
QLabel#hint { color: $text_muted; }
+212 -75
View File
@@ -8,18 +8,22 @@ accounts folder, so every machine pointed at the same share picks changes up
automatically (OneDrive/network sync) — non-admin machines only ever READ the
catalog (their pickers in Cowork / Schedule Task list the enabled agents).
The "Check" button probes each agent's effective provider (``check_agent``)
and shows an operational-status column (🟢 reachable / 🔴 error) separate from
the Enabled config flag.
The header's "Kiểm tra tất cả" icon probes each agent's effective provider
(``check_agent``) and shows the result as the Trạng thái pill (OK / error /
checking…) — separate from the per-row Kích hoạt switch, which only toggles
the config flag.
"""
from __future__ import annotations
from datetime import datetime
from typing import Dict, List, Optional
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap
from PySide6.QtWidgets import (
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, QTableWidget,
QTableWidgetItem, QVBoxLayout, QWidget,
QHeaderView, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton,
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
)
from ..config import PROVIDER_LABELS
@@ -27,10 +31,62 @@ from ..core import admin_agents, preview_ai
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
from .icons import icon, dot_icon, DOT_GREEN, DOT_RED, DOT_AMBER, DOT_GREY
from .icons import icon
from .widgets import ToggleSwitch, badge_pill_widget
_PROVIDER_DEFAULT = "" # "" = each machine's own active provider (unchanged default)
# Identity colour (avatar circle) + badge tone per task_kind — same "fixed
# colour regardless of theme" convention as monitoring_tab.py's per-agent
# avatars, plus a badge object name (theme.py) for the Vai trò pill. Seven
# kinds, seven distinct tones — no two kinds share a badge colour.
_KIND_COLOUR = {
"search": "#8A8886", "monitor": "#FFB900", "cowork": "#0078D4",
"graphrag": "#8764B8", "schedule": "#107C10", "security": "#D13438",
"help": "#E3008C",
}
_KIND_BADGE = {
"search": "badgeNeutral", "monitor": "badgeWarn", "cowork": "badge",
"graphrag": "badgePurple", "schedule": "badgeSuccess", "security": "badgeDanger",
"help": "badgePink",
}
_STATUS_BADGE = {
"unchecked": "badgeNeutral", "checking": "badgeWarn",
"ok": "badgeSuccess", "bad": "badgeDanger",
}
def _initials(name: str) -> str:
return "".join(w[0] for w in name.split() if w)[:2].upper()
def _fmt_updated(ts: str) -> str:
""""dd/MM hh:mm" — same Cập nhật/Thời gian format as Monitoring's
Bảo mật/MCP/Hành động tables (``_fmt_event_time`` in monitoring_tab.py)."""
try:
dt = datetime.fromisoformat(ts)
except (TypeError, ValueError):
return ts
return dt.strftime("%d/%m %H:%M")
def _kind_avatar_icon(kind: str, name: str, size: int = 20) -> QIcon:
pm = QPixmap(size, size)
pm.fill(Qt.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
p.setBrush(QColor(_KIND_COLOUR.get(kind, "#0078D4")))
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, _initials(name))
p.end()
return QIcon(pm)
class AgentEditDialog(QDialog):
"""Add/Edit one admin agent. The provider/model pickers are drop-lists,
@@ -161,41 +217,79 @@ class AgentsAdminTab(QWidget):
self._check_workers: List[AgentWorker] = []
root = QVBoxLayout(self)
hdr = QHBoxLayout()
self._title_lbl = QLabel()
self._title_lbl.setStyleSheet("font-weight:700; font-size:14px;")
hdr.addWidget(self._title_lbl)
hdr.addStretch(1)
# "Kiểm tra tất cả" keeps the real _check_all action reachable without
# competing with the 2 primary header buttons (Làm mới / + Thêm) — a
# flat, secondary-styled button rather than a 3rd primary one, but
# still labelled: an icon-only button here was a mystery button.
self.check_btn = QPushButton()
self.check_btn.setIcon(icon("check"))
self.check_btn.setFlat(True)
self.check_btn.setCursor(Qt.PointingHandCursor)
self.check_btn.clicked.connect(self._check_all)
hdr.addWidget(self.check_btn)
self.refresh_btn = QPushButton()
self.refresh_btn.setIcon(icon("refresh"))
self.refresh_btn.setCursor(Qt.PointingHandCursor)
self.refresh_btn.clicked.connect(self.refresh)
hdr.addWidget(self.refresh_btn)
self.add_btn = QPushButton()
self.add_btn.setIcon(icon("plus"))
self.add_btn.setObjectName("primary")
self.add_btn.setCursor(Qt.PointingHandCursor)
self.add_btn.clicked.connect(self._add)
hdr.addWidget(self.add_btn)
root.addLayout(hdr)
self._hint = QLabel("")
self._hint.setObjectName("hint")
self._hint.setWordWrap(True)
root.addWidget(self._hint)
self.table = QTableWidget(0, 6)
self.table = QTableWidget(0, 7)
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
# Sửa/Xoá/Kích hoạt are now per-row widgets (button/switch), and Vai
# trò/Trạng thái are pill cell widgets — none of those track a row
# across a re-sort (a cell widget stays pinned to its screen position,
# not to the item that moves — see monitoring_tab.py's _EventTable for
# the same lesson learned the hard way), so this table doesn't sort.
self.table.setSelectionMode(QTableWidget.NoSelection)
self.table.verticalHeader().setVisible(False)
self.table.horizontalHeader().setStretchLastSection(True)
self.table.setSortingEnabled(True)
# Fixed row height — letting Qt auto-size rows from content fights
# with the toggle switch / badge cell widgets: their layout settles on
# a stale, oversized geometry from an intermediate sizing pass, which
# then overlaps neighbouring rows (same bug _EventTable hit for its
# Hành động pill, fixed there the same way).
self.table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
self.table.verticalHeader().setDefaultSectionSize(32)
self.table.setIconSize(QSize(20, 20))
header = self.table.horizontalHeader()
header.setStretchLastSection(False)
for col in (0, 6):
header.setSectionResizeMode(col, QHeaderView.ResizeToContents)
# Vai trò/Trạng thái (1, 4) are pill cell widgets — ResizeToContents
# only measures QTableWidgetItem content, so it kept fighting refresh()'s
# manual sizeHint()-based setColumnWidth() and clipping the pill text.
# Interactive leaves whatever width refresh() sets alone.
for col in (1, 4):
header.setSectionResizeMode(col, QHeaderView.Interactive)
header.setSectionResizeMode(2, QHeaderView.Stretch) # Model
header.setSectionResizeMode(5, QHeaderView.ResizeToContents)
root.addWidget(self.table, 1)
btns = QHBoxLayout()
self.add_btn = QPushButton()
self.add_btn.setIcon(icon("plus"))
self.add_btn.setObjectName("primary")
self.add_btn.clicked.connect(self._add)
self.edit_btn = QPushButton()
self.edit_btn.setIcon(icon("edit"))
self.edit_btn.clicked.connect(self._edit)
self.del_btn = QPushButton()
self.del_btn.setIcon(icon("trash"))
self.del_btn.clicked.connect(self._delete)
self.check_btn = QPushButton()
self.check_btn.setIcon(icon("check"))
self.check_btn.clicked.connect(self._check_all)
for b in (self.add_btn, self.edit_btn, self.del_btn):
btns.addWidget(b)
btns.addStretch(1)
btns.addWidget(self.check_btn)
root.addLayout(btns)
# on_language_changed() already invokes _retranslate() once immediately
# (see i18n.py) — calling it again here was a harmless no-op back when
# every column was a plain QTableWidgetItem, but now refresh() also
# populates cell WIDGETS (toggle switch, pills, row actions): running
# it twice back-to-back with no event-loop turn in between left the
# first pass's widgets replaced but not yet deleted, so they briefly
# painted overlapping the second pass's row 0.
on_language_changed(self._retranslate)
self._retranslate()
# ---- storage ---------------------------------------------------------
def _dir(self):
@@ -205,17 +299,6 @@ class AgentsAdminTab(QWidget):
conf = self.ctx.config.provider_conf(self.ctx.config.active_provider)
return conf.get("model", "")
def _selected_agent(self) -> Optional[admin_agents.AdminAgent]:
row = self.table.currentRow()
if row < 0:
return None
item = self.table.item(row, 0)
if item is None:
return None
from PySide6.QtCore import Qt
return admin_agents.load_agent(item.data(Qt.UserRole), self._dir())
# ---- CRUD -------------------------------------------------------------
def _add(self) -> None:
dlg = AgentEditDialog(self, ctx=self.ctx, default_model_hint=self._default_model_hint())
@@ -232,8 +315,8 @@ class AgentsAdminTab(QWidget):
admin_agents.save_agent(agent, self._dir())
self.refresh()
def _edit(self) -> None:
agent = self._selected_agent()
def _edit_agent(self, agent_id: str) -> None:
agent = admin_agents.load_agent(agent_id, self._dir())
if agent is None:
return
dlg = AgentEditDialog(self, ctx=self.ctx, agent=agent,
@@ -243,7 +326,6 @@ class AgentsAdminTab(QWidget):
fields = dlg.result_fields()
if not fields["name"]:
return
from datetime import datetime
agent.name = fields["name"]
agent.task_kind = fields["task_kind"]
@@ -256,8 +338,8 @@ class AgentsAdminTab(QWidget):
admin_agents.save_agent(agent, self._dir())
self.refresh()
def _delete(self) -> None:
agent = self._selected_agent()
def _delete_agent(self, agent_id: str) -> None:
agent = admin_agents.load_agent(agent_id, self._dir())
if agent is None:
return
if QMessageBox.question(
@@ -267,27 +349,69 @@ class AgentsAdminTab(QWidget):
admin_agents.delete_agent(agent.agent_id, self._dir())
self.refresh()
def _set_enabled(self, agent_id: str, enabled: bool) -> None:
agent = admin_agents.load_agent(agent_id, self._dir())
if agent is None or agent.enabled == enabled:
return
agent.enabled = enabled
agent.updated = datetime.now().isoformat(timespec="seconds")
agent.updated_by = (getattr(self.ctx, "account", None).username if getattr(self.ctx, "account", None) else "")
admin_agents.save_agent(agent, self._dir())
self.refresh()
# ---- view --------------------------------------------------------------
def _status_cell(self, agent_id: str) -> tuple:
"""(status_icon | None, display_text, tooltip) for the operational-status
column — a colored LED dot instead of the old 🟢/🔴 emoji."""
"""(state_key, display_text, tooltip) for the Trạng thái pill —
state_key indexes _STATUS_BADGE for the badge's colour tone."""
res = self._status.get(agent_id)
if res is None:
return None, tr("agents_admin.status_unchecked"), tr("agents_admin.status_unchecked_tip")
return ("unchecked", tr("agents_admin.status_unchecked").lstrip("— ").strip(),
tr("agents_admin.status_unchecked_tip"))
ok, msg = res
if msg == "checking":
return dot_icon(DOT_AMBER), tr("agents_admin.status_checking"), ""
ic = dot_icon(DOT_GREEN if ok else DOT_RED)
return ic, (tr("agents_admin.status_ok") if ok else tr("agents_admin.status_bad")), msg
return "checking", tr("agents_admin.status_checking"), ""
return ("ok" if ok else "bad"), (tr("agents_admin.status_ok") if ok else tr("agents_admin.status_bad")), msg
def _toggle_widget(self, agent_id: str, enabled: bool) -> QWidget:
container = QWidget()
container.setStyleSheet("background: transparent;")
lay = QHBoxLayout(container)
lay.setContentsMargins(6, 0, 0, 0)
sw = ToggleSwitch()
sw.setChecked(enabled)
sw.toggled.connect(lambda checked, aid=agent_id: self._set_enabled(aid, checked))
lay.addWidget(sw, 0, Qt.AlignVCenter)
lay.addStretch(1)
return container
def _row_actions_widget(self, agent_id: str) -> QWidget:
container = QWidget()
container.setStyleSheet("background: transparent;")
lay = QHBoxLayout(container)
lay.setContentsMargins(2, 0, 2, 0)
lay.setSpacing(2)
edit_btn = QPushButton()
edit_btn.setIcon(icon("edit"))
edit_btn.setFlat(True)
edit_btn.setCursor(Qt.PointingHandCursor)
edit_btn.setToolTip(tr("agents_admin.edit_row_tooltip"))
edit_btn.clicked.connect(lambda: self._edit_agent(agent_id))
del_btn = QPushButton()
del_btn.setIcon(icon("trash"))
del_btn.setFlat(True)
del_btn.setCursor(Qt.PointingHandCursor)
del_btn.setToolTip(tr("agents_admin.delete_row_tooltip"))
del_btn.clicked.connect(lambda: self._delete_agent(agent_id))
lay.addWidget(edit_btn)
lay.addWidget(del_btn)
return container
def refresh(self) -> None:
from PySide6.QtCore import Qt
# Make sure the built-in in-app Help assistant exists, so the Admin can
# manage its provider/model here (the floating Help widget uses it).
admin_agents.ensure_help_agent(self._dir())
agents = admin_agents.list_agents(self._dir())
self.table.setSortingEnabled(False)
self.table.setRowCount(len(agents))
default_model = self._default_model_hint()
for row, agent in enumerate(agents):
@@ -296,23 +420,36 @@ class AgentsAdminTab(QWidget):
model = f"{provider_lbl} — {agent.model}" if provider_lbl else agent.model
else:
model = tr("agents_admin.default_model", model=default_model or "—")
status_icon, status_text, status_tip = self._status_cell(agent.agent_id)
cells = [agent.name, tr(f"agents_admin.kind.{agent.task_kind}"),
model, "", status_text, agent.updated]
for col, text in enumerate(cells):
item = QTableWidgetItem(str(text))
if col == 0:
item.setData(Qt.UserRole, agent.agent_id)
if col == 3: # Enabled — green check / grey minus icon (no emoji)
item.setIcon(icon("check", color=DOT_GREEN) if agent.enabled
else icon("minus", color=DOT_GREY))
if col == 4:
if status_icon:
item.setIcon(status_icon)
name_item = QTableWidgetItem(agent.name)
name_item.setIcon(_kind_avatar_icon(agent.task_kind, agent.name))
self.table.setItem(row, 0, name_item)
kind_tone = _KIND_BADGE.get(agent.task_kind, "badge")
self.table.setCellWidget(
row, 1, badge_pill_widget(tr(f"agents_admin.kind.{agent.task_kind}"), kind_tone))
self.table.setItem(row, 2, QTableWidgetItem(model))
self.table.setCellWidget(row, 3, self._toggle_widget(agent.agent_id, agent.enabled))
state_key, status_text, status_tip = self._status_cell(agent.agent_id)
status_widget = badge_pill_widget(status_text, _STATUS_BADGE[state_key])
if status_tip:
item.setToolTip(status_tip)
self.table.setItem(row, col, item)
self.table.setSortingEnabled(True)
status_widget.setToolTip(status_tip)
self.table.setCellWidget(row, 4, status_widget)
self.table.setItem(row, 5, QTableWidgetItem(_fmt_updated(agent.updated)))
self.table.setCellWidget(row, 6, self._row_actions_widget(agent.agent_id))
# ResizeToContents doesn't measure a cell WIDGET's real width (only
# QTableWidgetItem content) — size the Vai trò/Trạng thái pill columns
# by hand, or their text clips against whatever width it guessed.
if self.table.rowCount():
for col in (1, 4):
needed = max(self.table.cellWidget(r, col).sizeHint().width()
for r in range(self.table.rowCount()))
if needed + 24 > self.table.columnWidth(col):
self.table.setColumnWidth(col, needed + 24)
def _check_all(self) -> None:
"""Health-check every agent's effective provider off the UI thread and
@@ -347,15 +484,15 @@ class AgentsAdminTab(QWidget):
w.start()
def _retranslate(self) -> None:
self._title_lbl.setText(tr("agents_admin.page_title"))
self._hint.setText(tr("agents_admin.hint"))
self.table.setHorizontalHeaderLabels([
tr("agents_admin.col_name"), tr("agents_admin.col_kind"),
tr("agents_admin.col_model"), tr("agents_admin.col_enabled"),
tr("agents_admin.col_status"), tr("agents_admin.col_updated"),
tr("agents_admin.col_status"), tr("agents_admin.col_updated"), "",
])
self.add_btn.setText(tr("agents_admin.add_btn"))
self.edit_btn.setText(tr("agents_admin.edit_btn"))
self.del_btn.setText(tr("agents_admin.delete_btn"))
self.refresh_btn.setText(tr("monitoring.refresh"))
self.check_btn.setText(tr("agents_admin.check_btn"))
self.check_btn.setToolTip(tr("agents_admin.check_tooltip"))
self.refresh()
+561 -34
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,10 +806,33 @@ 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)
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"))
@@ -383,15 +840,60 @@ class MonitoringTab(QWidget):
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)
lay.addWidget(table, 1)
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:
+19
View File
@@ -18,6 +18,25 @@ from ..theme import current_palette
from .icons import DOT_BLUE, DOT_GREEN, DOT_GREY, DOT_RED, dot_icon, icon
def badge_pill_widget(text: str, object_name: str) -> QWidget:
"""A small rounded pill for a table cell — a coloured role/status tag
(``object_name`` is one of theme.py's ``badge*`` QLabel names). Wrapped in
a transparent container rather than passed as a bare label: a
``setCellWidget()`` widget is stretched to fill the whole cell, and
without the container's own ``background: transparent`` the app-wide
``QWidget { background: $bg }`` rule (theme.py) paints that stretched
area opaque, hiding the pill inside a solid block instead of a snug tag."""
container = QWidget()
container.setStyleSheet("background: transparent;")
lay = QHBoxLayout(container)
lay.setContentsMargins(4, 2, 4, 2)
lbl = QLabel(text)
lbl.setObjectName(object_name)
lay.addWidget(lbl, 0, Qt.AlignVCenter)
lay.addStretch(1)
return container
def _style_card(frame: QFrame) -> None:
"""Give a stat/budget card its surface. Flat by design: the raised surface
plus a hairline is what separates it from the page — the old drop shadow