refactor(proxy): 移除 unhealthy 状态、前端展示失败率替换连接数、命令提示修正

- 移除 ProxyNodeStatus.UNHEALTHY 枚举值,仅保留 online/offline
- 迁移脚本将已有 unhealthy 数据迁移为 offline,upgrade/downgrade 均有幂等性保护
- 前端代理节点列表用失败率列替换连接数列,超过 5% 高亮显示
- 节点列表排序从 updated_at DESC 改为 name ASC
- Rust 端命令行提示从 aether-proxy 改为 ./aether-proxy
This commit is contained in:
fawney19
2026-02-28 14:44:44 +08:00
parent 4b02078b60
commit 87d50052cc
7 changed files with 78 additions and 21 deletions

View File

@@ -136,8 +136,8 @@ async fn run_proxy(config: Config) -> anyhow::Result<()> {
// Skip this check when we ARE the systemd service (INVOCATION_ID is set by systemd). // Skip this check when we ARE the systemd service (INVOCATION_ID is set by systemd).
if std::env::var_os("INVOCATION_ID").is_none() && setup::service::is_service_active() { if std::env::var_os("INVOCATION_ID").is_none() && setup::service::is_service_active() {
eprintln!("Warning: systemd service is already running."); eprintln!("Warning: systemd service is already running.");
eprintln!("Use `aether-proxy stop` to stop it first, or manage via subcommands:"); eprintln!("Use `./aether-proxy stop` to stop it first, or manage via subcommands:");
eprintln!(" aether-proxy status / logs / restart / stop"); eprintln!(" ./aether-proxy status / logs / restart / stop");
std::process::exit(1); std::process::exit(1);
} }

View File

@@ -21,7 +21,7 @@ pub fn install_service(config_path: &Path) -> anyhow::Result<()> {
anyhow::bail!("systemd not available"); anyhow::bail!("systemd not available");
} }
if !is_root() { if !is_root() {
anyhow::bail!("root required, use: sudo aether-proxy setup"); anyhow::bail!("root required, use: sudo ./aether-proxy setup");
} }
let exe_path = std::env::current_exe()?.canonicalize()?; let exe_path = std::env::current_exe()?.canonicalize()?;
@@ -94,11 +94,11 @@ pub fn install_service(config_path: &Path) -> anyhow::Result<()> {
eprintln!(); eprintln!();
eprintln!(" Commands:"); eprintln!(" Commands:");
eprintln!(" aether-proxy status # service status"); eprintln!(" ./aether-proxy status # service status");
eprintln!(" aether-proxy logs # tail logs"); eprintln!(" ./aether-proxy logs # tail logs");
eprintln!(" sudo aether-proxy restart # restart"); eprintln!(" sudo ./aether-proxy restart # restart");
eprintln!(" sudo aether-proxy stop # stop"); eprintln!(" sudo ./aether-proxy stop # stop");
eprintln!(" sudo aether-proxy uninstall # remove service"); eprintln!(" sudo ./aether-proxy uninstall # remove service");
eprintln!(); eprintln!();
Ok(()) Ok(())
@@ -166,7 +166,7 @@ pub fn is_service_active() -> bool {
fn ensure_service_installed() -> anyhow::Result<()> { fn ensure_service_installed() -> anyhow::Result<()> {
if !std::path::Path::new(UNIT_PATH).exists() { if !std::path::Path::new(UNIT_PATH).exists() {
anyhow::bail!("service not installed, run `sudo aether-proxy setup` first"); anyhow::bail!("service not installed, run `sudo ./aether-proxy setup` first");
} }
Ok(()) Ok(())
} }
@@ -174,7 +174,7 @@ fn ensure_service_installed() -> anyhow::Result<()> {
fn ensure_root_and_service() -> anyhow::Result<()> { fn ensure_root_and_service() -> anyhow::Result<()> {
ensure_service_installed()?; ensure_service_installed()?;
if !is_root() { if !is_root() {
anyhow::bail!("root required, use: sudo aether-proxy <command>"); anyhow::bail!("root required, use: sudo ./aether-proxy <command>");
} }
Ok(()) Ok(())
} }

View File

@@ -31,7 +31,31 @@ def _table_exists(table_name: str) -> bool:
return table_name in insp.get_table_names() return table_name in insp.get_table_names()
def _enum_has_value(enum_name: str, value: str) -> bool:
"""检查 PostgreSQL 枚举类型是否包含指定值"""
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM pg_enum e JOIN pg_type t ON e.enumtypid = t.oid"
" WHERE t.typname = :enum_name AND e.enumlabel = :value"
),
{"enum_name": enum_name, "value": value},
)
return result.fetchone() is not None
def upgrade() -> None: def upgrade() -> None:
# proxy_nodes: 将已废弃的 unhealthy 状态迁移为 offline然后从枚举中移除
if _enum_has_value("proxynodestatus", "unhealthy"):
op.execute("UPDATE proxy_nodes SET status = 'offline' WHERE status = 'unhealthy'")
op.execute("ALTER TYPE proxynodestatus RENAME TO proxynodestatus_old")
op.execute("CREATE TYPE proxynodestatus AS ENUM ('online', 'offline')")
op.execute(
"ALTER TABLE proxy_nodes ALTER COLUMN status TYPE proxynodestatus"
" USING status::text::proxynodestatus"
)
op.execute("DROP TYPE proxynodestatus_old")
# proxy_nodes: 新增错误指标字段 # proxy_nodes: 新增错误指标字段
if not _column_exists("proxy_nodes", "failed_requests"): if not _column_exists("proxy_nodes", "failed_requests"):
op.add_column( op.add_column(
@@ -102,6 +126,16 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
# 恢复 proxynodestatus 枚举,加回 unhealthy
if not _enum_has_value("proxynodestatus", "unhealthy"):
op.execute("ALTER TYPE proxynodestatus RENAME TO proxynodestatus_old")
op.execute("CREATE TYPE proxynodestatus AS ENUM ('online', 'unhealthy', 'offline')")
op.execute(
"ALTER TABLE proxy_nodes ALTER COLUMN status TYPE proxynodestatus"
" USING status::text::proxynodestatus"
)
op.execute("DROP TYPE proxynodestatus_old")
if _table_exists("proxy_node_events"): if _table_exists("proxy_node_events"):
op.drop_index(op.f("ix_proxy_node_events_node_id"), table_name="proxy_node_events") op.drop_index(op.f("ix_proxy_node_events_node_id"), table_name="proxy_node_events")
op.drop_index("idx_proxy_node_events_node_created", table_name="proxy_node_events") op.drop_index("idx_proxy_node_events_node_created", table_name="proxy_node_events")

View File

@@ -124,10 +124,10 @@
状态 状态
</TableHead> </TableHead>
<TableHead class="w-[100px] h-12 font-semibold text-center"> <TableHead class="w-[100px] h-12 font-semibold text-center">
连接数 总请求
</TableHead> </TableHead>
<TableHead class="w-[100px] h-12 font-semibold text-center"> <TableHead class="w-[100px] h-12 font-semibold text-center">
总请求 失败率
</TableHead> </TableHead>
<TableHead class="w-[100px] h-12 font-semibold text-center"> <TableHead class="w-[100px] h-12 font-semibold text-center">
延迟 延迟
@@ -181,10 +181,13 @@
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell class="py-4 text-center"> <TableCell class="py-4 text-center">
<span class="text-sm tabular-nums">{{ node.active_connections }}</span> <span class="text-sm tabular-nums">{{ formatNumber(node.total_requests) }}</span>
</TableCell> </TableCell>
<TableCell class="py-4 text-center"> <TableCell class="py-4 text-center">
<span class="text-sm tabular-nums">{{ formatNumber(node.total_requests) }}</span> <span
class="text-sm tabular-nums"
:class="failureRate(node) > 5 ? 'text-destructive font-medium' : ''"
>{{ formatFailureRate(node) }}</span>
</TableCell> </TableCell>
<TableCell class="py-4 text-center"> <TableCell class="py-4 text-center">
<span class="text-sm tabular-nums">{{ node.avg_latency_ms != null ? `${node.avg_latency_ms.toFixed(0)}ms` : '-' }}</span> <span class="text-sm tabular-nums">{{ node.avg_latency_ms != null ? `${node.avg_latency_ms.toFixed(0)}ms` : '-' }}</span>
@@ -301,14 +304,21 @@
{{ statusLabel(node.status) }} {{ statusLabel(node.status) }}
</Badge> </Badge>
</div> </div>
<div class="grid grid-cols-3 gap-2 text-xs text-muted-foreground mb-3"> <div class="grid grid-cols-4 gap-2 text-xs text-muted-foreground mb-3">
<div> <div>
<span class="block text-foreground/60">区域</span> <span class="block text-foreground/60">区域</span>
<span>{{ formatRegion(node.region) }}</span> <span>{{ formatRegion(node.region) }}</span>
</div> </div>
<div> <div>
<span class="block text-foreground/60">连接</span> <span class="block text-foreground/60">总请求</span>
<span class="tabular-nums">{{ node.active_connections }}</span> <span class="tabular-nums">{{ formatNumber(node.total_requests) }}</span>
</div>
<div>
<span class="block text-foreground/60">失败率</span>
<span
class="tabular-nums"
:class="failureRate(node) > 5 ? 'text-destructive font-medium' : ''"
>{{ formatFailureRate(node) }}</span>
</div> </div>
<div> <div>
<span class="block text-foreground/60">延迟</span> <span class="block text-foreground/60">延迟</span>
@@ -980,6 +990,20 @@ function formatTime(iso: string | null) {
return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
} }
function failureRate(node: ProxyNode) {
if (!node.total_requests) return 0
const failed = (node.failed_requests || 0) + (node.dns_failures || 0) + (node.stream_errors || 0)
return (failed / node.total_requests) * 100
}
function formatFailureRate(node: ProxyNode) {
if (!node.total_requests) return '-'
const rate = failureRate(node)
if (rate === 0) return '0%'
if (rate < 0.1) return '<0.1%'
return `${rate.toFixed(1)}%`
}
function nodeAddress(node: ProxyNode) { function nodeAddress(node: ProxyNode) {
if (node.is_manual) return node.proxy_url || `${node.ip}:${node.port}` if (node.is_manual) return node.proxy_url || `${node.ip}:${node.port}`
if (node.tunnel_mode) return node.ip || 'WebSocket Tunnel' if (node.tunnel_mode) return node.ip || 'WebSocket Tunnel'

View File

@@ -172,7 +172,7 @@ async def unregister_proxy_node(request: Request, db: Session = Depends(get_db))
@router.get("") @router.get("")
async def list_proxy_nodes( async def list_proxy_nodes(
request: Request, request: Request,
status: str | None = Query(None, description="按状态筛选online/unhealthy/offline"), status: str | None = Query(None, description="按状态筛选online/offline"),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000), limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_db), db: Session = Depends(get_db),

View File

@@ -224,7 +224,6 @@ class ProxyNodeStatus(PyEnum):
"""代理节点状态""" """代理节点状态"""
ONLINE = "online" ONLINE = "online"
UNHEALTHY = "unhealthy"
OFFLINE = "offline" OFFLINE = "offline"

View File

@@ -392,13 +392,13 @@ class ProxyNodeService:
query = db.query(ProxyNode) query = db.query(ProxyNode)
if status: if status:
normalized = status.strip().lower() normalized = status.strip().lower()
allowed = {"online", "unhealthy", "offline"} allowed = {"online", "offline"}
if normalized not in allowed: if normalized not in allowed:
raise InvalidRequestException(f"status 必须是以下之一: {sorted(allowed)}", "status") raise InvalidRequestException(f"status 必须是以下之一: {sorted(allowed)}", "status")
query = query.filter(ProxyNode.status == ProxyNodeStatus(normalized)) query = query.filter(ProxyNode.status == ProxyNodeStatus(normalized))
total = query.count() total = query.count()
nodes = query.order_by(ProxyNode.updated_at.desc()).offset(skip).limit(limit).all() nodes = query.order_by(ProxyNode.name.asc()).offset(skip).limit(limit).all()
return nodes, total return nodes, total
@staticmethod @staticmethod