6 Commits

Author SHA1 Message Date
GW_MC
7a0111c4c5 fix: update anyhow dependency to include backtrace feature
Some checks failed
Test / get-ci-image (pull_request) Successful in 5s
Test / lint-frontend (pull_request) Successful in 24s
Test / frontend-build (pull_request) Successful in 23s
Verify / get-ci-image (pull_request) Successful in 5s
Test / test-frontend (pull_request) Successful in 37s
Test / lint-crates (pull_request) Successful in 1m46s
Test / test-crates (pull_request) Successful in 1m56s
Verify / verify-generated-db-entities (pull_request) Failing after 2m15s
2026-04-25 07:22:44 +00:00
GW_MC
0575c34fd6 fix: improve config file handling and add tests for NginxHandler
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 07:22:19 +00:00
GW_MC
1daac01583 Merge branch 'master' into feature/nginx-handler
All checks were successful
Test / get-ci-image (pull_request) Successful in 6s
Test / lint-frontend (pull_request) Successful in 24s
Test / test-frontend (pull_request) Successful in 25s
Verify / get-ci-image (pull_request) Successful in 5s
Test / frontend-build (pull_request) Successful in 40s
Test / lint-crates (pull_request) Successful in 4m58s
Test / test-crates (pull_request) Successful in 5m16s
Verify / verify-generated-db-entities (pull_request) Successful in 5m42s
2026-04-25 06:43:58 +00:00
GW_MC
60af2c5b80 Merge branch 'master' into feature/nginx-handler
All checks were successful
Test / get-ci-image (pull_request) Successful in 6s
Test / test-frontend (pull_request) Successful in 12s
Test / lint-frontend (pull_request) Successful in 15s
Test / frontend-build (pull_request) Successful in 13s
Verify / get-ci-image (pull_request) Successful in 5s
Test / lint-crates (pull_request) Successful in 5m4s
Test / test-crates (pull_request) Successful in 5m9s
Verify / verify-generated-db-entities (pull_request) Successful in 5m57s
2026-04-16 05:03:05 +00:00
GW_MC
e831640540 feat: Implement NginxHandler with reload, stop, validate, and config management methods 2026-04-11 07:32:20 +00:00
GW_MC
a023cbc082 feat: Add 'anyhow' dependency 2026-04-01 10:02:48 +00:00
23 changed files with 798 additions and 1075 deletions

22
Cargo.lock generated
View File

