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.
This commit is contained in:
2026-09-07 21:22:00 +09:00
parent e5fa21ecfd
commit 2a5ee29c2c
33 changed files with 1404 additions and 25 deletions
+26 -6
View File
@@ -10,6 +10,7 @@ the ``status_message`` signal, ``select_subtab(index)``, ``nav_subtabs()``,
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import List
from PySide6.QtCore import QTimer, Signal
@@ -25,11 +26,24 @@ 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).
# Comfortably larger than any realistic audit-log size for the WINDOW of
# events _load_events() now actually reads (see _LOG_WINDOW_DAYS below) — this
# is MonitoringQueryService's query-side page size, kept unbounded so it
# always returns every matching event within the window; the user-facing
# "Số dòng/trang" control (DF-006 — see shared/event_table.py::set_page_size,
# shared/filter_scaffold.py::build_filter_scaffold's with_page_size) trims
# that down for DISPLAY, client-side, per event tab.
_UNBOUNDED_PAGE_SIZE = 100_000
# _load_events() re-reads the audit log from disk every _REFRESH_MS (3s) via
# _auto_refresh(), and audit_log.load_events()/load_shared_audit_events() are
# day-sharded JSONL — unbounded start/end means EVERY day file ever written
# gets re-read and re-parsed on EVERY tick, which is what actually made
# Monitoring "gây nặng khi log lớn" (see DF-006): the slowness was never in
# rendering (EventTable already caps display at 300 rows — see
# shared/event_table.py::_MAX_ROWS), it was this repeated full-history read.
# 30 days is a live-monitoring window, not a hard retention limit — nothing
# is deleted, older days are simply not re-read on every 3s tick.
_LOG_WINDOW_DAYS = 30
class MonitoringTab(QWidget):
@@ -259,14 +273,20 @@ class MonitoringTab(QWidget):
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.
Chỉ đọc ``_LOG_WINDOW_DAYS`` ngày gần nhất — cả hai nguồn đều lưu theo
file JSONL từng ngày, nên bounding ở đây tránh việc đọc lại TOÀN BỘ
lịch sử mỗi 3 giây (xem ``_auto_refresh``), là nguyên nhân thật của
DF-006 (gây nặng khi log lớn).
"""
start = date.today() - timedelta(days=_LOG_WINDOW_DAYS)
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)
shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start)
if shared_events:
return shared_events
return audit_log.load_events()
return audit_log.load_events(start=start)
def _apply_events_to_event_tabs(self, events: List[dict]) -> None:
"""Filters the ALREADY-LOADED event list (see ``_load_events`` — one
+17 -2
View File
@@ -18,6 +18,7 @@ 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):
@@ -70,6 +71,8 @@ class EventTable(QTableWidget):
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)
@@ -98,13 +101,25 @@ class EventTable(QTableWidget):
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 ở ``_MAX_ROWS``.
"""Đổ 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.
"""
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:_MAX_ROWS]
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):
@@ -16,13 +16,13 @@ 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,
QApplication, QComboBox, 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_table import PAGE_SIZE_OPTIONS, ClickOutsideCloser, EventTable
from .event_detail_panel import EventDetailPanel
@@ -47,11 +47,12 @@ def _sync_event_detail(table: EventTable, panel: EventDetailPanel) -> None:
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,
with_detail: bool = False, with_page_size: 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.
nút lọc bằng AI, control "Số dòng/trang" (nếu ``with_page_size``) 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.
@@ -88,6 +89,23 @@ def build_filter_scaffold(
ai_btn.clicked.connect(lambda: on_ai_filter(search, ai_btn))
row.addWidget(search, 1)
row.addWidget(ai_btn)
if with_page_size and isinstance(table, EventTable):
# DF-006: the item-per-page count was never surfaced anywhere in
# the UI (design called for it) — EventTable already trims to a
# page size internally (default 300), this just makes that
# number visible AND user-choosable instead of a fixed constant.
page_size_lbl = QLabel(tr("monitoring.page_size_label"))
page_size_combo = QComboBox()
for n in PAGE_SIZE_OPTIONS:
page_size_combo.addItem(str(n), n)
current = table.page_size()
page_size_combo.setCurrentIndex(
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 2)
page_size_combo.currentIndexChanged.connect(
lambda i: table.set_page_size(page_size_combo.itemData(i)))
row.addWidget(page_size_lbl)
row.addWidget(page_size_combo)
parts.update(page_size_label=page_size_lbl, page_size_combo=page_size_combo)
lay.addLayout(row)
parts.update(filter_edit=search, ai_filter_btn=ai_btn)
@@ -25,13 +25,15 @@ class ActionLogsTab(QWidget):
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)
with_search=True, with_detail=True, with_page_size=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"]
self.page_size_label = parts["page_size_label"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
@@ -44,6 +46,7 @@ class ActionLogsTab(QWidget):
self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label"))
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."""
+4 -1
View File
@@ -25,13 +25,15 @@ class McpTab(QWidget):
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)
with_search=True, with_detail=True, with_page_size=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"]
self.page_size_label = parts["page_size_label"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
@@ -44,6 +46,7 @@ class McpTab(QWidget):
self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label"))
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."""
@@ -31,13 +31,15 @@ class SecurityEventsTab(QWidget):
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)
with_search=True, with_detail=True, with_page_size=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"]
self.page_size_label = parts["page_size_label"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
@@ -50,6 +52,7 @@ class SecurityEventsTab(QWidget):
self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label"))
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."""