29 Commits

Author SHA1 Message Date
qinglong 2798100323 docs: 更新ROADMAP至v0.8.0,补充v0.6-v0.8完成内容 2026-06-14 21:44:42 +08:00
qinglong 2a0f62ef1c Merge branch 'dev' 2026-06-14 20:22:37 +08:00
qinglong 8ca6da67ab fix: app_v3.js同步app.js的SENSU_BASE前缀改动 2026-06-14 20:21:08 +08:00
qinglong 10630f3b8f fix: 插件路由注册时从配置文件fallback读取面板路径,解决初始化顺序导致的路径缺失 2026-06-14 20:15:27 +08:00
qinglong 6c6c88b22a fix: 插件API路由统一加面板前缀(/SenSu),解决反代后插件页面空白 2026-06-14 20:14:22 +08:00
qinglong 11e8bf2a71 Merge branch 'dev' 2026-06-14 19:52:16 +08:00
qinglong 26d515e080 fix: WebSocket连接根据页面协议自动选择ws/wss,修复HTTPS下Mixed Content报错 2026-06-14 19:39:36 +08:00
qinglong cd45372141 fix: start.bat pip安装抑制警告避免误判失败,报错信息区分venv/no-venv 2026-06-14 19:23:50 +08:00
qinglong d626a0631a fix: RotatingFileHandler强制UTF-8编码避免Windows GBK日志乱码 2026-06-14 19:06:32 +08:00
qinglong 846b8a979a fix: Windows控制台强制UTF-8避免emoji编解码报错 2026-06-14 19:03:23 +08:00
qinglong 2c5cd57ad4 fix: venv改为懒加载,兼容嵌入式CPython无venv模块 2026-06-14 19:02:27 +08:00
qinglong b9252bdc58 fix: start.bat添加PYTHONPATH确保嵌入式CPython能找到项目模块 2026-06-14 18:59:37 +08:00
qinglong 40a1502f8b feat: start.bat支持--no-venv跳过虚拟环境使用系统Python 2026-06-14 18:29:31 +08:00
qinglong 6d30096005 feat: start.bat支持--no-venv跳过虚拟环境使用系统Python 2026-06-14 18:28:49 +08:00
qinglong 28670204cf fix: 横版数据更新时间移至卡片左下角col-left 2026-06-14 17:03:03 +08:00
qinglong 316986c420 Merge branch 'dev' 2026-06-14 16:57:54 +08:00
qinglong 6c36743b89 feat: Sentinel节点卡片添加HTTP链路延迟显示(横版左下角/网格右上角) 2026-06-14 16:57:04 +08:00
qinglong 280cd3e994 fix: HiChart用canvas自身getBoundingClientRect获取渲染尺寸,消除CSS拉伸导致的线条变形 2026-06-14 16:30:53 +08:00
qinglong f40b87ea52 fix: 仪表盘版本号从version.json读取,修复硬编码v0.6.0 2026-06-14 16:24:41 +08:00
qinglong 5f38c9e8eb fix: version.json url 指向 main.zip 2026-06-14 16:19:44 +08:00
qinglong 8168d24fad Merge branch 'dev' 2026-06-14 16:18:24 +08:00
qinglong a82c94bd51 chore: version.json切回dev 2026-06-14 16:04:09 +08:00
qinglong 35e2368023 Merge branch 'dev' 2026-06-14 16:04:07 +08:00
qinglong b29b236a52 chore: version.json→main 2026-06-14 16:04:05 +08:00
qinglong 3f5b01f817 refactor: 版本号从version.json读取 配置模板删version字段 2026-06-14 16:03:04 +08:00
qinglong 4d51a7620e chore: version.json切回dev分支 2026-06-14 15:58:36 +08:00
qinglong 103d192e8f merge: dev → main (v0.8.0 稳定版) 2026-06-14 15:57:08 +08:00
qinglong 90a563aef0 chore: version.json指向main分支 2026-06-14 15:56:45 +08:00
AskaEth 5a9d96dc1b Initial commit 2026-06-10 12:23:04 +08:00
18 changed files with 197 additions and 70 deletions
+31 -2
View File
@@ -1,7 +1,7 @@
# SenSu 开发路线图 # SenSu 开发路线图
> 当前版本: Alpha 0.7.0 > 当前版本: v0.8.0
> 更新: 2026-06-13 > 更新: 2026-06-14
--- ---
@@ -46,4 +46,33 @@
- [x] systemd 集成 — `deploy/sensu.service` - [x] systemd 集成 — `deploy/sensu.service`
- [x] Docker 化 — `deploy/Dockerfile` (Alpine, <100MB) - [x] Docker 化 — `deploy/Dockerfile` (Alpine, <100MB)
## 六、v0.6 — 安全 + 监控 ✅ 全部完成
- [x] API Key 系统 — sk-前缀, 权限模板(readonly/monitor/full), TTL过期, 用量追踪
- [x] 全端点 CSRF 保护 — panel_auth 装饰器, X-CSRF-Token 检查
- [x] 安全中间件 — X-Content-Type-Options/X-Frame-Options/XSS-Protection/Referrer-Policy
- [x] SSRF 防护 — URL scheme 白名单, 错误脱敏
- [x] Sentinel 集群监控插件 — 多节点实时仪表盘 + 节点管理 WebUI
- [x] 版本更新检测 — version.json, Gitea ZIP 下载, 备份/回滚
- [x] 插件导入安装 — zip 上传解压
- [x] 配置文件自动创建 — config.yaml.example → config.yaml
## 七、v0.7 — Windows 兼容 ✅ 全部完成
- [x] Windows 兼容 — SO_REUSEPORT 条件化, /proc 回退, 路径分隔符
- [x] 嵌入式 CPython 支持 — venv 懒加载, UTF-8 编码强制, sys.path 注入
- [x] `start.bat` — Windows 一键启动, `--no-venv` 系统 Python 模式
- [x] PYTHONPATH 自动注入 — 嵌入式 Python 模块搜索路径
- [x] HiChart 高分辨率 Canvas 图表 — devicePixelRatio 缩放, CSS 尺寸对齐
- [x] 宝塔面板部署适配 — D:\BtSoft\python\ 嵌入式 CPython 替换 PyPy
## 八、v0.8 — 网络 + 反代 ✅ 全部完成
- [x] WebSocket WSS 自动检测 — location.protocol → ws/wss
- [x] 插件路由统一面板前缀 — /SenSu/plugin/{name}, /SenSu/{plugin}/api/*
- [x] nginx 反代 WebSocket 支持 — proxy_http_version 1.1 + Upgrade/Connection 头
- [x] HTTP 链路延迟显示 — 节点卡片 RTT 实时展示 (横版左下角/网格右上角)
- [x] 多服务器部署 — test/t2/t3/cd 四节点集群
- [x] 仪表盘版本号修复 — 从 version.json 读取, 不再硬编码
--- ---
@@ -1,7 +1,6 @@
framework: framework:
debug: true debug: true
name: SenSu name: SenSu
version: v0.8.0
branch: dev branch: dev
repo_url: https://git.yeij.top/AskaEth/SenSu repo_url: https://git.yeij.top/AskaEth/SenSu
logging: logging:
+14
View File
@@ -4,9 +4,23 @@
import logging import logging
import asyncio import asyncio
import sys import sys
import os as _os
import signal import signal
import time import time
import argparse import argparse
# 确保项目根目录在 sys.path 中 (嵌入式 CPython / PyPy 需要)
_sys_path_root = _os.path.dirname(_os.path.abspath(__file__))
if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root)
# Windows 控制台强制 UTF-8 (避免 GBK 编解码 emoji 报错)
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
from services.project_engine import ProjectEngine from services.project_engine import ProjectEngine
from services.pyenv_manager import PyEnvManager from services.pyenv_manager import PyEnvManager
from services.proxy_service import ProxyService from services.proxy_service import ProxyService
+10 -4
View File
@@ -116,6 +116,7 @@ class Plugin(PluginWebMixin):
"system": c.get("system", {}), "system": c.get("system", {}),
"platform": c.get("platform", {}), "platform": c.get("platform", {}),
"last_seen": c.get("last_seen", 0), "last_seen": c.get("last_seen", 0),
"latency_ms": c.get("latency_ms", 0),
"error": c.get("error", ""), "error": c.get("error", ""),
}) })
return web.json_response({"nodes": result}) return web.json_response({"nodes": result})
@@ -195,12 +196,13 @@ class Plugin(PluginWebMixin):
for node in nodes: for node in nodes:
if not node.get("enabled", True): if not node.get("enabled", True):
continue continue
online, sys_info, err = await self._fetch_system(node) online, sys_info, err, latency = await self._fetch_system(node)
_NODE_CACHE[node.get("id", "")] = { _NODE_CACHE[node.get("id", "")] = {
"online": online, "online": online,
"system": sys_info or {}, "system": sys_info or {},
"platform": (sys_info or {}).get("platform", {}), "platform": (sys_info or {}).get("platform", {}),
"last_seen": time.time(), "last_seen": time.time(),
"latency_ms": latency,
"error": err if not online else "", "error": err if not online else "",
} }
# 每次轮询都推送 SSE (确保实时性) # 每次轮询都推送 SSE (确保实时性)
@@ -217,14 +219,17 @@ class Plugin(PluginWebMixin):
def _sync_fetch(): def _sync_fetch():
try: try:
t0 = time.time()
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
req = urllib.request.Request(f"{url}{base}/api/system", headers=headers) req = urllib.request.Request(f"{url}{base}/api/system", headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp: with urllib.request.urlopen(req, timeout=timeout) as resp:
elapsed = round((time.time() - t0) * 1000) # ms
if resp.status == 200: if resp.status == 200:
return True, _json.loads(resp.read().decode()), "" return True, _json.loads(resp.read().decode()), "", elapsed
return False, None, f"HTTP {resp.status}" return False, None, f"HTTP {resp.status}", elapsed
except Exception as e: except Exception as e:
return False, None, str(e)[:120] elapsed = round((time.time() - t0) * 1000)
return False, None, str(e)[:120], elapsed
return await asyncio.get_event_loop().run_in_executor(None, _sync_fetch) return await asyncio.get_event_loop().run_in_executor(None, _sync_fetch)
@@ -242,6 +247,7 @@ class Plugin(PluginWebMixin):
"system": c.get("system", {}), "system": c.get("system", {}),
"platform": c.get("platform", {}), "platform": c.get("platform", {}),
"last_seen": c.get("last_seen", 0), "last_seen": c.get("last_seen", 0),
"latency_ms": c.get("latency_ms", 0),
"error": c.get("error", ""), "error": c.get("error", ""),
}) })
return result return result
+26 -11
View File
@@ -157,12 +157,12 @@
window.addEventListener('resize', function(){ self._resize(); }); window.addEventListener('resize', function(){ self._resize(); });
} }
HiChart.prototype._resize = function() { HiChart.prototype._resize = function() {
var rect = this.canvas.parentElement.getBoundingClientRect(); // 用 canvas 自身的 CSS 渲染尺寸 → 内部分辨率 = CSS尺寸 × DPR (保持高清)
var w = Math.max(rect.width - 8, 100); var box = this.canvas.getBoundingClientRect();
var h = 60; var w = box.width, h = box.height;
if (w <= 0 || h <= 0) { w = 280; h = 60; } // fallback
this.canvas.width = w * this._dpr; this.canvas.width = w * this._dpr;
this.canvas.height = h * this._dpr; this.canvas.height = h * this._dpr;
// CSS controls display size via width:100%;height:60px
this.ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0); this.ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0);
this._draw(); this._draw();
}; };
@@ -294,12 +294,13 @@
(sysInfo.trim() ? '<div class="node-sys">'+esc(sysInfo)+'</div>' : '')+ (sysInfo.trim() ? '<div class="node-sys">'+esc(sysInfo)+'</div>' : '')+
'<div class="node-url">'+esc(n.url||'')+'</div>'+ '<div class="node-url">'+esc(n.url||'')+'</div>'+
(n.notes ? '<div class="node-notes">📝 '+esc(n.notes)+'</div>' : '')+ (n.notes ? '<div class="node-notes">📝 '+esc(n.notes)+'</div>' : '')+
'<div class="node-latency" style="font-size:.7rem;color:var(--text-dim);margin-top:4px">⏱ —</div>'+
'<div class="col-info" style="font-size:.65rem;color:var(--text-dim);margin-top:2px"></div>'+
'</div>'+ '</div>'+
'<div class="col-right-stack">'+ '<div class="col-right-stack">'+
'<div class="mtr" id="mtr-'+id+'-cpu"><div class="mtr-data"><span class="rlabel">CPU</span> <span class="rval cpu-val">0%</span></div><canvas id="'+id+'-cpu" height="60"></canvas></div>'+ '<div class="mtr" id="mtr-'+id+'-cpu"><div class="mtr-data"><span class="rlabel">CPU</span> <span class="rval cpu-val">0%</span></div><canvas id="'+id+'-cpu" height="60"></canvas></div>'+
'<div class="mtr" id="mtr-'+id+'-mem"><div class="mtr-data"><span class="rlabel">MEM</span> <span class="rval mem-val">0%</span> <span class="mem-row-detail" style="font-size:.65rem;color:var(--text-dim)"></span></div><canvas id="'+id+'-mem" height="60"></canvas></div>'+ '<div class="mtr" id="mtr-'+id+'-mem"><div class="mtr-data"><span class="rlabel">MEM</span> <span class="rval mem-val">0%</span> <span class="mem-row-detail" style="font-size:.65rem;color:var(--text-dim)"></span></div><canvas id="'+id+'-mem" height="60"></canvas></div>'+
'<div class="mtr" id="mtr-'+id+'-net"><div class="mtr-data"><span class="rlabel">NET</span> <span class="rval net-down" style="font-size:.85rem">0</span> <span class="rval net-up" style="font-size:.85rem">0</span></div><canvas id="'+id+'-net" height="60"></canvas></div>'+ '<div class="mtr" id="mtr-'+id+'-net"><div class="mtr-data"><span class="rlabel">NET</span> <span class="rval net-down" style="font-size:.85rem">0</span> <span class="rval net-up" style="font-size:.85rem">0</span></div><canvas id="'+id+'-net" height="60"></canvas></div>'+
'<div class="col-info" style="text-align:right;font-size:.7rem;color:var(--text-dim);margin-top:4px"></div>'+
'</div>'+ '</div>'+
'<div class="col-disk"><div class="disk-list"></div></div>'+ '<div class="col-disk"><div class="disk-list"></div></div>'+
'<div class="col-extra">'+ '<div class="col-extra">'+
@@ -311,7 +312,10 @@
'</div>'; '</div>';
} else { } else {
card.innerHTML = card.innerHTML =
'<div class="node-name">'+(n.online?'🟢':'🔴')+' '+esc(n.name)+'</div>'+ '<div style="display:flex;justify-content:space-between;align-items:flex-start">'+
'<div class="node-name">'+(n.online?'🟢':'🔴')+' '+esc(n.name)+'</div>'+
'<span class="latency-val" style="font-size:.7rem;color:var(--text-dim);flex-shrink:0">⏱ —</span>'+
'</div>'+
(sysInfo.trim() ? '<div class="node-sys">'+esc(sysInfo)+'</div>' : '')+ (sysInfo.trim() ? '<div class="node-sys">'+esc(sysInfo)+'</div>' : '')+
'<div class="node-url">'+esc(n.url||'')+'</div>'+ '<div class="node-url">'+esc(n.url||'')+'</div>'+
(n.notes ? '<div class="node-notes">📝 '+esc(n.notes)+'</div>' : '')+ (n.notes ? '<div class="node-notes">📝 '+esc(n.notes)+'</div>' : '')+
@@ -342,6 +346,17 @@
sysEl.textContent = si2; sysEl.textContent = si2;
} else if (sysEl) { sysEl.remove(); } } else if (sysEl) { sysEl.remove(); }
// 延迟 (横版: col-left底部 / 网格: 右上角)
var latEl = card.querySelector('.node-latency') || card.querySelector('.latency-val');
if (latEl && n.latency_ms > 0) {
var ms = n.latency_ms;
latEl.textContent = '⏱ ' + (ms < 1000 ? ms + 'ms' : (ms/1000).toFixed(1)+'s');
latEl.style.color = ms < 100 ? 'var(--success)' : ms < 500 ? 'var(--warning,#e0af68)' : 'var(--error)';
} else if (latEl && n.online === false) {
latEl.textContent = '⏱ —';
latEl.style.color = 'var(--text-dim)';
}
var body = isRow ? card.querySelector('.col-right-stack') : card.querySelector('.metrics-body'); var body = isRow ? card.querySelector('.col-right-stack') : card.querySelector('.metrics-body');
if (!body) return; if (!body) return;
@@ -512,7 +527,7 @@ function bar(v) { return v < 60 ? 'bar-green' : (v < 85 ? 'bar-yellow' : 'bar-re
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function pollNodes() { function pollNodes() {
fetch("/sentinel/api/nodes").then(function(r){return r.json()}).then(function(d){ fetch((window.SENSU_BASE||"")+"/sentinel/api/nodes").then(function(r){return r.json()}).then(function(d){
renderNodes(d.nodes || []); renderNodes(d.nodes || []);
}).catch(function(e){ }).catch(function(e){
var grid = document.getElementById("sentinel-grid"); var grid = document.getElementById("sentinel-grid");
@@ -564,7 +579,7 @@ function bar(v) { return v < 60 ? 'bar-green' : (v < 85 ? 'bar-yellow' : 'bar-re
notes: document.getElementById("edit-notes").value.trim() notes: document.getElementById("edit-notes").value.trim()
}; };
if(!data.name||!data.url){ alert("名称和 URL 不能为空"); return; } if(!data.name||!data.url){ alert("名称和 URL 不能为空"); return; }
var resp = await fetch("/sentinel/api/nodes",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(data)}); var resp = await fetch((window.SENSU_BASE||"")+"/sentinel/api/nodes",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(data)});
var r = await resp.json(); var r = await resp.json();
if(r.ok){ hideEdit(); loadNodes(); } if(r.ok){ hideEdit(); loadNodes(); }
else alert("保存失败: "+(r.error||"")); else alert("保存失败: "+(r.error||""));
@@ -572,7 +587,7 @@ function bar(v) { return v < 60 ? 'bar-green' : (v < 85 ? 'bar-yellow' : 'bar-re
async function deleteNode(id,name){ async function deleteNode(id,name){
if(!confirm("确认删除节点 '"+name+"'?")) return; if(!confirm("确认删除节点 '"+name+"'?")) return;
await fetch("/sentinel/api/nodes/delete",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:id})}); await fetch((window.SENSU_BASE||"")+"/sentinel/api/nodes/delete",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:id})});
loadNodes(); loadNodes();
} }
@@ -582,7 +597,7 @@ function bar(v) { return v < 60 ? 'bar-green' : (v < 85 ? 'bar-yellow' : 'bar-re
var el = document.getElementById("test-result"); var el = document.getElementById("test-result");
el.innerHTML = '<span style="color:var(--text-dim)">⏳ 测试中...</span>'; el.innerHTML = '<span style="color:var(--text-dim)">⏳ 测试中...</span>';
try { try {
var resp = await fetch("/sentinel/api/nodes/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:url,api_key:key})}); var resp = await fetch((window.SENSU_BASE||"")+"/sentinel/api/nodes/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:url,api_key:key})});
var r = await resp.json(); var r = await resp.json();
if(r.ok) el.innerHTML = '<span style="color:var(--success)">✅ 连接成功 — CPU:'+(r.system.cpu.percent||0)+'% MEM:'+(r.system.memory.percent||0)+'%</span>'; if(r.ok) el.innerHTML = '<span style="color:var(--success)">✅ 连接成功 — CPU:'+(r.system.cpu.percent||0)+'% MEM:'+(r.system.memory.percent||0)+'%</span>';
else el.innerHTML = '<span style="color:var(--error)">❌ '+esc(r.error||'失败')+'</span>'; else el.innerHTML = '<span style="color:var(--error)">❌ '+esc(r.error||'失败')+'</span>';
@@ -590,7 +605,7 @@ function bar(v) { return v < 60 ? 'bar-green' : (v < 85 ? 'bar-yellow' : 'bar-re
} }
async function loadNodes(){ async function loadNodes(){
var resp = await fetch("/sentinel/api/nodes"); var resp = await fetch((window.SENSU_BASE||"")+"/sentinel/api/nodes");
var data = await resp.json(); var data = await resp.json();
var nodes = data.nodes||[]; var nodes = data.nodes||[];
var list = document.getElementById("node-list"); var list = document.getElementById("node-list");
+20 -3
View File
@@ -193,7 +193,22 @@ class InternetService:
logger.debug("默认路由设置完成 (已加认证)") logger.debug("默认路由设置完成 (已加认证)")
async def register_plugin_route(self, plugin_name: str, route_path: str, def _read_panel_path(self) -> str:
"""从 base_config.yaml 读取面板路径 (插件早于面板初始化时的 fallback)"""
try:
import yaml
from pathlib import Path
cfg_path = Path(__file__).resolve().parent.parent / "config" / "framework" / "base_config.yaml"
if cfg_path.exists():
with open(cfg_path) as f:
cfg = yaml.safe_load(f)
p = cfg.get("panel", {}).get("entrance", {}).get("path", "/panel")
return f"/{p.strip('/')}"
except Exception:
pass
return ""
async def register_plugin_route(self, plugin_name: str, route_path: str,
handler: Callable, methods: List[str] = ["GET"], handler: Callable, methods: List[str] = ["GET"],
require_auth: bool = True): require_auth: bool = True):
"""为插件注册HTTP路由 - 修复冻结路由器问题""" """为插件注册HTTP路由 - 修复冻结路由器问题"""
@@ -202,7 +217,8 @@ class InternetService:
if not route_path.startswith('/'): if not route_path.startswith('/'):
route_path = '/' + route_path route_path = '/' + route_path
full_path = f"/{plugin_name}{route_path}" base = self.http_app.get('panel_base_path', '') or self._read_panel_path()
full_path = f"{base}/{plugin_name}{route_path}"
# 创建包装器处理权限验证 # 创建包装器处理权限验证
async def wrapped_handler(request): async def wrapped_handler(request):
@@ -267,7 +283,8 @@ class InternetService:
if not ws_path.startswith('/'): if not ws_path.startswith('/'):
ws_path = '/' + ws_path ws_path = '/' + ws_path
full_path = f"/plugin/{plugin_name}/ws{ws_path}" base = self.http_app.get('panel_base_path', '') or self._read_panel_path()
full_path = f"{base}/plugin/{plugin_name}/ws{ws_path}"
async def websocket_handler(request): async def websocket_handler(request):
try: try:
+4 -2
View File
@@ -89,7 +89,8 @@ class LogService:
runtime_handler = logging.handlers.RotatingFileHandler( runtime_handler = logging.handlers.RotatingFileHandler(
self.log_dir / "runtime" / runtime_log_file, self.log_dir / "runtime" / runtime_log_file,
maxBytes=self._parse_size(self.config['logging'].get('max_file_size', '10MB')), maxBytes=self._parse_size(self.config['logging'].get('max_file_size', '10MB')),
backupCount=self.config['logging'].get('max_log_files', 3) backupCount=self.config['logging'].get('max_log_files', 3),
encoding='utf-8'
) )
runtime_handler.setLevel(getattr(logging, self.config['logging']['level'], logging.INFO)) runtime_handler.setLevel(getattr(logging, self.config['logging']['level'], logging.INFO))
runtime_handler.setFormatter(file_formatter) runtime_handler.setFormatter(file_formatter)
@@ -101,7 +102,8 @@ class LogService:
debug_handler = logging.handlers.RotatingFileHandler( debug_handler = logging.handlers.RotatingFileHandler(
self.log_dir / "debug" / debug_log_file, self.log_dir / "debug" / debug_log_file,
maxBytes=self._parse_size(self.config['logging'].get('max_file_size', '10MB')), maxBytes=self._parse_size(self.config['logging'].get('max_file_size', '10MB')),
backupCount=self.config['logging'].get('max_log_files', 3) backupCount=self.config['logging'].get('max_log_files', 3),
encoding='utf-8'
) )
debug_handler.setLevel(logging.DEBUG) debug_handler.setLevel(logging.DEBUG)
debug_handler.setFormatter(file_formatter) debug_handler.setFormatter(file_formatter)
+3 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""SenSu PyEnvManager — Python 版本管理 + venv + 依赖安装""" """SenSu PyEnvManager — Python 版本管理 + venv + 依赖安装"""
import os, sys, subprocess, logging, venv, shutil import os, sys, subprocess, logging, shutil
from pathlib import Path from pathlib import Path
from typing import Optional, List from typing import Optional, List
@@ -76,7 +76,8 @@ class PyEnvManager:
logger.info(f"创建 venv: {venv_path} (Python {python_version or 'default'})") logger.info(f"创建 venv: {venv_path} (Python {python_version or 'default'})")
try: try:
venv.create(str(venv_path), with_pip=True, clear=True) import venv as _venv
_venv.create(str(venv_path), with_pip=True, clear=True)
# Install/upgrade pip # Install/upgrade pip
pip = str(venv_path / "bin" / "pip") pip = str(venv_path / "bin" / "pip")
subprocess.run([pip, "install", "--upgrade", "pip"], capture_output=True, timeout=60) subprocess.run([pip, "install", "--upgrade", "pip"], capture_output=True, timeout=60)
+15 -16
View File
@@ -149,11 +149,16 @@ class UpdateService:
return None return None
def _current_version(self) -> str: def _current_version(self) -> str:
"""从 version.json 读取本地版本号"""
try: try:
init = self.sm.get_service("init") vf = _PROJECT_ROOT / "version.json"
return init.get_config("base").get("framework", {}).get("version", "0.0.0").lstrip("vV") if vf.exists():
with open(vf) as f:
data = json.load(f)
return data.get("framework", {}).get("version", "0.0.0").lstrip("vV")
except Exception: except Exception:
return "0.0.0" pass
return "0.0.0"
# ── 执行更新 ── # ── 执行更新 ──
@@ -250,21 +255,15 @@ class UpdateService:
# 4.5 同步版本号到旧配置文件 # 4.5 同步版本号到旧配置文件
try: try:
old_cfg_path = str(_PROJECT_ROOT / "config" / "framework" / "base_config.yaml") # 同步版本号到 version.json
new_tpl_path = str(_PROJECT_ROOT / "config" / "framework" / "base_config.yaml.example") vf = _PROJECT_ROOT / "version.json"
if os.path.exists(old_cfg_path) and os.path.exists(new_tpl_path): if vf.exists():
import yaml as _yaml with open(vf) as f:
with open(new_tpl_path) as f: vdata = json.load(f)
new_tpl = _yaml.safe_load(f) new_ver = vdata.get("framework", {}).get("version", "")
new_ver = new_tpl.get("framework", {}).get("version", "")
if new_ver: if new_ver:
with open(old_cfg_path) as f:
old_cfg = _yaml.safe_load(f) or {}
old_cfg.setdefault("framework", {})["version"] = new_ver
with open(old_cfg_path, "w") as f:
_yaml.dump(old_cfg, f, default_flow_style=False, allow_unicode=True)
self._log(f"版本号已同步: {new_ver}") self._log(f"版本号已同步: {new_ver}")
logger.info(f"📝 版本号已同步: {new_ver}") logger.info(f"📝 版本号已同步: {new_ver}")
except Exception as e: except Exception as e:
logger.warning(f"版本号同步失败: {e}") logger.warning(f"版本号同步失败: {e}")
+3 -1
View File
@@ -85,7 +85,9 @@ class WebPanelManager:
logs.setup_routes(app, self.base_path) logs.setup_routes(app, self.base_path)
projects.setup_project_routes(app, self.sm, self.base_path) projects.setup_project_routes(app, self.sm, self.base_path)
proxy.setup_proxy_routes(app, self.sm, self.base_path) proxy.setup_proxy_routes(app, self.sm, self.base_path)
plugin_web.setup_plugin_web_routes(app, self.sm) # 存储面板前缀供插件路由使用
app['panel_base_path'] = self.base_path
plugin_web.setup_plugin_web_routes(app, self.sm, self.base_path)
files.setup_file_routes(app, self.sm, self.base_path) files.setup_file_routes(app, self.sm, self.base_path)
# 注册日志广播 # 注册日志广播
+7 -5
View File
@@ -4,7 +4,9 @@ from ..utils.auth import panel_auth
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def setup_plugin_web_routes(app, service_manager): def setup_plugin_web_routes(app, service_manager, base_path=""):
prefix = base_path.rstrip("/")
async def plugin_page(request): async def plugin_page(request):
name = request.match_info.get("name","") name = request.match_info.get("name","")
ps = service_manager.get_service("plugin") ps = service_manager.get_service("plugin")
@@ -36,7 +38,7 @@ def setup_plugin_web_routes(app, service_manager):
return web.json_response({"ok": True}) return web.json_response({"ok": True})
return web.json_response({"ok": False}, status=404) return web.json_response({"ok": False}, status=404)
app.router.add_get("/plugin/{name}", panel_auth(plugin_page)) app.router.add_get(f"{prefix}/plugin/{{name}}", panel_auth(plugin_page))
app.router.add_get("/plugin/{name}/sse", panel_auth(plugin_sse)) app.router.add_get(f"{prefix}/plugin/{{name}}/sse", panel_auth(plugin_sse))
app.router.add_post("/plugin/{name}/event", panel_auth(plugin_event)) app.router.add_post(f"{prefix}/plugin/{{name}}/event", panel_auth(plugin_event))
logger.info("Plugin web routes registered (已加认证)") logger.info(f"Plugin web routes registered at {prefix}/plugin/* (已加认证)")
+8 -8
View File
@@ -49,14 +49,14 @@ def setup_routes(app, prefix=''):
logger.info(f"📡 系统状态WS端点已注册: {prefix}/api/system/ws (已加认证)") logger.info(f"📡 系统状态WS端点已注册: {prefix}/api/system/ws (已加认证)")
def _read_version(): def _read_version():
"""Read version from config file (shared helper)""" """Read version from version.json (shared helper)"""
ver = "v0.6.0" ver = "v0.0.0"
try: try:
import yaml, os import json, os
config_path = os.path.join(os.path.dirname(__file__), "..", "..", "..", "config", "framework", "base_config.yaml") vf = os.path.join(os.path.dirname(__file__), "..", "..", "..", "version.json")
with open(config_path) as f: with open(vf) as f:
cfg = yaml.safe_load(f) data = json.load(f)
ver = cfg.get("framework", {}).get("version", ver) ver = data.get("framework", {}).get("version", ver)
except: except:
pass pass
return ver return ver
@@ -193,7 +193,7 @@ async def create_backup(req):
if sm: if sm:
try: try:
init = sm.get_service("init") init = sm.get_service("init")
ver = init.get_config("base").get("framework", {}).get("version", "0.0.0") ver = _read_version()
except: pass except: pass
name = f"SenSu_backup_{ver}_{_time.strftime('%Y%m%d_%H%M%S')}.zip" name = f"SenSu_backup_{ver}_{_time.strftime('%Y%m%d_%H%M%S')}.zip"
dest = _os.path.join(backup_dir, name) dest = _os.path.join(backup_dir, name)
+25 -5
View File
@@ -11,9 +11,22 @@ echo.
set PASS=0 set PASS=0
set FAIL=0 set FAIL=0
:: ── Python (venv) ── :: ── 检查 --no-venv 参数 ──
set PYTHON=%~dp0.venv\Scripts\python.exe set NO_VENV=0
if not exist "%PYTHON%" (echo [FAIL] .venv 未创建,请先运行: python -m venv .venv ^&^& .venv\Scripts\pip install -r requirements.txt & pause & exit /b 1) echo %* | findstr /i "\-\-no-venv" >nul && set NO_VENV=1
:: ── Python ──
if %NO_VENV% equ 1 (
set PYTHON=python
echo [INFO] --no-venv: 使用系统 Python
) else (
set PYTHON=%~dp0.venv\Scripts\python.exe
if not exist "%PYTHON%" (
echo [FAIL] .venv 未创建,请先运行: python -m venv .venv ^&^& .venv\Scripts\pip install -r requirements.txt
echo 或使用: start.bat --no-venv 跳过虚拟环境
pause & exit /b 1
)
)
"%PYTHON%" --version >nul 2>&1 "%PYTHON%" --version >nul 2>&1
if %errorlevel% equ 0 ( if %errorlevel% equ 0 (
echo [OK] Python echo [OK] Python
@@ -58,9 +71,13 @@ if exist "config\framework\base_config.yaml" (
echo. echo.
if %FAIL% gtr 0 ( if %FAIL% gtr 0 (
echo [WARN] %FAIL% 项缺失,自动安装... echo [WARN] %FAIL% 项缺失,自动安装...
"%PYTHON%" -m pip install -r requirements.txt "%PYTHON%" -m pip install -r requirements.txt --no-warn-script-location --root-user-action=ignore 2>&1
if %errorlevel% neq 0 ( if %errorlevel% neq 0 (
echo [FAIL] 安装失败,请手动执行: .venv\Scripts\pip install -r requirements.txt if %NO_VENV% equ 1 (
echo [FAIL] 安装失败,请手动执行: pip install -r requirements.txt
) else (
echo [FAIL] 安装失败,请手动执行: .venv\Scripts\pip install -r requirements.txt
)
pause pause
exit /b 1 exit /b 1
) )
@@ -73,6 +90,9 @@ echo 🚀 启动 SenSu (无头模式)...
echo Web 面板: http://0.0.0.0:4200/SenSu echo Web 面板: http://0.0.0.0:4200/SenSu
echo. echo.
:: 确保项目根目录在 Python 搜索路径中 (嵌入式 CPython 需要)
set PYTHONPATH=%~dp0;%PYTHONPATH%
"%PYTHON%" main.py --headless "%PYTHON%" main.py --headless
pause pause
+9 -2
View File
@@ -44,7 +44,14 @@ window.toggleTheme = function(){
// ═══ 路由系统 ═══ // ═══ 路由系统 ═══
// Hash format: #/dashboard | #/files | #/plugins/example_plugin // Hash format: #/dashboard | #/files | #/plugins/example_plugin
// Built-in pages map to ./static/pages/{name}.html // Built-in pages map to ./static/pages/{name}.html
// Plugin pages load from /plugin/{name} // Plugin pages load from {base}/plugin/{name}
// 从当前页面路径提取面板前缀 (e.g. /SenSu/home.html → /SenSu)
window.SENSU_BASE = (function(){
var p = window.location.pathname;
// 去掉最后的文件名部分
return p.substring(0, p.lastIndexOf('/')) || '';
})();
var BUILTIN_PAGES = ['dashboard','logs','console','plugins','projects','proxy','files','settings']; var BUILTIN_PAGES = ['dashboard','logs','console','plugins','projects','proxy','files','settings'];
var _pluginPagesCache = null; // {plugin_name: {path, title, icon}} var _pluginPagesCache = null; // {plugin_name: {path, title, icon}}
@@ -167,7 +174,7 @@ async function loadPluginPage(pluginName) {
if(!_pluginPagesCache) await fetchPluginPages(); if(!_pluginPagesCache) await fetchPluginPages();
try { try {
var resp = await fetch('/plugin/'+pluginName+'?t='+Date.now(), {credentials:'include'}); var resp = await fetch(window.SENSU_BASE+'/plugin/'+pluginName+'?t='+Date.now(), {credentials:'include'});
if(!resp.ok) throw new Error(resp.status); if(!resp.ok) throw new Error(resp.status);
var html = await resp.text(); var html = await resp.text();
+8 -2
View File
@@ -44,7 +44,13 @@ window.toggleTheme = function(){
// ═══ 路由系统 ═══ // ═══ 路由系统 ═══
// Hash format: #/dashboard | #/files | #/plugins/example_plugin // Hash format: #/dashboard | #/files | #/plugins/example_plugin
// Built-in pages map to ./static/pages/{name}.html // Built-in pages map to ./static/pages/{name}.html
// Plugin pages load from /plugin/{name} // Plugin pages load from {base}/plugin/{name}
// 从当前页面路径提取面板前缀 (e.g. /SenSu/home.html → /SenSu)
window.SENSU_BASE = (function(){
var p = window.location.pathname;
return p.substring(0, p.lastIndexOf('/')) || '';
})();
var BUILTIN_PAGES = ['dashboard','logs','console','plugins','projects','proxy','files','settings']; var BUILTIN_PAGES = ['dashboard','logs','console','plugins','projects','proxy','files','settings'];
var _pluginPagesCache = null; // {plugin_name: {path, title, icon}} var _pluginPagesCache = null; // {plugin_name: {path, title, icon}}
@@ -167,7 +173,7 @@ async function loadPluginPage(pluginName) {
if(!_pluginPagesCache) await fetchPluginPages(); if(!_pluginPagesCache) await fetchPluginPages();
try { try {
var resp = await fetch('/plugin/'+pluginName+'?t='+Date.now(), {credentials:'include'}); var resp = await fetch(window.SENSU_BASE+'/plugin/'+pluginName+'?t='+Date.now(), {credentials:'include'});
if(!resp.ok) throw new Error(resp.status); if(!resp.ok) throw new Error(resp.status);
var html = await resp.text(); var html = await resp.text();
+2 -1
View File
@@ -50,7 +50,8 @@ window.DashboardModule = {
} }
var base = window.location.pathname.split("/").slice(0, 2).join("/"); var base = window.location.pathname.split("/").slice(0, 2).join("/");
var ws = new WebSocket("ws://" + location.host + base + "/api/system/ws"); var proto = location.protocol === 'https:' ? 'wss://' : 'ws://';
var ws = new WebSocket(proto + location.host + base + "/api/system/ws");
self.ws = ws; self.ws = ws;
ws.onopen = function() { ws.onopen = function() {
+2 -1
View File
@@ -25,7 +25,8 @@ window.LogsModule = {
} }
var base = window.location.pathname.split("/").slice(0, 2).join("/"); var base = window.location.pathname.split("/").slice(0, 2).join("/");
var ws = new WebSocket("ws://" + location.host + base + "/api/logs/ws"); var proto = location.protocol === 'https:' ? 'wss://' : 'ws://';
var ws = new WebSocket(proto + location.host + base + "/api/logs/ws");
self.ws = ws; self.ws = ws;
ws.onopen = function() { ws.onopen = function() {
+10 -4
View File
@@ -1,11 +1,17 @@
{ {
"framework": { "framework": {
"version": "v0.8.0", "version": "v0.8.0",
"url": "https://git.yeij.top/AskaEth/SenSu/archive/dev.zip", "url": "https://git.yeij.top/AskaEth/SenSu/archive/main.zip",
"notes": "Sentinel横版折线图+磁盘分区+负载/SWAP/温度 | Windows兼容 | 自动更新+备份回滚 | 插件导入安装 | 安全加固29项" "notes": "Sentinel横版折线图+磁盘分区+负载/SWAP/温度 | Windows兼容 | 自动更新+备份回滚 | 插件导入安装 | 安全加固29项"
}, },
"plugins": { "plugins": {
"sentinel": { "version": "1.1.0", "url": "" }, "sentinel": {
"example_plugin": { "version": "1.0.0", "url": "" } "version": "1.1.0",
"url": ""
},
"example_plugin": {
"version": "1.0.0",
"url": ""
}
} }
} }