fix(ui): 9项端到端真实审计 — 修复记忆/技能/审计/工作区/MCP数据流断裂
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
基于 Tauri MCP 实机排查发现并修复:
1. VikingPanel: viking_ls('/') 返回0 → 改为 viking_ls('') 返回100条记忆
2. 技能列表: loadSkillsCatalog 静默失败 → 添加直接 invoke('skill_list') 回退
3. 审计日志: 面板读Gateway API无数据 → 回退读localStorage双源数据
4. 工作区: 浏览按钮无事件 → 接入prompt选择 + workspace_dir_stats 命令
5. MCP: 空列表无引导 → 添加配置文件路径提示
6. 新增 workspace_dir_stats Tauri 命令 (Rust)
排查确认正常的功能: 安全存储(OS Keyring✅), 心跳引擎(运行中✅),
定时任务(管道连通), Kernel(已初始化✅), SaaS relay模式
This commit is contained in:
@@ -17,6 +17,7 @@ pub mod orchestration;
|
|||||||
pub mod scheduled_task;
|
pub mod scheduled_task;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
pub mod trigger;
|
pub mod trigger;
|
||||||
|
pub mod workspace;
|
||||||
|
|
||||||
#[cfg(feature = "multi-agent")]
|
#[cfg(feature = "multi-agent")]
|
||||||
pub mod a2a;
|
pub mod a2a;
|
||||||
|
|||||||
43
desktop/src-tauri/src/kernel_commands/workspace.rs
Normal file
43
desktop/src-tauri/src/kernel_commands/workspace.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
//! Workspace directory statistics command
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DirStats {
|
||||||
|
pub file_count: u64,
|
||||||
|
pub total_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count files and total size in a directory (non-recursive, top-level only)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn workspace_dir_stats(path: String) -> Result<DirStats, String> {
|
||||||
|
let dir = Path::new(&path);
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(DirStats {
|
||||||
|
file_count: 0,
|
||||||
|
total_size: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !dir.is_dir() {
|
||||||
|
return Err(format!("{} is not a directory", path));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file_count: u64 = 0;
|
||||||
|
let mut total_size: u64 = 0;
|
||||||
|
|
||||||
|
let entries = std::fs::read_dir(dir).map_err(|e| format!("Failed to read dir: {}", e))?;
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if let Ok(metadata) = entry.metadata() {
|
||||||
|
if metadata.is_file() {
|
||||||
|
file_count += 1;
|
||||||
|
total_size += metadata.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(DirStats {
|
||||||
|
file_count,
|
||||||
|
total_size,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -323,6 +323,8 @@ pub fn run() {
|
|||||||
kernel_commands::trigger::trigger_update,
|
kernel_commands::trigger::trigger_update,
|
||||||
kernel_commands::trigger::trigger_delete,
|
kernel_commands::trigger::trigger_delete,
|
||||||
kernel_commands::trigger::trigger_execute,
|
kernel_commands::trigger::trigger_execute,
|
||||||
|
// Workspace commands
|
||||||
|
kernel_commands::workspace::workspace_dir_stats,
|
||||||
// Approval management commands
|
// Approval management commands
|
||||||
kernel_commands::approval::approval_list,
|
kernel_commands::approval::approval_list,
|
||||||
kernel_commands::approval::approval_respond,
|
kernel_commands::approval::approval_respond,
|
||||||
|
|||||||
@@ -180,14 +180,19 @@ export function MCPServices() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}) : (
|
}) : (
|
||||||
<div className="p-8 text-center text-sm text-gray-400">
|
<div className="p-8 text-center">
|
||||||
当前快速配置中尚未声明 MCP 服务
|
<div className="text-sm text-gray-400 mb-2">尚未配置 MCP 服务</div>
|
||||||
|
<div className="text-xs text-gray-300">
|
||||||
|
MCP 服务为 Agent 扩展外部工具能力。可通过编辑配置文件
|
||||||
|
<code className="mx-1 text-gray-500 bg-gray-100 px-1 rounded">config/mcp.toml</code>
|
||||||
|
添加服务。
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-amber-700 bg-amber-50 rounded-lg p-3">
|
<div className="text-xs text-amber-700 bg-amber-50 rounded-lg p-3">
|
||||||
新增服务、删除服务和详细参数配置尚未在桌面端接入。可通过配置文件手动添加。
|
新增/删除服务尚未在桌面端 UI 接入。可通过编辑 config/mcp.toml 手动添加 MCP 服务配置,重启后生效。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export function Skills() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connected) {
|
if (connected) {
|
||||||
loadSkillsCatalog().catch(silentErrorHandler('Skills'));
|
loadSkillsCatalog().catch(silentErrorHandler('Skills'));
|
||||||
|
} else {
|
||||||
|
// In Tauri mode, try loading even without 'connected' state
|
||||||
|
loadSkillsCatalog().catch(() => { /* direct invoke fallback handles this */ });
|
||||||
}
|
}
|
||||||
}, [connected]);
|
}, [connected]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useConfigStore } from '../../store/configStore';
|
import { useConfigStore } from '../../store/configStore';
|
||||||
import { silentErrorHandler } from '../../lib/error-utils';
|
import { silentErrorHandler } from '../../lib/error-utils';
|
||||||
|
|
||||||
@@ -8,6 +9,17 @@ export function Workspace() {
|
|||||||
const loadWorkspaceInfo = useConfigStore((s) => s.loadWorkspaceInfo);
|
const loadWorkspaceInfo = useConfigStore((s) => s.loadWorkspaceInfo);
|
||||||
const saveQuickConfig = useConfigStore((s) => s.saveQuickConfig);
|
const saveQuickConfig = useConfigStore((s) => s.saveQuickConfig);
|
||||||
const [projectDir, setProjectDir] = useState('~/.zclaw/zclaw-workspace');
|
const [projectDir, setProjectDir] = useState('~/.zclaw/zclaw-workspace');
|
||||||
|
const [dirStats, setDirStats] = useState<{ fileCount: number; totalSize: number } | null>(null);
|
||||||
|
|
||||||
|
// Load real directory stats via Tauri command
|
||||||
|
const loadDirStats = async (dir: string) => {
|
||||||
|
try {
|
||||||
|
const stats = await invoke<{ file_count: number; total_size: number }>('workspace_dir_stats', { path: dir });
|
||||||
|
setDirStats({ fileCount: stats.file_count, totalSize: stats.total_size });
|
||||||
|
} catch {
|
||||||
|
// Command may not exist in all modes — fallback to workspaceInfo
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadWorkspaceInfo().catch(silentErrorHandler('Workspace'));
|
loadWorkspaceInfo().catch(silentErrorHandler('Workspace'));
|
||||||
@@ -22,6 +34,18 @@ export function Workspace() {
|
|||||||
setProjectDir(nextValue);
|
setProjectDir(nextValue);
|
||||||
await saveQuickConfig({ workspaceDir: nextValue });
|
await saveQuickConfig({ workspaceDir: nextValue });
|
||||||
await loadWorkspaceInfo();
|
await loadWorkspaceInfo();
|
||||||
|
await loadDirStats(nextValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrowse = async () => {
|
||||||
|
// Dialog plugin not available — user types path manually
|
||||||
|
const dir = prompt('输入工作区目录路径:', projectDir);
|
||||||
|
if (dir && dir.trim()) {
|
||||||
|
setProjectDir(dir.trim());
|
||||||
|
await saveQuickConfig({ workspaceDir: dir.trim() });
|
||||||
|
await loadWorkspaceInfo();
|
||||||
|
await loadDirStats(dir.trim());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle = async (
|
const handleToggle = async (
|
||||||
@@ -51,13 +75,20 @@ export function Workspace() {
|
|||||||
onBlur={() => { handleWorkspaceBlur().catch(silentErrorHandler('Workspace')); }}
|
onBlur={() => { handleWorkspaceBlur().catch(silentErrorHandler('Workspace')); }}
|
||||||
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 focus:outline-none"
|
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<button className="text-xs px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
|
<button
|
||||||
|
onClick={() => { handleBrowse().catch(silentErrorHandler('Workspace')); }}
|
||||||
|
className="text-xs px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
浏览
|
浏览
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-1 text-xs text-gray-500">
|
<div className="mt-3 space-y-1 text-xs text-gray-500">
|
||||||
<div>当前解析路径:{workspaceInfo?.resolvedPath || '未解析'}</div>
|
<div>当前解析路径:{workspaceInfo?.resolvedPath || projectDir}</div>
|
||||||
<div>文件数:{workspaceInfo?.fileCount ?? 0},大小:{workspaceInfo?.totalSize ?? 0} bytes</div>
|
<div>
|
||||||
|
文件数:{dirStats?.fileCount ?? workspaceInfo?.fileCount ?? 0}
|
||||||
|
{dirStats && `,大小:${(dirStats.totalSize / 1024).toFixed(1)} KB`}
|
||||||
|
{!dirStats && workspaceInfo?.totalSize ? `,大小:${workspaceInfo.totalSize} bytes` : ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ export function VikingPanel() {
|
|||||||
setStatus(vikingStatus);
|
setStatus(vikingStatus);
|
||||||
|
|
||||||
if (vikingStatus.available) {
|
if (vikingStatus.available) {
|
||||||
// Load memory count
|
// Load memory count (use empty path — viking_ls('') returns all, viking_ls('/') returns 0)
|
||||||
try {
|
try {
|
||||||
const resources = await listVikingResources('/');
|
const resources = await listVikingResources('');
|
||||||
setMemoryCount(resources.length);
|
setMemoryCount(resources.length);
|
||||||
} catch {
|
} catch {
|
||||||
setMemoryCount(null);
|
setMemoryCount(null);
|
||||||
|
|||||||
@@ -450,21 +450,50 @@ export const useConfigStore = create<ConfigStateSlice & ConfigActionsSlice>((set
|
|||||||
|
|
||||||
loadSkillsCatalog: async () => {
|
loadSkillsCatalog: async () => {
|
||||||
const client = get().client;
|
const client = get().client;
|
||||||
if (!client) return;
|
|
||||||
|
|
||||||
try {
|
// Path A: via injected client (KernelClient or GatewayClient)
|
||||||
const result = await client.listSkills();
|
if (client) {
|
||||||
set({ skillsCatalog: result?.skills || [] });
|
try {
|
||||||
if (result?.extraDirs) {
|
const result = await client.listSkills();
|
||||||
set((state) => ({
|
if (result?.skills && result.skills.length > 0) {
|
||||||
quickConfig: {
|
set({ skillsCatalog: result.skills });
|
||||||
...state.quickConfig,
|
if (result.extraDirs) {
|
||||||
skillsExtraDirs: result.extraDirs,
|
set((state) => ({
|
||||||
},
|
quickConfig: {
|
||||||
}));
|
...state.quickConfig,
|
||||||
|
skillsExtraDirs: result.extraDirs,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[configStore] listSkills via client failed, trying direct invoke:', err);
|
||||||
}
|
}
|
||||||
} catch {
|
}
|
||||||
// Ignore if skills list not available
|
|
||||||
|
// Path B: direct Tauri invoke fallback (works even without client injection)
|
||||||
|
try {
|
||||||
|
const skills = await invoke('skill_list');
|
||||||
|
if (Array.isArray(skills) && skills.length > 0) {
|
||||||
|
set({ skillsCatalog: skills.map((s: Record<string, unknown>) => ({
|
||||||
|
id: s.id as string,
|
||||||
|
name: s.name as string,
|
||||||
|
description: (s.description as string) || '',
|
||||||
|
version: (s.version as string) || '',
|
||||||
|
capabilities: (s.capabilities as string[]) || [],
|
||||||
|
tags: (s.tags as string[]) || [],
|
||||||
|
mode: (s.mode as string) || '',
|
||||||
|
triggers: ((s.triggers as string[]) || []).map((t: string) => ({ type: 'keyword' as const, pattern: t })),
|
||||||
|
actions: ((s.capabilities as string[]) || []).map((cap: string) => ({ type: cap, params: undefined })),
|
||||||
|
enabled: (s.enabled as boolean) ?? true,
|
||||||
|
category: s.category as string,
|
||||||
|
source: ((s.source as string) || 'builtin') as 'builtin' | 'extra',
|
||||||
|
path: s.path as string | undefined,
|
||||||
|
})) });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[configStore] skill_list direct invoke also failed:', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -257,16 +257,64 @@ export const useSecurityStore = create<SecurityStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadAuditLogs: async (opts?: { limit?: number; offset?: number }) => {
|
loadAuditLogs: async (opts?: { limit?: number; offset?: number }) => {
|
||||||
const client = get().client;
|
|
||||||
if (!client) return;
|
|
||||||
|
|
||||||
set({ auditLogsLoading: true });
|
set({ auditLogsLoading: true });
|
||||||
|
|
||||||
|
// Path A: try client (Gateway mode)
|
||||||
|
const client = get().client;
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
const result = await client.getAuditLogs(opts);
|
||||||
|
if (result?.logs && result.logs.length > 0) {
|
||||||
|
set({ auditLogs: result.logs as AuditLogEntry[], auditLogsLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* fallback to local */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path B: read from localStorage (Tauri/Kernel mode)
|
||||||
try {
|
try {
|
||||||
const result = await client.getAuditLogs(opts);
|
const allLogs: AuditLogEntry[] = [];
|
||||||
set({ auditLogs: (result?.logs || []) as AuditLogEntry[], auditLogsLoading: false });
|
|
||||||
|
// Source 1: security-audit.ts events (zclaw_security_audit_log)
|
||||||
|
const securityLog = localStorage.getItem('zclaw_security_audit_log');
|
||||||
|
if (securityLog) {
|
||||||
|
const events = JSON.parse(securityLog) as Array<{
|
||||||
|
id: string; timestamp: string; eventType: string; severity: string;
|
||||||
|
description: string; details?: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
events.forEach(e => allLogs.push({
|
||||||
|
id: e.id,
|
||||||
|
timestamp: e.timestamp,
|
||||||
|
action: e.eventType,
|
||||||
|
actor: 'system',
|
||||||
|
result: e.severity === 'critical' || e.severity === 'high' ? 'failure' : 'success',
|
||||||
|
details: { description: e.description, ...e.details },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source 2: autonomy audit log (zclaw-autonomy-audit-log)
|
||||||
|
const autonomyLog = localStorage.getItem('zclaw-autonomy-audit-log');
|
||||||
|
if (autonomyLog) {
|
||||||
|
const events = JSON.parse(autonomyLog) as Array<{
|
||||||
|
id: string; timestamp: string; action: string;
|
||||||
|
decision?: Record<string, unknown>; outcome?: string;
|
||||||
|
}>;
|
||||||
|
events.forEach(e => allLogs.push({
|
||||||
|
id: e.id,
|
||||||
|
timestamp: e.timestamp,
|
||||||
|
action: `autonomy.${e.action}`,
|
||||||
|
actor: 'system',
|
||||||
|
result: e.outcome === 'success' ? 'success' : 'failure',
|
||||||
|
details: e.decision || {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by timestamp descending and apply limit
|
||||||
|
allLogs.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||||
|
const limited = opts?.limit ? allLogs.slice(0, opts.limit) : allLogs;
|
||||||
|
set({ auditLogs: limited, auditLogsLoading: false });
|
||||||
} catch {
|
} catch {
|
||||||
set({ auditLogsLoading: false });
|
set({ auditLogsLoading: false });
|
||||||
/* ignore if audit API not available */
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user