Files
cowork-local/presentation/monitoring/shared/event_table.py
T
vudt15 2a5ee29c2c fix(qa): resolve DF-002 through DF-011 from QA defect tracking sheet
Batch of fixes for defects tracked in "Task Tracking Template.xlsx" (sheet
Defect Management), verified against the sheet's Root Cause/Cach xu ly
columns before this commit:

- DF-002: Co4E node status not reflected after tab switch + missing
  edit-lock on running/done nodes (node_property_panel.py, co4e_runs.py,
  co4e_workflow_crud.py, co4e_canvas_widget.py, co4e_flow_tabs.py,
  canvas_items.py)
- DF-003: hide the run.bat console window unless the app exits with an
  error (run.bat, scripts/console_visibility.ps1 - new)
- DF-004: floating Help Assistant icon covering the Send button after a
  window resize (presentation/shell/main_window.py)
- DF-005: "block network" toggle didn't stop ICMP/raw-socket tools like
  ping (infrastructure/filesystem/command_tools.py,
  security/command_risk_classifier.py)
- DF-006: Monitoring "gay nang khi log lon" - root cause was re-reading
  the ENTIRE audit log history every 3s tick, not missing pagination;
  bounded to a 30-day window (presentation/monitoring/monitoring_tab.py)
  AND added the "So dong/trang" page-size control the ticket also asked
  for (presentation/monitoring/shared/event_table.py,
  shared/filter_scaffold.py, tabs/action_logs_tab.py, tabs/mcp_tab.py,
  tabs/security_events_tab.py, i18n/agents_admin_tab.py)
- DF-007: support choosing a OneDrive/SharePoint folder as a project's
  working directory via Microsoft Graph, downloaded as a local mirror
  with manual sync (core/projects.py, core/ms365_graph.py,
  core/cloud_workspace_sync.py - new, ui/ms365_signin_dialog.py - new,
  ui/cloud_folder_picker_dialog.py - new, i18n/cloud_workspace.py - new,
  ui/workspace_tab.py)
- DF-008: AI-edit instruction box was a fixed-height single-line QLineEdit;
  replaced with an auto-expanding, Enter-to-send/Shift+Enter-newline input
  (presentation/folder/ai_file_editor_dialog.py)
- DF-011: run_command failed with WinError 267 for a project whose
  per-turn output directory had never been created
  (application/conversations/core_runtime_adapter.py)

DF-009 (AI-edit Apply/Discard buttons easy to miss) and DF-010 (AI reply
language - dev-confirmed not a bug) are intentionally NOT part of this
commit: DF-009 has no code fix yet (still "Assigned" in the sheet, only a
UX recommendation was recorded), DF-010 was rejected as expected behavior.

Tests: tests/test_cloud_workspace_sync.py, tests/test_ms365_cloud_dialogs.py,
tests/test_ai_file_editor_input.py, tests/test_monitoring_page_size.py (all
new, all passing). Full suite: 896 passed, 13 known-and-documented failures
unrelated to this change (an existing core/audit_log.py bug, this checkout
not being a git repo before now, and a repo/subprocess folder-naming
mismatch affecting ~66 characterization tests) - see the sheet's DF-006
Evidence column for details.
2026-09-07 21:22:00 +09:00

210 lines
9.8 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
PAGE_SIZE_OPTIONS = (50, 100, 300, 500, 1000)
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
self._page_size = _MAX_ROWS
self._last_events: List[dict] = []
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 page_size(self) -> int:
"""Số dòng đang hiển thị mỗi trang."""
return self._page_size
def set_page_size(self, n: int) -> None:
"""Đổi số dòng hiển thị mỗi trang rồi vẽ lại với dữ liệu đã có sẵn
(không cần refresh lại từ nguồn — set_events() đã lưu lại lần đổ gần nhất)."""
self._page_size = n
self.set_events(self._last_events)
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 ở ``self._page_size``
(đổi được qua ``set_page_size`` — control "Số dòng/trang" ở filter_scaffold.py).
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.
"""
self._last_events = events
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:self._page_size]
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