Compare commits

..
9 Commits
Author SHA1 Message Date
hz4th_coder 070044f131 feat: 项目服务列表新增搜索/状态筛选/端口排序
- 新增关键字搜索框:按服务名称/描述/id/端口实时过滤
- 新增状态筛选按钮:全部状态/在线中(running|partial)/有后台(admin_url)
- 新增端口排序按钮:点击三态切换 无排序→升序→降序
- 筛选栏分组 + 分隔线,样式与现有主题一致
2026-08-24 15:09:21 +08:00
hz4th_coder 201d31d25e feat: 收录全部24个Web服务项目到项目面板
- 新增 param-hub-python/param-auto-manager/llama-cmd-gen/webtest-agent 等 22 个 Web 项目
- 每个项目含端口/启动停止命令/健康检查/后台地址/Git仓库/版本号
- 外部服务补充 Chroma 16010 + Embedding 16011
- 从 2 个项目扩展到 24 个项目
2026-08-24 13:14:08 +08:00
hz4th_coder 40213494df CDN资源本地化:tailwindcss.js和remixicon(含字体)下载到static/vendor,页面不再依赖外网CDN(cdn.tailwindcss.com/cdn.jsdelivr.net),解决外网波动导致页面load/networkidle超时 2026-08-09 23:33:37 +08:00
hz4th_coder a155e8fcd9 feat: 更新项目服务、工具函数和模板 2026-06-25 12:11:16 +08:00
hz4th_coder 0c54af8d26 feat: Web服务和Docker监控区支持折叠/展开
- 所有分组标题(Web服务/自定义服务/归档服务等)可点击折叠
- Docker容器列表、镜像列表标题可点击折叠
- 点击标题切换 chevron 旋转动画(→↓)
- 折叠状态持久化到 localStorage,刷新保持
2026-06-01 22:35:57 +08:00
hz4th_coder f7722be539 fix: 系统资源面板未开实时监控时按页面刷新间隔自动更新
- 新增 systemRefreshTimer,切换至系统Tab时自动启动
- 未开实时监控 → 按页面「刷新间隔」秒数定时刷新系统资源
- 开启实时监控 → 按实时间隔秒数刷新(原有逻辑不变)
- 关闭实时监控 → 切回页面刷新间隔继续更新
- 修改页面刷新间隔 → 同步更新系统资源刷新频率
- 新增 watchdog.sh 守护脚本,端口掉线自动重启
2026-06-01 20:35:07 +08:00
hz4th_coder 779f42c98c fix: 服务发现支持无权限查看的端口(Docker host网络等)
- ss -tlnp 输出中没有 pid= 的端口不再跳过
- pid 为 None 时显示「受权限限制」, 进程名标记「未知进程」
- 当前发现: 8088, 9119, 16031, 16067, 18789
2026-06-01 18:32:10 +08:00
hz4th_coder 525620b0f8 fix: 自定义服务保存时添加port字段,修复发现扫描排除失效
之前 quickAddService 保存自定义服务时遗漏了 port 字段,
导致 scanServices 的 exclude_ports 取不到有效端口值
2026-06-01 18:28:44 +08:00
hz4th_coder ebc8d687d0 fix: 服务发现排除前端已注册的自定义服务
- API 新增 exclude_ports 查询参数,接受逗号分隔端口列表
- 前端 scanServices() 读取 localStorage 自定义服务端口传给后端
- 已添加的服务不再出现在扫描结果中
2026-06-01 18:23:23 +08:00
20 changed files with 449911 additions and 147 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+446224 -54
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
[2026-06-01 20:29:22] 守护进程启动,监控端口 16022
+424 -22
View File
@@ -3,36 +3,416 @@
"name": "hz1-PowerEdge-R730xd",
"description": "黄庄1号服务器 - PowerEdge R730xd",
"python_path": "/home/hz1/miniconda3/envs/openclaw/bin/python3.12",
"updated": "2026-06-01"
"updated": "2026-08-24"
},
"projects": [
{
"id": "pdf-translate-web",
"name": "PDF翻译助手",
"type": "web",
"ports": [16021],
"directory": "works/pdf-translate-web",
"start_cmd": "mkdir -p logs && nohup /home/hz1/miniconda3/envs/openclaw/bin/python3.12 app.py > logs/app.log 2>&1 & disown",
"stop_cmd": "pkill -f 'python.*pdf-translate-web.*app.py' || true",
"health_url": "http://localhost:16021/",
"description": "英文PDF翻译中文网站,支持用户系统、会员体系",
"admin_url": "http://localhost:16021/admin",
"git_repo": "http://121.40.164.32:12007/coder/pdf-translate-web",
"version": "v2.0.0"
"id": "param-hub-python",
"name": "参数中心",
"ports": [
16041
],
"directory": "param-hub-python",
"start_cmd": "mkdir -p logs && nohup python3 app.py > logs/app.log 2>&1 & disown",
"stop_cmd": "pkill -f 'param-hub-python.*app.py' || true",
"health_url": "http://localhost:16041/",
"admin_url": "http://localhost:16041/admin",
"description": "参数配置管理平台,多业务线参数统一管理",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/param-hub-python.git",
"version": "v2.1.1",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "param-auto-manager",
"name": "参数自动化管理",
"ports": [
16043
],
"directory": "param-auto-manager",
"start_cmd": "./start.sh",
"stop_cmd": "./start.sh stop",
"health_url": "http://localhost:16043/",
"admin_url": "",
"description": "参数数据自动化管理系统,自动同步/校验/告警",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/param-auto-manager.git",
"version": "v2.1.0",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "llama-cmd-gen",
"name": "Llama命令生成器",
"ports": [
16052
],
"directory": "llama-cmd-gen",
"start_cmd": "mkdir -p logs && nohup /home/hz1/miniconda3/envs/openclaw/bin/python3 app.py > logs/app.log 2>&1 & disown",
"stop_cmd": "pkill -f 'llama-cmd-gen.*app.py' || true",
"health_url": "http://localhost:16052/",
"admin_url": "http://localhost:16052/admin",
"description": "Llama 模型推理命令生成器,GPU/量化/参数智能匹配",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/llama-cmd-gen.git",
"version": "v2.0.0",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "webtest-agent",
"name": "网页测试智能体",
"ports": [
16061
],
"directory": "webtest-agent",
"start_cmd": "./start.sh",
"stop_cmd": "./start.sh stop",
"health_url": "http://localhost:16061/",
"admin_url": "",
"description": "AI 网页测试智能体,LLM 驱动浏览器自动化测试(agent-browser",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/webtest-agent.git",
"version": "v1.1.1",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "project-panel",
"name": "项目服务管理面板",
"type": "web",
"ports": [16022],
"directory": "works/project-panel",
"start_cmd": "mkdir -p logs && nohup /home/hz1/miniconda3/envs/openclaw/bin/python3.12 app.py > logs/app.log 2>&1 & disown",
"stop_cmd": "pkill -f 'python.*project-panel.*app.py' || true",
"ports": [
16022
],
"directory": "project-panel",
"start_cmd": "mkdir -p logs && nohup /home/hz1/miniconda3/envs/openclaw/bin/python3 app.py > logs/app.log 2>&1 & disown",
"stop_cmd": "pkill -f 'project-panel.*app.py' || true",
"health_url": "http://localhost:16022/",
"description": "统一管理所有 Web 服务项目,支持 Docker 监控、系统资源监控、Cron 管理",
"admin_url": "http://localhost:16022",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/project-panel",
"version": "v3.0.0"
"description": "统一管理所有 Web 服务项目,支持 Docker 监控、系统资源监控、Cron 管理",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/project-panel.git",
"version": "v3.4.1",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "project-panel-dev",
"name": "项目面板(开发版)",
"ports": [
16023
],
"directory": "project-panel-dev",
"start_cmd": "mkdir -p logs && nohup /home/hz1/miniconda3/envs/openclaw/bin/python3 app.py > logs/app.log 2>&1 & disown",
"stop_cmd": "pkill -f 'project-panel-dev.*app.py' || true",
"health_url": "http://localhost:16023/",
"admin_url": "http://localhost:16023",
"description": "项目服务管理面板开发版(新功能预览)",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/project-panel-dev.git",
"version": "v3.7.5",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "universal-crawler",
"name": "通用爬虫系统",
"ports": [
16062
],
"directory": "universal-crawler",
"start_cmd": "./start.sh",
"stop_cmd": "./start.sh stop",
"health_url": "http://localhost:16062/",
"admin_url": "http://localhost:16062/admin",
"description": "批量/定时/自动网页爬取,反爬处理 + 邮件通知",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/universal-crawler.git",
"version": "v1.4.3",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "ai-worker-platform",
"name": "AI Worker 平台",
"ports": [
16071
],
"directory": "ai-worker-platform",
"start_cmd": "./start.sh",
"stop_cmd": "./start.sh stop",
"health_url": "http://localhost:16071/",
"admin_url": "http://localhost:16071/admin",
"description": "AI Worker 项目管理:任务看板/DAG编排/多Agent协作/评估/企业版",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/ai-worker-platform.git",
"version": "v3.4.0",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "companion-assistant",
"name": "随身助手",
"ports": [
16080
],
"directory": "companion-assistant",
"start_cmd": "cd backend && ./start.sh",
"stop_cmd": "cd backend && ./start.sh stop",
"health_url": "http://localhost:16080/",
"admin_url": "http://localhost:16080/#/admin",
"description": "AI 随身助手:流式对话/智能体/多类型项目/语音/附件",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/companion-assistant.git",
"version": "v0.2.1",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "nba-fan-hub",
"name": "NBA球迷大全",
"ports": [
16090
],
"directory": "nba-fan-hub",
"start_cmd": "./start.sh",
"stop_cmd": "./start.sh stop",
"health_url": "http://localhost:16090/",
"admin_url": "http://localhost:16090/admin",
"description": "NBA 数据问答 + 球队/球员/比赛/新闻/人物/排名六大浏览页",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/nba-fan-hub.git",
"version": "v1.2.13",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "data-chart-tool",
"name": "数据可视化图表生成器",
"ports": [
16016
],
"directory": "data-chart-tool",
"start_cmd": "bash keepalive.sh",
"stop_cmd": "pkill -f 'data-chart-tool.*server.js' || true",
"health_url": "http://localhost:16016/",
"admin_url": "",
"description": "柱状/折线/混合/饼图/雷达图/双Y轴/表格/多图合并,服务端出图",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/data-chart-tool.git",
"version": "v1.20.0",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "stock-advisor",
"name": "智能荐股系统",
"ports": [
16095
],
"directory": "stock-advisor",
"start_cmd": "./start.sh",
"stop_cmd": "./start.sh stop",
"health_url": "http://localhost:16095/",
"admin_url": "http://localhost:16095/admin",
"description": "六因子评分 + AI 深度研报 + RAG 资讯 + 量化策略回测 + 舆情监控",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/stock-advisor.git",
"version": "v1.8.0",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "news-tracker",
"name": "新闻智能跟踪",
"ports": [
16100
],
"directory": "news-tracker",
"start_cmd": "./start.sh",
"stop_cmd": "./start.sh stop",
"health_url": "http://localhost:16100/",
"admin_url": "",
"description": "自动采集→打分→DeepSeek 深析→重要资讯实时/每日汇总邮件",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/news-tracker.git",
"version": "v1.0.0",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "pdf-to-markdown",
"name": "PDF→Markdown 转换",
"ports": [
16082
],
"directory": "pdf-to-markdown",
"start_cmd": "./start.sh",
"stop_cmd": "./start.sh stop",
"health_url": "http://localhost:16082/",
"admin_url": "",
"description": "PDF 转 Markdown:文本页/影印页/混合页自适应 + 图片裁剪 + 表格转录",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/pdf-to-markdown.git",
"version": "v1.0.2",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "llm-speed-tester",
"name": "LLM 速度测试台",
"ports": [
16097
],
"directory": "llm-speed-tester",
"start_cmd": "./start.sh",
"stop_cmd": "./start.sh stop",
"health_url": "http://localhost:16097/",
"admin_url": "",
"description": "多提供商 LLM 速度基准:TTFT/预填充/解码 tok/s + Excel/JSON 导出",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/llm-speed-tester.git",
"version": "v2.1.1",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "tech-blog",
"name": "技术博客系统",
"ports": [
16012
],
"directory": "tech-blog",
"start_cmd": "mkdir -p logs && nohup python3 run.py > logs/app.log 2>&1 & disown",
"stop_cmd": "pkill -f 'tech-blog.*run.py' || true",
"health_url": "http://localhost:16012/",
"admin_url": "http://localhost:16012/admin",
"description": "Flask 技术文章展示 + 后台管理(文章/分类/标签/仪表盘)",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/tech-blog.git",
"version": "v1.1.0",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "worldcup-predictor",
"name": "世界杯预测系统",
"ports": [
16015
],
"directory": "worldcup-predictor",
"start_cmd": "./start.sh",
"stop_cmd": "pkill -f 'worldcup-predictor.*app.py' || true",
"health_url": "http://localhost:16015/",
"admin_url": "http://localhost:16015/admin",
"description": "世界杯比赛多算法预测对比 + 管理后台",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/worldcup-predictor.git",
"version": "v1.0.5",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "remote-host-agent",
"name": "远程主机代理",
"ports": [
16018
],
"directory": "remote-host-agent",
"start_cmd": "./start.sh",
"stop_cmd": "./start.sh stop",
"health_url": "http://localhost:16018/api/health",
"admin_url": "",
"description": "机器B(无固定IP)监控/控制:HTTP上报 + 命令长轮询",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/remote-host-agent.git",
"version": "v1.0.0",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "llm-forum",
"name": "LLM 论坛",
"ports": [
16037
],
"directory": "llm-forum",
"start_cmd": "mkdir -p logs && nohup node server.js > logs/app.log 2>&1 & disown",
"stop_cmd": "pkill -f 'llm-forum.*server.js' || true",
"health_url": "http://localhost:16037/",
"admin_url": "http://localhost:16037/admin",
"description": "大模型交流论坛:主题/回复/Markdown/分类/管理后台",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/llm-forum.git",
"version": "v1.0.2",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "music-platform",
"name": "音乐展示平台",
"ports": [
16031
],
"directory": "music-platform",
"start_cmd": "mkdir -p logs && nohup node server.js > logs/app.log 2>&1 & disown",
"stop_cmd": "pkill -f 'music-platform.*server.js' || true",
"health_url": "http://localhost:16031/",
"admin_url": "http://localhost:16031/admin",
"description": "音乐平台:播放/歌词同步/搜索/歌手专辑歌单 + 后台管理",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/music-platform.git",
"version": "v1.0.1",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "video-platform",
"name": "短视频展示平台",
"ports": [
16035
],
"directory": "video-platform",
"start_cmd": "./start.sh",
"stop_cmd": "./start.sh stop",
"health_url": "http://localhost:16035/",
"admin_url": "http://localhost:16035/admin",
"description": "短视频平台:弹幕(WS实时)/点赞/分类 + 后台管理",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/video-platform.git",
"version": "v1.0.0",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "web-capture-api",
"name": "网页截图API",
"ports": [
16025
],
"directory": "web-capture-api",
"start_cmd": "mkdir -p logs && nohup /home/hz1/miniconda3/envs/openclaw/bin/python3 app.py > logs/app.log 2>&1 & disown",
"stop_cmd": "pkill -f 'web-capture-api.*app.py' || true",
"health_url": "http://localhost:16025/",
"admin_url": "",
"description": "网页截图/HTML 提取 REST API + Web 界面",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/web-capture-api.git",
"version": "v1.4.2",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "agent-sessions-viewer",
"name": "会话历史查看器",
"ports": [
16039
],
"directory": "agent-sessions-viewer",
"start_cmd": "mkdir -p logs && nohup node server.js > logs/app.log 2>&1 & disown",
"stop_cmd": "pkill -f 'agent-sessions-viewer.*server.js' || true",
"health_url": "http://localhost:16039/",
"admin_url": "http://localhost:16039/admin",
"description": "OpenClaw 智能体历史会话/消息查看器",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/agent-sessions-viewer.git",
"version": "v1.1.0",
"log_file": "logs/app.log",
"type": "web"
},
{
"id": "pdf-translate-web",
"name": "PDF翻译助手",
"ports": [
16021
],
"directory": "pdf-translate-web",
"start_cmd": "mkdir -p logs && nohup /home/hz1/miniconda3/envs/openclaw/bin/python3.12 app.py > logs/app.log 2>&1 & disown",
"stop_cmd": "pkill -f 'pdf-translate-web.*app.py' || true",
"health_url": "http://localhost:16021/",
"admin_url": "http://localhost:16021/admin",
"description": "英文PDF翻译中文网站,支持用户系统、会员体系",
"git_repo": "http://121.40.164.32:12007/hz4th_coder/pdf-translate-web.git",
"version": "v2.10.1",
"log_file": "logs/app.log",
"type": "web"
}
],
"external_services": [
@@ -40,9 +420,31 @@
"id": "meilisearch",
"name": "Meilisearch 搜索引擎",
"type": "external",
"ports": [16001],
"ports": [
16001
],
"health_url": "http://localhost:16001/health",
"description": "高性能搜索引擎,只读监控"
},
{
"id": "chroma",
"name": "Chroma 向量库",
"type": "external",
"ports": [
16010
],
"health_url": "http://localhost:16010/",
"description": "向量数据库(RAG 项目共用)"
},
{
"id": "embedding",
"name": "Embedding 服务",
"type": "external",
"ports": [
16011
],
"health_url": "http://localhost:16011/",
"description": "bge-large-zh-v1.5 向量化 + rerank 精排"
}
]
}
+24 -12
View File
@@ -8,7 +8,7 @@ import os
import re
import json
import subprocess
from flask import jsonify
from flask import jsonify, request
from utils import log_message
from config import PROJECTS_FILE
@@ -71,13 +71,10 @@ def _get_listening_ports():
port = int(port_match.group(1))
pid_match = re.search(r'pid=(\d+)', line)
if not pid_match:
continue
pid = int(pid_match.group(1))
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 'unknown'
proc_name = proc_match.group(1) if proc_match else '未知进程'
services.append({
'port': port,
@@ -165,7 +162,7 @@ def register_discovery_routes(app):
@app.route('/api/discovery/services')
def api_discovery_services():
"""扫描本机未注册的新服务"""
# 1. 获取已知端口
# 1. 收集已知端口
known_ports = set()
try:
with open(PROJECTS_FILE, 'r', encoding='utf-8') as f:
@@ -179,7 +176,16 @@ def register_discovery_routes(app):
except:
pass
# 2. 获取 Docker 容器端口
# 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}}'],
@@ -210,10 +216,16 @@ def register_discovery_routes(app):
continue
seen_ports.add(port)
cmdline = _get_process_cmdline(pid)
cwd = _get_process_cwd(pid)
name = _guess_service_name(pid, svc['process'], cmdline)
svc_type = _guess_service_type(cmdline)
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,
+54 -1
View File
@@ -68,9 +68,11 @@ def register_project_routes(app):
if project.get('start_cmds'):
for name, info in project['start_cmds'].items():
log_message(f"[API-START] 项目={project_id} 命令={info['cmd']} 目录={cwd}")
run_command(info['cmd'], cwd, project_id, f'start-{name}')
else:
cmd = project.get('start_cmd', 'python3 app.py')
log_message(f"[API-START] 项目={project_id} 命令={cmd} 目录={cwd}")
run_command(cmd, cwd, project_id, 'start')
return jsonify({'message': '服务启动中...'})
@@ -128,7 +130,9 @@ def register_project_routes(app):
'directory': data.get('directory', ''),
'start_cmd': data.get('start_cmd', ''),
'stop_cmd': data.get('stop_cmd', ''),
'log_file': data.get('log_file', ''),
'health_url': data.get('health_url', ''),
'admin_url': data.get('admin_url', ''),
'description': data.get('description', ''),
'git_repo': data.get('git_repo', ''),
'version': data.get('version', 'v1.0.0')
@@ -152,7 +156,7 @@ def register_project_routes(app):
return jsonify({'error': '项目不存在'}), 404
# 更新字段
for key in ['name', 'ports', 'directory', 'start_cmd', 'stop_cmd', 'health_url', 'description', 'git_repo', 'version']:
for key in ['name', 'ports', 'directory', 'log_file', 'start_cmd', 'stop_cmd', 'health_url', 'admin_url', 'description', 'git_repo', 'version']:
if key in data:
project[key] = data[key]
@@ -162,6 +166,55 @@ def register_project_routes(app):
return jsonify({'message': '项目更新成功'})
@app.route('/api/projects/<project_id>/log')
def api_project_log(project_id):
"""获取项目日志"""
projects = load_projects()
project = next((p for p in projects if p['id'] == project_id), None)
if not project:
return jsonify({'error': '项目不存在'}), 404
lines = request.args.get('lines', 200, type=int)
lines = max(1, min(lines, 5000)) # 限制 1-5000 行
log_file = project.get('log_file', '')
if not log_file:
# 默认:项目目录下的 logs/app.log
cwd = project.get('directory', '')
if cwd:
from config import WORKSPACE_DIR
import os
abs_dir = cwd if cwd.startswith('/') else os.path.join(WORKSPACE_DIR, cwd)
log_file = os.path.join(abs_dir, 'logs', 'app.log')
# 支持相对路径和绝对路径
import os
if not log_file.startswith('/'):
cwd = project.get('directory', '')
if cwd:
from config import WORKSPACE_DIR
abs_dir = cwd if cwd.startswith('/') else os.path.join(WORKSPACE_DIR, cwd)
log_file = os.path.join(abs_dir, log_file)
if not os.path.exists(log_file):
return jsonify({'log': f'日志文件不存在: {log_file}'})
try:
# 读取最后 N 行(高效方式)
with open(log_file, 'r', encoding='utf-8', errors='replace') as f:
all_lines = f.readlines()
tail_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
content = ''.join(tail_lines)
return jsonify({
'log': content if content else '(日志为空)',
'file': log_file,
'total_lines': len(all_lines),
'shown_lines': len(tail_lines)
})
except Exception as e:
return jsonify({'error': f'读取日志失败: {str(e)}'}), 500
@app.route('/api/projects/<project_id>', methods=['DELETE'])
def api_delete_project(project_id):
"""删除项目"""
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
+83
View File
File diff suppressed because one or more lines are too long
+490 -56
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>项目服务管理面板 v3.3.1</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>">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<script src="/static/vendor/tailwindcss.js"></script>
<link href="/static/vendor/remixicon/remixicon.css" rel="stylesheet">
<style>
body { background: #0f172a; }
.card { background: #1e293b; border: 1px solid #334155; }
@@ -28,6 +28,12 @@
.nav-btn i { font-size: 20px; color: #94a3b8; }
.nav-btn:hover i { color: #f1f5f9; }
.filter-bar { position: sticky; top: 0; z-index: 50; display: flex; gap: 8px; padding: 12px 0; background: #0f172a; margin-bottom: 16px; flex-wrap: wrap; }
.filter-search { padding: 8px 12px; border-radius: 8px; background: #1e293b; border: 1px solid #334155; color: #f1f5f9; font-size: 14px; outline: none; min-width: 200px; transition: border-color 0.2s; }
.filter-search:focus { border-color: #3b82f6; }
.filter-search::placeholder { color: #64748b; }
.filter-divider { width: 1px; height: 24px; background: #334155; margin: 0 4px; }
.filter-bar-btn[data-status].active { background: #8b5cf6; border-color: #8b5cf6; }
.filter-bar-btn.sort-active { background: #0ea5e9; border-color: #0ea5e9; color: #fff; }
.filter-bar-btn { display: flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 8px; background: #1e293b; border: 1px solid #334155; color: #94a3b8; font-size: 14px; cursor: pointer; transition: all 0.2s; }
.filter-bar-btn:hover { background: #334155; color: #f1f5f9; }
.filter-bar-btn.active { background: #3b82f6; border-color: #3b82f6; color: #fff; }
@@ -67,6 +73,9 @@
.history-item.toggle { border-left-color: #8b5cf6; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 50; display: flex; align-items: center; justify-content: center; }
.modal-content { max-width: 600px; width: 90%; max-height: 80vh; overflow: auto; }
.section-header { cursor: pointer; user-select: none; }
.section-header:hover { opacity: 0.85; }
.chevron { display: inline-block; transition: transform 0.2s ease; font-size: 14px; }
</style>
</head>
<body class="min-h-screen text-gray-100">
@@ -168,11 +177,18 @@
<!-- 筛选器(顶部固定) -->
<div class="filter-bar">
<input type="text" id="projectSearch" class="filter-search" placeholder="🔍 搜索服务名称/描述/端口..." oninput="setSearch(this.value)" />
<button onclick="filterType('all')" class="filter-bar-btn active" data-type="all"><i class="ri-apps-line"></i> 全部</button>
<button onclick="filterType('web')" class="filter-bar-btn" data-type="web"><i class="ri-server-line"></i> Web服务</button>
<button onclick="filterType('custom')" class="filter-bar-btn" data-type="custom"><i class="ri-global-line"></i> 自定义</button>
<button onclick="filterType('cli')" class="filter-bar-btn" data-type="cli"><i class="ri-terminal-box-line"></i> CLI工具</button>
<button onclick="filterType('extension')" class="filter-bar-btn" data-type="extension"><i class="ri-chrome-line"></i> 插件</button>
<span class="filter-divider"></span>
<button onclick="setStatusFilter('')" class="filter-bar-btn" data-status=""><i class="ri-globe-line"></i> 全部状态</button>
<button onclick="setStatusFilter('online')" class="filter-bar-btn" data-status="online"><i class="ri-wifi-line"></i> 在线中</button>
<button onclick="setStatusFilter('admin')" class="filter-bar-btn" data-status="admin"><i class="ri-settings-3-line"></i> 有后台</button>
<span class="filter-divider"></span>
<button onclick="togglePortSort()" id="portSortBtn" class="filter-bar-btn" title="按端口号排序"><i class="ri-sort-asc"></i><span>端口排序</span></button>
<button onclick="showAddServiceModal()" class="filter-bar-btn add-btn"><i class="ri-add-circle-line"></i> 新增服务</button>
</div>
@@ -555,20 +571,20 @@
</div>
<!-- 容器列表 -->
<h3 class="text-sm font-semibold text-gray-300 mb-2 flex items-center gap-2">
<i class="ri-computer-line text-blue-400"></i> 容器列表
<h3 class="text-sm font-semibold text-gray-300 mb-2 flex items-center gap-2 section-header" onclick="toggleSection('docker-containers-grid', this.querySelector('.chevron'))">
<i class="ri-arrow-down-s-line chevron"></i><i class="ri-computer-line text-blue-400"></i> 容器列表
<span id="dockerContainerCount" class="text-xs text-gray-500"></span>
</h3>
<div id="dockerContainersList" class="space-y-2 mb-6">
<div id="docker-containers-grid" class="space-y-2 mb-6">
<div class="text-gray-500 text-sm py-4 text-center">加载中...</div>
</div>
<!-- 镜像列表 -->
<h3 class="text-sm font-semibold text-gray-300 mb-2 flex items-center gap-2">
<i class="ri-stack-line text-purple-400"></i> 镜像列表
<h3 class="text-sm font-semibold text-gray-300 mb-2 flex items-center gap-2 section-header" onclick="toggleSection('docker-images-grid', this.querySelector('.chevron'))">
<i class="ri-arrow-down-s-line chevron"></i><i class="ri-stack-line text-purple-400"></i> 镜像列表
<span id="dockerImageCount" class="text-xs text-gray-500"></span>
</h3>
<div id="dockerImagesList" class="space-y-1">
<div id="docker-images-grid" class="space-y-1">
<div class="text-gray-500 text-sm py-4 text-center">加载中...</div>
</div>
</div>
@@ -698,15 +714,16 @@
</div>
</div>
<!-- 新增自定义服务模态框 -->
<!-- 新增/编辑自定义服务模态框 -->
<div id="addServiceModal" class="modal-overlay hidden">
<div class="card rounded-xl modal-content p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg">新增自定义服务</h3>
<h3 id="addServiceModalTitle" class="font-bold text-lg">新增自定义服务</h3>
<button onclick="closeAddServiceModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button>
</div>
<form id="addServiceForm" onsubmit="saveCustomService(event)">
<input type="hidden" id="editServiceId">
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">服务名称 *</label>
<input type="text" id="serviceName" required class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如:外部API、监控面板">
@@ -909,6 +926,83 @@
</div>
</div>
<!-- 编辑项目配置模态框 -->
<div id="editProjectModal" class="modal-overlay hidden">
<div class="card rounded-xl modal-content p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="editProjectModalTitle" class="font-bold text-lg">编辑项目配置</h3>
<button onclick="closeEditProjectModal()" class="text-gray-400 hover:text-white"><i class="ri-close-line text-xl"></i></button>
</div>
<form id="editProjectForm" onsubmit="saveProjectEdit(event)">
<input type="hidden" id="editProjectId">
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">项目名称</label>
<input type="text" id="editProjectName" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">项目目录 <span class="text-xs text-gray-500">(相对或绝对路径)</span></label>
<input type="text" id="editProjectDir" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="相对:works/my-project 或 绝对:/home/user/project">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">端口(逗号分隔)</label>
<input type="text" id="editProjectPorts" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如:16022,16023">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">健康检查URL</label>
<input type="text" id="editProjectHealthUrl" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如:http://localhost:16022/">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">后台URL</label>
<input type="text" id="editProjectAdminUrl" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如:http://localhost:16022/admin">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">Git 仓库地址</label>
<input type="text" id="editProjectGitRepo" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="例如:http://.../project.git">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">版本</label>
<input type="text" id="editProjectVersion" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg" placeholder="v1.0.0">
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">📁 日志文件路径 <span class="text-xs text-gray-500">(相对或绝对路径)</span></label>
<input type="text" id="editProjectLogFile" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg font-mono text-sm" placeholder="相对:logs/app.log 或绝对路径。留空则默认 项目目录/logs/app.log">
<p class="text-xs text-gray-500 mt-1">服务日志文件的位置,点击「日志」按钮时读取此文件</p>
</div>
<div class="mb-3">
<label class="block text-gray-400 text-sm mb-1">📋 启动命令</label>
<textarea id="editProjectStartCmd" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg font-mono text-sm" rows="3" placeholder="例如:nohup python3 app.py > logs/app.log 2>&1 & disown"></textarea>
<p class="text-xs text-gray-500 mt-1">服务启动时执行的命令</p>
</div>
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">🛑 停止命令</label>
<textarea id="editProjectStopCmd" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg font-mono text-sm" rows="2" placeholder="例如:pkill -f 'python.*project.*app.py' || true"></textarea>
<p class="text-xs text-gray-500 mt-1">服务停止时执行的命令</p>
</div>
<div class="mb-4">
<label class="block text-gray-400 text-sm mb-1">描述</label>
<input type="text" id="editProjectDesc" class="w-full bg-gray-700 text-gray-200 px-3 py-2 rounded-lg">
</div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeEditProjectModal()" class="btn bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-lg">取消</button>
<button type="submit" class="btn bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg">💾 保存</button>
</div>
</form>
</div>
</div>
<!-- 导航按钮 -->
<div class="nav-buttons">
<button onclick="scrollToTop()" class="nav-btn" title="回到顶部"><i class="ri-arrow-up-line"></i></button>
@@ -920,6 +1014,9 @@
let cronTasks = [];
let currentTab = 'projects';
let currentFilter = 'all';
let searchKeyword = '';
let statusFilter = ''; // '' = all, 'online' = running/partial, 'admin' = has admin_url
let portSort = ''; // '' = none, 'asc', 'desc'
let externalIp = localStorage.getItem('externalIp') || '192.168.2.17';
let enableLogs = localStorage.getItem('enableLogs') === 'true';
@@ -929,11 +1026,46 @@
// 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 };
let desktopNotifyEnabled = false;
// ==================== 折叠/展开 ====================
function getCollapsedState() {
const stored = localStorage.getItem('collapsedSections');
return stored ? JSON.parse(stored) : {};
}
function saveCollapsedState(state) {
localStorage.setItem('collapsedSections', JSON.stringify(state));
}
function toggleSection(sectionId, chevronEl) {
const grid = document.getElementById(sectionId);
if (!grid) return;
const state = getCollapsedState();
if (grid.classList.contains('hidden')) {
grid.classList.remove('hidden');
if (chevronEl) chevronEl.style.transform = 'rotate(0deg)';
state[sectionId] = false;
} else {
grid.classList.add('hidden');
if (chevronEl) chevronEl.style.transform = 'rotate(-90deg)';
state[sectionId] = true;
}
saveCollapsedState(state);
}
function applyCollapsedState(sectionId, gridEl, chevronEl) {
const state = getCollapsedState();
if (state[sectionId]) {
gridEl.classList.add('hidden');
if (chevronEl) chevronEl.style.transform = 'rotate(-90deg)';
} else {
gridEl.classList.remove('hidden');
if (chevronEl) chevronEl.style.transform = 'rotate(0deg)';
}
}
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab-btn').forEach(btn => {
@@ -957,6 +1089,12 @@
document.getElementById('processListSection').classList.add('hidden');
}
// 离开系统Tab时关闭非实时刷新定时器
if (tab !== 'system' && systemRefreshTimer) {
clearInterval(systemRefreshTimer);
systemRefreshTimer = null;
}
if (tab === 'cron') {
loadCronTasks();
}
@@ -965,6 +1103,8 @@
loadDesktopNotifySetting();
renderEmailRules();
loadSystemStats();
// 启动系统资源定时刷新(按页面刷新间隔)
startSystemRefresh();
}
}
@@ -1281,14 +1421,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();
@@ -1297,14 +1440,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;
@@ -1341,6 +1498,16 @@
const res = await fetch('/api/projects');
const data = await res.json();
projects = data.projects;
// 加载外部服务列表
try {
const extRes = await fetch('/api/external-services');
const extData = await extRes.json();
window._externalServices = extData.services || [];
} catch (e) {
window._externalServices = [];
}
renderProjects();
updateStats();
updateConnectionStatus(true);
@@ -1375,11 +1542,40 @@
function filterType(type) {
currentFilter = type;
document.querySelectorAll('.filter-bar-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.type === type);
if (btn.dataset.type !== undefined) {
btn.classList.toggle('active', btn.dataset.type === type);
}
});
renderProjects();
}
function setSearch(val) {
searchKeyword = (val || '').trim().toLowerCase();
renderProjects();
}
function setStatusFilter(status) {
statusFilter = status;
document.querySelectorAll('.filter-bar-btn[data-status]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.status === status);
});
renderProjects();
}
function togglePortSort() {
if (!portSort) portSort = 'asc';
else if (portSort === 'asc') portSort = 'desc';
else portSort = '';
const btn = document.getElementById('portSortBtn');
btn.classList.toggle('sort-active', !!portSort);
const icon = btn.querySelector('i');
const label = btn.querySelector('span');
if (portSort === 'asc') { icon.className = 'ri-sort-asc'; label.textContent = '端口↑'; }
else if (portSort === 'desc') { icon.className = 'ri-sort-desc'; label.textContent = '端口↓'; }
else { icon.className = 'ri-sort-asc'; label.textContent = '端口排序'; }
renderProjects();
}
function renderProjects() {
const list = document.getElementById('projectsList');
@@ -1387,10 +1583,43 @@
const customServices = getCustomServices();
const allProjects = [...projects, ...customServices];
const filtered = currentFilter === 'all' ? allProjects : allProjects.filter(p => p.type === currentFilter || (currentFilter === 'web' && p.isCustom));
let filtered = currentFilter === 'all' ? allProjects : allProjects.filter(p => p.type === currentFilter || (currentFilter === 'web' && p.isCustom));
// 关键字搜索:名称/描述/id/端口
if (searchKeyword) {
filtered = filtered.filter(p => {
const haystack = [
p.name || '',
p.description || '',
p.id || '',
...(p.ports || []),
p.url || ''
].join(' ').toLowerCase();
return haystack.includes(searchKeyword);
});
}
// 状态筛选:在线中(running/partial/ 有后台(admin_url
if (statusFilter === 'online') {
filtered = filtered.filter(p => {
const st = p.status?.status;
return st === 'running' || st === 'partial';
});
} else if (statusFilter === 'admin') {
filtered = filtered.filter(p => !!p.admin_url);
}
// 端口排序:升/降序
if (portSort) {
filtered = filtered.slice().sort((a, b) => {
const pa = a.ports && a.ports.length ? a.ports[0] : 0;
const pb = b.ports && b.ports.length ? b.ports[0] : 0;
return portSort === 'asc' ? pa - pb : pb - pa;
});
}
if (filtered.length === 0) {
list.innerHTML = '<div class="text-center py-12 text-gray-400"><i class="ri-folder-open-line text-4xl"></i><p class="mt-2">暂无项目</p></div>';
list.innerHTML = '<div class="text-center py-12 text-gray-400"><i class="ri-folder-open-line text-4xl"></i><p class="mt-2">暂无匹配项目</p></div>';
return;
}
@@ -1419,23 +1648,38 @@
const archivedProjs = projs.filter(p => activeStatus[p.id] === false);
if (activeProjs.length > 0) {
html += `<div class="mb-6"><h2 class="text-lg font-semibold text-gray-300 mb-3 flex items-center gap-2"><i class="${typeInfo.icon} text-${typeInfo.color}-400"></i>${typeInfo.name}<span class="text-sm text-gray-500">(${activeProjs.length})</span></h2><div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">`;
const secId = 'section-web-active';
html += `<div class="mb-6"><h2 class="text-lg font-semibold text-gray-300 mb-3 flex items-center gap-2 section-header" onclick="toggleSection('${secId}', this.querySelector('.chevron'))"><i class="ri-arrow-down-s-line chevron"></i><i class="${typeInfo.icon} text-${typeInfo.color}-400"></i>${typeInfo.name}<span class="text-sm text-gray-500">(${activeProjs.length})</span></h2><div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3" id="${secId}">`;
activeProjs.forEach(p => { html += renderProjectCard(p, true); });
html += '</div></div>';
}
if (archivedProjs.length > 0) {
html += `<div class="mb-6 opacity-60"><h2 class="text-lg font-semibold text-gray-400 mb-3 flex items-center gap-2"><i class="ri-archive-line text-gray-400"></i>归档服务<span class="text-sm text-gray-500">(${archivedProjs.length})</span></h2><div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">`;
const secId = 'section-web-archived';
html += `<div class="mb-6 opacity-60"><h2 class="text-lg font-semibold text-gray-400 mb-3 flex items-center gap-2 section-header" onclick="toggleSection('${secId}', this.querySelector('.chevron'))"><i class="ri-arrow-down-s-line chevron"></i><i class="ri-archive-line text-gray-400"></i>归档服务<span class="text-sm text-gray-500">(${archivedProjs.length})</span></h2><div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3" id="${secId}">`;
archivedProjs.forEach(p => { html += renderProjectCard(p, false); });
html += '</div></div>';
}
} else {
html += `<div class="mb-6"><h2 class="text-lg font-semibold text-gray-300 mb-3 flex items-center gap-2"><i class="${typeInfo.icon} text-${typeInfo.color}-400"></i>${typeInfo.name}<span class="text-sm text-gray-500">(${projs.length})</span></h2><div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">`;
const secId = 'section-' + type;
html += `<div class="mb-6"><h2 class="text-lg font-semibold text-gray-300 mb-3 flex items-center gap-2 section-header" onclick="toggleSection('${secId}', this.querySelector('.chevron'))"><i class="ri-arrow-down-s-line chevron"></i><i class="${typeInfo.icon} text-${typeInfo.color}-400"></i>${typeInfo.name}<span class="text-sm text-gray-500">(${projs.length})</span></h2><div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3" id="${secId}">`;
projs.forEach(p => { html += renderProjectCard(p, true); });
html += '</div></div>';
}
}
list.innerHTML = html;
// 应用已保存的折叠状态
setTimeout(() => {
const state = getCollapsedState();
for (const [secId, collapsed] of Object.entries(state)) {
const grid = document.getElementById(secId);
const chevron = grid?.parentElement?.querySelector('.chevron');
if (grid && collapsed) {
grid.classList.add('hidden');
if (chevron) chevron.style.transform = 'rotate(-90deg)';
}
}
}, 10);
}
function getActiveStatus() {
@@ -1466,7 +1710,8 @@
<div class="flex items-center gap-1 text-xs flex-wrap">
<a href="${p.url}" target="_blank" class="px-2 py-0.5 rounded bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30">访问</a>
${p.admin_url ? `<a href="${p.admin_url}" target="_blank" class="text-yellow-400 hover:text-yellow-300 ml-1">后台</a>` : ''}
<button onclick="deleteCustomService('${p.id}')" class="text-red-400 hover:text-red-300 ml-2" title="删除服务"><i class="ri-delete-bin-line"></i></button>
<button onclick="editCustomService('${p.id}')" class="text-blue-400 hover:text-blue-300 ml-1" title="编辑服务"><i class="ri-edit-line"></i></button>
<button onclick="deleteCustomService('${p.id}')" class="text-red-400 hover:text-red-300 ml-1" title="删除服务"><i class="ri-delete-bin-line"></i></button>
</div>
</div>
`;
@@ -1512,6 +1757,7 @@
<button onclick="restartProject('${p.id}')" class="btn bg-yellow-600 hover:bg-yellow-700 px-2 py-0.5 rounded text-xs">重启</button>
` : `<button onclick="startProject('${p.id}')" class="btn bg-green-600 hover:bg-green-700 px-2 py-0.5 rounded text-xs">启动</button>`}
<button onclick="viewLog('${p.id}')" class="btn bg-gray-600 hover:bg-gray-700 px-2 py-0.5 rounded text-xs">日志</button>
<button onclick="editProject('${p.id}')" class="btn bg-blue-600 hover:bg-blue-700 px-2 py-0.5 rounded text-xs">编辑</button>
</div>
<button onclick="toggleServiceActive('${p.id}')" class="text-xs ${isActive ? 'text-green-400 hover:text-green-300' : 'text-gray-400 hover:text-gray-300'}" title="${isActive ? '点击归档' : '点击激活'}">
<i class="ri-${isActive ? 'checkbox-circle' : 'archive'}-line"></i>
@@ -1741,7 +1987,7 @@
return Array.from(checkboxes).map(cb => cb.value);
}
function onResourceChange() {
function onResourceChange(preselectedServiceId) {
const selected = getSelectedResources();
const unitEl = document.getElementById('emailRuleThresholdUnit');
const thresholdEl = document.getElementById('emailRuleThreshold');
@@ -1755,7 +2001,7 @@
// 显示/隐藏服务选择器
if (hasServiceDown) {
serviceSelector.style.display = 'block';
populateServiceSelector();
populateServiceSelector(preselectedServiceId);
// 服务下线使用分钟作为阈值单位
unitEl.textContent = '分钟';
thresholdEl.value = 5;
@@ -1794,17 +2040,63 @@
}
}
function populateServiceSelector() {
function populateServiceSelector(preselectedId) {
const sel = document.getElementById('emailRuleServiceId');
const currentVal = sel.value;
// 收集所有web类型服务
const webServices = projects.filter(p => p.type === 'web');
let options = '<option value="">-- 请选择要监控的服务 --</option>';
webServices.forEach(p => {
const portsStr = p.ports ? p.ports.join(',') : '';
const selected = p.id === currentVal ? ' selected' : '';
options += `<option value="${p.id}"${selected}>${p.name} (端口: ${portsStr})</option>`;
const currentVal = preselectedId || sel.value;
// 收集所有可监控的服务
const allServices = [];
// 1. Web 类型项目
const webProjects = projects.filter(p => p.type === 'web');
webProjects.forEach(p => {
allServices.push({
id: p.id,
name: p.name,
ports: p.ports || [],
type: 'web',
description: p.description || ''
});
});
// 2. 自定义服务(localStorage
const customServices = getCustomServices();
customServices.forEach(s => {
const port = s.port ? [parseInt(s.port)] : [];
allServices.push({
id: s.id,
name: s.name,
ports: port,
type: 'custom',
description: s.description || '自定义外部服务'
});
});
// 3. 外部服务(从 projects.json 中的 external_services
const externalServices = window._externalServices || [];
externalServices.forEach(s => {
allServices.push({
id: s.id,
name: s.name,
ports: s.ports || [],
type: 'external',
description: s.description || ''
});
});
const typeLabels = { web: 'Web服务', custom: '自定义', external: '外部' };
let options = '<option value="">-- 请选择要监控的服务 --</option>';
allServices.forEach(s => {
const portsStr = s.ports.length > 0 ? s.ports.join(',') : '无端口';
const label = s.type !== 'web' ? ` [${typeLabels[s.type]}]` : '';
const selected = s.id === currentVal ? ' selected' : '';
options += `<option value="${s.id}"${selected}>${s.name}${label} (端口: ${portsStr})</option>`;
});
if (allServices.length === 0) {
options += '<option value="" disabled>暂无可用服务</option>';
}
sel.innerHTML = options;
}
@@ -1868,11 +2160,8 @@
const cb = document.querySelector(`.email-rule-resource[value="${r}"]`);
if (cb) cb.checked = true;
});
onResourceChange();
// 服务下线相关
if (rule.serviceId) {
document.getElementById('emailRuleServiceId').value = rule.serviceId;
}
onResourceChange(rule.serviceId);
// 服务下线相关 - serviceId 已通过 onResourceChange 传入
document.getElementById('emailRuleThreshold').value = rule.threshold;
document.getElementById('emailRuleTriggerType').value = rule.triggerType || 'single';
document.getElementById('emailRuleTriggerParam').value = rule.triggerParam || 3;
@@ -2183,6 +2472,8 @@
}
function showAddServiceModal() {
document.getElementById('addServiceModalTitle').textContent = '新增自定义服务';
document.getElementById('editServiceId').value = '';
document.getElementById('serviceName').value = '';
document.getElementById('servicePort').value = '';
document.getElementById('servicePath').value = '';
@@ -2193,6 +2484,36 @@
document.getElementById('addServiceModal').classList.remove('hidden');
}
function editCustomService(serviceId) {
const services = getCustomServices();
const svc = services.find(s => s.id === serviceId);
if (!svc) return;
document.getElementById('addServiceModalTitle').textContent = `编辑自定义服务: ${svc.name}`;
document.getElementById('editServiceId').value = svc.id;
document.getElementById('serviceName').value = svc.name || '';
document.getElementById('servicePort').value = svc.port || '';
// 尝试从 url 中解析 path
let path = '';
if (svc.url && svc.port) {
const prefix = `http://${externalIp}:${svc.port}`;
if (svc.url.startsWith(prefix)) {
path = svc.url.substring(prefix.length);
}
}
document.getElementById('servicePath').value = path;
document.getElementById('serviceFullUrl').value = svc.url || '';
document.getElementById('serviceAdminUrl').value = svc.admin_url || '';
document.getElementById('serviceDesc').value = svc.description || '';
if (svc.port) {
updateServicePreview();
}
document.getElementById('addServiceModal').classList.remove('hidden');
}
function closeAddServiceModal() {
document.getElementById('addServiceModal').classList.add('hidden');
}
@@ -2212,6 +2533,7 @@
function saveCustomService(event) {
event.preventDefault();
const editId = document.getElementById('editServiceId').value;
const name = document.getElementById('serviceName').value.trim();
const port = document.getElementById('servicePort').value.trim();
const path = document.getElementById('servicePath').value.trim();
@@ -2235,18 +2557,35 @@
}
const services = getCustomServices();
const newService = {
id: 'custom_' + Date.now(),
name: name,
url: mainUrl,
admin_url: adminUrl || null,
description: desc || '自定义外部服务',
type: 'custom',
isCustom: true
};
services.push(newService);
saveCustomServices(services);
if (editId) {
// 编辑模式:更新已有服务
const idx = services.findIndex(s => s.id === editId);
if (idx === -1) {
alert('服务不存在');
return;
}
services[idx].name = name;
services[idx].port = port || '';
services[idx].url = mainUrl;
services[idx].admin_url = adminUrl || null;
services[idx].description = desc || '自定义外部服务';
} else {
// 新增模式
const newService = {
id: 'custom_' + Date.now(),
name: name,
port: port || '',
url: mainUrl,
admin_url: adminUrl || null,
description: desc || '自定义外部服务',
type: 'custom',
isCustom: true
};
services.push(newService);
}
saveCustomServices(services);
closeAddServiceModal();
loadProjects();
}
@@ -2314,16 +2653,21 @@
}
}
async function viewLog(id) {
async function viewLog(id, lines = 200) {
const project = projects.find(p => p.id === id);
document.getElementById('logTitle').textContent = `${project?.name || id} - 日志`;
document.getElementById('logTitle').textContent = `${project?.name || id} - 日志 (最近${lines}行)`;
document.getElementById('logContent').textContent = '加载中...';
document.getElementById('logModal').classList.remove('hidden');
document.getElementById('logModal').classList.add('flex');
try {
const res = await fetch(`/api/projects/${id}/log`);
const res = await fetch(`/api/projects/${id}/log?lines=${lines}`);
const data = await res.json();
document.getElementById('logContent').textContent = data.log || '暂无日志';
let display = data.log || '暂无日志';
if (data.file) {
display = `📁 日志文件: ${data.file}\n📊 共 ${data.total_lines} 行,显示最近 ${data.shown_lines} 行\n${'─'.repeat(60)}\n\n${display}`;
}
document.getElementById('logContent').textContent = display;
} catch (e) {
document.getElementById('logContent').textContent = '获取日志失败: ' + e.message;
}
@@ -2334,6 +2678,86 @@
document.getElementById('logModal').classList.remove('flex');
}
// ==================== 项目编辑 ====================
function editProject(id) {
const project = projects.find(p => p.id === id);
if (!project || project.type !== 'web') return;
document.getElementById('editProjectModalTitle').textContent = `${project.name} - 编辑配置`;
document.getElementById('editProjectId').value = project.id;
document.getElementById('editProjectName').value = project.name || '';
document.getElementById('editProjectDir').value = project.directory || '';
document.getElementById('editProjectPorts').value = (project.ports || []).join(',');
document.getElementById('editProjectHealthUrl').value = project.health_url || '';
document.getElementById('editProjectAdminUrl').value = project.admin_url || '';
document.getElementById('editProjectGitRepo').value = project.git_repo || '';
document.getElementById('editProjectVersion').value = project.version || '';
document.getElementById('editProjectLogFile').value = project.log_file || '';
document.getElementById('editProjectStartCmd').value = project.start_cmd || '';
document.getElementById('editProjectStopCmd').value = project.stop_cmd || '';
document.getElementById('editProjectDesc').value = project.description || '';
document.getElementById('editProjectModal').classList.remove('hidden');
}
function closeEditProjectModal() {
document.getElementById('editProjectModal').classList.add('hidden');
}
async function saveProjectEdit(event) {
event.preventDefault();
const id = document.getElementById('editProjectId').value;
const name = document.getElementById('editProjectName').value.trim();
const directory = document.getElementById('editProjectDir').value.trim();
const portsStr = document.getElementById('editProjectPorts').value.trim();
const healthUrl = document.getElementById('editProjectHealthUrl').value.trim();
const adminUrl = document.getElementById('editProjectAdminUrl').value.trim();
const gitRepo = document.getElementById('editProjectGitRepo').value.trim();
const version = document.getElementById('editProjectVersion').value.trim();
const logFile = document.getElementById('editProjectLogFile').value.trim();
const startCmd = document.getElementById('editProjectStartCmd').value.trim();
const stopCmd = document.getElementById('editProjectStopCmd').value.trim();
const description = document.getElementById('editProjectDesc').value.trim();
if (!name) { alert('请输入项目名称'); return; }
const ports = portsStr ? portsStr.split(',').map(p => parseInt(p.trim())).filter(p => !isNaN(p)) : [];
const data = {
name,
ports,
directory,
start_cmd: startCmd,
stop_cmd: stopCmd,
health_url: healthUrl,
admin_url: adminUrl,
git_repo: gitRepo,
log_file: logFile,
version,
description
};
try {
const res = await fetch(`/api/projects/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (result.error) {
alert('保存失败: ' + result.error);
} else {
alert(result.message || '保存成功');
closeEditProjectModal();
loadProjects();
}
} catch (e) {
alert('保存失败: ' + e.message);
}
}
// ==================== Cron 管理 ====================
async function loadCronTasks() {
@@ -2750,6 +3174,11 @@
clearInterval(refreshTimer);
refreshTimer = setInterval(() => { if (currentTab === 'projects') loadProjects(); }, seconds * 1000);
// 如果当前在系统Tab且非实时模式,更新系统刷新间隔
if (currentTab === 'system' && !document.getElementById('realtimeCheck').checked) {
startSystemRefresh();
}
}
setInterval(() => {
@@ -2796,7 +3225,7 @@
}
function renderDockerContainers(containers) {
const list = document.getElementById('dockerContainersList');
const list = document.getElementById('docker-containers-grid');
if (containers.length === 0) {
list.innerHTML = '<div class="text-gray-500 text-sm py-4 text-center">暂无容器</div>';
return;
@@ -2828,7 +3257,7 @@
}
function renderDockerImages(images) {
const list = document.getElementById('dockerImagesList');
const list = document.getElementById('docker-images-grid');
if (images.length === 0) {
list.innerHTML = '<div class="text-gray-500 text-sm py-4 text-center">暂无镜像</div>';
return;
@@ -2886,7 +3315,12 @@
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 res = await fetch('/api/discovery/services');
// 收集前端已注册的自定义服务端口,传给后端排除
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} 个`;
@@ -2899,7 +3333,7 @@
<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}</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">
+4 -2
View File
@@ -50,9 +50,11 @@ def load_projects_full():
def save_projects(projects):
"""保存项目配置"""
"""保存项目配置(保留 server 和 external_services"""
full_data = load_projects_full()
full_data['projects'] = projects
with open(PROJECTS_FILE, 'w', encoding='utf-8') as f:
json.dump({'projects': projects}, f, ensure_ascii=False, indent=2)
json.dump(full_data, f, ensure_ascii=False, indent=2)
def check_port(port):
Executable
+24
View 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