- ui/monitoring_tab.py (1546 dong) tach thanh presentation/monitoring/** (container + 7 tab/card + shared helper), ui/monitoring_tab.py con lai re-export shim de app.py khong doi. - infrastructure/telemetry/audit_logger.py: CanonicalAuditLogger, core/audit_log.py thanh wrapper mong, tuong thich nguoc 100% voi schema .jsonl cu. - application/monitoring/monitoring_query_service.py: MonitoringQueryService read-only, filter/sort/pagination, khong import PySide6. - Go circular import model_pricing<->usage_tracker va agent_security<-> agent_security_alert (core/agent_security_types.py moi). - infrastructure/sandbox/sandbox_capabilities.py: SandboxCapabilityMatrix theo OS (Windows/Linux/macOS), chua dau noi vao core/sandbox_manager.py. - conftest.py: sua loi checkout khong ten cowork_local khien pytest import nham thu muc khac. - 77 test moi, 167/167 pass. QA da xac nhan UI/business logic khong doi (xem evidence/report/unified_report.html). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
172 lines
7.7 KiB
Python
172 lines
7.7 KiB
Python
"""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):
|
|
super().__init__(display)
|
|
self._raw_ts = raw_ts
|
|
|
|
def __lt__(self, other):
|
|
if isinstance(other, _TimeItem):
|
|
return self._raw_ts < other._raw_ts
|
|
return super().__lt__(other)
|
|
|
|
|
|
class EventTable(QTableWidget):
|
|
"""A read-only table of audit-log events — newest-first by default, and
|
|
every column header is click-to-sort (ascending/descending toggle; the
|
|
Time column 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).
|
|
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:
|
|
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:
|
|
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:
|
|
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]:
|
|
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):
|
|
super().__init__(container)
|
|
self._table = table
|
|
self._panel = panel
|
|
self._container = container
|
|
|
|
def eventFilter(self, obj, event) -> bool:
|
|
if event.type() == QEvent.MouseButtonPress and self._panel.isVisible():
|
|
global_pos = event.globalPosition().toPoint()
|
|
top_left = self._container.mapToGlobal(self._container.rect().topLeft())
|
|
rect = QRect(top_left, self._container.size())
|
|
if not rect.contains(global_pos):
|
|
self._table.clearSelection()
|
|
return False
|