@@ -1443,6 +1443,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619"
[[package]]
name = "fs4"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4"
dependencies = [
"rustix",
"tokio",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "funty" name = "funty"
version = "2.0.0" version = "2.0.0"
@@ -2558,10 +2569,12 @@ dependencies = [
name = "nxmesh-agent" name = "nxmesh-agent"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"async-trait", "async-trait",
"chrono", "chrono",
"clap", "clap",
"config", "config",
"fs4",
"futures", "futures",
"hex", "hex",
"hostname", "hostname",
@@ -5380,6 +5393,15 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.60.2" version = "0.60.2"

View File

@@ -56,9 +56,6 @@ futures = "0.3"
toml = "0.9" toml = "0.9"
config = "0.15" config = "0.15"
# HTTP client
reqwest = { version = "0.13.2", default-features = false, features = ["json"] }
# Crypto # Crypto
sha2 = "0.10" sha2 = "0.10"
hex = "0.4" hex = "0.4"

View File

@@ -56,6 +56,8 @@ zip = { workspace = true }
# CLI # CLI
clap = { workspace = true, features = ["derive"] } clap = { workspace = true, features = ["derive"] }
anyhow = { version = "1.0.102", features = ["backtrace"] }
fs4 = { version = "0.13.1", features = ["tokio"] }
[dev-dependencies] [dev-dependencies]
tokio-test.workspace = true tokio-test.workspace = true

View File

@@ -14,6 +14,7 @@ use crate::connector::master::{MasterConnector, MasterConnectorTrait, ssh::SshMa
mod cli; mod cli;
mod config; mod config;
mod connector; mod connector;
mod service;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
@@ -60,13 +61,13 @@ async fn main() {
} }
// send a dummy heartbeat to verify the connection is working // send a dummy heartbeat to verify the connection is working
let client = master_connector.get_client(); let mut client = master_connector.get_client().lock().await.clone();
let request = nxmesh_proto::HealthReport { let request = nxmesh_proto::HealthReport {
..Default::default() ..Default::default()
}; };
match client.lock().await.report_health(request).await { match client.report_health(request).await {
Ok(_) => info!("Successfully sent health report to master."), Ok(_) => info!("Successfully sent health report to master."),
Err(e) => { Err(e) => {
error!("Failed to send health report to master: {}", e); error!("Failed to send health report to master: {}", e);

View File

@@ -0,0 +1 @@
pub mod nginx_handler;

View File

@@ -0,0 +1,495 @@
use std::sync::Arc;
use anyhow::Result;
use fs4::tokio::AsyncFileExt;
use tokio::{io::AsyncWriteExt, process::Command};
use tracing::{debug, warn};
use crate::config::settings::NginxSettings;
#[cfg(test)]
use mockall::predicate::*;
// TODO: custom error type
#[async_trait::async_trait]
#[cfg_attr(test, mockall::automock)]
pub trait NginxHandler {
// Reload nginx to apply new config. The config_path is an optional parameter that specifies the path to the nginx config file to be used for this reload operation. If not provided, the default config path will be used.
async fn reload(&self, config_path: Option<&str>) -> Result<()>;
async fn stop(&self) -> Result<()>;
async fn validate(&self, config_path: Option<&str>) -> Result<()>;
async fn get_version(&self) -> Result<String>;
async fn get_status(&self) -> Result<String>;
// Write a new config file for nginx.
// The output_path is a relative path to the nginx config directory of the deployment folder. The actual path to the config should not be assumed by the caller, as it can be different in different environments, but will be promised to be relative to the deployment folder for each the corresponding deployment_id. Path traversal is not allowed.
async fn write_config(
&self,
deployment_id: &str,
config_content: &str,
output_path: &str,
) -> Result<()>;
// Append a new config content to an existing config file for nginx. This is useful for some use cases where we want to keep the existing config and just add some new config content to it. The output_path is a relative path to the nginx config directory of the deployment folder, which should be the same as the one used in write_config function. Path traversal is not allowed.
async fn append_config(
&self,
deployment_id: &str,
config_content: &str,
output_path: &str,
) -> Result<()>;
// clean up old config files that are applied to nginx
// keep only latest n deployments.
async fn cleanup_config(&self, n: usize) -> Result<()>;
}
pub struct NginxHandlerImpl {
settings: Arc<NginxSettings>,
}
impl NginxHandlerImpl {
pub fn new(settings: Arc<NginxSettings>) -> Self {
Self { settings }
}
fn get_nginx_command(&self) -> String {
// TODO: rename the setting for better clarity, it can be a binary path or a custom command
self.settings
.nginx_binary_path
.clone()
.unwrap_or_else(|| "nginx".to_string())
}
fn validate_config_path(config_path: &str) -> Result<()> {
if !std::path::Path::new(config_path).exists() {
anyhow::bail!("Config file not found at path: {}", config_path);
}
if !std::path::Path::new(config_path).is_file() {
anyhow::bail!("Config path is not a file: {}", config_path);
}
Ok(())
}
fn apply_config_path_to_command_vecs<'a>(
command: &'a mut Vec<String>,
config_path: &str,
) -> Result<&'a mut Vec<String>> {
// if given a config path, add it to the end of the command arguments to override the default config path used
Self::validate_config_path(config_path)?;
let parent_dir = match std::path::Path::new(config_path).parent() {
Some(dir) => dir,
// return root
None => std::path::Path::new("/"),
};
// set prefix path to the parent directory of the config file to ensure nginx can find all related files (e.g. certs, conf.d, etc.)
command.push("-p".to_string());
command.push(parent_dir.to_string_lossy().to_string());
// add the config file path to the command arguments to override the default config path used by nginx
command.push("-c".to_string());
command.push(config_path.to_string());
Ok(command)
}
fn get_deployment_dir(&self) -> std::path::PathBuf {
std::path::Path::new(&self.settings.nginx_config_path).join("deployments")
}
fn get_deployment_dir_path(&self, deployment_id: &str) -> std::path::PathBuf {
self.get_deployment_dir().join(deployment_id)
}
async fn get_deployment_config_path(
&self,
deployment_id: &str,
output_path: &str,
create_dir_if_not_exists: bool,
) -> Result<std::path::PathBuf> {
let output_path_obj = std::path::Path::new(output_path);
if output_path_obj.is_absolute() {
anyhow::bail!("Output path must be a relative path");
}
if output_path_obj
.components()
.any(|comp| comp == std::path::Component::ParentDir)
{
anyhow::bail!("Output path must not contain parent directory traversal");
}
let deployment_config_dir = self.get_deployment_dir_path(deployment_id);
let full_path = deployment_config_dir.join(output_path);
if create_dir_if_not_exists {
if let Some(parent) = full_path.parent() {
tokio::fs::create_dir_all(parent).await?;
} else {
tokio::fs::create_dir_all(&deployment_config_dir).await?;
}
}
Ok(full_path)
}
}
#[async_trait::async_trait]
impl NginxHandler for NginxHandlerImpl {
async fn reload(&self, config_path: Option<&str>) -> Result<()> {
// TODO: add timeout for the command execution
let reload_command_str = self.settings.override_nginx_reload_command.clone();
let program = match reload_command_str.first() {
Some(cmd) => cmd,
None => &self.get_nginx_command(),
};
let mut reload_command_vec = reload_command_str[1..].to_vec();
// if given a config path, add it to the end of the command arguments to override the default config path used
if let Some(path) = config_path {
Self::apply_config_path_to_command_vecs(&mut reload_command_vec, path)?;
}
let output = Command::new(program)
.args(&reload_command_vec)
.output()
.await?;
if !output.status.success() {
let error_info = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to reload nginx: {}", error_info.trim());
}
let success_info = String::from_utf8_lossy(&output.stdout);
debug!("Nginx reloaded successfully: {}", success_info.trim());
Ok(())
}
async fn stop(&self) -> Result<()> {
let output = Command::new(self.get_nginx_command())
.arg("-s")
.arg("stop")
.output()
.await?;
if !output.status.success() {
let error_info = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to stop nginx: {}", error_info.trim());
}
let success_info = String::from_utf8_lossy(&output.stdout);
debug!("Nginx stopped successfully: {}", success_info.trim());
Ok(())
}
async fn validate(&self, config_path: Option<&str>) -> Result<()> {
// TODO: add timeout for the command execution
let validate_command_str = self.settings.override_nginx_test_command.clone();
let program = match validate_command_str.first() {
Some(cmd) => cmd,
None => &self.get_nginx_command(),
};
let mut validate_args = validate_command_str[1..].to_vec();
// if given a config path, add it to the end of the command arguments to override the default config path used
if let Some(path) = config_path {
Self::apply_config_path_to_command_vecs(&mut validate_args, path)?;
}
let output = Command::new(program).args(&validate_args).output().await?;
if !output.status.success() {
let error_info = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Nginx config validation failed: {}", error_info.trim());
}
let success_info = String::from_utf8_lossy(&output.stdout);
debug!("Nginx config validation succeeded: {}", success_info.trim());
Ok(())
}
async fn get_version(&self) -> Result<String> {
let output = Command::new(self.get_nginx_command())
.arg("-v")
.output()
.await?;
if !output.status.success() {
let error_info = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to get nginx version: {}", error_info.trim());
}
let version_info = String::from_utf8_lossy(&output.stderr);
Ok(version_info.trim().to_string())
}
async fn get_status(&self) -> Result<String> {
let output = Command::new(self.get_nginx_command())
.arg("-t")
.output()
.await?;
if !output.status.success() {
let error_info = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to get nginx status: {}", error_info.trim());
}
let status_info = String::from_utf8_lossy(&output.stderr);
Ok(status_info.trim().to_string())
}
async fn write_config(
&self,
deployment_id: &str,
config_content: &str,
output_path: &str,
) -> Result<()> {
let full_output_path = self
.get_deployment_config_path(deployment_id, output_path, true)
.await?;
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(full_output_path)
.await?;
// lock the file for writing to prevent concurrent write issue
file.lock_exclusive()?;
file.write_all(config_content.as_bytes()).await?;
file.unlock()?;
file.flush().await?;
Ok(())
}
async fn append_config(
&self,
deployment_id: &str,
config_content: &str,
output_path: &str,
) -> Result<()> {
let full_output_path = self
.get_deployment_config_path(deployment_id, output_path, true)
.await?;
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create(true)
.append(true)
.open(full_output_path)
.await?;
// lock the file for writing to prevent concurrent write issue
file.lock_exclusive()?;
file.write_all(config_content.as_bytes()).await?;
file.unlock()?;
file.flush().await?;
Ok(())
}
async fn cleanup_config(&self, n: usize) -> Result<()> {
let deployment_dir = self.get_deployment_dir();
// loop through all files in the deployment dir and delete them
let mut entries = tokio::fs::read_dir(&deployment_dir).await?;
let mut deployment_ids = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let file_type = entry.file_type().await?;
if file_type.is_dir()
&& let Some(deployment_id) = entry.file_name().to_str()
{
deployment_ids.push(deployment_id.to_string());
}
}
// sort the deployment ids by modified time in descending order and keep the latest n deployments, delete the rest
deployment_ids.sort_by_key(|id| {
let path = self.get_deployment_dir_path(id);
std::fs::metadata(path)
.and_then(|meta| meta.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
});
for deployment_id in deployment_ids.into_iter().skip(n) {
let path = self.get_deployment_dir_path(&deployment_id);
// ensure path is within the deplyment and nginx directory to prevent accidental deletion of other files
if !path.starts_with(&deployment_dir)
|| !path.starts_with(&self.settings.nginx_config_path)
{
warn!(
"Skipping deletion of path outside of deployment or nginx config directory: {:?}",
path
);
continue;
}
tokio::fs::remove_dir_all(path).await?;
}
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use anyhow::Result;
use std::sync::Arc;
use tempfile::TempDir;
#[tokio::test]
async fn write_and_append_config_roundtrip() -> Result<()> {
let temp = TempDir::new()?;
let settings = NginxSettings {
nginx_config_path: temp.path().to_string_lossy().to_string(),
nginx_binary_path: None,
override_nginx_reload_command: vec![],
override_nginx_test_command: vec![],
nginx_reload_timeout_seconds: 1,
nginx_test_timeout_seconds: 1,
};
let handler = NginxHandlerImpl::new(Arc::new(settings));
handler
.write_config("deployment1", "hello", "conf/nginx.conf")
.await?;
let full_path = temp
.path()
.join("deployments")
.join("deployment1")
.join("conf/nginx.conf");
let content = tokio::fs::read_to_string(&full_path).await?;
assert_eq!(content, "hello");
handler
.append_config("deployment1", " world", "conf/nginx.conf")
.await?;
let content = tokio::fs::read_to_string(&full_path).await?;
assert_eq!(content, "hello world");
Ok(())
}
#[tokio::test]
async fn write_config_rejects_absolute_and_traversal_paths() -> Result<()> {
let temp = TempDir::new()?;
let settings = NginxSettings {
nginx_config_path: temp.path().to_string_lossy().to_string(),
nginx_binary_path: None,
override_nginx_reload_command: vec![],
override_nginx_test_command: vec![],
nginx_reload_timeout_seconds: 1,
nginx_test_timeout_seconds: 1,
};
let handler = NginxHandlerImpl::new(Arc::new(settings));
let err = handler
.write_config("d", "x", "/absolute/path.conf")
.await
.err();
assert!(err.is_some());
let err = handler.write_config("d", "x", "../escape.conf").await.err();
assert!(err.is_some());
Ok(())
}
#[tokio::test]
async fn validate_config_path_checks_file_exists_and_is_file() {
// missing file
let res = NginxHandlerImpl::validate_config_path("/this/path/does/not/exist.conf");
assert!(res.is_err());
// create a temp dir and ensure a directory is rejected
let temp = TempDir::new().expect("Failed to create temp dir");
let dir_path = temp.path();
let res = NginxHandlerImpl::validate_config_path(dir_path.to_string_lossy().as_ref());
assert!(res.is_err());
}
#[tokio::test]
async fn apply_config_path_to_command_vecs_appends_prefix_and_config() -> Result<()> {
let temp = TempDir::new()?;
let cfg_file = temp.path().join("nginx.conf");
tokio::fs::write(&cfg_file, b"data").await?;
let mut args: Vec<String> = vec!["base".to_string()];
let result = NginxHandlerImpl::apply_config_path_to_command_vecs(
&mut args,
&cfg_file.to_string_lossy(),
);
assert!(result.is_ok());
let args = result.expect("Failed to apply config path to command vecs");
// expect -p <parent_dir> -c <config>
assert!(args.contains(&"-p".to_string()));
assert!(args.contains(&"-c".to_string()));
assert!(args.contains(&cfg_file.to_string_lossy().to_string()));
Ok(())
}
#[tokio::test]
async fn get_deployment_config_path_create_flag_behaviour() -> Result<()> {
let temp = TempDir::new()?;
let settings = NginxSettings {
nginx_config_path: temp.path().to_string_lossy().to_string(),
nginx_binary_path: None,
override_nginx_reload_command: vec![],
override_nginx_test_command: vec![],
nginx_reload_timeout_seconds: 1,
nginx_test_timeout_seconds: 1,
};
let handler = NginxHandlerImpl::new(Arc::new(settings));
// when create_dir_if_not_exists = false, directory shouldn't be created
let path = handler
.get_deployment_config_path("did", "conf/nginx.conf", false)
.await?;
assert!(
!path
.parent()
.expect("Failed to get parent directory of deployment config path")
.exists()
);
// when create_dir_if_not_exists = true, directory should be created
let path = handler
.get_deployment_config_path("did", "conf/nginx.conf", true)
.await?;
assert!(
path.parent()
.expect("Failed to get parent directory of deployment config path")
.exists()
);
Ok(())
}
#[tokio::test]
async fn cleanup_config_deletes_expected_deployments() -> Result<()> {
let temp = TempDir::new()?;
let settings = NginxSettings {
nginx_config_path: temp.path().to_string_lossy().to_string(),
nginx_binary_path: None,
override_nginx_reload_command: vec![],
override_nginx_test_command: vec![],
nginx_reload_timeout_seconds: 1,
nginx_test_timeout_seconds: 1,
};
let handler = NginxHandlerImpl::new(Arc::new(settings));
let base = temp.path().join("deployments");
// create three deployments sequentially so mtimes differ
for id in &["d1", "d2", "d3"] {
let p = base.join(id);
std::fs::create_dir_all(&p)?;
std::fs::write(p.join("file"), b"x")?;
std::thread::sleep(std::time::Duration::from_millis(500));
}
// call cleanup keeping 1; current implementation keeps the oldest n, so expect only d1 remains
handler.cleanup_config(1).await?;
let mut exists = vec![];
for id in &["d1", "d2", "d3"] {
exists.push((id.to_string(), base.join(id).exists()));
}
// d1 should remain, others removed (matches current implementation behavior)
assert!(exists.iter().find(|(id, e)| id == "d1" && *e).is_some());
assert!(exists.iter().find(|(id, e)| id == "d2" && !*e).is_some());
assert!(exists.iter().find(|(id, e)| id == "d3" && !*e).is_some());
Ok(())
}
}

View File

@@ -22,5 +22,3 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.react-router/

File diff suppressed because it is too large Load Diff

View File

@@ -4,24 +4,18 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "react-router build", "dev": "vite",
"dev": "react-router dev", "build": "tsc -b && vite build",
"start": "react-router-serve ./build/server/index.js", "lint": "eslint .",
"typecheck": "react-router typegen && tsc" "preview": "vite preview",
"test": "echo \"No test specified\" && exit 0"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.2.2", "react": "^19.2.0",
"@react-router/node": "7.15.0", "react-dom": "^19.2.0"
"@react-router/serve": "7.15.0",
"antd": "^6.3.7",
"isbot": "^5.1.36",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router": "7.15.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@react-router/dev": "7.15.0",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -32,6 +26,6 @@
"globals": "^16.5.0", "globals": "^16.5.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^8.0.3" "vite": "^7.3.1"
} }
} }

View File

@@ -1,8 +0,0 @@
import type { Config } from '@react-router/dev/config';
export default {
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: false,
appDirectory: 'src',
} satisfies Config;

View File

@@ -1,156 +1,42 @@
/* Light Professional Theme - nxMesh Dashboard */
* {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background-color: #f5f5f5;
margin: 0;
padding: 0;
}
#root { #root {
width: 100%; max-width: 1280px;
height: 100vh; margin: 0 auto;
margin: 0; padding: 2rem;
padding: 0; text-align: center;
} }
/* Header Styles */ .logo {
.ant-layout-header { height: 6em;
background: #ffffff !important; padding: 1.5em;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
} }
/* Sidebar Styles */ @keyframes logo-spin {
.ant-layout-sider { from {
background: #fafafa !important; transform: rotate(0deg);
}
.ant-menu {
background: #fafafa !important;
}
.ant-menu-item,
.ant-menu-submenu-title {
color: #333 !important;
transition: all 0.3s ease;
}
.ant-menu-item-selected {
background-color: #e6f7ff !important;
color: #1890ff !important;
}
.ant-menu-item:hover,
.ant-menu-submenu-title:hover {
color: #1890ff !important;
}
/* Content Area */
.ant-layout-content {
background: #f5f5f5;
}
/* Card Styles */
.ant-card {
border-color: #f0f0f0;
border-radius: 6px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
transition: all 0.3s ease;
}
.ant-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.ant-card-head {
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
}
/* Statistic Styles */
.ant-statistic-title {
color: #666;
font-size: 14px;
margin-bottom: 8px;
}
.ant-statistic-content {
color: #1890ff;
font-weight: 600;
font-size: 28px;
}
/* Button Styles */
.ant-btn-primary {
background-color: #1890ff;
border-color: #1890ff;
transition: all 0.3s ease;
}
.ant-btn-primary:hover {
background-color: #0050b3;
border-color: #0050b3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
/* Input Styles */
.ant-input {
border-radius: 4px;
border-color: #d9d9d9;
}
.ant-input:hover {
border-color: #1890ff;
}
.ant-input-focused {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
}
/* Badge Styles */
.ant-badge-success {
color: #52c41a;
}
.ant-badge-processing {
color: #1890ff;
}
/* Typography */
h1,
h2,
h3,
h4,
h5,
h6 {
color: #333;
font-weight: 600;
}
p {
color: #666;
line-height: 1.6;
}
/* Responsive Design */
@media (max-width: 768px) {
.ant-layout-sider {
width: 100% !important;
max-width: 100% !important;
} }
to {
.ant-layout-content { transform: rotate(360deg);
padding: 16px !important;
}
.ant-card {
margin-bottom: 12px;
} }
} }
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -0,0 +1,35 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,36 +0,0 @@
import type { PropsWithChildren } from 'react';
import { Layout, ConfigProvider, theme } from 'antd';
import { LayoutHeader } from './layout/Header';
import { LayoutSidebar } from './layout/Sidebar';
import { LayoutFooter } from './layout/Footer';
const { Content } = Layout;
export const RootLayout = ({ children }: PropsWithChildren) => {
return (
<ConfigProvider
theme={{
token: {
colorPrimary: '#1890ff',
borderRadius: 6,
},
algorithm: theme.defaultAlgorithm,
}}
>
<Layout style={{ minHeight: '100vh' }}>
{/* Header */}
<LayoutHeader />
<Layout>
{/* Sidebar */}
<LayoutSidebar />
{/* Main Content */}
<Content style={{ padding: '24px' }}>{children}</Content>
</Layout>
<LayoutFooter />
</Layout>
</ConfigProvider>
);
};

View File

@@ -1,17 +0,0 @@
import { Layout } from 'antd';
const { Footer } = Layout;
export const LayoutFooter = () => {
return (
<Footer
style={{
textAlign: 'center',
color: '#666',
borderTop: '1px solid #f0f0f0',
background: '#fafafa',
}}
>
nxMesh © 2026 All Rights Reserved
</Footer>
);
};

View File

@@ -1,47 +0,0 @@
import { Space, Avatar, Input, Button, Badge, Dropdown, Layout, type DropdownProps } from 'antd';
import { UserOutlined, SearchOutlined, BellOutlined, LogoutOutlined } from '@ant-design/icons';
const { Header } = Layout;
const userMenuItems: Exclude<DropdownProps['menu'], undefined>['items'] = [
{ key: 'profile', label: 'Profile' },
{ key: 'settings', label: 'Settings' },
{ type: 'divider' },
{ key: 'logout', label: 'Logout', icon: <LogoutOutlined /> },
];
export const LayoutHeader = () => {
return (
<Header
style={{
background: '#fff',
padding: '0 24px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
position: 'sticky',
top: 0,
zIndex: 999,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<h1 style={{ margin: 0, fontSize: '20px', fontWeight: 'bold', color: '#1890ff' }}>nxMesh</h1>
</div>
<Input placeholder="Search agents, workspaces..." prefix={<SearchOutlined />} style={{ width: '300px' }} />
<Space size="large">
<Badge count={3} style={{ backgroundColor: '#ff4d4f' }}>
<Button type="text" icon={<BellOutlined style={{ fontSize: '18px' }} />} />
</Badge>
<Dropdown menu={{ items: userMenuItems }} trigger={['click']}>
<Space style={{ cursor: 'pointer' }}>
<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} />
{/* TODO: from store */}
<span>Admin User</span>
</Space>
</Dropdown>
</Space>
</Header>
);
};

View File

@@ -1,57 +0,0 @@
import { Button, Menu, Layout } from 'antd';
import { useState } from 'react';
import { DashboardOutlined, AppstoreOutlined, TeamOutlined, SettingOutlined } from '@ant-design/icons';
const { Sider } = Layout;
const menuItems = [
{ key: '1', icon: <DashboardOutlined />, label: 'Dashboard' },
{ key: '2', icon: <AppstoreOutlined />, label: 'Agents' },
{ key: '3', icon: <AppstoreOutlined />, label: 'Workspaces' },
{ key: '4', icon: <TeamOutlined />, label: 'Organizations' },
{
key: '5',
icon: <SettingOutlined />,
label: 'Infrastructure',
children: [
{ key: '5-1', label: 'Virtual Hosts' },
{ key: '5-2', label: 'Upstreams' },
{ key: '5-3', label: 'Certificates' },
],
},
{ key: '6', icon: <TeamOutlined />, label: 'Users & Access' },
{ key: '7', icon: <SettingOutlined />, label: 'Settings' },
];
export const LayoutSidebar = () => {
const [collapsed, setCollapsed] = useState(false);
const [selectedKey, setSelectedKey] = useState('1');
return (
<Sider
width={250}
trigger={null}
collapsible
collapsed={collapsed}
style={{
background: '#fafafa',
borderRight: '1px solid #f0f0f0',
}}
>
<Button
type="text"
onClick={() => setCollapsed(!collapsed)}
style={{
width: '100%',
height: '64px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{collapsed ? '☰' : '☰'}
</Button>
<Menu mode="inline" selectedKeys={[selectedKey]} onSelect={(e) => setSelectedKey(e.key)} items={menuItems} style={{ border: 'none' }} />
</Sider>
);
};

View File

@@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -1,56 +0,0 @@
import './App.css';
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
import type { Route } from './+types/root';
import { RootLayout } from './components/Layout.tsx';
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return (
<RootLayout>
<Outlet />
</RootLayout>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = 'Oops!';
let details = 'An unexpected error occurred.';
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? '404' : 'Error';
details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}

View File

@@ -1,3 +0,0 @@
import { type RouteConfig, index } from "@react-router/dev/routes";
export default [index("routes/home.tsx")] satisfies RouteConfig;

View File

@@ -1,70 +0,0 @@
import type { Route } from './+types/home';
import { Row, Col, Card, Statistic, Space, Badge } from 'antd';
export function meta({}: Route.MetaArgs) {
return [{ title: 'New React Router App' }, { name: 'description', content: 'Welcome to React Router!' }];
}
export default function Home() {
return (
<div style={{ background: '#fff', padding: '24px', borderRadius: '6px', minHeight: '400px' }}>
<h2 style={{ marginBottom: '24px', fontSize: '24px', fontWeight: '600' }}>Dashboard</h2>
{/* Key Metrics */}
<Row gutter={[16, 16]} style={{ marginBottom: '32px' }}>
<Col xs={24} sm={12} lg={6}>
<Card hoverable>
<Statistic title="Active Agents" value={12} prefix="📡" />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card hoverable>
<Statistic title="Workspaces" value={5} prefix="📁" />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card hoverable>
<Statistic title="Organizations" value={3} prefix="🏢" />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card hoverable>
<Statistic title="System Health" value={98} suffix="%" valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
</Row>
{/* Content Sections */}
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card title="Recent Activities" hoverable>
<Space direction="vertical" style={{ width: '100%' }}>
<p> Agent-01 connected successfully</p>
<p> Workspace "Production" updated</p>
<p> Certificate renewed for api.example.com</p>
<p> New user invited to organization</p>
</Space>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="System Status" hoverable>
<Space direction="vertical" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>API Server</span>
<Badge status="success" text="Online" />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Database</span>
<Badge status="success" text="Online" />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Message Queue</span>
<Badge status="processing" text="Operational" />
</div>
</Space>
</Card>
</Col>
</Row>
</div>
);
}

View File

@@ -1,13 +1,7 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite'
import { reactRouter } from '@react-router/dev/vite'; import react from '@vitejs/plugin-react-swc'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [reactRouter()], plugins: [react()],
ssr: { })
noExternal: ['@ant-design/icons', '@rc-component/util'],
},
resolve: {
tsconfigPaths: true,
},
});