From 3e81bd3e501205d783df2569ba1b03ad802bca7b Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 15 Mar 2026 14:12:11 +0800 Subject: [PATCH] feat(ui): Phase 8 UI/UX optimization and system documentation update ## Sidebar Enhancement - Change tabs to icon + small label layout for better space utilization - Add Teams tab with team collaboration entry point ## Settings Page Improvements - Connect theme toggle to gatewayStore.saveQuickConfig for persistence - Remove OpenFang backend download section, simplify UI - Add time range filter to UsageStats (7d/30d/all) - Add stat cards with icons (sessions, messages, input/output tokens) - Add token usage overview bar chart - Add 8 ZCLAW system skill definitions with categories ## Bug Fixes - Fix ChannelList duplicate content with deduplication logic - Integrate CreateTriggerModal in TriggersPanel - Add independent SecurityStatusPanel with 12 default enabled layers - Change workflow view to use SchedulerPanel as unified entry ## New Components - CreateTriggerModal: Event trigger creation modal - HandApprovalModal: Hand approval workflow dialog - HandParamsForm: Enhanced Hand parameter form - SecurityLayersPanel: 16-layer security status display ## Architecture - Add TOML config parsing support (toml-utils.ts, config-parser.ts) - Add request timeout and retry mechanism (request-helper.ts) - Add secure token storage (secure-storage.ts, secure_storage.rs) ## Tests - Add unit tests for config-parser, toml-utils, request-helper - Add team-client and teamStore tests ## Documentation - Update SYSTEM_ANALYSIS.md with Phase 8 completion - UI completion: 100% (30/30 components) - API coverage: 93% (63/68 endpoints) Co-Authored-By: Claude Opus 4.6 --- desktop/scripts/test-api-connection.mjs | 595 +++++++++++ desktop/scripts/test-toml-parsing.mjs | 104 ++ desktop/src-tauri/src/secure_storage.rs | 99 ++ desktop/src/App.tsx | 8 +- desktop/src/components/AuditLogsPanel.tsx | 997 ++++++++++++++++-- desktop/src/components/ChannelList.tsx | 43 +- desktop/src/components/CreateTriggerModal.tsx | 538 ++++++++++ desktop/src/components/HandApprovalModal.tsx | 571 ++++++++++ desktop/src/components/HandParamsForm.tsx | 812 ++++++++++++++ .../src/components/SecurityLayersPanel.tsx | 722 +++++++++++++ desktop/src/components/Settings/General.tsx | 103 +- desktop/src/components/Settings/Skills.tsx | 130 ++- .../src/components/Settings/UsageStats.tsx | 172 ++- desktop/src/components/Sidebar.tsx | 26 +- desktop/src/components/TriggersPanel.tsx | 216 ++-- desktop/src/lib/config-parser.ts | 377 +++++++ desktop/src/lib/gateway-client.ts | 10 + desktop/src/lib/request-helper.ts | 542 ++++++++++ desktop/src/lib/secure-storage.ts | 181 ++++ desktop/src/lib/toml-utils.ts | 186 ++++ desktop/src/store/gatewayStore.ts | 3 + desktop/src/types/config.ts | 573 ++++++++++ desktop/src/types/hands.ts | 12 +- desktop/tests/config-parser.test.ts | 262 +++++ desktop/tests/lib/request-helper.test.ts | 417 ++++++++ desktop/tests/lib/team-client.test.ts | 564 ++++++++++ desktop/tests/store/teamStore.test.ts | 362 +++++++ desktop/tests/toml-utils.test.ts | 169 +++ docs/SYSTEM_ANALYSIS.md | 122 ++- plans/typed-dazzling-fog.md | 243 +++++ 30 files changed, 8875 insertions(+), 284 deletions(-) create mode 100644 desktop/scripts/test-api-connection.mjs create mode 100644 desktop/scripts/test-toml-parsing.mjs create mode 100644 desktop/src-tauri/src/secure_storage.rs create mode 100644 desktop/src/components/CreateTriggerModal.tsx create mode 100644 desktop/src/components/HandApprovalModal.tsx create mode 100644 desktop/src/components/HandParamsForm.tsx create mode 100644 desktop/src/components/SecurityLayersPanel.tsx create mode 100644 desktop/src/lib/config-parser.ts create mode 100644 desktop/src/lib/request-helper.ts create mode 100644 desktop/src/lib/secure-storage.ts create mode 100644 desktop/src/lib/toml-utils.ts create mode 100644 desktop/src/types/config.ts create mode 100644 desktop/tests/config-parser.test.ts create mode 100644 desktop/tests/lib/request-helper.test.ts create mode 100644 desktop/tests/lib/team-client.test.ts create mode 100644 desktop/tests/store/teamStore.test.ts create mode 100644 desktop/tests/toml-utils.test.ts create mode 100644 plans/typed-dazzling-fog.md diff --git a/desktop/scripts/test-api-connection.mjs b/desktop/scripts/test-api-connection.mjs new file mode 100644 index 0000000..e35783c --- /dev/null +++ b/desktop/scripts/test-api-connection.mjs @@ -0,0 +1,595 @@ +#!/usr/bin/env node +/** + * OpenFang Backend API Connection Test Script + * + * Tests all API endpoints used by the ZCLAW desktop client against + * the OpenFang Kernel backend. + * + * Usage: + * node desktop/scripts/test-api-connection.mjs [options] + * + * Options: + * --url=URL Base URL for OpenFang API (default: http://127.0.0.1:50051) + * --verbose Show detailed output + * --json Output results as JSON + * --timeout=MS Request timeout in milliseconds (default: 5000) + */ + +import { WebSocket } from 'ws'; + +// Configuration +const DEFAULT_BASE_URL = 'http://127.0.0.1:50051'; +const DEFAULT_TIMEOUT = 5000; + +// Parse command line arguments +const args = process.argv.slice(2); +const config = { + baseUrl: DEFAULT_BASE_URL, + verbose: false, + json: false, + timeout: DEFAULT_TIMEOUT, +}; + +for (const arg of args) { + if (arg.startsWith('--url=')) { + config.baseUrl = arg.slice(6); + } else if (arg === '--verbose' || arg === '-v') { + config.verbose = true; + } else if (arg === '--json') { + config.json = true; + } else if (arg.startsWith('--timeout=')) { + config.timeout = parseInt(arg.slice(10), 10); + } else if (arg === '--help' || arg === '-h') { + console.log(` +OpenFang API Connection Tester + +Usage: node test-api-connection.mjs [options] + +Options: + --url=URL Base URL for OpenFang API (default: ${DEFAULT_BASE_URL}) + --verbose Show detailed output including response bodies + --json Output results as JSON for programmatic processing + --timeout=MS Request timeout in milliseconds (default: ${DEFAULT_TIMEOUT}) + --help, -h Show this help message +`); + process.exit(0); + } +} + +// Test result tracking +const results = { + timestamp: new Date().toISOString(), + baseUrl: config.baseUrl, + summary: { + total: 0, + passed: 0, + failed: 0, + skipped: 0, + }, + categories: {}, + errors: [], +}; + +/** + * Make an HTTP request with timeout + */ +async function makeRequest(method, path, body = null, expectedStatus = null) { + const url = `${config.baseUrl}${path}`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), config.timeout); + + const startTime = Date.now(); + + try { + const fetchOptions = { + method, + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + }, + }; + + if (body && (method === 'POST' || method === 'PUT')) { + fetchOptions.body = JSON.stringify(body); + } + + const response = await fetch(url, fetchOptions); + clearTimeout(timeoutId); + + const duration = Date.now() - startTime; + let responseBody = null; + + try { + responseBody = await response.json(); + } catch { + responseBody = await response.text().catch(() => null); + } + + const statusMatch = expectedStatus ? response.status === expectedStatus : response.status < 500; + + return { + success: statusMatch, + status: response.status, + statusText: response.statusText, + duration, + body: responseBody, + error: null, + }; + } catch (error) { + clearTimeout(timeoutId); + const duration = Date.now() - startTime; + + return { + success: false, + status: 0, + statusText: 'Network Error', + duration, + body: null, + error: error.name === 'AbortError' ? 'Timeout' : error.message, + }; + } +} + +/** + * Test a single endpoint and record results + */ +async function testEndpoint(category, method, path, testOptions = {}) { + const { body = null, expectedStatus = null, description = '' } = testOptions; + + results.summary.total++; + + if (!results.categories[category]) { + results.categories[category] = { total: 0, passed: 0, failed: 0, tests: [] }; + } + results.categories[category].total++; + + const result = await makeRequest(method, path, body, expectedStatus); + + const testResult = { + method, + path, + description, + status: result.status, + statusText: result.statusText, + duration: result.duration, + success: result.success, + error: result.error, + }; + + if (config.verbose && result.body) { + testResult.responseBody = result.body; + } + + results.categories[category].tests.push(testResult); + + if (result.success) { + results.summary.passed++; + results.categories[category].passed++; + } else { + results.summary.failed++; + results.categories[category].failed++; + + if (result.error) { + results.errors.push({ + category, + method, + path, + error: result.error, + status: result.status, + }); + } + } + + // Print progress + if (!config.json) { + const statusIcon = result.success ? '[PASS]' : '[FAIL]'; + const statusColor = result.success ? '\x1b[32m' : '\x1b[31m'; + const reset = '\x1b[0m'; + const dim = '\x1b[2m'; + + console.log( + `${statusColor}${statusIcon}${reset} ${method.padEnd(6)} ${path} ${dim}${result.status} ${result.duration}ms${reset}` + ); + + if (config.verbose && result.body) { + const bodyStr = JSON.stringify(result.body, null, 2); + const indented = bodyStr.split('\n').join('\n '); + console.log(` Response: ${indented}`); + } + + if (!result.success && result.error) { + console.log(` Error: ${result.error}`); + } + } + + return result; +} + +/** + * Test WebSocket connection + */ +async function testWebSocketConnection(url) { + return new Promise((resolve) => { + const startTime = Date.now(); + let resolved = false; + + const ws = new WebSocket(url); + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + ws.close(); + resolve({ + method: 'CONNECT', + path: '/ws', + description: 'WebSocket connection test', + status: 0, + statusText: 'Timeout', + duration: Date.now() - startTime, + success: false, + error: 'Connection timeout', + }); + } + }, config.timeout); + + ws.on('open', () => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + ws.close(); + resolve({ + method: 'CONNECT', + path: '/ws', + description: 'WebSocket connection test', + status: 101, + statusText: 'Switching Protocols', + duration: Date.now() - startTime, + success: true, + error: null, + }); + } + }); + + ws.on('error', (error) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + resolve({ + method: 'CONNECT', + path: '/ws', + description: 'WebSocket connection test', + status: 0, + statusText: 'Connection Failed', + duration: Date.now() - startTime, + success: false, + error: error.message, + }); + } + }); + }); +} + +/** + * Print test summary + */ +function printSummary() { + console.log(`\n`); + console.log(`=== Test Summary ===`); + console.log(`\n`); + + const categories = Object.entries(results.categories); + const maxCategoryLen = Math.max(...categories.map(([name]) => name.length)); + + for (const [name, data] of categories) { + const passRate = data.total > 0 ? ((data.passed / data.total) * 100).toFixed(0) : '0'; + const statusIcon = data.failed === 0 ? '\x1b[32m' : '\x1b[31m'; + const reset = '\x1b[0m'; + + console.log( + `${statusIcon}${name.padEnd(maxCategoryLen + 1)}${reset} ` + + `${data.passed}/${data.total} passed (${passRate}%)` + ); + } + + console.log(`\n`); + const totalPassRate = results.summary.total > 0 + ? ((results.summary.passed / results.summary.total) * 100).toFixed(0) + : '0'; + console.log( + `Total: ${results.summary.passed}/${results.summary.total} passed ` + + `(${totalPassRate}%)` + ); + + if (results.errors.length > 0) { + console.log(`\n\x1b[31m=== Errors ===\x1b[0m\n`); + for (const error of results.errors) { + console.log(` [${error.category}] ${error.method} ${error.path}`); + console.log(` Status: ${error.status || 'N/A'}`); + console.log(` Error: ${error.error}`); + console.log(); + } + } + + // JSON output if requested + if (config.json) { + console.log(`\n=== JSON Output ===\n`); + console.log(JSON.stringify(results, null, 2)); + } + + // Exit with appropriate code + process.exit(results.summary.failed > 0 ? 1 : 0); +} + +/** + * Run all API tests + */ +async function runAllTests() { + console.log(`\n=== OpenFang API Connection Test ===`); + console.log(`Base URL: ${config.baseUrl}`); + console.log(`Timeout: ${config.timeout}ms`); + console.log(`\n`); + + // ========================================= + // Health & System Endpoints + // ========================================= + console.log(`\n--- Health & System ---`); + + await testEndpoint('System', 'GET', '/api/health', { + expectedStatus: 200, + description: 'Health check endpoint', + }); + + await testEndpoint('System', 'GET', '/api/version', { + description: 'Version information', + }); + + // ========================================= + // Agent Endpoints + // ========================================= + console.log(`\n--- Agent API ---`); + + await testEndpoint('Agents', 'GET', '/api/agents', { + expectedStatus: 200, + description: 'List all agents', + }); + + // Test agent creation (will fail if not authenticated) + await testEndpoint('Agents', 'POST', '/api/agents', { + body: { + name: 'test-agent', + role: 'assistant', + }, + description: 'Create agent (expect 401 without auth)', + }); + + // ========================================= + // Team API Endpoints + // ========================================= + console.log(`\n--- Team API ---`); + + await testEndpoint('Teams', 'GET', '/api/teams', { + description: 'List all teams', + }); + + await testEndpoint('Teams', 'POST', '/api/teams', { + body: { + name: 'Test Team', + pattern: 'sequential', + memberAgents: [], + }, + description: 'Create team (expect 401 without auth)', + }); + + await testEndpoint('Teams', 'GET', '/api/teams/test-team-id', { + description: 'Get team by ID', + }); + + await testEndpoint('Teams', 'PUT', '/api/teams/test-team-id', { + body: { name: 'Updated Team' }, + description: 'Update team', + }); + + await testEndpoint('Teams', 'DELETE', '/api/teams/test-team-id', { + description: 'Delete team', + }); + + // Team member endpoints + await testEndpoint('Teams', 'POST', '/api/teams/test-team-id/members', { + body: { agentId: 'test-agent', role: 'developer' }, + description: 'Add team member', + }); + + await testEndpoint('Teams', 'DELETE', '/api/teams/test-team-id/members/test-member-id', { + description: 'Remove team member', + }); + + // Team task endpoints + await testEndpoint('Teams', 'GET', '/api/teams/test-team-id/metrics', { + description: 'Get team metrics', + }); + + await testEndpoint('Teams', 'GET', '/api/teams/test-team-id/events', { + description: 'Get team events', + }); + + // ========================================= + // Config API Endpoints + // ========================================= + console.log(`\n--- Config API ---`); + + await testEndpoint('Config', 'GET', '/api/config', { + description: 'Get full configuration', + }); + + await testEndpoint('Config', 'GET', '/api/config/quick', { + description: 'Get quick configuration', + }); + + await testEndpoint('Config', 'PUT', '/api/config/quick', { + body: { default_model: 'gpt-4' }, + description: 'Update quick configuration', + }); + + // ========================================= + // Trigger API Endpoints + // ========================================= + console.log(`\n--- Trigger API ---`); + + await testEndpoint('Triggers', 'GET', '/api/triggers', { + description: 'List all triggers', + }); + + await testEndpoint('Triggers', 'POST', '/api/triggers', { + body: { + type: 'schedule', + enabled: true, + schedule: '0 * * * *', + }, + description: 'Create trigger', + }); + + await testEndpoint('Triggers', 'GET', '/api/triggers/test-trigger-id', { + description: 'Get trigger by ID', + }); + + await testEndpoint('Triggers', 'PUT', '/api/triggers/test-trigger-id', { + body: { enabled: false }, + description: 'Update trigger', + }); + + await testEndpoint('Triggers', 'DELETE', '/api/triggers/test-trigger-id', { + description: 'Delete trigger', + }); + + // ========================================= + // Audit API Endpoints + // ========================================= + console.log(`\n--- Audit API ---`); + + await testEndpoint('Audit', 'GET', '/api/audit/logs', { + description: 'Get audit logs', + }); + + await testEndpoint('Audit', 'GET', '/api/audit/logs?limit=10', { + description: 'Get audit logs with limit', + }); + + await testEndpoint('Audit', 'GET', '/api/audit/verify/test-log-id', { + description: 'Verify audit chain for log ID', + }); + + // ========================================= + // Skills & Plugins API Endpoints + // ========================================= + console.log(`\n--- Skills & Plugins ---`); + + await testEndpoint('Skills', 'GET', '/api/skills', { + description: 'List all skills', + }); + + await testEndpoint('Plugins', 'GET', '/api/plugins/status', { + description: 'Get plugin status', + }); + + // ========================================= + // Hands API Endpoints + // ========================================= + console.log(`\n--- Hands API ---`); + + await testEndpoint('Hands', 'GET', '/api/hands', { + description: 'List all hands', + }); + + await testEndpoint('Hands', 'POST', '/api/hands/researcher/trigger', { + body: { query: 'test query' }, + description: 'Trigger researcher hand', + }); + + // ========================================= + // Workflow API Endpoints + // ========================================= + console.log(`\n--- Workflow API ---`); + + await testEndpoint('Workflows', 'GET', '/api/workflows', { + description: 'List all workflows', + }); + + await testEndpoint('Workflows', 'POST', '/api/workflows', { + body: { + name: 'Test Workflow', + steps: [], + }, + description: 'Create workflow', + }); + + await testEndpoint('Workflows', 'GET', '/api/workflows/test-workflow-id', { + description: 'Get workflow by ID', + }); + + // ========================================= + // Stats API Endpoints + // ========================================= + console.log(`\n--- Stats API ---`); + + await testEndpoint('Stats', 'GET', '/api/stats/usage', { + description: 'Get usage statistics', + }); + + await testEndpoint('Stats', 'GET', '/api/stats/sessions', { + description: 'Get session statistics', + }); + + // ========================================= + // Channel API Endpoints + // ========================================= + console.log(`\n--- Channels API ---`); + + await testEndpoint('Channels', 'GET', '/api/channels', { + description: 'List all channels', + }); + + // ========================================= + // WebSocket Endpoint + // ========================================= + console.log(`\n--- WebSocket ---`); + + // WebSocket test is different - we need to check if the endpoint exists + const wsUrl = `${config.baseUrl.replace(/^http/, 'ws')}/ws`; + const wsResult = await testWebSocketConnection(wsUrl); + results.categories['WebSocket'] = { + total: 1, + passed: wsResult.success ? 1 : 0, + failed: wsResult.success ? 0 : 1, + tests: [wsResult], + }; + results.summary.total++; + + if (wsResult.success) { + results.summary.passed++; + if (!config.json) { + console.log(`\x1b[32m[PASS]\x1b[0m CONNECT /ws \x1b[2m${wsResult.status} ${wsResult.duration}ms\x1b[0m`); + } + } else { + results.summary.failed++; + results.errors.push({ + category: 'WebSocket', + method: 'CONNECT', + path: '/ws', + error: wsResult.error, + status: wsResult.status, + }); + if (!config.json) { + console.log(`\x1b[31m[FAIL]\x1b[0m CONNECT /ws \x1b[2m${wsResult.status} ${wsResult.duration}ms\x1b[0m`); + console.log(` Error: ${wsResult.error}`); + } + } + + // Print summary + printSummary(); +} + +// Run tests +runAllTests().catch((error) => { + console.error('Failed to run tests:', error.message); + process.exit(1); +}); diff --git a/desktop/scripts/test-toml-parsing.mjs b/desktop/scripts/test-toml-parsing.mjs new file mode 100644 index 0000000..0d7dc3f --- /dev/null +++ b/desktop/scripts/test-toml-parsing.mjs @@ -0,0 +1,104 @@ +/** + * Test script to verify TOML parsing with actual config files + */ + +import TOML from 'smol-toml'; + +// Use inline TOML strings for testing +const MAIN_CONFIG_TOML = ` +[server] +host = "127.0.0.1" +port = 4200 +websocket_port = 4200 +websocket_path = "/ws" + +[agent.defaults] +workspace = "~/.openfang/workspace" +default_model = "gpt-4" + +[llm] +default_provider = "openai" +default_model = "gpt-4" +`; + +const CHINESE_PROVIDERS_TOML = ` +[[llm.providers]] +name = "zhipu" +display_name = "Zhipu GLM" +api_key = "\${ZHIPU_API_KEY}" +base_url = "https://open.bigmodel.cn/api/paas/v4" + +[[llm.providers.models]] +id = "glm-4-plus" +alias = "GLM-4-Plus" +context_window = 128000 + +[[llm.providers]] +name = "qwen" +display_name = "Qwen" +api_key = "\${QWEN_API_KEY}" +base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1" + +[[llm.providers.models]] +id = "qwen-max" +alias = "Qwen-Max" +`; + +console.log('=== Testing TOML Parsing ===\n'); + +// Test 1: Parse main config +try { + console.log('\n--- Test 1: Main config.toml ---'); + const mainConfig = TOML.parse(MAIN_CONFIG_TOML); + console.log('Parsed successfully!'); + console.log('Server config:', JSON.stringify(mainConfig.server, null, 2)); + console.log('Agent defaults:', JSON.stringify(mainConfig.agent?.defaults, null, 2)); + console.log('LLM config:', JSON.stringify(mainConfig.llm, null, 2)); + + // Check required fields + if (!mainConfig.server?.host) throw new Error('Missing server.host'); + if (!mainConfig.server?.port) throw new Error('Missing server.port'); + if (!mainConfig.agent?.defaults?.workspace) throw new Error('Missing agent.defaults.workspace'); + if (!mainConfig.agent?.defaults?.default_model) throw new Error('Missing agent.defaults.default_model'); + if (!mainConfig.llm?.default_provider) throw new Error('Missing llm.default_provider'); + if (!mainConfig.llm?.default_model) throw new Error('Missing llm.default_model'); + + console.log('All required fields present!'); +} catch (error) { + console.error('Failed to parse main config:', error.message); + process.exit(1); +} + +// Test 2: Parse chinese-providers.toml +try { + console.log('\n--- Test 2: chinese-providers.toml ---'); + const chineseProviders = TOML.parse(CHINESE_PROVIDERS_TOML); + console.log('Parsed successfully!'); + console.log('Number of providers:', chineseProviders.llm?.providers?.length || 0); + + // List providers + if (chineseProviders.llm?.providers) { + console.log('\nProviders found:'); + chineseProviders.llm.providers.forEach((provider, index) => { + console.log(` ${index + 1}. ${provider.name} (${provider.display_name || 'N/A'})`); + if (provider.models) { + console.log(` Models: ${provider.models.length}`); + } + }); + } + + // Check for environment variable references + const envVarPattern = /\$\{([^}]+)\}/g; + const envVars = CHINESE_PROVIDERS_TOML.match(envVarPattern); + if (envVars) { + console.log('\nEnvironment variables referenced:'); + const uniqueVars = [...new Set(envVars)]; + uniqueVars.forEach(v => console.log(` - ${v}`)); + } + +} catch (error) { + console.error('Failed to parse chinese-providers:', error.message); + process.exit(1); +} + +console.log('\n=== All TOML parsing tests passed! ===\n'); diff --git a/desktop/src-tauri/src/secure_storage.rs b/desktop/src-tauri/src/secure_storage.rs new file mode 100644 index 0000000..41544b4 --- /dev/null +++ b/desktop/src-tauri/src/secure_storage.rs @@ -0,0 +1,99 @@ +// Secure storage module for ZCLAW desktop app +// Uses the OS keyring/keychain for secure credential storage +// - Windows: DPAPI +// - macOS: Keychain +// - Linux: Secret Service API (gnome-keyring, kwallet, etc.) + +use keyring::Entry; + +const SERVICE_NAME: &str = "zclaw"; + +/// Store a value securely in the OS keyring +#[tauri::command] +pub fn secure_store_set(key: String, value: String) -> Result<(), String> { + let entry = Entry::new(SERVICE_NAME, &key).map_err(|e| { + format!( + "Failed to create keyring entry for '{}': {}", + key, + e.to_string() + ) + })?; + + entry.set_password(&value).map_err(|e| { + format!( + "Failed to store value for key '{}': {}", + key, + e.to_string() + ) + })?; + + Ok(()) +} + +/// Retrieve a value from the OS keyring +#[tauri::command] +pub fn secure_store_get(key: String) -> Result { + let entry = Entry::new(SERVICE_NAME, &key).map_err(|e| { + format!( + "Failed to create keyring entry for '{}': {}", + key, + e.to_string() + ) + })?; + + entry.get_password().map_err(|e| { + // Return empty string for "not found" errors to distinguish from actual errors + let error_str = e.to_string(); + if error_str.contains("No matching entry") || error_str.contains("not found") { + String::new() + } else { + format!("Failed to retrieve value for key '{}': {}", key, error_str) + } + }) +} + +/// Delete a value from the OS keyring +#[tauri::command] +pub fn secure_store_delete(key: String) -> Result<(), String> { + let entry = Entry::new(SERVICE_NAME, &key).map_err(|e| { + format!( + "Failed to create keyring entry for '{}': {}", + key, + e.to_string() + ) + })?; + + match entry.delete_credential() { + Ok(()) => Ok(()), + Err(e) => { + let error_str = e.to_string(); + // Don't fail if the entry doesn't exist + if error_str.contains("No matching entry") || error_str.contains("not found") { + Ok(()) + } else { + Err(format!("Failed to delete value for key '{}': {}", key, error_str)) + } + } + } +} + +/// Check if secure storage is available on this platform +#[tauri::command] +pub fn secure_store_is_available() -> bool { + // Try to create a test entry to verify keyring is working + let test_key = "__zclaw_availability_test__"; + match Entry::new(SERVICE_NAME, test_key) { + Ok(entry) => { + // Try to set and delete a test value + match entry.set_password("test") { + Ok(_) => { + // Clean up the test entry + let _ = entry.delete_credential(); + true + } + Err(_) => false, + } + } + Err(_) => false, + } +} diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 62d383d..a898493 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -5,8 +5,7 @@ import { ChatArea } from './components/ChatArea'; import { RightPanel } from './components/RightPanel'; import { SettingsLayout } from './components/Settings/SettingsLayout'; import { HandTaskPanel } from './components/HandTaskPanel'; -import { WorkflowList } from './components/WorkflowList'; -import { TriggersPanel } from './components/TriggersPanel'; +import { SchedulerPanel } from './components/SchedulerPanel'; import { TeamCollaborationView } from './components/TeamCollaborationView'; import { useGatewayStore } from './store/gatewayStore'; import { useTeamStore } from './store/teamStore'; @@ -86,9 +85,8 @@ function App() { ) : mainContentView === 'workflow' ? ( -
- - +
+
) : mainContentView === 'team' ? ( activeTeam ? ( diff --git a/desktop/src/components/AuditLogsPanel.tsx b/desktop/src/components/AuditLogsPanel.tsx index 95c9111..86a0564 100644 --- a/desktop/src/components/AuditLogsPanel.tsx +++ b/desktop/src/components/AuditLogsPanel.tsx @@ -1,27 +1,702 @@ /** - * AuditLogsPanel - OpenFang Audit Logs UI + * AuditLogsPanel - OpenFang Audit Logs UI with Merkle Hash Chain Verification * - * Displays OpenFang's Merkle hash chain audit logs. + * Phase 3.4 Enhancement: Full-featured audit log viewer with: + * - Complete log entry display + * - Merkle hash chain verification + * - Export functionality (JSON/CSV) + * - Search and filter capabilities + * - Real-time log streaming */ -import { useState, useEffect } from 'react'; -import { useGatewayStore } from '../store/gatewayStore'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { + Search, + Filter, + FileJson, + FileSpreadsheet, + ChevronDown, + ChevronUp, + Hash, + Link, + CheckCircle2, + XCircle, + AlertCircle, + RefreshCw, + Pause, + Play, + X, + Loader2 +} from 'lucide-react'; +import { useGatewayStore, AuditLogEntry } from '../store/gatewayStore'; + +import { getGatewayClient } from '../lib/gateway-client'; + +// === Types === + +export interface MerkleVerificationResult { + valid: boolean; + chainDepth: number; + rootHash: string; + previousHash: string; + currentHash: string; + brokenAtIndex?: number; +} + +export interface AuditLogFilter { + timeRange?: { start: Date; end: Date }; + eventTypes?: string[]; + actors?: string[]; + searchTerm?: string; + result?: 'success' | 'failure' | 'all'; +} + +interface EnhancedAuditLogEntry extends AuditLogEntry { + // Extended fields from OpenFang + targetResource?: string; + operationDetails?: Record; + ipAddress?: string; + sessionId?: string; + // Merkle chain fields + hash?: string; + previousHash?: string; + chainIndex?: number; +} + +// === Helper Functions === + +function formatTimestamp(timestamp: string): string { + try { + const date = new Date(timestamp); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } catch { + return timestamp; + } +} + +function formatHash(hash: string | undefined): string { + if (!hash) return '-'; + if (hash.length <= 16) return hash; + return `${hash.slice(0, 8)}...${hash.slice(-8)}`; +} + +function truncateText(text: string | undefined, maxLength: number = 30): string { + if (!text) return '-'; + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength)}...`; +} + +// === Export Functions === + +function exportToJSON(logs: EnhancedAuditLogEntry[]): string { + const exportData = logs.map(log => ({ + id: log.id, + timestamp: log.timestamp, + action: log.action, + actor: log.actor || null, + result: log.result || null, + targetResource: log.targetResource || null, + details: log.details || null, + ipAddress: log.ipAddress || null, + sessionId: log.sessionId || null, + hash: log.hash || null, + previousHash: log.previousHash || null, + })); + return JSON.stringify(exportData, null, 2); +} + +function exportToCSV(logs: EnhancedAuditLogEntry[]): string { + const headers = [ + 'ID', + 'Timestamp', + 'Action', + 'Actor', + 'Result', + 'Target Resource', + 'IP Address', + 'Session ID', + 'Hash', + 'Previous Hash' + ]; + + const rows = logs.map(log => [ + log.id, + log.timestamp, + `"${(log.action || '').replace(/"/g, '""')}"`, + `"${(log.actor || '').replace(/"/g, '""')}"`, + log.result || '', + `"${(log.targetResource || '').replace(/"/g, '""')}"`, + log.ipAddress || '', + log.sessionId || '', + log.hash || '', + log.previousHash || '', + ].join(',')); + + return [headers.join(','), ...rows].join('\n'); +} + +function downloadFile(content: string, filename: string, mimeType: string): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// === Sub-Components === + +interface FilterPanelProps { + filter: AuditLogFilter; + onFilterChange: (filter: AuditLogFilter) => void; + eventTypes: string[]; + actors: string[]; + onReset: () => void; +} + +function FilterPanel({ filter, onFilterChange, eventTypes, actors, onReset }: FilterPanelProps) { + // Helper to safely create time range with proper null handling + const handleStartTimeChange = (e: React.ChangeEvent) => { + const newStart = e.target.value ? new Date(e.target.value) : undefined; + const currentEnd = filter.timeRange?.end; + onFilterChange({ + ...filter, + timeRange: (newStart || currentEnd) ? { start: newStart!, end: currentEnd! } : undefined, + }); + }; + + const handleEndTimeChange = (e: React.ChangeEvent) => { + const newEnd = e.target.value ? new Date(e.target.value) : undefined; + const currentStart = filter.timeRange?.start; + onFilterChange({ + ...filter, + timeRange: (currentStart || newEnd) ? { start: currentStart!, end: newEnd! } : undefined, + }); + }; + + return ( +
+ {/* Time Range */} +
+ +
+ + +
+
+ + {/* Event Type */} +
+ + +
+ + {/* Actor */} +
+ + +
+ + {/* Result */} +
+ + +
+ + {/* Reset Button */} + +
+ ); +} + +interface LogDetailSidebarProps { + log: EnhancedAuditLogEntry | null; + onClose: () => void; + onVerify: (log: EnhancedAuditLogEntry) => void; + verificationResult: MerkleVerificationResult | null; + isVerifying: boolean; +} + +function LogDetailSidebar({ + log, + onClose, + onVerify, + verificationResult, + isVerifying, +}: LogDetailSidebarProps) { + if (!log) return null; + + return ( +
+
+

Log Details

+ +
+ +
+ {/* Timestamp */} +
+ Timestamp + {formatTimestamp(log.timestamp)} +
+ + {/* Action */} +
+ Action + {log.action} +
+ + {/* Actor */} +
+ Actor + {log.actor || '-'} +
+ + {/* Result */} +
+ Result + + {log.result === 'success' ? ( + + ) : log.result === 'failure' ? ( + + ) : null} + {log.result || '-'} + +
+ + {/* Target Resource */} + {log.targetResource && ( +
+ Target Resource + {log.targetResource} +
+ )} + + {/* IP Address */} + {log.ipAddress && ( +
+ IP Address + {log.ipAddress} +
+ )} + + {/* Session ID */} + {log.sessionId && ( +
+ Session ID + {log.sessionId} +
+ )} + + {/* Details */} + {log.details && Object.keys(log.details).length > 0 && ( +
+ Details +
+              {JSON.stringify(log.details, null, 2)}
+            
+
+ )} + + {/* Merkle Hash Section */} +
+

+ + Merkle Hash Chain +

+ + {/* Current Hash */} +
+ Current Hash + + {log.hash || 'Not available'} + +
+ + {/* Previous Hash */} +
+ Previous Hash + + {log.previousHash || 'Not available'} + +
+ + {/* Chain Index */} + {log.chainIndex !== undefined && ( +
+ Chain Index + {log.chainIndex} +
+ )} + + {/* Verify Button */} + + + {/* Verification Result */} + {verificationResult && ( +
+
+ {verificationResult.valid ? ( + + ) : ( + + )} + + {verificationResult.valid ? 'Chain Valid' : 'Chain Broken'} + +
+ {!verificationResult.valid && verificationResult.brokenAtIndex !== undefined && ( +

Broken at index: {verificationResult.brokenAtIndex}

+ )} +

Depth: {verificationResult.chainDepth}

+
+ )} +
+
+
+ ); +} + +interface HashChainVisualizationProps { + logs: EnhancedAuditLogEntry[]; + selectedIndex: number | null; + onSelect: (index: number) => void; + brokenAtIndex?: number; +} + +function HashChainVisualization({ logs, selectedIndex, onSelect, brokenAtIndex }: HashChainVisualizationProps) { + const visibleLogs = logs.slice(0, 10); // Show last 10 for visualization + + return ( +
+

+ + Hash Chain (Last {visibleLogs.length} entries) +

+
+ {visibleLogs.map((log, idx) => ( +
+ + {idx < visibleLogs.length - 1 && ( +
+ )} +
+ ))} +
+ {brokenAtIndex !== undefined && ( +

+ + Chain broken at index {brokenAtIndex} +

+ )} +
+ ); +} + +// === Main Component === export function AuditLogsPanel() { const { auditLogs, loadAuditLogs, isLoading } = useGatewayStore(); - const [limit, setLimit] = useState(50); + const client = getGatewayClient(); + // State + const [limit, setLimit] = useState(50); + const [searchTerm, setSearchTerm] = useState(''); + const [showFilters, setShowFilters] = useState(false); + const [filter, setFilter] = useState({}); + const [selectedLog, setSelectedLog] = useState(null); + const [verificationResult, setVerificationResult] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + const [isStreaming, setIsStreaming] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [chainBrokenAt, setChainBrokenAt] = useState(undefined); + + const logsEndRef = useRef(null); + const streamingRef = useRef<(() => void) | null>(null); + + // Load logs on mount and when limit changes useEffect(() => { loadAuditLogs({ limit }); }, [loadAuditLogs, limit]); - const formatTimestamp = (timestamp: string) => { - try { - return new Date(timestamp).toLocaleString('zh-CN'); - } catch { - return timestamp; + // Auto-scroll to latest when streaming + useEffect(() => { + if (isStreaming && !isPaused && logsEndRef.current) { + logsEndRef.current.scrollIntoView({ behavior: 'smooth' }); } - }; + }, [auditLogs, isStreaming, isPaused]); + + // Extract unique event types and actors for filters + const { eventTypes, actors } = useMemo(() => { + const types = new Set(); + const actorSet = new Set(); + + auditLogs.forEach((log) => { + if (log.action) types.add(log.action); + if (log.actor) actorSet.add(log.actor); + }); + + return { + eventTypes: Array.from(types).sort(), + actors: Array.from(actorSet).sort(), + }; + }, [auditLogs]); + + // Filter logs + const filteredLogs = useMemo(() => { + let logs = auditLogs as EnhancedAuditLogEntry[]; + + // Time range filter + if (filter.timeRange?.start) { + const startTime = filter.timeRange.start.getTime(); + logs = logs.filter((log) => new Date(log.timestamp).getTime() >= startTime); + } + if (filter.timeRange?.end) { + const endTime = filter.timeRange.end.getTime(); + logs = logs.filter((log) => new Date(log.timestamp).getTime() <= endTime); + } + + // Event type filter + if (filter.eventTypes?.length) { + logs = logs.filter((log) => filter.eventTypes!.includes(log.action)); + } + + // Actor filter + if (filter.actors?.length) { + logs = logs.filter((log) => log.actor && filter.actors!.includes(log.actor)); + } + + // Result filter + if (filter.result) { + logs = logs.filter((log) => log.result === filter.result); + } + + // Search term + if (searchTerm.trim()) { + const term = searchTerm.toLowerCase(); + logs = logs.filter((log) => + log.action.toLowerCase().includes(term) || + (log.actor?.toLowerCase().includes(term)) || + (log.details && JSON.stringify(log.details).toLowerCase().includes(term)) + ); + } + + return logs; + }, [auditLogs, filter, searchTerm]); + + // Real-time streaming setup + const startStreaming = useCallback(() => { + if (streamingRef.current) return; + + const unsubscribe = client.on('audit_log', () => { + if (!isPaused) { + // Reload logs when new audit log event arrives + loadAuditLogs({ limit }); + } + }); + + streamingRef.current = unsubscribe; + setIsStreaming(true); + }, [client, isPaused, loadAuditLogs, limit]); + + const stopStreaming = useCallback(() => { + if (streamingRef.current) { + streamingRef.current(); + streamingRef.current = null; + } + setIsStreaming(false); + }, []); + + const togglePause = useCallback(() => { + setIsPaused((prev) => !prev); + }, []); + + // Verify Merkle chain + const verifyChain = useCallback(async (log: EnhancedAuditLogEntry) => { + setIsVerifying(true); + setVerificationResult(null); + + try { + // Call OpenFang API to verify the chain + const result = await client.verifyAuditLogChain(log.id); + + const verification: MerkleVerificationResult = { + valid: result.valid ?? true, + chainDepth: result.chain_depth ?? 0, + rootHash: result.root_hash ?? '', + previousHash: log.previousHash ?? '', + currentHash: log.hash ?? '', + brokenAtIndex: result.broken_at_index, + }; + + setVerificationResult(verification); + + if (!verification.valid && verification.brokenAtIndex !== undefined) { + setChainBrokenAt(verification.brokenAtIndex); + } + } catch { + // If API not available, do client-side verification + const logIndex = auditLogs.findIndex((l) => l.id === log.id); + const previousLog = logIndex > 0 ? (auditLogs[logIndex - 1] as EnhancedAuditLogEntry) : null; + + // Simple verification: check if previous hash matches + const isValid = !previousLog || log.previousHash === previousLog.hash; + + setVerificationResult({ + valid: isValid, + chainDepth: logIndex + 1, + rootHash: (auditLogs[0] as EnhancedAuditLogEntry)?.hash ?? '', + previousHash: log.previousHash ?? '', + currentHash: log.hash ?? '', + brokenAtIndex: isValid ? undefined : logIndex, + }); + + if (!isValid) { + setChainBrokenAt(logIndex); + } + } finally { + setIsVerifying(false); + } + }, [client, auditLogs]); + + // Export handlers + const handleExportJSON = useCallback(() => { + const content = exportToJSON(filteredLogs); + downloadFile( + content, + `audit-logs-${new Date().toISOString().slice(0, 10)}.json`, + 'application/json' + ); + }, [filteredLogs]); + + const handleExportCSV = useCallback(() => { + const content = exportToCSV(filteredLogs); + downloadFile( + content, + `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`, + 'text/csv' + ); + }, [filteredLogs]); + + const handleResetFilters = useCallback(() => { + setFilter({}); + setSearchTerm(''); + }, []); const resultColor = { success: 'text-green-600 dark:text-green-400', @@ -30,76 +705,252 @@ export function AuditLogsPanel() { if (isLoading && auditLogs.length === 0) { return ( -
- 加载中... +
+ + Loading...
); } return ( -
-
-

- 审计日志 -

-
- - +
+ {/* Main Content */} +
+ {/* Header */} +
+
+

+ Audit Logs +

+ {/* Real-time streaming controls */} +
+ {isStreaming ? ( + <> + + + + + {isPaused ? 'Paused' : 'Live'} + + + ) : ( + + )} +
+
+
+ {/* Search */} +
+ + setSearchTerm(e.target.value)} + placeholder="Search..." + className="pl-8 pr-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 w-48" + /> +
+ + {/* Filter Toggle */} + + + {/* Export */} +
+ + +
+ + {/* Limit selector */} + + + {/* Refresh */} + +
+
+ + {/* Filter Panel */} + {showFilters && ( +
+ +
+ )} + + {/* Hash Chain Visualization */} + {filteredLogs.length > 0 && ( +
+ l.id === selectedLog.id) : null} + onSelect={(idx) => setSelectedLog(filteredLogs[idx])} + brokenAtIndex={chainBrokenAt} + /> +
+ )} + + {/* Log Table */} +
+ {filteredLogs.length === 0 ? ( +
+ +

No audit logs found

+ {(searchTerm || Object.keys(filter).length > 0) && ( + + )} +
+ ) : ( + + + + + + + + + + + + {filteredLogs.map((log, index) => ( + setSelectedLog(log)} + className={`border-b border-gray-100 dark:border-gray-800 cursor-pointer transition-colors ${ + selectedLog?.id === log.id + ? 'bg-blue-50 dark:bg-blue-900/20' + : chainBrokenAt === index + ? 'bg-red-50 dark:bg-red-900/10 hover:bg-red-100 dark:hover:bg-red-900/20' + : 'hover:bg-gray-50 dark:hover:bg-gray-800/50' + }`} + > + + + + + + + ))} + +
TimeActionActorResultHash
+ {formatTimestamp(log.timestamp)} + + {truncateText(log.action, 40)} + + {truncateText(log.actor, 20)} + + + {log.result === 'success' ? ( + + ) : log.result === 'failure' ? ( + + ) : null} + {log.result === 'success' ? 'OK' : log.result === 'failure' ? 'Fail' : '-'} + + + {formatHash(log.hash)} +
+ )} +
+
+ + {/* Stats Footer */} +
+ + Showing {filteredLogs.length} of {auditLogs.length} logs + + + {chainBrokenAt !== undefined && ( + + Chain integrity: BROKEN at index {chainBrokenAt} + + )} +
- {auditLogs.length === 0 ? ( -
- 暂无审计日志 -
- ) : ( -
- - - - - - - - - - - {auditLogs.map((log, index) => ( - - - - - - - ))} - -
时间操作执行者结果
- {formatTimestamp(log.timestamp)} - - {log.action} - - {log.actor || '-'} - - {log.result === 'success' ? '成功' : log.result === 'failure' ? '失败' : '-'} -
-
+ {/* Detail Sidebar */} + {selectedLog && ( + { + setSelectedLog(null); + setVerificationResult(null); + }} + onVerify={verifyChain} + verificationResult={verificationResult} + isVerifying={isVerifying} + /> )}
); diff --git a/desktop/src/components/ChannelList.tsx b/desktop/src/components/ChannelList.tsx index 35c3482..46952af 100644 --- a/desktop/src/components/ChannelList.tsx +++ b/desktop/src/components/ChannelList.tsx @@ -8,6 +8,13 @@ const CHANNEL_ICONS: Record = { wechat: '微', }; +// 可用频道类型(用于显示未配置的频道) +const AVAILABLE_CHANNEL_TYPES = [ + { type: 'feishu', name: '飞书 (Feishu)' }, + { type: 'wechat', name: '微信' }, + { type: 'qqbot', name: 'QQ 机器人' }, +]; + interface ChannelListProps { onOpenSettings?: () => void; } @@ -27,6 +34,17 @@ export function ChannelList({ onOpenSettings }: ChannelListProps) { loadPluginStatus().then(() => loadChannels()); }; + // 去重:基于 channel id + const uniqueChannels = channels.filter((ch, index, self) => + index === self.findIndex(c => c.id === ch.id) + ); + + // 获取已配置的频道类型 + const configuredTypes = new Set(uniqueChannels.map(c => c.type)); + + // 未配置的频道类型 + const unconfiguredTypes = AVAILABLE_CHANNEL_TYPES.filter(ct => !configuredTypes.has(ct.type)); + if (!connected) { return (
@@ -53,7 +71,7 @@ export function ChannelList({ onOpenSettings }: ChannelListProps) {
{/* Configured channels */} - {channels.map((ch) => ( + {uniqueChannels.map((ch) => (
))} - {/* Always show available channels that aren't configured */} - {!channels.find(c => c.type === 'feishu') && ( -
+ {/* Unconfigured channels - 只显示一次 */} + {unconfiguredTypes.map((ct) => ( +
- 飞 + {CHANNEL_ICONS[ct.type] || }
-
飞书 (Feishu)
+
{ct.name}
未配置
- )} - {!channels.find(c => c.type === 'qqbot') && ( -
-
- QQ -
-
-
QQ 机器人
-
未安装插件
-
-
- )} + ))} {/* Help text */}
diff --git a/desktop/src/components/CreateTriggerModal.tsx b/desktop/src/components/CreateTriggerModal.tsx new file mode 100644 index 0000000..1c3787b --- /dev/null +++ b/desktop/src/components/CreateTriggerModal.tsx @@ -0,0 +1,538 @@ +/** + * CreateTriggerModal - Modal for creating event triggers + * + * Supports trigger types: + * - webhook: External HTTP request trigger + * - event: OpenFang internal event trigger + * - message: Chat message pattern trigger + */ + +import { useState, useEffect, useCallback } from 'react'; +import { useGatewayStore } from '../store/gatewayStore'; +import { + Zap, + X, + AlertCircle, + CheckCircle, + Loader2, + Globe, + MessageSquare, + Bell, +} from 'lucide-react'; + +// === Types === + +type TriggerType = 'webhook' | 'event' | 'message'; +type TargetType = 'hand' | 'workflow'; + +interface TriggerFormData { + name: string; + type: TriggerType; + pattern: string; + targetType: TargetType; + targetId: string; + webhookPath: string; + eventType: string; + enabled: boolean; +} + +interface CreateTriggerModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +const initialFormData: TriggerFormData = { + name: '', + type: 'webhook', + pattern: '', + targetType: 'hand', + targetId: '', + webhookPath: '', + eventType: '', + enabled: true, +}; + +// === Trigger Type Options === + +const triggerTypeOptions: Array<{ + value: TriggerType; + label: string; + description: string; + icon: React.ComponentType<{ className?: string }>; +}> = [ + { + value: 'webhook', + label: 'Webhook', + description: 'External HTTP request trigger', + icon: Globe, + }, + { + value: 'event', + label: 'Event', + description: 'OpenFang internal event trigger', + icon: Bell, + }, + { + value: 'message', + label: 'Message', + description: 'Chat message pattern trigger', + icon: MessageSquare, + }, +]; + +// === Event Type Options === + +const eventTypeOptions = [ + { value: 'file_changed', label: 'File Changed' }, + { value: 'agent_started', label: 'Agent Started' }, + { value: 'agent_stopped', label: 'Agent Stopped' }, + { value: 'hand_completed', label: 'Hand Completed' }, + { value: 'workflow_completed', label: 'Workflow Completed' }, + { value: 'session_created', label: 'Session Created' }, + { value: 'custom', label: 'Custom Event' }, +]; + +// === Component === + +export function CreateTriggerModal({ isOpen, onClose, onSuccess }: CreateTriggerModalProps) { + const { hands, workflows, createTrigger, loadHands, loadWorkflows } = useGatewayStore(); + const [formData, setFormData] = useState(initialFormData); + const [errors, setErrors] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle'); + const [errorMessage, setErrorMessage] = useState(''); + + // Load available targets on mount + useEffect(() => { + if (isOpen) { + loadHands(); + loadWorkflows(); + } + }, [isOpen, loadHands, loadWorkflows]); + + // Reset form when modal opens + useEffect(() => { + if (isOpen) { + setFormData(initialFormData); + setErrors({}); + setSubmitStatus('idle'); + setErrorMessage(''); + } + }, [isOpen]); + + // Validate form + const validateForm = useCallback((): boolean => { + const newErrors: Record = {}; + + if (!formData.name.trim()) { + newErrors.name = 'Trigger name is required'; + } + + switch (formData.type) { + case 'webhook': + if (!formData.webhookPath.trim()) { + newErrors.webhookPath = 'Webhook path is required'; + } else if (!formData.webhookPath.startsWith('/')) { + newErrors.webhookPath = 'Webhook path must start with /'; + } + break; + case 'event': + if (!formData.eventType) { + newErrors.eventType = 'Event type is required'; + } + break; + case 'message': + if (!formData.pattern.trim()) { + newErrors.pattern = 'Pattern is required'; + } else { + // Validate regex pattern + try { + new RegExp(formData.pattern); + } catch { + newErrors.pattern = 'Invalid regular expression pattern'; + } + } + break; + } + + if (!formData.targetId) { + newErrors.targetId = 'Please select a target'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }, [formData]); + + // Handle form submission + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + setSubmitStatus('idle'); + setErrorMessage(''); + + try { + // Build config based on trigger type + const config: Record = {}; + + switch (formData.type) { + case 'webhook': + config.path = formData.webhookPath; + break; + case 'event': + config.eventType = formData.eventType; + break; + case 'message': + config.pattern = formData.pattern; + break; + } + + await createTrigger({ + type: formData.type, + name: formData.name.trim(), + enabled: formData.enabled, + config, + handName: formData.targetType === 'hand' ? formData.targetId : undefined, + workflowId: formData.targetType === 'workflow' ? formData.targetId : undefined, + }); + + setSubmitStatus('success'); + setTimeout(() => { + onSuccess(); + onClose(); + }, 1500); + } catch (err) { + setSubmitStatus('error'); + setErrorMessage(err instanceof Error ? err.message : 'Failed to create trigger'); + } finally { + setIsSubmitting(false); + } + }; + + // Update form field + const updateField = (field: K, value: TriggerFormData[K]) => { + setFormData(prev => ({ ...prev, [field]: value })); + // Clear error when field is updated + if (errors[field]) { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors[field]; + return newErrors; + }); + } + }; + + // Get available targets based on type + const getAvailableTargets = (): Array<{ id: string; name?: string }> => { + switch (formData.targetType) { + case 'hand': + return hands.map(h => ({ id: h.id, name: h.name })); + case 'workflow': + return workflows.map(w => ({ id: w.id, name: w.name })); + default: + return []; + } + }; + + if (!isOpen) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+
+ +
+
+

+ Create Event Trigger +

+

+ Create a trigger to respond to events +

+
+
+ +
+ + {/* Form */} +
+ {/* Trigger Name */} +
+ + updateField('name', e.target.value)} + placeholder="e.g., Daily Report Webhook" + className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 ${ + errors.name ? 'border-red-500' : 'border-gray-300 dark:border-gray-600' + }`} + /> + {errors.name && ( +

+ + {errors.name} +

+ )} +
+ + {/* Trigger Type */} +
+ +
+ {triggerTypeOptions.map((option) => { + const Icon = option.icon; + return ( + + ); + })} +
+

+ {triggerTypeOptions.find(o => o.value === formData.type)?.description} +

+
+ + {/* Webhook Path */} + {formData.type === 'webhook' && ( +
+ + updateField('webhookPath', e.target.value)} + placeholder="/api/webhooks/daily-report" + className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 font-mono ${ + errors.webhookPath ? 'border-red-500' : 'border-gray-300 dark:border-gray-600' + }`} + /> + {errors.webhookPath && ( +

+ + {errors.webhookPath} +

+ )} +

+ The URL path that will trigger this action when called +

+
+ )} + + {/* Event Type */} + {formData.type === 'event' && ( +
+ + + {errors.eventType && ( +

+ + {errors.eventType} +

+ )} +
+ )} + + {/* Message Pattern */} + {formData.type === 'message' && ( +
+ + updateField('pattern', e.target.value)} + placeholder="^/report" + className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 font-mono ${ + errors.pattern ? 'border-red-500' : 'border-gray-300 dark:border-gray-600' + }`} + /> + {errors.pattern && ( +

+ + {errors.pattern} +

+ )} +

+ Regular expression pattern to match chat messages +

+
+ )} + + {/* Target Selection */} +
+ +
+ {[ + { value: 'hand', label: 'Hand' }, + { value: 'workflow', label: 'Workflow' }, + ].map((option) => ( + + ))} +
+
+ + {/* Target Selection Dropdown */} +
+ + + {errors.targetId && ( +

+ + {errors.targetId} +

+ )} + {getAvailableTargets().length === 0 && ( +

+ No {formData.targetType === 'hand' ? 'Hands' : 'Workflows'} available +

+ )} +
+ + {/* Enabled Toggle */} +
+ updateField('enabled', e.target.checked)} + className="w-4 h-4 text-amber-600 border-gray-300 rounded focus:ring-amber-500" + /> + +
+ + {/* Status Messages */} + {submitStatus === 'success' && ( +
+ + Trigger created successfully! +
+ )} + {submitStatus === 'error' && ( +
+ + {errorMessage} +
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +} + +export default CreateTriggerModal; diff --git a/desktop/src/components/HandApprovalModal.tsx b/desktop/src/components/HandApprovalModal.tsx new file mode 100644 index 0000000..4623c13 --- /dev/null +++ b/desktop/src/components/HandApprovalModal.tsx @@ -0,0 +1,571 @@ +/** + * HandApprovalModal - Modal for approving/rejecting Hand executions + * + * Provides detailed view of Hand execution request with: + * - Hand name and description + * - Trigger parameters + * - Expected output/impact + * - Risk level indicator + * - Approval timeout countdown + * - Approve/Reject buttons + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { + X, + AlertTriangle, + CheckCircle, + XCircle, + Clock, + Loader2, + Shield, + Zap, + Info, +} from 'lucide-react'; +import type { HandRun } from '../store/gatewayStore'; +import { HAND_DEFINITIONS, type HandId } from '../types/hands'; + +// === Types === + +export type RiskLevel = 'low' | 'medium' | 'high'; + +export interface HandApprovalData { + runId: string; + handId: HandId; + handName: string; + description: string; + params: Record; + riskLevel: RiskLevel; + expectedImpact?: string; + requestedAt: string; + requestedBy?: string; + timeoutSeconds?: number; +} + +interface HandApprovalModalProps { + handRun: HandRun | null; + isOpen: boolean; + onApprove: (runId: string) => Promise; + onReject: (runId: string, reason: string) => Promise; + onClose: () => void; +} + +// === Risk Level Config === + +const RISK_CONFIG: Record< + RiskLevel, + { label: string; color: string; bgColor: string; borderColor: string; icon: typeof AlertTriangle } +> = { + low: { + label: 'Low Risk', + color: 'text-green-600 dark:text-green-400', + bgColor: 'bg-green-100 dark:bg-green-900/30', + borderColor: 'border-green-300 dark:border-green-700', + icon: CheckCircle, + }, + medium: { + label: 'Medium Risk', + color: 'text-yellow-600 dark:text-yellow-400', + bgColor: 'bg-yellow-100 dark:bg-yellow-900/30', + borderColor: 'border-yellow-300 dark:border-yellow-700', + icon: AlertTriangle, + }, + high: { + label: 'High Risk', + color: 'text-red-600 dark:text-red-400', + bgColor: 'bg-red-100 dark:bg-red-900/30', + borderColor: 'border-red-300 dark:border-red-700', + icon: AlertTriangle, + }, +}; + +// === Helper Functions === + +function calculateRiskLevel(handId: HandId, params: Record): RiskLevel { + // Risk assessment based on Hand type and parameters + switch (handId) { + case 'browser': + // Browser automation can be high risk if interacting with sensitive sites + const url = String(params.url || '').toLowerCase(); + if (url.includes('bank') || url.includes('payment') || url.includes('admin')) { + return 'high'; + } + if (params.headless === false) { + return 'medium'; // Non-headless mode is more visible + } + return 'low'; + + case 'twitter': + // Twitter actions can have public impact + const action = String(params.action || ''); + if (action === 'post' || action === 'engage') { + return 'high'; // Public posting is high impact + } + return 'medium'; + + case 'collector': + // Data collection depends on scope + if (params.pagination === true) { + return 'medium'; // Large scale collection + } + return 'low'; + + case 'lead': + // Lead generation accesses external data + return 'medium'; + + case 'clip': + // Video processing is generally safe + return 'low'; + + case 'predictor': + // Predictions are read-only + return 'low'; + + case 'researcher': + // Research is generally safe + const depth = String(params.depth || ''); + return depth === 'deep' ? 'medium' : 'low'; + + default: + return 'medium'; + } +} + +function getExpectedImpact(handId: HandId, params: Record): string { + switch (handId) { + case 'browser': + return `Will perform browser automation on ${params.url || 'specified URL'}`; + case 'twitter': + if (params.action === 'post') { + return 'Will post content to Twitter/X publicly'; + } + if (params.action === 'engage') { + return 'Will like/reply to tweets'; + } + return 'Will perform Twitter/X operations'; + case 'collector': + return `Will collect data from ${params.targetUrl || 'specified source'}`; + case 'lead': + return `Will search for leads from ${params.source || 'specified source'}`; + case 'clip': + return `Will process video: ${params.inputPath || 'specified input'}`; + case 'predictor': + return `Will run prediction on ${params.dataSource || 'specified data'}`; + case 'researcher': + return `Will conduct research on: ${params.topic || 'specified topic'}`; + default: + return 'Will execute Hand operation'; + } +} + +function formatTimeRemaining(seconds: number): string { + if (seconds <= 0) return 'Expired'; + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${minutes}m ${secs}s`; +} + +// === Countdown Timer Hook === + +function useCountdown(startedAt: string, timeoutSeconds: number = 300) { + const [timeRemaining, setTimeRemaining] = useState(0); + const [isExpired, setIsExpired] = useState(false); + + useEffect(() => { + const started = new Date(startedAt).getTime(); + const expiresAt = started + timeoutSeconds * 1000; + + const updateTimer = () => { + const now = Date.now(); + const remaining = Math.max(0, Math.floor((expiresAt - now) / 1000)); + setTimeRemaining(remaining); + setIsExpired(remaining <= 0); + }; + + updateTimer(); + const interval = setInterval(updateTimer, 1000); + + return () => clearInterval(interval); + }, [startedAt, timeoutSeconds]); + + return { timeRemaining, isExpired }; +} + +// === Sub-components === + +function RiskBadge({ level }: { level: RiskLevel }) { + const config = RISK_CONFIG[level]; + const Icon = config.icon; + + return ( +
+ + {config.label} +
+ ); +} + +function TimeoutProgress({ timeRemaining, totalSeconds }: { timeRemaining: number; totalSeconds: number }) { + const percentage = Math.max(0, Math.min(100, (timeRemaining / totalSeconds) * 100)); + const isUrgent = timeRemaining < 60; + + return ( +
+
+ + + Time Remaining + + + {formatTimeRemaining(timeRemaining)} + +
+
+
+
+
+ ); +} + +function ParamsDisplay({ params }: { params: Record }) { + if (!params || Object.keys(params).length === 0) { + return ( +

No parameters provided

+ ); + } + + return ( +
+
+        {JSON.stringify(params, null, 2)}
+      
+
+ ); +} + +// === Main Component === + +export function HandApprovalModal({ + handRun, + isOpen, + onApprove, + onReject, + onClose, +}: HandApprovalModalProps) { + const [isProcessing, setIsProcessing] = useState(false); + const [showRejectInput, setShowRejectInput] = useState(false); + const [rejectReason, setRejectReason] = useState(''); + const [error, setError] = useState(null); + + // Parse HandRun to get approval data + const approvalData = useMemo((): HandApprovalData | null => { + if (!handRun) return null; + + // Extract hand ID from run data (could be stored in result or need to be passed separately) + const result = handRun.result as Record | undefined; + const handId = (result?.handId as HandId) || 'researcher'; // Default fallback + const params = (result?.params as Record) || {}; + const handDef = HAND_DEFINITIONS.find((h) => h.id === handId); + + return { + runId: handRun.runId, + handId, + handName: handDef?.name || handId, + description: handDef?.description || 'Hand execution request', + params, + riskLevel: calculateRiskLevel(handId, params), + expectedImpact: getExpectedImpact(handId, params), + requestedAt: handRun.startedAt, + timeoutSeconds: 300, // 5 minutes default timeout + }; + }, [handRun]); + + // Timer countdown + const { timeRemaining, isExpired } = useCountdown( + approvalData?.requestedAt || new Date().toISOString(), + approvalData?.timeoutSeconds || 300 + ); + + // Handle keyboard escape + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) { + onClose(); + } + }; + + window.addEventListener('keydown', handleEscape); + return () => window.removeEventListener('keydown', handleEscape); + }, [isOpen, onClose]); + + // Reset state when modal opens/closes + useEffect(() => { + if (isOpen) { + setShowRejectInput(false); + setRejectReason(''); + setError(null); + setIsProcessing(false); + } + }, [isOpen]); + + const handleApprove = useCallback(async () => { + if (!approvalData || isProcessing || isExpired) return; + + setIsProcessing(true); + setError(null); + + try { + await onApprove(approvalData.runId); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to approve'); + } finally { + setIsProcessing(false); + } + }, [approvalData, isProcessing, isExpired, onApprove, onClose]); + + const handleReject = useCallback(async () => { + if (!approvalData || isProcessing) return; + + if (!showRejectInput) { + setShowRejectInput(true); + return; + } + + if (!rejectReason.trim()) { + setError('Please provide a reason for rejection'); + return; + } + + setIsProcessing(true); + setError(null); + + try { + await onReject(approvalData.runId, rejectReason.trim()); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to reject'); + } finally { + setIsProcessing(false); + } + }, [approvalData, isProcessing, showRejectInput, rejectReason, onReject, onClose]); + + const handleCancelReject = useCallback(() => { + setShowRejectInput(false); + setRejectReason(''); + setError(null); + }, []); + + if (!isOpen || !approvalData) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+
+ +
+
+

+ Hand Approval Request +

+

+ Review and approve Hand execution +

+
+
+ +
+ + {/* Content */} +
+ {/* Expired Warning */} + {isExpired && ( +
+ + This approval request has expired +
+ )} + + {/* Hand Info */} +
+
+
+ +

+ {approvalData.handName} +

+
+ +
+

+ {approvalData.description} +

+
+ + {/* Timeout Progress */} + {!isExpired && ( + + )} + + {/* Parameters */} +
+ + +
+ + {/* Expected Impact */} + {approvalData.expectedImpact && ( +
+ +

+ {approvalData.expectedImpact} +

+
+ )} + + {/* Request Info */} +
+

Run ID: {approvalData.runId}

+

+ Requested: {new Date(approvalData.requestedAt).toLocaleString()} +

+
+ + {/* Reject Input */} + {showRejectInput && ( +
+ +