Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7722be539 | |||
| 779f42c98c | |||
| 525620b0f8 | |||
| ebc8d687d0 | |||
| 294526b403 |
2
app.py
2
app.py
@@ -14,6 +14,7 @@ from routes_config import register_config_routes
|
||||
from routes_email import register_email_routes
|
||||
from routes_project import register_project_routes
|
||||
from routes_docker import register_docker_routes
|
||||
from routes_discovery import register_discovery_routes
|
||||
from templates import get_html_template
|
||||
|
||||
|
||||
@@ -33,6 +34,7 @@ def init_app():
|
||||
register_email_routes(app)
|
||||
register_project_routes(app)
|
||||
register_docker_routes(app)
|
||||
register_discovery_routes(app)
|
||||
|
||||
# 主页路由
|
||||
@app.route('/')
|
||||
|
||||
247
routes_discovery.py
Normal file
247
routes_discovery.py
Normal file
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
服务发现 API 路由 - 扫描本机未注册的服务
|
||||
方案C: 端口扫描 + 进程信息增强
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import subprocess
|
||||
from flask import jsonify, request
|
||||
from utils import log_message
|
||||
from config import PROJECTS_FILE
|
||||
|
||||
|
||||
# 系统保留/常见基础服务端口,不纳入发现
|
||||
SYSTEM_PORTS = {
|
||||
22, # SSH
|
||||
25, # SMTP
|
||||
53, # DNS
|
||||
80, # HTTP (nginx/apache 前端)
|
||||
111, # portmapper
|
||||
135, # RPC
|
||||
137, # NetBIOS
|
||||
138, # NetBIOS
|
||||
139, # NetBIOS
|
||||
443, # HTTPS
|
||||
445, # SMB
|
||||
587, # SMTP submission
|
||||
631, # CUPS
|
||||
993, # IMAPS
|
||||
995, # POP3S
|
||||
3306, # MySQL
|
||||
3389, # RDP
|
||||
5432, # PostgreSQL
|
||||
6379, # Redis
|
||||
8080, # 常见代理
|
||||
8443, # 常见 HTTPS 代理
|
||||
9090, # Prometheus
|
||||
3000, # 常见开发端口
|
||||
4200, # Angular dev
|
||||
5000, # Flask dev
|
||||
8000, # 常见开发
|
||||
9000, # 常见开发
|
||||
}
|
||||
|
||||
|
||||
def _get_listening_ports():
|
||||
"""获取所有TCP监听端口及进程信息"""
|
||||
services = []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['ss', '-tlnp'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
for line in result.stdout.split('\n'):
|
||||
line = line.strip()
|
||||
if not line or 'State' in line or 'Local' in line:
|
||||
continue
|
||||
|
||||
# 解析: LISTEN 0 128 0.0.0.0:16022 0.0.0.0:* users:(("python3.12",pid=123,fd=3))
|
||||
if '127.0.0.1' in line and '0.0.0.0' not in line.split()[3] and '*' not in line.split()[3]:
|
||||
# 跳过仅监听 localhost 的
|
||||
pass
|
||||
|
||||
# 提取端口和pid
|
||||
addr_part = line.split()[3] if len(line.split()) > 3 else ''
|
||||
port_match = re.search(r':(\d+)$', addr_part)
|
||||
if not port_match:
|
||||
continue
|
||||
port = int(port_match.group(1))
|
||||
|
||||
pid_match = re.search(r'pid=(\d+)', line)
|
||||
pid = int(pid_match.group(1)) if pid_match else None
|
||||
|
||||
proc_match = re.search(r'users:\(\(\"([^\"]+)\"', line)
|
||||
proc_name = proc_match.group(1) if proc_match else '未知进程'
|
||||
|
||||
services.append({
|
||||
'port': port,
|
||||
'pid': pid,
|
||||
'process': proc_name,
|
||||
})
|
||||
except Exception as e:
|
||||
log_message(f"端口扫描失败: {e}")
|
||||
|
||||
return services
|
||||
|
||||
|
||||
def _get_process_cmdline(pid):
|
||||
"""读取进程命令行"""
|
||||
try:
|
||||
cmdline_path = f'/proc/{pid}/cmdline'
|
||||
if os.path.exists(cmdline_path):
|
||||
with open(cmdline_path, 'rb') as f:
|
||||
raw = f.read()
|
||||
# cmdline 以 \0 分隔
|
||||
args = raw.split(b'\x00')
|
||||
# 解码,跳过空字符串
|
||||
cmd = ' '.join(a.decode('utf-8', errors='replace') for a in args if a)
|
||||
return cmd
|
||||
except:
|
||||
pass
|
||||
return ''
|
||||
|
||||
|
||||
def _get_process_cwd(pid):
|
||||
"""读取进程工作目录"""
|
||||
try:
|
||||
cwd_path = f'/proc/{pid}/cwd'
|
||||
if os.path.exists(cwd_path):
|
||||
return os.readlink(cwd_path)
|
||||
except:
|
||||
pass
|
||||
return ''
|
||||
|
||||
|
||||
def _guess_service_name(pid, proc_name, cmdline):
|
||||
"""根据进程信息推测服务名"""
|
||||
# 已知框架特征
|
||||
patterns = [
|
||||
(r'python.*?app\.py', 'Python Web 服务'),
|
||||
(r'python.*?main\.py', 'Python 服务'),
|
||||
(r'python.*?server\.py', 'Python Server'),
|
||||
(r'node\s+.*?(server|app|index)\.js', 'Node.js 服务'),
|
||||
(r'npm\s+(start|run)', 'Node.js 服务'),
|
||||
(r'java\s+-jar\s+(\S+)', 'Java 服务'),
|
||||
(r'gunicorn', 'Gunicorn 服务'),
|
||||
(r'uvicorn', 'Uvicorn 服务'),
|
||||
(r'nginx', 'Nginx'),
|
||||
(r'php-fpm', 'PHP-FPM'),
|
||||
(r'mysqld', 'MySQL'),
|
||||
(r'redis-server', 'Redis'),
|
||||
(r'docker-proxy', 'Docker 代理'),
|
||||
(r'containerd', 'containerd'),
|
||||
]
|
||||
|
||||
for pattern, name in patterns:
|
||||
if re.search(pattern, cmdline, re.IGNORECASE):
|
||||
return name
|
||||
|
||||
# 默认使用进程名
|
||||
return proc_name
|
||||
|
||||
|
||||
def _guess_service_type(cmdline):
|
||||
"""推测服务类型"""
|
||||
if re.search(r'(python|gunicorn|uvicorn|flask|django)', cmdline, re.IGNORECASE):
|
||||
return 'web'
|
||||
if re.search(r'(node|npm|next|express)', cmdline, re.IGNORECASE):
|
||||
return 'web'
|
||||
if re.search(r'(java|spring|tomcat)', cmdline, re.IGNORECASE):
|
||||
return 'web'
|
||||
if re.search(r'(mysqld|postgres|redis|mongo)', cmdline, re.IGNORECASE):
|
||||
return 'external'
|
||||
return 'external'
|
||||
|
||||
|
||||
def register_discovery_routes(app):
|
||||
"""注册服务发现路由"""
|
||||
|
||||
@app.route('/api/discovery/services')
|
||||
def api_discovery_services():
|
||||
"""扫描本机未注册的新服务"""
|
||||
# 1. 收集已知端口
|
||||
known_ports = set()
|
||||
try:
|
||||
with open(PROJECTS_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
for proj in data.get('projects', []):
|
||||
for p in proj.get('ports', []):
|
||||
known_ports.add(p)
|
||||
for svc in data.get('external_services', []):
|
||||
for p in svc.get('ports', []):
|
||||
known_ports.add(p)
|
||||
except:
|
||||
pass
|
||||
|
||||
# 2. 前端传入的额外排除端口(自定义服务等)
|
||||
exclude_str = request.args.get('exclude_ports', '')
|
||||
if exclude_str:
|
||||
try:
|
||||
for p in exclude_str.split(','):
|
||||
known_ports.add(int(p.strip()))
|
||||
except:
|
||||
pass
|
||||
|
||||
# 3. 获取 Docker 容器端口
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['docker', 'ps', '--format', '{{.Ports}}'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
for line in result.stdout.split('\n'):
|
||||
# 匹配 0.0.0.0:16001->7700/tcp 格式
|
||||
for m in re.finditer(r':(\d+)->', line):
|
||||
known_ports.add(int(m.group(1)))
|
||||
except:
|
||||
pass
|
||||
|
||||
# 3. 扫描所有监听端口
|
||||
all_services = _get_listening_ports()
|
||||
|
||||
# 4. 筛选未注册的
|
||||
undiscovered = []
|
||||
seen_ports = set()
|
||||
|
||||
for svc in all_services:
|
||||
port = svc['port']
|
||||
pid = svc['pid']
|
||||
|
||||
# 跳过已知/系统端口
|
||||
if port in known_ports or port in SYSTEM_PORTS:
|
||||
continue
|
||||
if port in seen_ports:
|
||||
continue
|
||||
seen_ports.add(port)
|
||||
|
||||
if pid:
|
||||
cmdline = _get_process_cmdline(pid)
|
||||
cwd = _get_process_cwd(pid)
|
||||
name = _guess_service_name(pid, svc['process'], cmdline)
|
||||
svc_type = _guess_service_type(cmdline)
|
||||
else:
|
||||
cmdline = ''
|
||||
cwd = ''
|
||||
name = svc['process']
|
||||
svc_type = 'unknown'
|
||||
|
||||
undiscovered.append({
|
||||
'port': port,
|
||||
'pid': pid,
|
||||
'process': svc['process'],
|
||||
'cmdline': cmdline[:200] if cmdline else '',
|
||||
'cwd': cwd,
|
||||
'suggested_name': name,
|
||||
'suggested_type': svc_type,
|
||||
})
|
||||
|
||||
# 按端口排序
|
||||
undiscovered.sort(key=lambda x: x['port'])
|
||||
|
||||
return jsonify({
|
||||
'services': undiscovered,
|
||||
'count': len(undiscovered),
|
||||
'known_ports': len(known_ports),
|
||||
})
|
||||
@@ -180,6 +180,24 @@
|
||||
<div id="projectsList" class="space-y-4">
|
||||
<div class="text-center py-12 text-gray-400"><i class="ri-loader-4-line text-4xl animate-spin"></i><p class="mt-2">加载中...</p></div>
|
||||
</div>
|
||||
|
||||
<!-- 发现新服务 -->
|
||||
<div id="discoverySection" class="hidden mt-4 pt-4 border-t border-gray-600">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-gray-200 text-sm font-semibold flex items-center gap-2">
|
||||
<i class="ri-radar-line text-cyan-400"></i> 发现未注册服务
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="discoveryCount" class="text-xs text-gray-500"></span>
|
||||
<button onclick="scanServices()" class="btn bg-cyan-600 hover:bg-cyan-700 px-2 py-1 rounded text-xs flex items-center gap-1">
|
||||
<i class="ri-scan-line"></i> 扫描
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="discoveryList" class="space-y-2">
|
||||
<div class="text-gray-500 text-xs text-center py-2">点击「扫描」发现本机未注册的新服务</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统资源 Tab -->
|
||||
@@ -911,6 +929,7 @@
|
||||
// Tab 切换
|
||||
// 系统资源监控变量(必须先声明)
|
||||
let realtimeTimer = null;
|
||||
let systemRefreshTimer = null;
|
||||
let realtimeInterval = 2; // 秒
|
||||
let lastAlertTime = 0; // 上次警告时间
|
||||
let thresholds = { cpu: 80, cpu_temp: 80, memory: 85, disk: 90, interval: 60 };
|
||||
@@ -939,6 +958,12 @@
|
||||
document.getElementById('processListSection').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 离开系统Tab时关闭非实时刷新定时器
|
||||
if (tab !== 'system' && systemRefreshTimer) {
|
||||
clearInterval(systemRefreshTimer);
|
||||
systemRefreshTimer = null;
|
||||
}
|
||||
|
||||
if (tab === 'cron') {
|
||||
loadCronTasks();
|
||||
}
|
||||
@@ -947,6 +972,8 @@
|
||||
loadDesktopNotifySetting();
|
||||
renderEmailRules();
|
||||
loadSystemStats();
|
||||
// 启动系统资源定时刷新(按页面刷新间隔)
|
||||
startSystemRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1263,14 +1290,17 @@
|
||||
const toggle = document.getElementById('realtimeToggle');
|
||||
const indicator = document.getElementById('realtimeIndicator');
|
||||
|
||||
// 先停止系统定时刷新
|
||||
if (systemRefreshTimer) {
|
||||
clearInterval(systemRefreshTimer);
|
||||
systemRefreshTimer = null;
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
toggle.classList.add('active');
|
||||
indicator.classList.remove('hidden');
|
||||
// 立即加载一次
|
||||
loadSystemStats();
|
||||
// 启动定时器
|
||||
realtimeTimer = setInterval(loadSystemStats, realtimeInterval * 1000);
|
||||
// 如果进程列表开关也开启,则显示并加载
|
||||
if (document.getElementById('showProcessListCheck').checked) {
|
||||
document.getElementById('processListSection').classList.remove('hidden');
|
||||
loadTopProcesses();
|
||||
@@ -1279,14 +1309,28 @@
|
||||
toggle.classList.remove('active');
|
||||
indicator.classList.add('hidden');
|
||||
document.getElementById('processListSection').classList.add('hidden');
|
||||
// 停止定时器
|
||||
if (realtimeTimer) {
|
||||
clearInterval(realtimeTimer);
|
||||
realtimeTimer = null;
|
||||
}
|
||||
// 关闭实时后,按页面刷新间隔继续刷新系统资源
|
||||
startSystemRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
function startSystemRefresh() {
|
||||
// 先停掉旧定时器
|
||||
if (systemRefreshTimer) {
|
||||
clearInterval(systemRefreshTimer);
|
||||
systemRefreshTimer = null;
|
||||
}
|
||||
// 如果在实时监控模式则不启动(由realtimeTimer负责)
|
||||
if (document.getElementById('realtimeCheck').checked) return;
|
||||
// 按页面刷新间隔定时刷新
|
||||
const intervalSec = parseInt(document.getElementById('refreshInterval').value) || 30;
|
||||
systemRefreshTimer = setInterval(loadSystemStats, intervalSec * 1000);
|
||||
}
|
||||
|
||||
function toggleProcessList() {
|
||||
const checked = document.getElementById('showProcessListCheck').checked;
|
||||
const realtimeChecked = document.getElementById('realtimeCheck').checked;
|
||||
@@ -2220,6 +2264,7 @@
|
||||
const newService = {
|
||||
id: 'custom_' + Date.now(),
|
||||
name: name,
|
||||
port: port || '',
|
||||
url: mainUrl,
|
||||
admin_url: adminUrl || null,
|
||||
description: desc || '自定义外部服务',
|
||||
@@ -2732,6 +2777,11 @@
|
||||
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = setInterval(() => { if (currentTab === 'projects') loadProjects(); }, seconds * 1000);
|
||||
|
||||
// 如果当前在系统Tab且非实时模式,更新系统刷新间隔
|
||||
if (currentTab === 'system' && !document.getElementById('realtimeCheck').checked) {
|
||||
startSystemRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
@@ -2861,6 +2911,60 @@
|
||||
} catch (e) { alert('失败: ' + e.message); }
|
||||
}
|
||||
|
||||
// ==================== 服务发现 ====================
|
||||
|
||||
async function scanServices() {
|
||||
const list = document.getElementById('discoveryList');
|
||||
list.innerHTML = '<div class="text-gray-400 text-xs text-center py-2"><i class="ri-loader-4-line animate-spin"></i> 扫描中...</div>';
|
||||
|
||||
try {
|
||||
// 收集前端已注册的自定义服务端口,传给后端排除
|
||||
const customServices = getCustomServices();
|
||||
const customPorts = customServices.map(s => parseInt(s.port)).filter(p => !isNaN(p));
|
||||
const excludeParam = customPorts.length > 0 ? `?exclude_ports=${customPorts.join(',')}` : '';
|
||||
|
||||
const res = await fetch(`/api/discovery/services${excludeParam}`);
|
||||
const data = await res.json();
|
||||
const services = data.services || [];
|
||||
document.getElementById('discoveryCount').textContent = `发现 ${services.length} 个`;
|
||||
|
||||
if (services.length === 0) {
|
||||
list.innerHTML = '<div class="text-green-400 text-xs text-center py-2"><i class="ri-checkbox-circle-line"></i> 所有服务已注册,没有遗漏</div>';
|
||||
} else {
|
||||
list.innerHTML = services.map(s => `
|
||||
<div class="flex items-center justify-between bg-gray-700/30 px-3 py-2 rounded-lg">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<span class="text-xs text-cyan-400 font-mono">:${s.port}</span>
|
||||
<span class="text-gray-300 text-sm truncate">${s.suggested_name}</span>
|
||||
<span class="text-gray-500 text-xs">PID ${s.pid != null ? s.pid : '受权限限制'}</span>
|
||||
<span class="text-gray-500 text-xs truncate hidden md:inline">${(s.cmdline || '').substring(0, 60)}</span>
|
||||
</div>
|
||||
<button onclick="quickAddService(${s.port}, '${s.suggested_name.replace(/'/g, "\\'")}', '${s.suggested_type}', '${(s.cwd || '').replace(/'/g, "\\'")}')" class="btn bg-green-600/50 hover:bg-green-600 px-2 py-1 rounded text-xs shrink-0 ml-2">
|
||||
<i class="ri-add-line"></i> 添加
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div class="text-red-400 text-xs text-center py-2">扫描失败: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function quickAddService(port, name, type, cwd) {
|
||||
showAddServiceModal();
|
||||
document.getElementById('serviceName').value = name;
|
||||
document.getElementById('servicePort').value = port;
|
||||
document.getElementById('serviceFullUrl').value = `http://${externalIp}:${port}`;
|
||||
document.getElementById('serviceDesc').value = `自动发现 | ${type} | PID路径: ${cwd || '未知'}`;
|
||||
updateServicePreview();
|
||||
}
|
||||
|
||||
// 页面加载后显示发现区块
|
||||
function showDiscoverySection() {
|
||||
document.getElementById('discoverySection').classList.remove('hidden');
|
||||
}
|
||||
setTimeout(showDiscoverySection, 500);
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
24
watchdog.sh
Executable file
24
watchdog.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# project-panel 守护脚本 - 进程挂了自动重启
|
||||
SERVICE_DIR="/home/openclaw/.openclaw/workspace-hz4th_coder/works/project-panel"
|
||||
PYTHON="/home/hz1/miniconda3/envs/openclaw/bin/python3.12"
|
||||
PORT=16022
|
||||
LOG="$SERVICE_DIR/logs/watchdog.log"
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 守护进程启动,监控端口 $PORT" >> "$LOG"
|
||||
|
||||
while true; do
|
||||
if ! ss -tlnp 2>/dev/null | grep -q ":$PORT "; then
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 检测到 $PORT 端口离线,重启服务..." >> "$LOG"
|
||||
cd "$SERVICE_DIR"
|
||||
mkdir -p logs
|
||||
nohup "$PYTHON" app.py > logs/app.log 2>&1 &
|
||||
sleep 3
|
||||
if ss -tlnp 2>/dev/null | grep -q ":$PORT "; then
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 服务重启成功" >> "$LOG"
|
||||
else
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 服务重启失败!" >> "$LOG"
|
||||
fi
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
Reference in New Issue
Block a user