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
+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.widgets import Static, Input, Header, Footer
from textual.reactive import reactive
from services.sysmon_widget import SysMonWidget
from typing import List, Dict
import asyncio
from datetime import datetime
@@ -658,6 +659,42 @@ class TUIFramework(App):
except Exception as 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:
"""TUI服务"""