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.
210 lines
9.8 KiB
Python
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
|