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:
@@ -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 — 触发事件
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user