v0.4+v0.5: TUI dashboard, tab completion, systemd, Docker, process isolation

- New: services/sysmon_widget.py (CPU/MEM/DISK/NET real-time)
- Enhanced: tui_service.py (4-section layout, plugin status panel)
- Enhanced: command_service.py + tui (Tab completion)
- New: deploy/sensu.service (systemd)
- New: deploy/Dockerfile (Alpine, 4200+4240)
- New: services/process_isolated.py (multiprocess plugin isolation)
- Tests: 23/23 passing
This commit is contained in:
2026-06-10 19:08:16 +08:00
parent d48ef65b35
commit 4b26b6f086
5 changed files with 219 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p data logs
EXPOSE 4200 4240
ENV SENSU_ADMIN_PASSWORD=changeme
ENV SENSU_API_PASSWORD=changeme
CMD ["python3", "main.py", "--headless"]
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=SenSu Plugin Framework
After=network.target
[Service]
Type=simple
User=aska
WorkingDirectory=/data/data/com.termux/files/home/Proj/SenSu_workspace
ExecStart=/data/data/com.termux/files/usr/bin/python3 /data/data/com.termux/files/home/Proj/SenSu_workspace/start.py --headless
Restart=on-failure
RestartSec=5
Environment=SENSU_ADMIN_PASSWORD=changeme
Environment=SENSU_API_PASSWORD=changeme
Environment=SENSU_PANEL_USER=admin
Environment=SENSU_PANEL_PASS=changeme
[Install]
WantedBy=multi-user.target
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""插件进程隔离 — 在子进程中运行插件,崩溃不影响框架"""
import asyncio, subprocess, json, os, logging, tempfile
from multiprocessing import Process, Queue
from typing import Dict, Any
logger = logging.getLogger(__name__)
def _plugin_runner(plugin_path: str, config_json: str, cmd_queue: Queue, result_queue: Queue):
"""子进程入口 - 加载插件并监听命令队列"""
import sys, importlib.util, json as j
sys.path.insert(0, os.path.dirname(plugin_path))
spec = importlib.util.spec_from_file_location("plugin", plugin_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
plugin = mod.Plugin(os.path.basename(os.path.dirname(plugin_path)),
j.loads(config_json), None)
asyncio.run(plugin.initialize())
while True:
cmd = cmd_queue.get()
if cmd == "__SHUTDOWN__":
asyncio.run(plugin.shutdown())
break
try:
method = getattr(plugin, cmd.get("method", ""), None)
if method:
result = method(*cmd.get("args", []))
result_queue.put({"ok": True, "result": str(result)})
else:
result_queue.put({"ok": False, "error": f"method not found: {cmd.get(method)}"})
except Exception as e:
result_queue.put({"ok": False, "error": str(e)})
class IsolatedPlugin:
"""进程隔离插件包装器"""
def __init__(self, plugin_name: str, plugin_path: str, config: dict):
self.name = plugin_name
self.cmd_queue = Queue()
self.result_queue = Queue()
self.process = Process(
target=_plugin_runner,
args=(plugin_path, json.dumps(config), self.cmd_queue, self.result_queue),
daemon=True
)
self.process.start()
logger.info(f"隔离插件已启动: {plugin_name} (PID={self.process.pid})")
def call(self, method: str, *args, timeout: float = 30):
self.cmd_queue.put({"method": method, "args": args})
try:
result = self.result_queue.get(timeout=timeout)
return result
except:
return {"ok": False, "error": "timeout"}
def shutdown(self):
self.cmd_queue.put("__SHUTDOWN__")
self.process.join(timeout=10)
if self.process.is_alive():
self.process.terminate()
logger.info(f"隔离插件已关闭: {self.name}")
@property
def pid(self):
return self.process.pid if self.process.is_alive() else None
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""SenSu TUI 系统监控组件 — CPU/内存/磁盘/插件状态"""
import psutil, time, asyncio, logging
from textual.widgets import Static
from textual.reactive import reactive
logger = logging.getLogger(__name__)
def get_system_stats():
"""获取系统实时状态"""
try:
cpu = psutil.cpu_percent(interval=0.1)
mem = psutil.virtual_memory()
disk = psutil.disk_usage("/")
net = psutil.net_io_counters()
return {
"cpu": cpu,
"mem_total": mem.total,
"mem_used": mem.used,
"mem_percent": mem.percent,
"disk_total": disk.total,
"disk_used": disk.used,
"disk_percent": disk.percent,
"net_sent": net.bytes_sent,
"net_recv": net.bytes_recv,
"time": time.time(),
}
except Exception as e:
return {"error": str(e)}
def format_bytes(b):
if b < 1024: return f"{b}B"
if b < 1024**2: return f"{b/1024:.1f}K"
if b < 1024**3: return f"{b/1024**2:.1f}M"
return f"{b/1024**3:.1f}G"
def make_bar(percent, width=20, filled="", empty=""):
n = int(percent / 100 * width)
return filled * n + empty * (width - n)
class SysMonWidget(Static):
"""系统监控显示组件"""
stats = reactive({})
plugin_info = reactive("")
def __init__(self):
super().__init__("系统监控初始化中...")
self._refresh_task = None
def on_mount(self):
self._refresh_task = asyncio.create_task(self._periodic_refresh())
async def _periodic_refresh(self):
while True:
try:
s = get_system_stats()
if "error" not in s:
cpu_bar = make_bar(s["cpu"], 20)
mem_bar = make_bar(s["mem_percent"], 20)
disk_bar = make_bar(s["disk_percent"], 20)
text = (
f"[bold cyan]━━━ 系统状态 ━━━[/]\n"
f"CPU {cpu_bar} {s['cpu']:5.1f}%\n"
f"MEM {mem_bar} {s['mem_percent']:5.1f}% "
f"({format_bytes(s['mem_used'])}/{format_bytes(s['mem_total'])})\n"
f"DISK {disk_bar} {s['disk_percent']:5.1f}% "
f"({format_bytes(s['disk_used'])}/{format_bytes(s['disk_total'])})\n"
f"NET ↑{format_bytes(s['net_sent'])}{format_bytes(s['net_recv'])}"
)
if self.plugin_info:
text += f"\n\n[bold yellow]━━━ 插件状态 ━━━[/]\n{self.plugin_info}"
self.update(text)
await asyncio.sleep(2)
except asyncio.CancelledError:
break
except Exception as e:
logger.debug(f"SysMon refresh error: {e}")
await asyncio.sleep(5)
def update_plugins(self, info: str):
self.plugin_info = info
def on_unmount(self):
if self._refresh_task:
self._refresh_task.cancel()
+37
View File
@@ -9,6 +9,7 @@ from textual.app import App
from textual.containers import Container, ScrollableContainer from textual.containers import Container, ScrollableContainer
from textual.widgets import Static, Input, Header, Footer from textual.widgets import Static, Input, Header, Footer
from textual.reactive import reactive from textual.reactive import reactive
from services.sysmon_widget import SysMonWidget
from typing import List, Dict from typing import List, Dict
import asyncio import asyncio
from datetime import datetime from datetime import datetime
@@ -658,6 +659,42 @@ class TUIFramework(App):
except Exception as e: except Exception as e:
print(f"❌ 关闭TUI时出错: {str(e)}") print(f"❌ 关闭TUI时出错: {str(e)}")
def _start_plugin_status_refresh(self):
async def refresh():
while True:
try:
ps = self.service_manager.get_service("plugin")
if ps and hasattr(ps, "plugin_status"):
lines = []
for name, status in ps.plugin_status.items():
icon = {"running":"🟢","error":"🔴","loading":"🟡","unloaded":""}.get(str(status),"")
lines.append(f"{icon} {name}: {status}")
widget = self.tui_app.query_one(SysMonWidget)
widget.update_plugins("\n".join(lines) if lines else "无已加载插件")
except: pass
await asyncio.sleep(3)
import asyncio
asyncio.create_task(refresh())
def _get_suggester(self):
from textual.suggester import Suggester
class CmdSuggester(Suggester):
async def get_suggestion(self, value):
try:
cs = self.app.query_one("#input-area").app
names = ["help","status","history","testlog","netdiag",
"permissions","pmpending","pmhelp","echo","plugin_status",
"scroll","autoscroll","create-plugin","exit","quit"]
if not value: return None
for n in names:
if n.startswith(value) and n != value:
return n
except: pass
return None
return CmdSuggester()
class TuiService: class TuiService:
"""TUI服务""" """TUI服务"""