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
|
#!/usr/bin/env python3
|
||||||
import logging
|
import logging, os
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
from aiohttp import web
|
||||||
try:
|
try:
|
||||||
from sdk.plugin_command_decorator import plugin_command, command
|
from sdk.plugin_command_decorator import plugin_command, command
|
||||||
|
from sdk.plugin_web import PluginWebMixin
|
||||||
except ImportError:
|
except ImportError:
|
||||||
def plugin_command(n=None,d=None,p=None):
|
def plugin_command(n=None,d=None,p=None):
|
||||||
def deco(f):
|
def deco(f):f._is_plugin_command=True;f._command_name=n or f.__name__;return f
|
||||||
f._is_plugin_command=True;f._command_name=n or f.__name__
|
|
||||||
f._command_description=d or (f.__doc__ or "").strip();return f
|
|
||||||
return deco
|
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__)
|
logger=logging.getLogger(__name__)
|
||||||
|
|
||||||
class Plugin:
|
class Plugin(PluginWebMixin):
|
||||||
def __init__(self, plugin_name=None, config=None, bridge=None, n=None, c=None):
|
def __init__(self, plugin_name=None, config=None, bridge=None):
|
||||||
self.plugin_name=plugin_name or n;self.config=config or c;self.bridge=bridge
|
PluginWebMixin.__init__(self)
|
||||||
self.network_bridge=None;self.is_running=False
|
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):
|
async def initialize(self):
|
||||||
logger.info(f"init: {self.plugin_name}")
|
logger.info(f"init: {self.plugin_name}")
|
||||||
@@ -28,17 +34,21 @@ class Plugin:
|
|||||||
"/api/example/info",self._api_info,methods=["GET"],require_auth=False)
|
"/api/example/info",self._api_info,methods=["GET"],require_auth=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"network skip: {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
|
self.is_running=True
|
||||||
|
|
||||||
async def _api_info(self,req):
|
async def _api_info(self,req):
|
||||||
from aiohttp import web
|
|
||||||
return web.json_response({"plugin":self.plugin_name,"status":"running"})
|
return web.json_response({"plugin":self.plugin_name,"status":"running"})
|
||||||
|
|
||||||
@plugin_command(name="echo",description="echo input")
|
@plugin_command(name="echo",description="echo input")
|
||||||
async def cmd_echo(self,*args):
|
async def cmd_echo(self,*args):
|
||||||
return " ".join(args) if args else "echo: no input"
|
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):
|
async def cmd_status(self,*args):
|
||||||
return f"{self.plugin_name} v{self.config.get('version','?')} - running"
|
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
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from aiohttp import web
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -64,6 +64,7 @@ class WebPanelManager:
|
|||||||
logs.setup_routes(app, self.base_path)
|
logs.setup_routes(app, self.base_path)
|
||||||
projects.setup_project_routes(app, self.sm)
|
projects.setup_project_routes(app, self.sm)
|
||||||
proxy.setup_proxy_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")
|
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