Phase 3: PluginWebMixin SDK + web plugin control panels

- New: sdk/plugin_web.py (PluginWebMixin: web pages, API routes, SSE)
- New: services/web_panel/routes/plugin_web.py (3 endpoints)
- New: plugins/example_plugin/dashboard.html (counter + SSE demo)
- Updated: example_plugin uses PluginWebMixin
- Tests: 28/28 passing
This commit is contained in:
2026-06-10 20:54:11 +08:00
parent c680fea8e0
commit e52f7f3566
7 changed files with 155 additions and 12 deletions
+20
View File
@@ -0,0 +1,20 @@
# Phase 3 开发进度总结
> 完成: 2026-06-10
> 测试: 28/28 通过
## 新增
- `sdk/plugin_web.py` — PluginWebMixin (Web页面注册+API+SSE)
- `services/web_panel/routes/plugin_web.py` — 插件面板路由
- `plugins/example_plugin/dashboard.html` — 示例仪表盘 (计数器+SSE)
## SDK API
- `register_web_page(path, title, html, icon)` — 注册控制页面
- `register_api_route(method, path, handler)` — 注册 REST 端点
- `push_sse_event(type, data)` — SSE 实时推送
- `handle_sse(request)` — SSE 连接处理
## 端点
- GET /SenSu/plugin/{name} — 插件控制面板
- GET /SenSu/plugin/{name}/sse — SSE 事件流
- POST /SenSu/plugin/{name}/event — 触发事件
+21 -11
View File
@@ -1,22 +1,28 @@
#!/usr/bin/env python3
import logging
import logging, os
from typing import Dict
from aiohttp import web
try:
from sdk.plugin_command_decorator import plugin_command, command
from sdk.plugin_web import PluginWebMixin
except ImportError:
def plugin_command(n=None,d=None,p=None):
def deco(f):
f._is_plugin_command=True;f._command_name=n or f.__name__
f._command_description=d or (f.__doc__ or "").strip();return f
def deco(f):f._is_plugin_command=True;f._command_name=n or f.__name__;return f
return deco
command=plugin_command
class PluginWebMixin:
def register_web_page(self,*a,**k):pass
def register_api_route(self,*a,**k):pass
logger=logging.getLogger(__name__)
class Plugin:
def __init__(self, plugin_name=None, config=None, bridge=None, n=None, c=None):
self.plugin_name=plugin_name or n;self.config=config or c;self.bridge=bridge
self.network_bridge=None;self.is_running=False
class Plugin(PluginWebMixin):
def __init__(self, plugin_name=None, config=None, bridge=None):
PluginWebMixin.__init__(self)
self.plugin_name=plugin_name or "example"
self.config=config or {}
self.bridge=bridge
self.network_bridge=None
self.is_running=False
async def initialize(self):
logger.info(f"init: {self.plugin_name}")
@@ -28,17 +34,21 @@ class Plugin:
"/api/example/info",self._api_info,methods=["GET"],require_auth=False)
except Exception as e:
logger.warning(f"network skip: {e}")
# Load dashboard HTML from file
html_path=os.path.join(os.path.dirname(__file__),"dashboard.html")
if os.path.exists(html_path):
with open(html_path) as hf:
self.register_web_page("example_plugin","Example Plugin",hf.read(),icon="P")
self.is_running=True
async def _api_info(self,req):
from aiohttp import web
return web.json_response({"plugin":self.plugin_name,"status":"running"})
@plugin_command(name="echo",description="echo input")
async def cmd_echo(self,*args):
return " ".join(args) if args else "echo: no input"
@plugin_command(name="plugin_status",description="show plugin status")
@plugin_command(name="plugin_status",description="show status")
async def cmd_status(self,*args):
return f"{self.plugin_name} v{self.config.get('version','?')} - running"
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html><html lang="zh"><head><meta charset="UTF-8"><title>Example</title>
<style>body{font-family:monospace;background:#0e1416;color:#e0e3e4;padding:16px}
h2{color:#00bcd4}.card{background:#1a1f21;border-radius:8px;padding:12px;margin:8px 0}
button{background:#00bcd4;color:#000;padding:8px 16px;border:none;border-radius:4px;cursor:pointer}
.counter{font-size:48px;color:#4caf50;text-align:center;padding:20px}
.log{background:#000;color:#0f0;padding:8px;border-radius:4px;max-height:200px;overflow-y:auto;font-size:12px}
</style></head><body>
<h2>Example Plugin Panel</h2>
<div class="card"><h3>Counter</h3><div class="counter" id="count">0</div>
<button onclick="inc()">+1</button> <button onclick="reset()">Reset</button></div>
<div class="card"><h3>Events (SSE)</h3><div class="log" id="log">Waiting...</div></div>
<script>let n=0;function inc(){n++;document.getElementById("count").textContent=n;fetch("/SenSu/plugin/example_plugin/event",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type:"counter",value:n})})}function reset(){n=0;document.getElementById("count").textContent=0}const es=new EventSource("/SenSu/plugin/example_plugin/sse");es.addEventListener("counter",e=>{const d=JSON.parse(e.data);document.getElementById("log").innerHTML+=new Date().toLocaleTimeString()+" counter="+d.value+"<br>"})</script></body></html>
+50
View File
@@ -0,0 +1,50 @@
"""PluginWebMixin — plugin web panel SDK"""
import logging, json, asyncio
from aiohttp import web
from typing import Dict, Callable
logger = logging.getLogger(__name__)
class PluginWebMixin:
def __init__(self):
self._web_pages: Dict[str, dict] = {}
self._api_routes: list = []
self._sse_clients: list = []
def register_web_page(self, path: str, title: str, html_content: str, icon: str = "P"):
self._web_pages[path] = {"title": title, "icon": icon, "html": html_content}
logger.info(f"Web page registered: {path}")
def register_api_route(self, method: str, path: str, handler: Callable):
self._api_routes.append((method, path, handler))
def get_web_pages(self) -> dict:
return self._web_pages
def get_api_routes(self) -> list:
return self._api_routes
async def push_sse_event(self, event_type: str, data: dict):
payload = json.dumps(data)
dead = []
for client in self._sse_clients:
try:
await client.send(f"event: {event_type}\\ndata: {payload}\\n\\n")
except:
dead.append(client)
for d in dead:
self._sse_clients.remove(d)
async def handle_sse(self, request):
resp = web.StreamResponse()
resp.headers["Content-Type"] = "text/event-stream"
resp.headers["Cache-Control"] = "no-cache"
await resp.prepare(request)
self._sse_clients.append(resp)
try:
while True:
await asyncio.sleep(30)
await resp.write(b": keepalive\\n\\n")
except:
self._sse_clients.remove(resp)
return resp
+2 -1
View File
@@ -5,7 +5,7 @@ import os
import logging
from pathlib import Path
from aiohttp import web
from .routes import auth, status, plugins, commands, logs, projects, proxy
from .routes import auth, status, plugins, commands, logs, projects, proxy, plugin_web
logger = logging.getLogger(__name__)
@@ -64,6 +64,7 @@ class WebPanelManager:
logs.setup_routes(app, self.base_path)
projects.setup_project_routes(app, self.sm)
proxy.setup_proxy_routes(app, self.sm)
plugin_web.setup_plugin_web_routes(app, self.sm)
# 注册日志广播
ls = self.sm.get_service("log")
+38
View File
@@ -0,0 +1,38 @@
from aiohttp import web
import json, logging
logger = logging.getLogger(__name__)
def setup_plugin_web_routes(app, service_manager):
async def plugin_page(request):
name = request.match_info.get("name","")
ps = service_manager.get_service("plugin")
plugin = ps.plugins.get(name)
if not plugin or not hasattr(plugin, "get_web_pages"):
return web.Response(text=f"Plugin {name} not found", status=404)
pages = plugin.get_web_pages()
if name in pages:
return web.Response(text=pages[name]["html"], content_type="text/html")
return web.json_response({"error": "no web page"})
async def plugin_sse(request):
name = request.match_info.get("name","")
ps = service_manager.get_service("plugin")
plugin = ps.plugins.get(name)
if plugin and hasattr(plugin, "handle_sse"):
return await plugin.handle_sse(request)
return web.json_response({"error": "SSE not supported"}, status=404)
async def plugin_event(request):
name = request.match_info.get("name","")
data = await request.json()
ps = service_manager.get_service("plugin")
plugin = ps.plugins.get(name)
if plugin and hasattr(plugin, "push_sse_event"):
await plugin.push_sse_event(data.get("type","event"), data)
return web.json_response({"ok": True})
return web.json_response({"ok": False}, status=404)
app.router.add_get("/plugin/{name}", plugin_page)
app.router.add_get("/plugin/{name}/sse", plugin_sse)
app.router.add_post("/plugin/{name}/event", plugin_event)
logger.info("Plugin web routes registered")
@@ -0,0 +1,12 @@
<!DOCTYPE html><html lang="zh"><head><meta charset="UTF-8"><title>Example Plugin</title>
<style>body{font-family:monospace;background:#0e1416;color:#e0e3e4;padding:16px}
h2{color:#00bcd4}.card{background:#1a1f21;border-radius:8px;padding:12px;margin:8px 0}
button{background:#00bcd4;color:#000;padding:8px 16px;border:none;border-radius:4px;cursor:pointer}
.counter{font-size:48px;color:#4caf50;text-align:center;padding:20px}
.log{background:#000;color:#0f0;padding:8px;border-radius:4px;max-height:200px;overflow-y:auto;font-size:12px}
</style></head><body>
<h2>Example Plugin Panel</h2>
<div class="card"><h3>Counter</h3><div class="counter" id="count">0</div>
<button onclick="inc()">+1</button> <button onclick="reset()">Reset</button></div>
<div class="card"><h3>Events (SSE)</h3><div class="log" id="log">Waiting...</div></div>
<script>let n=0;function inc(){n++;document.getElementById("count").textContent=n;fetch("/SenSu/plugin/example_plugin/event",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type:"counter",value:n})})}function reset(){n=0;document.getElementById("count").textContent=0}const es=new EventSource("/SenSu/plugin/example_plugin/sse");es.addEventListener("counter",e=>{const d=JSON.parse(e.data);document.getElementById("log").innerHTML+=new Date().toLocaleTimeString()+" counter="+d.value+"<br>"})</script></body></html>