feat(vnt-web): 支持同时启动多个组网配置
- 后端实例状态从全局单槽改为按配置文件名隔离的 HashMap,每个实例持有 独立的状态机、启动日志与任务组管理器 - 启动/停止/重启/信息/对端/路由/启动状态接口均按 file_name 维度隔离, 新增 GET /api/instances 实例列表与 DELETE /api/instance 移除已停止实例 - 启动前冲突检测:device_id 相同(同服务器同组网范围内)或 tunnel_port 显式相同则拒绝启动 - vnt_current_config.txt 改为每行一个配置名,重启后自动恢复所有运行中实例 - 前端重做为多实例模型:实例卡片列表、按实例隔离的启动日志弹窗、 peers/routes 顶部实例切换器、配置列表运行状态标记、已停止实例可移除
This commit is contained in:
+2
-1
@@ -3,4 +3,5 @@
|
||||
./wintun.dll
|
||||
vnt_config
|
||||
vnt_current_config.txt
|
||||
logs/*
|
||||
logs/*
|
||||
/.tools/
|
||||
+576
-113
@@ -8,7 +8,7 @@ use axum::{
|
||||
extract::{Query, Request, State},
|
||||
middleware,
|
||||
response::Response,
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use ipnet::Ipv4Net;
|
||||
use mime_guess::from_path;
|
||||
@@ -47,84 +47,156 @@ enum VntStatus {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HttpAppState {
|
||||
task_group_manager: TaskGroupManager,
|
||||
inner: Arc<Mutex<HttpAppStateInner>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct HttpAppStateInner {
|
||||
/// 组网实例表,key = 配置文件名,同一配置最多一个实例
|
||||
instances: HashMap<String, InstanceState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct InstanceState {
|
||||
vnt: Option<VntHandler>,
|
||||
status: VntStatus,
|
||||
start_logs: Vec<String>,
|
||||
/// 启动任务句柄,用于在 Starting 状态中断注册重试循环
|
||||
start_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
/// 每个实例持有自己的任务组管理器(TaskGroupManager 是单槽的,不能共享)
|
||||
task_group_manager: TaskGroupManager,
|
||||
/// 启动时解析出的配置快照,用于多实例启动前冲突检测
|
||||
start_config: Option<StartConfig>,
|
||||
/// 展示名;Starting 阶段还没有 vnt,用配置里的 config_name 或 file_name 兜底
|
||||
config_name: String,
|
||||
}
|
||||
|
||||
impl HttpAppState {
|
||||
fn starting(&self) -> anyhow::Result<()> {
|
||||
fn starting(&self, file_name: &str) -> anyhow::Result<()> {
|
||||
let mut inner = self.inner.lock();
|
||||
if inner.status != VntStatus::Stopped {
|
||||
return Err(anyhow!("VNT is already starting or running"));
|
||||
let inst = inner.instances.entry(file_name.to_string()).or_default();
|
||||
if inst.status != VntStatus::Stopped {
|
||||
return Err(anyhow!("配置 {} 正在启动或已运行", file_name));
|
||||
}
|
||||
if inner.vnt.is_some() {
|
||||
return Err(anyhow!("VNT is already running"));
|
||||
if inst.vnt.is_some() {
|
||||
return Err(anyhow!("配置 {} 已在运行", file_name));
|
||||
}
|
||||
inner.status = VntStatus::Starting;
|
||||
inner.start_logs.clear();
|
||||
inst.status = VntStatus::Starting;
|
||||
inst.start_logs.clear();
|
||||
inst.start_config = None;
|
||||
inst.config_name = file_name.to_string();
|
||||
Ok(())
|
||||
}
|
||||
fn stopped(&self) {
|
||||
fn stopped(&self, file_name: &str) {
|
||||
let mut inner = self.inner.lock();
|
||||
inner.vnt.take();
|
||||
inner.status = VntStatus::Stopped;
|
||||
let Some(inst) = inner.instances.get_mut(file_name) else {
|
||||
return;
|
||||
};
|
||||
inst.vnt.take();
|
||||
inst.status = VntStatus::Stopped;
|
||||
inst.start_config = None;
|
||||
// 已完成任务的句柄只是残留,不算运行内容
|
||||
if inst.start_handle.as_ref().is_some_and(|h| h.is_finished()) {
|
||||
inst.start_handle.take();
|
||||
}
|
||||
// 实例已无任何运行内容时移除条目,避免实例表堆积已停止的配置。
|
||||
// 注意 Starting 失败路径走 record_log_and_stopped/starting_to_stopped 保留日志,
|
||||
// 不经过这里,不会被误删。
|
||||
let removable = inst.start_handle.is_none() && inst.task_group_manager.is_stopped();
|
||||
if removable {
|
||||
inner.instances.remove(file_name);
|
||||
}
|
||||
}
|
||||
fn starting_to_stopped(&self) {
|
||||
fn starting_to_stopped(&self, file_name: &str) {
|
||||
let mut inner = self.inner.lock();
|
||||
if inner.status != VntStatus::Starting {
|
||||
let Some(inst) = inner.instances.get_mut(file_name) else {
|
||||
return;
|
||||
};
|
||||
if inst.status != VntStatus::Starting {
|
||||
return;
|
||||
}
|
||||
inner.vnt.take();
|
||||
inner.status = VntStatus::Stopped;
|
||||
inner
|
||||
inst.vnt.take();
|
||||
inst.status = VntStatus::Stopped;
|
||||
inst
|
||||
.start_logs
|
||||
.push(format!("[{}] 启动中断", HttpAppState::timestamp()));
|
||||
}
|
||||
fn starting_to_running(&self) {
|
||||
fn starting_to_running(&self, file_name: &str) {
|
||||
let mut inner = self.inner.lock();
|
||||
if inner.status != VntStatus::Starting {
|
||||
let Some(inst) = inner.instances.get_mut(file_name) else {
|
||||
return;
|
||||
};
|
||||
if inst.status != VntStatus::Starting {
|
||||
log::error!("starting_to_running VNT is not starting");
|
||||
return;
|
||||
}
|
||||
inner.status = VntStatus::Running;
|
||||
inner.start_logs.clear();
|
||||
inst.status = VntStatus::Running;
|
||||
inst.start_logs.clear();
|
||||
}
|
||||
|
||||
fn record_log(&self, msg: impl Into<String>) {
|
||||
fn record_log(&self, file_name: &str, msg: impl Into<String>) {
|
||||
let mut inner = self.inner.lock();
|
||||
if inner.status != VntStatus::Starting {
|
||||
let Some(inst) = inner.instances.get_mut(file_name) else {
|
||||
return;
|
||||
};
|
||||
if inst.status != VntStatus::Starting {
|
||||
return;
|
||||
}
|
||||
inner
|
||||
.start_logs
|
||||
inst.start_logs
|
||||
.push(format!("[{}] {}", Self::timestamp(), msg.into()));
|
||||
}
|
||||
fn record_log_and_stopped(&self, msg: impl Into<String>) {
|
||||
fn record_log_and_stopped(&self, file_name: &str, msg: impl Into<String>) {
|
||||
let mut inner = self.inner.lock();
|
||||
if inner.status != VntStatus::Starting {
|
||||
let Some(inst) = inner.instances.get_mut(file_name) else {
|
||||
return;
|
||||
};
|
||||
if inst.status != VntStatus::Starting {
|
||||
return;
|
||||
}
|
||||
inner
|
||||
.start_logs
|
||||
inst.start_logs
|
||||
.push(format!("[{}] {}", Self::timestamp(), msg.into()));
|
||||
inner.status = VntStatus::Stopped;
|
||||
inst.status = VntStatus::Stopped;
|
||||
}
|
||||
fn status(&self) -> VntStatus {
|
||||
self.inner.lock().status
|
||||
fn status(&self, file_name: &str) -> VntStatus {
|
||||
self.inner
|
||||
.lock()
|
||||
.instances
|
||||
.get(file_name)
|
||||
.map(|inst| inst.status)
|
||||
.unwrap_or(VntStatus::Stopped)
|
||||
}
|
||||
|
||||
fn task_group_manager(&self, file_name: &str) -> Option<TaskGroupManager> {
|
||||
self.inner
|
||||
.lock()
|
||||
.instances
|
||||
.get(file_name)
|
||||
.map(|inst| inst.task_group_manager.clone())
|
||||
}
|
||||
|
||||
/// 启动解析出配置后写入展示名和配置快照(供实例列表与冲突检测使用)
|
||||
fn set_starting_config(&self, file_name: &str, config_name: String, cfg: StartConfig) {
|
||||
if let Some(inst) = self.inner.lock().instances.get_mut(file_name) {
|
||||
inst.config_name = config_name;
|
||||
inst.start_config = Some(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_start_handle(&self, file_name: &str, handle: tokio::task::JoinHandle<()>) {
|
||||
if let Some(inst) = self.inner.lock().instances.get_mut(file_name) {
|
||||
inst.start_handle = Some(handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// 中断启动任务(如注册重试循环)。任务已完成时为空操作。
|
||||
fn abort_start_task(&self) {
|
||||
if let Some(handle) = self.inner.lock().start_handle.take() {
|
||||
fn abort_start_task(&self, file_name: &str) {
|
||||
let handle = self
|
||||
.inner
|
||||
.lock()
|
||||
.instances
|
||||
.get_mut(file_name)
|
||||
.and_then(|inst| inst.start_handle.take());
|
||||
if let Some(handle) = handle {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
@@ -141,6 +213,8 @@ struct VntHandler {
|
||||
api: VntApi,
|
||||
config_name: String,
|
||||
config_file_name: String,
|
||||
/// 启动时的配置快照,用于多实例冲突检测
|
||||
start_config: StartConfig,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -313,14 +387,61 @@ struct StartStatusResponse {
|
||||
logs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct InstanceSummary {
|
||||
file_name: String,
|
||||
config_name: String,
|
||||
status: VntStatus,
|
||||
}
|
||||
|
||||
async fn get_start_status(
|
||||
State(state): State<HttpAppState>,
|
||||
Query(req): Query<FileReq>,
|
||||
) -> Json<ApiResponse<StartStatusResponse>> {
|
||||
let lock = state.inner.lock();
|
||||
Json(ApiResponse::success(StartStatusResponse {
|
||||
status: lock.status,
|
||||
logs: lock.start_logs.clone(),
|
||||
}))
|
||||
// 实例不存在(从未启动或已停止并清理)时返回 Stopped + 空日志,
|
||||
// 前端轮询已停止实例时自然终止
|
||||
let resp = match lock.instances.get(&req.file_name) {
|
||||
Some(inst) => StartStatusResponse {
|
||||
status: inst.status,
|
||||
logs: inst.start_logs.clone(),
|
||||
},
|
||||
None => StartStatusResponse {
|
||||
status: VntStatus::Stopped,
|
||||
logs: Vec::new(),
|
||||
},
|
||||
};
|
||||
Json(ApiResponse::success(resp))
|
||||
}
|
||||
|
||||
async fn get_instances(
|
||||
State(state): State<HttpAppState>,
|
||||
) -> Json<ApiResponse<Vec<InstanceSummary>>> {
|
||||
let lock = state.inner.lock();
|
||||
let mut list: Vec<InstanceSummary> = lock
|
||||
.instances
|
||||
.iter()
|
||||
.map(|(file_name, inst)| {
|
||||
let config_name = inst
|
||||
.vnt
|
||||
.as_ref()
|
||||
.map(|v| v.config_name.clone())
|
||||
.unwrap_or_else(|| {
|
||||
if inst.config_name.is_empty() {
|
||||
file_name.clone()
|
||||
} else {
|
||||
inst.config_name.clone()
|
||||
}
|
||||
});
|
||||
InstanceSummary {
|
||||
file_name: file_name.clone(),
|
||||
config_name,
|
||||
status: inst.status,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
list.sort_by(|a, b| a.file_name.cmp(&b.file_name));
|
||||
Json(ApiResponse::success(list))
|
||||
}
|
||||
|
||||
async fn logging_middleware(req: Request, next: axum::middleware::Next) -> Response {
|
||||
@@ -351,14 +472,13 @@ pub async fn run_http_server(
|
||||
.context("Failed to create config directory")?;
|
||||
|
||||
let state = HttpAppState {
|
||||
task_group_manager: TaskGroupManager::new(),
|
||||
inner: Arc::new(Default::default()),
|
||||
};
|
||||
|
||||
// 自动启动逻辑
|
||||
let auto_start_file = determine_auto_start_file(start_config_file_name).await;
|
||||
let auto_start_files = determine_auto_start_files(start_config_file_name).await;
|
||||
|
||||
if let Some((file_name, path)) = auto_start_file {
|
||||
for (file_name, path) in auto_start_files {
|
||||
log::info!("Auto starting VNT with config: {:?}", path);
|
||||
let state_clone = state.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -378,6 +498,8 @@ pub async fn run_http_server(
|
||||
.route("/api/peers", get(get_peers))
|
||||
.route("/api/routes", get(get_routes))
|
||||
.route("/api/start/status", get(get_start_status))
|
||||
.route("/api/instances", get(get_instances))
|
||||
.route("/api/instance", delete(dismiss_instance_handler))
|
||||
.route("/api/start", post(start_vnt_handler))
|
||||
.route("/api/stop", post(stop_vnt_handler))
|
||||
.route("/api/restart", post(restart_vnt_handler))
|
||||
@@ -400,31 +522,83 @@ pub async fn run_http_server(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 确定自动启动的配置文件
|
||||
async fn determine_auto_start_file(
|
||||
/// 确定自动启动的配置文件列表。
|
||||
/// --conf 显式指定时只返回那一个;否则读自启记录文件(每行一个 file_name),过滤存在的文件。
|
||||
async fn determine_auto_start_files(
|
||||
start_config_file_name: Option<PathBuf>,
|
||||
) -> Option<(String, PathBuf)> {
|
||||
let path = if let Some(name) = start_config_file_name {
|
||||
Some(name)
|
||||
) -> Vec<(String, PathBuf)> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let paths: Vec<PathBuf> = if let Some(name) = start_config_file_name {
|
||||
vec![name]
|
||||
} else if Path::new(CURRENT_CONFIG_RECORD).exists() {
|
||||
fs::read_to_string(CURRENT_CONFIG_RECORD)
|
||||
.await
|
||||
.ok()
|
||||
.filter(|content| !content.trim().is_empty())
|
||||
.map(|content| Path::new(CONFIG_DIR).join(content.trim()))
|
||||
match fs::read_to_string(CURRENT_CONFIG_RECORD).await {
|
||||
Ok(content) => content
|
||||
.lines()
|
||||
.map(|line| line.trim())
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|line| Path::new(CONFIG_DIR).join(line))
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read auto start record: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
path.and_then(|p| {
|
||||
let file_name = p.file_name()?.to_str()?.to_string();
|
||||
for p in paths {
|
||||
let Some(file_name) = p.file_name().and_then(|s| s.to_str()).map(|s| s.to_string()) else {
|
||||
continue;
|
||||
};
|
||||
if result.iter().any(|(name, _)| *name == file_name) {
|
||||
continue;
|
||||
}
|
||||
if p.exists() {
|
||||
Some((file_name, p))
|
||||
result.push((file_name, p));
|
||||
} else {
|
||||
log::warn!("Auto start config file not found: {:?}", p);
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 读取自启记录文件(每行一个 file_name,去空白、去重)
|
||||
async fn read_running_records() -> Vec<String> {
|
||||
let Ok(content) = fs::read_to_string(CURRENT_CONFIG_RECORD).await else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for line in content.lines() {
|
||||
let name = line.trim();
|
||||
if !name.is_empty() && !names.iter().any(|n| n == name) {
|
||||
names.push(name.to_string());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
async fn write_running_records(names: &[String]) {
|
||||
if let Err(e) = fs::write(CURRENT_CONFIG_RECORD, names.join("\n")).await {
|
||||
log::warn!("Failed to record running configs: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动成功后把 file_name 加入自启记录
|
||||
async fn record_add_running(file_name: &str) {
|
||||
let mut names = read_running_records().await;
|
||||
if !names.iter().any(|n| n == file_name) {
|
||||
names.push(file_name.to_string());
|
||||
}
|
||||
write_running_records(&names).await;
|
||||
}
|
||||
|
||||
/// 实例停止后把 file_name 从自启记录移除
|
||||
async fn record_remove_running(file_name: &str) {
|
||||
let mut names = read_running_records().await;
|
||||
names.retain(|n| n != file_name);
|
||||
write_running_records(&names).await;
|
||||
}
|
||||
|
||||
fn build_headers_for_path(path: &str) -> HeaderMap {
|
||||
@@ -493,6 +667,40 @@ async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||
(StatusCode::NOT_FOUND, "404 Not Found").into_response()
|
||||
}
|
||||
|
||||
/// 启动前冲突检测:新配置与所有 Starting/Running 实例的配置比对。
|
||||
/// 纯函数,便于单元测试。
|
||||
fn check_config_conflict(new: &StartConfig, running: &[&StartConfig]) -> Result<(), String> {
|
||||
for cfg in running {
|
||||
// device_id 的唯一性只在"同一服务器 + 同一组网编号"范围内成立:
|
||||
// 不同服务器或不同 network_code 的实例互不影响
|
||||
let same_network = new.network_code == cfg.network_code;
|
||||
let server_overlap = (new.server.is_empty() && cfg.server.is_empty())
|
||||
|| new.server.iter().any(|s| cfg.server.contains(s));
|
||||
// 两者都为 None 也算冲突:缺省 device_id 使用同一 machine_uid
|
||||
if same_network && server_overlap && new.device_id == cfg.device_id {
|
||||
return Err(match &new.device_id {
|
||||
Some(id) => format!(
|
||||
"启动冲突:device_id \"{}\" 已被同服务器同组网的运行中实例使用",
|
||||
id
|
||||
),
|
||||
None => {
|
||||
"启动冲突:与同服务器同组网的实例都未指定 device_id,缺省会使用相同的本机标识"
|
||||
.to_string()
|
||||
}
|
||||
});
|
||||
}
|
||||
if let (Some(a), Some(b)) = (new.tunnel_port, cfg.tunnel_port)
|
||||
&& a == b
|
||||
{
|
||||
return Err(format!(
|
||||
"启动冲突:tunnel_port {} 已被其他运行中的实例使用",
|
||||
a
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 启动 VNT 服务的入口函数
|
||||
async fn start_vnt_internal(
|
||||
state: &HttpAppState,
|
||||
@@ -500,42 +708,72 @@ async fn start_vnt_internal(
|
||||
file_path: PathBuf,
|
||||
) -> anyhow::Result<()> {
|
||||
log::info!("Starting VNT service: {}", file_name);
|
||||
state.starting()?;
|
||||
state.starting(&file_name)?;
|
||||
|
||||
let state_for_error = state.clone();
|
||||
let file_name_for_error = file_name.clone();
|
||||
let on_error_guard = defer(move || {
|
||||
state_for_error.starting_to_stopped();
|
||||
state_for_error.starting_to_stopped(&file_name_for_error);
|
||||
});
|
||||
|
||||
state.record_log(format!("启动配置: {}", file_name));
|
||||
state.record_log("读取配置文件");
|
||||
state.record_log(&file_name, format!("启动配置: {}", file_name));
|
||||
state.record_log(&file_name, "读取配置文件");
|
||||
|
||||
// 读取并解析配置
|
||||
let content = fs::read_to_string(&file_path)
|
||||
.await
|
||||
.with_context(|| format!("Config file not found: {:?}", file_path))?;
|
||||
|
||||
state.record_log("解析配置文件内容");
|
||||
state.record_log(&file_name, "解析配置文件内容");
|
||||
let cfg: StartConfig = toml::from_str(&content).context("Failed to parse TOML config")?;
|
||||
|
||||
let config_display_name = cfg.config_name.clone().unwrap_or_else(|| file_name.clone());
|
||||
|
||||
// 启动前冲突检测:与所有 Starting/Running 实例的配置比对
|
||||
{
|
||||
let inner = state.inner.lock();
|
||||
let running: Vec<&StartConfig> = inner
|
||||
.instances
|
||||
.iter()
|
||||
.filter(|(name, inst)| {
|
||||
name.as_str() != file_name && inst.status != VntStatus::Stopped
|
||||
})
|
||||
.filter_map(|(_, inst)| {
|
||||
inst.vnt
|
||||
.as_ref()
|
||||
.map(|v| &v.start_config)
|
||||
.or(inst.start_config.as_ref())
|
||||
})
|
||||
.collect();
|
||||
if let Err(msg) = check_config_conflict(&cfg, &running) {
|
||||
bail!(msg);
|
||||
}
|
||||
}
|
||||
|
||||
state.set_starting_config(&file_name, config_display_name.clone(), cfg.clone());
|
||||
|
||||
let start_config = cfg.clone();
|
||||
let core_config = convert_config(cfg)?;
|
||||
let sub_input = core_config.input.clone();
|
||||
|
||||
state.record_log("创建异步任务组");
|
||||
let (task_group, task_group_guard) = state
|
||||
.task_group_manager
|
||||
state.record_log(&file_name, "创建异步任务组");
|
||||
let task_group_manager = state
|
||||
.task_group_manager(&file_name)
|
||||
.context("Instance not found")?;
|
||||
let (task_group, task_group_guard) = task_group_manager
|
||||
.create_task()
|
||||
.context("Create task failed")?;
|
||||
|
||||
state.record_log("创建组网管理器");
|
||||
state.record_log(&file_name, "创建组网管理器");
|
||||
|
||||
let state_clone = state.clone();
|
||||
let file_name_clone = file_name.clone();
|
||||
let start_handle = tokio::spawn(async move {
|
||||
let result = start_vnt_network(
|
||||
state_clone.clone(),
|
||||
file_name,
|
||||
file_name_clone.clone(),
|
||||
config_display_name,
|
||||
start_config,
|
||||
core_config,
|
||||
sub_input,
|
||||
task_group,
|
||||
@@ -545,11 +783,11 @@ async fn start_vnt_internal(
|
||||
|
||||
if let Err(e) = result {
|
||||
log::error!("Failed to start VNT network: {:?}", e);
|
||||
state_clone.record_log_and_stopped(format!("启动失败: {}", e));
|
||||
state_clone.record_log_and_stopped(&file_name_clone, format!("启动失败: {}", e));
|
||||
}
|
||||
drop(on_error_guard);
|
||||
});
|
||||
state.inner.lock().start_handle = Some(start_handle);
|
||||
state.set_start_handle(&file_name, start_handle);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -559,6 +797,7 @@ async fn start_vnt_network(
|
||||
state: HttpAppState,
|
||||
file_name: String,
|
||||
config_display_name: String,
|
||||
start_config: StartConfig,
|
||||
core_config: CoreConfig,
|
||||
sub_input: Vec<NetInput>,
|
||||
task_group: vnt_core::utils::task_control::TaskGroup,
|
||||
@@ -573,22 +812,27 @@ async fn start_vnt_network(
|
||||
|
||||
{
|
||||
let mut lock = state.inner.lock();
|
||||
if lock.vnt.is_some() {
|
||||
let Some(inst) = lock.instances.get_mut(&file_name) else {
|
||||
return Err(anyhow!("Instance not found: {}", file_name));
|
||||
};
|
||||
if inst.vnt.is_some() {
|
||||
return Err(anyhow!("VNT is already running"));
|
||||
}
|
||||
lock.vnt = Some(VntHandler {
|
||||
inst.vnt = Some(VntHandler {
|
||||
api: vnt_api,
|
||||
config_name: config_display_name,
|
||||
config_file_name: file_name.clone(),
|
||||
start_config,
|
||||
});
|
||||
}
|
||||
|
||||
let state_for_vnt_cleanup = state.clone();
|
||||
let file_name_for_cleanup = file_name.clone();
|
||||
let vnt_cleanup_guard = defer(move || {
|
||||
state_for_vnt_cleanup.stopped();
|
||||
state_for_vnt_cleanup.stopped(&file_name_for_cleanup);
|
||||
});
|
||||
|
||||
state.record_log("连接服务器,执行注册");
|
||||
state.record_log(&file_name, "连接服务器,执行注册");
|
||||
log::info!("Registering with server");
|
||||
|
||||
let reg_msg = loop {
|
||||
@@ -596,7 +840,7 @@ async fn start_vnt_network(
|
||||
Ok(rs) => rs,
|
||||
Err(e) => {
|
||||
log::error!("Register failed: {:?}", e);
|
||||
state.record_log(format!("注册失败:{},5秒后重试", e));
|
||||
state.record_log(&file_name, format!("注册失败:{},5秒后重试", e));
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
@@ -611,24 +855,27 @@ async fn start_vnt_network(
|
||||
}
|
||||
}
|
||||
};
|
||||
state.record_log(format!("注册成功 {}/{}", reg_msg.ip, reg_msg.prefix_len));
|
||||
state.record_log(
|
||||
&file_name,
|
||||
format!("注册成功 {}/{}", reg_msg.ip, reg_msg.prefix_len),
|
||||
);
|
||||
log::info!("Network Started: {}/{}", reg_msg.ip, reg_msg.prefix_len);
|
||||
if !network_manager.is_no_tun() {
|
||||
state.record_log("正在创建 TUN 虚拟网卡");
|
||||
state.record_log(&file_name, "正在创建 TUN 虚拟网卡");
|
||||
network_manager.start_tun().await?;
|
||||
|
||||
state.record_log("创建 TUN 虚拟网卡成功,设置 IP");
|
||||
state.record_log(&file_name, "创建 TUN 虚拟网卡成功,设置 IP");
|
||||
network_manager
|
||||
.set_tun_network_ip(reg_msg.ip, reg_msg.prefix_len)
|
||||
.await?;
|
||||
state.record_log("设置 IP 成功");
|
||||
state.record_log(&file_name, "设置 IP 成功");
|
||||
|
||||
// 配置子网路由
|
||||
if !sub_input.is_empty()
|
||||
&& let Ok(if_index) = network_manager.tun_if_index().await
|
||||
&& let Ok(mut route_manager) = route_manager::RouteManager::new()
|
||||
{
|
||||
state.record_log("配置子网路由");
|
||||
state.record_log(&file_name, "配置子网路由");
|
||||
for input in &sub_input {
|
||||
let route =
|
||||
route_manager::Route::new(input.net.network().into(), input.net.prefix_len())
|
||||
@@ -644,23 +891,24 @@ async fn start_vnt_network(
|
||||
}
|
||||
}
|
||||
|
||||
state.starting_to_running();
|
||||
state.starting_to_running(&file_name);
|
||||
|
||||
// 启动成功后记录到自启列表
|
||||
record_add_running(&file_name).await;
|
||||
|
||||
// 启动网络管理任务。
|
||||
// 注意必须在任务组外等待:等待目标就是这个 task_group,
|
||||
// 若 spawn 进组内会形成自引用等待,网络自行停止时永不返回
|
||||
let file_name_for_wait = file_name.clone();
|
||||
tokio::spawn(async move {
|
||||
network_manager.wait_all_stopped().await;
|
||||
drop(task_group_guard);
|
||||
drop(network_manager);
|
||||
drop(vnt_cleanup_guard);
|
||||
record_remove_running(&file_name_for_wait).await;
|
||||
log::info!("Network manager stopped.");
|
||||
});
|
||||
|
||||
// 记录当前配置
|
||||
if let Err(e) = fs::write(CURRENT_CONFIG_RECORD, &file_name).await {
|
||||
log::warn!("Failed to record current config: {}", e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -700,18 +948,42 @@ async fn start_vnt_handler(
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_vnt_handler(State(state): State<HttpAppState>) -> Json<ApiResponse<()>> {
|
||||
if state.status() == VntStatus::Stopped {
|
||||
async fn stop_vnt_handler(
|
||||
State(state): State<HttpAppState>,
|
||||
Json(req): Json<FileReq>,
|
||||
) -> Json<ApiResponse<()>> {
|
||||
let Some(task_group_manager) = state.task_group_manager(&req.file_name) else {
|
||||
return Json(ApiResponse::error("实例不存在"));
|
||||
};
|
||||
if state.status(&req.file_name) == VntStatus::Stopped {
|
||||
return Json(ApiResponse::error("Vnt stopped"));
|
||||
}
|
||||
// 先中断可能处于注册重试循环中的启动任务,再停止任务组
|
||||
state.abort_start_task();
|
||||
state.task_group_manager.stop();
|
||||
state.abort_start_task(&req.file_name);
|
||||
task_group_manager.stop();
|
||||
|
||||
let _ = fs::write(CURRENT_CONFIG_RECORD, "").await;
|
||||
record_remove_running(&req.file_name).await;
|
||||
Json(ApiResponse::success(()))
|
||||
}
|
||||
|
||||
/// 移除已停止的实例条目(清理启动失败的残留卡片)
|
||||
async fn dismiss_instance_handler(
|
||||
State(state): State<HttpAppState>,
|
||||
Query(req): Query<FileReq>,
|
||||
) -> Json<ApiResponse<()>> {
|
||||
let mut lock = state.inner.lock();
|
||||
match lock.instances.get(&req.file_name) {
|
||||
None => Json(ApiResponse::error("实例不存在")),
|
||||
Some(inst) if inst.status != VntStatus::Stopped => {
|
||||
Json(ApiResponse::error("实例正在运行,不能移除"))
|
||||
}
|
||||
Some(_) => {
|
||||
lock.instances.remove(&req.file_name);
|
||||
Json(ApiResponse::success(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn restart_vnt_handler(
|
||||
State(state): State<HttpAppState>,
|
||||
Json(req): Json<FileReq>,
|
||||
@@ -726,12 +998,14 @@ async fn restart_vnt_handler(
|
||||
}
|
||||
|
||||
// 先停止(如果正在运行则停止,否则忽略)
|
||||
if state.status() != VntStatus::Stopped {
|
||||
state.abort_start_task();
|
||||
state.task_group_manager.stop();
|
||||
if state.status(&req.file_name) != VntStatus::Stopped {
|
||||
state.abort_start_task(&req.file_name);
|
||||
if let Some(task_group_manager) = state.task_group_manager(&req.file_name) {
|
||||
task_group_manager.stop();
|
||||
}
|
||||
// 等待停止完成
|
||||
for _ in 0..50 {
|
||||
if state.status() == VntStatus::Stopped {
|
||||
if state.status(&req.file_name) == VntStatus::Stopped {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
@@ -745,11 +1019,17 @@ async fn restart_vnt_handler(
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_info(State(state): State<HttpAppState>) -> Json<ApiResponse<HttpAppInfo>> {
|
||||
async fn get_info(
|
||||
State(state): State<HttpAppState>,
|
||||
Query(req): Query<FileReq>,
|
||||
) -> Json<ApiResponse<HttpAppInfo>> {
|
||||
let lock = state.inner.lock();
|
||||
let status = lock.status;
|
||||
let Some(inst) = lock.instances.get(&req.file_name) else {
|
||||
return Json(ApiResponse::error("实例不存在"));
|
||||
};
|
||||
let status = inst.status;
|
||||
|
||||
let info = if let Some(handler) = lock.vnt.as_ref() {
|
||||
let info = if let Some(handler) = inst.vnt.as_ref() {
|
||||
let api = &handler.api;
|
||||
let config = api.get_config();
|
||||
let ips = api.client_ips();
|
||||
@@ -912,8 +1192,10 @@ async fn delete_config(
|
||||
return Json(ApiResponse::error("Invalid file name"));
|
||||
}
|
||||
{
|
||||
if let Some(vnt) = &state.inner.lock().vnt
|
||||
&& vnt.config_file_name == req.file_name
|
||||
let lock = state.inner.lock();
|
||||
// 实例存在且有运行内容(已运行或非 Stopped)即视为占用
|
||||
if let Some(inst) = lock.instances.get(&req.file_name)
|
||||
&& (inst.vnt.is_some() || inst.status != VntStatus::Stopped)
|
||||
{
|
||||
return Json(ApiResponse::error("此配置已被使用,不能删除"));
|
||||
}
|
||||
@@ -1031,8 +1313,17 @@ async fn shutdown_signal() {
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_peers(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<HttpClientItem>>> {
|
||||
let api = state.inner.lock().vnt.as_ref().map(|v| v.api.clone());
|
||||
async fn get_peers(
|
||||
State(state): State<HttpAppState>,
|
||||
Query(req): Query<FileReq>,
|
||||
) -> Json<ApiResponse<Vec<HttpClientItem>>> {
|
||||
let api = state
|
||||
.inner
|
||||
.lock()
|
||||
.instances
|
||||
.get(&req.file_name)
|
||||
.and_then(|inst| inst.vnt.as_ref())
|
||||
.map(|v| v.api.clone());
|
||||
|
||||
let Some(api) = api else {
|
||||
return Json(ApiResponse::error("VNT not running"));
|
||||
@@ -1143,10 +1434,17 @@ async fn get_peers(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<Ht
|
||||
Json(ApiResponse::success(items))
|
||||
}
|
||||
|
||||
async fn get_routes(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<HttpRouteItem>>> {
|
||||
async fn get_routes(
|
||||
State(state): State<HttpAppState>,
|
||||
Query(req): Query<FileReq>,
|
||||
) -> Json<ApiResponse<Vec<HttpRouteItem>>> {
|
||||
let lock = state.inner.lock();
|
||||
|
||||
let Some(handler) = lock.vnt.as_ref() else {
|
||||
let Some(handler) = lock
|
||||
.instances
|
||||
.get(&req.file_name)
|
||||
.and_then(|inst| inst.vnt.as_ref())
|
||||
else {
|
||||
return Json(ApiResponse::error("VNT not running"));
|
||||
};
|
||||
|
||||
@@ -1222,21 +1520,186 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn new_test_state() -> HttpAppState {
|
||||
HttpAppState {
|
||||
inner: Arc::new(Mutex::new(HttpAppStateInner::default())),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_test_config() -> StartConfig {
|
||||
StartConfig {
|
||||
config_name: None,
|
||||
server: Vec::new(),
|
||||
cert_mode: None,
|
||||
network_code: "test".to_string(),
|
||||
device_id: Some("device-a".to_string()),
|
||||
device_name: None,
|
||||
tun_name: None,
|
||||
ip: None,
|
||||
password: None,
|
||||
no_punch: false,
|
||||
compress: false,
|
||||
rtx: false,
|
||||
fec: false,
|
||||
input: Vec::new(),
|
||||
output: Vec::new(),
|
||||
no_nat: false,
|
||||
// 默认 no_tun,避免无关用例意外触发 tun_name 冲突
|
||||
no_tun: true,
|
||||
mtu: None,
|
||||
port_mapping: Vec::new(),
|
||||
allow_mapping: false,
|
||||
udp_stun: Vec::new(),
|
||||
tcp_stun: Vec::new(),
|
||||
tunnel_port: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 两个实例同时处于 Starting 互不影响
|
||||
#[test]
|
||||
fn test_two_instances_starting_independent() {
|
||||
let state = new_test_state();
|
||||
state.starting("a.toml").unwrap();
|
||||
state.starting("b.toml").unwrap();
|
||||
state.record_log("a.toml", "a 的日志");
|
||||
state.record_log("b.toml", "b 的日志");
|
||||
|
||||
assert_eq!(state.status("a.toml"), VntStatus::Starting);
|
||||
assert_eq!(state.status("b.toml"), VntStatus::Starting);
|
||||
|
||||
// a 启动失败停止,b 的状态和日志不受影响
|
||||
state.record_log_and_stopped("a.toml", "启动失败");
|
||||
assert_eq!(state.status("a.toml"), VntStatus::Stopped);
|
||||
assert_eq!(state.status("b.toml"), VntStatus::Starting);
|
||||
|
||||
let lock = state.inner.lock();
|
||||
let a = lock.instances.get("a.toml").unwrap();
|
||||
assert!(a.start_logs.iter().any(|l| l.contains("启动失败")));
|
||||
let b = lock.instances.get("b.toml").unwrap();
|
||||
assert_eq!(b.start_logs.len(), 1);
|
||||
assert!(b.start_logs[0].contains("b 的日志"));
|
||||
}
|
||||
|
||||
/// 移除已停止实例:Stopped 可移除,Starting 拒绝
|
||||
#[tokio::test]
|
||||
async fn test_dismiss_instance() {
|
||||
let state = new_test_state();
|
||||
state.starting("a.toml").unwrap();
|
||||
state.record_log_and_stopped("a.toml", "启动失败");
|
||||
state.starting("b.toml").unwrap();
|
||||
|
||||
// Starting 中的实例不能移除
|
||||
let resp = dismiss_instance_handler(
|
||||
State(state.clone()),
|
||||
Query(FileReq {
|
||||
file_name: "b.toml".to_string(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.code, -1);
|
||||
assert!(state.inner.lock().instances.contains_key("b.toml"));
|
||||
|
||||
// 已停止(启动失败残留)的实例可以移除
|
||||
let resp = dismiss_instance_handler(
|
||||
State(state.clone()),
|
||||
Query(FileReq {
|
||||
file_name: "a.toml".to_string(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.code, 0);
|
||||
assert!(!state.inner.lock().instances.contains_key("a.toml"));
|
||||
|
||||
// 不存在的实例报错
|
||||
let resp = dismiss_instance_handler(
|
||||
State(state.clone()),
|
||||
Query(FileReq {
|
||||
file_name: "nope.toml".to_string(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.code, -1);
|
||||
}
|
||||
|
||||
/// 同一 file_name 重复 starting 报错
|
||||
#[test]
|
||||
fn test_duplicate_starting_same_file() {
|
||||
let state = new_test_state();
|
||||
state.starting("a.toml").unwrap();
|
||||
assert!(state.starting("a.toml").is_err());
|
||||
// 不同 file_name 不受影响
|
||||
state.starting("b.toml").unwrap();
|
||||
}
|
||||
|
||||
/// device_id 相同(含双方都为 None)且同服务器同组网时冲突;
|
||||
/// 不同服务器或不同 network_code 时允许相同 device_id
|
||||
#[test]
|
||||
fn test_conflict_same_device_id() {
|
||||
let running = new_test_config();
|
||||
// 相同 device_id(双方 server 均为空,视为同范围)
|
||||
let new = new_test_config();
|
||||
assert!(check_config_conflict(&new, &[&running]).is_err());
|
||||
// 双方都不指定 device_id(缺省会用同一 machine_uid)也算冲突
|
||||
let mut a = new_test_config();
|
||||
a.device_id = None;
|
||||
let mut b = new_test_config();
|
||||
b.device_id = None;
|
||||
assert!(check_config_conflict(&b, &[&a]).is_err());
|
||||
// 不同 device_id 不冲突
|
||||
let mut c = new_test_config();
|
||||
c.device_id = Some("device-c".to_string());
|
||||
assert!(check_config_conflict(&c, &[&running]).is_ok());
|
||||
// 相同 device_id 但 network_code 不同 → 不冲突
|
||||
let mut d = new_test_config();
|
||||
d.network_code = "other-net".to_string();
|
||||
assert!(check_config_conflict(&d, &[&running]).is_ok());
|
||||
// 相同 device_id 相同 network_code 但服务器不同 → 不冲突
|
||||
let mut e_running = new_test_config();
|
||||
e_running.server = vec!["server1:29870".to_string()];
|
||||
let mut e = new_test_config();
|
||||
e.server = vec!["server2:29870".to_string()];
|
||||
assert!(check_config_conflict(&e, &[&e_running]).is_ok());
|
||||
// 相同 device_id 相同 network_code 且服务器有交集 → 冲突
|
||||
let mut f = new_test_config();
|
||||
f.server = vec!["server1:29870".to_string(), "server3:29870".to_string()];
|
||||
assert!(check_config_conflict(&f, &[&e_running]).is_err());
|
||||
}
|
||||
|
||||
/// tunnel_port 都为 Some 且相等时冲突
|
||||
#[test]
|
||||
fn test_conflict_same_tunnel_port() {
|
||||
let mut running = new_test_config();
|
||||
running.device_id = Some("d1".to_string());
|
||||
running.tunnel_port = Some(12345);
|
||||
let mut new = new_test_config();
|
||||
new.device_id = Some("d2".to_string());
|
||||
new.tunnel_port = Some(12345);
|
||||
assert!(check_config_conflict(&new, &[&running]).is_err());
|
||||
// 一方未指定不冲突
|
||||
let mut new_none = new_test_config();
|
||||
new_none.device_id = Some("d2".to_string());
|
||||
assert!(check_config_conflict(&new_none, &[&running]).is_ok());
|
||||
// 端口不同不冲突
|
||||
let mut new_other = new_test_config();
|
||||
new_other.device_id = Some("d2".to_string());
|
||||
new_other.tunnel_port = Some(23456);
|
||||
assert!(check_config_conflict(&new_other, &[&running]).is_ok());
|
||||
}
|
||||
|
||||
/// Starting 状态下执行停止:必须中断注册重试循环并迁移到 Stopped。
|
||||
/// 复现 bug 场景——服务器不可达时启动任务陷在无限重试里,
|
||||
/// 不中断启动任务则状态永远卡在 Starting。
|
||||
#[tokio::test]
|
||||
async fn test_stop_during_starting() {
|
||||
let state = HttpAppState {
|
||||
task_group_manager: TaskGroupManager::new(),
|
||||
inner: Arc::new(Mutex::new(HttpAppStateInner::default())),
|
||||
};
|
||||
state.starting().unwrap();
|
||||
let state = new_test_state();
|
||||
let file_name = "a.toml";
|
||||
state.starting(file_name).unwrap();
|
||||
|
||||
// 模拟启动任务:注册一直失败、5 秒重试的无限循环
|
||||
let state_clone = state.clone();
|
||||
let file_name_owned = file_name.to_string();
|
||||
let on_error_guard = defer(move || {
|
||||
state_clone.starting_to_stopped();
|
||||
state_clone.starting_to_stopped(&file_name_owned);
|
||||
});
|
||||
let handle = tokio::spawn(async move {
|
||||
let _on_error_guard = on_error_guard;
|
||||
@@ -1244,18 +1707,18 @@ mod tests {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
});
|
||||
state.inner.lock().start_handle = Some(handle);
|
||||
state.set_start_handle(file_name, handle);
|
||||
|
||||
assert_eq!(state.status(), VntStatus::Starting);
|
||||
state.abort_start_task();
|
||||
assert_eq!(state.status(file_name), VntStatus::Starting);
|
||||
state.abort_start_task(file_name);
|
||||
|
||||
// abort 生效后 defer 触发,状态应迁移到 Stopped
|
||||
for _ in 0..100 {
|
||||
if state.status() == VntStatus::Stopped {
|
||||
if state.status(file_name) == VntStatus::Stopped {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
assert_eq!(state.status(), VntStatus::Stopped);
|
||||
assert_eq!(state.status(file_name), VntStatus::Stopped);
|
||||
}
|
||||
}
|
||||
|
||||
+562
-224
@@ -263,7 +263,7 @@
|
||||
</nav>
|
||||
|
||||
<div class="p-4 text-xs text-slate-500 text-center">
|
||||
Client: v{{ info.version }}
|
||||
Client: v{{ version || '-' }}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -280,17 +280,15 @@
|
||||
>
|
||||
<span
|
||||
class="w-2.5 h-2.5 rounded-full"
|
||||
:class="info.status === 'running' ? 'bg-green-500 animate-pulse' : (info.status === 'starting' ? 'bg-yellow-500 animate-pulse' : 'bg-red-500')"
|
||||
:class="runningCount > 0 ? 'bg-green-500 animate-pulse' : (startingCount > 0 ? 'bg-blue-500 animate-pulse' : 'bg-red-500')"
|
||||
></span>
|
||||
<span class="text-sm font-medium"
|
||||
>{{ info.status === 'running' ? '已运行' :
|
||||
(info.status === 'starting' ? '启动中...' :
|
||||
'未启动') }}</span
|
||||
>{{ headerStatusText }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="info.status === 'running'"
|
||||
v-if="selectedInfo"
|
||||
class="flex items-center space-x-2 bg-slate-800 rounded-full px-3 py-1 border border-slate-700"
|
||||
:title="serverStatusText"
|
||||
>
|
||||
@@ -317,25 +315,28 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="info.ip"
|
||||
v-if="selectedInfo && selectedInfo.ip"
|
||||
class="flex items-center space-x-2 text-slate-300"
|
||||
>
|
||||
<span
|
||||
class="font-mono text-blue-400 font-bold bg-blue-900/20 px-2 py-0.5 rounded"
|
||||
>{{ info.ip }}</span
|
||||
>{{ selectedInfo.ip }}</span
|
||||
>
|
||||
<span class="text-xs text-slate-500"
|
||||
>{{ selectedConfigName }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 text-slate-400">
|
||||
<div class="flex items-center space-x-2 text-slate-400" v-if="selectedInfo">
|
||||
<span class="text-sm">设备:</span>
|
||||
<span class="font-bold text-white"
|
||||
>{{ info.name || '' }}</span
|
||||
>{{ selectedInfo.name || '' }}</span
|
||||
>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded bg-slate-800 text-slate-500"
|
||||
title="Device ID"
|
||||
>{{ info.device_id.substring(0, 8) }}...</span
|
||||
>{{ (selectedInfo.device_id || '').substring(0, 8) }}...</span
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
@@ -379,10 +380,17 @@
|
||||
'running' ? '启动成功' : '启动失败') }}
|
||||
</h3>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-mono text-slate-500 uppercase tracking-widest"
|
||||
>{{ startStatus }}</span
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span
|
||||
class="text-sm font-medium text-blue-400 truncate max-w-[200px]"
|
||||
:title="logFileName"
|
||||
>{{ logConfigName }}</span
|
||||
>
|
||||
<span
|
||||
class="text-xs font-mono text-slate-500 uppercase tracking-widest"
|
||||
>{{ startStatus }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="logContainer"
|
||||
@@ -500,11 +508,12 @@
|
||||
<!-- 1. General (通用) -->
|
||||
<template id="tpl-general">
|
||||
<div class="space-y-6 max-w-5xl mx-auto">
|
||||
<!-- 启动组网 -->
|
||||
<div class="glass-panel rounded-xl p-6 shadow-lg">
|
||||
<h2
|
||||
class="text-xl font-bold mb-6 text-white border-l-4 border-blue-500 pl-3"
|
||||
>
|
||||
运行控制
|
||||
启动组网
|
||||
</h2>
|
||||
<div class="flex items-end space-x-4">
|
||||
<div class="flex-1">
|
||||
@@ -514,12 +523,11 @@
|
||||
>
|
||||
<select
|
||||
v-model="localSelectedConfig"
|
||||
:disabled="info.status === 'starting'"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-4 py-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="" disabled>请选择配置...</option>
|
||||
<option
|
||||
v-for="cfg in configList"
|
||||
v-for="cfg in availableConfigs"
|
||||
:key="cfg.file_name"
|
||||
:value="cfg.file_name"
|
||||
>
|
||||
@@ -528,84 +536,117 @@
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
v-if="info.status === 'running'"
|
||||
@click="handleRestart"
|
||||
:disabled="loading || !localSelectedConfig"
|
||||
class="px-6 py-2.5 rounded-lg font-bold text-white shadow-lg transition-transform active:scale-95 flex items-center disabled:opacity-50 disabled:cursor-not-allowed bg-blue-500 hover:bg-blue-600"
|
||||
@click="handleStart"
|
||||
:disabled="!localSelectedConfig || !!loadingMap[localSelectedConfig]"
|
||||
class="px-8 py-2.5 rounded-lg font-bold text-white shadow-lg transition-transform active:scale-95 flex items-center disabled:opacity-50 disabled:cursor-not-allowed bg-green-500 hover:bg-green-600"
|
||||
>
|
||||
<span v-if="loading" class="mr-2 animate-spin">⟳</span>
|
||||
重启
|
||||
</button>
|
||||
<button
|
||||
@click="handleToggle"
|
||||
:disabled="loading || (!localSelectedConfig && info.status !== 'running' && info.status !== 'starting')"
|
||||
:class="(info.status === 'running' || info.status === 'starting') ? 'bg-red-500 hover:bg-red-600' : 'bg-green-500 hover:bg-green-600'"
|
||||
class="px-8 py-2.5 rounded-lg font-bold text-white shadow-lg transition-transform active:scale-95 flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="loading" class="mr-2 animate-spin"
|
||||
<span v-if="loadingMap[localSelectedConfig]" class="mr-2 animate-spin"
|
||||
>⟳</span
|
||||
>
|
||||
{{ (info.status === 'running' || info.status ===
|
||||
'starting') ? '停止运行' : '启动运行' }}
|
||||
启动
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div
|
||||
class="glass-panel p-5 rounded-xl flex flex-col items-center justify-center border-t-4 border-t-blue-500"
|
||||
<!-- 组网实例 -->
|
||||
<div class="glass-panel rounded-xl p-6 shadow-lg">
|
||||
<h2
|
||||
class="text-xl font-bold mb-6 text-white border-l-4 border-green-500 pl-3"
|
||||
>
|
||||
<span class="text-slate-400 text-sm mb-1"
|
||||
>在线设备</span
|
||||
>
|
||||
<span class="text-3xl font-bold text-blue-400"
|
||||
>{{ info.online_client_num }}</span
|
||||
>
|
||||
组网实例
|
||||
</h2>
|
||||
<div
|
||||
v-if="instanceList.length === 0"
|
||||
class="text-center text-slate-500 py-8"
|
||||
>
|
||||
暂无运行中的组网,请在上方选择配置启动
|
||||
</div>
|
||||
<div
|
||||
class="glass-panel p-5 rounded-xl flex flex-col items-center justify-center border-t-4 border-t-green-500"
|
||||
>
|
||||
<span class="text-slate-400 text-sm mb-1"
|
||||
>直连设备</span
|
||||
>
|
||||
<span class="text-3xl font-bold text-green-400"
|
||||
>{{ info.direct_client_num }}</span
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="inst in instanceList"
|
||||
:key="inst.file_name"
|
||||
@click="selectInstance(inst.file_name)"
|
||||
:class="selectedInstance === inst.file_name ? 'ring-2 ring-blue-500 bg-slate-800/80' : 'bg-slate-800/40 hover:bg-slate-800/60'"
|
||||
class="rounded-xl p-5 border border-slate-700 transition-all cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="glass-panel p-5 rounded-xl flex flex-col items-center justify-center border-t-4 border-t-slate-500"
|
||||
>
|
||||
<span class="text-slate-400 text-sm mb-1"
|
||||
>离线设备</span
|
||||
<div class="flex items-center justify-between">
|
||||
<h3
|
||||
class="font-bold text-lg text-white truncate"
|
||||
:title="inst.file_name"
|
||||
>
|
||||
<span class="text-3xl font-bold text-slate-400"
|
||||
>{{ info.offline_client_num }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="glass-panel p-5 rounded-xl flex flex-col items-center justify-center border-t-4 border-t-yellow-500"
|
||||
>
|
||||
<span class="text-slate-400 text-sm mb-1"
|
||||
>当前配置</span
|
||||
>
|
||||
<span
|
||||
class="text-lg font-medium text-yellow-400 truncate w-full text-center px-2"
|
||||
:title="info.current_config_file"
|
||||
>
|
||||
{{ info.current_config_name || '-' }}
|
||||
{{ inst.config_name || inst.file_name }}
|
||||
</h3>
|
||||
<span class="flex items-center text-sm shrink-0 ml-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full mr-1.5"
|
||||
:class="statusDotClass(inst.status)"
|
||||
></span>
|
||||
<span :class="statusTextClass(inst.status)">{{ statusText(inst.status) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 text-sm">
|
||||
<span class="text-slate-400">虚拟 IP:</span>
|
||||
<span class="font-mono text-blue-300 ml-1">{{ infoOf(inst.file_name).ip || '-' }}</span>
|
||||
</div>
|
||||
<div class="mt-3 flex space-x-4 text-xs text-slate-400">
|
||||
<span>在线 <span class="text-blue-400 font-bold">{{ infoOf(inst.file_name).online_client_num || 0 }}</span></span>
|
||||
<span>直连 <span class="text-green-400 font-bold">{{ infoOf(inst.file_name).direct_client_num || 0 }}</span></span>
|
||||
<span>离线 <span class="text-slate-500 font-bold">{{ infoOf(inst.file_name).offline_client_num || 0 }}</span></span>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end space-x-2">
|
||||
<button
|
||||
@click.stop="openStartLog(inst.file_name)"
|
||||
class="px-3 py-1.5 text-sm rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-200 transition-colors"
|
||||
>
|
||||
日志
|
||||
</button>
|
||||
<button
|
||||
v-if="inst.status === 'running' || inst.status === 'stopped'"
|
||||
@click.stop="restartVnt(inst.file_name)"
|
||||
:disabled="!!loadingMap[inst.file_name]"
|
||||
class="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-500 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||
>
|
||||
<span v-if="loadingMap[inst.file_name]" class="mr-1 animate-spin">⟳</span>
|
||||
{{ inst.status === 'stopped' ? '重新启动' : '重启' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="inst.status !== 'stopped'"
|
||||
@click.stop="stopVnt(inst.file_name)"
|
||||
:disabled="!!loadingMap[inst.file_name]"
|
||||
class="px-3 py-1.5 text-sm rounded-lg bg-red-500 hover:bg-red-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||
>
|
||||
<span v-if="loadingMap[inst.file_name]" class="mr-1 animate-spin">⟳</span
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
<button
|
||||
v-if="inst.status === 'stopped'"
|
||||
@click.stop="dismissInstance(inst.file_name)"
|
||||
:disabled="!!loadingMap[inst.file_name]"
|
||||
class="px-3 py-1.5 text-sm rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||
>
|
||||
<span v-if="loadingMap[inst.file_name]" class="mr-1 animate-spin">⟳</span>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选中实例详情 -->
|
||||
<template v-if="selectedInstance">
|
||||
<div class="glass-panel rounded-xl p-6 shadow-lg">
|
||||
<h2 class="text-lg font-bold mb-4 text-white">网络详情</h2>
|
||||
<h2 class="text-lg font-bold mb-4 text-white">
|
||||
网络详情
|
||||
<span class="text-sm font-medium text-blue-400 ml-2">{{ selectedConfigName }}</span>
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div
|
||||
class="flex justify-between border-b border-slate-700 pb-2"
|
||||
>
|
||||
<span class="text-slate-400">虚拟 IP / 掩码</span>
|
||||
<span class="font-mono text-white"
|
||||
>{{ info.ip || '-' }} / {{ info.prefix_len ||
|
||||
>{{ selectedInfo.ip || '-' }} / {{ selectedInfo.prefix_len ||
|
||||
'-' }}</span
|
||||
>
|
||||
</div>
|
||||
@@ -614,7 +655,7 @@
|
||||
>
|
||||
<span class="text-slate-400">网关</span>
|
||||
<span class="font-mono text-white"
|
||||
>{{ info.gateway || '-' }}</span
|
||||
>{{ selectedInfo.gateway || '-' }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
@@ -622,7 +663,7 @@
|
||||
>
|
||||
<span class="text-slate-400">网络编号</span>
|
||||
<span class="font-mono text-white"
|
||||
>{{ info.network_code || '-' }}</span
|
||||
>{{ selectedInfo.network_code || '-' }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
@@ -630,7 +671,7 @@
|
||||
>
|
||||
<span class="text-slate-400">MTU</span>
|
||||
<span class="font-mono text-white"
|
||||
>{{ info.mtu || '' }}</span
|
||||
>{{ selectedInfo.mtu || '' }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
@@ -638,7 +679,7 @@
|
||||
>
|
||||
<span class="text-slate-400">NAT 类型</span>
|
||||
<span class="font-mono text-blue-300"
|
||||
>{{ info.nat_type || 'Unknown' }}</span
|
||||
>{{ selectedInfo.nat_type || 'Unknown' }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
@@ -647,26 +688,26 @@
|
||||
<span class="text-slate-400">Public IPv6</span>
|
||||
<span
|
||||
class="font-mono text-white truncate max-w-[200px]"
|
||||
:title="info.public_ipv6"
|
||||
>{{ info.public_ipv6 || '-' }}</span
|
||||
:title="selectedInfo.public_ipv6"
|
||||
>{{ selectedInfo.public_ipv6 || '-' }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="flex items-center space-x-2 bg-slate-800/50 rounded-lg px-3 py-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="info.encrypt ? 'bg-green-500' : 'bg-slate-600'"></span>
|
||||
<span class="w-2 h-2 rounded-full" :class="selectedInfo.encrypt ? 'bg-green-500' : 'bg-slate-600'"></span>
|
||||
<span class="text-sm text-slate-300">加密</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 bg-slate-800/50 rounded-lg px-3 py-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="info.compress ? 'bg-green-500' : 'bg-slate-600'"></span>
|
||||
<span class="w-2 h-2 rounded-full" :class="selectedInfo.compress ? 'bg-green-500' : 'bg-slate-600'"></span>
|
||||
<span class="text-sm text-slate-300">压缩</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 bg-slate-800/50 rounded-lg px-3 py-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="info.fec ? 'bg-green-500' : 'bg-slate-600'"></span>
|
||||
<span class="w-2 h-2 rounded-full" :class="selectedInfo.fec ? 'bg-green-500' : 'bg-slate-600'"></span>
|
||||
<span class="text-sm text-slate-300">FEC纠错</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 bg-slate-800/50 rounded-lg px-3 py-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="info.rtx ? 'bg-green-500' : 'bg-slate-600'"></span>
|
||||
<span class="w-2 h-2 rounded-full" :class="selectedInfo.rtx ? 'bg-green-500' : 'bg-slate-600'"></span>
|
||||
<span class="text-sm text-slate-300">QUIC传输</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -676,13 +717,13 @@
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="pip in info.public_ipv4s"
|
||||
v-for="pip in selectedInfo.public_ipv4s"
|
||||
:key="pip"
|
||||
class="px-2 py-1 bg-slate-800 rounded text-xs font-mono text-green-300 border border-slate-600"
|
||||
>{{ pip }}</span
|
||||
>
|
||||
<span
|
||||
v-if="!info.public_ipv4s || info.public_ipv4s.length === 0"
|
||||
v-if="!selectedInfo.public_ipv4s || selectedInfo.public_ipv4s.length === 0"
|
||||
class="text-slate-600 text-xs"
|
||||
>无</span
|
||||
>
|
||||
@@ -726,7 +767,7 @@
|
||||
class="divide-y divide-slate-700 bg-slate-900/30"
|
||||
>
|
||||
<tr
|
||||
v-for="(server, idx) in info.server_info"
|
||||
v-for="(server, idx) in selectedInfo.server_info"
|
||||
:key="idx"
|
||||
>
|
||||
<td
|
||||
@@ -756,7 +797,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-if="!info.server_info || info.server_info.length === 0"
|
||||
v-if="!selectedInfo.server_info || selectedInfo.server_info.length === 0"
|
||||
>
|
||||
<td
|
||||
colspan="4"
|
||||
@@ -769,6 +810,7 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -804,15 +846,21 @@
|
||||
<div
|
||||
v-for="cfg in configList"
|
||||
:key="cfg.file_name"
|
||||
:class="{'ring-2 ring-green-500 bg-slate-800/80': info.current_config_file === cfg.file_name, 'bg-slate-800/40 hover:bg-slate-800/60': info.current_config_file !== cfg.file_name}"
|
||||
:class="{'ring-2 ring-green-500 bg-slate-800/80': instStatus(cfg.file_name) === 'running', 'ring-2 ring-blue-500 bg-slate-800/80': instStatus(cfg.file_name) === 'starting', 'bg-slate-800/40 hover:bg-slate-800/60': !instStatus(cfg.file_name)}"
|
||||
class="rounded-xl p-5 border border-slate-700 transition-all cursor-pointer group relative overflow-hidden flex flex-col justify-between min-h-[140px]"
|
||||
@click="openEditor(cfg.file_name)"
|
||||
>
|
||||
<div
|
||||
v-if="info.current_config_file === cfg.file_name"
|
||||
v-if="instStatus(cfg.file_name) === 'running'"
|
||||
class="absolute top-0 right-0 bg-green-500 text-white text-xs px-2 py-1 rounded-bl"
|
||||
>
|
||||
Running
|
||||
运行中
|
||||
</div>
|
||||
<div
|
||||
v-else-if="instStatus(cfg.file_name) === 'starting'"
|
||||
class="absolute top-0 right-0 bg-blue-500 text-white text-xs px-2 py-1 rounded-bl"
|
||||
>
|
||||
启动中
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<div
|
||||
@@ -1301,7 +1349,32 @@
|
||||
<!-- 3. Peers (设备列表) -->
|
||||
<template id="tpl-peers">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="glass-panel rounded-xl shadow-lg overflow-hidden">
|
||||
<!-- 实例切换器 -->
|
||||
<div
|
||||
v-if="instanceList.length > 0"
|
||||
class="flex items-center space-x-2 mb-4 overflow-x-auto scrollbar-hide"
|
||||
>
|
||||
<button
|
||||
v-for="inst in instanceList"
|
||||
:key="inst.file_name"
|
||||
@click="selectedInstance = inst.file_name"
|
||||
:class="selectedInstance === inst.file_name ? 'bg-blue-600 text-white border-blue-500' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'"
|
||||
class="flex items-center px-4 py-2 rounded-lg border text-sm font-medium transition-colors shrink-0"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full mr-2"
|
||||
:class="inst.status === 'running' ? 'bg-green-500' : (inst.status === 'starting' ? 'bg-blue-400 animate-pulse' : 'bg-red-500')"
|
||||
></span>
|
||||
{{ inst.config_name || inst.file_name }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!selectedInstance"
|
||||
class="glass-panel rounded-xl p-12 text-center text-slate-500"
|
||||
>
|
||||
暂无运行中的组网实例
|
||||
</div>
|
||||
<div v-else class="glass-panel rounded-xl shadow-lg overflow-hidden">
|
||||
<div
|
||||
class="px-6 py-4 border-b border-slate-700 flex justify-between items-center"
|
||||
>
|
||||
@@ -1528,7 +1601,32 @@
|
||||
<!-- 4. Routes (路由) -->
|
||||
<template id="tpl-routes">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="glass-panel rounded-xl shadow-lg overflow-hidden">
|
||||
<!-- 实例切换器 -->
|
||||
<div
|
||||
v-if="instanceList.length > 0"
|
||||
class="flex items-center space-x-2 mb-4 overflow-x-auto scrollbar-hide"
|
||||
>
|
||||
<button
|
||||
v-for="inst in instanceList"
|
||||
:key="inst.file_name"
|
||||
@click="selectedInstance = inst.file_name"
|
||||
:class="selectedInstance === inst.file_name ? 'bg-blue-600 text-white border-blue-500' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'"
|
||||
class="flex items-center px-4 py-2 rounded-lg border text-sm font-medium transition-colors shrink-0"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full mr-2"
|
||||
:class="inst.status === 'running' ? 'bg-green-500' : (inst.status === 'starting' ? 'bg-blue-400 animate-pulse' : 'bg-red-500')"
|
||||
></span>
|
||||
{{ inst.config_name || inst.file_name }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!selectedInstance"
|
||||
class="glass-panel rounded-xl p-12 text-center text-slate-500"
|
||||
>
|
||||
暂无运行中的组网实例
|
||||
</div>
|
||||
<div v-else class="glass-panel rounded-xl shadow-lg overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-700">
|
||||
<h2 class="text-xl font-bold text-white">路由表</h2>
|
||||
</div>
|
||||
@@ -1654,53 +1752,96 @@
|
||||
const GeneralView = {
|
||||
template: "#tpl-general",
|
||||
setup() {
|
||||
const info = inject("info");
|
||||
const instanceList = inject("instanceList");
|
||||
const instances = inject("instances");
|
||||
const configList = inject("configList");
|
||||
const toggleVnt = inject("toggleVnt");
|
||||
const startVnt = inject("startVnt");
|
||||
const stopVnt = inject("stopVnt");
|
||||
const restartVnt = inject("restartVnt");
|
||||
const loading = inject("loading");
|
||||
const dismissInstance = inject("dismissInstance");
|
||||
const openStartLog = inject("openStartLog");
|
||||
const loadingMap = inject("loadingMap");
|
||||
const selectedInstance = inject("selectedInstance");
|
||||
const localSelectedConfig = ref("");
|
||||
|
||||
// 同步当前配置
|
||||
watch(
|
||||
() => info.value.current_config_file,
|
||||
(newVal) => {
|
||||
if (
|
||||
(info.value.status === "running" ||
|
||||
info.value.status === "starting") &&
|
||||
newVal
|
||||
) {
|
||||
localSelectedConfig.value = newVal;
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
// 只列出没有对应实例的配置(同一配置最多一个实例)
|
||||
const availableConfigs = computed(() =>
|
||||
configList.value.filter(
|
||||
(cfg) =>
|
||||
!instanceList.value.some(
|
||||
(inst) => inst.file_name === cfg.file_name,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const handleToggle = () => {
|
||||
// 如果是启动,且有本地选择的配置,传递给 toggle
|
||||
if (
|
||||
info.value.status !== "running" &&
|
||||
info.value.status !== "starting"
|
||||
) {
|
||||
toggleVnt(localSelectedConfig.value);
|
||||
} else {
|
||||
toggleVnt(null);
|
||||
}
|
||||
const infoOf = (fileName) => instances.value[fileName] || {};
|
||||
|
||||
const selectedInfo = computed(() =>
|
||||
selectedInstance.value
|
||||
? infoOf(selectedInstance.value)
|
||||
: {},
|
||||
);
|
||||
|
||||
const selectedConfigName = computed(() => {
|
||||
if (!selectedInstance.value) return "";
|
||||
const inst = instanceList.value.find(
|
||||
(i) => i.file_name === selectedInstance.value,
|
||||
);
|
||||
return inst
|
||||
? inst.config_name || inst.file_name
|
||||
: selectedInstance.value;
|
||||
});
|
||||
|
||||
const selectInstance = (fileName) => {
|
||||
selectedInstance.value = fileName;
|
||||
};
|
||||
|
||||
const handleRestart = () => {
|
||||
if (localSelectedConfig.value) {
|
||||
restartVnt(localSelectedConfig.value);
|
||||
const handleStart = () => {
|
||||
if (!localSelectedConfig.value) {
|
||||
alert("请先选择一个配置");
|
||||
return;
|
||||
}
|
||||
startVnt(localSelectedConfig.value);
|
||||
localSelectedConfig.value = "";
|
||||
};
|
||||
|
||||
const statusText = (status) =>
|
||||
status === "running"
|
||||
? "运行中"
|
||||
: status === "starting"
|
||||
? "启动中"
|
||||
: "已停止";
|
||||
const statusDotClass = (status) =>
|
||||
status === "running"
|
||||
? "bg-green-500"
|
||||
: status === "starting"
|
||||
? "bg-blue-500 animate-pulse"
|
||||
: "bg-red-500";
|
||||
const statusTextClass = (status) =>
|
||||
status === "running"
|
||||
? "text-green-400"
|
||||
: status === "starting"
|
||||
? "text-blue-400"
|
||||
: "text-red-400";
|
||||
|
||||
return {
|
||||
info,
|
||||
configList,
|
||||
instanceList,
|
||||
availableConfigs,
|
||||
localSelectedConfig,
|
||||
loading,
|
||||
handleToggle,
|
||||
handleRestart,
|
||||
loadingMap,
|
||||
selectedInstance,
|
||||
selectedInfo,
|
||||
selectedConfigName,
|
||||
infoOf,
|
||||
selectInstance,
|
||||
handleStart,
|
||||
stopVnt,
|
||||
restartVnt,
|
||||
dismissInstance,
|
||||
openStartLog,
|
||||
statusText,
|
||||
statusDotClass,
|
||||
statusTextClass,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1708,10 +1849,18 @@
|
||||
const ConfigView = {
|
||||
template: "#tpl-config",
|
||||
setup() {
|
||||
const info = inject("info");
|
||||
const instanceList = inject("instanceList");
|
||||
const configList = inject("configList");
|
||||
const fetchConfigList = inject("fetchConfigList");
|
||||
|
||||
// 配置对应的实例运行状态(无实例返回 null)
|
||||
const instStatus = (fileName) => {
|
||||
const inst = instanceList.value.find(
|
||||
(i) => i.file_name === fileName,
|
||||
);
|
||||
return inst ? inst.status : null;
|
||||
};
|
||||
|
||||
// 编辑器状态
|
||||
const showEditor = ref(false);
|
||||
const editorContent = ref("");
|
||||
@@ -2255,7 +2404,7 @@ server = ["quic://1.2.3.4:29872"]
|
||||
};
|
||||
|
||||
return {
|
||||
info,
|
||||
instStatus,
|
||||
configList,
|
||||
showEditor,
|
||||
editorContent,
|
||||
@@ -2276,7 +2425,8 @@ server = ["quic://1.2.3.4:29872"]
|
||||
template: "#tpl-peers",
|
||||
setup() {
|
||||
const peers = ref([]);
|
||||
const info = inject("info");
|
||||
const instanceList = inject("instanceList");
|
||||
const selectedInstance = inject("selectedInstance");
|
||||
const isPageVisible = inject("isPageVisible");
|
||||
const showTooltipGlobal = inject("showPeerTooltip");
|
||||
const hideTooltipGlobal = inject("hidePeerTooltip");
|
||||
@@ -2408,15 +2558,34 @@ server = ["quic://1.2.3.4:29872"]
|
||||
return Math.ceil(val / (1024 * 1024 * 1024)) * 1024 * 1024 * 1024;
|
||||
};
|
||||
|
||||
const currentStatus = () => {
|
||||
const inst = instanceList.value.find(
|
||||
(i) => i.file_name === selectedInstance.value,
|
||||
);
|
||||
return inst ? inst.status : null;
|
||||
};
|
||||
|
||||
const resetPeerState = () => {
|
||||
peers.value = [];
|
||||
lastTrafficMap = {};
|
||||
lastFetchTime = 0;
|
||||
for (const key in speedHistoryMap)
|
||||
delete speedHistoryMap[key];
|
||||
for (const key in expandedPeers) delete expandedPeers[key];
|
||||
};
|
||||
|
||||
const fetchPeers = async () => {
|
||||
if (info.value.status !== "running") {
|
||||
peers.value = [];
|
||||
lastTrafficMap = {};
|
||||
lastFetchTime = 0;
|
||||
if (
|
||||
!selectedInstance.value ||
|
||||
currentStatus() !== "running"
|
||||
) {
|
||||
resetPeerState();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/peers`);
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/peers?file_name=${encodeURIComponent(selectedInstance.value)}`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (json.code === 0) {
|
||||
const now = Date.now();
|
||||
@@ -2473,8 +2642,14 @@ server = ["quic://1.2.3.4:29872"]
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
// 监听 status 变化,当变为 running 时立即获取数据
|
||||
watch(() => info.value.status, (newStatus) => {
|
||||
// 切换实例时重置并重新拉取
|
||||
watch(selectedInstance, () => {
|
||||
resetPeerState();
|
||||
fetchPeers();
|
||||
});
|
||||
|
||||
// 监听选中实例状态变化,当变为 running 时立即获取数据
|
||||
watch(currentStatus, (newStatus) => {
|
||||
if (newStatus === "running") {
|
||||
fetchPeers();
|
||||
}
|
||||
@@ -2505,6 +2680,8 @@ server = ["quic://1.2.3.4:29872"]
|
||||
|
||||
return {
|
||||
peers,
|
||||
instanceList,
|
||||
selectedInstance,
|
||||
expandedPeers,
|
||||
toggleExpand,
|
||||
formatTime,
|
||||
@@ -2522,17 +2699,30 @@ server = ["quic://1.2.3.4:29872"]
|
||||
template: "#tpl-routes",
|
||||
setup() {
|
||||
const routes = ref([]);
|
||||
const info = inject("info");
|
||||
const instanceList = inject("instanceList");
|
||||
const selectedInstance = inject("selectedInstance");
|
||||
const isPageVisible = inject("isPageVisible");
|
||||
let timer = null;
|
||||
|
||||
const currentStatus = () => {
|
||||
const inst = instanceList.value.find(
|
||||
(i) => i.file_name === selectedInstance.value,
|
||||
);
|
||||
return inst ? inst.status : null;
|
||||
};
|
||||
|
||||
const fetchRoutes = async () => {
|
||||
if (info.value.status !== "running") {
|
||||
if (
|
||||
!selectedInstance.value ||
|
||||
currentStatus() !== "running"
|
||||
) {
|
||||
routes.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/routes`);
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/routes?file_name=${encodeURIComponent(selectedInstance.value)}`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (json.code === 0) routes.value = json.data || [];
|
||||
} catch (e) {
|
||||
@@ -2551,14 +2741,19 @@ server = ["quic://1.2.3.4:29872"]
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
// 监听 status 变化,当变为 running 时立即获取数据
|
||||
watch(() => info.value.status, (newStatus) => {
|
||||
// 切换实例时重新拉取
|
||||
watch(selectedInstance, () => {
|
||||
fetchRoutes();
|
||||
});
|
||||
|
||||
// 监听选中实例状态变化,当变为 running 时立即获取数据
|
||||
watch(currentStatus, (newStatus) => {
|
||||
if (newStatus === "running") {
|
||||
fetchRoutes();
|
||||
}
|
||||
});
|
||||
|
||||
return {routes};
|
||||
return {routes, instanceList, selectedInstance};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2581,26 +2776,18 @@ server = ["quic://1.2.3.4:29872"]
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const info = ref({
|
||||
status: "stopped",
|
||||
ip: "",
|
||||
name: "",
|
||||
device_id: "",
|
||||
version: "",
|
||||
server_info: [],
|
||||
online_client_num: 0,
|
||||
direct_client_num: 0,
|
||||
offline_client_num: 0,
|
||||
current_config_file: null,
|
||||
current_config_name: null,
|
||||
});
|
||||
// 多实例状态:key=file_name, value=该实例 info
|
||||
const instances = ref({});
|
||||
const instanceList = ref([]);
|
||||
const selectedInstance = ref(null);
|
||||
const configList = ref([]);
|
||||
const loading = ref(false);
|
||||
const loadingMap = ref({});
|
||||
|
||||
// 启动日志相关
|
||||
const showStartLog = ref(false);
|
||||
const startLogs = ref([]);
|
||||
const startStatus = ref("stopped");
|
||||
const logFileName = ref(null);
|
||||
const logContainer = ref(null);
|
||||
let statusInterval = null;
|
||||
let infoTimer = null;
|
||||
@@ -2644,31 +2831,127 @@ server = ["quic://1.2.3.4:29872"]
|
||||
};
|
||||
|
||||
// 计算属性
|
||||
const runningCount = computed(
|
||||
() =>
|
||||
instanceList.value.filter(
|
||||
(i) => i.status === "running",
|
||||
).length,
|
||||
);
|
||||
const startingCount = computed(
|
||||
() =>
|
||||
instanceList.value.filter(
|
||||
(i) => i.status === "starting",
|
||||
).length,
|
||||
);
|
||||
const headerStatusText = computed(() => {
|
||||
if (runningCount.value > 0)
|
||||
return `运行中 x${runningCount.value}`;
|
||||
if (startingCount.value > 0) return "启动中...";
|
||||
return "未启动";
|
||||
});
|
||||
const selectedInfo = computed(() =>
|
||||
selectedInstance.value
|
||||
? instances.value[selectedInstance.value] || null
|
||||
: null,
|
||||
);
|
||||
const selectedConfigName = computed(() => {
|
||||
if (!selectedInstance.value) return "";
|
||||
const inst = instanceList.value.find(
|
||||
(i) => i.file_name === selectedInstance.value,
|
||||
);
|
||||
return inst
|
||||
? inst.config_name || inst.file_name
|
||||
: selectedInstance.value;
|
||||
});
|
||||
const version = computed(() => {
|
||||
for (const key in instances.value) {
|
||||
const item = instances.value[key];
|
||||
if (item && item.version) return item.version;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const logConfigName = computed(() => {
|
||||
if (!logFileName.value) return "";
|
||||
const inst = instanceList.value.find(
|
||||
(i) => i.file_name === logFileName.value,
|
||||
);
|
||||
if (inst) return inst.config_name || inst.file_name;
|
||||
const cfg = configList.value.find(
|
||||
(c) => c.file_name === logFileName.value,
|
||||
);
|
||||
return cfg
|
||||
? cfg.config_name || cfg.file_name
|
||||
: logFileName.value;
|
||||
});
|
||||
const isServerConnected = computed(
|
||||
() =>
|
||||
info.value.server_info &&
|
||||
info.value.server_info.some((s) => s.connected),
|
||||
!!(
|
||||
selectedInfo.value &&
|
||||
selectedInfo.value.server_info &&
|
||||
selectedInfo.value.server_info.some((s) => s.connected)
|
||||
),
|
||||
);
|
||||
const serverStatusText = computed(() => {
|
||||
if (
|
||||
!info.value.server_info ||
|
||||
!info.value.server_info.length
|
||||
)
|
||||
const si = selectedInfo.value;
|
||||
if (!si || !si.server_info || !si.server_info.length)
|
||||
return "未配置服务器";
|
||||
return `${info.value.server_info.filter((s) => s.connected).length} / ${info.value.server_info.length} 已连接`;
|
||||
return `${si.server_info.filter((s) => s.connected).length} / ${si.server_info.length} 已连接`;
|
||||
});
|
||||
|
||||
// 基础 API
|
||||
const fetchInfo = async () => {
|
||||
const fetchInstanceInfo = async (fileName) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/info`);
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/info?file_name=${encodeURIComponent(fileName)}`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (json.code === 0) info.value = json.data;
|
||||
if (json.code === 0)
|
||||
instances.value[fileName] = json.data;
|
||||
} catch (e) {
|
||||
console.error("Fetch info error", e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchInstances = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/instances`);
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) return;
|
||||
const list = json.data || [];
|
||||
instanceList.value = list;
|
||||
// 清理已消失实例的 info 缓存
|
||||
for (const key of Object.keys(instances.value)) {
|
||||
if (!list.some((i) => i.file_name === key)) {
|
||||
delete instances.value[key];
|
||||
}
|
||||
}
|
||||
// 默认选中第一个 running 实例
|
||||
if (
|
||||
!selectedInstance.value ||
|
||||
!list.some(
|
||||
(i) => i.file_name === selectedInstance.value,
|
||||
)
|
||||
) {
|
||||
const running = list.find(
|
||||
(i) => i.status === "running",
|
||||
);
|
||||
selectedInstance.value = running
|
||||
? running.file_name
|
||||
: list.length > 0
|
||||
? list[0].file_name
|
||||
: null;
|
||||
}
|
||||
// 拉取 running 实例的详情
|
||||
for (const inst of list) {
|
||||
if (inst.status === "running") {
|
||||
fetchInstanceInfo(inst.file_name);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Fetch instances error", e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConfigList = async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
@@ -2683,9 +2966,10 @@ server = ["quic://1.2.3.4:29872"]
|
||||
|
||||
// 启动流程控制
|
||||
const pollStartStatus = async () => {
|
||||
if (!logFileName.value) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/start/status`,
|
||||
`${API_BASE}/api/start/status?file_name=${encodeURIComponent(logFileName.value)}`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (json.code === 0) {
|
||||
@@ -2699,14 +2983,14 @@ server = ["quic://1.2.3.4:29872"]
|
||||
|
||||
if (startStatus.value === "running") {
|
||||
stopPolling();
|
||||
fetchInfo();
|
||||
fetchInstances();
|
||||
showStartLog.value = false;
|
||||
} else if (
|
||||
startStatus.value === "stopped" &&
|
||||
startLogs.value.length > 0
|
||||
) {
|
||||
stopPolling();
|
||||
fetchInfo();
|
||||
fetchInstances();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -2726,97 +3010,137 @@ server = ["quic://1.2.3.4:29872"]
|
||||
pollStartStatus();
|
||||
};
|
||||
|
||||
const openStartingModal = () => {
|
||||
const openStartLog = (fileName) => {
|
||||
logFileName.value = fileName;
|
||||
startLogs.value = [];
|
||||
startStatus.value = "starting";
|
||||
showStartLog.value = true;
|
||||
startPolling();
|
||||
};
|
||||
|
||||
const toggleVnt = async (selectedFile) => {
|
||||
if (loading.value) return;
|
||||
|
||||
// 停止逻辑
|
||||
if (
|
||||
info.value.status === "running" ||
|
||||
info.value.status === "starting"
|
||||
) {
|
||||
loading.value = true;
|
||||
await fetch(`${API_BASE}/api/stop`, {
|
||||
method: "POST",
|
||||
});
|
||||
loading.value = false;
|
||||
stopPolling();
|
||||
showStartLog.value = false;
|
||||
fetchInfo();
|
||||
return;
|
||||
}
|
||||
|
||||
// 启动逻辑
|
||||
if (!selectedFile) {
|
||||
const startVnt = async (fileName) => {
|
||||
if (!fileName) {
|
||||
alert("请先选择一个配置");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
if (loadingMap.value[fileName]) return;
|
||||
loadingMap.value[fileName] = true;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/start`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
file_name: selectedFile,
|
||||
file_name: fileName,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
loading.value = false;
|
||||
if (json.code !== 0) {
|
||||
alert("启动失败: " + json.msg);
|
||||
return;
|
||||
}
|
||||
openStartingModal();
|
||||
openStartLog(fileName);
|
||||
fetchInstances();
|
||||
} catch (e) {
|
||||
loading.value = false;
|
||||
alert("网络请求失败: " + e.message);
|
||||
} finally {
|
||||
loadingMap.value[fileName] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const stopVnt = async (fileName) => {
|
||||
if (!fileName || loadingMap.value[fileName]) return;
|
||||
loadingMap.value[fileName] = true;
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/stop`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
file_name: fileName,
|
||||
}),
|
||||
});
|
||||
if (logFileName.value === fileName) {
|
||||
stopPolling();
|
||||
showStartLog.value = false;
|
||||
}
|
||||
fetchInstances();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loadingMap.value[fileName] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelStart = async () => {
|
||||
stopPolling();
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/stop`, {
|
||||
method: "POST",
|
||||
});
|
||||
startLogs.value.push("启动已手动取消");
|
||||
} catch (e) {
|
||||
const fileName = logFileName.value;
|
||||
if (fileName) {
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/stop`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
file_name: fileName,
|
||||
}),
|
||||
});
|
||||
startLogs.value.push("启动已手动取消");
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
startStatus.value = "stopped";
|
||||
fetchInfo();
|
||||
fetchInstances();
|
||||
};
|
||||
|
||||
const restartVnt = async (selectedFile) => {
|
||||
if (loading.value) return;
|
||||
if (!selectedFile) {
|
||||
alert("请先选择一个配置");
|
||||
return;
|
||||
// 移除已停止(启动失败残留)的实例条目
|
||||
const dismissInstance = async (fileName) => {
|
||||
if (!fileName || loadingMap.value[fileName]) return;
|
||||
loadingMap.value[fileName] = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/instance?file_name=${encodeURIComponent(fileName)}`,
|
||||
{method: "DELETE"},
|
||||
);
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) {
|
||||
alert("移除失败: " + json.msg);
|
||||
return;
|
||||
}
|
||||
if (logFileName.value === fileName) {
|
||||
stopPolling();
|
||||
showStartLog.value = false;
|
||||
}
|
||||
if (selectedInstance.value === fileName) {
|
||||
selectedInstance.value = null;
|
||||
}
|
||||
fetchInstances();
|
||||
} catch (e) {
|
||||
alert("网络请求失败: " + e.message);
|
||||
} finally {
|
||||
loadingMap.value[fileName] = false;
|
||||
}
|
||||
loading.value = true;
|
||||
};
|
||||
|
||||
const restartVnt = async (fileName) => {
|
||||
if (!fileName || loadingMap.value[fileName]) return;
|
||||
loadingMap.value[fileName] = true;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/restart`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
file_name: selectedFile,
|
||||
file_name: fileName,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
loading.value = false;
|
||||
if (json.code !== 0) {
|
||||
alert("重启失败: " + json.msg);
|
||||
return;
|
||||
}
|
||||
openStartingModal();
|
||||
openStartLog(fileName);
|
||||
fetchInstances();
|
||||
} catch (e) {
|
||||
loading.value = false;
|
||||
alert("网络请求失败: " + e.message);
|
||||
} finally {
|
||||
loadingMap.value[fileName] = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2833,13 +3157,18 @@ server = ["quic://1.2.3.4:29872"]
|
||||
};
|
||||
|
||||
// 依赖注入
|
||||
provide("info", info);
|
||||
provide("instanceList", instanceList);
|
||||
provide("instances", instances);
|
||||
provide("selectedInstance", selectedInstance);
|
||||
provide("isPageVisible", isPageVisible);
|
||||
provide("configList", configList);
|
||||
provide("fetchConfigList", fetchConfigList);
|
||||
provide("toggleVnt", toggleVnt);
|
||||
provide("startVnt", startVnt);
|
||||
provide("stopVnt", stopVnt);
|
||||
provide("restartVnt", restartVnt);
|
||||
provide("loading", loading);
|
||||
provide("dismissInstance", dismissInstance);
|
||||
provide("openStartLog", openStartLog);
|
||||
provide("loadingMap", loadingMap);
|
||||
provide("showPeerTooltip", showPeerTooltip);
|
||||
provide("hidePeerTooltip", hidePeerTooltip);
|
||||
|
||||
@@ -2848,15 +3177,17 @@ server = ["quic://1.2.3.4:29872"]
|
||||
"visibilitychange",
|
||||
visibilityHandler,
|
||||
);
|
||||
await fetchInfo();
|
||||
await fetchInstances();
|
||||
fetchConfigList();
|
||||
if (info.value.status === "starting")
|
||||
openStartingModal();
|
||||
// 页面加载时若有正在启动的实例,恢复其日志弹窗
|
||||
const starting = instanceList.value.find(
|
||||
(i) => i.status === "starting",
|
||||
);
|
||||
if (starting) openStartLog(starting.file_name);
|
||||
|
||||
// 全局轮询 info (状态/IP等)
|
||||
// 全局轮询实例列表及 running 实例详情
|
||||
infoTimer = setInterval(() => {
|
||||
if (info.value.status !== "running") return;
|
||||
if (isPageVisible.value) fetchInfo();
|
||||
if (isPageVisible.value) fetchInstances();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
@@ -2870,13 +3201,20 @@ server = ["quic://1.2.3.4:29872"]
|
||||
});
|
||||
|
||||
return {
|
||||
info,
|
||||
version,
|
||||
runningCount,
|
||||
startingCount,
|
||||
headerStatusText,
|
||||
selectedInfo,
|
||||
selectedConfigName,
|
||||
isServerConnected,
|
||||
serverStatusText,
|
||||
navClass,
|
||||
showStartLog,
|
||||
startStatus,
|
||||
startLogs,
|
||||
logFileName,
|
||||
logConfigName,
|
||||
logContainer,
|
||||
cancelStart,
|
||||
tooltipState,
|
||||
|
||||
Reference in New Issue
Block a user