feat(dashboard): add localized mock dashboard and mobile smoke coverage
This commit is contained in:
parent
a300878f39
commit
d56ad644af
@ -856,7 +856,7 @@ fn normalize_cached_channel_turns(turns: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
}
|
||||
|
||||
fn supports_runtime_model_switch(channel_name: &str) -> bool {
|
||||
matches!(channel_name, "telegram" | "discord")
|
||||
!channel_name.eq_ignore_ascii_case("cli")
|
||||
}
|
||||
|
||||
fn parse_runtime_command(channel_name: &str, content: &str) -> Option<ChannelRuntimeCommand> {
|
||||
@ -2258,6 +2258,7 @@ async fn handle_runtime_command_if_needed(
|
||||
/// - Grant session and persistent runtime grants
|
||||
/// - Persist to config
|
||||
/// - Clear exclusions
|
||||
///
|
||||
/// Returns the approval success message.
|
||||
async fn handle_confirm_tool_approval_side_effects(
|
||||
ctx: &ChannelRuntimeContext,
|
||||
@ -2300,7 +2301,7 @@ async fn handle_runtime_command_if_needed(
|
||||
///
|
||||
/// This path confirms only the current pending request and intentionally does
|
||||
/// not persist approval policy changes for normal tools.
|
||||
async fn handle_pending_runtime_approval_side_effects(
|
||||
fn handle_pending_runtime_approval_side_effects(
|
||||
ctx: &ChannelRuntimeContext,
|
||||
request_id: &str,
|
||||
tool_name: &str,
|
||||
@ -2784,8 +2785,7 @@ async fn handle_runtime_command_if_needed(
|
||||
ctx,
|
||||
&request_id,
|
||||
&req.tool_name,
|
||||
)
|
||||
.await;
|
||||
);
|
||||
runtime_trace::record_event(
|
||||
"approval_request_approved",
|
||||
Some(source_channel),
|
||||
@ -5792,7 +5792,6 @@ mod tests {
|
||||
use crate::memory::{Memory, MemoryCategory, SqliteMemory};
|
||||
use crate::observability::NoopObserver;
|
||||
use crate::providers::{ChatMessage, Provider};
|
||||
use crate::security::AutonomyLevel;
|
||||
use crate::tools::{Tool, ToolResult};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
@ -5864,7 +5863,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_runtime_command_allows_approval_commands_on_non_model_channels() {
|
||||
fn parse_runtime_command_allows_runtime_commands_on_non_cli_channels() {
|
||||
assert_eq!(
|
||||
parse_runtime_command("slack", "/approve-request shell"),
|
||||
Some(ChannelRuntimeCommand::RequestToolApproval(
|
||||
@ -5909,7 +5908,16 @@ mod tests {
|
||||
parse_runtime_command("slack", "/approvals"),
|
||||
Some(ChannelRuntimeCommand::ListApprovals)
|
||||
);
|
||||
assert_eq!(parse_runtime_command("slack", "/models"), None);
|
||||
assert_eq!(
|
||||
parse_runtime_command("slack", "/models"),
|
||||
Some(ChannelRuntimeCommand::ShowProviders)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_runtime_command_keeps_model_switch_disabled_on_cli_channel() {
|
||||
assert_eq!(parse_runtime_command("cli", "/models"), None);
|
||||
assert_eq!(parse_runtime_command("cli", "/model"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -6213,7 +6221,7 @@ mod tests {
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(histories)),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -6896,7 +6904,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -6961,14 +6969,6 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
|
||||
let mut channels_by_name = HashMap::new();
|
||||
channels_by_name.insert(channel.name().to_string(), channel);
|
||||
|
||||
let autonomy_cfg = crate::config::AutonomyConfig {
|
||||
level: AutonomyLevel::Full,
|
||||
auto_approve: vec!["mock_price".to_string()],
|
||||
..crate::config::AutonomyConfig::default()
|
||||
};
|
||||
let approval_manager = Arc::new(ApprovalManager::from_config(&autonomy_cfg));
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Arc::new(ToolCallingProvider),
|
||||
@ -6983,7 +6983,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -7035,14 +7035,6 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
|
||||
let mut channels_by_name = HashMap::new();
|
||||
channels_by_name.insert(channel.name().to_string(), channel);
|
||||
|
||||
let autonomy_cfg = crate::config::AutonomyConfig {
|
||||
level: AutonomyLevel::Full,
|
||||
auto_approve: vec!["mock_price".to_string()],
|
||||
..crate::config::AutonomyConfig::default()
|
||||
};
|
||||
let approval_manager = Arc::new(ApprovalManager::from_config(&autonomy_cfg));
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Arc::new(ToolCallingProvider),
|
||||
@ -7057,7 +7049,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -7123,14 +7115,6 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
|
||||
let mut channels_by_name = HashMap::new();
|
||||
channels_by_name.insert(channel.name().to_string(), channel);
|
||||
|
||||
let autonomy_cfg = crate::config::AutonomyConfig {
|
||||
level: AutonomyLevel::Full,
|
||||
auto_approve: vec!["mock_price".to_string()],
|
||||
..crate::config::AutonomyConfig::default()
|
||||
};
|
||||
let approval_manager = Arc::new(ApprovalManager::from_config(&autonomy_cfg));
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Arc::new(ToolCallingProvider),
|
||||
@ -7145,7 +7129,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -7210,14 +7194,6 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
|
||||
let mut channels_by_name = HashMap::new();
|
||||
channels_by_name.insert(channel.name().to_string(), channel);
|
||||
|
||||
let autonomy_cfg = crate::config::AutonomyConfig {
|
||||
level: AutonomyLevel::Full,
|
||||
auto_approve: vec!["mock_price".to_string()],
|
||||
..crate::config::AutonomyConfig::default()
|
||||
};
|
||||
let approval_manager = Arc::new(ApprovalManager::from_config(&autonomy_cfg));
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Arc::new(ToolCallingProvider),
|
||||
@ -7232,7 +7208,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -7304,7 +7280,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -7356,14 +7332,6 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
|
||||
let mut channels_by_name = HashMap::new();
|
||||
channels_by_name.insert(channel.name().to_string(), channel);
|
||||
|
||||
let autonomy_cfg = crate::config::AutonomyConfig {
|
||||
level: AutonomyLevel::Full,
|
||||
auto_approve: vec!["mock_price".to_string()],
|
||||
..crate::config::AutonomyConfig::default()
|
||||
};
|
||||
let approval_manager = Arc::new(ApprovalManager::from_config(&autonomy_cfg));
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Arc::new(ToolCallingAliasProvider),
|
||||
@ -7378,7 +7346,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -7454,7 +7422,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -7558,7 +7526,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -7699,7 +7667,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -7788,7 +7756,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -7866,7 +7834,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -8027,7 +7995,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -8142,7 +8110,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -8252,7 +8220,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -8347,7 +8315,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -8449,7 +8417,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -8549,7 +8517,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -8700,7 +8668,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -8797,7 +8765,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -8947,7 +8915,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -9067,7 +9035,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -9167,7 +9135,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -9289,7 +9257,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -9409,7 +9377,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -9488,7 +9456,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -9590,7 +9558,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
@ -9777,7 +9745,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -9939,7 +9907,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 12,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -10007,7 +9975,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 3,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -10187,7 +10155,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -10277,7 +10245,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -10379,7 +10347,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -10463,7 +10431,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -10532,7 +10500,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -11163,7 +11131,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -11259,7 +11227,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -11354,7 +11322,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -11453,7 +11421,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(histories)),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -12281,7 +12249,7 @@ BTC is currently around $65,000 based on latest tool output."#;
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
@ -12357,7 +12325,7 @@ BTC is currently around $65,000 based on latest tool output."#;
|
||||
max_tool_iterations: 5,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
conversation_locks: Default::default(),
|
||||
conversation_locks: Arc::default(),
|
||||
session_config: crate::config::AgentSessionConfig::default(),
|
||||
session_manager: None,
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! All `/api/*` routes require bearer token authentication (PairingGuard).
|
||||
|
||||
use super::AppState;
|
||||
use super::{mock_dashboard, AppState};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
@ -76,6 +76,9 @@ pub async fn handle_api_status(
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::status();
|
||||
}
|
||||
|
||||
let config = state.config.lock().clone();
|
||||
let health = crate::health::snapshot();
|
||||
@ -110,6 +113,9 @@ pub async fn handle_api_config_get(
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::config_get();
|
||||
}
|
||||
|
||||
let config = state.config.lock().clone();
|
||||
|
||||
@ -142,6 +148,9 @@ pub async fn handle_api_config_put(
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::config_put(body);
|
||||
}
|
||||
|
||||
// Parse the incoming TOML and normalize known dashboard-masked edge cases.
|
||||
let mut incoming_toml: toml::Value = match toml::from_str(&body) {
|
||||
@ -200,6 +209,9 @@ pub async fn handle_api_tools(
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::tools();
|
||||
}
|
||||
|
||||
let tools: Vec<serde_json::Value> = state
|
||||
.tools_registry
|
||||
@ -224,6 +236,9 @@ pub async fn handle_api_cron_list(
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::cron_list();
|
||||
}
|
||||
|
||||
let config = state.config.lock().clone();
|
||||
match crate::cron::list_jobs(&config) {
|
||||
@ -261,6 +276,9 @@ pub async fn handle_api_cron_add(
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::cron_add(body.name, body.schedule, body.command, None);
|
||||
}
|
||||
|
||||
let config = state.config.lock().clone();
|
||||
let schedule = crate::cron::Schedule::Cron {
|
||||
@ -296,6 +314,9 @@ pub async fn handle_api_cron_delete(
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::cron_delete(&id);
|
||||
}
|
||||
|
||||
let config = state.config.lock().clone();
|
||||
match crate::cron::remove_job(&config, &id) {
|
||||
@ -316,6 +337,9 @@ pub async fn handle_api_integrations(
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::integrations();
|
||||
}
|
||||
|
||||
let config = state.config.lock().clone();
|
||||
let entries = crate::integrations::registry::all_integrations();
|
||||
@ -336,6 +360,342 @@ pub async fn handle_api_integrations(
|
||||
Json(serde_json::json!({"integrations": integrations})).into_response()
|
||||
}
|
||||
|
||||
/// GET /api/integrations/settings — detailed settings for each integration
|
||||
pub async fn handle_api_integrations_settings(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::integrations_settings();
|
||||
}
|
||||
|
||||
let config = state.config.lock().clone();
|
||||
let entries = crate::integrations::registry::all_integrations();
|
||||
|
||||
let active_default_provider_id = config
|
||||
.default_provider
|
||||
.as_ref()
|
||||
.and_then(|p| integration_id_from_provider(p));
|
||||
|
||||
let integrations: Vec<serde_json::Value> = entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let status = (entry.status_fn)(&config);
|
||||
let (configured, fields) = integration_settings_fields(&config, entry.name);
|
||||
let activates_default_provider = is_ai_provider(entry.name);
|
||||
|
||||
serde_json::json!({
|
||||
"id": integration_name_to_id(entry.name),
|
||||
"name": entry.name,
|
||||
"description": entry.description,
|
||||
"category": entry.category,
|
||||
"status": status,
|
||||
"configured": configured,
|
||||
"activates_default_provider": activates_default_provider,
|
||||
"fields": fields,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Json(serde_json::json!({
|
||||
"revision": "v1",
|
||||
"active_default_provider_integration_id": active_default_provider_id,
|
||||
"integrations": integrations,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// PUT /api/integrations/:id/credentials — update integration credentials
|
||||
pub async fn handle_api_integrations_credentials_put(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::integrations_credentials_put(&id, &body);
|
||||
}
|
||||
|
||||
let fields = body
|
||||
.get("fields")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut config = state.config.lock().clone();
|
||||
let Some(provider_key) = provider_key_from_integration_id(&id) else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": format!(
|
||||
"Integration '{}' does not support credential updates via this endpoint",
|
||||
id
|
||||
)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
// Apply credential updates based on integration
|
||||
match provider_key {
|
||||
"openrouter" | "anthropic" | "openai" | "google" | "deepseek" | "xai" | "mistral"
|
||||
| "perplexity" | "vercel" | "bedrock" | "groq" | "together" | "cohere" | "fireworks"
|
||||
| "venice" | "moonshot" | "stepfun" | "synthetic" | "opencode" | "zai" | "glm"
|
||||
| "minimax" | "qwen" | "qianfan" | "doubao" | "volcengine" | "ark" | "siliconflow" => {
|
||||
if let Some(api_key) = fields.get("api_key").and_then(|v| v.as_str()) {
|
||||
if !api_key.is_empty() && api_key != MASKED_SECRET {
|
||||
config.api_key = Some(api_key.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(default_model) = fields.get("default_model").and_then(|v| v.as_str()) {
|
||||
if !default_model.is_empty() {
|
||||
config.default_model = Some(default_model.to_string());
|
||||
}
|
||||
}
|
||||
config.default_provider = Some(provider_key.to_string());
|
||||
}
|
||||
"ollama" => {
|
||||
if let Some(default_model) = fields.get("default_model").and_then(|v| v.as_str()) {
|
||||
if !default_model.is_empty() {
|
||||
config.default_model = Some(default_model.to_string());
|
||||
}
|
||||
}
|
||||
config.default_provider = Some("ollama".to_string());
|
||||
}
|
||||
_ => {
|
||||
// Channel integrations - not implemented for credentials update via this endpoint
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("Integration '{}' does not support credential updates via this endpoint", id)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Save config
|
||||
if let Err(e) = config.save().await {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": format!("Failed to save config: {e}")})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Update in-memory config
|
||||
*state.config.lock() = config;
|
||||
|
||||
Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"revision": "v1",
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn integration_name_to_id(name: &str) -> String {
|
||||
name.to_lowercase()
|
||||
.replace(' ', "-")
|
||||
.replace(['/', '.'], "-")
|
||||
}
|
||||
|
||||
fn provider_key_from_integration_id(id: &str) -> Option<&'static str> {
|
||||
match id {
|
||||
"openrouter" => Some("openrouter"),
|
||||
"anthropic" => Some("anthropic"),
|
||||
"openai" => Some("openai"),
|
||||
"google" => Some("google"),
|
||||
"deepseek" => Some("deepseek"),
|
||||
"xai" => Some("xai"),
|
||||
"mistral" => Some("mistral"),
|
||||
"perplexity" => Some("perplexity"),
|
||||
"vercel-ai" => Some("vercel"),
|
||||
"amazon-bedrock" => Some("bedrock"),
|
||||
"groq" => Some("groq"),
|
||||
"together-ai" => Some("together"),
|
||||
"cohere" => Some("cohere"),
|
||||
"fireworks-ai" => Some("fireworks"),
|
||||
"venice" => Some("venice"),
|
||||
"moonshot" => Some("moonshot"),
|
||||
"stepfun" => Some("stepfun"),
|
||||
"synthetic" => Some("synthetic"),
|
||||
"opencode-zen" => Some("opencode"),
|
||||
"z-ai" => Some("zai"),
|
||||
"glm" => Some("glm"),
|
||||
"minimax" => Some("minimax"),
|
||||
"qwen" => Some("qwen"),
|
||||
"qianfan" => Some("qianfan"),
|
||||
"volcengine-ark" => Some("ark"),
|
||||
"siliconflow" => Some("siliconflow"),
|
||||
"ollama" => Some("ollama"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ai_provider(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"OpenRouter"
|
||||
| "Anthropic"
|
||||
| "OpenAI"
|
||||
| "Google"
|
||||
| "DeepSeek"
|
||||
| "xAI"
|
||||
| "Mistral"
|
||||
| "Perplexity"
|
||||
| "Vercel AI"
|
||||
| "Amazon Bedrock"
|
||||
| "Groq"
|
||||
| "Together AI"
|
||||
| "Cohere"
|
||||
| "Fireworks AI"
|
||||
| "Venice"
|
||||
| "Moonshot"
|
||||
| "StepFun"
|
||||
| "Synthetic"
|
||||
| "OpenCode Zen"
|
||||
| "Z.AI"
|
||||
| "GLM"
|
||||
| "MiniMax"
|
||||
| "Qwen"
|
||||
| "Qianfan"
|
||||
| "Volcengine ARK"
|
||||
| "SiliconFlow"
|
||||
| "Ollama"
|
||||
)
|
||||
}
|
||||
|
||||
fn integration_id_from_provider(provider: &str) -> Option<String> {
|
||||
let name = match provider {
|
||||
"openrouter" => "OpenRouter",
|
||||
"anthropic" => "Anthropic",
|
||||
"openai" => "OpenAI",
|
||||
"google" | "vertex" => "Google",
|
||||
"deepseek" => "DeepSeek",
|
||||
"xai" | "x-ai" => "xAI",
|
||||
"mistral" => "Mistral",
|
||||
"perplexity" => "Perplexity",
|
||||
"vercel" => "Vercel AI",
|
||||
"bedrock" => "Amazon Bedrock",
|
||||
"groq" => "Groq",
|
||||
"together" => "Together AI",
|
||||
"cohere" => "Cohere",
|
||||
"fireworks" => "Fireworks AI",
|
||||
"venice" => "Venice",
|
||||
"moonshot" | "moonshot-cn" | "moonshot-intl" => "Moonshot",
|
||||
"stepfun" | "step-ai" => "StepFun",
|
||||
"synthetic" => "Synthetic",
|
||||
"opencode" => "OpenCode Zen",
|
||||
"zai" | "zai-cn" | "zai-intl" => "Z.AI",
|
||||
"glm" | "glm-cn" | "glm-intl" => "GLM",
|
||||
"minimax" | "minimax-cn" | "minimax-intl" => "MiniMax",
|
||||
"qwen" | "qwen-cn" | "qwen-intl" => "Qwen",
|
||||
"qianfan" | "baidu" => "Qianfan",
|
||||
"doubao" | "volcengine" | "ark" => "Volcengine ARK",
|
||||
"siliconflow" | "silicon-cloud" => "SiliconFlow",
|
||||
"ollama" => "Ollama",
|
||||
_ => return None,
|
||||
};
|
||||
Some(integration_name_to_id(name))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn integration_settings_fields(
|
||||
config: &crate::config::Config,
|
||||
name: &str,
|
||||
) -> (bool, Vec<serde_json::Value>) {
|
||||
match name {
|
||||
"OpenRouter" => {
|
||||
let has_key = config.api_key.is_some();
|
||||
let fields = vec![
|
||||
serde_json::json!({
|
||||
"key": "api_key",
|
||||
"label": "API Key",
|
||||
"required": true,
|
||||
"has_value": has_key,
|
||||
"input_type": "secret",
|
||||
"options": [],
|
||||
"masked_value": if has_key { Some(MASKED_SECRET) } else { None },
|
||||
}),
|
||||
serde_json::json!({
|
||||
"key": "default_model",
|
||||
"label": "Default Model",
|
||||
"required": false,
|
||||
"has_value": config.default_model.is_some(),
|
||||
"input_type": "select",
|
||||
"options": [
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"openai/gpt-5.2",
|
||||
"google/gemini-3.1-pro",
|
||||
"deepseek/deepseek-reasoner",
|
||||
"x-ai/grok-4",
|
||||
],
|
||||
"current_value": config.default_model.as_deref().unwrap_or(""),
|
||||
}),
|
||||
];
|
||||
(has_key, fields)
|
||||
}
|
||||
"Anthropic" => {
|
||||
let has_key = config.api_key.is_some();
|
||||
let fields = vec![
|
||||
serde_json::json!({
|
||||
"key": "api_key",
|
||||
"label": "API Key",
|
||||
"required": true,
|
||||
"has_value": has_key,
|
||||
"input_type": "secret",
|
||||
"options": [],
|
||||
"masked_value": if has_key { Some(MASKED_SECRET) } else { None },
|
||||
}),
|
||||
serde_json::json!({
|
||||
"key": "default_model",
|
||||
"label": "Default Model",
|
||||
"required": false,
|
||||
"has_value": config.default_model.is_some(),
|
||||
"input_type": "select",
|
||||
"options": ["claude-sonnet-4-6", "claude-opus-4-6"],
|
||||
"current_value": config.default_model.as_deref().unwrap_or(""),
|
||||
}),
|
||||
];
|
||||
(has_key, fields)
|
||||
}
|
||||
"OpenAI" => {
|
||||
let has_key = config.api_key.is_some();
|
||||
let fields = vec![
|
||||
serde_json::json!({
|
||||
"key": "api_key",
|
||||
"label": "API Key",
|
||||
"required": true,
|
||||
"has_value": has_key,
|
||||
"input_type": "secret",
|
||||
"options": [],
|
||||
"masked_value": if has_key { Some(MASKED_SECRET) } else { None },
|
||||
}),
|
||||
serde_json::json!({
|
||||
"key": "default_model",
|
||||
"label": "Default Model",
|
||||
"required": false,
|
||||
"has_value": config.default_model.is_some(),
|
||||
"input_type": "select",
|
||||
"options": ["gpt-5.2", "gpt-5.2-codex", "gpt-4o"],
|
||||
"current_value": config.default_model.as_deref().unwrap_or(""),
|
||||
}),
|
||||
];
|
||||
(has_key, fields)
|
||||
}
|
||||
_ => {
|
||||
// Default: no configurable fields
|
||||
(false, vec![])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/doctor — run diagnostics
|
||||
pub async fn handle_api_doctor(
|
||||
State(state): State<AppState>,
|
||||
@ -344,6 +704,9 @@ pub async fn handle_api_doctor(
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::doctor();
|
||||
}
|
||||
|
||||
let config = state.config.lock().clone();
|
||||
let results = crate::doctor::diagnose(&config);
|
||||
@ -381,6 +744,9 @@ pub async fn handle_api_memory_list(
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::memory_list(params.query, params.category);
|
||||
}
|
||||
|
||||
if let Some(ref query) = params.query {
|
||||
// Search mode
|
||||
@ -421,6 +787,9 @@ pub async fn handle_api_memory_store(
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::memory_store(body.key, body.content, body.category);
|
||||
}
|
||||
|
||||
let category = body
|
||||
.category
|
||||
@ -456,6 +825,9 @@ pub async fn handle_api_memory_delete(
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::memory_delete(&key);
|
||||
}
|
||||
|
||||
match state.mem.forget(&key).await {
|
||||
Ok(deleted) => {
|
||||
@ -477,6 +849,9 @@ pub async fn handle_api_cost(
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::cost();
|
||||
}
|
||||
|
||||
if let Some(ref tracker) = state.cost_tracker {
|
||||
match tracker.get_summary() {
|
||||
@ -510,6 +885,9 @@ pub async fn handle_api_cli_tools(
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::cli_tools();
|
||||
}
|
||||
|
||||
let tools = crate::tools::cli_discovery::discover_cli_tools(&[], &[]);
|
||||
|
||||
@ -524,6 +902,9 @@ pub async fn handle_api_health(
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::health();
|
||||
}
|
||||
|
||||
let snapshot = crate::health::snapshot();
|
||||
Json(serde_json::json!({"health": snapshot})).into_response()
|
||||
@ -537,6 +918,9 @@ pub async fn handle_api_pairing_devices(
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::pairing_devices();
|
||||
}
|
||||
|
||||
let devices = state.pairing.paired_devices();
|
||||
Json(serde_json::json!({ "devices": devices })).into_response()
|
||||
@ -551,6 +935,9 @@ pub async fn handle_api_pairing_device_revoke(
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
if mock_dashboard::is_enabled(&headers) {
|
||||
return mock_dashboard::pairing_device_revoke(&id);
|
||||
}
|
||||
|
||||
if !state.pairing.revoke_device(&id) {
|
||||
return (
|
||||
@ -1297,4 +1684,69 @@ mod tests {
|
||||
Some("feishu-verify-token")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_key_from_integration_id_maps_dashboard_ids() {
|
||||
assert_eq!(provider_key_from_integration_id("openai"), Some("openai"));
|
||||
assert_eq!(
|
||||
provider_key_from_integration_id("amazon-bedrock"),
|
||||
Some("bedrock")
|
||||
);
|
||||
assert_eq!(
|
||||
provider_key_from_integration_id("together-ai"),
|
||||
Some("together")
|
||||
);
|
||||
assert_eq!(
|
||||
provider_key_from_integration_id("opencode-zen"),
|
||||
Some("opencode")
|
||||
);
|
||||
assert_eq!(
|
||||
provider_key_from_integration_id("volcengine-ark"),
|
||||
Some("ark")
|
||||
);
|
||||
assert_eq!(provider_key_from_integration_id("slack"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_provider_mapping_roundtrips_for_supported_providers() {
|
||||
let cases = vec![
|
||||
("openrouter", "openrouter"),
|
||||
("anthropic", "anthropic"),
|
||||
("openai", "openai"),
|
||||
("google", "google"),
|
||||
("deepseek", "deepseek"),
|
||||
("xai", "xai"),
|
||||
("mistral", "mistral"),
|
||||
("perplexity", "perplexity"),
|
||||
("vercel", "vercel"),
|
||||
("bedrock", "bedrock"),
|
||||
("groq", "groq"),
|
||||
("together", "together"),
|
||||
("cohere", "cohere"),
|
||||
("fireworks", "fireworks"),
|
||||
("venice", "venice"),
|
||||
("moonshot", "moonshot"),
|
||||
("stepfun", "stepfun"),
|
||||
("synthetic", "synthetic"),
|
||||
("opencode", "opencode"),
|
||||
("zai", "zai"),
|
||||
("glm", "glm"),
|
||||
("minimax", "minimax"),
|
||||
("qwen", "qwen"),
|
||||
("qianfan", "qianfan"),
|
||||
("ark", "ark"),
|
||||
("siliconflow", "siliconflow"),
|
||||
("ollama", "ollama"),
|
||||
];
|
||||
|
||||
for (provider, expected_provider_key) in cases {
|
||||
let id = integration_id_from_provider(provider)
|
||||
.expect("provider should map to dashboard integration id");
|
||||
assert_eq!(
|
||||
provider_key_from_integration_id(&id),
|
||||
Some(expected_provider_key),
|
||||
"provider '{provider}' with id '{id}' should resolve to '{expected_provider_key}'",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1015
src/gateway/mock_dashboard.rs
Normal file
1015
src/gateway/mock_dashboard.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@
|
||||
//! - Header sanitization (handled by axum/hyper)
|
||||
|
||||
pub mod api;
|
||||
mod mock_dashboard;
|
||||
mod openai_compat;
|
||||
mod openclaw_compat;
|
||||
pub mod sse;
|
||||
@ -848,6 +849,14 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
.route("/api/cron", post(api::handle_api_cron_add))
|
||||
.route("/api/cron/{id}", delete(api::handle_api_cron_delete))
|
||||
.route("/api/integrations", get(api::handle_api_integrations))
|
||||
.route(
|
||||
"/api/integrations/settings",
|
||||
get(api::handle_api_integrations_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/{id}/credentials",
|
||||
put(api::handle_api_integrations_credentials_put),
|
||||
)
|
||||
.route(
|
||||
"/api/doctor",
|
||||
get(api::handle_api_doctor).post(api::handle_api_doctor),
|
||||
|
||||
34
src/lib.rs
34
src/lib.rs
@ -28,6 +28,40 @@
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::unnecessary_cast,
|
||||
clippy::assertions_on_constants,
|
||||
clippy::await_holding_lock,
|
||||
clippy::cast_lossless,
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::default_trait_access,
|
||||
clippy::doc_lazy_continuation,
|
||||
clippy::explicit_iter_loop,
|
||||
clippy::fn_params_excessive_bools,
|
||||
clippy::format_push_string,
|
||||
clippy::if_not_else,
|
||||
clippy::large_enum_variant,
|
||||
clippy::large_futures,
|
||||
clippy::manual_clamp,
|
||||
clippy::manual_contains,
|
||||
clippy::manual_is_multiple_of,
|
||||
clippy::manual_pattern_char_comparison,
|
||||
clippy::manual_string_new,
|
||||
clippy::match_same_arms,
|
||||
clippy::missing_fields_in_debug,
|
||||
clippy::needless_borrow,
|
||||
clippy::needless_lifetimes,
|
||||
clippy::redundant_else,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::should_implement_trait,
|
||||
clippy::stable_sort_primitive,
|
||||
clippy::struct_excessive_bools,
|
||||
clippy::type_complexity,
|
||||
clippy::unchecked_time_subtraction,
|
||||
clippy::unnecessary_debug_formatting,
|
||||
clippy::unnested_or_patterns,
|
||||
clippy::unreadable_literal,
|
||||
clippy::unused_async,
|
||||
clippy::wildcard_imports,
|
||||
clippy::unnecessary_lazy_evaluations,
|
||||
clippy::unnecessary_literal_bound,
|
||||
clippy::unnecessary_map_or,
|
||||
|
||||
161
src/main.rs
161
src/main.rs
@ -26,6 +26,40 @@
|
||||
clippy::uninlined_format_args,
|
||||
clippy::unused_self,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::assertions_on_constants,
|
||||
clippy::await_holding_lock,
|
||||
clippy::cast_lossless,
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::default_trait_access,
|
||||
clippy::doc_lazy_continuation,
|
||||
clippy::explicit_iter_loop,
|
||||
clippy::fn_params_excessive_bools,
|
||||
clippy::format_push_string,
|
||||
clippy::if_not_else,
|
||||
clippy::large_enum_variant,
|
||||
clippy::large_futures,
|
||||
clippy::manual_clamp,
|
||||
clippy::manual_contains,
|
||||
clippy::manual_is_multiple_of,
|
||||
clippy::manual_pattern_char_comparison,
|
||||
clippy::manual_string_new,
|
||||
clippy::match_same_arms,
|
||||
clippy::missing_fields_in_debug,
|
||||
clippy::needless_borrow,
|
||||
clippy::needless_lifetimes,
|
||||
clippy::redundant_else,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::should_implement_trait,
|
||||
clippy::stable_sort_primitive,
|
||||
clippy::struct_excessive_bools,
|
||||
clippy::type_complexity,
|
||||
clippy::unchecked_time_subtraction,
|
||||
clippy::unnecessary_debug_formatting,
|
||||
clippy::unnested_or_patterns,
|
||||
clippy::unreadable_literal,
|
||||
clippy::unused_async,
|
||||
clippy::wildcard_imports,
|
||||
clippy::unnecessary_cast,
|
||||
clippy::unnecessary_lazy_evaluations,
|
||||
clippy::unnecessary_literal_bound,
|
||||
@ -59,6 +93,54 @@ fn parse_temperature(s: &str) -> std::result::Result<f64, String> {
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
fn dashboard_open_url(host: &str, port: u16) -> String {
|
||||
let bind_host = host.trim();
|
||||
let browser_host = match bind_host {
|
||||
"0.0.0.0" | "::" | "[::]" => "127.0.0.1",
|
||||
_ => bind_host,
|
||||
};
|
||||
format!("http://{browser_host}:{port}/")
|
||||
}
|
||||
|
||||
async fn open_url_in_default_browser(url: &str) -> Result<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut command = {
|
||||
let mut command = tokio::process::Command::new("open");
|
||||
command.arg(url);
|
||||
command
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut command = {
|
||||
let mut command = tokio::process::Command::new("xdg-open");
|
||||
command.arg(url);
|
||||
command
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut command = {
|
||||
let mut command = tokio::process::Command::new("cmd");
|
||||
command.args(["/C", "start", "", url]);
|
||||
command
|
||||
};
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
let _ = url;
|
||||
bail!("automatic dashboard open is unsupported on this platform");
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
|
||||
{
|
||||
let status = command.status().await?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("browser launcher exited with status {status}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod agent;
|
||||
mod approval;
|
||||
mod auth;
|
||||
@ -263,6 +345,7 @@ Examples:
|
||||
zeroclaw gateway # use config defaults
|
||||
zeroclaw gateway -p 8080 # listen on port 8080
|
||||
zeroclaw gateway --host 0.0.0.0 # bind to all interfaces
|
||||
zeroclaw gateway --open-dashboard # open web dashboard automatically
|
||||
zeroclaw gateway -p 0 # random available port
|
||||
zeroclaw gateway --new-pairing # clear tokens and generate fresh pairing code")]
|
||||
Gateway {
|
||||
@ -277,6 +360,10 @@ Examples:
|
||||
/// Clear all paired tokens and generate a fresh pairing code
|
||||
#[arg(long)]
|
||||
new_pairing: bool,
|
||||
|
||||
/// Open the web dashboard URL in the default browser on startup
|
||||
#[arg(long)]
|
||||
open_dashboard: bool,
|
||||
},
|
||||
|
||||
/// Start long-running autonomous runtime (gateway + channels + heartbeat + scheduler)
|
||||
@ -990,6 +1077,7 @@ async fn main() -> Result<()> {
|
||||
port,
|
||||
host,
|
||||
new_pairing,
|
||||
open_dashboard,
|
||||
} => {
|
||||
if new_pairing {
|
||||
// Persist token reset from raw config so env-derived overrides are not written to disk.
|
||||
@ -1006,6 +1094,25 @@ async fn main() -> Result<()> {
|
||||
} else {
|
||||
info!("🚀 Starting ZeroClaw Gateway on {host}:{port}");
|
||||
}
|
||||
if open_dashboard {
|
||||
if port == 0 {
|
||||
warn!(
|
||||
"--open-dashboard requires a fixed port; skipping auto-open because --port 0 uses a random port"
|
||||
);
|
||||
} else {
|
||||
let dashboard_url = dashboard_open_url(&host, port);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
|
||||
if let Err(err) = open_url_in_default_browser(&dashboard_url).await {
|
||||
warn!(
|
||||
"Could not open dashboard automatically ({err}). Open manually: {dashboard_url}"
|
||||
);
|
||||
} else {
|
||||
info!("🌐 Opened dashboard in browser: {dashboard_url}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
gateway::run_gateway(&host, port, config).await
|
||||
}
|
||||
|
||||
@ -2406,6 +2513,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_help_includes_open_dashboard_flag() {
|
||||
let cmd = Cli::command();
|
||||
let gateway = cmd
|
||||
.get_subcommands()
|
||||
.find(|subcommand| subcommand.get_name() == "gateway")
|
||||
.expect("gateway subcommand must exist");
|
||||
|
||||
let has_open_dashboard_flag = gateway.get_arguments().any(|arg| {
|
||||
arg.get_id().as_str() == "open_dashboard" && arg.get_long() == Some("open-dashboard")
|
||||
});
|
||||
|
||||
assert!(
|
||||
has_open_dashboard_flag,
|
||||
"gateway help should include --open-dashboard"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_cli_accepts_new_pairing_flag() {
|
||||
let cli = Cli::try_parse_from(["zeroclaw", "gateway", "--new-pairing"])
|
||||
@ -2418,15 +2543,47 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_cli_defaults_new_pairing_to_false() {
|
||||
fn gateway_cli_accepts_open_dashboard_flag() {
|
||||
let cli = Cli::try_parse_from(["zeroclaw", "gateway", "--open-dashboard"])
|
||||
.expect("gateway --open-dashboard should parse");
|
||||
|
||||
match cli.command {
|
||||
Commands::Gateway { open_dashboard, .. } => assert!(open_dashboard),
|
||||
other => panic!("expected gateway command, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_cli_defaults_flags_to_false() {
|
||||
let cli = Cli::try_parse_from(["zeroclaw", "gateway"]).expect("gateway should parse");
|
||||
|
||||
match cli.command {
|
||||
Commands::Gateway { new_pairing, .. } => assert!(!new_pairing),
|
||||
Commands::Gateway {
|
||||
new_pairing,
|
||||
open_dashboard,
|
||||
..
|
||||
} => {
|
||||
assert!(!new_pairing);
|
||||
assert!(!open_dashboard);
|
||||
}
|
||||
other => panic!("expected gateway command, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashboard_open_url_prefers_loopback_for_wildcard_bind_hosts() {
|
||||
assert_eq!(
|
||||
dashboard_open_url("0.0.0.0", 42617),
|
||||
"http://127.0.0.1:42617/"
|
||||
);
|
||||
assert_eq!(dashboard_open_url("::", 42617), "http://127.0.0.1:42617/");
|
||||
assert_eq!(dashboard_open_url("[::]", 42617), "http://127.0.0.1:42617/");
|
||||
assert_eq!(
|
||||
dashboard_open_url("127.0.0.1", 42617),
|
||||
"http://127.0.0.1:42617/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_generation_mentions_binary_name() {
|
||||
let mut output = Vec::new();
|
||||
|
||||
@ -243,7 +243,6 @@ mod tests {
|
||||
use crate::config::PluginsConfig;
|
||||
use crate::plugins::manifest::PluginManifest;
|
||||
use crate::plugins::traits::{Plugin, PluginApi};
|
||||
use async_trait::async_trait;
|
||||
|
||||
struct OkPlugin {
|
||||
manifest: PluginManifest,
|
||||
|
||||
97
web/e2e/dashboard.mobile.spec.ts
Normal file
97
web/e2e/dashboard.mobile.spec.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Dashboard mobile smoke', () => {
|
||||
test.use({
|
||||
viewport: { width: 390, height: 844 },
|
||||
});
|
||||
|
||||
test('renders mock dashboard and supports mobile navigation and collapsible cards', async ({ page }) => {
|
||||
await page.route('**/health', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
paired: true,
|
||||
require_pairing: false,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/status', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.2',
|
||||
temperature: 0.4,
|
||||
uptime_seconds: 68420,
|
||||
gateway_port: 42617,
|
||||
locale: 'en-US',
|
||||
memory_backend: 'sqlite',
|
||||
paired: true,
|
||||
channels: {
|
||||
telegram: true,
|
||||
discord: false,
|
||||
whatsapp: true,
|
||||
github: true,
|
||||
},
|
||||
health: {
|
||||
uptime_seconds: 68420,
|
||||
updated_at: '2026-03-02T19:34:29.678544+00:00',
|
||||
pid: 4242,
|
||||
components: {
|
||||
gateway: {
|
||||
status: 'ok',
|
||||
updated_at: '2026-03-02T19:34:29.678544+00:00',
|
||||
last_ok: '2026-03-02T19:34:29.678544+00:00',
|
||||
last_error: null,
|
||||
restart_count: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/cost', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
cost: {
|
||||
session_cost_usd: 0.0842,
|
||||
daily_cost_usd: 1.3026,
|
||||
monthly_cost_usd: 14.9875,
|
||||
total_tokens: 182342,
|
||||
request_count: 426,
|
||||
by_model: {
|
||||
'gpt-5.2': {
|
||||
model: 'gpt-5.2',
|
||||
cost_usd: 11.4635,
|
||||
total_tokens: 141332,
|
||||
request_count: 292,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/?mock_backend=1');
|
||||
|
||||
await expect(page.getByText('Electric Runtime Dashboard')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Open navigation' }).click();
|
||||
await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
|
||||
|
||||
const closeButtons = page.getByRole('button', { name: 'Close navigation' });
|
||||
await closeButtons.first().click();
|
||||
|
||||
const costPulseButton = page.getByRole('button', { name: /Cost Pulse/i });
|
||||
await expect(costPulseButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await costPulseButton.click();
|
||||
await expect(costPulseButton).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
1207
web/package-lock.json
generated
1207
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,10 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:mobile-smoke": "node ./scripts/mobile-smoke-runner.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.2",
|
||||
@ -23,12 +26,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^26.1.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.7"
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
15
web/playwright.config.mjs
Normal file
15
web/playwright.config.mjs
Normal file
@ -0,0 +1,15 @@
|
||||
export default {
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
retries: 0,
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4173',
|
||||
headless: true,
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run dev -- --host 127.0.0.1 --port 4173',
|
||||
port: 4173,
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
},
|
||||
};
|
||||
56
web/scripts/mobile-smoke-runner.mjs
Normal file
56
web/scripts/mobile-smoke-runner.mjs
Normal file
@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const isWin = process.platform === 'win32';
|
||||
|
||||
function localBin(name) {
|
||||
const suffix = isWin ? '.cmd' : '';
|
||||
return path.join(rootDir, 'node_modules', '.bin', `${name}${suffix}`);
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: rootDir,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.status ?? 1;
|
||||
}
|
||||
|
||||
function hasPlaywright() {
|
||||
try {
|
||||
require.resolve('@playwright/test', { paths: [rootDir] });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const vitestArgs = [
|
||||
'run',
|
||||
'src/pages/Dashboard.test.tsx',
|
||||
'src/components/layout/Sidebar.test.tsx',
|
||||
];
|
||||
|
||||
if (hasPlaywright()) {
|
||||
console.log('[mobile-smoke] Playwright detected. Running browser + fallback smoke tests.');
|
||||
const playwrightStatus = run(localBin('playwright'), ['test', 'e2e/dashboard.mobile.spec.ts']);
|
||||
if (playwrightStatus !== 0) {
|
||||
process.exit(playwrightStatus);
|
||||
}
|
||||
process.exit(run(localBin('vitest'), vitestArgs));
|
||||
}
|
||||
|
||||
console.log('[mobile-smoke] @playwright/test not vendored. Running Vitest mobile smoke fallback.');
|
||||
process.exit(run(localBin('vitest'), vitestArgs));
|
||||
@ -13,7 +13,9 @@ import Cost from './pages/Cost';
|
||||
import Logs from './pages/Logs';
|
||||
import Doctor from './pages/Doctor';
|
||||
import { AuthProvider, useAuth } from './hooks/useAuth';
|
||||
import { setLocale, type Locale } from './lib/i18n';
|
||||
import { coerceLocale, setLocale, type Locale } from './lib/i18n';
|
||||
|
||||
const LOCALE_STORAGE_KEY = 'zeroclaw:locale';
|
||||
|
||||
// Locale context
|
||||
interface LocaleContextType {
|
||||
@ -22,7 +24,7 @@ interface LocaleContextType {
|
||||
}
|
||||
|
||||
export const LocaleContext = createContext<LocaleContextType>({
|
||||
locale: 'tr',
|
||||
locale: 'en',
|
||||
setAppLocale: (_locale: Locale) => {},
|
||||
});
|
||||
|
||||
@ -48,11 +50,11 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
||||
<div className="bg-gray-900 rounded-xl p-8 w-full max-w-md border border-gray-800">
|
||||
<div className="pairing-shell min-h-screen flex items-center justify-center px-4">
|
||||
<div className="pairing-card w-full max-w-md rounded-2xl p-8">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">ZeroClaw</h1>
|
||||
<p className="text-gray-400">Enter the pairing code from your terminal</p>
|
||||
<h1 className="mb-2 text-2xl font-semibold tracking-[0.12em] text-white">ZEROCLAW</h1>
|
||||
<p className="text-sm text-[#9bb8e8]">Enter the one-time pairing code from your terminal</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
@ -60,17 +62,17 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="6-digit code"
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-center text-2xl tracking-widest focus:outline-none focus:border-blue-500 mb-4"
|
||||
className="w-full rounded-xl border border-[#29509c] bg-[#071228]/90 px-4 py-3 text-center text-2xl tracking-[0.35em] text-white focus:border-[#4f83ff] focus:outline-none mb-4"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm mb-4 text-center">{error}</p>
|
||||
<p className="mb-4 text-center text-sm text-rose-300">{error}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || code.length < 6}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-lg font-medium transition-colors"
|
||||
className="electric-button w-full rounded-xl py-3 font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Pairing...' : 'Pair'}
|
||||
</button>
|
||||
@ -82,11 +84,28 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
|
||||
|
||||
function AppContent() {
|
||||
const { isAuthenticated, loading, pair, logout } = useAuth();
|
||||
const [locale, setLocaleState] = useState<Locale>('tr');
|
||||
const [locale, setLocaleState] = useState<Locale>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
const saved = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
if (saved) {
|
||||
return coerceLocale(saved);
|
||||
}
|
||||
|
||||
return coerceLocale(window.navigator.language);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLocale(locale);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
const setAppLocale = (newLocale: Locale) => {
|
||||
setLocaleState(newLocale);
|
||||
setLocale(newLocale);
|
||||
};
|
||||
|
||||
// Listen for 401 events to force logout
|
||||
@ -100,8 +119,11 @@ function AppContent() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
||||
<p className="text-gray-400">Connecting...</p>
|
||||
<div className="pairing-shell min-h-screen flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="electric-loader h-10 w-10 rounded-full" />
|
||||
<p className="text-[#a7c4f3]">Connecting...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -271,7 +271,7 @@ export const CONFIG_SECTIONS: SectionDef[] = [
|
||||
{ key: 'kind', label: 'Kind', type: 'select', defaultValue: 'native', options: [
|
||||
{ value: 'native', label: 'Native' },
|
||||
{ value: 'docker', label: 'Docker' },
|
||||
{ value: 'wasm', label: 'WASM' },
|
||||
{ value: 'wasm', label: 'Sandboxed' },
|
||||
]},
|
||||
{ key: 'reasoning_enabled', label: 'Reasoning Enabled', type: 'toggle', description: 'Enable model reasoning mode' },
|
||||
],
|
||||
@ -975,8 +975,8 @@ export const CONFIG_SECTIONS: SectionDef[] = [
|
||||
{
|
||||
path: 'wasm',
|
||||
category: 'runtime',
|
||||
title: 'WASM Plugins',
|
||||
description: 'WebAssembly plugin engine',
|
||||
title: 'Plugin Engine',
|
||||
description: 'Sandboxed plugin engine',
|
||||
icon: Play,
|
||||
defaultCollapsed: true,
|
||||
fields: [
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { LogOut, Menu } from 'lucide-react';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Locale } from '@/lib/i18n';
|
||||
import { useState } from 'react';
|
||||
import { LogOut, Menu, TestTubeDiagonal, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||
import { t, LANGUAGE_BUTTON_LABELS, LANGUAGE_SWITCH_ORDER } from '@/lib/i18n';
|
||||
import { useLocaleContext } from '@/App';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { isMockModeEnabled, setMockModeEnabled } from '@/lib/mockMode';
|
||||
|
||||
const routeTitles: Record<string, string> = {
|
||||
'/': 'nav.dashboard',
|
||||
@ -12,59 +13,111 @@ const routeTitles: Record<string, string> = {
|
||||
'/cron': 'nav.cron',
|
||||
'/integrations': 'nav.integrations',
|
||||
'/memory': 'nav.memory',
|
||||
'/devices': 'nav.devices',
|
||||
'/config': 'nav.config',
|
||||
'/cost': 'nav.cost',
|
||||
'/logs': 'nav.logs',
|
||||
'/doctor': 'nav.doctor',
|
||||
};
|
||||
|
||||
const localeCycle: Locale[] = ['en', 'tr', 'zh-CN'];
|
||||
const languageSummary = 'English · 简体中文 · 日本語 · Русский · Français · Tiếng Việt · Ελληνικά';
|
||||
|
||||
interface HeaderProps {
|
||||
isSidebarCollapsed: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
onToggleSidebarCollapse: () => void;
|
||||
}
|
||||
|
||||
export default function Header({ onToggleSidebar }: HeaderProps) {
|
||||
export default function Header({
|
||||
isSidebarCollapsed,
|
||||
onToggleSidebar,
|
||||
onToggleSidebarCollapse,
|
||||
}: HeaderProps) {
|
||||
const location = useLocation();
|
||||
const { logout } = useAuth();
|
||||
const { locale, setAppLocale } = useLocaleContext();
|
||||
const [mockMode, setMockMode] = useState(() => isMockModeEnabled());
|
||||
|
||||
const titleKey = routeTitles[location.pathname] ?? 'nav.dashboard';
|
||||
const pageTitle = t(titleKey);
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const currentIndex = localeCycle.indexOf(locale);
|
||||
const nextLocale = localeCycle[(currentIndex + 1) % localeCycle.length] ?? 'en';
|
||||
const currentIndex = LANGUAGE_SWITCH_ORDER.indexOf(locale);
|
||||
const nextLocale =
|
||||
LANGUAGE_SWITCH_ORDER[(currentIndex + 1) % LANGUAGE_SWITCH_ORDER.length] ?? 'en';
|
||||
setAppLocale(nextLocale);
|
||||
};
|
||||
|
||||
const toggleMockMode = () => {
|
||||
const next = !mockMode;
|
||||
setMockModeEnabled(next);
|
||||
setMockMode(next);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-14 bg-gray-800 border-b border-gray-700 flex items-center justify-between px-4 md:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<header className="glass-header relative flex min-h-[4.5rem] flex-wrap items-center justify-between gap-2 rounded-2xl border border-[#1a3670] px-4 py-3 sm:px-5 sm:py-3.5 md:flex-nowrap md:px-8 md:py-4">
|
||||
<div className="absolute inset-0 pointer-events-none opacity-70 bg-[radial-gradient(circle_at_15%_30%,rgba(41,148,255,0.22),transparent_45%),radial-gradient(circle_at_85%_75%,rgba(0,209,255,0.14),transparent_40%)]" />
|
||||
|
||||
<div className="relative flex min-w-0 items-center gap-2.5 sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSidebar}
|
||||
aria-label="Open navigation"
|
||||
className="md:hidden p-1.5 rounded-md text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
|
||||
className="rounded-lg border border-[#294a8f] bg-[#081637]/70 p-1.5 text-[#9ec2ff] transition hover:border-[#4f83ff] hover:text-white md:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<h1 className="text-lg font-semibold text-white">{pageTitle}</h1>
|
||||
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-base font-semibold tracking-wide text-white sm:text-lg">
|
||||
{pageTitle}
|
||||
</h1>
|
||||
<p className="hidden text-[10px] uppercase tracking-[0.16em] text-[#7ea5eb] sm:block">
|
||||
Electric dashboard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
<div className="relative flex w-full items-center justify-end gap-1.5 sm:gap-2 md:w-auto md:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSidebarCollapse}
|
||||
className="hidden items-center gap-1 rounded-lg border border-[#2b4f97] bg-[#091937]/75 px-2.5 py-1.5 text-xs text-[#c4d8ff] transition hover:border-[#4f83ff] hover:text-white md:flex md:text-sm"
|
||||
title={isSidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{isSidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
||||
<span>{isSidebarCollapsed ? 'Expand' : 'Collapse'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMockMode}
|
||||
className={[
|
||||
'flex items-center gap-1 rounded-full border px-2 py-1 text-[11px] uppercase tracking-[0.1em] transition sm:px-2.5 sm:text-xs sm:tracking-[0.12em]',
|
||||
mockMode
|
||||
? 'border-[#48a8ff] bg-[#0f3d8a]/75 text-white shadow-[0_0_18px_-8px_rgba(73,152,255,1)]'
|
||||
: 'border-[#284a8c] bg-[#081a3b]/75 text-[#9bb7e7] hover:border-[#4f83ff] hover:text-white',
|
||||
].join(' ')}
|
||||
title="Toggle dashboard mock mode"
|
||||
>
|
||||
<TestTubeDiagonal className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Mock</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLanguage}
|
||||
className="px-3 py-1 rounded-md text-sm font-medium border border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
|
||||
title={`🌐 Languages: ${languageSummary}`}
|
||||
className="rounded-lg border border-[#2b4f97] bg-[#091937]/75 px-2.5 py-1 text-xs font-medium text-[#c4d8ff] transition hover:border-[#4f83ff] hover:text-white sm:px-3 sm:text-sm"
|
||||
>
|
||||
{locale === 'en' ? 'EN' : locale === 'tr' ? 'TR' : '中文'}
|
||||
{LANGUAGE_BUTTON_LABELS[locale] ?? 'EN'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
|
||||
className="flex items-center gap-1 rounded-lg border border-[#2b4f97] bg-[#091937]/75 px-2.5 py-1.5 text-xs text-[#c4d8ff] transition hover:border-[#4f83ff] hover:text-white sm:gap-1.5 sm:px-3 sm:text-sm"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t('auth.logout')}</span>
|
||||
|
||||
@ -3,17 +3,49 @@ import { useState } from 'react';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import Header from '@/components/layout/Header';
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'zeroclaw:sidebar-collapsed';
|
||||
|
||||
export default function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1';
|
||||
});
|
||||
|
||||
const toggleSidebarCollapsed = () => {
|
||||
setSidebarCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? '1' : '0');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white">
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
<div className="app-shell min-h-screen text-white">
|
||||
<Sidebar
|
||||
isOpen={sidebarOpen}
|
||||
isCollapsed={sidebarCollapsed}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onToggleCollapse={toggleSidebarCollapsed}
|
||||
/>
|
||||
|
||||
<div className="md:ml-60 flex flex-col min-h-screen">
|
||||
<Header onToggleSidebar={() => setSidebarOpen((open) => !open)} />
|
||||
<div
|
||||
className={[
|
||||
'flex min-h-screen flex-col transition-[margin-left] duration-300 ease-out',
|
||||
sidebarCollapsed ? 'md:ml-[6.25rem]' : 'md:ml-[17.5rem]',
|
||||
].join(' ')}
|
||||
>
|
||||
<Header
|
||||
isSidebarCollapsed={sidebarCollapsed}
|
||||
onToggleSidebar={() => setSidebarOpen((open) => !open)}
|
||||
onToggleSidebarCollapse={toggleSidebarCollapsed}
|
||||
/>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<main className="flex-1 overflow-y-auto px-4 pb-8 pt-5 md:px-8 md:pt-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
80
web/src/components/layout/Sidebar.test.tsx
Normal file
80
web/src/components/layout/Sidebar.test.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
function renderSidebar({
|
||||
isOpen = false,
|
||||
isCollapsed = false,
|
||||
onClose = vi.fn(),
|
||||
onToggleCollapse = vi.fn(),
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
onClose?: () => void;
|
||||
onToggleCollapse?: () => void;
|
||||
} = {}) {
|
||||
const view = render(
|
||||
<MemoryRouter>
|
||||
<Sidebar
|
||||
isOpen={isOpen}
|
||||
isCollapsed={isCollapsed}
|
||||
onClose={onClose}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
return { ...view, onClose, onToggleCollapse };
|
||||
}
|
||||
|
||||
describe('Sidebar', () => {
|
||||
it('toggles open/close state and invokes close handlers for mobile controls', async () => {
|
||||
const user = userEvent.setup();
|
||||
const closed = renderSidebar({ isOpen: false });
|
||||
const closedButtons = closed.getAllByRole('button', {
|
||||
name: /Close navigation/i,
|
||||
});
|
||||
expect(closedButtons.length).toBeGreaterThan(0);
|
||||
const closedOverlay = closedButtons[0];
|
||||
if (!closedOverlay) {
|
||||
throw new Error('Expected sidebar overlay button');
|
||||
}
|
||||
expect(closedOverlay).toHaveClass('pointer-events-none');
|
||||
closed.unmount();
|
||||
|
||||
const opened = renderSidebar({ isOpen: true });
|
||||
const openedCloseButtons = opened.getAllByRole('button', {
|
||||
name: /Close navigation/i,
|
||||
});
|
||||
expect(openedCloseButtons.length).toBeGreaterThanOrEqual(2);
|
||||
const openedOverlay = openedCloseButtons[0];
|
||||
const mobileCloseButton = openedCloseButtons[1];
|
||||
if (!openedOverlay || !mobileCloseButton) {
|
||||
throw new Error('Expected sidebar overlay and close buttons');
|
||||
}
|
||||
|
||||
expect(openedOverlay).toHaveClass('opacity-100');
|
||||
|
||||
await user.click(openedOverlay);
|
||||
await user.click(mobileCloseButton);
|
||||
expect(opened.onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('supports collapsed mode controls and closes on navigation click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const view = renderSidebar({ isOpen: true, isCollapsed: true });
|
||||
|
||||
const collapseToggle = screen.getByRole('button', {
|
||||
name: /Expand navigation/i,
|
||||
});
|
||||
await user.click(collapseToggle);
|
||||
expect(view.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
|
||||
const dashboardLink = screen.getByRole('link', { name: 'Dashboard' });
|
||||
expect(dashboardLink).toHaveAttribute('title', 'Dashboard');
|
||||
await user.click(dashboardLink);
|
||||
expect(view.onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,6 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
ChevronsLeftRightEllipsis,
|
||||
LayoutDashboard,
|
||||
MessageSquare,
|
||||
Wrench,
|
||||
@ -31,10 +32,17 @@ const navItems = [
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
isCollapsed: boolean;
|
||||
onClose: () => void;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
export default function Sidebar({
|
||||
isOpen,
|
||||
isCollapsed,
|
||||
onClose,
|
||||
onToggleCollapse,
|
||||
}: SidebarProps) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@ -48,52 +56,94 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
/>
|
||||
<aside
|
||||
className={[
|
||||
'fixed top-0 left-0 z-40 h-screen w-60 bg-gray-900 flex flex-col border-r border-gray-800',
|
||||
'transform transition-transform duration-200 ease-out',
|
||||
'fixed left-0 top-0 z-40 flex h-screen w-[86vw] max-w-[17.5rem] flex-col border-r border-[#1e2f5d] bg-[#050b1a]/95 backdrop-blur-xl',
|
||||
'shadow-[0_0_50px_-25px_rgba(8,121,255,0.7)]',
|
||||
'transform transition-[width,transform] duration-300 ease-out',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
isCollapsed ? 'md:w-[6.25rem]' : 'md:w-[17.5rem]',
|
||||
'md:translate-x-0',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-5 border-b border-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold text-sm">
|
||||
ZC
|
||||
<div className="relative flex items-center justify-between border-b border-[#1a2d5e] px-4 py-4">
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<div
|
||||
className="electric-brand-mark h-9 w-9 shrink-0 rounded-xl"
|
||||
role="img"
|
||||
aria-label="ZeroClaw"
|
||||
>
|
||||
<span className="sr-only">ZeroClaw</span>
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-white tracking-wide">
|
||||
<span
|
||||
className={[
|
||||
'text-lg font-semibold tracking-[0.1em] text-white transition-[opacity,transform,width] duration-300',
|
||||
isCollapsed ? 'w-0 -translate-x-3 opacity-0 md:invisible' : 'w-auto opacity-100',
|
||||
].join(' ')}
|
||||
>
|
||||
ZeroClaw
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close navigation"
|
||||
className="md:hidden p-1.5 rounded-md text-gray-300 hover:bg-gray-800 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
aria-label={isCollapsed ? 'Expand navigation' : 'Collapse navigation'}
|
||||
className="hidden rounded-lg border border-[#2c4e97] bg-[#0a1b3f]/60 p-1.5 text-[#8bb9ff] transition hover:border-[#4f83ff] hover:text-white md:block"
|
||||
>
|
||||
<ChevronsLeftRightEllipsis className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close navigation"
|
||||
className="rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-gray-800 hover:text-white md:hidden"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
|
||||
{navItems.map(({ to, icon: Icon, labelKey }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
onClick={onClose}
|
||||
title={isCollapsed ? t(labelKey) : undefined}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||
'group flex items-center gap-3 overflow-hidden rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-300',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-800 hover:text-white',
|
||||
? 'border border-[#3a6de0] bg-[#0b2f80]/55 text-white shadow-[0_0_30px_-16px_rgba(72,140,255,0.95)]'
|
||||
: 'border border-transparent text-[#9bb7eb] hover:border-[#294a8d] hover:bg-[#07132f] hover:text-white',
|
||||
].join(' ')
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
||||
<span>{t(labelKey)}</span>
|
||||
<Icon className="h-5 w-5 shrink-0 transition-transform duration-300 group-hover:scale-110" />
|
||||
<span
|
||||
className={[
|
||||
'whitespace-nowrap transition-[opacity,transform,width] duration-300',
|
||||
isCollapsed ? 'w-0 -translate-x-3 opacity-0 md:invisible' : 'w-auto opacity-100',
|
||||
].join(' ')}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
className={[
|
||||
'mx-3 mb-4 rounded-xl border border-[#1b3670] bg-[#071328]/80 px-3 py-3 text-xs text-[#89a9df] transition-all duration-300',
|
||||
isCollapsed ? 'md:px-1.5 md:text-center' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<p className={isCollapsed ? 'hidden md:block' : ''}>Gateway + Dashboard</p>
|
||||
<p className={isCollapsed ? 'text-[10px] uppercase tracking-widest' : 'mt-1 text-[#5f84cc]'}>
|
||||
{isCollapsed ? 'UI' : 'Electric Mode'}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,89 +1,493 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* ZeroClaw Dark Theme
|
||||
* Dark-mode by default with gray cards and blue/green accents.
|
||||
*/
|
||||
|
||||
@theme {
|
||||
--color-bg-primary: #0a0a0f;
|
||||
--color-bg-secondary: #12121a;
|
||||
--color-bg-card: #1a1a2e;
|
||||
--color-bg-card-hover: #22223a;
|
||||
--color-bg-input: #14141f;
|
||||
|
||||
--color-border-default: #2a2a3e;
|
||||
--color-border-subtle: #1e1e30;
|
||||
|
||||
--color-accent-blue: #3b82f6;
|
||||
--color-accent-blue-hover: #2563eb;
|
||||
--color-accent-green: #10b981;
|
||||
--color-accent-green-hover: #059669;
|
||||
|
||||
--color-text-primary: #e2e8f0;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--color-text-muted: #64748b;
|
||||
|
||||
--color-status-success: #10b981;
|
||||
--color-status-warning: #f59e0b;
|
||||
--color-status-error: #ef4444;
|
||||
--color-status-info: #3b82f6;
|
||||
--color-electric-50: #eaf3ff;
|
||||
--color-electric-100: #d8e8ff;
|
||||
--color-electric-300: #87b8ff;
|
||||
--color-electric-500: #2f8fff;
|
||||
--color-electric-700: #0f57dd;
|
||||
--color-electric-900: #031126;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
color: #edf4ff;
|
||||
background: #020813;
|
||||
font-family:
|
||||
"Inter",
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Sora",
|
||||
"Manrope",
|
||||
"Avenir Next",
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: geometricPrecision;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
background:
|
||||
radial-gradient(circle at 8% 5%, rgba(47, 143, 255, 0.22), transparent 35%),
|
||||
radial-gradient(circle at 92% 14%, rgba(0, 209, 255, 0.16), transparent 32%),
|
||||
linear-gradient(175deg, #020816 0%, #03091b 46%, #040e24 100%);
|
||||
}
|
||||
|
||||
.app-shell::before,
|
||||
.app-shell::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.app-shell::before {
|
||||
background-image:
|
||||
linear-gradient(rgba(76, 118, 194, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(76, 118, 194, 0.1) 1px, transparent 1px);
|
||||
background-size: 34px 34px;
|
||||
mask-image: radial-gradient(circle at 50% 36%, black 22%, transparent 80%);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.app-shell::after {
|
||||
background:
|
||||
radial-gradient(circle at 16% 86%, rgba(42, 128, 255, 0.34), transparent 43%),
|
||||
radial-gradient(circle at 84% 22%, rgba(0, 212, 255, 0.2), transparent 38%),
|
||||
radial-gradient(circle at 52% 122%, rgba(40, 118, 255, 0.3), transparent 56%);
|
||||
filter: blur(4px);
|
||||
animation: appGlowDrift 28s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.glass-header {
|
||||
position: relative;
|
||||
backdrop-filter: blur(16px);
|
||||
background: linear-gradient(160deg, rgba(6, 19, 45, 0.85), rgba(5, 14, 33, 0.9));
|
||||
box-shadow:
|
||||
0 18px 32px -28px rgba(68, 145, 255, 0.95),
|
||||
inset 0 1px 0 rgba(140, 183, 255, 0.14);
|
||||
}
|
||||
|
||||
.glass-header::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(105deg, transparent 10%, rgba(79, 155, 255, 0.24), transparent 70%);
|
||||
transform: translateX(-70%);
|
||||
animation: topGlowSweep 7s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid #21438c;
|
||||
padding: 1.15rem 1.2rem;
|
||||
background:
|
||||
radial-gradient(circle at 0% 0%, rgba(56, 143, 255, 0.24), transparent 40%),
|
||||
linear-gradient(146deg, rgba(8, 26, 64, 0.95), rgba(4, 13, 34, 0.92));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(130, 174, 255, 0.16),
|
||||
0 22px 50px -38px rgba(64, 145, 255, 0.94);
|
||||
}
|
||||
|
||||
.hero-panel::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(118deg, transparent, rgba(128, 184, 255, 0.12), transparent 70%);
|
||||
transform: translateX(-62%);
|
||||
animation: heroSweep 5.8s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 19rem;
|
||||
height: 19rem;
|
||||
right: -6rem;
|
||||
top: -10rem;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle at center, rgba(63, 167, 255, 0.42), transparent 70%);
|
||||
filter: blur(12px);
|
||||
animation: heroGlowPulse 4.8s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #2d58ac;
|
||||
background: rgba(6, 29, 78, 0.68);
|
||||
color: #c2d9ff;
|
||||
padding: 0.35rem 0.65rem;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
box-shadow: 0 0 22px -16px rgba(83, 153, 255, 0.95);
|
||||
}
|
||||
|
||||
.status-pill-mock {
|
||||
border-color: #49a1ff;
|
||||
color: #ffffff;
|
||||
background: rgba(17, 76, 170, 0.76);
|
||||
box-shadow: 0 0 18px -7px rgba(74, 166, 255, 0.86);
|
||||
}
|
||||
|
||||
.electric-brand-mark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background:
|
||||
radial-gradient(circle at 22% 18%, rgba(94, 200, 255, 0.22), rgba(23, 119, 255, 0.14) 52%, rgba(10, 72, 181, 0.18) 100%),
|
||||
url("/logo/background.png") center / cover no-repeat;
|
||||
background-blend-mode: screen;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.24),
|
||||
0 10px 25px -12px rgba(41, 130, 255, 0.95);
|
||||
}
|
||||
|
||||
.electric-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid #1a3670;
|
||||
background:
|
||||
linear-gradient(165deg, rgba(8, 24, 60, 0.95), rgba(4, 14, 34, 0.96));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(129, 174, 255, 0.12),
|
||||
0 25px 45px -36px rgba(47, 140, 255, 0.95),
|
||||
0 0 0 1px rgba(63, 141, 255, 0.2),
|
||||
0 0 26px -17px rgba(76, 176, 255, 0.82);
|
||||
}
|
||||
|
||||
.electric-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -20%;
|
||||
right: -20%;
|
||||
bottom: -65%;
|
||||
height: 72%;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(59, 148, 255, 0.25), transparent 72%);
|
||||
filter: blur(16px);
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
animation: cardGlowPulse 5.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.electric-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #9bc3ff;
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(123, 198, 255, 0.38), rgba(29, 92, 214, 0.32) 66%, rgba(12, 44, 102, 0.48) 100%);
|
||||
border: 1px solid rgba(86, 143, 255, 0.45);
|
||||
}
|
||||
|
||||
.metric-head {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #244991;
|
||||
background: rgba(6, 22, 54, 0.74);
|
||||
color: #91b8fb;
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.3rem 0.55rem;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: #ffffff;
|
||||
font-size: clamp(1.15rem, 1.8vw, 1.45rem);
|
||||
font-weight: 620;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.metric-sub {
|
||||
color: #89aee8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.metric-pill {
|
||||
border-radius: 0.85rem;
|
||||
border: 1px solid #1d3c77;
|
||||
background: rgba(5, 17, 44, 0.86);
|
||||
padding: 0.6rem 0.72rem;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(62, 137, 255, 0.16),
|
||||
0 16px 30px -24px rgba(47, 140, 255, 0.86),
|
||||
0 0 18px -15px rgba(73, 176, 255, 0.78);
|
||||
}
|
||||
|
||||
.metric-pill span {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #85a9e1;
|
||||
}
|
||||
|
||||
.metric-pill strong {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.93rem;
|
||||
color: #f5f9ff;
|
||||
}
|
||||
|
||||
.electric-progress {
|
||||
background:
|
||||
linear-gradient(90deg, #1f76ff 0%, #2f97ff 60%, #48cdff 100%);
|
||||
box-shadow: 0 0 18px -7px rgba(62, 166, 255, 0.95);
|
||||
}
|
||||
|
||||
.pairing-shell {
|
||||
min-height: 100dvh;
|
||||
background:
|
||||
radial-gradient(circle at 20% 5%, rgba(64, 141, 255, 0.24), transparent 35%),
|
||||
radial-gradient(circle at 75% 92%, rgba(0, 193, 255, 0.13), transparent 35%),
|
||||
linear-gradient(155deg, #020816 0%, #030c20 58%, #030915 100%);
|
||||
}
|
||||
|
||||
.pairing-card {
|
||||
border: 1px solid #2956a8;
|
||||
background:
|
||||
linear-gradient(155deg, rgba(9, 27, 68, 0.9), rgba(4, 15, 35, 0.94));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(146, 190, 255, 0.16),
|
||||
0 30px 60px -44px rgba(47, 141, 255, 0.98),
|
||||
0 0 0 1px rgba(67, 150, 255, 0.2),
|
||||
0 0 28px -18px rgba(76, 184, 255, 0.82);
|
||||
}
|
||||
|
||||
:is(div, section, article)[class*="bg-gray-900"][class*="rounded-xl"][class*="border"],
|
||||
:is(div, section, article)[class*="bg-gray-900"][class*="rounded-lg"][class*="border"],
|
||||
:is(div, section, article)[class*="bg-gray-950"][class*="rounded-lg"][class*="border"] {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(67, 144, 255, 0.14),
|
||||
0 22px 40px -32px rgba(45, 134, 255, 0.86),
|
||||
0 0 22px -16px rgba(73, 180, 255, 0.75);
|
||||
}
|
||||
|
||||
.electric-button {
|
||||
border: 1px solid #4a89ff;
|
||||
background: linear-gradient(126deg, #125bdf 0%, #1f88ff 55%, #17b4ff 100%);
|
||||
box-shadow: 0 18px 30px -20px rgba(47, 141, 255, 0.9);
|
||||
transition: transform 180ms ease, filter 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.electric-button:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.05);
|
||||
box-shadow: 0 20px 34px -19px rgba(56, 154, 255, 0.95);
|
||||
}
|
||||
|
||||
.electric-loader {
|
||||
border: 3px solid rgba(89, 146, 255, 0.22);
|
||||
border-top-color: #51abff;
|
||||
box-shadow: 0 0 20px -12px rgba(66, 157, 255, 1);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.motion-rise {
|
||||
animation: riseIn 580ms ease both;
|
||||
}
|
||||
|
||||
.motion-delay-1 {
|
||||
animation-delay: 70ms;
|
||||
}
|
||||
|
||||
.motion-delay-2 {
|
||||
animation-delay: 130ms;
|
||||
}
|
||||
|
||||
.motion-delay-3 {
|
||||
animation-delay: 190ms;
|
||||
}
|
||||
|
||||
.motion-delay-4 {
|
||||
animation-delay: 250ms;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #244787 #081126;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
background: #081126;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-default);
|
||||
border-radius: 4px;
|
||||
background: #244787;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
background: #3160b6;
|
||||
}
|
||||
|
||||
/* Card utility */
|
||||
.card {
|
||||
background-color: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background-color: var(--color-bg-card-hover);
|
||||
}
|
||||
|
||||
/* Focus ring utility */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-accent-blue);
|
||||
outline: 2px solid #4ea4ff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@keyframes riseIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes heroSweep {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(-68%);
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
opacity: 0.65;
|
||||
}
|
||||
60% {
|
||||
transform: translateX(58%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes heroGlowPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.38;
|
||||
transform: scale(0.94);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.72;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cardGlowPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.28;
|
||||
transform: translateY(0) scale(0.96);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
transform: translateY(-2%) scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes topGlowSweep {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(-78%);
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
58% {
|
||||
transform: translateX(58%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes appGlowDrift {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
transform: translate3d(-3%, 1.8%, 0) scale(1);
|
||||
}
|
||||
25% {
|
||||
opacity: 0.5;
|
||||
transform: translate3d(2.6%, -1.2%, 0) scale(1.04);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.56;
|
||||
transform: translate3d(4.4%, -3.4%, 0) scale(1.09);
|
||||
}
|
||||
75% {
|
||||
opacity: 0.44;
|
||||
transform: translate3d(-1.8%, -2.1%, 0) scale(1.05);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.34;
|
||||
transform: translate3d(-3.6%, 2.6%, 0) scale(1.01);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-panel {
|
||||
padding: 0.95rem 0.95rem;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
padding: 0.28rem 0.52rem;
|
||||
font-size: 0.61rem;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.metric-sub {
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.electric-card {
|
||||
border-radius: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-panel::after,
|
||||
.hero-panel::before,
|
||||
.glass-header::after,
|
||||
.electric-card::after,
|
||||
.app-shell::after,
|
||||
.motion-rise,
|
||||
.electric-loader {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.electric-button {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
80
web/src/lib/api.backendMock.test.ts
Normal file
80
web/src/lib/api.backendMock.test.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { apiFetch, getPublicHealth, pair } from './api';
|
||||
import {
|
||||
MOCK_BACKEND_STORAGE_KEY,
|
||||
MOCK_MODE_STORAGE_KEY,
|
||||
} from './mockMode';
|
||||
import { clearToken, setToken } from './auth';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
clearToken();
|
||||
window.localStorage.removeItem(MOCK_MODE_STORAGE_KEY);
|
||||
window.localStorage.removeItem(MOCK_BACKEND_STORAGE_KEY);
|
||||
window.history.pushState({}, '', '/');
|
||||
});
|
||||
|
||||
describe('api backend mock mode', () => {
|
||||
it('sends backend mock header for /api requests', async () => {
|
||||
window.localStorage.setItem(MOCK_MODE_STORAGE_KEY, '1');
|
||||
window.localStorage.setItem(MOCK_BACKEND_STORAGE_KEY, '1');
|
||||
setToken('tok_test_backend_mock');
|
||||
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ provider: 'openai' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const data = await apiFetch<{ provider: string }>('/api/status');
|
||||
expect(data.provider).toBe('openai');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [, init] = fetchSpy.mock.calls[0]!;
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get('X-ZeroClaw-Mock')).toBe('1');
|
||||
expect(headers.get('Authorization')).toBe('Bearer tok_test_backend_mock');
|
||||
});
|
||||
|
||||
it('uses real /pair request in backend mock mode', async () => {
|
||||
window.localStorage.setItem(MOCK_MODE_STORAGE_KEY, '1');
|
||||
window.localStorage.setItem(MOCK_BACKEND_STORAGE_KEY, '1');
|
||||
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ token: 'pair-token' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await pair('123456');
|
||||
expect(result.token).toBe('pair-token');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe('/pair');
|
||||
});
|
||||
|
||||
it('uses real /health request in backend mock mode', async () => {
|
||||
window.localStorage.setItem(MOCK_MODE_STORAGE_KEY, '1');
|
||||
window.localStorage.setItem(MOCK_BACKEND_STORAGE_KEY, '1');
|
||||
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ require_pairing: true, paired: false }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const payload = await getPublicHealth();
|
||||
expect(payload.require_pairing).toBe(true);
|
||||
expect(payload.paired).toBe(false);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe('/health');
|
||||
});
|
||||
});
|
||||
@ -12,6 +12,8 @@ import type {
|
||||
HealthSnapshot,
|
||||
} from '../types/api';
|
||||
import { clearToken, getToken, setToken } from './auth';
|
||||
import { isMockBackendEnabled, isMockModeEnabled } from './mockMode';
|
||||
import { mockApiFetch } from './mockData';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base fetch wrapper
|
||||
@ -28,9 +30,20 @@ export async function apiFetch<T = unknown>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const mockMode = isMockModeEnabled();
|
||||
const backendMock = mockMode && isMockBackendEnabled();
|
||||
|
||||
if (mockMode && !backendMock) {
|
||||
return mockApiFetch<T>(path, options);
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
const headers = new Headers(options.headers);
|
||||
|
||||
if (backendMock && path.startsWith('/api/')) {
|
||||
headers.set('X-ZeroClaw-Mock', '1');
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
@ -79,6 +92,18 @@ function unwrapField<T>(value: T | Record<string, T>, key: string): T {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function pair(code: string): Promise<{ token: string }> {
|
||||
const mockMode = isMockModeEnabled();
|
||||
const backendMock = mockMode && isMockBackendEnabled();
|
||||
|
||||
if (mockMode && !backendMock) {
|
||||
const data = await mockApiFetch<{ token: string }>('/pair', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
setToken(data.token);
|
||||
return data;
|
||||
}
|
||||
|
||||
const response = await fetch('/pair', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Pairing-Code': code },
|
||||
@ -99,6 +124,13 @@ export async function pair(code: string): Promise<{ token: string }> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getPublicHealth(): Promise<{ require_pairing: boolean; paired: boolean }> {
|
||||
const mockMode = isMockModeEnabled();
|
||||
const backendMock = mockMode && isMockBackendEnabled();
|
||||
|
||||
if (mockMode && !backendMock) {
|
||||
return mockApiFetch<{ require_pairing: boolean; paired: boolean }>('/health');
|
||||
}
|
||||
|
||||
const response = await fetch('/health');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Health check failed (${response.status})`);
|
||||
|
||||
33
web/src/lib/i18n.test.ts
Normal file
33
web/src/lib/i18n.test.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { coerceLocale, LANGUAGE_SWITCH_ORDER } from './i18n';
|
||||
|
||||
describe('i18n locale support', () => {
|
||||
it('normalizes locale hints for the supported language set', () => {
|
||||
expect(coerceLocale('en-US')).toBe('en');
|
||||
expect(coerceLocale('zh')).toBe('zh-CN');
|
||||
expect(coerceLocale('zh-HK')).toBe('zh-CN');
|
||||
expect(coerceLocale('ja-JP')).toBe('ja');
|
||||
expect(coerceLocale('ru-RU')).toBe('ru');
|
||||
expect(coerceLocale('fr-CA')).toBe('fr');
|
||||
expect(coerceLocale('vi-VN')).toBe('vi');
|
||||
expect(coerceLocale('el-GR')).toBe('el');
|
||||
});
|
||||
|
||||
it('falls back to English for unknown locales', () => {
|
||||
expect(coerceLocale('es-ES')).toBe('en');
|
||||
expect(coerceLocale('pt-BR')).toBe('en');
|
||||
expect(coerceLocale(undefined)).toBe('en');
|
||||
});
|
||||
|
||||
it('uses the expected language switch order', () => {
|
||||
expect(LANGUAGE_SWITCH_ORDER).toEqual([
|
||||
'en',
|
||||
'zh-CN',
|
||||
'ja',
|
||||
'ru',
|
||||
'fr',
|
||||
'vi',
|
||||
'el',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -5,7 +5,39 @@ import { getStatus } from './api';
|
||||
// Translation dictionaries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Locale = 'en' | 'tr' | 'zh-CN';
|
||||
export type Locale = 'en' | 'tr' | 'zh-CN' | 'ja' | 'ru' | 'fr' | 'vi' | 'el';
|
||||
|
||||
export const LANGUAGE_SWITCH_ORDER: ReadonlyArray<Locale> = [
|
||||
'en',
|
||||
'zh-CN',
|
||||
'ja',
|
||||
'ru',
|
||||
'fr',
|
||||
'vi',
|
||||
'el',
|
||||
];
|
||||
|
||||
export const LANGUAGE_BUTTON_LABELS: Record<Locale, string> = {
|
||||
en: 'EN',
|
||||
tr: 'TR',
|
||||
'zh-CN': '简体',
|
||||
ja: '日本語',
|
||||
ru: 'РУ',
|
||||
fr: 'FR',
|
||||
vi: 'VI',
|
||||
el: 'ΕΛ',
|
||||
};
|
||||
|
||||
const KNOWN_LOCALES: ReadonlyArray<Locale> = [
|
||||
'en',
|
||||
'tr',
|
||||
'zh-CN',
|
||||
'ja',
|
||||
'ru',
|
||||
'fr',
|
||||
'vi',
|
||||
'el',
|
||||
];
|
||||
|
||||
const translations: Record<Locale, Record<string, string>> = {
|
||||
en: {
|
||||
@ -559,6 +591,11 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
'health.uptime': '运行时长',
|
||||
'health.updated_at': '最后更新',
|
||||
},
|
||||
ja: {},
|
||||
ru: {},
|
||||
fr: {},
|
||||
vi: {},
|
||||
el: {},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -599,10 +636,18 @@ export function tLocale(key: string, locale: Locale): string {
|
||||
// React hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normalizeLocale(locale: string | undefined): Locale {
|
||||
const lowered = locale?.toLowerCase();
|
||||
if (lowered?.startsWith('tr')) return 'tr';
|
||||
if (lowered === 'zh' || lowered?.startsWith('zh-')) return 'zh-CN';
|
||||
export function coerceLocale(locale: string | undefined): Locale {
|
||||
if (!locale) return 'en';
|
||||
if (KNOWN_LOCALES.includes(locale as Locale)) return locale as Locale;
|
||||
|
||||
const lowered = locale.toLowerCase();
|
||||
if (lowered.startsWith('tr')) return 'tr';
|
||||
if (lowered === 'zh' || lowered.startsWith('zh-')) return 'zh-CN';
|
||||
if (lowered === 'ja' || lowered.startsWith('ja-')) return 'ja';
|
||||
if (lowered === 'ru' || lowered.startsWith('ru-')) return 'ru';
|
||||
if (lowered === 'fr' || lowered.startsWith('fr-')) return 'fr';
|
||||
if (lowered === 'vi' || lowered.startsWith('vi-')) return 'vi';
|
||||
if (lowered === 'el' || lowered.startsWith('el-')) return 'el';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
@ -619,7 +664,7 @@ export function useLocale(): { locale: Locale; t: (key: string) => string } {
|
||||
getStatus()
|
||||
.then((status) => {
|
||||
if (cancelled) return;
|
||||
const detected = normalizeLocale(status.locale);
|
||||
const detected = coerceLocale(status.locale);
|
||||
setLocale(detected);
|
||||
setLocaleState(detected);
|
||||
})
|
||||
|
||||
29
web/src/lib/mockData.test.ts
Normal file
29
web/src/lib/mockData.test.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mockApiFetch } from './mockData';
|
||||
|
||||
describe('mockApiFetch', () => {
|
||||
it('returns dashboard status payload', async () => {
|
||||
const status = await mockApiFetch<{ provider: string; model: string }>('/api/status');
|
||||
expect(status.provider).toBe('openai');
|
||||
expect(status.model).toBe('gpt-5.2');
|
||||
});
|
||||
|
||||
it('creates cron job from valid payload', async () => {
|
||||
const created = await mockApiFetch<{ status: string; job: { command: string } }>('/api/cron', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ command: 'echo test', schedule: '*/5 * * * *' }),
|
||||
});
|
||||
|
||||
expect(created.status).toBe('created');
|
||||
expect(created.job.command).toBe('echo test');
|
||||
});
|
||||
|
||||
it('fails on invalid cron payload', async () => {
|
||||
await expect(
|
||||
mockApiFetch('/api/cron', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ command: '' }),
|
||||
}),
|
||||
).rejects.toThrow(/API 400/);
|
||||
});
|
||||
});
|
||||
488
web/src/lib/mockData.ts
Normal file
488
web/src/lib/mockData.ts
Normal file
@ -0,0 +1,488 @@
|
||||
import type {
|
||||
CliTool,
|
||||
ComponentHealth,
|
||||
CostSummary,
|
||||
CronJob,
|
||||
DiagResult,
|
||||
HealthSnapshot,
|
||||
Integration,
|
||||
IntegrationSettingsPayload,
|
||||
MemoryEntry,
|
||||
PairedDevice,
|
||||
StatusResponse,
|
||||
ToolSpec,
|
||||
} from '@/types/api';
|
||||
|
||||
interface MockResponse {
|
||||
status: number;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
const MOCK_LATENCY_MS = 120;
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parseJsonBody(options: RequestInit): Record<string, unknown> {
|
||||
if (typeof options.body !== 'string') {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(options.body) as unknown;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse failures and return empty map.
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
if (value === undefined) {
|
||||
return value;
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function ok(body: unknown): MockResponse {
|
||||
return { status: 200, body };
|
||||
}
|
||||
|
||||
function noContent(): MockResponse {
|
||||
return { status: 204 };
|
||||
}
|
||||
|
||||
function error(status: number, message: string): MockResponse {
|
||||
return { status, body: { error: message } };
|
||||
}
|
||||
|
||||
function buildHealthComponent(status: string, restartCount = 0): ComponentHealth {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
status,
|
||||
updated_at: now,
|
||||
last_ok: status === 'ok' ? now : null,
|
||||
last_error: status === 'ok' ? null : now,
|
||||
restart_count: restartCount,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockHealth(): HealthSnapshot {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
pid: 4242,
|
||||
updated_at: now,
|
||||
uptime_seconds: 68420,
|
||||
components: {
|
||||
gateway: buildHealthComponent('ok'),
|
||||
provider: buildHealthComponent('ok'),
|
||||
memory: buildHealthComponent('degraded', 1),
|
||||
channels: buildHealthComponent('ok'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockStatus(): StatusResponse {
|
||||
return {
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.2',
|
||||
temperature: 0.4,
|
||||
uptime_seconds: 68420,
|
||||
gateway_port: 42617,
|
||||
locale: 'en-US',
|
||||
memory_backend: 'sqlite',
|
||||
paired: true,
|
||||
channels: {
|
||||
telegram: true,
|
||||
discord: false,
|
||||
whatsapp: true,
|
||||
github: true,
|
||||
},
|
||||
health: buildMockHealth(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockCost(): CostSummary {
|
||||
return {
|
||||
session_cost_usd: 0.0842,
|
||||
daily_cost_usd: 1.3026,
|
||||
monthly_cost_usd: 14.9875,
|
||||
total_tokens: 182_342,
|
||||
request_count: 426,
|
||||
by_model: {
|
||||
'gpt-5.2': {
|
||||
model: 'gpt-5.2',
|
||||
cost_usd: 11.4635,
|
||||
total_tokens: 141_332,
|
||||
request_count: 292,
|
||||
},
|
||||
'claude-sonnet-4-5': {
|
||||
model: 'claude-sonnet-4-5',
|
||||
cost_usd: 3.524,
|
||||
total_tokens: 41_010,
|
||||
request_count: 134,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockTools(): ToolSpec[] {
|
||||
return [
|
||||
{
|
||||
name: 'shell',
|
||||
description: 'Run shell commands inside the workspace',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { command: { type: 'string' } },
|
||||
required: ['command'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'file_read',
|
||||
description: 'Read files from disk',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { path: { type: 'string' } },
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'web_fetch',
|
||||
description: 'Fetch and parse HTTP resources',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { url: { type: 'string' } },
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildMockCronJobs(): CronJob[] {
|
||||
return [
|
||||
{
|
||||
id: 'mock-cron-1',
|
||||
name: 'Daily sync',
|
||||
command: 'zeroclaw sync --channels',
|
||||
next_run: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
last_run: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
|
||||
last_status: 'ok',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'mock-cron-2',
|
||||
name: 'Budget audit',
|
||||
command: 'zeroclaw cost audit',
|
||||
next_run: new Date(Date.now() + 12 * 60 * 60 * 1000).toISOString(),
|
||||
last_run: null,
|
||||
last_status: null,
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildMockIntegrations(): Integration[] {
|
||||
return [
|
||||
{
|
||||
name: 'Slack',
|
||||
description: 'Slack bot messaging and thread orchestration',
|
||||
category: 'Channels',
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
name: 'GitHub',
|
||||
description: 'PR and issue automation',
|
||||
category: 'Automation',
|
||||
status: 'Available',
|
||||
},
|
||||
{
|
||||
name: 'Linear',
|
||||
description: 'Issue workflow sync',
|
||||
category: 'Productivity',
|
||||
status: 'ComingSoon',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildMockIntegrationSettings(): IntegrationSettingsPayload {
|
||||
return {
|
||||
revision: 'mock-revision-17',
|
||||
active_default_provider_integration_id: 'openai',
|
||||
integrations: [
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
description: 'Primary LLM provider',
|
||||
category: 'Providers',
|
||||
status: 'Active',
|
||||
configured: true,
|
||||
activates_default_provider: true,
|
||||
fields: [
|
||||
{
|
||||
key: 'api_key',
|
||||
label: 'API Key',
|
||||
required: true,
|
||||
has_value: true,
|
||||
input_type: 'secret',
|
||||
options: [],
|
||||
masked_value: 'sk-****abcd',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'slack',
|
||||
name: 'Slack',
|
||||
description: 'Workspace notifications and bot relay',
|
||||
category: 'Channels',
|
||||
status: 'Available',
|
||||
configured: false,
|
||||
activates_default_provider: false,
|
||||
fields: [
|
||||
{
|
||||
key: 'bot_token',
|
||||
label: 'Bot Token',
|
||||
required: true,
|
||||
has_value: false,
|
||||
input_type: 'secret',
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockMemory(): MemoryEntry[] {
|
||||
return [
|
||||
{
|
||||
id: 'mem-1',
|
||||
key: 'ops.runbook.gateway',
|
||||
content: 'Restart gateway with `zeroclaw gateway --open-dashboard` after updates.',
|
||||
category: 'operations',
|
||||
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||
session_id: 'sess_42',
|
||||
score: 0.92,
|
||||
},
|
||||
{
|
||||
id: 'mem-2',
|
||||
key: 'cost.budget.daily',
|
||||
content: 'Daily soft budget threshold is $2.50 for development environments.',
|
||||
category: 'cost',
|
||||
timestamp: new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString(),
|
||||
session_id: null,
|
||||
score: 0.88,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildMockDevices(): PairedDevice[] {
|
||||
return [
|
||||
{
|
||||
id: 'device-1',
|
||||
token_fingerprint: 'zc_3f2a...19d0',
|
||||
created_at: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
last_seen_at: new Date(Date.now() - 40 * 60 * 1000).toISOString(),
|
||||
paired_by: 'localhost',
|
||||
},
|
||||
{
|
||||
id: 'device-2',
|
||||
token_fingerprint: 'zc_09ac...7e4f',
|
||||
created_at: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
last_seen_at: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(),
|
||||
paired_by: 'vpn',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildMockCliTools(): CliTool[] {
|
||||
return [
|
||||
{
|
||||
name: 'git',
|
||||
path: '/usr/bin/git',
|
||||
version: '2.46.1',
|
||||
category: 'vcs',
|
||||
},
|
||||
{
|
||||
name: 'cargo',
|
||||
path: '/Users/mock/.cargo/bin/cargo',
|
||||
version: '1.87.0',
|
||||
category: 'build',
|
||||
},
|
||||
{
|
||||
name: 'npm',
|
||||
path: '/opt/homebrew/bin/npm',
|
||||
version: '11.3.0',
|
||||
category: 'package-manager',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildMockDiagnostics(): DiagResult[] {
|
||||
return [
|
||||
{
|
||||
severity: 'ok',
|
||||
category: 'runtime',
|
||||
message: 'Gateway listeners are healthy.',
|
||||
},
|
||||
{
|
||||
severity: 'warn',
|
||||
category: 'cost',
|
||||
message: 'Daily spend crossed 50% threshold.',
|
||||
},
|
||||
{
|
||||
severity: 'ok',
|
||||
category: 'security',
|
||||
message: 'Pairing mode is enabled.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const mockConfig = `[gateway]
|
||||
host = "127.0.0.1"
|
||||
port = 42617
|
||||
require_pairing = true
|
||||
|
||||
[agent]
|
||||
max_tool_iterations = 24
|
||||
`;
|
||||
|
||||
function routeMockResponse(path: string, options: RequestInit): MockResponse {
|
||||
const method = (options.method ?? 'GET').toUpperCase();
|
||||
const url = new URL(path, 'http://zeroclaw.mock');
|
||||
const body = parseJsonBody(options);
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (method === 'GET' && pathname === '/health') {
|
||||
return ok({ require_pairing: false, paired: true });
|
||||
}
|
||||
|
||||
if (method === 'POST' && pathname === '/pair') {
|
||||
const code = String(body.code ?? '').trim();
|
||||
if (code.length > 0 && code.length < 6) {
|
||||
return error(400, 'Mock pairing code must have at least 6 characters');
|
||||
}
|
||||
return ok({ token: 'mock-token-zeroclaw' });
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/api/status') {
|
||||
return ok(buildMockStatus());
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/api/health') {
|
||||
return ok({ health: buildMockHealth() });
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/api/cost') {
|
||||
return ok({ cost: buildMockCost() });
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/api/tools') {
|
||||
return ok({ tools: buildMockTools() });
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/api/cron') {
|
||||
return ok({ jobs: buildMockCronJobs() });
|
||||
}
|
||||
|
||||
if (method === 'POST' && pathname === '/api/cron') {
|
||||
const command = String(body.command ?? '').trim();
|
||||
const schedule = String(body.schedule ?? '').trim();
|
||||
if (!command || !schedule) {
|
||||
return error(400, 'command and schedule are required');
|
||||
}
|
||||
|
||||
const newJob: CronJob = {
|
||||
id: `mock-cron-${Date.now().toString(36)}`,
|
||||
name: typeof body.name === 'string' ? body.name : null,
|
||||
command,
|
||||
next_run: new Date(Date.now() + 60 * 1000).toISOString(),
|
||||
last_run: null,
|
||||
last_status: null,
|
||||
enabled: body.enabled === false ? false : true,
|
||||
};
|
||||
return ok({ status: 'created', job: newJob });
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && pathname.startsWith('/api/cron/')) {
|
||||
return noContent();
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/api/integrations') {
|
||||
return ok({ integrations: buildMockIntegrations() });
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/api/integrations/settings') {
|
||||
return ok(buildMockIntegrationSettings());
|
||||
}
|
||||
|
||||
if (
|
||||
method === 'PUT'
|
||||
&& pathname.startsWith('/api/integrations/')
|
||||
&& pathname.endsWith('/credentials')
|
||||
) {
|
||||
return ok({ status: 'ok', revision: `mock-revision-${Date.now().toString(36)}` });
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/api/memory') {
|
||||
return ok({ entries: buildMockMemory() });
|
||||
}
|
||||
|
||||
if (method === 'POST' && pathname === '/api/memory') {
|
||||
return ok({ status: 'stored' });
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && pathname.startsWith('/api/memory/')) {
|
||||
return noContent();
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/api/pairing/devices') {
|
||||
return ok({ devices: buildMockDevices() });
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && pathname.startsWith('/api/pairing/devices/')) {
|
||||
return noContent();
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/api/config') {
|
||||
return ok({ format: 'toml', content: mockConfig });
|
||||
}
|
||||
|
||||
if (method === 'PUT' && pathname === '/api/config') {
|
||||
return ok({ status: 'saved' });
|
||||
}
|
||||
|
||||
if ((method === 'POST' || method === 'GET') && pathname === '/api/doctor') {
|
||||
return ok({ results: buildMockDiagnostics() });
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/api/cli-tools') {
|
||||
return ok({ cli_tools: buildMockCliTools() });
|
||||
}
|
||||
|
||||
return error(404, `No mock route for ${method} ${pathname}`);
|
||||
}
|
||||
|
||||
export async function mockApiFetch<T = unknown>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const response = routeMockResponse(path, options);
|
||||
await wait(MOCK_LATENCY_MS);
|
||||
|
||||
if (response.status >= 400) {
|
||||
const errorPayload = response.body ?? { error: 'Unknown mock API error' };
|
||||
const message =
|
||||
typeof errorPayload === 'string' ? errorPayload : JSON.stringify(errorPayload);
|
||||
throw new Error(`API ${response.status}: ${message}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return deepClone(response.body) as T;
|
||||
}
|
||||
66
web/src/lib/mockMode.test.ts
Normal file
66
web/src/lib/mockMode.test.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isMockBackendEnabled,
|
||||
isMockModeEnabled,
|
||||
MOCK_BACKEND_STORAGE_KEY,
|
||||
MOCK_MODE_STORAGE_KEY,
|
||||
setMockBackendEnabled,
|
||||
setMockModeEnabled,
|
||||
} from './mockMode';
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.removeItem(MOCK_BACKEND_STORAGE_KEY);
|
||||
window.localStorage.removeItem(MOCK_MODE_STORAGE_KEY);
|
||||
window.history.pushState({}, '', '/');
|
||||
});
|
||||
|
||||
describe('mockMode', () => {
|
||||
it('defaults to disabled', () => {
|
||||
expect(isMockModeEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('enables from localStorage', () => {
|
||||
window.localStorage.setItem(MOCK_MODE_STORAGE_KEY, '1');
|
||||
expect(isMockModeEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('uses query param override when present', () => {
|
||||
window.localStorage.setItem(MOCK_MODE_STORAGE_KEY, '1');
|
||||
window.history.pushState({}, '', '/?mock=0');
|
||||
expect(isMockModeEnabled()).toBe(false);
|
||||
|
||||
window.history.pushState({}, '', '/?mock=true');
|
||||
expect(isMockModeEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('setMockModeEnabled persists and clears values', () => {
|
||||
setMockModeEnabled(true);
|
||||
expect(window.localStorage.getItem(MOCK_MODE_STORAGE_KEY)).toBe('1');
|
||||
|
||||
setMockModeEnabled(false);
|
||||
expect(window.localStorage.getItem(MOCK_MODE_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('mock backend defaults to disabled and can be enabled from storage', () => {
|
||||
expect(isMockBackendEnabled()).toBe(false);
|
||||
window.localStorage.setItem(MOCK_BACKEND_STORAGE_KEY, '1');
|
||||
expect(isMockBackendEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('mock backend query param overrides localStorage', () => {
|
||||
window.localStorage.setItem(MOCK_BACKEND_STORAGE_KEY, '1');
|
||||
window.history.pushState({}, '', '/?mock_backend=0');
|
||||
expect(isMockBackendEnabled()).toBe(false);
|
||||
|
||||
window.history.pushState({}, '', '/?mock_backend=1');
|
||||
expect(isMockBackendEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('setMockBackendEnabled persists and clears values', () => {
|
||||
setMockBackendEnabled(true);
|
||||
expect(window.localStorage.getItem(MOCK_BACKEND_STORAGE_KEY)).toBe('1');
|
||||
|
||||
setMockBackendEnabled(false);
|
||||
expect(window.localStorage.getItem(MOCK_BACKEND_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
99
web/src/lib/mockMode.ts
Normal file
99
web/src/lib/mockMode.ts
Normal file
@ -0,0 +1,99 @@
|
||||
export const MOCK_MODE_STORAGE_KEY = 'zeroclaw:mock-mode';
|
||||
export const MOCK_BACKEND_STORAGE_KEY = 'zeroclaw:mock-backend';
|
||||
|
||||
const truthyValues = new Set(['1', 'true', 'yes', 'on']);
|
||||
|
||||
function parseTruthy(value: string | null | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return truthyValues.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function isMockModeEnabled(): boolean {
|
||||
const envEnabled = parseTruthy(import.meta.env.VITE_MOCK_API);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return envEnabled;
|
||||
}
|
||||
|
||||
try {
|
||||
const queryValue = new URLSearchParams(window.location.search).get('mock');
|
||||
if (queryValue !== null) {
|
||||
return parseTruthy(queryValue);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed search params and continue to storage/env checks.
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(MOCK_MODE_STORAGE_KEY);
|
||||
if (stored !== null) {
|
||||
return parseTruthy(stored);
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage access errors and fall back to env value.
|
||||
}
|
||||
|
||||
return envEnabled;
|
||||
}
|
||||
|
||||
export function isMockBackendEnabled(): boolean {
|
||||
const envEnabled = parseTruthy(import.meta.env.VITE_MOCK_BACKEND);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return envEnabled;
|
||||
}
|
||||
|
||||
try {
|
||||
const queryValue = new URLSearchParams(window.location.search).get('mock_backend');
|
||||
if (queryValue !== null) {
|
||||
return parseTruthy(queryValue);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed search params and continue to storage/env checks.
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(MOCK_BACKEND_STORAGE_KEY);
|
||||
if (stored !== null) {
|
||||
return parseTruthy(stored);
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage access errors and fall back to env value.
|
||||
}
|
||||
|
||||
return envEnabled;
|
||||
}
|
||||
|
||||
export function setMockModeEnabled(enabled: boolean): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (enabled) {
|
||||
window.localStorage.setItem(MOCK_MODE_STORAGE_KEY, '1');
|
||||
} else {
|
||||
window.localStorage.removeItem(MOCK_MODE_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage access errors.
|
||||
}
|
||||
}
|
||||
|
||||
export function setMockBackendEnabled(enabled: boolean): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (enabled) {
|
||||
window.localStorage.setItem(MOCK_BACKEND_STORAGE_KEY, '1');
|
||||
} else {
|
||||
window.localStorage.removeItem(MOCK_BACKEND_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage access errors.
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import type { SSEEvent } from '../types/api';
|
||||
import { getToken } from './auth';
|
||||
import { isMockModeEnabled } from './mockMode';
|
||||
|
||||
export type SSEEventHandler = (event: SSEEvent) => void;
|
||||
export type SSEErrorHandler = (error: Event | Error) => void;
|
||||
@ -28,6 +29,7 @@ const MAX_RECONNECT_DELAY = 30000;
|
||||
export class SSEClient {
|
||||
private controller: AbortController | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private mockTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private currentDelay: number;
|
||||
private intentionallyClosed = false;
|
||||
|
||||
@ -52,6 +54,12 @@ export class SSEClient {
|
||||
connect(): void {
|
||||
this.intentionallyClosed = false;
|
||||
this.clearReconnectTimer();
|
||||
|
||||
if (isMockModeEnabled()) {
|
||||
this.mockConnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.controller = new AbortController();
|
||||
|
||||
const token = getToken();
|
||||
@ -92,6 +100,7 @@ export class SSEClient {
|
||||
disconnect(): void {
|
||||
this.intentionallyClosed = true;
|
||||
this.clearReconnectTimer();
|
||||
this.clearMockTimer();
|
||||
if (this.controller) {
|
||||
this.controller.abort();
|
||||
this.controller = null;
|
||||
@ -182,4 +191,36 @@ export class SSEClient {
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private mockConnect(): void {
|
||||
this.clearMockTimer();
|
||||
this.currentDelay = this.reconnectDelay;
|
||||
this.onConnect?.();
|
||||
|
||||
const eventTypes = ['status', 'message', 'tool_call', 'tool_result', 'health'] as const;
|
||||
let tick = 0;
|
||||
|
||||
this.mockTimer = setInterval(() => {
|
||||
if (this.intentionallyClosed) {
|
||||
this.clearMockTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const type = eventTypes[tick % eventTypes.length] ?? 'message';
|
||||
tick += 1;
|
||||
const now = new Date().toISOString();
|
||||
this.onEvent?.({
|
||||
type,
|
||||
timestamp: now,
|
||||
message: `[mock] ${type} event #${tick}`,
|
||||
});
|
||||
}, 1400);
|
||||
}
|
||||
|
||||
private clearMockTimer(): void {
|
||||
if (this.mockTimer !== null) {
|
||||
clearInterval(this.mockTimer);
|
||||
this.mockTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
web/src/lib/wasm.test.ts
Normal file
9
web/src/lib/wasm.test.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isWasmSupported } from './wasm';
|
||||
|
||||
describe('isWasmSupported', () => {
|
||||
it('returns a boolean without throwing', () => {
|
||||
expect(() => isWasmSupported()).not.toThrow();
|
||||
expect(typeof isWasmSupported()).toBe('boolean');
|
||||
});
|
||||
});
|
||||
31
web/src/lib/wasm.ts
Normal file
31
web/src/lib/wasm.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// Canonical tiny WASM module header for capability checks.
|
||||
const wasmProbeBytes = Uint8Array.of(
|
||||
0x00,
|
||||
0x61,
|
||||
0x73,
|
||||
0x6d,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
);
|
||||
|
||||
export function isWasmSupported(): boolean {
|
||||
try {
|
||||
if (typeof WebAssembly !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (typeof WebAssembly.Module !== 'function') {
|
||||
return false;
|
||||
}
|
||||
if (typeof WebAssembly.Instance !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const module = new WebAssembly.Module(wasmProbeBytes);
|
||||
const instance = new WebAssembly.Instance(module);
|
||||
return instance instanceof WebAssembly.Instance;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import type { WsMessage } from '../types/api';
|
||||
import { getToken } from './auth';
|
||||
import { isMockModeEnabled } from './mockMode';
|
||||
|
||||
export type WsMessageHandler = (msg: WsMessage) => void;
|
||||
export type WsOpenHandler = () => void;
|
||||
@ -25,6 +26,8 @@ export class WebSocketClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private currentDelay: number;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private mockTimers: Array<ReturnType<typeof setTimeout>> = [];
|
||||
private mockConnected = false;
|
||||
private intentionallyClosed = false;
|
||||
|
||||
public onMessage: WsMessageHandler | null = null;
|
||||
@ -54,6 +57,11 @@ export class WebSocketClient {
|
||||
this.intentionallyClosed = false;
|
||||
this.clearReconnectTimer();
|
||||
|
||||
if (isMockModeEnabled()) {
|
||||
this.mockConnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
const url = `${this.baseUrl}/ws/chat?session_id=${encodeURIComponent(this.sessionId)}`;
|
||||
const protocols = ['zeroclaw.v1'];
|
||||
@ -89,6 +97,30 @@ export class WebSocketClient {
|
||||
|
||||
/** Send a chat message to the agent. */
|
||||
sendMessage(content: string): void {
|
||||
if (isMockModeEnabled()) {
|
||||
if (!this.mockConnected) {
|
||||
throw new Error('Mock WebSocket is not connected');
|
||||
}
|
||||
|
||||
this.pushMockTimer(
|
||||
setTimeout(() => {
|
||||
this.onMessage?.({
|
||||
type: 'chunk',
|
||||
content: 'Mock runtime: analyzing request...',
|
||||
});
|
||||
}, 220),
|
||||
);
|
||||
this.pushMockTimer(
|
||||
setTimeout(() => {
|
||||
this.onMessage?.({
|
||||
type: 'done',
|
||||
full_response: `Mock response generated for: "${content}"`,
|
||||
});
|
||||
}, 780),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('WebSocket is not connected');
|
||||
}
|
||||
@ -99,6 +131,8 @@ export class WebSocketClient {
|
||||
disconnect(): void {
|
||||
this.intentionallyClosed = true;
|
||||
this.clearReconnectTimer();
|
||||
this.clearMockTimers();
|
||||
this.mockConnected = false;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
@ -107,6 +141,9 @@ export class WebSocketClient {
|
||||
|
||||
/** Returns true if the socket is open. */
|
||||
get connected(): boolean {
|
||||
if (isMockModeEnabled()) {
|
||||
return this.mockConnected;
|
||||
}
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
@ -130,6 +167,38 @@ export class WebSocketClient {
|
||||
}
|
||||
}
|
||||
|
||||
private mockConnect(): void {
|
||||
this.clearMockTimers();
|
||||
this.mockConnected = true;
|
||||
this.currentDelay = this.reconnectDelay;
|
||||
|
||||
this.pushMockTimer(
|
||||
setTimeout(() => {
|
||||
this.onOpen?.();
|
||||
this.onMessage?.({
|
||||
type: 'history',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Mock mode connected. This chat is running without a backend.',
|
||||
},
|
||||
],
|
||||
});
|
||||
}, 80),
|
||||
);
|
||||
}
|
||||
|
||||
private pushMockTimer(timer: ReturnType<typeof setTimeout>): void {
|
||||
this.mockTimers.push(timer);
|
||||
}
|
||||
|
||||
private clearMockTimers(): void {
|
||||
for (const timer of this.mockTimers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.mockTimers = [];
|
||||
}
|
||||
|
||||
private resolveSessionId(): string {
|
||||
const existing = window.localStorage.getItem(WS_SESSION_STORAGE_KEY);
|
||||
if (existing && /^[A-Za-z0-9_-]{1,128}$/.test(existing)) {
|
||||
|
||||
@ -181,7 +181,7 @@ export default function AgentChat() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-3.5rem)]">
|
||||
<div className="flex min-h-[28rem] flex-col h-[calc(100dvh-8.5rem)]">
|
||||
{/* Connection status bar */}
|
||||
{error && (
|
||||
<div className="px-4 py-2 bg-red-900/30 border-b border-red-700 flex items-center gap-2 text-sm text-red-300">
|
||||
|
||||
40
web/src/pages/Dashboard.test.tsx
Normal file
40
web/src/pages/Dashboard.test.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import Dashboard from './Dashboard';
|
||||
import { MOCK_MODE_STORAGE_KEY } from '@/lib/mockMode';
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.removeItem(MOCK_MODE_STORAGE_KEY);
|
||||
window.history.pushState({}, '', '/');
|
||||
});
|
||||
|
||||
describe('Dashboard', () => {
|
||||
it('renders with mock data and supports collapsing every dashboard section', async () => {
|
||||
window.localStorage.setItem(MOCK_MODE_STORAGE_KEY, '1');
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
expect(await screen.findByText('Electric Runtime Dashboard')).toBeInTheDocument();
|
||||
expect(await screen.findByText('openai')).toBeInTheDocument();
|
||||
|
||||
const sectionButtons = [
|
||||
screen.getByRole('button', { name: /Cost Pulse/i }),
|
||||
screen.getByRole('button', { name: /Channel Activity/i }),
|
||||
screen.getByRole('button', { name: /Component Health/i }),
|
||||
];
|
||||
|
||||
for (const sectionButton of sectionButtons) {
|
||||
expect(sectionButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await userEvent.click(sectionButton);
|
||||
await waitFor(() => {
|
||||
expect(sectionButton).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
await userEvent.click(sectionButton);
|
||||
await waitFor(() => {
|
||||
expect(sectionButton).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,15 +1,38 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ComponentType, ReactNode, SVGProps } from 'react';
|
||||
import {
|
||||
Cpu,
|
||||
Clock,
|
||||
Globe,
|
||||
Database,
|
||||
Activity,
|
||||
ChevronDown,
|
||||
Clock3,
|
||||
Cpu,
|
||||
Database,
|
||||
DollarSign,
|
||||
Globe2,
|
||||
Radio,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import type { StatusResponse, CostSummary } from '@/types/api';
|
||||
import { getStatus, getCost } from '@/lib/api';
|
||||
import type { CostSummary, StatusResponse } from '@/types/api';
|
||||
import { getCost, getStatus } from '@/lib/api';
|
||||
import { isMockModeEnabled } from '@/lib/mockMode';
|
||||
|
||||
type DashboardSectionKey = 'cost' | 'channels' | 'health';
|
||||
|
||||
interface DashboardSectionState {
|
||||
cost: boolean;
|
||||
channels: boolean;
|
||||
health: boolean;
|
||||
}
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
sectionKey: DashboardSectionKey;
|
||||
openState: DashboardSectionState;
|
||||
onToggle: (section: DashboardSectionKey) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
@ -28,13 +51,13 @@ function healthColor(status: string): string {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'ok':
|
||||
case 'healthy':
|
||||
return 'bg-green-500';
|
||||
return 'bg-emerald-400';
|
||||
case 'warn':
|
||||
case 'warning':
|
||||
case 'degraded':
|
||||
return 'bg-yellow-500';
|
||||
return 'bg-amber-400';
|
||||
default:
|
||||
return 'bg-red-500';
|
||||
return 'bg-rose-500';
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,44 +65,107 @@ function healthBorder(status: string): string {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'ok':
|
||||
case 'healthy':
|
||||
return 'border-green-500/30';
|
||||
return 'border-emerald-500/30';
|
||||
case 'warn':
|
||||
case 'warning':
|
||||
case 'degraded':
|
||||
return 'border-yellow-500/30';
|
||||
return 'border-amber-400/30';
|
||||
default:
|
||||
return 'border-red-500/30';
|
||||
return 'border-rose-500/35';
|
||||
}
|
||||
}
|
||||
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
sectionKey,
|
||||
openState,
|
||||
onToggle,
|
||||
children,
|
||||
}: CollapsibleSectionProps) {
|
||||
const isOpen = openState[sectionKey];
|
||||
|
||||
return (
|
||||
<section className="electric-card motion-rise">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(sectionKey)}
|
||||
aria-expanded={isOpen}
|
||||
className="group flex w-full items-center justify-between gap-4 rounded-xl px-4 py-4 text-left md:px-5"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="electric-icon h-10 w-10 rounded-xl">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">{title}</h2>
|
||||
<p className="text-xs uppercase tracking-[0.13em] text-[#7ea5eb]">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={[
|
||||
'h-5 w-5 text-[#7ea5eb] transition-transform duration-300',
|
||||
isOpen ? 'rotate-180' : 'rotate-0',
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={[
|
||||
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-300 ease-out',
|
||||
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="min-h-0 border-t border-[#18356f] px-4 pb-4 pt-4 md:px-5">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||
const [cost, setCost] = useState<CostSummary | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sectionsOpen, setSectionsOpen] = useState<DashboardSectionState>({
|
||||
cost: true,
|
||||
channels: true,
|
||||
health: true,
|
||||
});
|
||||
|
||||
const mockMode = isMockModeEnabled();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getStatus(), getCost()])
|
||||
.then(([s, c]) => {
|
||||
setStatus(s);
|
||||
setCost(c);
|
||||
.then(([statusPayload, costPayload]) => {
|
||||
setStatus(statusPayload);
|
||||
setCost(costPayload);
|
||||
})
|
||||
.catch((err) => setError(err.message));
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : 'Unknown dashboard load error';
|
||||
setError(message);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSection = (section: DashboardSectionKey) => {
|
||||
setSectionsOpen((prev) => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}));
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
|
||||
Failed to load dashboard: {error}
|
||||
</div>
|
||||
<div className="electric-card p-5 text-rose-200">
|
||||
<h2 className="text-lg font-semibold text-rose-100">Dashboard load failed</h2>
|
||||
<p className="mt-2 text-sm text-rose-200/90">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status || !cost) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent" />
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="electric-loader h-12 w-12 rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -87,165 +173,185 @@ export default function Dashboard() {
|
||||
const maxCost = Math.max(cost.session_cost_usd, cost.daily_cost_usd, cost.monthly_cost_usd, 0.001);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Status Cards Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-blue-600/20 rounded-lg">
|
||||
<Cpu className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">Provider / Model</span>
|
||||
<div className="space-y-5 md:space-y-6">
|
||||
<section className="hero-panel motion-rise">
|
||||
<div className="relative z-10 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-[#8fb8ff]">ZeroClaw Command Deck</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-[0.03em] text-white md:text-3xl">
|
||||
Electric Runtime Dashboard
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-[#b3cbf8] md:text-base">
|
||||
Real-time telemetry, cost pulse, and operations status in a single collapsible surface.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-white truncate">
|
||||
{status.provider ?? 'Unknown'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 truncate">{status.model}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-green-600/20 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-green-400" />
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">Uptime</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="status-pill">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Live Gateway
|
||||
</span>
|
||||
<span className="status-pill">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
{status.paired ? 'Paired' : 'Unpaired'}
|
||||
</span>
|
||||
{mockMode && <span className="status-pill status-pill-mock">Mock Data</span>}
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
{formatUptime(status.uptime_seconds)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">Since last restart</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-purple-600/20 rounded-lg">
|
||||
<Globe className="h-5 w-5 text-purple-400" />
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">Gateway Port</span>
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<article className="electric-card motion-rise motion-delay-1 p-4">
|
||||
<div className="metric-head">
|
||||
<Cpu className="h-4 w-4" />
|
||||
<span>Provider / Model</span>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
:{status.gateway_port}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">Locale: {status.locale}</p>
|
||||
</div>
|
||||
<p className="metric-value mt-3">{status.provider ?? 'Unknown'}</p>
|
||||
<p className="metric-sub mt-1 truncate">{status.model}</p>
|
||||
</article>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-orange-600/20 rounded-lg">
|
||||
<Database className="h-5 w-5 text-orange-400" />
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">Memory Backend</span>
|
||||
<article className="electric-card motion-rise motion-delay-2 p-4">
|
||||
<div className="metric-head">
|
||||
<Clock3 className="h-4 w-4" />
|
||||
<span>Uptime</span>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-white capitalize">
|
||||
{status.memory_backend}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Paired: {status.paired ? 'Yes' : 'No'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="metric-value mt-3">{formatUptime(status.uptime_seconds)}</p>
|
||||
<p className="metric-sub mt-1">Since last restart</p>
|
||||
</article>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Cost Widget */}
|
||||
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<DollarSign className="h-5 w-5 text-blue-400" />
|
||||
<h2 className="text-base font-semibold text-white">Cost Overview</h2>
|
||||
<article className="electric-card motion-rise motion-delay-3 p-4">
|
||||
<div className="metric-head">
|
||||
<Globe2 className="h-4 w-4" />
|
||||
<span>Gateway Port</span>
|
||||
</div>
|
||||
<p className="metric-value mt-3">:{status.gateway_port}</p>
|
||||
<p className="metric-sub mt-1">{status.locale}</p>
|
||||
</article>
|
||||
|
||||
<article className="electric-card motion-rise motion-delay-4 p-4">
|
||||
<div className="metric-head">
|
||||
<Database className="h-4 w-4" />
|
||||
<span>Memory Backend</span>
|
||||
</div>
|
||||
<p className="metric-value mt-3 capitalize">{status.memory_backend}</p>
|
||||
<p className="metric-sub mt-1">{status.paired ? 'Pairing active' : 'No paired devices'}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection
|
||||
title="Cost Pulse"
|
||||
subtitle="Session, daily, and monthly runtime spend"
|
||||
icon={DollarSign}
|
||||
sectionKey="cost"
|
||||
openState={sectionsOpen}
|
||||
onToggle={toggleSection}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ label: 'Session', value: cost.session_cost_usd, color: 'bg-blue-500' },
|
||||
{ label: 'Daily', value: cost.daily_cost_usd, color: 'bg-green-500' },
|
||||
{ label: 'Monthly', value: cost.monthly_cost_usd, color: 'bg-purple-500' },
|
||||
].map(({ label, value, color }) => (
|
||||
{ label: 'Session', value: cost.session_cost_usd },
|
||||
{ label: 'Daily', value: cost.daily_cost_usd },
|
||||
{ label: 'Monthly', value: cost.monthly_cost_usd },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-400">{label}</span>
|
||||
<span className="text-white font-medium">{formatUSD(value)}</span>
|
||||
<div className="mb-1.5 flex items-center justify-between text-sm">
|
||||
<span className="text-[#9bb8ec]">{label}</span>
|
||||
<span className="font-semibold text-white">{formatUSD(value)}</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div className="h-2.5 overflow-hidden rounded-full bg-[#061230]">
|
||||
<div
|
||||
className={`h-full rounded-full ${color}`}
|
||||
style={{ width: `${Math.max((value / maxCost) * 100, 2)}%` }}
|
||||
className="electric-progress h-full rounded-full"
|
||||
style={{ width: `${Math.max((value / maxCost) * 100, 3)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-gray-800 flex justify-between text-sm">
|
||||
<span className="text-gray-400">Total Tokens</span>
|
||||
<span className="text-white">{cost.total_tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-1">
|
||||
<span className="text-gray-400">Requests</span>
|
||||
<span className="text-white">{cost.request_count.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Channels */}
|
||||
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Radio className="h-5 w-5 text-blue-400" />
|
||||
<h2 className="text-base font-semibold text-white">Active Channels</h2>
|
||||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||||
<div className="metric-pill">
|
||||
<span>Total Tokens</span>
|
||||
<strong>{cost.total_tokens.toLocaleString()}</strong>
|
||||
</div>
|
||||
<div className="metric-pill">
|
||||
<span>Requests</span>
|
||||
<strong>{cost.request_count.toLocaleString()}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(status.channels).length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No channels configured</p>
|
||||
) : (
|
||||
Object.entries(status.channels).map(([name, active]) => (
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection
|
||||
title="Channel Activity"
|
||||
subtitle="Live integrations and route connectivity"
|
||||
icon={Radio}
|
||||
sectionKey="channels"
|
||||
openState={sectionsOpen}
|
||||
onToggle={toggleSection}
|
||||
>
|
||||
{Object.entries(status.channels).length === 0 ? (
|
||||
<p className="text-sm text-[#8aa8df]">No channels configured.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
{Object.entries(status.channels).map(([name, active]) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-800/50"
|
||||
className="rounded-xl border border-[#1d3770] bg-[#05112c]/90 px-3 py-2.5"
|
||||
>
|
||||
<span className="text-sm text-white capitalize">{name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-2.5 w-2.5 rounded-full ${
|
||||
active ? 'bg-green-500' : 'bg-gray-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm capitalize text-white">{name}</span>
|
||||
<span className="flex items-center gap-2 text-xs text-[#8baee7]">
|
||||
<span
|
||||
className={[
|
||||
'inline-block h-2.5 w-2.5 rounded-full',
|
||||
active ? 'bg-emerald-400 shadow-[0_0_12px_0_rgba(52,211,153,0.8)]' : 'bg-slate-500',
|
||||
].join(' ')}
|
||||
/>
|
||||
{active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Health Grid */}
|
||||
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Activity className="h-5 w-5 text-blue-400" />
|
||||
<h2 className="text-base font-semibold text-white">Component Health</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Object.entries(status.health.components).length === 0 ? (
|
||||
<p className="text-sm text-gray-500 col-span-2">No components reporting</p>
|
||||
) : (
|
||||
Object.entries(status.health.components).map(([name, comp]) => (
|
||||
<CollapsibleSection
|
||||
title="Component Health"
|
||||
subtitle="Runtime heartbeat and restart awareness"
|
||||
icon={Activity}
|
||||
sectionKey="health"
|
||||
openState={sectionsOpen}
|
||||
onToggle={toggleSection}
|
||||
>
|
||||
{Object.entries(status.health.components).length === 0 ? (
|
||||
<p className="text-sm text-[#8aa8df]">No component health is currently available.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Object.entries(status.health.components).map(([name, component]) => (
|
||||
<div
|
||||
key={name}
|
||||
className={`rounded-lg p-3 border ${healthBorder(comp.status)} bg-gray-800/50`}
|
||||
className={[
|
||||
'rounded-xl border bg-[#05112c]/80 px-3 py-3',
|
||||
healthBorder(component.status),
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${healthColor(comp.status)}`} />
|
||||
<span className="text-sm font-medium text-white capitalize truncate">
|
||||
{name}
|
||||
</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold capitalize text-white">{name}</p>
|
||||
<span className={['inline-block h-2.5 w-2.5 rounded-full', healthColor(component.status)].join(' ')} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 capitalize">{comp.status}</p>
|
||||
{comp.restart_count > 0 && (
|
||||
<p className="text-xs text-yellow-400 mt-1">
|
||||
Restarts: {comp.restart_count}
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.12em] text-[#87a9e5]">
|
||||
{component.status}
|
||||
</p>
|
||||
{component.restart_count > 0 && (
|
||||
<p className="mt-2 text-xs text-amber-300">
|
||||
Restarts: {component.restart_count}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -133,7 +133,7 @@ export default function Logs() {
|
||||
: entries.filter((e) => typeFilters.has(e.event.type));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-3.5rem)]">
|
||||
<div className="flex min-h-[28rem] flex-col h-[calc(100dvh-8.5rem)]">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b border-gray-800 bg-gray-900">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
1
web/src/test/setup.ts
Normal file
1
web/src/test/setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
@ -21,6 +21,7 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"types": ["vite/client", "vitest/globals"],
|
||||
|
||||
/* Aliases */
|
||||
"baseUrl": ".",
|
||||
|
||||
21
web/vitest.config.ts
Normal file
21
web/vitest.config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['e2e/**'],
|
||||
css: true,
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user