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,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"]
|
||||||
@@ -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
|
||||||
@@ -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.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服务"""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user