Initial release: Mihomo management UI with subscription support
Features: - Subscription import/update/delete - Proxy group management - System dashboard - Yacd-meta integration - One-click deployment script
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
*.bak.*
|
||||
/tmp/
|
||||
@@ -0,0 +1,86 @@
|
||||
# OrangePi Mihomo UI 🍊
|
||||
|
||||
轻量级 Mihomo(Clash Meta)管理面板,专为 Orange Pi RV2 设计。
|
||||
|
||||
## ✨ 功能
|
||||
|
||||
- 📋 **订阅管理** - 导入/更新/删除订阅链接
|
||||
- 🌐 **代理切换** - 可视化切换代理组和节点
|
||||
- 📊 **系统概览** - 实时查看 mihomo 运行状态
|
||||
- 📜 **规则查看** - 浏览路由规则
|
||||
- 🔗 **连接监控** - 查看活跃连接
|
||||
- 📝 **日志查看** - 运行日志
|
||||
- 🎨 **Yacd 集成** - 内置 Yacd-meta 高级面板
|
||||
|
||||
## 🚀 一键部署
|
||||
|
||||
```bash
|
||||
git clone http://gitea.gofor.ltd/GoforStudio/OrangePi-Mihomo-UI.git
|
||||
cd OrangePi-Mihomo-UI
|
||||
sudo bash deploy.sh
|
||||
```
|
||||
|
||||
## 📦 手动安装
|
||||
|
||||
### 1. 安装 Mihomo
|
||||
|
||||
```bash
|
||||
# 下载 mihomo (根据架构选择)
|
||||
# riscv64:
|
||||
cp bin/mihomo-riscv64 /usr/local/bin/mihomo
|
||||
chmod +x /usr/local/bin/mihomo
|
||||
|
||||
# 配置文件
|
||||
mkdir -p /etc/mihomo
|
||||
cp config-example.yaml /etc/mihomo/config.yaml
|
||||
```
|
||||
|
||||
### 2. 启动 Mihomo
|
||||
|
||||
```bash
|
||||
mihomo -d /etc/mihomo
|
||||
```
|
||||
|
||||
### 3. 启动 Web UI
|
||||
|
||||
```bash
|
||||
node server.js
|
||||
```
|
||||
|
||||
## 🔧 配置
|
||||
|
||||
环境变量:
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `PORT` | 8899 | Web UI 端口 |
|
||||
| `MIHOMO_API` | http://127.0.0.1:9097 | Mihomo API 地址 |
|
||||
| `MIHOMO_SECRET` | orangepi | Mihomo API 密钥 |
|
||||
| `MIHOMO_CONFIG` | /etc/mihomo/config.yaml | 配置文件路径 |
|
||||
|
||||
## 📁 目录结构
|
||||
|
||||
```
|
||||
├── server.js # Node.js 后端服务
|
||||
├── public/ # Web 前端
|
||||
│ └── index.html
|
||||
├── yacd-meta/ # Yacd-meta 面板
|
||||
├── bin/ # Mihomo 二进制
|
||||
│ └── mihomo-riscv64
|
||||
├── deploy.sh # 一键部署脚本
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 🌐 访问
|
||||
|
||||
部署完成后:
|
||||
|
||||
- **管理面板**: `http://<IP>:8899`
|
||||
- **Yacd 面板**: `http://<IP>:8899/yacd/`
|
||||
- **Mihomo API**: `http://<IP>:9097`
|
||||
|
||||
默认密钥: `orangepi`
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,309 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# OrangePi Mihomo UI - One-Click Deployment Script
|
||||
# Compatible with: Debian/Ubuntu (arm64, riscv64, amd64)
|
||||
# Usage: sudo bash deploy.sh
|
||||
# ============================================================
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
ORANGE='\033[38;5;208m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||
|
||||
echo -e "${ORANGE}"
|
||||
echo " ___ ____ _ _ _ ___ ___ _ _ "
|
||||
echo " / _ \ _ __ __ _ __ _ ___ | _ \| | | \ | |_ \ / _ \| \ | |"
|
||||
echo "| | | | '__/ _\` |/ _\` |/ _ \ | |_) | | | \| | | | | | | \| |"
|
||||
echo "| |_| | | | (_| | (_| | __/ | __/| |___ | |\ | | | |_| | |\ |"
|
||||
echo " \___/|_| \__,_|\__, |\___| |_| |_____| |_| \_|___|\___/|_| \_|"
|
||||
echo " |___/ "
|
||||
echo -e "${NC}"
|
||||
|
||||
# Check root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
error "Please run as root: sudo bash deploy.sh"
|
||||
fi
|
||||
|
||||
# ========== Configuration ==========
|
||||
INSTALL_DIR="/opt/orangepi-mihomo-ui"
|
||||
MIHOMO_DIR="/etc/mihomo"
|
||||
MIHOMO_BIN="/usr/local/bin/mihomo"
|
||||
ARCH=$(uname -m)
|
||||
|
||||
info "Detected architecture: $ARCH"
|
||||
|
||||
# Map architecture
|
||||
case "$ARCH" in
|
||||
x86_64|amd64) MIHOMO_ARCH="linux-amd64"; NODE_ARCH="linux-x64" ;;
|
||||
aarch64|arm64) MIHOMO_ARCH="linux-arm64"; NODE_ARCH="linux-arm64" ;;
|
||||
riscv64) MIHOMO_ARCH="linux-riscv64"; NODE_ARCH="linux-riscv64" ;;
|
||||
armv7l) MIHOMO_ARCH="linux-armv7"; NODE_ARCH="linux-armv7l" ;;
|
||||
*) error "Unsupported architecture: $ARCH" ;;
|
||||
esac
|
||||
|
||||
info "Mihomo target: $MIHOMO_ARCH"
|
||||
|
||||
# ========== Check Dependencies ==========
|
||||
info "Checking dependencies..."
|
||||
|
||||
# Check Node.js
|
||||
if command -v node &>/dev/null; then
|
||||
NODE_VER=$(node --version)
|
||||
info "Node.js found: $NODE_VER"
|
||||
NODE_MAJOR=$(echo "$NODE_VER" | sed 's/v//' | cut -d. -f1)
|
||||
if [ "$NODE_MAJOR" -lt 18 ]; then
|
||||
warn "Node.js version too old, please install v18+"
|
||||
fi
|
||||
else
|
||||
warn "Node.js not found, attempting to install..."
|
||||
if command -v apt &>/dev/null; then
|
||||
apt update && apt install -y nodejs npm
|
||||
elif command -v apk &>/dev/null; then
|
||||
apk add nodejs npm
|
||||
else
|
||||
error "Cannot install Node.js automatically. Please install Node.js 18+ manually."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check npm
|
||||
if ! command -v npm &>/dev/null; then
|
||||
warn "npm not found, installing..."
|
||||
apt install -y npm 2>/dev/null || apk add npm 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Check curl/unzip
|
||||
for cmd in curl unzip; do
|
||||
if ! command -v $cmd &>/dev/null; then
|
||||
info "Installing $cmd..."
|
||||
apt install -y $cmd 2>/dev/null || apk add $cmd 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# ========== Install Mihomo ==========
|
||||
if [ ! -f "$MIHOMO_BIN" ]; then
|
||||
info "Installing Mihomo..."
|
||||
|
||||
# Get latest version
|
||||
MIHOMO_VER=$(curl -sL "https://api.github.com/repos/MetaCubeX/mihomo/releases/latest" | grep '"tag_name"' | sed 's/.*"tag_name": *"//;s/".*//')
|
||||
if [ -z "$MIHOMO_VER" ]; then
|
||||
MIHOMO_VER="v1.19.27"
|
||||
warn "Cannot fetch latest version, using $MIHOMO_VER"
|
||||
fi
|
||||
|
||||
DOWNLOAD_URL="https://github.com/MetaCubeX/mihomo/releases/download/${MIHOMO_VER}/mihomo-${MIHOMO_ARCH}-${MIHOMO_VER}.gz"
|
||||
# Try mirror
|
||||
MIRROR_URL="https://ghfast.top/${DOWNLOAD_URL}"
|
||||
|
||||
info "Downloading Mihomo $MIHOMO_VER for $MIHOMO_ARCH..."
|
||||
curl -L --progress-bar "$MIRROR_URL" -o /tmp/mihomo.gz || \
|
||||
curl -L --progress-bar "$DOWNLOAD_URL" -o /tmp/mihomo.gz || \
|
||||
error "Download failed"
|
||||
|
||||
gunzip -f /tmp/mihomo.gz
|
||||
chmod +x /tmp/mihomo
|
||||
mv /tmp/mihomo "$MIHOMO_BIN"
|
||||
info "Mihomo installed to $MIHOMO_BIN"
|
||||
else
|
||||
info "Mihomo already installed: $(mihomo -v 2>&1 | head -1)"
|
||||
fi
|
||||
|
||||
# ========== Setup Mihomo Config ==========
|
||||
if [ ! -d "$MIHOMO_DIR" ]; then
|
||||
info "Creating mihomo config directory..."
|
||||
mkdir -p "$MIHOMO_DIR"
|
||||
fi
|
||||
|
||||
if [ ! -f "$MIHOMO_DIR/config.yaml" ]; then
|
||||
info "Creating default mihomo config..."
|
||||
cat > "$MIHOMO_DIR/config.yaml" << 'MIHOMO_CONFIG'
|
||||
# OrangePi Mihomo Default Config
|
||||
mixed-port: 7890
|
||||
socks-port: 7891
|
||||
port: 7892
|
||||
redir-port: 7893
|
||||
tproxy-port: 7894
|
||||
allow-lan: true
|
||||
bind-address: "*"
|
||||
mode: rule
|
||||
log-level: info
|
||||
ipv6: false
|
||||
external-controller: 0.0.0.0:9097
|
||||
external-ui: /opt/orangepi-mihomo-ui/yacd-meta
|
||||
secret: "orangepi"
|
||||
|
||||
dns:
|
||||
enable: true
|
||||
listen: 0.0.0.0:1053
|
||||
ipv6: false
|
||||
enhanced-mode: fake-ip
|
||||
fake-ip-range: 198.18.0.1/16
|
||||
fake-ip-filter:
|
||||
- "*.lan"
|
||||
- "*.local"
|
||||
default-nameserver:
|
||||
- 223.5.5.5
|
||||
- 114.114.114.114
|
||||
nameserver:
|
||||
- https://dns.alidns.com/dns-query
|
||||
- https://doh.pub/dns-query
|
||||
fallback:
|
||||
- https://dns.google/dns-query
|
||||
- https://cloudflare-dns.com/dns-query
|
||||
fallback-filter:
|
||||
geoip: true
|
||||
geoip-code: CN
|
||||
ipcidr:
|
||||
- 240.0.0.0/4
|
||||
|
||||
proxies: []
|
||||
|
||||
proxy-groups:
|
||||
- name: "Proxy"
|
||||
type: select
|
||||
proxies:
|
||||
- DIRECT
|
||||
|
||||
rules:
|
||||
- GEOIP,CN,DIRECT
|
||||
- MATCH,Proxy
|
||||
MIHOMO_CONFIG
|
||||
info "Default config created at $MIHOMO_DIR/config.yaml"
|
||||
fi
|
||||
|
||||
# ========== Setup Mihomo Service ==========
|
||||
if [ ! -f /etc/systemd/system/mihomo.service ]; then
|
||||
info "Creating mihomo systemd service..."
|
||||
cat > /etc/systemd/system/mihomo.service << 'EOF'
|
||||
[Unit]
|
||||
Description=mihomo Daemon
|
||||
After=network.target NetworkManager.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
LimitNPROC=500
|
||||
LimitNOFILE=1000000
|
||||
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_TIME CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE
|
||||
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_TIME CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE
|
||||
Restart=always
|
||||
ExecStartPre=/usr/local/bin/mihomo -t -d /etc/mihomo
|
||||
ExecStart=/usr/local/bin/mihomo -d /etc/mihomo
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable mihomo
|
||||
info "Mihomo service created and enabled"
|
||||
fi
|
||||
|
||||
# ========== Deploy Web UI ==========
|
||||
info "Deploying Web UI..."
|
||||
|
||||
# Copy project files
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
warn "Backing up existing installation..."
|
||||
mv "$INSTALL_DIR" "${INSTALL_DIR}.bak.$(date +%s)"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cp -r "$SCRIPT_DIR" "$INSTALL_DIR"
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
# Install Node.js dependencies (if any)
|
||||
if [ -f package.json ]; then
|
||||
info "Installing Node.js dependencies..."
|
||||
npm install --production 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ========== Setup Yacd-meta ==========
|
||||
YACD_DIR="$INSTALL_DIR/yacd-meta"
|
||||
if [ ! -f "$YACD_DIR/index.html" ]; then
|
||||
info "Downloading Yacd-meta..."
|
||||
curl -L "https://ghfast.top/https://github.com/MetaCubeX/Yacd-meta/archive/refs/heads/gh-pages.zip" \
|
||||
-o /tmp/yacd-meta.zip --progress-bar || \
|
||||
curl -L "https://github.com/MetaCubeX/Yacd-meta/archive/refs/heads/gh-pages.zip" \
|
||||
-o /tmp/yacd-meta.zip --progress-bar
|
||||
|
||||
cd /tmp && unzip -o yacd-meta.zip
|
||||
rm -rf "$YACD_DIR"
|
||||
mv /tmp/Yacd-meta-gh-pages "$YACD_DIR"
|
||||
rm -f /tmp/yacd-meta.zip
|
||||
cd "$INSTALL_DIR"
|
||||
info "Yacd-meta deployed"
|
||||
fi
|
||||
|
||||
# ========== Create Web UI Service ==========
|
||||
info "Creating Web UI systemd service..."
|
||||
cat > /etc/systemd/system/orangepi-mihomo-ui.service << EOF
|
||||
[Unit]
|
||||
Description=OrangePi Mihomo Web UI
|
||||
After=network.target mihomo.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
ExecStart=$(which node) server.js
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=PORT=8899
|
||||
Environment=MIHOMO_API=http://127.0.0.1:9097
|
||||
Environment=MIHOMO_SECRET=orangepi
|
||||
Environment=MIHOMO_CONFIG=$MIHOMO_DIR/config.yaml
|
||||
Environment=YACD_PATH=$YACD_DIR
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable orangepi-mihomo-ui
|
||||
|
||||
# ========== Start Services ==========
|
||||
info "Starting services..."
|
||||
|
||||
systemctl restart mihomo
|
||||
sleep 1
|
||||
if systemctl is-active --quiet mihomo; then
|
||||
info "✅ Mihomo started"
|
||||
else
|
||||
warn "Mihomo failed to start, check: journalctl -u mihomo"
|
||||
fi
|
||||
|
||||
systemctl restart orangepi-mihomo-ui
|
||||
sleep 1
|
||||
if systemctl is-active --quiet orangepi-mihomo-ui; then
|
||||
info "✅ Web UI started"
|
||||
else
|
||||
warn "Web UI failed to start, check: journalctl -u orangepi-mihomo-ui"
|
||||
fi
|
||||
|
||||
# ========== Done ==========
|
||||
IP_ADDR=$(hostname -I | awk '{print $1}')
|
||||
echo ""
|
||||
echo -e "${ORANGE}============================================${NC}"
|
||||
echo -e "${GREEN} 🎉 Deployment Complete!${NC}"
|
||||
echo -e "${ORANGE}============================================${NC}"
|
||||
echo ""
|
||||
echo -e " 📊 Web UI: ${GREEN}http://${IP_ADDR}:8899${NC}"
|
||||
echo -e " 🎨 Yacd: ${GREEN}http://${IP_ADDR}:8899/yacd/${NC}"
|
||||
echo -e " 🔧 Mihomo API: ${GREEN}http://${IP_ADDR}:9097${NC}"
|
||||
echo -e " 🔑 Secret: ${YELLOW}orangepi${NC}"
|
||||
echo ""
|
||||
echo -e " 📝 Config: ${MIHOMO_DIR}/config.yaml"
|
||||
echo -e " 📁 Install: ${INSTALL_DIR}"
|
||||
echo ""
|
||||
echo -e " Service commands:"
|
||||
echo -e " systemctl status mihomo"
|
||||
echo -e " systemctl status orangepi-mihomo-ui"
|
||||
echo -e " systemctl restart orangepi-mihomo-ui"
|
||||
echo ""
|
||||
echo -e "${ORANGE}============================================${NC}"
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "orangepi-mihomo-ui",
|
||||
"version": "1.0.0",
|
||||
"description": "Lightweight Mihomo management UI for Orange Pi RV2",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node server.js --dev"
|
||||
},
|
||||
"keywords": ["mihomo", "clash", "proxy", "orangepi"],
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OrangePi Mihomo UI</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🍊</text></svg>">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f172a; --surface: #1e293b; --surface2: #334155;
|
||||
--border: #475569; --text: #e2e8f0; --text2: #94a3b8;
|
||||
--primary: #f97316; --primary-hover: #ea580c;
|
||||
--success: #22c55e; --danger: #ef4444; --warning: #eab308;
|
||||
--radius: 10px;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||
|
||||
/* Layout */
|
||||
.app { display: flex; min-height: 100vh; }
|
||||
.sidebar {
|
||||
width: 220px; background: var(--surface); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; position: fixed; height: 100vh; z-index: 10;
|
||||
}
|
||||
.main { flex: 1; margin-left: 220px; padding: 24px; }
|
||||
|
||||
/* Sidebar */
|
||||
.logo { padding: 20px; text-align: center; border-bottom: 1px solid var(--border); }
|
||||
.logo h1 { font-size: 18px; color: var(--primary); }
|
||||
.logo small { color: var(--text2); font-size: 11px; }
|
||||
.nav { flex: 1; padding: 12px 0; }
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 12px 20px;
|
||||
color: var(--text2); cursor: pointer; transition: all 0.2s; text-decoration: none;
|
||||
}
|
||||
.nav-item:hover, .nav-item.active { background: var(--surface2); color: var(--primary); }
|
||||
.nav-item .icon { font-size: 18px; width: 24px; text-align: center; }
|
||||
.sidebar-footer { padding: 16px; border-top: 1px solid var(--border); }
|
||||
.sidebar-footer small { color: var(--text2); }
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 20px; margin-bottom: 16px;
|
||||
}
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.card-title { font-size: 16px; font-weight: 600; }
|
||||
|
||||
/* Stats */
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 20px; }
|
||||
.stat-card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 16px;
|
||||
}
|
||||
.stat-label { color: var(--text2); font-size: 12px; margin-bottom: 4px; }
|
||||
.stat-value { font-size: 24px; font-weight: 700; color: var(--primary); }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px;
|
||||
border: none; border-radius: 6px; cursor: pointer; font-size: 13px;
|
||||
font-weight: 500; transition: all 0.2s;
|
||||
}
|
||||
.btn-primary { background: var(--primary); color: white; }
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
.btn-success { background: var(--success); color: white; }
|
||||
.btn-danger { background: var(--danger); color: white; }
|
||||
.btn-ghost { background: transparent; color: var(--text2); border: 1px solid var(--border); }
|
||||
.btn-ghost:hover { background: var(--surface2); color: var(--text); }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Input */
|
||||
.input-group { margin-bottom: 12px; }
|
||||
.input-group label { display: block; font-size: 13px; color: var(--text2); margin-bottom: 4px; }
|
||||
input, select, textarea {
|
||||
width: 100%; padding: 10px 12px; background: var(--surface2); border: 1px solid var(--border);
|
||||
border-radius: 6px; color: var(--text); font-size: 14px; outline: none;
|
||||
}
|
||||
input:focus, select:focus { border-color: var(--primary); }
|
||||
textarea { resize: vertical; min-height: 80px; font-family: monospace; }
|
||||
|
||||
/* Table */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
th { color: var(--text2); font-weight: 500; font-size: 12px; text-transform: uppercase; }
|
||||
tr:hover { background: var(--surface2); }
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 10px;
|
||||
font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.badge-success { background: rgba(34,197,94,0.15); color: var(--success); }
|
||||
.badge-danger { background: rgba(239,68,68,0.15); color: var(--danger); }
|
||||
.badge-warning { background: rgba(234,179,8,0.15); color: var(--warning); }
|
||||
|
||||
/* Proxy group */
|
||||
.proxy-group { margin-bottom: 12px; }
|
||||
.proxy-group-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px; background: var(--surface2); border-radius: 6px; cursor: pointer;
|
||||
}
|
||||
.proxy-group-header:hover { background: rgba(249,115,22,0.1); }
|
||||
.proxy-list { display: none; padding: 8px 0; }
|
||||
.proxy-list.show { display: block; }
|
||||
.proxy-item {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 12px; border-radius: 4px; cursor: pointer;
|
||||
}
|
||||
.proxy-item:hover { background: var(--surface2); }
|
||||
.proxy-item.selected { color: var(--primary); }
|
||||
.latency { font-size: 12px; font-weight: 600; }
|
||||
.latency-fast { color: var(--success); }
|
||||
.latency-medium { color: var(--warning); }
|
||||
.latency-slow { color: var(--danger); }
|
||||
.latency-timeout { color: var(--text2); }
|
||||
|
||||
/* Toast */
|
||||
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 100; }
|
||||
.toast {
|
||||
padding: 12px 20px; border-radius: 8px; margin-bottom: 8px;
|
||||
font-size: 13px; animation: slideIn 0.3s ease; min-width: 200px;
|
||||
}
|
||||
.toast-success { background: rgba(34,197,94,0.9); color: white; }
|
||||
.toast-error { background: rgba(239,68,68,0.9); color: white; }
|
||||
.toast-info { background: rgba(59,130,246,0.9); color: white; }
|
||||
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
|
||||
/* Loading */
|
||||
.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid var(--border);
|
||||
border-top-color: var(--primary); border-radius: 50%; animation: spin 0.6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Page */
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
z-index: 50; justify-content: center; align-items: center;
|
||||
}
|
||||
.modal-overlay.show { display: flex; }
|
||||
.modal {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 24px; width: 90%; max-width: 500px;
|
||||
}
|
||||
.modal-title { font-size: 18px; font-weight: 600; margin-bottom: 16px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { display: none; }
|
||||
.main { margin-left: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<!-- Sidebar -->
|
||||
<div class="sidebar">
|
||||
<div class="logo">
|
||||
<h1>🍊 OrangePi</h1>
|
||||
<small>Mihomo UI</small>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<a class="nav-item active" onclick="showPage('dashboard')" href="#dashboard">
|
||||
<span class="icon">📊</span> 概览
|
||||
</a>
|
||||
<a class="nav-item" onclick="showPage('subscriptions')" href="#subscriptions">
|
||||
<span class="icon">📋</span> 订阅管理
|
||||
</a>
|
||||
<a class="nav-item" onclick="showPage('proxies')" href="#proxies">
|
||||
<span class="icon">🌐</span> 代理节点
|
||||
</a>
|
||||
<a class="nav-item" onclick="showPage('rules')" href="#rules">
|
||||
<span class="icon">📜</span> 规则
|
||||
</a>
|
||||
<a class="nav-item" onclick="showPage('connections')" href="#connections">
|
||||
<span class="icon">🔗</span> 连接
|
||||
</a>
|
||||
<a class="nav-item" onclick="showPage('logs')" href="#logs">
|
||||
<span class="icon">📝</span> 日志
|
||||
</a>
|
||||
<a class="nav-item" href="/yacd/" target="_blank">
|
||||
<span class="icon">🎨</span> Yacd 面板
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<small id="version-info">v1.0.0</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main">
|
||||
<!-- Dashboard Page -->
|
||||
<div id="page-dashboard" class="page active">
|
||||
<h2 style="margin-bottom:20px">📊 系统概览</h2>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Mihomo 版本</div>
|
||||
<div class="stat-value" id="stat-version">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">代理节点数</div>
|
||||
<div class="stat-value" id="stat-proxies">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">运行模式</div>
|
||||
<div class="stat-value" id="stat-mode">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">订阅数量</div>
|
||||
<div class="stat-value" id="stat-subs">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">快捷操作</span>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="reloadConfig()">🔄 重载配置</button>
|
||||
<a class="btn btn-ghost" href="/yacd/" target="_blank" style="margin-left:8px">🎨 打开 Yacd 面板</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subscriptions Page -->
|
||||
<div id="page-subscriptions" class="page">
|
||||
<h2 style="margin-bottom:20px">📋 订阅管理</h2>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">导入订阅</span>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>订阅链接</label>
|
||||
<input type="text" id="sub-url" placeholder="https://example.com/api/v1/client/subscribe?token=...">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>备注名称(可选)</label>
|
||||
<input type="text" id="sub-name" placeholder="我的订阅">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="importSubscription()">📥 导入并应用</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">已保存的订阅</span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="loadSubscriptions()">🔄 刷新</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>链接</th><th>添加时间</th><th>更新时间</th><th>操作</th></tr></thead>
|
||||
<tbody id="sub-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="sub-empty" style="text-align:center;color:var(--text2);padding:20px;display:none">
|
||||
暂无订阅,请导入订阅链接
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proxies Page -->
|
||||
<div id="page-proxies" class="page">
|
||||
<h2 style="margin-bottom:20px">🌐 代理节点</h2>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">代理组</span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="loadProxies()">🔄 刷新</button>
|
||||
</div>
|
||||
<div id="proxy-groups"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rules Page -->
|
||||
<div id="page-rules" class="page">
|
||||
<h2 style="margin-bottom:20px">📜 规则</h2>
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>类型</th><th>匹配内容</th><th>目标策略</th></tr></thead>
|
||||
<tbody id="rule-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connections Page -->
|
||||
<div id="page-connections" class="page">
|
||||
<h2 style="margin-bottom:20px">🔗 活跃连接</h2>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">连接列表</span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="loadConnections()">🔄 刷新</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>来源</th><th>目标</th><th>规则</th><th>代理</th><th>上传</th><th>下载</th></tr></thead>
|
||||
<tbody id="conn-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs Page -->
|
||||
<div id="page-logs" class="page">
|
||||
<h2 style="margin-bottom:20px">📝 日志</h2>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">运行日志</span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="loadLogs()">🔄 刷新</button>
|
||||
</div>
|
||||
<div id="log-list" style="font-family:monospace;font-size:12px;max-height:500px;overflow-y:auto;background:var(--bg);padding:12px;border-radius:6px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div class="toast-container" id="toasts"></div>
|
||||
|
||||
<script>
|
||||
// ========== Utilities ==========
|
||||
function toast(msg, type = 'info') {
|
||||
const el = document.createElement('div');
|
||||
el.className = `toast toast-${type}`;
|
||||
el.textContent = msg;
|
||||
document.getElementById('toasts').appendChild(el);
|
||||
setTimeout(() => el.remove(), 3000);
|
||||
}
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(path, opts);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024, sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return '-';
|
||||
return new Date(iso).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function latencyClass(ms) {
|
||||
if (!ms || ms < 0) return 'latency-timeout';
|
||||
if (ms < 200) return 'latency-fast';
|
||||
if (ms < 500) return 'latency-medium';
|
||||
return 'latency-slow';
|
||||
}
|
||||
|
||||
// ========== Navigation ==========
|
||||
function showPage(name) {
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
document.getElementById('page-' + name).classList.add('active');
|
||||
event && event.target && event.target.closest('.nav-item')?.classList.add('active');
|
||||
|
||||
// Load data for page
|
||||
switch(name) {
|
||||
case 'dashboard': loadDashboard(); break;
|
||||
case 'subscriptions': loadSubscriptions(); break;
|
||||
case 'proxies': loadProxies(); break;
|
||||
case 'rules': loadRules(); break;
|
||||
case 'connections': loadConnections(); break;
|
||||
case 'logs': loadLogs(); break;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Dashboard ==========
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const [status, subs] = await Promise.all([
|
||||
api('GET', '/api/status'),
|
||||
api('GET', '/api/subscriptions'),
|
||||
]);
|
||||
if (status.error) throw new Error(status.error);
|
||||
document.getElementById('stat-version').textContent = status.version?.version || '-';
|
||||
document.getElementById('stat-proxies').textContent = status.proxyCount || 0;
|
||||
document.getElementById('stat-mode').textContent = status.config?.mode || '-';
|
||||
document.getElementById('stat-subs').textContent = subs.length || 0;
|
||||
document.getElementById('version-info').textContent = 'v' + (status.version?.version || '?');
|
||||
} catch(e) {
|
||||
toast('无法连接 mihomo: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadConfig() {
|
||||
try {
|
||||
const res = await api('POST', '/api/config/reload');
|
||||
if (res.error) throw new Error(res.error);
|
||||
toast('配置已重载', 'success');
|
||||
} catch(e) { toast('重载失败: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ========== Subscriptions ==========
|
||||
async function importSubscription() {
|
||||
const url = document.getElementById('sub-url').value.trim();
|
||||
if (!url) return toast('请输入订阅链接', 'error');
|
||||
|
||||
try {
|
||||
toast('正在获取订阅...', 'info');
|
||||
// First fetch and validate
|
||||
const res = await api('POST', '/api/subscription/import', { url });
|
||||
if (res.error) throw new Error(res.error);
|
||||
toast('订阅获取成功,正在应用...', 'info');
|
||||
|
||||
// Then apply to mihomo
|
||||
const applyRes = await api('POST', '/api/subscription/apply', { url });
|
||||
if (applyRes.error) throw new Error(applyRes.error);
|
||||
|
||||
toast('🎉 订阅导入并应用成功!', 'success');
|
||||
document.getElementById('sub-url').value = '';
|
||||
document.getElementById('sub-name').value = '';
|
||||
loadSubscriptions();
|
||||
} catch(e) {
|
||||
toast('导入失败: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubscriptions() {
|
||||
try {
|
||||
const subs = await api('GET', '/api/subscriptions');
|
||||
const tbody = document.getElementById('sub-list');
|
||||
const empty = document.getElementById('sub-empty');
|
||||
|
||||
if (subs.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
tbody.innerHTML = subs.map(s => `
|
||||
<tr>
|
||||
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${s.url}">${s.url}</td>
|
||||
<td>${formatTime(s.addedAt)}</td>
|
||||
<td>${formatTime(s.updatedAt)}</td>
|
||||
<td>
|
||||
<button class="btn btn-primary btn-sm" onclick="refreshSub('${s.url}')">🔄 更新</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteSub('${s.url}')">🗑️ 删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} catch(e) { toast('加载失败: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function refreshSub(url) {
|
||||
try {
|
||||
toast('正在更新订阅...', 'info');
|
||||
const res = await api('POST', '/api/subscription/apply', { url });
|
||||
if (res.error) throw new Error(res.error);
|
||||
toast('订阅更新成功!', 'success');
|
||||
loadSubscriptions();
|
||||
} catch(e) { toast('更新失败: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteSub(url) {
|
||||
if (!confirm('确定删除此订阅?')) return;
|
||||
try {
|
||||
await api('DELETE', '/api/subscription', { url });
|
||||
toast('已删除', 'success');
|
||||
loadSubscriptions();
|
||||
} catch(e) { toast('删除失败', 'error'); }
|
||||
}
|
||||
|
||||
// ========== Proxies ==========
|
||||
async function loadProxies() {
|
||||
try {
|
||||
const groups = await api('GET', '/api/proxy-groups');
|
||||
const container = document.getElementById('proxy-groups');
|
||||
container.innerHTML = '';
|
||||
|
||||
for (const [name, group] of Object.entries(groups)) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'proxy-group';
|
||||
div.innerHTML = `
|
||||
<div class="proxy-group-header" onclick="this.nextElementSibling.classList.toggle('show')">
|
||||
<span><strong>${name}</strong> (${group.type}) → <em style="color:var(--primary)">${group.now || 'DIRECT'}</em></span>
|
||||
<span>▾</span>
|
||||
</div>
|
||||
<div class="proxy-list">
|
||||
${(group.all || []).map(p => `
|
||||
<div class="proxy-item ${p === group.now ? 'selected' : ''}"
|
||||
onclick="switchProxy('${name}', '${p}')">
|
||||
<span>${p === group.now ? '⊙ ' : ''}${p}</span>
|
||||
<span class="latency" id="latency-${name}-${p}"></span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
}
|
||||
} catch(e) { toast('加载代理失败: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function switchProxy(group, proxy) {
|
||||
try {
|
||||
await api('PUT', `/api/proxy-group/${encodeURIComponent(group)}`, { name: proxy });
|
||||
toast(`${group} → ${proxy}`, 'success');
|
||||
loadProxies();
|
||||
} catch(e) { toast('切换失败: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ========== Rules ==========
|
||||
async function loadRules() {
|
||||
try {
|
||||
const data = await api('GET', '/api/rules');
|
||||
const tbody = document.getElementById('rule-list');
|
||||
tbody.innerHTML = (data.rules || []).map(r => `
|
||||
<tr>
|
||||
<td><span class="badge badge-warning">${r.type}</span></td>
|
||||
<td>${r.payload || '-'}</td>
|
||||
<td>${r.proxy || '-'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} catch(e) { toast('加载规则失败', 'error'); }
|
||||
}
|
||||
|
||||
// ========== Connections ==========
|
||||
async function loadConnections() {
|
||||
try {
|
||||
const data = await api('GET', '/api/connections');
|
||||
const tbody = document.getElementById('conn-list');
|
||||
tbody.innerHTML = (data.connections || []).map(c => `
|
||||
<tr>
|
||||
<td>${c.metadata?.sourceIP || '-'}:${c.metadata?.sourcePort || '-'}</td>
|
||||
<td>${c.metadata?.destinationIP || c.metadata?.host || '-'}:${c.metadata?.destinationPort || '-'}</td>
|
||||
<td>${c.rule || '-'}</td>
|
||||
<td>${c.chains?.[0] || '-'}</td>
|
||||
<td>${formatBytes(c.upload || 0)}</td>
|
||||
<td>${formatBytes(c.download || 0)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} catch(e) { toast('加载连接失败', 'error'); }
|
||||
}
|
||||
|
||||
// ========== Logs ==========
|
||||
async function loadLogs() {
|
||||
try {
|
||||
const res = await fetch('/api/logs');
|
||||
const text = await res.text();
|
||||
const container = document.getElementById('log-list');
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
container.innerHTML = (data.logs || []).map(l =>
|
||||
`<div style="color:${l.type==='error'?'var(--danger)':l.type==='warning'?'var(--warning)':'var(--text2)'}">[${l.time}] [${l.type}] ${l.payload}</div>`
|
||||
).join('');
|
||||
} catch {
|
||||
container.textContent = text;
|
||||
}
|
||||
} catch(e) { toast('加载日志失败', 'error'); }
|
||||
}
|
||||
|
||||
// ========== Init ==========
|
||||
window.addEventListener('load', () => {
|
||||
loadDashboard();
|
||||
// Handle hash navigation
|
||||
const hash = location.hash.replace('#', '');
|
||||
if (hash) showPage(hash);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,498 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* OrangePi Mihomo UI - Lightweight Mihomo Management Server
|
||||
* Supports: subscription import, proxy management, config reload
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { URL } = require('url');
|
||||
const { execSync, exec } = require('child_process');
|
||||
|
||||
// ========== Configuration ==========
|
||||
const CONFIG = {
|
||||
port: parseInt(process.env.PORT || '8899'),
|
||||
mihomoApi: process.env.MIHOMO_API || 'http://127.0.0.1:9097',
|
||||
mihomoSecret: process.env.MIHOMO_SECRET || 'orangepi',
|
||||
mihomoConfig: process.env.MIHOMO_CONFIG || '/etc/mihomo/config.yaml',
|
||||
yacdPath: process.env.YACD_PATH || path.join(__dirname, 'yacd-meta'),
|
||||
};
|
||||
|
||||
// ========== Utility Functions ==========
|
||||
function fetchUrl(url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mod = url.startsWith('https') ? https : http;
|
||||
const req = mod.get(url, { timeout: 15000, ...options }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
return fetchUrl(res.headers.location, options).then(resolve).catch(reject);
|
||||
}
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => resolve({
|
||||
status: res.statusCode,
|
||||
headers: res.headers,
|
||||
body: Buffer.concat(chunks),
|
||||
}));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
||||
});
|
||||
}
|
||||
|
||||
function mihomoApi(method, apiPath, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(apiPath, CONFIG.mihomoApi);
|
||||
const postData = body ? JSON.stringify(body) : null;
|
||||
const opts = {
|
||||
method,
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname + url.search,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${CONFIG.mihomoSecret}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 10000,
|
||||
};
|
||||
const mod = url.protocol === 'https:' ? https : http;
|
||||
const req = mod.request(opts, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString();
|
||||
try { resolve({ status: res.statusCode, data: JSON.parse(raw) }); }
|
||||
catch { resolve({ status: res.statusCode, data: raw }); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
||||
if (postData) req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res, data, status = 200) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function sendError(res, msg, status = 500) {
|
||||
sendJson(res, { error: msg }, status);
|
||||
}
|
||||
|
||||
// ========== Static File Server ==========
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.ico': 'image/x-icon',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
};
|
||||
|
||||
function serveStatic(res, filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mime = MIME_TYPES[ext] || 'application/octet-stream';
|
||||
try {
|
||||
const data = fs.readFileSync(filePath);
|
||||
res.writeHead(200, { 'Content-Type': mime });
|
||||
res.end(data);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Subscription Management ==========
|
||||
function parseSubscription(content) {
|
||||
// Parse YAML-like subscription content (base64 decoded)
|
||||
// Supports: clash, v2ray, ss, ssr formats
|
||||
const lines = content.trim().split('\n');
|
||||
const proxies = [];
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-') || trimmed.startsWith('port:') ||
|
||||
trimmed.startsWith('socks-port:') || trimmed.startsWith('mixed-port:') || trimmed.startsWith('allow-lan:') ||
|
||||
trimmed.startsWith('mode:') || trimmed.startsWith('log-level:') || trimmed.startsWith('external-controller:') ||
|
||||
trimmed.startsWith('dns:') || trimmed.startsWith('proxies:') || trimmed.startsWith('proxy-groups:') ||
|
||||
trimmed.startsWith('rules:') || trimmed.startsWith(' ')) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return proxies;
|
||||
}
|
||||
|
||||
function mergeProxies(existingConfig, newProxies) {
|
||||
// Merge new proxies into existing config
|
||||
const lines = existingConfig.split('\n');
|
||||
let insertIdx = -1;
|
||||
let inProxies = false;
|
||||
let proxyEndIdx = -1;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.match(/^proxies:\s*$/)) {
|
||||
inProxies = true;
|
||||
insertIdx = i + 1;
|
||||
continue;
|
||||
}
|
||||
if (inProxies && !line.startsWith(' ') && !line.startsWith('- ') && line.trim() !== '' && !line.match(/^\s*$/)) {
|
||||
proxyEndIdx = i;
|
||||
inProxies = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (insertIdx === -1) {
|
||||
// Add proxies section before rules
|
||||
const rulesIdx = lines.findIndex(l => l.match(/^rules:/));
|
||||
if (rulesIdx !== -1) {
|
||||
lines.splice(rulesIdx, 0, '\nproxies:', ...newProxies.map(p => ` ${p}`));
|
||||
} else {
|
||||
lines.push('\nproxies:', ...newProxies.map(p => ` ${p}`));
|
||||
}
|
||||
} else {
|
||||
// Replace existing proxies
|
||||
if (proxyEndIdx === -1) proxyEndIdx = insertIdx;
|
||||
lines.splice(insertIdx, proxyEndIdx - insertIdx, ...newProxies.map(p => ` ${p}`));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ========== API Routes ==========
|
||||
async function handleApi(req, res, apiPath) {
|
||||
const method = req.method;
|
||||
|
||||
// GET /api/status - System status
|
||||
if (apiPath === '/api/status' && method === 'GET') {
|
||||
try {
|
||||
const [version, configs, proxies] = await Promise.all([
|
||||
mihomoApi('GET', '/version'),
|
||||
mihomoApi('GET', '/configs'),
|
||||
mihomoApi('GET', '/proxies'),
|
||||
]);
|
||||
return sendJson(res, {
|
||||
version: version.data,
|
||||
config: configs.data,
|
||||
proxyCount: proxies.data ? Object.keys(proxies.data.proxies || {}).length : 0,
|
||||
});
|
||||
} catch (e) {
|
||||
return sendError(res, 'Cannot connect to mihomo: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/proxies - List all proxies
|
||||
if (apiPath === '/api/proxies' && method === 'GET') {
|
||||
try {
|
||||
const result = await mihomoApi('GET', '/proxies');
|
||||
return sendJson(res, result.data);
|
||||
} catch (e) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/proxy-groups - List proxy groups
|
||||
if (apiPath === '/api/proxy-groups' && method === 'GET') {
|
||||
try {
|
||||
const result = await mihomoApi('GET', '/proxies');
|
||||
const proxies = result.data.proxies || {};
|
||||
const groups = {};
|
||||
for (const [name, proxy] of Object.entries(proxies)) {
|
||||
if (proxy.type !== 'Direct' && proxy.type !== 'Reject' && proxy.all && proxy.all.length > 0) {
|
||||
groups[name] = proxy;
|
||||
}
|
||||
}
|
||||
return sendJson(res, groups);
|
||||
} catch (e) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/proxy-group/:name - Switch proxy group selection
|
||||
if (apiPath.startsWith('/api/proxy-group/') && method === 'PUT') {
|
||||
const groupName = decodeURIComponent(apiPath.replace('/api/proxy-group/', ''));
|
||||
const body = JSON.parse(await readBody(req));
|
||||
try {
|
||||
await mihomoApi('PUT', `/proxies/${encodeURIComponent(groupName)}`, { name: body.name });
|
||||
return sendJson(res, { ok: true });
|
||||
} catch (e) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/delay/:name - Test proxy latency
|
||||
if (apiPath.startsWith('/api/delay/') && method === 'GET') {
|
||||
const proxyName = decodeURIComponent(apiPath.replace('/api/delay/', ''));
|
||||
try {
|
||||
const result = await mihomoApi('GET', `/proxies/${encodeURIComponent(proxyName)}/delay?timeout=5000&url=http://www.gstatic.com/generate_204`);
|
||||
return sendJson(res, result.data);
|
||||
} catch (e) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/subscription/import - Import subscription
|
||||
if (apiPath === '/api/subscription/import' && method === 'POST') {
|
||||
const body = JSON.parse(await readBody(req));
|
||||
const { url } = body;
|
||||
if (!url) return sendError(res, 'Missing url', 400);
|
||||
|
||||
try {
|
||||
// Fetch subscription
|
||||
const subRes = await fetchUrl(url);
|
||||
if (subRes.status !== 200) return sendError(res, `HTTP ${subRes.status}`);
|
||||
|
||||
// Try base64 decode
|
||||
let content = subRes.body.toString('utf-8');
|
||||
try {
|
||||
const decoded = Buffer.from(content.trim(), 'base64').toString('utf-8');
|
||||
if (decoded.includes('://') || decoded.includes('proxies')) content = decoded;
|
||||
} catch {}
|
||||
|
||||
// Save to temp file
|
||||
const tempFile = '/tmp/mihomo-sub.yaml';
|
||||
fs.writeFileSync(tempFile, content);
|
||||
|
||||
// Save subscription URL for future updates
|
||||
const subFile = '/etc/mihomo/subscriptions.json';
|
||||
let subscriptions = [];
|
||||
try { subscriptions = JSON.parse(fs.readFileSync(subFile, 'utf-8')); } catch {}
|
||||
const existing = subscriptions.find(s => s.url === url);
|
||||
if (existing) {
|
||||
existing.updatedAt = new Date().toISOString();
|
||||
} else {
|
||||
subscriptions.push({ url, addedAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
|
||||
}
|
||||
fs.writeFileSync(subFile, JSON.stringify(subscriptions, null, 2));
|
||||
|
||||
return sendJson(res, {
|
||||
ok: true,
|
||||
message: 'Subscription fetched successfully',
|
||||
preview: content.substring(0, 500),
|
||||
size: content.length,
|
||||
});
|
||||
} catch (e) {
|
||||
return sendError(res, 'Failed to fetch subscription: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/subscription/apply - Apply subscription to mihomo config
|
||||
if (apiPath === '/api/subscription/apply' && method === 'POST') {
|
||||
const body = JSON.parse(await readBody(req));
|
||||
const { url, proxyGroup } = body;
|
||||
if (!url) return sendError(res, 'Missing url', 400);
|
||||
|
||||
try {
|
||||
const subRes = await fetchUrl(url);
|
||||
if (subRes.status !== 200) return sendError(res, `HTTP ${subRes.status}`);
|
||||
|
||||
let content = subRes.body.toString('utf-8');
|
||||
try {
|
||||
const decoded = Buffer.from(content.trim(), 'base64').toString('utf-8');
|
||||
if (decoded.includes('://') || decoded.includes('proxies')) content = decoded;
|
||||
} catch {}
|
||||
|
||||
// Write to mihomo config directory
|
||||
const subConfigPath = path.join(path.dirname(CONFIG.mihomoConfig), 'sub-config.yaml');
|
||||
fs.writeFileSync(subConfigPath, content);
|
||||
|
||||
// Update main config to include subscription
|
||||
let mainConfig = fs.readFileSync(CONFIG.mihomoConfig, 'utf-8');
|
||||
|
||||
// Add include for subscription config if not exists
|
||||
if (!mainConfig.includes('sub-config.yaml')) {
|
||||
const proxyGroupsIdx = mainConfig.indexOf('proxy-groups:');
|
||||
if (proxyGroupsIdx !== -1) {
|
||||
// Add proxy include before proxy-groups
|
||||
mainConfig = mainConfig.slice(0, proxyGroupsIdx) +
|
||||
'# Subscription proxies loaded from sub-config.yaml\n' +
|
||||
mainConfig.slice(proxyGroupsIdx);
|
||||
}
|
||||
}
|
||||
|
||||
// Reload mihomo config
|
||||
await mihomoApi('PUT', '/configs', { path: CONFIG.mihomoConfig });
|
||||
|
||||
// Save subscription URL
|
||||
const subFile = '/etc/mihomo/subscriptions.json';
|
||||
let subscriptions = [];
|
||||
try { subscriptions = JSON.parse(fs.readFileSync(subFile, 'utf-8')); } catch {}
|
||||
const existing = subscriptions.find(s => s.url === url);
|
||||
if (existing) {
|
||||
existing.updatedAt = new Date().toISOString();
|
||||
} else {
|
||||
subscriptions.push({ url, addedAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
|
||||
}
|
||||
fs.writeFileSync(subFile, JSON.stringify(subscriptions, null, 2));
|
||||
|
||||
return sendJson(res, { ok: true, message: 'Subscription applied and config reloaded' });
|
||||
} catch (e) {
|
||||
return sendError(res, 'Failed to apply subscription: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/subscriptions - List saved subscriptions
|
||||
if (apiPath === '/api/subscriptions' && method === 'GET') {
|
||||
const subFile = '/etc/mihomo/subscriptions.json';
|
||||
try {
|
||||
const subs = JSON.parse(fs.readFileSync(subFile, 'utf-8'));
|
||||
return sendJson(res, subs);
|
||||
} catch {
|
||||
return sendJson(res, []);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/subscription - Remove subscription
|
||||
if (apiPath === '/api/subscription' && method === 'DELETE') {
|
||||
const body = JSON.parse(await readBody(req));
|
||||
const subFile = '/etc/mihomo/subscriptions.json';
|
||||
try {
|
||||
let subs = JSON.parse(fs.readFileSync(subFile, 'utf-8'));
|
||||
subs = subs.filter(s => s.url !== body.url);
|
||||
fs.writeFileSync(subFile, JSON.stringify(subs, null, 2));
|
||||
return sendJson(res, { ok: true });
|
||||
} catch {
|
||||
return sendError(res, 'Failed');
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/config/reload - Reload mihomo config
|
||||
if (apiPath === '/api/config/reload' && method === 'POST') {
|
||||
try {
|
||||
await mihomoApi('PUT', '/configs', { path: CONFIG.mihomoConfig });
|
||||
return sendJson(res, { ok: true });
|
||||
} catch (e) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/config - Get current config
|
||||
if (apiPath === '/api/config' && method === 'GET') {
|
||||
try {
|
||||
const result = await mihomoApi('GET', '/configs');
|
||||
return sendJson(res, result.data);
|
||||
} catch (e) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/rules - Get rules
|
||||
if (apiPath === '/api/rules' && method === 'GET') {
|
||||
try {
|
||||
const result = await mihomoApi('GET', '/rules');
|
||||
return sendJson(res, result.data);
|
||||
} catch (e) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/connections - Get connections
|
||||
if (apiPath === '/api/connections' && method === 'GET') {
|
||||
try {
|
||||
const result = await mihomoApi('GET', '/connections');
|
||||
return sendJson(res, result.data);
|
||||
} catch (e) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/logs - Get logs
|
||||
if (apiPath === '/api/logs' && method === 'GET') {
|
||||
try {
|
||||
const result = await mihomoApi('GET', '/logs');
|
||||
return sendJson(res, result.data);
|
||||
} catch (e) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/traffic - Get traffic stream (SSE)
|
||||
if (apiPath === '/api/traffic' && method === 'GET') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
});
|
||||
const url = new URL('/traffic', CONFIG.mihomoApi);
|
||||
const mod = url.protocol === 'https:' ? https : http;
|
||||
const proxyReq = mod.get({
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
headers: { 'Authorization': `Bearer ${CONFIG.mihomoSecret}` },
|
||||
}, (proxyRes) => {
|
||||
proxyRes.on('data', (chunk) => {
|
||||
res.write(chunk);
|
||||
});
|
||||
proxyRes.on('end', () => res.end());
|
||||
});
|
||||
proxyReq.on('error', () => res.end());
|
||||
req.on('close', () => proxyReq.destroy());
|
||||
return;
|
||||
}
|
||||
|
||||
sendError(res, 'Not found', 404);
|
||||
}
|
||||
|
||||
// ========== Main Server ==========
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const pathname = url.pathname;
|
||||
|
||||
// CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
// API routes
|
||||
if (pathname.startsWith('/api/')) {
|
||||
try {
|
||||
await handleApi(req, res, pathname);
|
||||
} catch (e) {
|
||||
sendError(res, e.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve Yacd-meta
|
||||
if (pathname.startsWith('/yacd/')) {
|
||||
const yacdFile = pathname.replace('/yacd/', '') || 'index.html';
|
||||
const filePath = path.join(CONFIG.yacdPath, yacdFile);
|
||||
return serveStatic(res, filePath);
|
||||
}
|
||||
|
||||
// Serve static files (our UI)
|
||||
let filePath = path.join(__dirname, 'public', pathname === '/' ? 'index.html' : pathname);
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(filePath, 'index.html');
|
||||
}
|
||||
serveStatic(res, filePath);
|
||||
});
|
||||
|
||||
server.listen(CONFIG.port, '0.0.0.0', () => {
|
||||
console.log(`\n🍊 OrangePi Mihomo UI`);
|
||||
console.log(` Dashboard: http://0.0.0.0:${CONFIG.port}`);
|
||||
console.log(` Yacd-meta: http://0.0.0.0:${CONFIG.port}/yacd/`);
|
||||
console.log(` Mihomo API: ${CONFIG.mihomoApi}`);
|
||||
console.log(` Config: ${CONFIG.mihomoConfig}\n`);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
yacd.metacubex.one
|
||||
Binary file not shown.
@@ -0,0 +1,12 @@
|
||||
# for netlify hosting
|
||||
# https://docs.netlify.com/routing/headers/#syntax-for-the-headers-file
|
||||
|
||||
/*
|
||||
X-Frame-Options: DENY
|
||||
X-XSS-Protection: 1; mode=block
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: same-origin
|
||||
/*.css
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
/*.js
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
const e={All:"All",Overview:"Overview",Proxies:"Proxies",Rules:"Rules",Conns:"Conns",Config:"Config",Logs:"Logs",Upload:"Upload",Download:"Download","Upload Total":"Upload Total","Download Total":"Download Total","Active Connections":"Active Connections","Memory Usage":"Memory Usage","Pause Refresh":"Pause Refresh","Resume Refresh":"Resume Refresh",close_all_connections:"Close All Connections",close_filter_connections:"Close all connections after filtering",Search:"Search",Sort:"Sort",Up:"Up",Down:"Down","Test Latency":"Test Latency",settings:"settings",general:"General",management:"Management",dashboard:"Dashboard",sort_in_grp:"Sorting in group",hide_unavail_proxies:"Hide unavailable proxies",auto_close_conns:"Automatically close old connections",double_column_layout:"Double column layout",group_by_provider:"Group proxies by provider",order_natural:"Original order in config file",order_latency_asc:"By latency from small to big",order_latency_desc:"By latency from big to small",order_name_asc:"By name alphabetically (A-Z)",order_name_desc:"By name alphabetically (Z-A)",Connections:"Connections",current_backend:"Current Backend",Active:"Active",switch_backend:"Switch backend",Closed:"Closed",switch_theme:"Switch theme",theme:"theme",about:"about",no_logs:"No logs yet, hang tight...",chart_style:"Chart Style",latency_test_url:"Latency Test URL",latency_test_timeout:"Latency Test Timeout",lang:"Language",proxy_provider:"Proxy Provider",rule_provider:"Rule Provider",update_all_rule_provider:"Update all rule providers",update_all_proxy_provider:"Update all proxy providers",reload_config_file:"Reload config file",restart_core:"Restart core",upgrade_core:"Upgrade core",upgrade_geo:"Upgrade GEO Databases",upgrade_ui:"Upgrade Dashboard UI",update_geo_databases_file:"Update GEO Databases ",flush_fake_ip_pool:"Flush fake-ip data",enable_tun_device:"Enable TUN Device",allow_lan:"Allow LAN",tls_sniffing:"Sniffer",c_host:"Host",c_sni:"Sniff Host",c_process:"Process",c_dl:"DL",c_ul:"UL",c_dl_speed:"DL Speed",c_ul_speed:"UL Speed",c_chains:"Chains",c_rule:"Rule",c_time:"Time",c_source:"Source",c_destination_ip:"Destination IP",c_type:"Type",c_ctrl:"Close",close_all_confirm:"Are you sure you want to close all connections?",close_all_confirm_yes:"I'm sure",close_all_confirm_no:"No",manage_column:"Custom columns",reset_column:"Reset columns",device_name:"Device Tag",delete:"Delete",add_tag:"Add tag",client_tag:"Client tags",sourceip_tip:"Prefix with / for regular expressions, otherwise it's a complete match",disconnect:"Close Connection",internel:"Internal Connection",Clear:"Clear"};export{e as data};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
const e={All:"Все",Overview:"Обзор",Proxies:"Прокси",Rules:"Правила",Conns:"Подключения",Config:"Настройки",Logs:"Логи",Upload:"Отдача",Download:"Загрузка","Upload Total":"Всего отдано","Download Total":"Всего загружено","Active Connections":"Активные подключения","Memory Usage":"Использование памяти","Pause Refresh":"Приостановить обновление","Resume Refresh":"Возобновить обновление",close_all_connections:"Закрыть все подключения",close_filter_connections:"Закрыть все отфильтрованные подключения",Search:"Поиск",Sort:"Сортировка",Up:"Вверх",Down:"Вниз","Test Latency":"Проверить задержку",settings:"настройки",general:"Основные",management:"Управление",dashboard:"Панель управления",sort_in_grp:"Сортировка в группе",hide_unavail_proxies:"Скрыть недоступные прокси",auto_close_conns:"Автоматически закрывать старые подключения",double_column_layout:"Двухколоночный макет",group_by_provider:"Группировать прокси по провайдеру",order_natural:"Исходный порядок из конфигурации",order_latency_asc:"По задержке (от меньшей к большей)",order_latency_desc:"По задержке (от большей к меньшей)",order_name_asc:"По имени (А-Я)",order_name_desc:"По имени (Я-А)",Connections:"Подключения",current_backend:"Текущий бэкенд",Active:"Активные",switch_backend:"Сменить бэкенд",Closed:"Закрытые",switch_theme:"Сменить тему",theme:"тема",about:"о программе",no_logs:"Пока нет логов, подождите...",chart_style:"Стиль графика",latency_test_url:"URL для проверки задержки",latency_test_timeout:"Таймаут проверки задержки",lang:"Язык",proxy_provider:"Провайдер прокси",rule_provider:"Провайдер правил",update_all_rule_provider:"Обновить все провайдеры правил",update_all_proxy_provider:"Обновить все провайдеры прокси",reload_config_file:"Перезагрузить конфигурацию",restart_core:"Перезапустить ядро",upgrade_core:"Обновить ядро",upgrade_geo:"Обновить GEO базы данных",upgrade_ui:"Обновить интерфейс",update_geo_databases_file:"Обновить файлы GEO баз данных",flush_fake_ip_pool:"Очистить пул fake-ip",enable_tun_device:"Включить TUN устройство",allow_lan:"Разрешить LAN",tls_sniffing:"Сниффер",c_host:"Хост",c_sni:"Sniff хост",c_process:"Процесс",c_dl:"Загр.",c_ul:"Отд.",c_dl_speed:"Скорость загр.",c_ul_speed:"Скорость отд.",c_chains:"Цепочки",c_rule:"Правило",c_time:"Время",c_source:"Источник",c_destination_ip:"IP назначения",c_type:"Тип",c_ctrl:"Закрыть",close_all_confirm:"Вы уверены, что хотите закрыть все подключения?",close_all_confirm_yes:"Да, уверен",close_all_confirm_no:"Нет",manage_column:"Настройка столбцов",reset_column:"Сбросить столбцы",device_name:"Метка устройства",delete:"Удалить",add_tag:"Добавить метку",client_tag:"Метки клиентов",sourceip_tip:"Префикс / для регулярных выражений, иначе — точное совпадение",disconnect:"Закрыть подключение",internel:"Внутреннее подключение",Clear:"Очистить"};export{e as data};
|
||||
@@ -0,0 +1 @@
|
||||
const n={All:"Tất cả",Overview:"Tổng quan",Proxies:"Proxy",Rules:"Quy tắc",Conns:"Kết nối",Config:"Cấu hình",Logs:"Nhật ký",Upload:"Tải lên",Download:"Tải xuống","Upload Total":"Tổng tải lên","Download Total":"Tổng tải xuống","Active Connections":"Kết nối hoạt động","Memory Usage":"Sử dụng bộ nhớ","Pause Refresh":"Tạm dừng làm mới","Resume Refresh":"Tiếp tục làm mới",close_all_connections:"Đóng tất cả kết nối",close_filter_connections:"Đóng tất cả kết nối sau khi lọc",Search:"Tìm kiếm",Up:"Lên",Down:"Xuống","Test Latency":"Kiểm tra độ trễ",settings:"Cài đặt",sort_in_grp:"Sắp xếp trong nhóm",hide_unavail_proxies:"Ẩn proxy không khả dụng",auto_close_conns:"Tự động đóng kết nối cũ",group_by_provider:"Nhóm proxy theo nhà cung cấp",order_natural:"Thứ tự ban đầu trong tệp cấu hình",order_latency_asc:"Theo độ trễ từ nhỏ đến lớn",order_latency_desc:"Theo độ trễ từ lớn đến nhỏ",order_name_asc:"Theo tên theo thứ tự bảng chữ cái (A-Z)",order_name_desc:"Theo tên theo thứ tự bảng chữ cái (Z-A)",Connections:"Kết nối",current_backend:"Backend hiện tại",Active:"Hoạt động",switch_backend:"Chuyển đổi backend",Closed:"Đã đóng",switch_theme:"Chuyển đổi giao diện",theme:"Giao diện",about:"Về chúng tôi",no_logs:"Chưa có nhật ký, hãy kiên nhẫn...",chart_style:"Kiểu biểu đồ",latency_test_url:"URL kiểm tra độ trễ",latency_test_timeout:"Thời gian chờ kiểm tra độ trễ",lang:"Ngôn ngữ",proxy_provider:"nhà cung cấp proxy",update_all_rule_provider:"Cập nhật tất cả nhà cung cấp quy tắc",update_all_proxy_provider:"Cập nhật tất cả nhà cung cấp proxy",reload_config_file:"Tải lại tệp cấu hình",restart_core:"Khởi động lõi lại Clash",upgrade_core:"Nâng cấp lõi Clash",update_geo_databases_file:"Cập nhật tệp cơ sở dữ liệu GEO",flush_fake_ip_pool:"Xóa bộ nhớ đệm fake-ip",enable_tun_device:"Bật thiết bị TUN",allow_lan:"Cho phép LAN",tls_sniffing:"Bộ giám sát gói tin Sniffer",c_host:"Máy chủ",c_sni:"Phát hiện máy chủ Sniff ",c_process:"Quá trình",c_dl:"Tải Xuống",c_ul:"Tải Lên",c_dl_speed:"Tốc độ Tải Xuống",c_ul_speed:"Tốc độ Tải lên",c_chains:"Chuỗi",c_rule:"Quy tắc",c_time:"Thời gian",c_source:"Nguồn",c_destination_ip:"Địa chỉ IP đích",c_type:"Loại",c_ctrl:"Đóng",close_all_confirm:"Bạn có chắc chắn muốn đóng tất cả kết nối không?",close_all_confirm_yes:"Chắc chắn",close_all_confirm_no:"Không",manage_column:"Quản lý cột",reset_column:"Đặt lại cột",device_name:"Thẻ thiết bị",delete:"Xóa",add_tag:"Thêm thẻ",client_tag:"Thẻ khách hàng",sourceip_tip:"Thêm / vào đầu để sử dụng biểu thức chính quy, nếu không sẽ là kết quả khớp chính xác(By Ohoang7)",disconnect:"Đóng kết nối",internel:"Kết nối nội bộ",Clear:"Dọn dẹp"};export{n as data};
|
||||
@@ -0,0 +1 @@
|
||||
const e={All:"全部",Overview:"概览",Proxies:"代理",Rules:"规则",Conns:"连接",Config:"配置",Logs:"日志",Upload:"上传",Download:"下载","Upload Total":"上传总量","Download Total":"下载总量","Active Connections":"活动连接","Memory Usage":"内存使用情况",Memory:"内存","Pause Refresh":"暂停刷新","Resume Refresh":"继续刷新",close_all_connections:"关闭所有连接",close_filter_connections:"关闭所有过滤后的连接",Search:"查找",Sort:"排序",Up:"上传",Down:"下载","Test Latency":"延迟测速",settings:"设置",general:"常规",management:"管理",dashboard:"面板",sort_in_grp:"代理组条目排序",hide_unavail_proxies:"隐藏不可用代理",auto_close_conns:"切换代理时自动断开旧连接",double_column_layout:"双列显示",group_by_provider:"按提供商分组节点",order_natural:"原 config 文件中的排序",order_latency_asc:"按延迟从小到大",order_latency_desc:"按延迟从大到小",order_name_asc:"按名称字母排序 (A-Z)",order_name_desc:"按名称字母排序 (Z-A)",Connections:"连接",current_backend:"当前后端",Active:"活动",switch_backend:"切换后端",Closed:"已断开",switch_theme:"切换主题",theme:"主题",about:"关于",no_logs:"暂无日志...",chart_style:"流量图样式",latency_test_url:"延迟测速 URL",latency_test_timeout:"测速超时",lang:"语言",proxy_provider:"代理提供商",rule_provider:"规则提供商",update_all_rule_provider:"更新所有规则提供商",update_all_proxy_provider:"更新所有代理提供商",reload_config_file:"重载配置文件",update_geo_databases_file:"更新 GEO 数据库文件",flush_fake_ip_pool:"清空 FakeIP 数据库",enable_tun_device:"开启 TUN 转发",allow_lan:"允许局域网连接",tls_sniffing:"SNI 嗅探",c_host:"域名",c_sni:"嗅探域名",c_process:"进程",c_dl:"下载",c_ul:"上传",c_dl_speed:"下载速率",c_ul_speed:"上传速率",c_chains:"节点链",c_rule:"规则",c_time:"连接时间",c_source:"来源",c_destination_ip:"目标IP",c_type:"类型",c_ctrl:"关闭",restart_core:"重启核心",upgrade_core:"更新核心",upgrade_geo:"更新 GEO 数据库",upgrade_ui:"更新面板 UI",close_all_confirm:"确定关闭所有连接?",close_all_confirm_yes:"确定",close_all_confirm_no:"取消",manage_column:"管理列",reset_column:"重置列",device_name:"设备名",delete:"删除",add_tag:"添加标签",client_tag:"客户端标签",sourceip_tip:"/开头为正则,否则为全匹配",disconnect:"断开连接",internel:"内部链接",Clear:"清空"};export{e as data};
|
||||
@@ -0,0 +1 @@
|
||||
const e={All:"全部",Overview:"概覽",Proxies:"代理",Rules:"規則",Conns:"連線",Config:"設定",Logs:"紀錄",Upload:"上傳",Download:"下載","Upload Total":"總上傳","Download Total":"總下載","Active Connections":"活動中連線","Memory Usage":"記憶體使用狀況",Memory:"記憶體","Pause Refresh":"暫停重整","Resume Refresh":"繼續重整",close_all_connections:"斷開所有連線",close_filter_connections:"斷開所有過濾後的連線",Search:"搜尋",Up:"上傳",Down:"下載","Test Latency":"測試延遲速度",settings:"設定",sort_in_grp:"依代理群組排序",hide_unavail_proxies:"隱藏不可用的代理伺服器",auto_close_conns:"切換代理伺服器時自動斷開舊連線",double_column_layout:"雙列顯示",group_by_provider:"依提供商分組節點",order_natural:"原 config 文件中的順序",order_latency_asc:"按延遲從小到大",order_latency_desc:"按延遲從大到小",order_name_asc:"按名稱字母順序排序 (A-Z)",order_name_desc:"按名稱字母順序排序 (Z-A)",Connections:"連線",current_backend:"當前後端",Active:"活動中",switch_backend:"切換後端",Closed:"已斷線",switch_theme:"切換主題",theme:"主題",about:"關於",no_logs:"暫時沒有紀錄…",chart_style:"流量圖樣式",latency_test_url:"延遲測速 URL",latency_test_timeout:"測速超時",lang:"語言",proxy_provider:"代理伺服器提供者",update_all_rule_provider:"更新所有規則提供者",update_all_proxy_provider:"更新所有代理伺服器提供者",reload_config_file:"重新載入設定檔",update_geo_databases_file:"更新 GEO 資料庫文件",flush_fake_ip_pool:"清除 Fake IP 資料庫",enable_tun_device:"開啟 TUN 轉發",allow_lan:"允許區域網路連接",tls_sniffing:"SNI 嗅探",c_host:"網域名稱",c_sni:"嗅探網域名稱",c_process:"處理程序",c_dl:"下載",c_ul:"上傳",c_dl_speed:"下載速度",c_ul_speed:"上傳速度",c_chains:"節點鍊",c_rule:"規則",c_time:"連線時間",c_source:"來源",c_destination_ip:"目標 IP",c_type:"類型",c_ctrl:"關閉",restart_core:"重啟核心",upgrade_core:"更新核心",close_all_confirm:"確定關閉所有連接?",close_all_confirm_yes:"確定",close_all_confirm_no:"取消",manage_column:"管理列",reset_column:"重置列",device_name:"設備名稱",delete:"刪除",add_tag:"新增標籤",client_tag:"客戶端標籤",sourceip_tip:"/開頭為正規表達式,否則為全面配對",disconnect:"斷開連線",internel:"內部連線",Clear:"清空"};export{e as data};
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="shortcut icon" href="yacd.ico" />
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="yacd.png" />
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="yacd.png" />
|
||||
<link rel="apple-touch-icon-precomposed" href="apple-touch-icon-precomposed.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="yacd" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="application-name" content="yacd" />
|
||||
<meta name="description" content="Yet Another Clash Dashboard" />
|
||||
<meta name="theme-color" content="#202020" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#f7f7f7" media="(prefers-color-scheme: light)" />
|
||||
<title>yacd</title>
|
||||
<script type="module" crossorigin src="./assets/index-C9L_vfV_.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DfZ88PCr.css">
|
||||
<link rel="manifest" href="./manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="./registerSW.js"></script></head>
|
||||
<body>
|
||||
<div id="app" data-base-url="http://127.0.0.1:9090"></div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1 @@
|
||||
{"name":"yacd","short_name":"yacd","description":"Yet another Clash dashboard","start_url":"./","display":"standalone","background_color":"#ffffff","theme_color":"#42b883","lang":"en","scope":"./","icons":[{"src":"apple-touch-icon-precomposed.png","sizes":"512x512","type":"image/png"}]}
|
||||
@@ -0,0 +1 @@
|
||||
if('serviceWorker' in navigator) {window.addEventListener('load', () => {navigator.serviceWorker.register('./sw.js', { scope: './' })})}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Reference in New Issue
Block a user