diff --git a/src/components/SettingsPlugins.test.tsx b/src/components/SettingsPlugins.test.tsx
index cc51704..9388088 100644
--- a/src/components/SettingsPlugins.test.tsx
+++ b/src/components/SettingsPlugins.test.tsx
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { render, screen, cleanup, within } from '@testing-library/react';
+import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
import { SettingsPlugins } from '@/components/SettingsPlugins';
import { FeatureProvider } from '@/contexts/FeatureContext';
import { LocaleProvider } from '@/contexts/LocaleContext';
@@ -12,6 +12,9 @@ import { storageRemove } from '@/lib/persistentStore';
* đúng: quyền hiện ra là quyền THẬT trong manifest (không phải danh sách chép
* tay song song — chính thứ mô hình plugin sinh ra để xoá bỏ), và lời gọi BỊ
* TỪ CHỐI phải hiện, vì đó là loại lỗi duy nhất không tự lộ ra ở chỗ khác.
+ *
+ * Nhật ký hoạt động ĐÓNG theo mặc định (xem SettingsPlugins.tsx) — mọi test
+ * cần đọc nội dung nhật ký phải bấm mở trước qua `openAuditLog()`.
*/
function renderPanel() {
@@ -26,6 +29,10 @@ function renderPanel() {
);
}
+function openAuditLog() {
+ fireEvent.click(screen.getByRole('button', { name: /Show activity log|Hiện nhật ký/ }));
+}
+
beforeEach(() => {
pluginAudit.clear();
});
@@ -58,11 +65,20 @@ describe('Settings — Plugins', () => {
expect(within(row).getByText(/mcp_/)).toBeTruthy();
});
- it('lời gọi bị từ chối hiện trong nhật ký, kèm quyền còn thiếu', () => {
+ it('nhật ký hoạt động đóng theo mặc định', () => {
+ renderPanel();
+ expect(screen.queryByPlaceholderText(/Search by tool id|Tìm theo id tool/)).toBeNull();
+ expect(screen.getByRole('button', { name: /Show activity log|Hiện nhật ký/ })).toBeTruthy();
+ });
+
+ it('bấm mở thì hiện ô tìm kiếm và danh sách; lời gọi bị từ chối hiện kèm quyền còn thiếu', () => {
const sdk = createPluginSdk({ ...PLUGINS[0], permissions: [], commands: [] });
expect(() => sdk.storage.get('k')).toThrow();
renderPanel();
+ openAuditLog();
+
+ expect(screen.getByPlaceholderText(/Search by tool id|Tìm theo id tool/)).toBeTruthy();
// Cả ô "denied" lẫn hàng chứa nó đều khớp regex, nên khớp NHIỀU phần tử là
// bình thường — điều cần khẳng định là có một phần tử nêu đúng quyền thiếu.
const denied = screen.getAllByText(/denied|bị từ chối/);
@@ -71,6 +87,43 @@ describe('Settings — Plugins', () => {
it('nhật ký rỗng thì nói rõ là rỗng thay vì để khoảng trắng', () => {
renderPanel();
+ openAuditLog();
expect(screen.getByText(/No calls yet|Chưa có lời gọi/)).toBeTruthy();
});
+
+ it('tìm theo id tool lọc đúng dòng, xoá ô tìm kiếm thì hiện lại đủ', () => {
+ const first = createPluginSdk({ ...PLUGINS[0], permissions: [], commands: [] });
+ const second = createPluginSdk({ ...PLUGINS[1], permissions: [], commands: [] });
+ expect(() => first.storage.get('k')).toThrow();
+ expect(() => second.storage.get('k')).toThrow();
+
+ renderPanel();
+ openAuditLog();
+ // `p.id` cũng xuất hiện ở bảng quyền phía trên — dùng getAllByText, chỉ
+ // cần khẳng định CÓ mặt (permission table + audit row) trước khi lọc.
+ expect(screen.getAllByText(PLUGINS[0].id).length).toBeGreaterThan(1);
+ expect(screen.getAllByText(PLUGINS[1].id).length).toBeGreaterThan(1);
+
+ fireEvent.change(screen.getByPlaceholderText(/Search by tool id|Tìm theo id tool/), {
+ target: { value: PLUGINS[0].id },
+ });
+ // Sau khi lọc: PLUGINS[0].id vẫn còn (bảng quyền + audit row còn lại),
+ // PLUGINS[1].id chỉ còn ở bảng quyền (audit row của nó bị lọc mất) — so
+ // sánh SỐ LẦN xuất hiện giảm đi đúng một, thay vì đòi vắng mặt hẳn.
+ expect(screen.getAllByText(PLUGINS[0].id).length).toBeGreaterThan(1);
+ expect(screen.getAllByText(PLUGINS[1].id).length).toBe(1);
+ });
+
+ it('từ khoá không khớp gì thì nói rõ, không để trắng danh sách', () => {
+ const sdk = createPluginSdk({ ...PLUGINS[0], permissions: [], commands: [] });
+ expect(() => sdk.storage.get('k')).toThrow();
+
+ renderPanel();
+ openAuditLog();
+ fireEvent.change(screen.getByPlaceholderText(/Search by tool id|Tìm theo id tool/), {
+ target: { value: 'khong-ton-tai' },
+ });
+
+ expect(screen.getByText(/No calls match|Không có lời gọi nào khớp/)).toBeTruthy();
+ });
});
diff --git a/src/components/SettingsPlugins.tsx b/src/components/SettingsPlugins.tsx
index 1e6fac0..9192554 100644
--- a/src/components/SettingsPlugins.tsx
+++ b/src/components/SettingsPlugins.tsx
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
-import { Globe, ShieldCheck, Terminal } from 'lucide-react';
+import { ChevronDown, ChevronRight, Globe, Search, ShieldCheck, Terminal, X } from 'lucide-react';
import { useFeatures } from '@/contexts/FeatureContext';
import { useLocale } from '@/contexts/LocaleContext';
import { cn } from '@/lib/utils';
+import { Input } from '@/components/ui/input';
import { PLUGINS, SDK_VERSION, pluginAudit, vaultStatus, type AuditEntry, type VaultStatus } from '@/platform';
/**
@@ -28,6 +29,11 @@ export function SettingsPlugins() {
const { isFeatureEnabled } = useFeatures();
const [entries, setEntries] = useState
(() => pluginAudit.recent(MAX_ROWS));
const [vault, setVault] = useState(null);
+ // Đóng theo mặc định: nhật ký này dài (tới MAX_ROWS dòng) và ít khi cần
+ // tra ngay khi mở trang — một mục gấp lại, tự mở khi cần, đỡ chiếm chỗ hơn
+ // hẳn so với luôn hiện sẵn dưới bảng quyền.
+ const [auditOpen, setAuditOpen] = useState(false);
+ const [auditQuery, setAuditQuery] = useState('');
useEffect(() => {
// Nhật ký được ghi cả khi Settings đang đóng, nên đọc lại một lần lúc mount
@@ -51,6 +57,19 @@ export function SettingsPlugins() {
const enabledCount = PLUGINS.filter((p) => isFeatureEnabled(p.id)).length;
+ // Khớp theo id tool TRƯỚC HẾT (đúng câu hỏi hay gặp nhất — "tool nào gọi
+ // gì"), nhưng cũng khớp channel/action/detail/quyền thiếu để một từ khoá
+ // bất kỳ (vd tên một lệnh native, hay "denied") cũng lọc được, không chỉ
+ // riêng id.
+ const auditQueryLower = auditQuery.trim().toLowerCase();
+ const filteredEntries = auditQueryLower
+ ? entries.filter((e) =>
+ [e.pluginId, e.channel, e.action, e.detail, e.missingPermission].some((field) =>
+ field?.toLowerCase().includes(auditQueryLower),
+ ),
+ )
+ : entries;
+
return (
{t('settings.plugins.description')}
@@ -135,27 +154,64 @@ export function SettingsPlugins() {
{t('settings.plugins.auditDescription')}
- {entries.length === 0 ? (
-
{t('settings.plugins.auditEmpty')}
- ) : (
- entries.map((e, i) => (
-
- {timeOf(e.ts)}
- {e.pluginId}
- {e.channel}
- {e.action}
- {e.detail && {e.detail}}
- {!e.allowed && (
-
- {t('settings.plugins.auditDenied')}
- {e.missingPermission ? ` · ${e.missingPermission}` : ''}
-
- )}
-
- ))
- )}
-
+ {entries.length === 0 ? (
+
{t('settings.plugins.auditEmpty')}
+ ) : filteredEntries.length === 0 ? (
+
+ {t('settings.plugins.auditNoMatch', { query: auditQuery })}
+
+ ) : (
+ filteredEntries.map((e, i) => (
+
+ {timeOf(e.ts)}
+ {e.pluginId}
+ {e.channel}
+ {e.action}
+ {e.detail && {e.detail}}
+ {!e.allowed && (
+
+ {t('settings.plugins.auditDenied')}
+ {e.missingPermission ? ` · ${e.missingPermission}` : ''}
+
+ )}
+
+ ))
+ )}
+
+ >
+ )}
);
}
diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts
index 6ce0757..7cd87f2 100644
--- a/src/lib/i18n.ts
+++ b/src/lib/i18n.ts
@@ -199,7 +199,6 @@ export const DICTIONARY = {
},
// ── Settings — Plugins (biên dịch sẵn trong app, dùng SDK nội bộ) ────────
- 'settings.plugins.title': { vi: 'Plugin', en: 'Plugins' },
'settings.plugins.description': {
vi: 'Mỗi tool BIÊN DỊCH SẴN trong app là một plugin tự khai báo trong src/plugins/