## 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:
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user