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:
@@ -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
|
||||
@@ -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()
|
||||
@@ -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服务"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user