Feature/delta team/epic r04 (#7)
CI / test (push) Canceled after 0s

## Summary

epic r04 - begin refactor

## Change Type

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

## Related Work

Cowork Task:

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

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

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

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

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

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
This commit was merged in pull request #7.
This commit is contained in:
2026-08-31 05:15:13 +00:00
committed by gitea-admin
co-authored by anhtnm1 huongltt35 Nam Pham Dinh Thanh vudt15 Hiep Ha Van lamhv7
parent 86c27e2e79
commit f9f6bc01fd
496 changed files with 68421 additions and 19688 deletions
+1
View File
@@ -0,0 +1 @@
"""Presentation monitoring package: 8 modular sub-tab widgets."""
+292
View File
@@ -0,0 +1,292 @@
"""Monitoring Dashboard — container only. Builds the tab strip, wires the
auto-refresh timer and language-change retranslation, and forwards nav-rail
sub-tab selection. Each sub-tab is its own class under ``tabs/``; this class
owns no display logic of its own beyond assembling and refreshing them.
Public API preserved exactly for ``app.py`` (which cannot be modified):
``MonitoringTab(ctx, cowork=None, structure=None, task_scheduler=None)``,
the ``status_message`` signal, ``select_subtab(index)``, ``nav_subtabs()``,
``hide_tab_bar()``.
"""
from __future__ import annotations
from typing import List
from PySide6.QtCore import QTimer, Signal
from PySide6.QtWidgets import QHBoxLayout, QLabel, QTabWidget, QVBoxLayout, QWidget
from ...core import audit_log
from ...i18n import on_language_changed, tr
from ...state import AppContext
from .tabs.action_logs_tab import ActionLogsTab
from .tabs.agent_status_tab import AgentStatusTab
from .tabs.mcp_tab import McpTab
from .tabs.overview_tab import OverviewTab
from .tabs.security_events_tab import SecurityEventsTab
_REFRESH_MS = 3000
# Comfortably larger than any realistic audit-log size — the event tables
# have never had pagination controls, so every tab still shows "all matching
# events" exactly like before; MonitoringQueryService's pagination support
# is exercised for real here, just not surfaced as UI (yet).
_UNBOUNDED_PAGE_SIZE = 100_000
class MonitoringTab(QWidget):
"""Vỏ màn Giám sát: dải 8 tab và phần dữ liệu dùng chung cho chúng.
Nhật ký kiểm toán được đọc MỘT lần mỗi nhịp làm mới rồi chia cho các tab,
nên quyết định "đọc từ nguồn cục bộ hay nguồn chia sẻ" chỉ xảy ra đúng một
lần chứ không phải ở từng tab.
"""
status_message = Signal(str)
# ---- cầu tương thích sau khi tách 8 tab (R08-T08) --------------------
# Trước khi tách, 55 control của Tổng quan treo thẳng trên MonitoringTab với
# tiền tố ov_. Tách xong mỗi cái về đúng tab/thẻ của nó và rụng tiền tố.
#
# tools/check_controls_alive.py so với mốc git 291a611 và đòi cả 55 cái phải
# còn tới được — đó chính là việc của nó: bắt control biến mất trong lúc bóc
# tách. Lần này control còn đủ, chỉ đổi chỗ ở, nên bắc cầu theo tiền tố.
#
# Định tuyến theo tiền tố chứ không dò mờ: overview_tab và permissions_card
# có những tên trùng nhau (network_lbl nằm ở cả hai — một cái là mức dùng
# mạng, một cái là quyền truy cập mạng). Dò mờ vớ nhầm cái đầu tiên tìm thấy.
_OV_TIEN_TO = (
("ov_perm_", lambda s: s.overview_tab.sandbox_card.permissions_card),
("ov_sbx_", lambda s: s.overview_tab.sandbox_card),
("ov_price_", lambda s: s.overview_tab.pricing_panel),
("ov_pricing_", lambda s: s.overview_tab.pricing_panel),
("ov_", lambda s: s.overview_tab),
)
#: Ba hộp nhóm: bản thân widget con CHÍNH LÀ hộp đó, không phải thuộc tính.
_OV_CHINH_NO = {
"ov_pricing_group": lambda s: s.overview_tab.pricing_panel,
"ov_sandbox_details_group": lambda s: s.overview_tab.sandbox_card,
"ov_permissions_group": lambda s: s.overview_tab.sandbox_card.permissions_card,
}
#: Vài control không mang tiền tố ov_ nhưng cũng đã dời đi.
_KHAC = {
"status_table": lambda s: s.status_tab.table,
}
def __getattr__(self, name):
"""Cầu tương thích cho 55 control cũ mang tiền tố ``ov_``.
R08-T08 chuyển chúng về đúng tab/thẻ của mình và bỏ tiền tố, nhưng các
checker trong ``tools/`` vẫn hỏi theo tên cũ. Định tuyến theo TIỀN TỐ chứ
không dò mờ: ``overview_tab`` và ``permissions_card`` có tên trùng nhau
(``network_lbl`` ở cả hai — một cái là mức dùng mạng, một cái là quyền truy
cập mạng), dò mờ sẽ vớ nhầm cái tìm thấy trước.
"""
lay_khac = self._KHAC.get(name)
if lay_khac is not None:
return lay_khac(self)
# Qt gọi __getattr__ rất nhiều lúc khởi tạo; chặn sớm cho rẻ.
if not name.startswith("ov_"):
raise AttributeError(name)
lay = self._OV_CHINH_NO.get(name)
if lay is not None:
return lay(self)
for tien_to, chu in self._OV_TIEN_TO:
if name.startswith(tien_to):
try:
return getattr(chu(self), name[len(tien_to):])
except AttributeError:
continue
raise AttributeError(name)
def __init__(self, ctx: AppContext, cowork=None, structure=None, task_scheduler=None):
"""Ghép các tab con của màn Giám sát.
Các widget được truyền vào đều có thể là None (test dựng tab lẻ) — mọi chỗ
dùng phải kiểm trước.
"""
super().__init__()
self.ctx = ctx
self._cowork = cowork
self._structure = structure
self._task_scheduler = task_scheduler
root = QVBoxLayout(self)
head = QHBoxLayout()
self._title = QLabel()
self._title.setStyleSheet("font-weight:700; font-size:15px;")
head.addWidget(self._title)
head.addStretch(1)
root.addLayout(head)
self.tabs = QTabWidget()
root.addWidget(self.tabs, 1)
visible = self._tab_visible
self.overview_tab = OverviewTab(
ctx, on_status_message=self.status_message.emit,
on_settings_changed=self.refresh,
on_view_all_action_logs=self._show_action_logs_tab,
action_logs_tab_visible=visible("action_logs"))
self.tabs.addTab(self.overview_tab, "")
self.security_tab = SecurityEventsTab(ctx, on_refresh_all=self.refresh)
if visible("security_events"):
self.tabs.addTab(self.security_tab, "")
self.mcp_tab = McpTab(ctx, on_refresh_all=self.refresh)
if visible("mcp_history"):
self.tabs.addTab(self.mcp_tab, "")
self.action_tab = ActionLogsTab(ctx, on_refresh_all=self.refresh)
if visible("action_logs"):
self.tabs.addTab(self.action_tab, "")
self.status_tab = AgentStatusTab(
ctx, on_refresh_all=self.refresh,
cowork=cowork, structure=structure, task_scheduler=task_scheduler)
if visible("agent_status"):
self.tabs.addTab(self.status_tab, "")
# ---- Agents Admin (catalog: assign a role + pinned model per agent) --
from ...ui.agents_admin_tab import AgentsAdminTab
self.agents_admin_tab = AgentsAdminTab(ctx)
if visible("agents_admin"):
self.tabs.addTab(self.agents_admin_tab, "")
# ---- Tools (govern built-in tools + Connectors/MCP in one place) -----
from ...ui.tools_admin_tab import ToolsAdminTab
self.tools_admin_tab = ToolsAdminTab(ctx)
if visible("tools_admin"):
self.tabs.addTab(self.tools_admin_tab, "")
# ---- Icons (browse built-in icons + add custom icons for agents/flows) --
from ...ui.icons_admin_tab import IconsAdminTab
self.icons_admin_tab = IconsAdminTab(ctx)
self.tabs.addTab(self.icons_admin_tab, "")
self.tabs.setCurrentIndex(0)
self._timer = QTimer(self)
# Only the Overview cards auto-refresh on this tick — Security, MCP,
# Action Logs, Agent Status (and the admin tabs) are read-only tables
# a background re-sort would otherwise disturb mid-interaction; the
# user refreshes them explicitly via a "Refresh" button.
self._timer.setInterval(_REFRESH_MS)
self._timer.timeout.connect(self._auto_refresh)
self._timer.start()
on_language_changed(self._retranslate)
self.refresh()
# ---- nav integration: sub-tabs driven from the left nav rail ------------
def nav_subtabs(self):
"""(label, index, icon_name) for each sub-tab — the left nav lists
these as children under 'Monitoring'."""
by_widget = {self.agents_admin_tab: "robot", self.tools_admin_tab: "wrench",
self.icons_admin_tab: "star"}
for attr, name in (("security_tab", "shield"), ("mcp_tab", "plug"),
("action_tab", "bolt"), ("status_tab", "monitor")):
w = getattr(self, attr, None)
if w is not None:
by_widget[w] = name
out = []
for i in range(self.tabs.count()):
out.append((self.tabs.tabText(i), i, by_widget.get(self.tabs.widget(i), "dashboard")))
return out
def select_subtab(self, index: int) -> None:
"""Chuyển sang tab con thứ ``index`` (thanh menu bên trái gọi vào đây)."""
if 0 <= index < self.tabs.count():
self.tabs.setCurrentIndex(index)
def hide_tab_bar(self) -> None:
"""Hide the in-content tab strip; the nav rail drives the sub-tabs."""
self.tabs.tabBar().hide()
def _show_action_logs_tab(self) -> None:
"""Nhảy tới tab Nhật ký hành động (dùng khi bấm vào phần trích ở Tổng quan)."""
self.tabs.setCurrentIndex(self.tabs.indexOf(self.action_tab))
def _tab_visible(self, key: str) -> bool:
"""Tab này có được hiện không: admin thấy hết, còn lại theo cấu hình
``monitoring_visibility``.
"""
return self.ctx.role == "admin" or bool(self.ctx.config.monitoring_visibility.get(key, True))
def _set_tab_text_if_present(self, widget, text: str) -> None:
"""Đổi nhãn một tab, bỏ qua nếu tab đó đang bị ẩn theo phân quyền."""
idx = self.tabs.indexOf(widget)
if idx >= 0:
self.tabs.setTabText(idx, text)
# ---- i18n ---------------------------------------------------------------
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và nhãn từng tab."""
self._title.setText(tr("monitoring.title"))
if self.tabs.count():
self.tabs.setTabText(0, tr("monitoring.tab_overview"))
self._set_tab_text_if_present(self.security_tab, tr("monitoring.tab_security"))
self._set_tab_text_if_present(self.mcp_tab, tr("monitoring.tab_mcp"))
self._set_tab_text_if_present(self.action_tab, tr("monitoring.tab_actions"))
self._set_tab_text_if_present(self.status_tab, tr("monitoring.tab_agents"))
self._set_tab_text_if_present(self.agents_admin_tab, tr("monitoring.tab_agents_admin"))
self._set_tab_text_if_present(self.tools_admin_tab, tr("monitoring.tab_tools"))
self._set_tab_text_if_present(self.icons_admin_tab, tr("monitoring.tab_icons"))
self.overview_tab.retranslate()
self.security_tab.retranslate()
self.mcp_tab.retranslate()
self.action_tab.retranslate()
self.status_tab.retranslate()
self.refresh()
# ---- refresh -------------------------------------------------------------
def refresh(self) -> None:
"""Full refresh — Overview cards plus every table. Wired to the
top-of-page and per-section "Refresh" buttons, called once at
startup/language-change, but NOT to the auto-refresh timer (see
``_auto_refresh``)."""
events = self._load_events()
self._apply_events_to_event_tabs(events)
self.status_tab.refresh()
self.overview_tab.refresh(events)
def _auto_refresh(self) -> None:
"""3-second timer tick — Overview cards only (see ``refresh``)."""
self.overview_tab.refresh(self._load_events())
def _load_events(self) -> List[dict]:
"""Đọc nhật ký kiểm toán, ưu tiên nguồn chia sẻ rồi mới tới nguồn cục bộ.
Có cấu hình thư mục chia sẻ VÀ đọc ra được dữ liệu thì dùng nó, để cả đội
nhìn chung một bức tranh; rỗng thì rơi về nhật ký của máy này.
"""
shared_dir = self.ctx.config.shared_dir
if shared_dir:
from ...core import telemetry_shared
shared_events = telemetry_shared.load_shared_audit_events(shared_dir)
if shared_events:
return shared_events
return audit_log.load_events()
def _apply_events_to_event_tabs(self, events: List[dict]) -> None:
"""Filters the ALREADY-LOADED event list (see ``_load_events`` — one
shared local-or-shared decision per refresh, not one per tab) three
ways via :class:`MonitoringQueryService`, mirroring what each
``EventTable.set_events`` used to receive directly."""
from ...application.monitoring.dto.audit_event_dto import AuditEventDTO
from ...application.monitoring.monitoring_query_service import MonitoringQueryService
from ...application.monitoring.repository.audit_event_repository import (
InMemoryAuditEventRepository,
)
repository = InMemoryAuditEventRepository([AuditEventDTO.from_raw(e) for e in events])
service = MonitoringQueryService(repository)
def _events_for(kind) -> List[dict]:
"""Lấy toàn bộ sự kiện của một loại, không phân trang."""
page = service.query(kind=kind, page_size=_UNBOUNDED_PAGE_SIZE)
return [e.to_dict() for e in page.items]
self.security_tab.set_events(_events_for("security_block"))
self.mcp_tab.set_events(_events_for("mcp_call"))
self.action_tab.set_events(_events_for(None))
@@ -0,0 +1,3 @@
"""Mảnh dùng chung của các tab Giám sát: bảng sự kiện, panel chi tiết, huy hiệu,
bộ lọc và các hàm định dạng.
"""
@@ -0,0 +1,67 @@
"""Màu, huy hiệu và avatar của agent quản trị theo LOẠI VIỆC nó làm.
Bảng Agents Admin xếp agent theo ``task_kind`` (search / monitor / cowork /
graphrag / schedule / security / help). Ở đây gom toàn bộ phần "loại việc ấy
trông thế nào": màu định danh cho avatar, tông huy hiệu cho ô Vai trò, tông
huy hiệu cho ô Trạng thái.
Khác với ``formatters.py::agent_avatar_colour`` — chỗ đó đoán màu bằng cách
dò từ khoá trong TÊN agent, dùng cho các bảng chỉ có tên. Bảng quản trị biết
chính xác loại việc nên tra thẳng, không phải đoán.
Bảy loại, bảy tông riêng: không hai loại nào trùng màu huy hiệu, để phân biệt
được bằng màu chứ không phải chỉ bằng chữ. Màu cố định, không đổi theo giao
diện sáng/tối — cùng quy ước với avatar từng agent ở ``monitoring_tab.py``.
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap
from .formatters import agent_initials
#: Màu vòng tròn avatar theo loại việc.
KIND_COLOUR = {
"search": "#8A8886", "monitor": "#FFB900", "cowork": "#0078D4",
"graphrag": "#8764B8", "schedule": "#107C10", "security": "#D13438",
"help": "#E3008C",
}
#: Tên đối tượng huy hiệu (định nghĩa trong ``theme.py``) cho ô Vai trò.
KIND_BADGE = {
"search": "badgeNeutral", "monitor": "badgeWarn", "cowork": "badge",
"graphrag": "badgePurple", "schedule": "badgeSuccess", "security": "badgeDanger",
"help": "badgePink",
}
#: Tông huy hiệu cho ô Trạng thái, theo kết quả lần kiểm tra gần nhất.
STATUS_BADGE = {
"unchecked": "badgeNeutral", "checking": "badgeWarn",
"ok": "badgeSuccess", "bad": "badgeDanger",
}
def kind_avatar_icon(kind: str, name: str, size: int = 20) -> QIcon:
"""Avatar tròn cho một agent: nền là màu của loại việc, chữ là tên viết tắt.
Loại lạ (file cấu hình sửa tay, loại mới thêm mà quên khai màu) rơi về màu
xanh mặc định thay vì không vẽ gì — thiếu màu đúng vẫn hơn thiếu avatar.
"""
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, agent_initials(name))
p.end()
return QIcon(pm)
__all__ = ["KIND_COLOUR", "KIND_BADGE", "STATUS_BADGE", "kind_avatar_icon"]
@@ -0,0 +1,54 @@
"""AI-assisted search-keyword filter — shared by the Security Events / MCP
Call History / Action Logs tabs' search box. Extracted verbatim from
``ui/monitoring_tab.py``'s ``MonitoringTab._ai_filter``.
Each caller owns its own ``state`` dict (just ``{}`` at construction) so a
repeat click is a no-op while a request is already in flight — mirrors the
original single ``self._ai_filter_worker`` attribute, without needing a
shared base class.
"""
from __future__ import annotations
from PySide6.QtWidgets import QLineEdit, QPushButton
def start_ai_filter(ctx, search: QLineEdit, ai_btn: QPushButton, state: dict) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc bảng sự kiện.
Ô tìm rỗng hoặc đang chạy dở thì bỏ qua.
"""
query = search.text().strip()
if not query or state.get("worker") is not None:
return
ai_btn.setEnabled(False)
def job(worker):
"""Chạy nền: hỏi model từ khoá lọc tương ứng câu người dùng gõ."""
provider = ctx.build_active_provider()
reply = provider.chat([
{"role": "system", "content":
"Turn the user's natural-language question about an audit/security event "
"log into ONE short search keyword. Reply with ONLY the keyword."},
{"role": "user", "content": query},
], cancel=worker.is_cancelled)
return {"keyword": (reply.get("content") or "").strip().splitlines()[0][:60]}
def done(result: dict) -> None:
"""Đổ từ khoá model trả về vào ô tìm và áp bộ lọc."""
state["worker"] = None
ai_btn.setEnabled(True)
search.setText(result.get("keyword") or query)
def failed(_err: str) -> None:
"""Lọc bằng AI lỗi: chỉ mở khoá lại nút, giữ nguyên bộ lọc đang có — đây là
tiện ích phụ, hỏng thì không được làm phiền người dùng.
"""
state["worker"] = None
ai_btn.setEnabled(True)
from ....core.worker import AgentWorker
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(failed)
state["worker"] = w
w.start()
+101
View File
@@ -0,0 +1,101 @@
"""Badge/label vocabulary shared by the Monitoring event tables and detail
panel — human labels for a raw audit ``name``, and the (QSS object name,
i18n key) pair for the "Trạng thái"/"Mức độ" pills.
``apply_badge`` replaces the two byte-identical
``_EventDetailPanel._apply_badge`` / ``MonitoringTab._set_badge`` static
methods the original file duplicated.
"""
from __future__ import annotations
from typing import Tuple
from PySide6.QtWidgets import QLabel
from ....i18n import tr
# 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:
"""Nhãn đã dịch của một loại hành động; không có trong bảng thì trả nguyên tên."""
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, mapped onto
# the app's existing badge* tones (theme.py).
_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]:
"""Cặp (tên style, khoá dịch) cho huy hiệu trạng thái của một sự kiện."""
if name in _STATUS_INFO:
return _STATUS_INFO[name]
if kind == "security_block":
# Security Events rows are always ok=False — 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]:
# An unapproved shell command is the one CRITICAL case; everything else
# blocked is MEDIUM.
"""Cặp (tên style, khoá dịch) cho huy hiệu mức nghiêm trọng.
Lệnh shell chạy mà chưa được duyệt là trường hợp NGHIÊM TRỌNG duy nhất; mọi
thứ bị chặn khác đều ở mức trung bình.
"""
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 ``formatters.agent_avatar_colour`` uses, expressed as one of the
shared badge* QSS classes (theme.py) instead of a literal hex."""
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"
def apply_badge(label: QLabel, object_name: str) -> None:
"""Gán style cho một nhãn huy hiệu và ép Qt vẽ lại.
Phải ``unpolish``/``polish`` vì Qt không tự áp lại QSS khi ``objectName`` đổi.
"""
label.setObjectName(object_name)
label.style().unpolish(label)
label.style().polish(label)
@@ -0,0 +1,196 @@
"""Right-hand "Chi tiet su kien" detail panel shared by the Security Events /
MCP Call History / Action Logs tabs — the full record behind whichever row is
selected in an :class:`~.event_table.EventTable`. Extracted verbatim from
``ui/monitoring_tab.py``.
"""
from __future__ import annotations
from typing import Dict, List, Tuple
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import (
QHBoxLayout, QLabel, QPushButton, QScrollArea, QVBoxLayout, QWidget,
)
from ....core import agent_roles
from ....i18n import tr
from ....ui.icons import icon
from .badges import agent_badge_name, action_label, apply_badge, severity_info, status_info
from .formatters import event_id, fmt_event_time_full
from .layout_helpers import kv_row
# Cosmetic label only — no real policy-versioning system exists yet.
_STATIC_POLICY_LABEL = "security_policy_v2"
class EventDetailPanel(QWidget):
"""Laid out as three labelled sections, a terminal-style block quote for
the detail text, and a metadata footer, closed by the header close
button, the footer button, Esc, or a click outside the table/panel (see
:class:`~.event_table.ClickOutsideCloser`)."""
closed = Signal()
def __init__(self):
"""Panel chi tiết hiện đầy đủ một sự kiện đang chọn trong bảng."""
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:
"""Thêm một dòng tiêu đề mục vào panel."""
lbl = QLabel()
lbl.setObjectName("detailSectionHdr")
self._body_lay.addWidget(lbl)
self._section_hdrs.append((key, lbl))
def _field(key: str) -> QLabel:
"""Thêm một hàng nhãn–giá trị và ghi nhớ để cập nhật sau."""
lbl, val = kv_row(self._body_lay)
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)
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
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:
"""Hiện chi tiết một sự kiện; trường trống hiển thị dấu "—" thay vì để rỗng."""
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)
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))
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))
apply_badge(self._severity_val, severity_badge)
def _copy_detail(self) -> None:
"""Chép toàn bộ chi tiết vào clipboard và đổi nhãn nút để báo đã chép."""
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:
"""Trả nút Sao chép về nhãn ban đầu sau vài giây."""
self._copy_btn.setText(tr("monitoring.detail_copy"))
self._copy_btn.setIcon(icon("document"))
@@ -0,0 +1,194 @@
"""Read-only audit-event table shared by the Security Events / MCP Call
History / Action Logs tabs, plus the click-outside-closes-detail-panel event
filter. Extracted verbatim from ``ui/monitoring_tab.py``.
"""
from __future__ import annotations
from typing import List, Optional
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt
from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
from ....core import agent_roles
from ....i18n import tr
from ....theme import current_palette
from ....ui.icons import DOT_GREEN, DOT_RED, icon
from .badges import action_label
from .formatters import agent_avatar_icon, fmt_event_time
_MAX_ROWS = 300
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):
"""Ô thời gian giữ luôn chuỗi gốc: hiện ra là chữ đã rút gọn, nhưng sắp xếp
phải theo mốc thật chứ không theo thứ tự chữ cái.
"""
super().__init__(display)
self._raw_ts = raw_ts
def __lt__(self, other):
"""So sánh theo mốc thời gian gốc, không theo chuỗi hiển thị.
Sắp theo chuỗi đã định dạng sẽ ra thứ tự sai ngay khi định dạng có chữ
("5 phút trước" đứng trước "hôm qua").
"""
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 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
# useful distinction is WHICH rule fired.
_ACTION_TINTS = {
"prompt": "accent",
"dangerous_command": "danger",
"run_command": "danger",
"install_package": "warning",
"path_outside_sandbox": "success",
"network_blocked": "accent",
"secret_in_output": "warning",
}
def __init__(self, show_result: bool = True):
# Security Events drops the result column entirely (see _ACTION_TINTS).
"""Bảng sự kiện dùng chung của các tab Giám sát.
``show_result`` tắt cột Kết quả cho màn Sự kiện bảo mật — ở đó mọi dòng đều
là thất bại nên cột ấy chỉ tốn chỗ.
"""
self._show_result = show_result
super().__init__(0, 7 if show_result else 6)
self.setEditTriggers(QTableWidget.NoEditTriggers)
self.setSelectionBehavior(QTableWidget.SelectRows)
self.setIconSize(QSize(20, 20))
self.verticalHeader().setVisible(False)
# Fixed row height — letting Qt auto-size rows from content fought with
# the action column's cell widget geometry settling stale/oversized on
# an intermediate sizing pass, clipping the pill's text.
self.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
self.verticalHeader().setDefaultSectionSize(32)
self.setSortingEnabled(True)
header = self.horizontalHeader()
header.setStretchLastSection(True)
for col in range(self.columnCount() - 1):
header.setSectionResizeMode(col, QHeaderView.ResizeToContents)
def retranslate(self) -> None:
"""Áp lại tên cột theo ngôn ngữ đang chọn."""
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_block") if not self._show_result else tr("monitoring.col_detail")]
self.setHorizontalHeaderLabels(cols)
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, cắt ở ``_MAX_ROWS``.
Tắt sắp xếp trong lúc đổ dữ liệu — để bật, Qt sắp lại sau mỗi dòng và việc
nạp chậm đi theo bậc hai.
"""
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:_MAX_ROWS]
self.setSortingEnabled(False)
self.setRowCount(len(events))
for row, ev in enumerate(events):
is_admin_violation = ev.get("role") == "admin" and not ev.get("ok", True)
cells = [
ev.get("ts", ""), agent_roles.label_for(ev.get("agent_role", "")),
ev.get("account", "") or "—", ev.get("machine", "") or "—",
ev.get("name", ""),
]
if self._show_result:
cells.append("")
cells.append((ev.get("detail") or "")[:300])
pal = current_palette()
for col, text in enumerate(cells):
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:
item.setIcon(agent_avatar_icon(str(text)))
if self._show_result and col == 5:
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:
# Human-readable label, tinted by which rule fired, via the
# ITEM's own colours — NOT a setCellWidget() pill, which is
# pinned to a screen position rather than travelling with
# the item across a re-sort.
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)
soft.setAlpha(38)
item.setBackground(QBrush(soft))
if is_admin_violation:
item.setBackground(QBrush(QColor(229, 72, 77, 60)))
self.setItem(row, col, item)
self.setSortingEnabled(True)
self.apply_filter(getattr(self, "_filter_needle", ""))
def apply_filter(self, needle: str) -> None:
"""Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường)."""
self._filter_needle = (needle or "").strip().lower()
for row in range(self.rowCount()):
if not self._filter_needle:
self.setRowHidden(row, False)
continue
match = any(
self._filter_needle in (self.item(row, col).text().lower()
if self.item(row, col) else "")
for col in range(self.columnCount()))
self.setRowHidden(row, not match)
def event_at_row(self, row: int) -> Optional[dict]:
"""Bản ghi sự kiện gắn với một dòng; ``None`` nếu dòng trống."""
item = self.item(row, 0)
return item.data(Qt.UserRole) if item else None
class ClickOutsideCloser(QObject):
"""Closes the event-detail panel on a click anywhere outside the
table/panel splitter — 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 (unreliable mid-drag on the
splitter's handle)."""
def __init__(self, table: "EventTable", panel: QWidget, container: QWidget):
"""Bấm ra ngoài panel chi tiết thì đóng nó lại."""
super().__init__(container)
self._table = table
self._panel = panel
self._container = container
def eventFilter(self, obj, event) -> bool:
"""Bắt cú bấm chuột trong toàn khung: rơi ngoài cả bảng lẫn panel thì đóng panel."""
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
@@ -0,0 +1,127 @@
"""Page scaffolding shared by the Security Events / MCP Call History / Action
Logs / Agent Status tabs: title + refresh button, optional search box +
AI-filter button, optional table+detail-panel split. Extracted from
``ui/monitoring_tab.py``'s ``MonitoringTab._wrap_with_filter``/
``_sync_event_detail`` — those used to be rebuilt 3 times almost identically
for the 3 event-table pages; this is the one shared implementation.
Builds directly into a caller-supplied ``page`` widget (which must not have a
layout yet) and returns a dict of the sub-widgets the caller needs to keep
(e.g. to implement its own ``retranslate()``).
"""
from __future__ import annotations
from typing import Callable, Dict, Optional
from PySide6.QtCore import Qt
from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtWidgets import (
QApplication, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSplitter,
QTableWidget, QVBoxLayout, QWidget,
)
from ....i18n import tr
from ....ui.icons import icon
from .event_table import ClickOutsideCloser, EventTable
from .event_detail_panel import EventDetailPanel
def _sync_event_detail(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.
"""Đồng bộ panel chi tiết với dòng đang chọn trong bảng.
Chỉ nhìn ``currentRow()`` là chưa đủ: ``clearSelection()`` (nút đóng của panel
dùng) bỏ vùng chọn nhưng GIỮ nguyên ô hiện tại, nên chỉ dựa vào
``currentRow()`` thì panel sẽ không bao giờ đóng.
"""
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 build_filter_scaffold(
page: QWidget, table: QTableWidget, *, on_refresh: Callable[[], None],
title_key: Optional[str] = None, with_search: bool = True,
with_detail: bool = False,
on_ai_filter: Optional[Callable[[QLineEdit, QPushButton], None]] = None,
) -> Dict[str, object]:
"""Dựng khung chung cho một tab sự kiện: tiêu đề, nút làm mới, ô tìm kiếm,
nút lọc bằng AI và panel chi tiết.
Bốn tab sự kiện của màn Giám sát chỉ khác nhau ở nguồn dữ liệu, nên phần vỏ
này được dựng một lần và dùng chung.
"""
lay = QVBoxLayout(page)
lay.setContentsMargins(0, 0, 0, 0)
parts: Dict[str, object] = {}
if title_key:
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(on_refresh)
hdr.addWidget(refresh_btn)
lay.addLayout(hdr)
parts.update(title_lbl=title_lbl, title_key=title_key, 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)
if on_ai_filter is not None:
ai_btn.clicked.connect(lambda: on_ai_filter(search, ai_btn))
row.addWidget(search, 1)
row.addWidget(ai_btn)
lay.addLayout(row)
parts.update(filter_edit=search, ai_filter_btn=ai_btn)
if with_detail:
# Click a row -> its full record opens in a detail 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: _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)
parts["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)
parts["detail_esc_shortcut"] = esc
# A click outside both the table and the panel also closes it.
click_filter = ClickOutsideCloser(table, detail, split)
QApplication.instance().installEventFilter(click_filter)
parts["detail_click_filter"] = click_filter
else:
lay.addWidget(table, 1)
return parts
@@ -0,0 +1,115 @@
"""Formatting helpers shared by the Monitoring tabs — timestamps, byte
counts, and the per-agent colour-coded initials avatar.
Extracted from ``ui/monitoring_tab.py`` verbatim (same output for the same
input); the three timestamp formatters used to each repeat their own
``datetime.fromisoformat`` + ``try/except (TypeError, ValueError)`` guard —
that parse step is now a single shared ``_parse_iso`` helper.
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap
from ....i18n import tr
def _parse_iso(ts: str) -> Optional[datetime]:
"""Đọc chuỗi thời gian ISO; sai định dạng thì trả ``None``."""
try:
return datetime.fromisoformat(ts)
except (TypeError, ValueError):
return None
def fmt_bytes(n: float) -> str:
"""Đổi số byte sang chuỗi dễ đọc (B / KB / MB / GB)."""
for unit in ("B", "KB", "MB", "GB"):
if n < 1024:
return f"{n:.0f} {unit}"
n /= 1024
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."""
dt = _parse_iso(ts)
return dt.strftime("%d/%m %H:%M") if dt else ts
_MIDDLE_DOT = chr(0xB7)
def fmt_event_time_full(ts: str) -> str:
""""dd/MM/yyyy [middle dot] HH:mm:ss" — the detail panel's Thoi gian field."""
dt = _parse_iso(ts)
return dt.strftime(f"%d/%m/%Y {_MIDDLE_DOT} %H:%M:%S") if dt else ts
def relative_time(ts: str) -> str:
"""A short "Xm ago"-style string for an audit-log ``ts``; "" if
unparsable."""
dt = _parse_iso(ts)
if dt is None:
return ""
delta = (datetime.now() - dt).total_seconds()
if delta < 60:
return tr("monitoring.time_just_now")
if delta < 3600:
return tr("monitoring.time_minutes_ago", n=int(delta // 60))
if delta < 86400:
return tr("monitoring.time_hours_ago", n=int(delta // 3600))
return tr("monitoring.time_days_ago", n=int(delta // 86400))
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}"
def agent_initials(name: str) -> str:
"""First letter of each word, max 2."""
return "".join(w[0] for w in name.split() if w)[:2].upper()
def agent_avatar_colour(name: str) -> str:
"""A fixed identity colour per agent kind, unchanged by theme."""
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."""
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)
@@ -0,0 +1,27 @@
"""``kv_row`` — the "label — stretch — value" row shape that
``ui/monitoring_tab.py`` used to redefine as 3 byte-identical local closures
(Sandbox Details' ``_kv``, Permissions' ``_pkv``, the detail panel's
``_field``). Overview's Resource Usage row (``_pair``) is a genuinely
different layout (inline on one horizontal line with "·" separators, no
stretch) and is intentionally NOT folded into this helper — unifying it would
risk a visible layout change.
"""
from __future__ import annotations
from typing import Tuple
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout
def kv_row(outer_layout: QVBoxLayout, hint_object_name: str = "hint") -> Tuple[QLabel, QLabel]:
"""Appends a new ``label — stretch — value`` row to ``outer_layout``.
Returns ``(label, value)``."""
row = QHBoxLayout()
label = QLabel()
label.setObjectName(hint_object_name)
value = QLabel()
row.addWidget(label)
row.addStretch(1)
row.addWidget(value)
outer_layout.addLayout(row)
return label, value
@@ -0,0 +1,22 @@
"""Opens the Settings dialog and runs a callback afterward — shared by the
Sandbox Details and Permissions cards' "Edit" buttons, both of which used to
call the identical ``MonitoringTab._open_settings_and_refresh``.
"""
from __future__ import annotations
from typing import Callable
from PySide6.QtWidgets import QWidget
def open_settings_and_notify(ctx, parent: QWidget, on_changed: Callable[[], None]) -> None:
"""Mở hộp thoại Cài đặt rồi gọi ``on_changed`` sau khi đóng.
Các thẻ trong màn Giám sát dùng chung hàm này để làm mới sau khi người dùng
sửa cấu hình an toàn.
"""
from ....ui.settings_dialog import SettingsDialog
dlg = SettingsDialog(ctx, parent)
dlg.exec()
on_changed()
+3
View File
@@ -0,0 +1,3 @@
"""Tám tab của màn Giám sát, mỗi tab một file: Tổng quan, Sandbox, Sự kiện bảo
mật, Lịch sử MCP, Nhật ký hành động, Trạng thái Agent, Quản trị Agent, Công cụ.
"""
@@ -0,0 +1,50 @@
"""Action Logs tab — the full audit log, newest first. Extracted from
``ui/monitoring_tab.py``'s action-table wiring inside
``MonitoringTab.__init__``.
"""
from __future__ import annotations
from typing import Callable, List
from PySide6.QtWidgets import QLineEdit, QPushButton, QWidget
from ....i18n import tr
from ..shared.ai_filter import start_ai_filter
from ..shared.event_table import EventTable
from ..shared.filter_scaffold import build_filter_scaffold
class ActionLogsTab(QWidget):
"""Tab "Nhật ký hành động": bảng mọi thao tác đọc/ghi tệp và chạy lệnh của agent."""
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
"""Tab Nhật ký hành động: bảng sự kiện kèm bộ lọc dùng chung."""
super().__init__()
self._ctx = ctx
self._ai_state: dict = {}
self.table = EventTable()
parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.action_logs_title",
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter)
self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
self.table.set_events(events)
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.table.retranslate()
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
@@ -0,0 +1,154 @@
"""Hộp thoại thêm/sửa một agent trong danh mục quản trị — R08-T08.
Tách khỏi ``agents_admin_tab.py``: bảng danh sách và hộp thoại sửa là hai
việc khác nhau, và hộp thoại còn tự đi hỏi provider xem có những model nào
(``_load_live_models``) — thứ bảng không cần biết.
"""
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,
QHeaderView, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton,
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
)
from ....config import PROVIDER_LABELS
from ....core import admin_agents, preview_ai
from ....core.worker import AgentWorker
from ....i18n import on_language_changed, tr
from ....state import AppContext
from ....ui.icons import icon
from ....ui.widgets import ToggleSwitch, badge_pill_widget
class AgentEditDialog(QDialog):
"""Add/Edit one admin agent. The provider/model pickers are drop-lists,
not free text — ``provider_combo`` offers the app's built-in providers
(plus "machine default"), ``model_combo`` offers that provider's REAL
model list once fetched via "Load models" (same on-demand fetch the
Preview tab and Settings' own "Load" button use) — editable so an admin
can still pin an exact model string that isn't in the fetched list yet."""
def __init__(self, parent=None, ctx: Optional[AppContext] = None,
agent: Optional[admin_agents.AdminAgent] = None,
default_model_hint: str = ""):
"""Form thêm/sửa một agent quản trị.
Danh sách model của từng provider được nạp nền và nhớ lại, để đổi qua đổi
lại giữa các provider không phải gọi mạng lần nữa.
"""
super().__init__(parent)
self.ctx = ctx
self._existing = agent
self._live_models: Dict[str, List[str]] = {}
self._workers: List[AgentWorker] = []
self.setWindowTitle(tr("agents_admin.edit_title") if agent
else tr("agents_admin.add_title"))
self.resize(420, 400)
form = QFormLayout(self)
self.name_edit = QLineEdit(agent.name if agent else "")
form.addRow(tr("agents_admin.f_name"), self.name_edit)
self.kind_combo = QComboBox()
for kind in admin_agents.TASK_KINDS:
self.kind_combo.addItem(tr(f"agents_admin.kind.{kind}"), kind)
if agent:
idx = self.kind_combo.findData(agent.task_kind)
if idx >= 0:
self.kind_combo.setCurrentIndex(idx)
form.addRow(tr("agents_admin.f_kind"), self.kind_combo)
self.prompt_edit = QPlainTextEdit(agent.prompt if agent else "")
self.prompt_edit.setPlaceholderText(tr("agents_admin.f_prompt_placeholder"))
self.prompt_edit.setMaximumHeight(110)
form.addRow(tr("agents_admin.f_prompt"), self.prompt_edit)
self.provider_combo = QComboBox()
self.provider_combo.addItem(tr("agents_admin.provider_default"), _PROVIDER_DEFAULT)
for key, label in PROVIDER_LABELS.items():
self.provider_combo.addItem(label, key)
if agent and agent.provider:
idx = self.provider_combo.findData(agent.provider)
if idx >= 0:
self.provider_combo.setCurrentIndex(idx)
self.provider_combo.currentIndexChanged.connect(self._refresh_model_combo)
form.addRow(tr("agents_admin.f_provider"), self.provider_combo)
model_row = QHBoxLayout()
self.model_combo = QComboBox()
self.model_combo.setEditable(True)
if agent and agent.model:
self.model_combo.addItem(agent.model)
self.model_combo.setEditText(agent.model if agent else "")
self.model_combo.lineEdit().setPlaceholderText(
tr("agents_admin.f_model_placeholder", model=default_model_hint or "—"))
self.load_models_btn = QPushButton()
self.load_models_btn.setIcon(icon("download"))
self.load_models_btn.setToolTip(tr("agents_admin.load_models_tooltip"))
self.load_models_btn.clicked.connect(self._load_live_models)
self.load_models_btn.setEnabled(self.ctx is not None)
model_row.addWidget(self.model_combo, 1)
model_row.addWidget(self.load_models_btn)
form.addRow(tr("agents_admin.f_model"), model_row)
self.enabled_chk = QCheckBox(tr("agents_admin.f_enabled"))
self.enabled_chk.setChecked(agent.enabled if agent else True)
form.addRow("", self.enabled_chk)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
form.addRow(buttons)
def _load_live_models(self) -> None:
"""Nạp danh sách model đang sống của mọi provider, ở luồng nền."""
if self.ctx is None:
return
self.load_models_btn.setEnabled(False)
ctx = self.ctx
def job(_worker: AgentWorker):
"""Chạy nền: hỏi từng provider danh sách model."""
return preview_ai.fetch_live_models(ctx)
def done(result: dict) -> None:
"""Ghi nhớ danh sách model rồi nạp vào ô chọn theo provider đang chọn."""
self.load_models_btn.setEnabled(True)
self._live_models = result or {}
self._refresh_model_combo()
if not self._live_models:
QMessageBox.information(self, tr("agents_admin.add_title"),
tr("agents_admin.load_models_empty"))
def failed(err: str) -> None:
"""Nạp model lỗi: hiện cảnh báo và mở khoá lại nút."""
self.load_models_btn.setEnabled(True)
QMessageBox.warning(self, tr("agents_admin.add_title"), err)
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(failed)
self._workers.append(w)
w.start()
def _refresh_model_combo(self) -> None:
"""Nạp lại ô chọn model theo provider đang chọn, giữ nguyên thứ đang gõ."""
provider_key = self.provider_combo.currentData()
current_text = self.model_combo.currentText().strip()
models = self._live_models.get(provider_key, []) if provider_key else []
self.model_combo.blockSignals(True)
self.model_combo.clear()
self.model_combo.addItems(models)
self.model_combo.setEditText(current_text)
self.model_combo.blockSignals(False)
def result_fields(self) -> Dict[str, str]:
"""Các trường agent lấy từ form, để chỗ gọi dựng bản ghi."""
return {
"name": self.name_edit.text().strip(),
"task_kind": self.kind_combo.currentData(),
"prompt": self.prompt_edit.toPlainText().strip(),
"provider": self.provider_combo.currentData() or "",
"model": self.model_combo.currentText().strip(),
"enabled": self.enabled_chk.isChecked(),
}
@@ -0,0 +1,96 @@
"""Agent Status tab — which agent roles are currently running, read from the
existing ``ChatPanel``/``TaskScheduler``/GraphRAG-ask-worker state (no new
runtime tracking of its own). Extracted from ``ui/monitoring_tab.py``'s
status-table wiring + ``_refresh_agent_status``.
"""
from __future__ import annotations
from typing import Callable, Optional
from PySide6.QtCore import QSize
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
from ....core import agent_roles
from ....i18n import tr
from ....ui.widgets import badge_pill_widget
from ..shared.filter_scaffold import build_filter_scaffold
from ..shared.formatters import agent_avatar_icon
class AgentStatusTab(QWidget):
"""Tab "Trạng thái Agent": đang có bao nhiêu lượt chat, task và luồng chạy."""
def __init__(self, ctx, on_refresh_all: Callable[[], None],
cowork=None, structure=None, task_scheduler=None):
"""Tab Tình trạng agent. Các widget truyền vào có thể là None khi dựng tab lẻ."""
super().__init__()
self._ctx = ctx
self._cowork = cowork
self._structure = structure
self._task_scheduler = task_scheduler
self.table = QTableWidget(0, 3)
self.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.
self.table.setSelectionMode(QTableWidget.NoSelection)
self.table.verticalHeader().setVisible(False)
self.table.horizontalHeader().setStretchLastSection(True)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.table.setColumnWidth(1, 130) # status is a cell widget — size it explicitly
self.table.setIconSize(QSize(20, 20))
parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.agent_status_title", with_search=False, with_detail=False)
self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"]
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.table.setHorizontalHeaderLabels([
tr("monitoring.col_agent"), tr("monitoring.detail_status"), tr("monitoring.col_source"),
])
def refresh(self) -> None:
"""Đếm lại số việc đang chạy ở từng phân hệ và cập nhật bảng."""
cowork_n = len(self._cowork.active_workers()) if self._cowork is not None else 0
task_n = self._task_scheduler.running_count() if self._task_scheduler is not None else 0
ask_worker = getattr(self._structure, "_ask_worker", None)
knowledge_n = 1 if (ask_worker is not None and ask_worker.isRunning()) else 0
# Security is a system-management agent that runs INLINE on the
# active turn (agent_security prompt/command validation) — there is
# no separate worker to count, so its "active" cell shows On/Off
# from Settings instead of a live count.
sec_on = bool(self._ctx.config.agent_security.get("enabled"))
rows = [
(agent_roles.COWORK, cowork_n, tr("monitoring.source_cowork")),
(agent_roles.TASK, task_n, tr("monitoring.source_task")),
(agent_roles.KNOWLEDGE, knowledge_n, tr("monitoring.source_knowledge")),
(agent_roles.PLANNER, None, tr("monitoring.source_planner")),
(agent_roles.REASONING, None, tr("monitoring.source_reasoning")),
(agent_roles.SECURITY, None, tr("monitoring.source_security")),
]
self.table.setRowCount(len(rows))
for row, (role_key, count, source) in enumerate(rows):
label = agent_roles.label_for(role_key)
name_item = QTableWidgetItem(label)
name_item.setIcon(agent_avatar_icon(label))
self.table.setItem(row, 0, name_item)
if role_key == agent_roles.SECURITY:
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:
running = False
status_text = "—"
badge_tone = "badgeSuccess" if running else "badgeNeutral"
self.table.setCellWidget(row, 1, badge_pill_widget(status_text, badge_tone))
self.table.setItem(row, 2, QTableWidgetItem(source))
@@ -0,0 +1,353 @@
"""Agents Admin — Monitoring tab visible to the Admin role ONLY.
CRUD over the shared admin-agent catalog (``core/admin_agents.py``): each
agent has a name, an app function from a fixed droplist (search / monitor /
cowork / graphrag / schedule / security), optional extra instructions and a
model (blank = the machine's Settings model). Saved straight into the shared
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 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.
Màu, huy hiệu và avatar theo loại việc nằm ở
``../shared/agent_kind_visuals.py``. File này từng giữ bản sao riêng của ba
hàm dùng chung — ``_initials``, ``_fmt_updated``, ``_kind_avatar_icon`` —
giống hệt ``shared/formatters.py`` đến từng dòng; nay dùng thẳng bản chung
để định dạng thời gian và avatar không lệch nhau giữa các bảng Giám sát.
"""
from __future__ import annotations
from datetime import datetime
from typing import Dict, List
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import (
QHBoxLayout, QHeaderView, QLabel, QMessageBox, QPushButton,
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
)
from ....config import PROVIDER_LABELS
from ....core import admin_agents
from ....core.worker import AgentWorker
from ....i18n import on_language_changed, tr
from ....state import AppContext
from ....ui.icons import icon
from ....ui.widgets import ToggleSwitch, badge_pill_widget
from .agent_edit_dialog import AgentEditDialog
from ..shared.agent_kind_visuals import KIND_BADGE, STATUS_BADGE, kind_avatar_icon
from ..shared.formatters import fmt_event_time
_PROVIDER_DEFAULT = "" # "" = each machine's own active provider (unchanged default)
class AgentsAdminTab(QWidget):
"""Tab "Quản trị Agent": cấu hình prompt, provider và model cho từng agent chuyên trách, kèm nút kiểm tra tình trạng hoạt động."""
def __init__(self, ctx: AppContext):
"""Dựng bảng agent quản trị.
Kết quả kiểm tra lần gần nhất được giữ trong ``_status`` và sống qua các lần
vẽ lại — người dùng bấm Kiểm tra một lần rồi lọc/sắp xếp vẫn thấy kết quả.
"""
super().__init__()
self.ctx = ctx
# Last operational-health result per agent_id → (ok, message). Populated
# on demand by the "Check" button (see _check_all); survives refresh().
self._status: Dict[str, tuple] = {}
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, 7)
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
# 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)
# 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)
# 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)
# ---- storage ---------------------------------------------------------
def _dir(self):
"""Thư mục chứa agent quản trị (dùng thư mục chia sẻ nếu có cấu hình)."""
return admin_agents.agents_admin_dir(self.ctx.config.shared_dir)
def _default_model_hint(self) -> str:
"""Model mặc định gợi ý cho agent mới — lấy từ provider đang chọn."""
conf = self.ctx.config.provider_conf(self.ctx.config.active_provider)
return conf.get("model", "")
# ---- CRUD -------------------------------------------------------------
def _add(self) -> None:
"""Thêm một agent quản trị mới qua hộp thoại."""
dlg = AgentEditDialog(self, ctx=self.ctx, default_model_hint=self._default_model_hint())
if not dlg.exec():
return
fields = dlg.result_fields()
if not fields["name"]:
return
agent = admin_agents.new_agent(
fields["name"], fields["task_kind"], fields["prompt"],
provider=fields.get("provider", ""), model=fields["model"],
updated_by=(getattr(self.ctx, "account", None).username if getattr(self.ctx, "account", None) else ""))
agent.enabled = bool(fields["enabled"])
admin_agents.save_agent(agent, self._dir())
self.refresh()
def _edit_agent(self, agent_id: str) -> None:
"""Sửa một agent; id không còn tồn tại thì bỏ qua."""
agent = admin_agents.load_agent(agent_id, self._dir())
if agent is None:
return
dlg = AgentEditDialog(self, ctx=self.ctx, agent=agent,
default_model_hint=self._default_model_hint())
if not dlg.exec():
return
fields = dlg.result_fields()
if not fields["name"]:
return
agent.name = fields["name"]
agent.task_kind = fields["task_kind"]
agent.prompt = fields["prompt"]
agent.provider = fields.get("provider", "")
agent.model = fields["model"]
agent.enabled = bool(fields["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()
def _delete_agent(self, agent_id: str) -> None:
"""Xoá một agent sau khi hỏi xác nhận."""
agent = admin_agents.load_agent(agent_id, self._dir())
if agent is None:
return
if QMessageBox.question(
self, tr("agents_admin.delete_title"),
tr("agents_admin.delete_confirm", name=agent.name)) != QMessageBox.Yes:
return
admin_agents.delete_agent(agent.agent_id, self._dir())
self.refresh()
def _set_enabled(self, agent_id: str, enabled: bool) -> None:
"""Bật/tắt một agent; trạng thái không đổi thì không ghi đĩa."""
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:
"""(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 ("unchecked", tr("agents_admin.status_unchecked").lstrip("— ").strip(),
tr("agents_admin.status_unchecked_tip"))
ok, msg = res
if msg == "checking":
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:
"""Dựng ô công tắc bật/tắt cho một dòng trong bảng."""
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:
"""Dựng cụm nút sửa/xoá cho một dòng trong bảng."""
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:
# 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).
"""Vẽ lại bảng agent."""
admin_agents.ensure_help_agent(self._dir())
agents = admin_agents.list_agents(self._dir())
self.table.setRowCount(len(agents))
default_model = self._default_model_hint()
for row, agent in enumerate(agents):
if agent.model:
provider_lbl = PROVIDER_LABELS.get(agent.provider, "") if agent.provider else ""
model = f"{provider_lbl} — {agent.model}" if provider_lbl else agent.model
else:
model = tr("agents_admin.default_model", model=default_model or "—")
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:
status_widget.setToolTip(status_tip)
self.table.setCellWidget(row, 4, status_widget)
self.table.setItem(row, 5, QTableWidgetItem(fmt_event_time(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
update the Status column with the result (🟢 reachable / 🔴 error)."""
agents = admin_agents.list_agents(self._dir())
if not agents:
return
for a in agents:
self._status[a.agent_id] = (False, "checking")
self.check_btn.setEnabled(False)
self.refresh()
ctx = self.ctx
def job(_worker: AgentWorker) -> dict:
"""Chạy nền: gọi thử từng agent để kiểm tra tình trạng hoạt động."""
return {a.agent_id: admin_agents.check_agent(ctx, a) for a in agents}
def done(result: dict) -> None:
"""Ghi kết quả kiểm tra vào bảng trạng thái rồi vẽ lại."""
self.check_btn.setEnabled(True)
self._status.update(result or {})
self.refresh()
def failed(err: str) -> None:
"""Kiểm tra lỗi: đánh dấu mọi agent là hỏng kèm lý do (cắt ở 200 ký tự)."""
self.check_btn.setEnabled(True)
for a in agents:
self._status[a.agent_id] = (False, err[:200])
self.refresh()
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(failed)
self._check_workers.append(w)
w.start()
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề, gợi ý và tên cột."""
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"), "",
])
self.add_btn.setText(tr("agents_admin.add_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()
+50
View File
@@ -0,0 +1,50 @@
"""MCP Call History tab — the audit log filtered to ``kind="mcp_call"``.
Extracted from ``ui/monitoring_tab.py``'s MCP-table wiring inside
``MonitoringTab.__init__``.
"""
from __future__ import annotations
from typing import Callable, List
from PySide6.QtWidgets import QLineEdit, QPushButton, QWidget
from ....i18n import tr
from ..shared.ai_filter import start_ai_filter
from ..shared.event_table import EventTable
from ..shared.filter_scaffold import build_filter_scaffold
class McpTab(QWidget):
"""Tab "Lịch sử MCP": bảng các lượt gọi tool MCP kèm độ trễ và kết quả."""
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
"""Tab Lịch sử MCP: bảng sự kiện kèm bộ lọc dùng chung."""
super().__init__()
self._ctx = ctx
self._ai_state: dict = {}
self.table = EventTable()
parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.mcp_history_title",
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter)
self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
self.table.set_events(events)
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.table.retranslate()
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
@@ -0,0 +1,350 @@
"""Overview tab — the card-based dashboard (Token Usage & Cost, Resource
Usage, Recent Activity, Sandbox Details + nested Permissions, Model Pricing,
Audit Log preview). Extracted from ``ui/monitoring_tab.py``'s
``_build_overview_page`` and the refresh/pricing/budget methods it wires to.
"""
from __future__ import annotations
import time
from typing import Callable, List
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QGridLayout, QGroupBox, QHBoxLayout, QLabel, QProgressBar, QPushButton,
QScrollArea, QVBoxLayout, QWidget,
)
from ....core import usage_tracker as ut
from ....i18n import tr
from ....theme import current_palette
from ....ui.icons import DOT_AMBER, DOT_GREEN, DOT_RED, icon
from ....ui.widgets import BudgetCard, StatCard, fmt_tokens
from ..shared.formatters import fmt_bytes, relative_time
from .pricing_panel import PricingPanel
from .sandbox_tab import SandboxDetailsCard
class OverviewTab(QWidget):
"""Tab "Tổng quan" của màn Giám sát: tài nguyên máy, thẻ token/chi phí, ngân
sách, trạng thái sandbox, hoạt động gần đây và trích nhật ký kiểm toán.
Một cột dọc duy nhất, mỗi phần là một mục có tiêu đề — bố cục này là chủ ý
để mắt quét từ trên xuống thay vì phải nhảy giữa hai cột.
"""
def __init__(self, ctx, *, on_status_message: Callable[[str], None],
on_settings_changed: Callable[[], None],
on_view_all_action_logs: Callable[[], None],
action_logs_tab_visible: bool):
"""Tab Tổng quan.
Mọi việc ra ngoài tab (báo trạng thái, mở màn khác) đều đi qua callback
truyền vào, nên tab này không cần biết cửa sổ chính.
"""
super().__init__()
self.ctx = ctx
self._on_status_message = on_status_message
self._on_view_all_action_logs = on_view_all_action_logs
self._last_io_sample = None
self._res_first = True
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
content = QWidget()
scroll.setWidget(content)
outer.addWidget(scroll)
# ONE main column, scrolled vertically, sections in a fixed order.
root = QVBoxLayout(content)
root.setSpacing(12)
self._build_usage_section(root)
self._build_activity_section() # added to `root` further down, beside the audit log
self._build_resource_section(root)
self.sandbox_card = SandboxDetailsCard(ctx, on_settings_changed)
root.addWidget(self.sandbox_card)
self.pricing_panel = PricingPanel(ctx, on_status_message)
root.addWidget(self.pricing_panel)
root.addWidget(self.activity_group)
self._build_audit_section(root, action_logs_tab_visible)
root.addStretch(1)
# ---- Token Usage & Cost ------------------------------------------------
def _build_usage_section(self, root: QVBoxLayout) -> None:
"""Dựng mục thẻ token/chi phí kèm thẻ ngân sách."""
self.usage_group = QGroupBox()
self.usage_group.setObjectName("monSection")
usage_lay = QGridLayout(self.usage_group)
usage_lay.setSpacing(8)
self.usage_total = StatCard()
self.usage_in = StatCard()
self.usage_out = StatCard()
self.usage_cache = StatCard()
self.usage_cost = StatCard()
self.usage_calls = StatCard()
for i, card in enumerate((self.usage_cost, self.usage_total,
self.usage_in, self.usage_out, self.usage_cache)):
usage_lay.addWidget(card, 0, i)
self.usage_calls.setVisible(False) # rides on the cost tile's label
self.budget_card = BudgetCard()
self.budget_card.apply_btn.setIcon(icon("check"))
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
usage_lay.addWidget(self.budget_card, 0, 3)
for col in range(4):
usage_lay.setColumnStretch(col, 1)
root.addWidget(self.usage_group)
# ---- Recent Activity ----------------------------------------------------
def _build_activity_section(self) -> None:
"""Dựng mục "Hoạt động gần đây" (thêm vào cột ở dưới, cạnh nhật ký kiểm toán)."""
self.activity_group = QGroupBox()
self.activity_group.setObjectName("monSection")
act_lay = QVBoxLayout(self.activity_group)
self.activity_lbl = QLabel()
self.activity_lbl.setWordWrap(True)
self.activity_lbl.setTextFormat(Qt.RichText)
act_lay.addWidget(self.activity_lbl)
# ---- Resource Usage -------------------------------------------------------
def _build_resource_section(self, root: QVBoxLayout) -> None:
"""Dựng mục tài nguyên: CPU, bộ nhớ, đĩa, mạng và trạng thái sandbox."""
self.resource_group = QGroupBox()
self.resource_group.setObjectName("monSection")
res_lay = QHBoxLayout(self.resource_group)
res_lay.setSpacing(6)
def _pair():
"""Thêm một cặp nhãn–giá trị vào hàng tài nguyên, tự chèn dấu phân cách giữa
các cặp.
"""
if not self._res_first:
sep = QLabel(chr(0xB7))
sep.setObjectName("hint")
res_lay.addWidget(sep)
self._res_first = False
lbl = QLabel()
lbl.setObjectName("hint")
val = QLabel()
res_lay.addWidget(lbl)
res_lay.addWidget(val)
return lbl, val
def _bar_row():
"""Thêm một cặp nhãn–giá trị kèm thanh tiến độ (CPU, bộ nhớ)."""
lbl, val = _pair()
bar = QProgressBar()
bar.setVisible(False)
return lbl, bar, val
self.cpu_lbl, self.cpu_bar, self.cpu_val = _bar_row()
self.mem_lbl, self.mem_bar, self.mem_val = _bar_row()
self.diskfree_lbl, self.diskfree_val = _pair()
self.disk_lbl, self.disk_val = QLabel(), QLabel()
self.network_lbl, self.network_val = QLabel(), QLabel()
res_lay.addStretch(1)
root.addWidget(self.resource_group)
# ---- Audit Log preview --------------------------------------------------
def _build_audit_section(self, root: QVBoxLayout, action_logs_tab_visible: bool) -> None:
"""Dựng mục trích nhật ký kiểm toán; ẩn hẳn khi tab Nhật ký hành động bị tắt."""
self.audit_group = QGroupBox()
self.audit_group.setObjectName("monSection")
audit_lay = QVBoxLayout(self.audit_group)
self.audit_lbl = QLabel()
self.audit_lbl.setWordWrap(True)
self.audit_lbl.setTextFormat(Qt.RichText)
audit_lay.addWidget(self.audit_lbl)
self.view_all_btn = QPushButton()
self.view_all_btn.setFlat(True)
self.view_all_btn.clicked.connect(lambda: self._on_view_all_action_logs())
audit_lay.addWidget(self.view_all_btn, 0, Qt.AlignRight)
self.audit_group.setVisible(action_logs_tab_visible)
root.addWidget(self.audit_group)
# ---- budget --------------------------------------------------------------
def _apply_budget(self) -> None:
"""Persist the spin box's value as the new budget — starts a fresh
remaining-balance window (spend before now is no longer counted)."""
ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD")
ut.set_budget(self.ctx.config, self.budget_card.budget_spin.value(), ccy)
self.ctx.save()
self._refresh_budget()
def _refresh_budget(self) -> None:
"""Cập nhật thẻ ngân sách: còn lại / tổng, phần trăm đã dùng, cảnh báo khi vượt 85%.
Không ghi đè ô nhập khi người dùng đang gõ trong đó.
"""
from ....core import model_pricing as mp
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
status = ut.budget_status(self.ctx.config)
if status is None:
self.budget_card.set(tr("usage.budget_title"), "—", tr("usage.budget_no_budget"))
self.budget_card.budget_spin.setValue(0.0)
return
amount_disp = mp.convert(status["amount_usd"], "USD",
pricing.get("currency", "USD"), self.ctx.config)
value = (f"{ut.format_cost(status['remaining_usd'], pricing, digits=2)}"
f" / {ut.format_cost(status['amount_usd'], pricing, digits=2)}")
pct = int(round(status["pct_used"] * 100))
sub = tr("usage.budget_over_warning") if status["over_85"] else tr("usage.budget_used_pct", pct=pct)
self.budget_card.set(tr("usage.budget_title"), value, sub, warn=status["over_85"])
if not self.budget_card.budget_spin.hasFocus():
self.budget_card.budget_spin.setValue(round(amount_disp, 2))
# ---- resource usage --------------------------------------------------------
def _refresh_resource_usage(self) -> None:
"""Đọc CPU/RAM/đĩa/mạng của CHÍNH tiến trình app.
Đĩa và mạng là tốc độ tức thời, tính bằng hiệu hai lần lấy mẫu chia cho
khoảng thời gian giữa chúng — nên lần đo đầu tiên luôn hiện "—". Không có
``psutil`` thì cả mục chuyển sang "—" chứ không làm vỡ màn hình.
"""
try:
import psutil
except ImportError:
self._set_resource_na()
return
try:
own = psutil.Process()
own_cpu = own.cpu_percent(interval=None)
own_mem = own.memory_info().rss
except Exception:
own, own_cpu, own_mem = None, 0.0, 0
self.cpu_bar.setValue(int(min(own_cpu, 100)))
self.cpu_val.setText(f"{own_cpu:.0f}%")
try:
total_mem = psutil.virtual_memory().total
mem_pct = int(own_mem * 100 / total_mem) if total_mem else 0
except Exception:
mem_pct = 0
self.mem_bar.setValue(min(mem_pct, 100))
try:
self.mem_val.setText(f"{fmt_bytes(own_mem)}/{fmt_bytes(total_mem)}")
except Exception: # noqa: BLE001
self.mem_val.setText(fmt_bytes(own_mem))
try:
free = psutil.disk_usage(str(self.ctx.config.cowork_output_dir())).free
self.diskfree_val.setText(tr("monitoring.overview_disk_free", size=fmt_bytes(free)))
except Exception: # noqa: BLE001
self.diskfree_val.setText(tr("monitoring.na"))
now = time.monotonic()
try:
io = own.io_counters() if own is not None else None
disk_bytes = (io.read_bytes + io.write_bytes) if io is not None else None
except Exception:
disk_bytes = None
try:
net = psutil.net_io_counters()
net_bytes = net.bytes_sent + net.bytes_recv
except Exception:
net_bytes = None
prev = self._last_io_sample
self._last_io_sample = (now, disk_bytes, net_bytes)
na = tr("monitoring.na")
if prev and disk_bytes is not None and prev[1] is not None and now > prev[0]:
rate = max(0.0, (disk_bytes - prev[1]) / (now - prev[0]))
self.disk_val.setText(f"{fmt_bytes(rate)}/s")
else:
self.disk_val.setText(na)
if prev and net_bytes is not None and prev[2] is not None and now > prev[0]:
rate = max(0.0, (net_bytes - prev[2]) / (now - prev[0]))
self.network_val.setText(f"{fmt_bytes(rate)}/s")
else:
self.network_val.setText(na)
def _set_resource_na(self) -> None:
"""Đặt mọi chỉ số tài nguyên về "—" (khi thiếu ``psutil``)."""
na = tr("monitoring.na")
self.cpu_bar.setValue(0)
self.cpu_val.setText(na)
self.mem_bar.setValue(0)
self.mem_val.setText(na)
self.disk_val.setText(na)
self.network_val.setText(na)
# ---- usage cards -----------------------------------------------------------
def _activity_line(self, event: dict) -> str:
"""Một dòng hoạt động: dấu ✓/!/✗ theo kết quả, tên việc, và thời gian tương đối."""
ok = event.get("ok", True)
if ok:
mark = f"<span style='color:{DOT_GREEN};'>✓</span>"
elif event.get("kind") == "security_block":
mark = f"<span style='color:{DOT_AMBER};'>!</span>"
else:
mark = f"<span style='color:{DOT_RED};'>✗</span>"
name = event.get("name", "") or event.get("kind", "")
rel = relative_time(event.get("ts", ""))
muted = current_palette().text_muted
suffix = f" <span style='color:{muted};'>— {rel}</span>" if rel else ""
return f"{mark} {name}{suffix}"
def _refresh_usage_cards(self) -> None:
"""Cập nhật các thẻ token và chi phí.
Đồng bộ bảng đơn giá trước rồi mới tính tiền, để con số chi phí luôn khớp
với bảng giá người dùng vừa sửa.
"""
from ....core import model_pricing as mp
mp.sync_to_usage(self.ctx.config) # cost total comes straight from the price table
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
events = ut.load_events()
s = ut.summarize(events)
costs = ut.cost_usd_events(events, pricing)
self.usage_total.set(tr("dashboard.card_total"), fmt_tokens(s["total"]))
self.usage_calls.set(tr("monitoring.overview_calls"), str(s["turns"]), "")
self.usage_in.set(tr("dashboard.card_in"), fmt_tokens(s["in"]),
ut.format_cost(costs["in"], pricing))
self.usage_out.set(tr("dashboard.card_out"), fmt_tokens(s["out"]),
ut.format_cost(costs["out"], pricing))
self.usage_cache.set(tr("dashboard.card_cache"), fmt_tokens(s["cache"]),
ut.format_cost(costs["cache"], pricing))
self.usage_cost.set(
f'{tr("dashboard.card_cost")} · {s["turns"]} {tr("monitoring.overview_calls")}',
ut.format_cost(sum(costs.values()), pricing, digits=2))
self._refresh_budget()
# ---- public API used by the container ---------------------------------
def refresh(self, events: List[dict]) -> None:
"""Full refresh — resource usage + usage cards + sandbox/permissions
+ recent activity + audit preview. ``events`` is the already-loaded
(local-or-shared) audit log, shared with the event-table tabs so the
decision of which source to read from is made exactly once per
refresh tick."""
self._refresh_resource_usage()
self._refresh_usage_cards()
self.sandbox_card.refresh()
recent = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)
if recent:
self.activity_lbl.setText("<br>".join(self._activity_line(e) for e in recent[:6]))
self.audit_lbl.setText("<br>".join(
f"{e.get('ts', '')} — {e.get('name', '')}" for e in recent[:4]))
else:
self.activity_lbl.setText(tr("monitoring.overview_no_activity"))
self.audit_lbl.setText(tr("monitoring.overview_no_activity"))
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho mọi tiêu đề mục và nhãn."""
self.usage_group.setTitle(tr("monitoring.overview_usage_title").upper().replace("&", "&&"))
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
self.activity_group.setTitle(tr("monitoring.overview_activity_title").upper())
self.resource_group.setTitle(tr("monitoring.overview_resource_title").upper())
self.cpu_lbl.setText(tr("monitoring.overview_res_cpu"))
self.mem_lbl.setText(tr("monitoring.overview_res_mem"))
self.diskfree_lbl.setText(tr("monitoring.overview_disk_label"))
self.disk_lbl.setText(tr("monitoring.overview_res_disk"))
self.network_lbl.setText(tr("monitoring.overview_res_network"))
self.pricing_panel.retranslate()
self.sandbox_card.retranslate()
self.audit_group.setTitle(tr("monitoring.overview_audit_title").upper())
self.view_all_btn.setText(tr("monitoring.overview_view_all"))
@@ -0,0 +1,198 @@
"""Model Pricing panel — the editable price-table card on the Overview page
(currency picker, import/export/add/auto-link/delete, and the table itself).
Extracted from ``ui/monitoring_tab.py``'s pricing-table construction and
``_reload_pricing_table``/``_import_pricing``/``_export_pricing``/
``_add_pricing_row``/``_autolink_pricing``/``_delete_pricing_row``.
"""
from __future__ import annotations
from typing import Callable
from PySide6.QtWidgets import (
QComboBox, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QPushButton,
QTableWidget, QTableWidgetItem, QVBoxLayout,
)
from ....core import usage_tracker as ut
from ....i18n import tr
from ....ui.icons import icon
class PricingPanel(QGroupBox):
"""Bảng đơn giá model trong màn Giám sát: nhập, xuất, tự dò và sửa tay.
Đơn giá ở đây là thứ Dashboard dùng để quy token ra tiền, nên sửa ở đây là
mọi con số chi phí trong app đổi theo.
"""
def __init__(self, ctx, on_status_message: Callable[[str], None]):
"""Bảng đơn giá theo model, sửa được ngay tại chỗ."""
super().__init__()
self.ctx = ctx
self._on_status_message = on_status_message
self._worker = None
self.setObjectName("monSection")
pg = QVBoxLayout(self)
phdr = QHBoxLayout()
self.ccy_lbl = QLabel()
self.ccy_lbl.setObjectName("hint")
self.ccy = QComboBox()
for cur in ut.SUPPORTED_CURRENCIES:
self.ccy.addItem(cur, cur)
pidx = self.ccy.findData((self.ctx.config.data.get("usage") or {}).get("currency", "USD"))
self.ccy.setCurrentIndex(max(0, pidx))
self.ccy.currentIndexChanged.connect(self._reload_table)
phdr.addWidget(self.ccy_lbl)
phdr.addWidget(self.ccy)
phdr.addStretch(1)
self.import_btn = QPushButton()
self.import_btn.setIcon(icon("download"))
self.import_btn.clicked.connect(self._import_pricing)
self.export_btn = QPushButton()
self.export_btn.setIcon(icon("upload"))
self.export_btn.clicked.connect(self._export_pricing)
self.add_btn = QPushButton()
self.add_btn.setIcon(icon("plus"))
self.add_btn.clicked.connect(self._add_pricing_row)
self.link_btn = QPushButton()
self.link_btn.setIcon(icon("refresh"))
self.link_btn.clicked.connect(self._autolink_pricing)
self.del_btn = QPushButton()
self.del_btn.setIcon(icon("trash"))
self.del_btn.clicked.connect(self._delete_pricing_row)
for b in (self.import_btn, self.export_btn, self.add_btn, self.link_btn, self.del_btn):
phdr.addWidget(b)
pg.addLayout(phdr)
self.table = QTableWidget(0, 5)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table.verticalHeader().setVisible(False)
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
pg.addWidget(self.table, 1)
self._reload_table()
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề, nút và tên cột."""
self.setTitle(tr("monitoring.pricing_title").upper())
self.ccy_lbl.setText(tr("monitoring.pricing_currency"))
self.import_btn.setText(tr("monitoring.pricing_import"))
self.export_btn.setText(tr("monitoring.pricing_export"))
self.add_btn.setText(tr("monitoring.pricing_add"))
self.link_btn.setText(tr("monitoring.pricing_autolink"))
self.del_btn.setText(tr("monitoring.pricing_delete"))
self.table.setHorizontalHeaderLabels([
tr("monitoring.pricing_col_model"), tr("monitoring.pricing_col_context"),
tr("monitoring.pricing_col_maxout"), tr("monitoring.pricing_col_input"),
tr("monitoring.pricing_col_output")])
def _reload_table(self, *_a) -> None:
"""Nạp lại toàn bộ bảng đơn giá từ cấu hình."""
from ....core import model_pricing as mp
to_ccy = self.ccy.currentData() or "USD"
entries = mp.list_entries(self.ctx.config)
self.table.setRowCount(len(entries))
for r, e in enumerate(entries):
in_v = mp.convert(e.get("input_price", 0), e.get("input_ccy", "USD"), to_ccy, self.ctx.config)
out_v = mp.convert(e.get("output_price", 0), e.get("output_ccy", "USD"), to_ccy, self.ctx.config)
vals = [e.get("model", ""), e.get("context_length", ""), e.get("max_output", ""),
f"{mp.format_price(in_v, to_ccy)} / {e.get('input_unit', '')}",
f"{mp.format_price(out_v, to_ccy)} / {e.get('output_unit', '')}"]
for c, v in enumerate(vals):
self.table.setItem(r, c, QTableWidgetItem(str(v)))
def _import_pricing(self) -> None:
"""Nhập đơn giá từ file Excel/CSV, gộp vào bảng hiện có."""
from PySide6.QtWidgets import QFileDialog, QMessageBox
from ....core import model_pricing as mp
path, _ = QFileDialog.getOpenFileName(
self, tr("monitoring.pricing_import"), "", "Pricing (*.xlsx *.csv)")
if not path:
return
default_ccy = self.ccy.currentData() or "USD"
try:
imported = mp.import_table(path, default_ccy=default_ccy)
except ValueError as exc:
QMessageBox.warning(self, tr("monitoring.pricing_title"), str(exc))
return
merged = {e["model"]: e for e in mp.list_entries(self.ctx.config)}
for e in imported:
merged[e["model"]] = e
mp.save_entries(self.ctx.config, list(merged.values()))
self.ctx.save()
self._reload_table()
self._on_status_message(tr("monitoring.pricing_imported", n=len(imported)))
def _export_pricing(self) -> None:
"""Xuất file mẫu để điền đơn giá rồi nhập ngược lại."""
from PySide6.QtWidgets import QFileDialog
from ....core import model_pricing as mp
path, _ = QFileDialog.getSaveFileName(
self, tr("monitoring.pricing_export"), "model_pricing_template.xlsx", "Excel (*.xlsx)")
if not path:
return
mp.export_template(path)
self._on_status_message(tr("monitoring.pricing_exported"))
def _add_pricing_row(self) -> None:
"""Thêm một dòng đơn giá trống để người dùng điền tay."""
from PySide6.QtWidgets import QInputDialog
from ....core import model_pricing as mp
name, ok = QInputDialog.getText(self, tr("monitoring.pricing_add"),
tr("monitoring.pricing_add_prompt"))
name = (name or "").strip()
if not ok or not name:
return
ccy = self.ccy.currentData() or "USD"
mp.add_entry(self.ctx.config, mp.entry_from_row(
[name, "", "", "0", "Million tokens", "0", "Million tokens"], default_ccy=ccy))
self.ctx.save()
self._reload_table()
def _autolink_pricing(self) -> None:
"""Nhờ AI dò đơn giá cho các model đang dùng mà chưa có giá, chạy ở luồng nền."""
from ....core import model_pricing as mp
from ....core.worker import AgentWorker
if self._worker is not None:
return
self.link_btn.setEnabled(False)
ctx = self.ctx
ccy = self.ccy.currentData() or "USD"
def job(_w):
"""Chạy nền: nhờ AI dò đơn giá cho các model đang dùng mà chưa có giá."""
return {"entries": mp.auto_link(ctx, default_ccy=ccy)}
def done(r):
"""Đổ đơn giá vừa dò được vào bảng và lưu lại."""
self._worker = None
self.link_btn.setEnabled(True)
self.ctx.save()
self._reload_table()
self._on_status_message(tr("monitoring.pricing_linked", n=len(r.get("entries", []))))
def failed(_e):
"""Dò giá lỗi: chỉ mở khoá lại nút, giữ nguyên bảng đang có."""
self._worker = None
self.link_btn.setEnabled(True)
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(failed)
self._worker = w
w.start()
def _delete_pricing_row(self) -> None:
"""Xoá dòng đơn giá đang chọn."""
from ....core import model_pricing as mp
row = self.table.currentRow()
entries = mp.list_entries(self.ctx.config)
if 0 <= row < len(entries):
del entries[row]
mp.save_entries(self.ctx.config, entries)
self.ctx.save()
self._reload_table()
+142
View File
@@ -0,0 +1,142 @@
"""Sandbox Details card — the current sandbox id/status/uptime/resource
limits/network state, with a collapsible fold that ALSO nests the
Permissions card inside it (see ``security_settings_tab.PermissionsCard``),
exactly matching the pre-refactor ``ui/monitoring_tab.py`` layout: Sandbox
and Permissions answer the same question ("what is the agent allowed to
touch?"), so they share one fold rather than being two independent
top-level sections. This card is embedded inside ``overview_tab.OverviewTab``
at the same position the original ``QGroupBox`` occupied — no new top-level
tab is added, so the visible UI is unchanged.
"""
from __future__ import annotations
import os
import time
from datetime import datetime
from typing import Callable
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from ....i18n import tr
from ..shared.badges import apply_badge
from ..shared.layout_helpers import kv_row
from ..shared.open_settings import open_settings_and_notify
from .security_settings_tab import PermissionsCard
class SandboxDetailsCard(QGroupBox):
"""Thẻ "Trạng thái sandbox": backend cách ly đang dùng, giới hạn tài nguyên và
trạng thái chặn mạng.
"""
def __init__(self, ctx, on_settings_changed: Callable[[], None]):
"""Thẻ thông tin sandbox: cơ chế cách ly đang dùng và các giới hạn của nó."""
super().__init__()
self._ctx = ctx
self._on_settings_changed = on_settings_changed
self.setObjectName("monSection")
sbx_lay = QVBoxLayout(self)
self.summary_lbl = QLabel()
self.summary_lbl.setWordWrap(True)
sbx_lay.addWidget(self.summary_lbl)
self.more_btn = QPushButton()
self.more_btn.setObjectName("co4eSectionAction")
self.more_btn.setFlat(True)
self.more_btn.setCheckable(True)
self.more_btn.setCursor(Qt.PointingHandCursor)
sbx_lay.addWidget(self.more_btn, 0, Qt.AlignLeft)
self._detail = QWidget()
self._detail.setVisible(False)
self.more_btn.toggled.connect(self._detail.setVisible)
self.more_btn.toggled.connect(self._sync_more_label)
sbx_lay.addWidget(self._detail)
detail_lay = QVBoxLayout(self._detail)
detail_lay.setContentsMargins(0, 4, 0, 0)
self.id_lbl, self.id_val = kv_row(detail_lay)
self.status_lbl, self.status_val = kv_row(detail_lay)
self.status_val.setObjectName("badgeSuccess")
self.created_lbl, self.created_val = kv_row(detail_lay)
self.uptime_lbl, self.uptime_val = kv_row(detail_lay)
limits_row = QHBoxLayout()
self.limits_lbl = QLabel()
self.limits_lbl.setObjectName("hint")
self.limits_lbl.setWordWrap(True)
self.edit_btn = QPushButton()
self.edit_btn.setFlat(True)
self.edit_btn.clicked.connect(self._open_settings)
limits_row.addWidget(self.limits_lbl, 1)
limits_row.addWidget(self.edit_btn)
detail_lay.addLayout(limits_row)
self.net_lbl, self.net_val = kv_row(detail_lay)
# The Permissions card is nested inside THIS fold, not a sibling
# section — matches the original layout exactly.
self.permissions_card = PermissionsCard(ctx, on_settings_changed)
detail_lay.addWidget(self.permissions_card)
def _open_settings(self) -> None:
"""Mở hộp thoại Cài đặt tới nhóm an toàn, đóng xong thì làm mới thẻ."""
open_settings_and_notify(self._ctx, self, self._on_settings_changed)
def _sync_more_label(self, *_a) -> None:
"""Label the fold with what it will do next."""
open_ = self.more_btn.isChecked()
self.more_btn.setText(("▾ " if open_ else "▸ ") + tr("monitoring.overview_sbx_detail"))
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.setTitle(tr("monitoring.overview_sandbox_details_title").upper().replace("&", "&&"))
self.id_lbl.setText(tr("monitoring.overview_sandbox_id"))
self.status_lbl.setText(tr("monitoring.overview_status"))
self.created_lbl.setText(tr("monitoring.overview_created"))
self.uptime_lbl.setText(tr("monitoring.overview_uptime"))
self.edit_btn.setText(tr("monitoring.overview_edit"))
self.net_lbl.setText(tr("monitoring.overview_network_label"))
self.permissions_card.retranslate()
self._sync_more_label()
def refresh(self) -> None:
"""Đọc lại cấu hình an toàn và cập nhật các dòng trạng thái."""
sec = self._ctx.config.agent_security
net_blocked = bool(sec.get("block_network"))
self.id_val.setText(f"sbx_{os.getpid():x}")
self.status_val.setText(tr("monitoring.overview_status_running"))
self.created_val.setText(datetime.fromtimestamp(self._ctx.started_at).strftime("%H:%M:%S"))
uptime_s = max(0, int(time.time() - self._ctx.started_at))
h, rem = divmod(uptime_s, 3600)
m, s = divmod(rem, 60)
self.uptime_val.setText(f"{h}h {m}m {s}s" if h else f"{m}m {s}s")
limit_parts = []
if sec.get("resource_limit_cpu_percent"):
limit_parts.append(f"CPU {sec['resource_limit_cpu_percent']}%")
if sec.get("resource_limit_memory_mb"):
limit_parts.append(f"MEM {sec['resource_limit_memory_mb']}MB")
if sec.get("resource_limit_disk_mb"):
limit_parts.append(f"DISK {sec['resource_limit_disk_mb']}MB")
limits_text = ", ".join(limit_parts) if limit_parts else tr("monitoring.na")
self.limits_lbl.setText(tr("monitoring.overview_resource_limits") + ": " + limits_text)
self.net_val.setText(
tr("monitoring.overview_network_disabled") if net_blocked
else tr("monitoring.overview_network_enabled"))
apply_badge(self.net_val, "badgeWarn" if net_blocked else "badgeSuccess")
# The one line the wireframe shows; the detail above stays a fold away.
self.summary_lbl.setText(" · ".join([
f'{tr("monitoring.overview_perm_fs")}: {tr("monitoring.overview_perm_fs_value")}',
f'{tr("monitoring.overview_perm_network")}: '
f'{tr("monitoring.overview_network_disabled") if net_blocked else tr("monitoring.overview_network_enabled")}',
f'{tr("monitoring.overview_perm_process")}: {tr("monitoring.overview_perm_process_value")}',
f'{tr("monitoring.overview_resource_limits")}: {limits_text}',
]))
self._sync_more_label()
self.permissions_card.refresh()
@@ -0,0 +1,56 @@
"""Security Events tab — the audit log filtered to ``kind="security_block"``.
Extracted from ``ui/monitoring_tab.py``'s security-events wiring inside
``MonitoringTab.__init__``.
"""
from __future__ import annotations
from typing import Callable, List
from PySide6.QtWidgets import QLineEdit, QPushButton, QWidget
from ....i18n import tr
from ..shared.ai_filter import start_ai_filter
from ..shared.event_table import EventTable
from ..shared.filter_scaffold import build_filter_scaffold
class SecurityEventsTab(QWidget):
"""Tab "Sự kiện bảo mật": các lượt bị chặn hoặc phải xin phê duyệt."""
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
"""Tab Sự kiện bảo mật.
Bỏ cột Kết quả (mọi dòng đều là bị chặn) và thay bằng cột Hành động có tô
màu theo mức nghiêm trọng.
"""
super().__init__()
self._ctx = ctx
self._ai_state: dict = {}
# Security events are always ok=False, so this table trades the
# tick/cross column for a tinted Action column (see EventTable).
self.table = EventTable(show_result=False)
parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.security_events_title",
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter)
self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
self.table.set_events(events)
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.table.retranslate()
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
@@ -0,0 +1,64 @@
"""Permissions card — "what is the agent allowed to touch?", displayed
nested inside the Sandbox Details card's expandable fold (see
``sandbox_tab.SandboxDetailsCard``), exactly as in the pre-refactor
``ui/monitoring_tab.py`` (``self._sbx_detail.layout().addWidget(self.ov_permissions_group)``).
Editing still opens the same Settings dialog as the Sandbox card's own
"Edit" button — this card only DISPLAYS ``ctx.config.agent_security``, it
does not host its own settings-editing UI.
"""
from __future__ import annotations
from typing import Callable
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QGroupBox, QPushButton, QVBoxLayout
from ....i18n import tr
from ..shared.badges import apply_badge
from ..shared.layout_helpers import kv_row
from ..shared.open_settings import open_settings_and_notify
class PermissionsCard(QGroupBox):
"""Thẻ "Quyền": agent đang được phép làm gì với tệp, mạng và lệnh shell."""
def __init__(self, ctx, on_settings_changed: Callable[[], None]):
"""Thẻ thiết lập quyền: agent được đụng tới gì mà không phải hỏi."""
super().__init__()
self._ctx = ctx
self._on_settings_changed = on_settings_changed
self.setObjectName("monSection")
perm_lay = QVBoxLayout(self)
self.fs_lbl, self.fs_val = kv_row(perm_lay)
self.network_lbl, self.network_val = kv_row(perm_lay)
self.process_lbl, self.process_val = kv_row(perm_lay)
self.env_lbl, self.env_val = kv_row(perm_lay)
self.edit_btn = QPushButton()
self.edit_btn.setFlat(True)
self.edit_btn.clicked.connect(self._open_settings)
perm_lay.addWidget(self.edit_btn, 0, Qt.AlignLeft)
def _open_settings(self) -> None:
"""Mở hộp thoại Cài đặt tới nhóm an toàn, đóng xong thì làm mới thẻ."""
open_settings_and_notify(self._ctx, self, self._on_settings_changed)
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.setTitle(tr("monitoring.overview_permissions_title").upper())
self.fs_lbl.setText(tr("monitoring.overview_perm_fs"))
self.fs_val.setText(tr("monitoring.overview_perm_fs_value"))
self.network_lbl.setText(tr("monitoring.overview_perm_network"))
self.process_lbl.setText(tr("monitoring.overview_perm_process"))
self.process_val.setText(tr("monitoring.overview_perm_process_value"))
self.env_lbl.setText(tr("monitoring.overview_perm_env"))
self.env_val.setText(tr("monitoring.overview_perm_env_value"))
self.edit_btn.setText(tr("monitoring.overview_edit"))
def refresh(self) -> None:
"""Đọc lại cấu hình an toàn và cập nhật các dòng quyền."""
net_blocked = bool(self._ctx.config.agent_security.get("block_network"))
self.network_val.setText(
tr("monitoring.overview_perm_network_blocked") if net_blocked
else tr("monitoring.overview_perm_network_allowed"))
apply_badge(self.network_val, "badgeWarn" if net_blocked else "badgeSuccess")
@@ -0,0 +1,260 @@
"""Tools — Monitoring tab (Admin) to govern every agent capability.
Two sub-tabs:
* "Tool" — built-in agent tools (read/write/edit files, run commands,
install packages, fetch URLs) as a left-aligned card grid;
toggling one OFF removes it from the agent's toolset
(persisted in ``config.tools_disabled``).
* "Connector" — the full Connectors (MCP / REST API) setup, moved here from
Settings: add/edit/delete CAD/CAE/MS365/Other connectors and
enable/disable each (``ConnectorsPanel``).
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QPainter, QPixmap
from PySide6.QtWidgets import (
QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTabWidget,
QVBoxLayout, QWidget,
)
from ....core.tools import TOOL_SPECS
from ....core.worker import AgentWorker
from ....i18n import on_language_changed, tr
from ....state import AppContext
from ....ui.connectors_panel import ConnectorsPanel
from ....ui.icons import icon
from ....ui.widgets import FlowLayout, ToggleSwitch, enable_height_for_width, style_card
# Identity colour + icon per built-in tool — same "fixed colour regardless of
# theme" convention as monitoring_tab.py's agent avatars / agents_admin_tab.py's
# kind avatars, grouped by what the tool actually touches (file i/o, shell,
# packages, network, Jira).
_TOOL_COLOUR = {
"read_file": "#0078D4", "list_dir": "#0078D4", "write_file": "#0078D4",
"edit_file": "#0078D4", "run_command": "#107C10", "install_package": "#8764B8",
"fetch_url": "#FFB900", "jira_search": "#8764B8", "jira_get_issue": "#8764B8",
}
_TOOL_ICON_NAME = {
"read_file": "document", "list_dir": "folder", "write_file": "new",
"edit_file": "edit", "run_command": "terminal", "install_package": "download",
"fetch_url": "globe", "jira_search": "search", "jira_get_issue": "link",
}
def _tool_icon_pixmap(name: str, size: int = 28) -> QPixmap:
"""Vẽ icon tròn cho một thẻ công cụ."""
pm = QPixmap(size, size)
pm.fill(Qt.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
p.setBrush(QColor(_TOOL_COLOUR.get(name, "#0078D4")))
r = size * 0.28
p.drawRoundedRect(0, 0, size, size, r, r)
inner = int(size * 0.58)
glyph = icon(_TOOL_ICON_NAME.get(name, "puzzle"), size=inner, color="#FFFFFF").pixmap(inner, inner)
p.drawPixmap((size - inner) // 2, (size - inner) // 2, glyph)
p.end()
return pm
def _clear_flow(flow: FlowLayout) -> None:
"""Xoá sạch mọi widget trong một ``FlowLayout`` trước khi dựng lại."""
while flow.count():
item = flow.takeAt(0)
w = item.widget()
if w is not None:
w.deleteLater()
class ToolsAdminTab(QWidget):
"""Tab "Công cụ" trong màn Giám sát: bật/tắt từng tool dựng sẵn và tool MCP.
Tắt một tool ở đây là agent không còn được gọi nó nữa — danh sách tắt lưu
trong cấu hình dưới dạng ``tools_disabled``.
"""
def __init__(self, ctx: AppContext):
"""Tab quản trị tool, chia thành các tab con theo nhóm tool."""
super().__init__()
self.ctx = ctx
root = QVBoxLayout(self)
self.subtabs = QTabWidget()
root.addWidget(self.subtabs, 1)
# ---- "Tool" sub-tab: built-in agent tools ------------------------
tool_page = QWidget()
tl = QVBoxLayout(tool_page)
self._net_worker = None
self._hint = QLabel()
self._hint.setObjectName("hint")
self._hint.setWordWrap(True)
tl.addWidget(self._hint)
# A left-aligned, wrapping card grid — one card per built-in tool
# (colour-coded icon + name + toggle switch + description), replacing
# the old flat Name/Description/Enabled table.
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
cards_host = QWidget()
self._tool_flow = FlowLayout(cards_host, margin=0, h_spacing=10, v_spacing=10)
scroll.setWidget(cards_host)
tl.addWidget(scroll, 1)
# "Test Internet" self-test lives INSIDE the fetch_url tool's card now
# (see refresh) instead of a separate boxed section — persistent
# widgets so they survive card rebuilds.
self.test_internet_btn = QPushButton(tr("settings.test_internet"))
self.test_internet_btn.setIcon(icon("globe"))
self.test_internet_btn.setToolTip(tr("settings.test_internet_tooltip"))
self.test_internet_btn.clicked.connect(self._test_internet)
self.test_internet_status = QLabel("")
self.test_internet_status.setWordWrap(True)
btn_row = QHBoxLayout()
self.refresh_btn = QPushButton()
self.refresh_btn.clicked.connect(self.refresh)
btn_row.addStretch(1)
btn_row.addWidget(self.refresh_btn)
tl.addLayout(btn_row)
# Jira CONNECTION setup lives in the Connector sub-tab now; here the Tool
# list just lets the admin turn the jira_* tools on/off. A pointer note:
self.jira_note = QLabel()
self.jira_note.setObjectName("hint")
self.jira_note.setWordWrap(True)
tl.addWidget(self.jira_note)
self.subtabs.addTab(tool_page, "")
# ---- "Connector" sub-tab: MCP / REST API setup (moved from Settings) --
self.connectors_panel = ConnectorsPanel(ctx)
self.subtabs.addTab(self.connectors_panel, "")
# on_language_changed() already invokes _retranslate() once immediately
# (see i18n.py) — a second explicit call here double-populates the
# card grid back-to-back with no event-loop turn in between, so the
# first pass's cards are only queued for deleteLater() (not yet gone)
# when the second pass adds new ones on top (see connectors_panel.py's
# ConnectorsPanel, which hit the exact same bug this same way).
on_language_changed(self._retranslate)
# ---- built-in tools card grid ---------------------------------------------
def refresh(self) -> None:
"""Vẽ lại toàn bộ lưới thẻ công cụ theo cấu hình mới nhất."""
disabled = set(self.ctx.config.tools_disabled)
_clear_flow(self._tool_flow)
for spec in TOOL_SPECS:
self._tool_flow.addWidget(self._tool_card(spec, spec.name not in disabled))
def _tool_card(self, spec, enabled: bool) -> QWidget:
"""Dựng một thẻ công cụ: icon, tên, mô tả và công tắc bật/tắt."""
card = QFrame()
card.setFrameShape(QFrame.NoFrame)
style_card(card)
card.setFixedWidth(220)
# The description below wraps to a variable number of lines at this
# fixed width, so the card's own height depends on its width — without
# this, the outer FlowLayout's QWidgetItem queries card.sizePolicy()
# (not the description label's), gets a too-short sizeHint, and
# squeezes the card into less height than its QVBoxLayout needs,
# which is what overlapped the header onto the description text.
enable_height_for_width(card)
lay = QVBoxLayout(card)
lay.setContentsMargins(10, 8, 10, 8)
lay.setSpacing(4)
hdr = QHBoxLayout()
icon_lbl = QLabel()
icon_lbl.setPixmap(_tool_icon_pixmap(spec.name))
icon_lbl.setStyleSheet("border: none;")
hdr.addWidget(icon_lbl)
name_lbl = QLabel(spec.name)
name_lbl.setStyleSheet("font-weight:700; border: none;")
hdr.addWidget(name_lbl)
hdr.addStretch(1)
sw = ToggleSwitch()
sw.setChecked(enabled)
sw.toggled.connect(lambda on, n=spec.name: self._toggle_builtin(n, on))
hdr.addWidget(sw)
lay.addLayout(hdr)
desc = QLabel(spec.description)
desc.setWordWrap(True)
desc.setToolTip(spec.description)
desc.setObjectName("hint")
desc.setStyleSheet("border: none;")
lay.addWidget(desc)
if spec.name == "fetch_url":
# The live "Test Internet" self-test lives inside fetch_url's own
# card — it tests THIS capability, not the tab as a whole.
net = QWidget()
net.setStyleSheet("border: none;")
nl = QHBoxLayout(net)
nl.setContentsMargins(0, 2, 0, 0)
nl.addWidget(self.test_internet_btn)
nl.addWidget(self.test_internet_status, 1)
lay.addWidget(net)
return card
def _toggle_builtin(self, name: str, enabled: bool) -> None:
"""Bật/tắt một tool dựng sẵn và ghi ngay vào cấu hình."""
self.ctx.config.set_tool_enabled(name, enabled)
# For fetch_url, the Enabled toggle also governs the runtime web-access
# gate (agent_security.allow_url_fetch) — one control for the capability.
if name == "fetch_url":
self.ctx.config.agent_security["allow_url_fetch"] = bool(enabled)
self.ctx.config.save()
def _test_internet(self) -> None:
"""Live-check the app's own outbound HTTPS path and report the concrete
result. Respects the fetch_url toggle: when web access is OFF the agent
cannot reach the internet, so the test reports that instead of probing."""
disabled = ("fetch_url" in self.ctx.config.tools_disabled
or not bool(self.ctx.config.agent_security.get("allow_url_fetch", True)))
if disabled:
self.test_internet_status.setText(tr("tools_admin.internet_disabled"))
self.test_internet_status.setStyleSheet("color: #c00;")
return
def job(worker):
"""Chạy nền: chẩn đoán kết nối Internet và tình trạng chứng chỉ TLS."""
from ....core import tls_trust
ok, message = tls_trust.diagnose_internet()
return {"ok": ok, "message": message}
def done(result):
"""Hiện kết quả chẩn đoán, tô xanh/đỏ theo kết quả."""
ok = result.get("ok")
self.test_internet_status.setText(result.get("message", ""))
self.test_internet_status.setStyleSheet("color: #090;" if ok else "color: #c00;")
self.test_internet_btn.setEnabled(True)
def failed(e):
"""Chẩn đoán ném lỗi: hiện lỗi bằng màu đỏ."""
self.test_internet_status.setText(str(e))
self.test_internet_status.setStyleSheet("color: #c00;")
self.test_internet_btn.setEnabled(True)
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(failed)
self._net_worker = w # keep a ref so the thread isn't GC'd mid-run
self.test_internet_btn.setEnabled(False)
self.test_internet_status.setStyleSheet("")
self.test_internet_status.setText(tr("settings.testing_internet"))
w.start()
# ---- i18n -----------------------------------------------------------------
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn rồi vẽ lại lưới thẻ."""
self.subtabs.setTabText(0, tr("tools_admin.subtab_tool"))
self.subtabs.setTabText(1, tr("tools_admin.subtab_connector"))
self._hint.setText(tr("tools_admin.hint"))
self.test_internet_btn.setText(tr("settings.test_internet"))
self.test_internet_btn.setToolTip(tr("settings.test_internet_tooltip"))
self.refresh_btn.setText(tr("tools_admin.refresh"))
self.jira_note.setText(tr("tools_admin.jira_note"))
self.refresh()