mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。 按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩, CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。 修复空值、异步和供应方类型边界,保持插件与 API 兼容合同, 并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
621 lines
26 KiB
Python
621 lines
26 KiB
Python
"""
|
||
插件公开能力投影。
|
||
"""
|
||
|
||
import inspect
|
||
import posixpath
|
||
import re
|
||
from typing import Any, Callable, Dict, List, Mapping, Optional
|
||
from urllib.parse import urlencode
|
||
|
||
from app.runtime.extensions.plugin.contracts import (
|
||
PluginDashboardError,
|
||
PluginNotFoundError,
|
||
supports_plugin_hook,
|
||
)
|
||
from app.runtime.log import logger as default_logger
|
||
from app.runtime.log import wrap_for_plugin_instance
|
||
from app.schemas.plugin import PluginDashboard
|
||
|
||
# Agent 助手形象 key 规则:插件内唯一,前端以 "<plugin_id>:<key>" 组合成全局选择值
|
||
_AGENT_PET_KEY_PATTERN = re.compile(r"^[a-z0-9_-]{1,32}$")
|
||
_AGENT_PET_MODES = frozenset({"stage", "renderer"})
|
||
_AGENT_PET_BUBBLES = frozenset({"host", "self"})
|
||
# 预览图与头像允许原样透传的绝对地址前缀,其余一律视为相对联邦产物目录的路径
|
||
_AGENT_PET_ABSOLUTE_PREVIEW_PREFIXES = ("http://", "https://", "data:")
|
||
|
||
|
||
class _InvalidAgentPet(ValueError):
|
||
"""
|
||
Agent 助手形象声明字段不符合宿主契约。
|
||
"""
|
||
|
||
|
||
class PluginProjection:
|
||
"""
|
||
把运行态插件投影为宿主命令、API、服务、模块和动作清单。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
running_plugins: Mapping[str, Any],
|
||
log: Any = default_logger,
|
||
remote_entry_factory: Optional[Callable[[str, str], str]] = None,
|
||
) -> None:
|
||
"""
|
||
保存运行态插件映射和错误日志端口。
|
||
"""
|
||
self._running_plugins = running_plugins
|
||
self._logger = log
|
||
self._remote_entry_factory = remote_entry_factory
|
||
|
||
def _items(self, pid: Optional[str]) -> list[tuple[str, Any]]:
|
||
"""
|
||
返回指定插件或运行态插件的稳定快照。
|
||
"""
|
||
snapshot = dict(self._running_plugins)
|
||
if pid:
|
||
plugin = snapshot.get(pid)
|
||
return [(pid, plugin)] if plugin is not None else []
|
||
return list(snapshot.items())
|
||
|
||
def commands(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||
"""
|
||
聚合插件命令并补充插件 ID。
|
||
"""
|
||
commands: list[dict[str, Any]] = []
|
||
for plugin_id, plugin in self._items(pid):
|
||
if not supports_plugin_hook(plugin, "get_command"):
|
||
continue
|
||
try:
|
||
if not plugin.get_state():
|
||
continue
|
||
for command in plugin.get_command() or []:
|
||
command["pid"] = plugin_id
|
||
commands.append(command)
|
||
except Exception as error:
|
||
self._logger.error(f"获取插件命令出错:{str(error)}")
|
||
return commands
|
||
|
||
def apis(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||
"""
|
||
聚合插件 API 并补充宿主路径和默认认证方式,端点绑定发起实例的日志上下文。
|
||
"""
|
||
apis: list[dict[str, Any]] = []
|
||
for plugin_id, plugin in self._items(pid):
|
||
if not supports_plugin_hook(plugin, "get_api"):
|
||
continue
|
||
try:
|
||
for source_api in plugin.get_api() or []:
|
||
api = dict(source_api)
|
||
api["path"] = f"/{plugin_id}{api['path']}"
|
||
if not api.get("auth"):
|
||
api["auth"] = "apikey"
|
||
endpoint = api.get("endpoint")
|
||
if callable(endpoint):
|
||
api["endpoint"] = wrap_for_plugin_instance(endpoint, plugin_id)
|
||
apis.append(api)
|
||
except Exception as error:
|
||
self._logger.error(f"获取插件 {plugin_id} API出错:{str(error)}")
|
||
return apis
|
||
|
||
def services(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||
"""
|
||
聚合启用插件的定时服务。
|
||
"""
|
||
services: list[dict[str, Any]] = []
|
||
for plugin_id, plugin in self._items(pid):
|
||
if not supports_plugin_hook(plugin, "get_service"):
|
||
continue
|
||
try:
|
||
if plugin.get_state():
|
||
services.extend(plugin.get_service() or [])
|
||
except Exception as error:
|
||
self._logger.error(f"获取插件 {plugin_id} 服务出错:{str(error)}")
|
||
return services
|
||
|
||
def modules(self, pid: Optional[str] = None) -> Dict[tuple[str, str], Dict[str, Any]]:
|
||
"""
|
||
聚合启用插件的模块方法清单。
|
||
"""
|
||
modules: dict[tuple[str, str], dict[str, Any]] = {}
|
||
for plugin_id, plugin in self._items(pid):
|
||
if not supports_plugin_hook(plugin, "get_module"):
|
||
continue
|
||
try:
|
||
if plugin.get_state():
|
||
declared = plugin.get_module()
|
||
# 基类默认实现返回 None;只接受映射,防止把 list 当成方法表传入调度器
|
||
if declared is None:
|
||
continue
|
||
if not isinstance(declared, Mapping):
|
||
self._logger.error(
|
||
f"插件 {plugin_id} 的 get_module() 返回值必须是字典,实际是 {type(declared).__name__}"
|
||
)
|
||
continue
|
||
modules[(plugin_id, plugin.get_name())] = dict(declared)
|
||
except Exception as error:
|
||
self._logger.error(f"获取插件 {plugin_id} 模块出错:{str(error)}")
|
||
return modules
|
||
|
||
def media_sources(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||
"""
|
||
聚合启用插件声明的媒体数据源。
|
||
"""
|
||
sources: list[dict[str, Any]] = []
|
||
for plugin_id, plugin in self._items(pid):
|
||
if not supports_plugin_hook(plugin, "get_media_source"):
|
||
continue
|
||
try:
|
||
if not plugin.get_state():
|
||
continue
|
||
for source in plugin.get_media_source() or []:
|
||
item = self._media_source_mapping(source)
|
||
if item is None:
|
||
continue
|
||
item.setdefault("plugin_id", plugin_id)
|
||
sources.append(item)
|
||
except Exception as error:
|
||
self._logger.error(f"获取插件 {plugin_id} 媒体数据源出错:{str(error)}")
|
||
return sources
|
||
|
||
@staticmethod
|
||
def _media_source_mapping(source: Any) -> dict[str, Any] | None:
|
||
"""
|
||
把旧字典或新 SDK 模型转换为隔离的 JSON 字典。
|
||
"""
|
||
if isinstance(source, Mapping):
|
||
return dict(source)
|
||
model_dump = getattr(source, "model_dump", None)
|
||
if not callable(model_dump):
|
||
return None
|
||
payload = model_dump(mode="json")
|
||
return dict(payload) if isinstance(payload, Mapping) else None
|
||
|
||
def actions(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||
"""
|
||
聚合启用插件的工作流动作。
|
||
"""
|
||
actions: list[dict[str, Any]] = []
|
||
for plugin_id, plugin in self._items(pid):
|
||
if not supports_plugin_hook(plugin, "get_actions"):
|
||
continue
|
||
try:
|
||
if not plugin.get_state():
|
||
continue
|
||
plugin_actions = plugin.get_actions()
|
||
if plugin_actions:
|
||
actions.append({
|
||
"plugin_id": plugin_id,
|
||
"plugin_name": plugin.plugin_name,
|
||
"actions": plugin_actions,
|
||
})
|
||
except Exception as error:
|
||
self._logger.error(f"获取插件 {plugin_id} 动作出错:{str(error)}")
|
||
return actions
|
||
|
||
def remotes(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||
"""
|
||
投影插件联邦远程入口,并保持旧渲染模式筛选语义。
|
||
"""
|
||
remotes = []
|
||
for plugin_id, plugin in self._items(pid):
|
||
if not supports_plugin_hook(plugin, "get_render_mode"):
|
||
continue
|
||
render_mode, dist_path = plugin.get_render_mode()
|
||
if render_mode != "vue":
|
||
continue
|
||
remotes.append(self._remote_descriptor(plugin_id, plugin, dist_path))
|
||
return remotes
|
||
|
||
def _remote_descriptor(
|
||
self,
|
||
plugin_id: str,
|
||
plugin: Any,
|
||
dist_path: str,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
构造联邦远程入口描述,分身额外带出其源插件 ID。
|
||
|
||
分身与本体共享同一份前端产物,只有源插件名下才有产物目录;前端联邦加载器
|
||
拿不到源插件 ID 就只能按分身 ID 去取,必然落空。入口 URL 同时带上插件版本,
|
||
让插件更新后得到新的浏览器与 Service Worker 缓存键。
|
||
"""
|
||
if not self._remote_entry_factory:
|
||
raise RuntimeError("插件联邦入口生成器尚未配置")
|
||
remote_url = self._versioned_url(
|
||
self._remote_entry_factory(plugin_id, dist_path), plugin
|
||
)
|
||
remote: Dict[str, Any] = {
|
||
"id": plugin_id,
|
||
"url": remote_url,
|
||
"name": plugin.plugin_name,
|
||
}
|
||
source_plugin_id = getattr(plugin, "plugin_source_id", None)
|
||
if source_plugin_id:
|
||
remote["source_plugin_id"] = source_plugin_id
|
||
return remote
|
||
|
||
@staticmethod
|
||
def _versioned_url(url: str, plugin: Any) -> str:
|
||
"""
|
||
为插件静态资源 URL 附加插件版本,插件更新后得到新的缓存键。
|
||
"""
|
||
plugin_version = getattr(plugin, "plugin_version", None)
|
||
if not plugin_version:
|
||
return url
|
||
separator = "&" if "?" in url else "?"
|
||
return f"{url}{separator}{urlencode({'v': str(plugin_version)})}"
|
||
|
||
def auth_providers(self) -> List[Dict[str, Any]]:
|
||
"""
|
||
投影启用插件声明的登录认证提供方。
|
||
"""
|
||
providers = []
|
||
for plugin_id, plugin in self._items(None):
|
||
if not plugin.get_state() or not supports_plugin_hook(
|
||
plugin, "get_auth_providers"
|
||
):
|
||
continue
|
||
try:
|
||
plugin_providers = plugin.get_auth_providers() or []
|
||
except Exception as error:
|
||
self._logger.error(
|
||
f"获取插件 {plugin_id} 登录认证提供方出错:{str(error)}"
|
||
)
|
||
continue
|
||
render_mode = None
|
||
dist_path = None
|
||
if supports_plugin_hook(plugin, "get_render_mode"):
|
||
render_mode, dist_path = plugin.get_render_mode()
|
||
for raw_provider in plugin_providers:
|
||
if not raw_provider or not isinstance(raw_provider, dict):
|
||
continue
|
||
provider = raw_provider.copy()
|
||
provider["type"] = "plugin"
|
||
provider["plugin_id"] = plugin_id
|
||
provider.setdefault("id", f"plugin:{plugin_id}")
|
||
provider.setdefault("name", plugin.plugin_name)
|
||
provider.setdefault("enabled", True)
|
||
if render_mode == "vue" and dist_path:
|
||
remote = self._remote_descriptor(plugin_id, plugin, dist_path)
|
||
provider.setdefault("component", "AuthPage")
|
||
provider["remote"] = remote
|
||
providers.append(provider)
|
||
return providers
|
||
|
||
def sidebar(self) -> List[Dict[str, Any]]:
|
||
"""
|
||
投影启用 Vue 插件的侧栏导航,并规整权限、分区和顺序。
|
||
"""
|
||
valid_sections = {"start", "discovery", "subscribe", "organize", "system"}
|
||
valid_permissions = {"subscribe", "discovery", "search", "manage", "admin"}
|
||
items = []
|
||
for plugin_id, plugin in self._items(None):
|
||
if not plugin.get_state() or not supports_plugin_hook(
|
||
plugin, "get_sidebar_nav"
|
||
):
|
||
continue
|
||
if not supports_plugin_hook(plugin, "get_render_mode"):
|
||
continue
|
||
render_mode, _ = plugin.get_render_mode()
|
||
if render_mode != "vue":
|
||
continue
|
||
try:
|
||
nav_list = plugin.get_sidebar_nav()
|
||
if not nav_list:
|
||
continue
|
||
for raw in nav_list:
|
||
if not raw or not isinstance(raw, dict):
|
||
continue
|
||
nav_key = str(
|
||
raw.get("nav_key") or raw.get("key") or "main"
|
||
).strip()
|
||
if not nav_key or any(
|
||
character in nav_key for character in ["/", "?", "#", " "]
|
||
):
|
||
self._logger.warning(
|
||
f"插件[{plugin_id}]侧栏项 nav_key 无效,已跳过: "
|
||
f"{nav_key!r}"
|
||
)
|
||
continue
|
||
section = str(raw.get("section") or "system").lower()
|
||
if section not in valid_sections:
|
||
section = "system"
|
||
permission = raw.get("permission")
|
||
if permission is not None and str(permission) not in valid_permissions:
|
||
permission = None
|
||
elif permission is not None:
|
||
permission = str(permission)
|
||
try:
|
||
order = int(raw.get("order", 0))
|
||
except (TypeError, ValueError):
|
||
order = 0
|
||
items.append({
|
||
"plugin_id": plugin_id,
|
||
"nav_key": nav_key,
|
||
"title": raw.get("title") or plugin.plugin_name,
|
||
"icon": raw.get("icon") or "mdi-puzzle",
|
||
"section": section,
|
||
"permission": permission,
|
||
"order": order,
|
||
})
|
||
except Exception as error:
|
||
self._logger.error(
|
||
f"获取插件[{plugin_id}]侧栏导航出错:{str(error)}"
|
||
)
|
||
items.sort(
|
||
key=lambda item: (
|
||
item["section"],
|
||
item["order"],
|
||
item["plugin_id"],
|
||
item["nav_key"],
|
||
)
|
||
)
|
||
return items
|
||
|
||
def agent_pets(self) -> List[Dict[str, Any]]:
|
||
"""
|
||
投影启用 Vue 插件声明的 Agent 助手形象。
|
||
|
||
只收已启用、渲染模式为 vue 且实现 ``get_agent_pets`` 的插件;任一字段非法的
|
||
声明整项丢弃并记录警告,同一插件内重复 key 只保留首项。分身沿用实例
|
||
``plugin_id``,``source_plugin_id`` 指向共享前端产物的源插件。
|
||
"""
|
||
items: List[Dict[str, Any]] = []
|
||
for plugin_id, plugin in sorted(self._items(None), key=lambda pair: pair[0]):
|
||
if not plugin.get_state() or not supports_plugin_hook(
|
||
plugin, "get_agent_pets"
|
||
):
|
||
continue
|
||
if not supports_plugin_hook(plugin, "get_render_mode"):
|
||
continue
|
||
render_mode, dist_path = plugin.get_render_mode()
|
||
if render_mode != "vue":
|
||
continue
|
||
try:
|
||
declared = plugin.get_agent_pets() or []
|
||
except Exception as error:
|
||
self._logger.error(
|
||
f"获取插件[{plugin_id}]助手形象出错:{str(error)}"
|
||
)
|
||
continue
|
||
items.extend(
|
||
self._agent_pet_items(plugin_id, plugin, dist_path or "", declared)
|
||
)
|
||
return items
|
||
|
||
def _agent_pet_items(
|
||
self,
|
||
plugin_id: str,
|
||
plugin: Any,
|
||
dist_path: str,
|
||
declared: Any,
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
规整单个插件的助手形象声明,丢弃非法项与重复 key。
|
||
"""
|
||
if not isinstance(declared, list):
|
||
self._logger.warning(
|
||
f"插件[{plugin_id}]get_agent_pets() 必须返回列表,已忽略"
|
||
)
|
||
return []
|
||
# 带出联邦入口与插件版本:前端据此直接注册远程入口、省去一次 remotes 往返,
|
||
# 并按版本判断插件是否升级,以决定是否重试本会话加载失败的形象。
|
||
plugin_version = getattr(plugin, "plugin_version", None)
|
||
base = {
|
||
"plugin_id": plugin_id,
|
||
"source_plugin_id": getattr(plugin, "plugin_source_id", None) or plugin_id,
|
||
"plugin_name": plugin.plugin_name,
|
||
"plugin_version": str(plugin_version) if plugin_version else None,
|
||
"remote_url": self._remote_descriptor(plugin_id, plugin, dist_path)["url"],
|
||
}
|
||
seen: set[str] = set()
|
||
items: List[Dict[str, Any]] = []
|
||
for raw in declared:
|
||
try:
|
||
item = self._agent_pet_fields(raw)
|
||
except _InvalidAgentPet as error:
|
||
self._logger.warning(f"插件[{plugin_id}]助手形象声明无效,已跳过:{error}")
|
||
continue
|
||
if item["key"] in seen:
|
||
self._logger.warning(
|
||
f"插件[{plugin_id}]助手形象 key 重复,已跳过:{item['key']!r}"
|
||
)
|
||
continue
|
||
seen.add(item["key"])
|
||
for field in ("preview", "avatar"):
|
||
image = item.pop(field)
|
||
item[f"{field}_url"] = (
|
||
self._agent_pet_image_url(plugin_id, plugin, dist_path, image)
|
||
if image else None
|
||
)
|
||
items.append({**base, **item})
|
||
return items
|
||
|
||
@staticmethod
|
||
def _agent_pet_fields(raw: Any) -> Dict[str, Any]:
|
||
"""
|
||
校验并补齐单项助手形象声明的默认值,非法时抛出 ``_InvalidAgentPet``。
|
||
"""
|
||
if not isinstance(raw, Mapping):
|
||
raise _InvalidAgentPet(f"声明必须是字典:{raw!r}")
|
||
key = raw.get("key")
|
||
if not isinstance(key, str) or not _AGENT_PET_KEY_PATTERN.match(key):
|
||
raise _InvalidAgentPet(f"key 不符合 [a-z0-9_-]{{1,32}}:{key!r}")
|
||
name = raw.get("name")
|
||
if not isinstance(name, str) or not name.strip():
|
||
raise _InvalidAgentPet(f"{key} 缺少 name")
|
||
description = raw.get("description")
|
||
if description is not None and not isinstance(description, str):
|
||
raise _InvalidAgentPet(f"{key} 的 description 必须是字符串")
|
||
mode = raw.get("mode") or "renderer"
|
||
if not isinstance(mode, str) or mode not in _AGENT_PET_MODES:
|
||
raise _InvalidAgentPet(f"{key} 的 mode 未知:{mode!r}")
|
||
component = raw.get("component") or "AgentPet"
|
||
if not isinstance(component, str) or not component.strip().removeprefix("./"):
|
||
raise _InvalidAgentPet(f"{key} 的 component 无效:{component!r}")
|
||
api_version = raw.get("api_version", 1)
|
||
if isinstance(api_version, bool) or not isinstance(api_version, int) or api_version < 1:
|
||
raise _InvalidAgentPet(f"{key} 的 api_version 必须是正整数:{api_version!r}")
|
||
preview = PluginProjection._agent_pet_image(key, "preview", raw.get("preview"))
|
||
avatar = PluginProjection._agent_pet_image(key, "avatar", raw.get("avatar"))
|
||
bubbles = None
|
||
random_actions = None
|
||
if mode == "stage":
|
||
bubbles = raw.get("bubbles") or "host"
|
||
if not isinstance(bubbles, str) or bubbles not in _AGENT_PET_BUBBLES:
|
||
raise _InvalidAgentPet(f"{key} 的 bubbles 未知:{bubbles!r}")
|
||
else:
|
||
random_actions = PluginProjection._agent_pet_random_actions(
|
||
key, raw.get("random_actions")
|
||
)
|
||
return {
|
||
"key": key,
|
||
"name": name.strip(),
|
||
"description": description.strip() if description else None,
|
||
"mode": mode,
|
||
"component": component.strip().removeprefix("./"),
|
||
"api_version": api_version,
|
||
"preview": preview,
|
||
"avatar": avatar,
|
||
"bubbles": bubbles,
|
||
"random_actions": random_actions,
|
||
}
|
||
|
||
@staticmethod
|
||
def _agent_pet_random_actions(key: str, value: Any) -> Optional[List[str]]:
|
||
"""
|
||
校验 renderer 模式的随机动作候选,保持声明顺序去重;缺省表示宿主全集。
|
||
"""
|
||
if value is None:
|
||
return None
|
||
if not isinstance(value, list) or not all(
|
||
isinstance(action, str) and action for action in value
|
||
):
|
||
raise _InvalidAgentPet(f"{key} 的 random_actions 必须是非空字符串列表")
|
||
return list(dict.fromkeys(value))
|
||
|
||
@staticmethod
|
||
def _agent_pet_image(key: str, field: str, value: Any) -> Optional[str]:
|
||
"""
|
||
校验预览图或头像声明:绝对地址仅允许 http(s) 与 data,相对路径不得越出联邦产物目录。
|
||
"""
|
||
if value is None or value == "":
|
||
return None
|
||
if not isinstance(value, str):
|
||
raise _InvalidAgentPet(f"{key} 的 {field} 必须是字符串")
|
||
preview = value.strip()
|
||
if preview.lower().startswith(_AGENT_PET_ABSOLUTE_PREVIEW_PREFIXES):
|
||
return preview
|
||
relative = preview.removeprefix("./")
|
||
if (
|
||
":" in relative
|
||
or "\\" in relative
|
||
or any(segment in ("", ".", "..") for segment in relative.split("/"))
|
||
):
|
||
raise _InvalidAgentPet(f"{key} 的 {field} 路径无效:{value!r}")
|
||
return relative
|
||
|
||
def _agent_pet_image_url(
|
||
self,
|
||
plugin_id: str,
|
||
plugin: Any,
|
||
dist_path: str,
|
||
preview: str,
|
||
) -> str:
|
||
"""
|
||
把已校验的预览图或头像解析为可访问 URL。
|
||
|
||
绝对地址原样透传;相对路径解析到 remoteEntry 所在目录,与 remoteEntry 共用
|
||
插件静态文件接口、实例 ID 与版本缓存键,分身由静态文件接口映射回源插件目录。
|
||
"""
|
||
if preview.lower().startswith(_AGENT_PET_ABSOLUTE_PREVIEW_PREFIXES):
|
||
return preview
|
||
if not self._remote_entry_factory:
|
||
raise RuntimeError("插件联邦入口生成器尚未配置")
|
||
entry_dir = posixpath.dirname(self._remote_entry_factory(plugin_id, dist_path))
|
||
return self._versioned_url(f"{entry_dir}/{preview}", plugin)
|
||
|
||
def dashboard_metadata(self) -> List[Dict[str, str]]:
|
||
"""
|
||
投影启用插件的单仪表板或多仪表板元信息。
|
||
"""
|
||
metadata: list[dict[str, str]] = []
|
||
for plugin_id, plugin in self._items(None):
|
||
if not supports_plugin_hook(plugin, "get_dashboard"):
|
||
continue
|
||
try:
|
||
if not plugin.get_state():
|
||
continue
|
||
if supports_plugin_hook(plugin, "get_dashboard_meta"):
|
||
plugin_metadata = plugin.get_dashboard_meta()
|
||
if plugin_metadata:
|
||
metadata.extend({
|
||
"id": plugin_id,
|
||
"name": item.get("name"),
|
||
"key": item.get("key"),
|
||
} for item in plugin_metadata if item)
|
||
else:
|
||
metadata.append({
|
||
"id": plugin_id,
|
||
"name": plugin.plugin_name,
|
||
"key": "",
|
||
})
|
||
except Exception as error:
|
||
self._logger.error(
|
||
f"获取插件[{plugin_id}]仪表盘元数据出错:{str(error)}"
|
||
)
|
||
return metadata
|
||
|
||
def dashboard(
|
||
self,
|
||
plugin_id: str,
|
||
key: str,
|
||
user_agent: Optional[str] = None,
|
||
) -> Optional[PluginDashboard]:
|
||
"""
|
||
调用插件仪表板钩子并返回稳定投影,不依赖 HTTP 异常。
|
||
"""
|
||
plugin = self._running_plugins.get(plugin_id)
|
||
if not plugin:
|
||
raise PluginNotFoundError(f"插件 {plugin_id} 不存在或未加载")
|
||
try:
|
||
render_mode, _ = plugin.get_render_mode()
|
||
method = plugin.get_dashboard
|
||
count = len(inspect.signature(method).parameters)
|
||
if count > 1:
|
||
dashboard = method(key=key, user_agent=user_agent)
|
||
elif count > 0:
|
||
dashboard = method(user_agent=user_agent)
|
||
else:
|
||
dashboard = method()
|
||
# 第三方仪表盘钩子失败只影响当前面板,不能中断其他插件展示。
|
||
except Exception as error: # noqa: BLE001
|
||
self._logger.error(f"插件 {plugin_id} 调用方法 get_dashboard 出错: {error}")
|
||
raise PluginDashboardError(
|
||
f"插件 {plugin_id} 调用方法 get_dashboard 出错: {error}"
|
||
) from error
|
||
if dashboard is None:
|
||
return None
|
||
if not isinstance(dashboard, (tuple, list)) or len(dashboard) != 3:
|
||
self._logger.error(f"插件 {plugin_id} 返回的仪表盘数据格式错误")
|
||
raise PluginDashboardError(
|
||
f"插件 {plugin_id} 返回的仪表盘数据格式错误"
|
||
)
|
||
cols, attrs, elements = dashboard
|
||
return PluginDashboard(
|
||
id=plugin_id,
|
||
name=plugin.plugin_name,
|
||
key=key,
|
||
render_mode=render_mode,
|
||
cols=cols or {},
|
||
attrs=attrs or {},
|
||
elements=elements,
|
||
source_plugin_id=getattr(plugin, "plugin_source_id", None),
|
||
is_instance=bool(getattr(plugin, "plugin_source_id", None)),
|
||
instance_mode=(
|
||
"virtual" if getattr(plugin, "plugin_source_id", None) else None
|
||
),
|
||
)
|