D1 免登录改造:App 启动直接进主界面,删 LoginPage/github.rs/OAuth 全套命令、
Sidebar 账户区与 GitHub OAuth 设置弹窗、健康检查 GitHub Token 项
D2 移除发布/导入/PR/CI 入口:删 PublishModal/ImportRepoModal、ProjectCard 的
PR compare 链接与 Actions 状态灯、RepoRegistryModal 同步 tab、CicdModal runs tab、
publish.rs 的 github_create_repo/git_push_to_github
D3 spacesSync 单机化:删 spacesSync/useSpacesSync/SpacesPage/DiscoveryPanel,
拆除 App→Dashboard→ProjectCard 的 spacesJson 链
D4 git_ops 凭据链重写:ssh agent → git credential helper → gitea_instances token
前缀匹配 → default,不再依赖 github_token
保留:server_software GitHub 镜像(Releases 下载加速)、gitea_migrate 迁移入口、
cicd.rs(待 Gitea Actions 改造卡);DB 表不删(R05 migration 兼容)
顺带:修复 servers.rs 两个存量测试的 schema 漂移(测试建表缺 deploy_type 列);
包含会话前未提交的 agent-infra F3(get_agent_health 健康诊断,与 lib.rs/
commands.ts 物理耦合无法拆分提交)
验证:cargo test 53 passed / typecheck 全绿 / vitest passWithNoTests
430 lines
16 KiB
Rust
430 lines
16 KiB
Rust
use rusqlite::{params, Connection};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
// ── 数据结构 ──────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct Server {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub server_host: String,
|
|
pub server_port: Option<String>,
|
|
pub username: Option<String>,
|
|
pub password: Option<String>,
|
|
pub ssh_key_path: Option<String>,
|
|
pub quick_links: Option<String>,
|
|
pub notes: Option<String>,
|
|
pub created_at: Option<String>,
|
|
pub updated_at: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct ProjectServerLink {
|
|
pub project_id: String,
|
|
pub server_id: String,
|
|
pub role: Option<String>,
|
|
pub deploy_type: Option<String>,
|
|
pub deploy_path: Option<String>,
|
|
pub container_name: Option<String>,
|
|
pub start_command: Option<String>,
|
|
pub notes: Option<String>,
|
|
// 关联查询时附带的服务器信息
|
|
pub server_name: Option<String>,
|
|
pub server_host: Option<String>,
|
|
pub server_port: Option<String>,
|
|
pub username: Option<String>,
|
|
pub password: Option<String>,
|
|
pub ssh_key_path: Option<String>,
|
|
pub quick_links: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct SaveServerInput {
|
|
pub name: String,
|
|
pub server_host: String,
|
|
pub server_port: Option<String>,
|
|
pub username: Option<String>,
|
|
pub password: Option<String>,
|
|
pub ssh_key_path: Option<String>,
|
|
pub quick_links: Option<String>,
|
|
pub notes: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct LinkServerInput {
|
|
pub role: Option<String>,
|
|
pub deploy_type: Option<String>,
|
|
pub deploy_path: Option<String>,
|
|
pub container_name: Option<String>,
|
|
pub start_command: Option<String>,
|
|
pub notes: Option<String>,
|
|
}
|
|
|
|
// ── 业务函数 ──────────────────────────────────────────────────────────────────
|
|
|
|
pub fn list_servers(conn: &Connection) -> Vec<Server> {
|
|
let mut stmt = match conn.prepare(
|
|
"SELECT id, name, server_host, server_port, username, password,
|
|
ssh_key_path, quick_links, notes, created_at, updated_at
|
|
FROM servers ORDER BY updated_at DESC",
|
|
) {
|
|
Ok(s) => s,
|
|
Err(_) => return vec![],
|
|
};
|
|
stmt.query_map([], |row| {
|
|
Ok(Server {
|
|
id: row.get(0)?,
|
|
name: row.get(1)?,
|
|
server_host: row.get(2)?,
|
|
server_port: row.get(3)?,
|
|
username: row.get(4)?,
|
|
password: row.get(5)?,
|
|
ssh_key_path: row.get(6)?,
|
|
quick_links: row.get(7)?,
|
|
notes: row.get(8)?,
|
|
created_at: row.get(9)?,
|
|
updated_at: row.get(10)?,
|
|
})
|
|
})
|
|
.map(|iter| iter.filter_map(|r| r.ok()).collect())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn create_server(conn: &Connection, input: &SaveServerInput) -> Result<Server, String> {
|
|
let id = format!("srv-{}", uuid::Uuid::new_v4());
|
|
conn.execute(
|
|
"INSERT INTO servers (id, name, server_host, server_port, username, password,
|
|
ssh_key_path, quick_links, notes)
|
|
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
|
|
params![
|
|
id, input.name, input.server_host, input.server_port,
|
|
input.username, input.password, input.ssh_key_path,
|
|
input.quick_links, input.notes,
|
|
],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
get_server(conn, &id).ok_or_else(|| "创建后查询失败".into())
|
|
}
|
|
|
|
pub fn get_server(conn: &Connection, id: &str) -> Option<Server> {
|
|
conn.query_row(
|
|
"SELECT id, name, server_host, server_port, username, password,
|
|
ssh_key_path, quick_links, notes, created_at, updated_at
|
|
FROM servers WHERE id = ?1",
|
|
params![id],
|
|
|row| {
|
|
Ok(Server {
|
|
id: row.get(0)?,
|
|
name: row.get(1)?,
|
|
server_host: row.get(2)?,
|
|
server_port: row.get(3)?,
|
|
username: row.get(4)?,
|
|
password: row.get(5)?,
|
|
ssh_key_path: row.get(6)?,
|
|
quick_links: row.get(7)?,
|
|
notes: row.get(8)?,
|
|
created_at: row.get(9)?,
|
|
updated_at: row.get(10)?,
|
|
})
|
|
},
|
|
)
|
|
.ok()
|
|
}
|
|
|
|
pub fn update_server(conn: &Connection, id: &str, input: &SaveServerInput) -> Result<Server, String> {
|
|
let rows = conn.execute(
|
|
"UPDATE servers SET
|
|
name = ?1, server_host = ?2, server_port = ?3, username = ?4,
|
|
password = ?5, ssh_key_path = ?6, quick_links = ?7, notes = ?8,
|
|
updated_at = datetime('now')
|
|
WHERE id = ?9",
|
|
params![
|
|
input.name, input.server_host, input.server_port, input.username,
|
|
input.password, input.ssh_key_path, input.quick_links, input.notes,
|
|
id,
|
|
],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
if rows == 0 {
|
|
return Err(format!("服务器 {} 不存在", id));
|
|
}
|
|
get_server(conn, id).ok_or_else(|| "更新后查询失败".into())
|
|
}
|
|
|
|
pub fn delete_server_fn(conn: &Connection, id: &str) -> Result<(), String> {
|
|
conn.execute("DELETE FROM project_server_map WHERE server_id = ?1", params![id])
|
|
.map_err(|e| e.to_string())?;
|
|
// 级联删除服务连接配置 + 宿主机应用
|
|
let _ = super::service_config::delete_configs_by_server(conn, id);
|
|
let _ = super::host_apps::delete_apps_by_server(conn, id);
|
|
conn.execute("DELETE FROM servers WHERE id = ?1", params![id])
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
// ── 关联管理 ──────────────────────────────────────────────────────────────────
|
|
|
|
pub fn link_project_server(
|
|
conn: &Connection,
|
|
project_id: &str,
|
|
server_id: &str,
|
|
input: &LinkServerInput,
|
|
) -> Result<(), String> {
|
|
conn.execute(
|
|
"INSERT INTO project_server_map
|
|
(project_id, server_id, role, deploy_type, deploy_path, container_name, start_command, notes)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
|
ON CONFLICT(project_id, server_id) DO UPDATE SET
|
|
role = ?3, deploy_type = ?4, deploy_path = ?5,
|
|
container_name = ?6, start_command = ?7, notes = ?8",
|
|
params![
|
|
project_id, server_id, input.role, input.deploy_type,
|
|
input.deploy_path, input.container_name, input.start_command, input.notes,
|
|
],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn unlink_project_server(
|
|
conn: &Connection,
|
|
project_id: &str,
|
|
server_id: &str,
|
|
) -> Result<(), String> {
|
|
conn.execute(
|
|
"DELETE FROM project_server_map WHERE project_id = ?1 AND server_id = ?2",
|
|
params![project_id, server_id],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
const LINK_SELECT_SQL: &str =
|
|
"SELECT m.project_id, m.server_id, m.role,
|
|
m.deploy_type, m.deploy_path, m.container_name, m.start_command, m.notes,
|
|
s.name, s.server_host, s.server_port, s.username, s.password,
|
|
s.ssh_key_path, s.quick_links
|
|
FROM project_server_map m
|
|
JOIN servers s ON s.id = m.server_id";
|
|
|
|
fn link_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ProjectServerLink> {
|
|
Ok(ProjectServerLink {
|
|
project_id: row.get(0)?,
|
|
server_id: row.get(1)?,
|
|
role: row.get(2)?,
|
|
deploy_type: row.get(3)?,
|
|
deploy_path: row.get(4)?,
|
|
container_name: row.get(5)?,
|
|
start_command: row.get(6)?,
|
|
notes: row.get(7)?,
|
|
server_name: row.get(8)?,
|
|
server_host: row.get(9)?,
|
|
server_port: row.get(10)?,
|
|
username: row.get(11)?,
|
|
password: row.get(12)?,
|
|
ssh_key_path: row.get(13)?,
|
|
quick_links: row.get(14)?,
|
|
})
|
|
}
|
|
|
|
/// 获取某个项目关联的所有服务器(含服务器详细信息)
|
|
pub fn get_project_servers(conn: &Connection, project_id: &str) -> Vec<ProjectServerLink> {
|
|
let sql = format!("{LINK_SELECT_SQL} WHERE m.project_id = ?1");
|
|
let mut stmt = match conn.prepare(&sql) {
|
|
Ok(s) => s,
|
|
Err(_) => return vec![],
|
|
};
|
|
stmt.query_map(params![project_id], link_from_row)
|
|
.map(|iter| iter.filter_map(|r| r.ok()).collect())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// 获取某台服务器关联的所有项目
|
|
pub fn get_server_projects(conn: &Connection, server_id: &str) -> Vec<ProjectServerLink> {
|
|
let sql = format!("{LINK_SELECT_SQL} WHERE m.server_id = ?1");
|
|
let mut stmt = match conn.prepare(&sql) {
|
|
Ok(s) => s,
|
|
Err(_) => return vec![],
|
|
};
|
|
stmt.query_map(params![server_id], link_from_row)
|
|
.map(|iter| iter.filter_map(|r| r.ok()).collect())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
// ── Tauri command 薄包装层 ────────────────────────────────────────────────────
|
|
|
|
#[tauri::command]
|
|
pub fn get_servers() -> Result<Vec<Server>, String> {
|
|
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
|
Ok(list_servers(&conn))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn add_server(input: SaveServerInput) -> Result<Server, String> {
|
|
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
|
create_server(&conn, &input)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn edit_server(id: String, input: SaveServerInput) -> Result<Server, String> {
|
|
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
|
update_server(&conn, &id, &input)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_server(id: String) -> Result<(), String> {
|
|
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
|
delete_server_fn(&conn, &id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn link_server(project_id: String, server_id: String, input: LinkServerInput) -> Result<(), String> {
|
|
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
|
link_project_server(&conn, &project_id, &server_id, &input)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn unlink_server(project_id: String, server_id: String) -> Result<(), String> {
|
|
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
|
unlink_project_server(&conn, &project_id, &server_id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_project_server_links(project_id: String) -> Result<Vec<ProjectServerLink>, String> {
|
|
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
|
Ok(get_project_servers(&conn, &project_id))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_server_project_links(server_id: String) -> Result<Vec<ProjectServerLink>, String> {
|
|
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
|
|
Ok(get_server_projects(&conn, &server_id))
|
|
}
|
|
|
|
// ── 单元测试 ──────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use rusqlite::Connection;
|
|
|
|
fn setup() -> Connection {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
conn.execute_batch(
|
|
"CREATE TABLE servers (
|
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_host TEXT NOT NULL,
|
|
server_port TEXT DEFAULT '22', username TEXT, password TEXT,
|
|
ssh_key_path TEXT, quick_links TEXT, notes TEXT,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
CREATE TABLE project_server_map (
|
|
project_id TEXT NOT NULL, server_id TEXT NOT NULL,
|
|
role TEXT DEFAULT 'production', deploy_type TEXT DEFAULT 'bare',
|
|
deploy_path TEXT, container_name TEXT, start_command TEXT, notes TEXT,
|
|
PRIMARY KEY (project_id, server_id)
|
|
);",
|
|
)
|
|
.unwrap();
|
|
conn
|
|
}
|
|
|
|
#[test]
|
|
fn test_crud_server() {
|
|
let conn = setup();
|
|
let input = SaveServerInput {
|
|
name: "测试服务器".into(),
|
|
server_host: "192.168.1.100".into(),
|
|
server_port: Some("22".into()),
|
|
username: Some("root".into()),
|
|
password: Some("secret".into()),
|
|
ssh_key_path: None,
|
|
quick_links: None,
|
|
notes: None,
|
|
};
|
|
let srv = create_server(&conn, &input).unwrap();
|
|
assert_eq!(srv.name, "测试服务器");
|
|
assert_eq!(srv.server_host, "192.168.1.100");
|
|
|
|
let all = list_servers(&conn);
|
|
assert_eq!(all.len(), 1);
|
|
|
|
let update = SaveServerInput {
|
|
name: "生产服务器".into(),
|
|
server_host: "10.0.0.1".into(),
|
|
server_port: Some("2222".into()),
|
|
username: Some("deploy".into()),
|
|
password: None,
|
|
ssh_key_path: None,
|
|
quick_links: None,
|
|
notes: Some("已更新".into()),
|
|
};
|
|
let updated = update_server(&conn, &srv.id, &update).unwrap();
|
|
assert_eq!(updated.name, "生产服务器");
|
|
assert_eq!(updated.server_host, "10.0.0.1");
|
|
|
|
delete_server_fn(&conn, &srv.id).unwrap();
|
|
assert!(list_servers(&conn).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_link_and_query() {
|
|
let conn = setup();
|
|
let input = SaveServerInput {
|
|
name: "Web Server".into(),
|
|
server_host: "1.2.3.4".into(),
|
|
server_port: Some("22".into()),
|
|
username: Some("ubuntu".into()),
|
|
password: None,
|
|
ssh_key_path: None,
|
|
quick_links: None,
|
|
notes: None,
|
|
};
|
|
let srv = create_server(&conn, &input).unwrap();
|
|
|
|
let link = LinkServerInput {
|
|
role: Some("production".into()),
|
|
deploy_type: None,
|
|
deploy_path: Some("/var/www/app".into()),
|
|
container_name: None,
|
|
start_command: None,
|
|
notes: None,
|
|
};
|
|
link_project_server(&conn, "proj-1", &srv.id, &link).unwrap();
|
|
link_project_server(&conn, "proj-2", &srv.id, &LinkServerInput {
|
|
role: Some("staging".into()),
|
|
deploy_type: None,
|
|
deploy_path: Some("/var/www/staging".into()),
|
|
container_name: None,
|
|
start_command: None,
|
|
notes: None,
|
|
}).unwrap();
|
|
|
|
let project_links = get_project_servers(&conn, "proj-1");
|
|
assert_eq!(project_links.len(), 1);
|
|
assert_eq!(project_links[0].server_name.as_deref(), Some("Web Server"));
|
|
assert_eq!(project_links[0].deploy_path.as_deref(), Some("/var/www/app"));
|
|
|
|
let server_links = get_server_projects(&conn, &srv.id);
|
|
assert_eq!(server_links.len(), 2);
|
|
|
|
unlink_project_server(&conn, "proj-1", &srv.id).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_delete_server_cascades_links() {
|
|
let conn = setup();
|
|
let srv = create_server(&conn, &SaveServerInput {
|
|
name: "Temp".into(), server_host: "1.1.1.1".into(),
|
|
server_port: None, username: None, password: None,
|
|
ssh_key_path: None, quick_links: None, notes: None,
|
|
}).unwrap();
|
|
link_project_server(&conn, "proj-1", &srv.id, &LinkServerInput {
|
|
role: None, deploy_type: None, deploy_path: None,
|
|
container_name: None, start_command: None, notes: None,
|
|
}).unwrap();
|
|
delete_server_fn(&conn, &srv.id).unwrap();
|
|
assert!(get_project_servers(&conn, "proj-1").is_empty());
|
|
}
|
|
}
|