新增简易的8080面板😊
This commit is contained in:
8
store/@{Falck}/html-render/SIGNATURE
Normal file
8
store/@{Falck}/html-render/SIGNATURE
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"signature": "SizmRKKsPO3WuOYi+GtSOvKwZb5UrwRbSlJNJ26RF7l7811PLQlrBPJ7Awx1SUwy50TLrDpwtqbRIdCnGVqI9yzghBhdkwz7dpaAQ//lZK6SM9ygMMtS4ADJ839/AHTuB4USQM5FlqOwTIBE6QGAMgQw+w4di7Rpyh/6VD4Fg3GoiLJi7Pte0Upuglr4oIfZwpEt1liAi0ZlnE+Qb1GkmEGfQYyNYDYQkLKS0KG113YxqMj7sef9WcRCaKJSm+FZ8rV7dA0pCj1jY5sKOdXO/3PYH9g6O/BdgP0XuAoAUgGWshB0Z/D4WwHyykOIRM3jRHmU8kUB4PjxCzFVoDnkYfvN7wBojMjb0F9POjfbSv40jjC3EDjeDusbAP1FGv+F7QaJyAWhNUBSlRUBcHZZ8icSqRAStwX9MHsBVZa5EGrvHFK4SP8b6X6gm01+3JuKpiSRPGkxyDuxlFLNNDipmUNuHh1byofE/oD48yLNh7nGofVIvaDdOn6bhnc3ZDd54onncDNEBaWAHrLvly1nzkP5VN1bFEax/jZPWbSrcntmQ0Ua+11D0Ot/FVFhhrJo1dBBECM9zkVBUkpYAAf1RN7f9IglBVhi5iK+LmbGXzTSUX695tMvnufwXEJsH4fu3Jkom/PUkEggWNHEgb4qm4IsO2wzMWns+ZbZi3PzXP0=",
|
||||
"signer": "Falck",
|
||||
"algorithm": "RSA-SHA256",
|
||||
"timestamp": 1775964953.1502125,
|
||||
"plugin_hash": "84d69d65913b62d156e13a22e09dfcc3a5b36e052ae0532c569ced1fb269bb11",
|
||||
"author": "Falck"
|
||||
}
|
||||
Binary file not shown.
@@ -1,9 +1,24 @@
|
||||
"""HTML 渲染服务 - 通过 config.json 配置,统一文件入口"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from oss.plugin.types import Plugin, register_plugin_type, Response
|
||||
|
||||
|
||||
class _Log:
|
||||
_TTY = sys.stdout.isatty()
|
||||
_C = {"reset": "\033[0m", "white": "\033[0;37m", "yellow": "\033[1;33m", "blue": "\033[1;34m", "red": "\033[1;31m"}
|
||||
@classmethod
|
||||
def _c(cls, t, c):
|
||||
return f"{cls._C.get(c,'')}{t}{cls._C['reset']}" if cls._TTY else t
|
||||
@classmethod
|
||||
def info(cls, m): print(f"{cls._c('[html-render]', 'white')} {cls._c(m, 'white')}")
|
||||
@classmethod
|
||||
def warn(cls, m): print(f"{cls._c('[html-render]', 'yellow')} {cls._c('⚠', 'yellow')} {cls._c(m, 'yellow')}")
|
||||
@classmethod
|
||||
def error(cls, m): print(f"{cls._c('[html-render]', 'red')} {cls._c('✗', 'red')} {cls._c(m, 'red')}")
|
||||
|
||||
|
||||
class HtmlRenderPlugin(Plugin):
|
||||
"""HTML 渲染插件 - 渲染服务由 html-render 提供"""
|
||||
|
||||
@@ -16,16 +31,16 @@ class HtmlRenderPlugin(Plugin):
|
||||
def init(self, deps: dict = None):
|
||||
"""初始化 - 读取 config.json 并解析网站根目录"""
|
||||
self._load_config()
|
||||
print(f"[html-render] 配置加载完成: root_dir={self.root_dir}")
|
||||
_Log.info(f"配置加载完成: root_dir={self.root_dir}")
|
||||
|
||||
def start(self):
|
||||
"""启动 - 注册路由到 http-api,共享配置给 web-toolkit"""
|
||||
# 注册首页路由
|
||||
if self.http_api and hasattr(self.http_api, 'router'):
|
||||
self.http_api.router.get("/", self._serve_html)
|
||||
print("[html-render] 已注册路由到 http-api")
|
||||
_Log.info("已注册路由到 http-api")
|
||||
else:
|
||||
print("[html-render] http-api 未加载")
|
||||
_Log.warn("http-api 未加载")
|
||||
|
||||
# 将配置共享给 web-toolkit(通过 plugin-storage 的 DCIM 共享存储)
|
||||
if self.storage:
|
||||
@@ -35,7 +50,7 @@ class HtmlRenderPlugin(Plugin):
|
||||
"index_file": self.config.get("index_file", "index.html"),
|
||||
"static_prefix": self.config.get("static_prefix", "/static"),
|
||||
})
|
||||
print("[html-render] 配置已共享到 DCIM")
|
||||
_Log.info("配置已共享到 DCIM")
|
||||
|
||||
def stop(self):
|
||||
"""停止"""
|
||||
@@ -53,7 +68,7 @@ class HtmlRenderPlugin(Plugin):
|
||||
"""读取 config.json,解析根目录"""
|
||||
config_path = Path("./data/html-render/config.json")
|
||||
if not config_path.exists():
|
||||
print("[html-render] 警告: config.json 不存在,使用默认配置")
|
||||
_Log.warn("config.json 不存在,使用默认配置")
|
||||
self.config = {"root_dir": "../website", "index_file": "index.html"}
|
||||
else:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
@@ -66,6 +81,11 @@ class HtmlRenderPlugin(Plugin):
|
||||
def _serve_html(self, request):
|
||||
"""提供 HTML 页面 - 通过 plugin-storage 读取并注入静态资源路径"""
|
||||
index_file = self.config.get("index_file", "index.html")
|
||||
|
||||
# 安全检查:防止路径穿越
|
||||
if ".." in index_file or index_file.startswith("/"):
|
||||
return Response(status=403, body="Forbidden")
|
||||
|
||||
if self.storage:
|
||||
storage = self.storage.get_storage("html-render")
|
||||
if storage.file_exists(index_file):
|
||||
|
||||
8
store/@{Falck}/web-toolkit/SIGNATURE
Normal file
8
store/@{Falck}/web-toolkit/SIGNATURE
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"signature": "GYBKpyVNgNFbpeoGlkXNY+wvt5wrJFHeP06At2h3SPsZUX3sXCtUL8RoidfzkqrfphBKAaKYvRnXaZdi3hyaDfXNQ88Ik18U+K7Usx+/o/rrQqzMKqh1pT75UZgZtJpXHu7CiIEjNIQ0pbujRHVfnRFe/4K3E2IClpJLcrziyrvn0fUBcUytt/WCTGBJ8pnyWB+ybcIDTJJQ+l4E69vsy2YmJHZBbBreyOo+TN5AQHDAlZ851dxI1K9euCNtdnlufbW6QSshnQ7DSS94KYZEUgTYFGON4Qi1RiVTFJK4iJEkTExEmohc3AuFJtEoIBBJzbUj/yCmfGcyWrbK7wchdwdGuNxGbexB97FONGm0WFS/z6OM08ljMJUAgvDRZtpInpQHFWJfxBfH+wzBx0AvhkgiJeeUApeofOxlggveOLDYDEH8P858sf0sjHHL0qgE17alvn0Fi8rArOI40wrh420SF7p4VlXE7fufXoue+yAhlSt68zaXOJHAtK5CuMh2ytVFKonRJgF5TAXvXYJeOZgujHyUUTtVqje+thIaBzqtGhEt9xp5N6Ikky2sutKRMgXx34As3hvx0U6a2CHuVykcX9neoB8XtJNlE1+AT24wnWw8LBqm6OjCTeJtAOFWFkliHNID9b1xfq69rZBp/L4Djj1bzy8WNLM7QLbjAvc=",
|
||||
"signer": "Falck",
|
||||
"algorithm": "RSA-SHA256",
|
||||
"timestamp": 1775964953.1846428,
|
||||
"plugin_hash": "eab1e047be16fe50b9c46f26570924f2975fac71a45af7f6c0b1f9c16ac8b096",
|
||||
"author": "Falck"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,6 @@
|
||||
"""Web 工具包 - 路由注册、静态文件服务、前端事件(不负责渲染)"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from oss.plugin.types import Plugin, register_plugin_type, Response
|
||||
from .router import WebRouter
|
||||
@@ -7,6 +8,20 @@ from .static import StaticFileHandler
|
||||
from .template import TemplateEngine
|
||||
|
||||
|
||||
class _Log:
|
||||
_TTY = sys.stdout.isatty()
|
||||
_C = {"reset": "\033[0m", "white": "\033[0;37m", "yellow": "\033[1;33m", "blue": "\033[1;34m", "red": "\033[1;31m"}
|
||||
@classmethod
|
||||
def _c(cls, t, c):
|
||||
return f"{cls._C.get(c,'')}{t}{cls._C['reset']}" if cls._TTY else t
|
||||
@classmethod
|
||||
def info(cls, m): print(f"{cls._c('[web-toolkit]', 'white')} {cls._c(m, 'white')}")
|
||||
@classmethod
|
||||
def warn(cls, m): print(f"{cls._c('[web-toolkit]', 'yellow')} {cls._c('⚠', 'yellow')} {cls._c(m, 'yellow')}")
|
||||
@classmethod
|
||||
def error(cls, m): print(f"{cls._c('[web-toolkit]', 'red')} {cls._c('✗', 'red')} {cls._c(m, 'red')}")
|
||||
|
||||
|
||||
class WebToolkitPlugin(Plugin):
|
||||
"""Web 工具包插件 - 提供网站前端所有服务"""
|
||||
|
||||
@@ -26,7 +41,7 @@ class WebToolkitPlugin(Plugin):
|
||||
self.template_engine = TemplateEngine()
|
||||
self._load_config()
|
||||
self.static_handler = StaticFileHandler(root=str(self.root_dir))
|
||||
print(f"[web-toolkit] 配置加载完成: root_dir={self.root_dir}")
|
||||
_Log.info(f"配置加载完成: root_dir={self.root_dir}")
|
||||
|
||||
def start(self):
|
||||
"""启动"""
|
||||
@@ -65,7 +80,7 @@ class WebToolkitPlugin(Plugin):
|
||||
self._serve_static
|
||||
)
|
||||
|
||||
print("[web-toolkit] Web 工具包已启动")
|
||||
_Log.info("Web 工具包已启动")
|
||||
|
||||
def stop(self):
|
||||
"""停止"""
|
||||
@@ -97,7 +112,7 @@ class WebToolkitPlugin(Plugin):
|
||||
"""读取 config.json,解析网站根目录"""
|
||||
config_path = Path("./data/web-toolkit/config.json")
|
||||
if not config_path.exists():
|
||||
print("[web-toolkit] 警告: config.json 不存在,使用默认配置")
|
||||
_Log.warn("config.json 不存在,使用默认配置")
|
||||
self.config = {
|
||||
"root_dir": "../website",
|
||||
"index_file": "index.html",
|
||||
@@ -146,6 +161,10 @@ class WebToolkitPlugin(Plugin):
|
||||
else:
|
||||
filename = path.lstrip("/")
|
||||
|
||||
# 安全检查:防止路径穿越
|
||||
if ".." in filename or filename.startswith("/"):
|
||||
return Response(status=403, body="Forbidden")
|
||||
|
||||
if not filename:
|
||||
return self._serve_website_index(request)
|
||||
return self.static_handler.serve(filename)
|
||||
|
||||
@@ -43,27 +43,74 @@ class TemplateEngine:
|
||||
return content
|
||||
|
||||
def _safe_eval(self, expression: str, context: dict) -> Any:
|
||||
"""安全评估表达式(仅允许简单的属性访问和比较)"""
|
||||
# 只允许访问 context 中的变量
|
||||
# 支持的运算符: and, or, not, ==, !=, <, >, <=, >=, in
|
||||
# 不允许函数调用、导入、属性访问等
|
||||
|
||||
# 使用 AST 解析并验证
|
||||
"""安全评估表达式(使用 AST 验证,不使用 eval)"""
|
||||
try:
|
||||
tree = ast.parse(expression, mode='eval')
|
||||
except SyntaxError:
|
||||
return False
|
||||
|
||||
|
||||
# 验证 AST 节点
|
||||
if not self._validate_ast(tree.body[0].value, set(context.keys())):
|
||||
return False
|
||||
|
||||
# 在受限环境中评估
|
||||
|
||||
# 使用安全的 AST 解释器,不使用 eval
|
||||
try:
|
||||
return eval(expression, {"__builtins__": {}}, context)
|
||||
return self._eval_ast(tree.body[0].value, context)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _eval_ast(self, node: ast.AST, context: dict) -> Any:
|
||||
"""安全地评估 AST 节点"""
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
elif isinstance(node, ast.Name):
|
||||
return context.get(node.id, False)
|
||||
elif isinstance(node, ast.BoolOp):
|
||||
if isinstance(node.op, ast.And):
|
||||
return all(self._eval_ast(v, context) for v in node.values)
|
||||
elif isinstance(node.op, ast.Or):
|
||||
return any(self._eval_ast(v, context) for v in node.values)
|
||||
elif isinstance(node, ast.Compare):
|
||||
return self._eval_compare(node, context)
|
||||
elif isinstance(node, ast.UnaryOp):
|
||||
if isinstance(node.op, ast.Not):
|
||||
return not self._eval_ast(node.operand, context)
|
||||
elif isinstance(node, ast.Subscript):
|
||||
return self._eval_subscript(node, context)
|
||||
return False
|
||||
|
||||
def _eval_compare(self, node: ast.Compare, context: dict) -> bool:
|
||||
"""评估比较表达式"""
|
||||
left = self._eval_ast(node.left, context)
|
||||
for op, comp in zip(node.ops, node.comparators):
|
||||
right = self._eval_ast(comp, context)
|
||||
if isinstance(op, ast.Eq):
|
||||
if not (left == right): return False
|
||||
elif isinstance(op, ast.NotEq):
|
||||
if not (left != right): return False
|
||||
elif isinstance(op, ast.Lt):
|
||||
if not (left < right): return False
|
||||
elif isinstance(op, ast.Gt):
|
||||
if not (left > right): return False
|
||||
elif isinstance(op, ast.LtE):
|
||||
if not (left <= right): return False
|
||||
elif isinstance(op, ast.GtE):
|
||||
if not (left >= right): return False
|
||||
elif isinstance(op, ast.In):
|
||||
if not (left in right): return False
|
||||
elif isinstance(op, ast.NotIn):
|
||||
if not (left not in right): return False
|
||||
left = right
|
||||
return True
|
||||
|
||||
def _eval_subscript(self, node: ast.Subscript, context: dict) -> Any:
|
||||
"""评估下标访问"""
|
||||
value = self._eval_ast(node.value, context)
|
||||
key = self._eval_ast(node.slice, context)
|
||||
if isinstance(value, (dict, list, str)):
|
||||
return value[key]
|
||||
return None
|
||||
|
||||
def _validate_ast(self, node: ast.AST, allowed_names: set) -> bool:
|
||||
"""验证 AST 只包含安全的操作"""
|
||||
if isinstance(node, ast.Name):
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# circuit-breaker 熔断器
|
||||
|
||||
为插件提供熔断能力,防止级联失败。
|
||||
|
||||
## 功能
|
||||
|
||||
- 失败计数熔断
|
||||
- 状态:`closed` → `open` → `half-open`
|
||||
- 可配置失败阈值
|
||||
- 自动恢复机制
|
||||
|
||||
## 状态机
|
||||
|
||||
```
|
||||
closed (正常) → open (熔断) → half-open (半开) → closed (恢复)
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
```python
|
||||
# 检查是否有熔断能力
|
||||
if "circuit_breaker" in capabilities:
|
||||
breaker = extensions["_circuit_breaker_provider"]
|
||||
cb = breaker.create("my-plugin", threshold=5)
|
||||
|
||||
try:
|
||||
result = cb.call(risky_function, arg1, arg2)
|
||||
except Exception:
|
||||
print("熔断器已触发")
|
||||
```
|
||||
Binary file not shown.
@@ -1,70 +0,0 @@
|
||||
"""熔断插件 - 为插件提供熔断能力"""
|
||||
from oss.plugin.types import Plugin, register_plugin_type
|
||||
|
||||
|
||||
class CircuitBreakerProvider:
|
||||
"""熔断能力提供者"""
|
||||
|
||||
def __init__(self):
|
||||
self.breakers: dict[str, "CircuitBreaker"] = {}
|
||||
|
||||
def create(self, name: str, threshold: int = 5) -> "CircuitBreaker":
|
||||
breaker = CircuitBreaker(name, threshold)
|
||||
self.breakers[name] = breaker
|
||||
return breaker
|
||||
|
||||
def get(self, name: str):
|
||||
return self.breakers.get(name)
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
"""熔断器"""
|
||||
|
||||
def __init__(self, name: str, threshold: int = 5):
|
||||
self.name = name
|
||||
self.threshold = threshold
|
||||
self.failures = 0
|
||||
self.state = "closed" # closed, open, half-open
|
||||
|
||||
def call(self, func, *args, **kwargs):
|
||||
if self.state == "open":
|
||||
raise Exception(f"熔断器 '{self.name}' 已打开")
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
self.failures = 0
|
||||
self.state = "closed"
|
||||
return result
|
||||
except Exception as e:
|
||||
self.failures += 1
|
||||
if self.failures >= self.threshold:
|
||||
self.state = "open"
|
||||
raise e
|
||||
|
||||
|
||||
class CircuitBreakerPlugin(Plugin):
|
||||
"""熔断插件"""
|
||||
|
||||
def __init__(self):
|
||||
self.provider = CircuitBreakerProvider()
|
||||
|
||||
def init(self, deps: dict = None):
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def get_provider(self):
|
||||
return self.provider
|
||||
|
||||
|
||||
# 注册类型
|
||||
register_plugin_type("CircuitBreakerProvider", CircuitBreakerProvider)
|
||||
register_plugin_type("CircuitBreaker", CircuitBreaker)
|
||||
|
||||
|
||||
def New():
|
||||
return CircuitBreakerPlugin()
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "circuit-breaker",
|
||||
"version": "1.0.0",
|
||||
"author": "FutureOSS",
|
||||
"description": "熔断器 - 为插件提供熔断能力",
|
||||
"type": "extension"
|
||||
},
|
||||
"config": {
|
||||
"enabled": true,
|
||||
"args": {
|
||||
"default_threshold": 5
|
||||
}
|
||||
},
|
||||
"dependencies": [],
|
||||
"permissions": []
|
||||
}
|
||||
8
store/@{FutureOSS}/code-reviewer.disabled/SIGNATURE
Normal file
8
store/@{FutureOSS}/code-reviewer.disabled/SIGNATURE
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"signature": "BRVmR6gX5do7yBsBCtR9jk5/YoE6igio8d3IVNxAtwAtkBdS2Z3LNv9VwMBXeqOE84Dz1+/ypkQO+rdh9VZpGOpAPGxjCyArff9oS3nW6gazMZdLfMKrtsHxVBAL4Ycjb1NmQ3W0kdZa/aS+r2Q/tqVMJ62bqVR5Lbrc2H8eG/i1gPZsEu5tA7KC9pB8oDfaAY/QxeDczg32zWqh9UDD59Hp7TQMZhsWXsH9FgfvKjYKjcsQUEXs6ijUJ6PxHuc2Jx71xhD/IXseOTmnDCMe+8JdPA5aaVN/TEgmT99RXv62wHR+tulyaCYRd/P3sTItSSb1UYfLqEGBumetNAAGdgf33DMijUHKvufuha0JNOm6CCk+8UGbnYnG79HyaBz+pWfiF/pFX+LV7HTJTkBwQc3vXcvXep25UDspSkL+x2w3f1mk9S/oA5mT2go4kSaORxkCb1fAbh74Bn51VRmQV8XLSUOoZvWHjiaMkMdLsyPyTi2+fxqrDD7ehgeQBp3cNSoiGViqYcFcg2xCuHo2P/W441cZMOscfawdLJxg3N4+UC41LTooXN1+IBWzG7jrGTLyeXAFxGeOBo165WoAnsQZ9hh+uj/plv+LIU/mmOBSpJZIb4SuVJfoEcIDGpa7iieVr//8cTnbNTt9zh3GWYuW1NPIm+/WT4YoPfeAs/M=",
|
||||
"signer": "FutureOSS",
|
||||
"algorithm": "RSA-SHA256",
|
||||
"timestamp": 1775964953.1082504,
|
||||
"plugin_hash": "8894b78ac59c0154acaeb9a976f80588ece406e55079ca633c3b2bd839098d40",
|
||||
"author": "FutureOSS"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
100
store/@{FutureOSS}/code-reviewer.disabled/checks/quality.py
Normal file
100
store/@{FutureOSS}/code-reviewer.disabled/checks/quality.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""质量检查器"""
|
||||
import ast
|
||||
|
||||
|
||||
class QualityChecker:
|
||||
"""质量检查器"""
|
||||
|
||||
def check(self, filepath: str, content: str) -> list:
|
||||
"""执行质量检查"""
|
||||
issues = []
|
||||
|
||||
# 检查函数长度
|
||||
issues.extend(self._check_function_length(filepath, content))
|
||||
|
||||
# 检查参数数量
|
||||
issues.extend(self._check_parameter_count(filepath, content))
|
||||
|
||||
# 检查复杂度
|
||||
issues.extend(self._check_complexity(filepath, content))
|
||||
|
||||
return issues
|
||||
|
||||
def _check_function_length(self, filepath: str, content: str) -> list:
|
||||
"""检查函数长度"""
|
||||
issues = []
|
||||
|
||||
try:
|
||||
tree = ast.parse(content)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
lines = node.end_lineno - node.lineno
|
||||
if lines > 100:
|
||||
issues.append({
|
||||
"file": filepath,
|
||||
"line": node.lineno,
|
||||
"severity": "warning",
|
||||
"type": "long_function",
|
||||
"message": f"函数 {node.name} 过长 ({lines} 行)"
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
return issues
|
||||
|
||||
def _check_parameter_count(self, filepath: str, content: str) -> list:
|
||||
"""检查参数数量"""
|
||||
issues = []
|
||||
|
||||
try:
|
||||
tree = ast.parse(content)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
args = node.args
|
||||
count = len(args.args)
|
||||
if count > 5:
|
||||
issues.append({
|
||||
"file": filepath,
|
||||
"line": node.lineno,
|
||||
"severity": "info",
|
||||
"type": "too_many_params",
|
||||
"message": f"函数 {node.name} 参数过多 ({count} 个)"
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
return issues
|
||||
|
||||
def _check_complexity(self, filepath: str, content: str) -> list:
|
||||
"""检查圈复杂度"""
|
||||
issues = []
|
||||
|
||||
try:
|
||||
tree = ast.parse(content)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
complexity = self._calculate_complexity(node)
|
||||
if complexity > 10:
|
||||
issues.append({
|
||||
"file": filepath,
|
||||
"line": node.lineno,
|
||||
"severity": "warning",
|
||||
"type": "high_complexity",
|
||||
"message": f"函数 {node.name} 复杂度过高 (圈复杂度: {complexity})"
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
return issues
|
||||
|
||||
def _calculate_complexity(self, node: ast.AST) -> int:
|
||||
"""计算圈复杂度"""
|
||||
complexity = 1
|
||||
|
||||
for child in ast.walk(node):
|
||||
if isinstance(child, (ast.If, ast.While, ast.For, ast.Try, ast.With)):
|
||||
complexity += 1
|
||||
elif isinstance(child, ast.BoolOp):
|
||||
complexity += len(child.values) - 1
|
||||
|
||||
return complexity
|
||||
323
store/@{FutureOSS}/code-reviewer.disabled/checks/references.py
Normal file
323
store/@{FutureOSS}/code-reviewer.disabled/checks/references.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""引用检查器 - 检测导入错误、变量错误等"""
|
||||
import ast
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ReferenceChecker:
|
||||
"""引用检查器"""
|
||||
|
||||
# Python 标准库模块列表
|
||||
STD_MODULES = {
|
||||
'os', 'sys', 'json', 're', 'time', 'datetime', 'pathlib',
|
||||
'typing', 'collections', 'functools', 'itertools', 'io',
|
||||
'string', 'math', 'random', 'hashlib', 'hmac', 'secrets',
|
||||
'urllib', 'http', 'email', 'html', 'xml', 'csv', 'configparser',
|
||||
'logging', 'warnings', 'traceback', 'inspect', 'importlib',
|
||||
'threading', 'multiprocessing', 'subprocess', 'socket',
|
||||
'asyncio', 'concurrent', 'queue', 'contextlib', 'abc',
|
||||
'enum', 'dataclasses', 'copy', 'pprint', 'textwrap',
|
||||
'struct', 'codecs', 'locale', 'gettext', 'argparse',
|
||||
'unittest', 'doctest', 'pdb', 'profile', 'timeit',
|
||||
'tempfile', 'glob', 'fnmatch', 'stat', 'fileinput',
|
||||
'shutil', 'pickle', 'shelve', 'sqlite3', 'dbm',
|
||||
'gzip', 'bz2', 'lzma', 'zipfile', 'tarfile',
|
||||
'base64', 'binascii', 'quopri', 'uu',
|
||||
}
|
||||
|
||||
# Python 内置函数和类型(不应报告为未定义)
|
||||
BUILTINS = {
|
||||
'print', 'len', 'str', 'int', 'float', 'bool', 'list', 'dict',
|
||||
'set', 'tuple', 'range', 'enumerate', 'zip', 'map', 'filter',
|
||||
'sorted', 'reversed', 'min', 'max', 'sum', 'abs', 'round',
|
||||
'isinstance', 'issubclass', 'type', 'id', 'hash', 'repr',
|
||||
'True', 'False', 'None', 'Exception', 'ValueError', 'TypeError',
|
||||
'KeyError', 'AttributeError', 'ImportError', 'FileNotFoundError',
|
||||
'IndexError', 'RuntimeError', 'StopIteration', 'GeneratorExit',
|
||||
'staticmethod', 'classmethod', 'property', 'super',
|
||||
'open', 'input', 'format', 'hex', 'oct', 'bin', 'chr', 'ord',
|
||||
'dir', 'vars', 'locals', 'globals', 'callable', 'getattr',
|
||||
'setattr', 'hasattr', 'delattr', 'exec', 'eval', 'compile',
|
||||
'any', 'all', 'slice', 'frozenset', 'bytearray', 'bytes',
|
||||
'memoryview', 'complex', 'divmod', 'pow', 'object',
|
||||
'dict', 'list', 'str', 'int', 'float', 'bool', 'set',
|
||||
'tuple', 'Exception', 'ValueError', 'TypeError', 'KeyError',
|
||||
'self', 'cls', 'args', 'kwargs',
|
||||
}
|
||||
|
||||
def __init__(self, project_root: str = "."):
|
||||
self.project_root = Path(project_root)
|
||||
self._available_modules = set(self.STD_MODULES)
|
||||
self._scan_project_modules()
|
||||
|
||||
def _scan_project_modules(self):
|
||||
"""扫描项目中的可用模块"""
|
||||
# 扫描 oss 目录(框架核心)
|
||||
oss_dir = self.project_root / "oss"
|
||||
if oss_dir.exists():
|
||||
self._available_modules.add("oss")
|
||||
self._scan_module_dir(oss_dir, "oss")
|
||||
|
||||
# 扫描 store 目录下的所有插件
|
||||
store_dir = self.project_root / "store"
|
||||
if store_dir.exists():
|
||||
for author_dir in store_dir.iterdir():
|
||||
if not author_dir.is_dir():
|
||||
continue
|
||||
for plugin_dir in author_dir.iterdir():
|
||||
if not plugin_dir.is_dir():
|
||||
continue
|
||||
plugin_name = plugin_dir.name
|
||||
# 添加插件名作为可用模块
|
||||
self._available_modules.add(plugin_name)
|
||||
# 扫描插件内部的子模块
|
||||
self._scan_plugin_modules(plugin_dir, plugin_name)
|
||||
|
||||
def _scan_module_dir(self, dir_path: Path, base_name: str):
|
||||
"""扫描模块目录"""
|
||||
if dir_path.exists():
|
||||
for item in dir_path.iterdir():
|
||||
if item.is_file() and item.name.endswith(".py") and item.name != "__init__.py":
|
||||
module_name = item.name[:-3]
|
||||
full_name = f"{base_name}.{module_name}"
|
||||
self._available_modules.add(full_name)
|
||||
elif item.is_dir() and (item / "__init__.py").exists():
|
||||
full_name = f"{base_name}.{item.name}"
|
||||
self._available_modules.add(full_name)
|
||||
self._scan_module_dir(item, full_name)
|
||||
|
||||
def _scan_plugin_modules(self, plugin_dir: Path, base_name: str):
|
||||
"""扫描插件内部的子模块"""
|
||||
for item in plugin_dir.iterdir():
|
||||
if item.is_dir() and (item / "__init__.py").exists():
|
||||
full_name = f"{base_name}.{item.name}"
|
||||
self._available_modules.add(full_name)
|
||||
self._scan_module_dir(item, full_name)
|
||||
elif item.is_file() and item.name.endswith(".py") and item.name != "__init__.py":
|
||||
module_name = item.name[:-3]
|
||||
full_name = f"{base_name}.{module_name}"
|
||||
self._available_modules.add(full_name)
|
||||
|
||||
def _add_module_from_dir(self, dir_path: Path, base_name: str):
|
||||
"""从目录添加模块"""
|
||||
if dir_path.exists():
|
||||
for item in dir_path.iterdir():
|
||||
if item.is_file() and item.name.endswith(".py") and item.name != "__init__.py":
|
||||
module_name = item.name[:-3]
|
||||
self._available_modules.add(f"{base_name}.{module_name}")
|
||||
elif item.is_dir() and (item / "__init__.py").exists():
|
||||
self._add_module_from_dir(item, f"{base_name}.{item.name}")
|
||||
|
||||
def check(self, filepath: str, content: str) -> list:
|
||||
"""执行引用检查"""
|
||||
issues = []
|
||||
|
||||
try:
|
||||
tree = ast.parse(content)
|
||||
except SyntaxError as e:
|
||||
return [{
|
||||
"file": filepath,
|
||||
"line": e.lineno or 0,
|
||||
"severity": "critical",
|
||||
"type": "syntax_error",
|
||||
"message": f"语法错误: {e.msg}"
|
||||
}]
|
||||
|
||||
# 检查导入语句(跳过相对导入)
|
||||
issues.extend(self._check_imports(filepath, tree))
|
||||
|
||||
# 检查属性访问错误
|
||||
issues.extend(self._check_attribute_access(filepath, tree, content))
|
||||
|
||||
# 检查函数调用错误
|
||||
issues.extend(self._check_function_calls(filepath, tree, content))
|
||||
|
||||
return issues
|
||||
|
||||
def _check_imports(self, filepath: str, tree: ast.AST) -> list:
|
||||
"""检查导入语句"""
|
||||
issues = []
|
||||
file_path = Path(filepath)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
# 跳过 oss 框架模块(运行时可用)
|
||||
if alias.name.startswith('oss.') or alias.name == 'oss':
|
||||
continue
|
||||
# 跳过 websockets 等第三方库
|
||||
if alias.name in ('websockets', 'yaml', 'click'):
|
||||
continue
|
||||
if not self._is_module_available(alias.name, file_path):
|
||||
issues.append({
|
||||
"file": filepath,
|
||||
"line": node.lineno,
|
||||
"severity": "critical",
|
||||
"type": "import_error",
|
||||
"message": f"无法导入模块: {alias.name}"
|
||||
})
|
||||
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
# 跳过相对导入(以 . 开头)
|
||||
if node.level and node.level > 0:
|
||||
continue
|
||||
|
||||
# 跳过 oss 框架模块
|
||||
if node.module and (node.module.startswith('oss.') or node.module == 'oss'):
|
||||
continue
|
||||
|
||||
# 跳过第三方库
|
||||
if node.module and node.module.split('.')[0] in ('websockets', 'yaml', 'click'):
|
||||
continue
|
||||
|
||||
if node.module:
|
||||
if not self._is_module_available(node.module, file_path):
|
||||
issues.append({
|
||||
"file": filepath,
|
||||
"line": node.lineno,
|
||||
"severity": "critical",
|
||||
"type": "import_error",
|
||||
"message": f"无法导入模块: {node.module}"
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
def _check_variable_references(self, filepath: str, tree: ast.AST, content: str) -> list:
|
||||
"""检查变量引用"""
|
||||
issues = []
|
||||
lines = content.split('\n')
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
|
||||
# 检查是否引用了未定义的变量
|
||||
if not self._is_name_defined(node.id, tree, node.lineno):
|
||||
if node.id not in ('True', 'False', 'None', 'self', 'cls'):
|
||||
issues.append({
|
||||
"file": filepath,
|
||||
"line": node.lineno,
|
||||
"severity": "warning",
|
||||
"type": "undefined_variable",
|
||||
"message": f"使用了未定义的变量: {node.id}"
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
def _check_attribute_access(self, filepath: str, tree: ast.AST, content: str) -> list:
|
||||
"""检查属性访问"""
|
||||
issues = []
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Attribute):
|
||||
# 检查可能的属性错误
|
||||
if isinstance(node.value, ast.Name):
|
||||
var_name = node.value.id
|
||||
if var_name in ('None', 'True', 'False'):
|
||||
issues.append({
|
||||
"file": filepath,
|
||||
"line": node.lineno,
|
||||
"severity": "critical",
|
||||
"type": "attribute_error",
|
||||
"message": f"尝试访问 {var_name} 的属性: {node.attr}"
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
def _check_function_calls(self, filepath: str, tree: ast.AST, content: str) -> list:
|
||||
"""检查函数调用"""
|
||||
issues = []
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call):
|
||||
# 检查调用不存在的方法
|
||||
if isinstance(node.func, ast.Attribute):
|
||||
if isinstance(node.func.value, ast.Constant) and node.func.value.value is None:
|
||||
issues.append({
|
||||
"file": filepath,
|
||||
"line": node.lineno,
|
||||
"severity": "critical",
|
||||
"type": "method_call_on_none",
|
||||
"message": f"在 None 上调用方法: {node.func.attr}"
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
def _is_module_available(self, module_name: str, file_path: Path = None) -> bool:
|
||||
"""检查模块是否可用"""
|
||||
# 检查是否在已扫描的模块中
|
||||
if module_name in self._available_modules:
|
||||
return True
|
||||
|
||||
# 检查标准库
|
||||
base_module = module_name.split('.')[0]
|
||||
if base_module in self.STD_MODULES:
|
||||
return True
|
||||
|
||||
# 检查是否是 oss 框架模块
|
||||
if module_name.startswith('oss.') or module_name == 'oss':
|
||||
return True
|
||||
|
||||
# 检查是否是常见第三方库
|
||||
third_party = {'websockets', 'yaml', 'click', 'requests', 'flask', 'django', 'numpy', 'pandas'}
|
||||
if module_name.split('.')[0] in third_party:
|
||||
return True
|
||||
|
||||
# 检查是否是当前文件的同目录模块(相对导入的情况)
|
||||
if file_path:
|
||||
file_dir = file_path.parent
|
||||
# 检查同级 .py 文件
|
||||
sibling_module = file_dir / f"{module_name}.py"
|
||||
if sibling_module.exists():
|
||||
return True
|
||||
# 检查同级包
|
||||
sibling_pkg = file_dir / module_name
|
||||
if sibling_pkg.is_dir() and (sibling_pkg / "__init__.py").exists():
|
||||
return True
|
||||
# 检查 store 目录下的插件
|
||||
store_dir = self.project_root / "store"
|
||||
if store_dir.exists():
|
||||
for author_dir in store_dir.iterdir():
|
||||
if author_dir.is_dir():
|
||||
for plugin_dir in author_dir.iterdir():
|
||||
if plugin_dir.is_dir() and plugin_dir.name == module_name.split('.')[0]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_name_defined(self, name: str, tree: ast.AST, line: int) -> bool:
|
||||
"""检查名称是否已定义"""
|
||||
# 检查是否是内置函数/类型
|
||||
if name in self.BUILTINS:
|
||||
return True
|
||||
|
||||
# 检查是否是函数参数
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
for arg in node.args.args:
|
||||
if arg.arg == name:
|
||||
return True
|
||||
|
||||
# 检查是否是赋值目标
|
||||
elif isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == name:
|
||||
return True
|
||||
|
||||
# 检查是否是循环变量
|
||||
elif isinstance(node, ast.For):
|
||||
if isinstance(node.target, ast.Name) and node.target.id == name:
|
||||
return True
|
||||
|
||||
# 检查是否是导入
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.asname == name or alias.name == name:
|
||||
return True
|
||||
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module:
|
||||
for alias in node.names:
|
||||
if alias.asname == name or alias.name == name:
|
||||
return True
|
||||
|
||||
return False
|
||||
85
store/@{FutureOSS}/code-reviewer.disabled/checks/security.py
Normal file
85
store/@{FutureOSS}/code-reviewer.disabled/checks/security.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""安全检查器"""
|
||||
|
||||
|
||||
class SecurityChecker:
|
||||
"""安全检查器"""
|
||||
|
||||
def check(self, filepath: str, content: str) -> list:
|
||||
"""执行安全检查"""
|
||||
issues = []
|
||||
|
||||
# 检查硬编码密钥
|
||||
issues.extend(self._check_secrets(filepath, content))
|
||||
|
||||
# 检查危险函数
|
||||
issues.extend(self._check_dangerous_functions(filepath, content))
|
||||
|
||||
# 检查路径穿越
|
||||
issues.extend(self._check_path_traversal(filepath, content))
|
||||
|
||||
return issues
|
||||
|
||||
def _check_secrets(self, filepath: str, content: str) -> list:
|
||||
"""检查硬编码密钥"""
|
||||
issues = []
|
||||
patterns = ['password', 'secret', 'token', 'api_key', 'access_token']
|
||||
|
||||
for i, line in enumerate(content.split('\n'), 1):
|
||||
stripped = line.strip()
|
||||
# 跳过注释和模式定义行
|
||||
if stripped.startswith('#') or stripped.startswith('patterns') or "'" in stripped[:20]:
|
||||
continue
|
||||
|
||||
for pattern in patterns:
|
||||
if pattern + ' = "' in line.lower() or pattern + " = '" in line.lower():
|
||||
issues.append({
|
||||
"file": filepath,
|
||||
"line": i,
|
||||
"severity": "critical",
|
||||
"type": "hardcoded_secret",
|
||||
"message": f"发现硬编码密钥: {line.strip()[:50]}"
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
def _check_dangerous_functions(self, filepath: str, content: str) -> list:
|
||||
"""检查危险函数"""
|
||||
issues = []
|
||||
dangerous = ['eval(', 'exec(', 'os.system(', 'subprocess.call(', 'subprocess.run(']
|
||||
|
||||
# 跳过检查安全检查器自身
|
||||
if 'code-reviewer/checks/security.py' in filepath:
|
||||
return []
|
||||
|
||||
for i, line in enumerate(content.split('\n'), 1):
|
||||
# 跳过注释和模式定义行
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('#') or 'dangerous' in stripped.lower() or "['" in stripped[:30]:
|
||||
continue
|
||||
|
||||
for func in dangerous:
|
||||
if func in line:
|
||||
issues.append({
|
||||
"file": filepath,
|
||||
"line": i,
|
||||
"severity": "warning",
|
||||
"type": "dangerous_function",
|
||||
"message": f"使用危险函数: {func.strip()}"
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
def _check_path_traversal(self, filepath: str, content: str) -> list:
|
||||
"""检查路径穿越风险"""
|
||||
issues = []
|
||||
|
||||
if '../' in content and 'open(' in content:
|
||||
issues.append({
|
||||
"file": filepath,
|
||||
"line": 0,
|
||||
"severity": "warning",
|
||||
"type": "path_traversal_risk",
|
||||
"message": "可能存在路径穿越漏洞"
|
||||
})
|
||||
|
||||
return issues
|
||||
70
store/@{FutureOSS}/code-reviewer.disabled/checks/style.py
Normal file
70
store/@{FutureOSS}/code-reviewer.disabled/checks/style.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""风格检查器"""
|
||||
|
||||
|
||||
class StyleChecker:
|
||||
"""风格检查器"""
|
||||
|
||||
def check(self, filepath: str, content: str) -> list:
|
||||
"""执行风格检查"""
|
||||
issues = []
|
||||
|
||||
# 检查行长度
|
||||
issues.extend(self._check_line_length(filepath, content))
|
||||
|
||||
# 检查空行
|
||||
issues.extend(self._check_blank_lines(filepath, content))
|
||||
|
||||
# 检查文件末尾换行
|
||||
issues.extend(self._check_final_newline(filepath, content))
|
||||
|
||||
return issues
|
||||
|
||||
def _check_line_length(self, filepath: str, content: str) -> list:
|
||||
"""检查行长度"""
|
||||
issues = []
|
||||
|
||||
for i, line in enumerate(content.split('\n'), 1):
|
||||
if len(line) > 120:
|
||||
issues.append({
|
||||
"file": filepath,
|
||||
"line": i,
|
||||
"severity": "info",
|
||||
"type": "line_too_long",
|
||||
"message": f"行过长 ({len(line)} 字符)"
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
def _check_blank_lines(self, filepath: str, content: str) -> list:
|
||||
"""检查连续空行"""
|
||||
issues = []
|
||||
blank_count = 0
|
||||
|
||||
for i, line in enumerate(content.split('\n'), 1):
|
||||
if line.strip() == '':
|
||||
blank_count += 1
|
||||
if blank_count > 2:
|
||||
issues.append({
|
||||
"file": filepath,
|
||||
"line": i,
|
||||
"severity": "info",
|
||||
"type": "too_many_blanks",
|
||||
"message": "连续空行过多"
|
||||
})
|
||||
else:
|
||||
blank_count = 0
|
||||
|
||||
return issues
|
||||
|
||||
def _check_final_newline(self, filepath: str, content: str) -> list:
|
||||
"""检查文件末尾换行"""
|
||||
if content and not content.endswith('\n'):
|
||||
return [{
|
||||
"file": filepath,
|
||||
"line": len(content.split('\n')),
|
||||
"severity": "info",
|
||||
"type": "missing_final_newline",
|
||||
"message": "文件末尾缺少换行符"
|
||||
}]
|
||||
|
||||
return []
|
||||
Binary file not shown.
Binary file not shown.
94
store/@{FutureOSS}/code-reviewer.disabled/core/reviewer.py
Normal file
94
store/@{FutureOSS}/code-reviewer.disabled/core/reviewer.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""代码审查器核心"""
|
||||
import os
|
||||
import ast
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from checks.security import SecurityChecker
|
||||
from checks.quality import QualityChecker
|
||||
from checks.style import StyleChecker
|
||||
from checks.references import ReferenceChecker
|
||||
from report.formatter import ReportFormatter
|
||||
|
||||
|
||||
class CodeReviewer:
|
||||
"""代码审查器"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
self.security = SecurityChecker()
|
||||
self.quality = QualityChecker()
|
||||
self.style = StyleChecker()
|
||||
self.references = ReferenceChecker()
|
||||
self.formatter = ReportFormatter(config.get("report_format", "console"))
|
||||
|
||||
def run_check(self, scan_dirs: list) -> dict:
|
||||
"""执行检查"""
|
||||
start_time = time.time()
|
||||
issues = []
|
||||
files_scanned = 0
|
||||
|
||||
for scan_dir in scan_dirs:
|
||||
if not os.path.exists(scan_dir):
|
||||
continue
|
||||
|
||||
for root, dirs, files in os.walk(scan_dir):
|
||||
# 排除目录
|
||||
dirs[:] = [d for d in dirs if d not in self.config.get("exclude_patterns", [])]
|
||||
|
||||
for file in files:
|
||||
if file.endswith('.py'):
|
||||
filepath = os.path.join(root, file)
|
||||
file_size = os.path.getsize(filepath)
|
||||
|
||||
if file_size > self.config.get("max_file_size", 102400):
|
||||
continue
|
||||
|
||||
issues.extend(self._check_file(filepath))
|
||||
files_scanned += 1
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
result = {
|
||||
"status": "completed",
|
||||
"files_scanned": files_scanned,
|
||||
"total_issues": len(issues),
|
||||
"issues": issues,
|
||||
"scan_time": round(elapsed, 2),
|
||||
"timestamp": time.time()
|
||||
}
|
||||
|
||||
print(self.formatter.format(result))
|
||||
return result
|
||||
|
||||
def _check_file(self, filepath: str) -> list:
|
||||
"""检查单个文件"""
|
||||
issues = []
|
||||
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 安全检查
|
||||
issues.extend(self.security.check(filepath, content))
|
||||
|
||||
# 质量检查
|
||||
issues.extend(self.quality.check(filepath, content))
|
||||
|
||||
# 风格检查
|
||||
issues.extend(self.style.check(filepath, content))
|
||||
|
||||
# 引用检查(新增)
|
||||
issues.extend(self.references.check(filepath, content))
|
||||
|
||||
except Exception as e:
|
||||
issues.append({
|
||||
"file": filepath,
|
||||
"line": 0,
|
||||
"severity": "error",
|
||||
"type": "parse_error",
|
||||
"message": f"文件解析失败: {e}"
|
||||
})
|
||||
|
||||
return issues
|
||||
70
store/@{FutureOSS}/code-reviewer.disabled/main.py
Normal file
70
store/@{FutureOSS}/code-reviewer.disabled/main.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""代码审查器插件"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from oss.logger.logger import Log
|
||||
from oss.plugin.types import Plugin, register_plugin_type
|
||||
from core.reviewer import CodeReviewer
|
||||
|
||||
|
||||
class CodeReviewerPlugin(Plugin):
|
||||
"""代码审查器插件"""
|
||||
|
||||
def __init__(self):
|
||||
self.reviewer = None
|
||||
self.config = {}
|
||||
|
||||
def meta(self):
|
||||
from oss.plugin.types import Metadata, PluginConfig, Manifest
|
||||
return Manifest(
|
||||
metadata=Metadata(
|
||||
name="code-reviewer",
|
||||
version="1.0.0",
|
||||
author="FutureOSS",
|
||||
description="代码审查器 - 自动扫描代码问题"
|
||||
),
|
||||
config=PluginConfig(
|
||||
enabled=True,
|
||||
args={
|
||||
"scan_dirs": ["store", "oss"],
|
||||
"exclude_patterns": ["__pycache__", "*.pyc"],
|
||||
"max_file_size": 102400,
|
||||
"report_format": "console"
|
||||
}
|
||||
),
|
||||
dependencies=[]
|
||||
)
|
||||
|
||||
def init(self, deps: dict = None):
|
||||
config = {}
|
||||
if deps:
|
||||
config = deps.get("config", {})
|
||||
|
||||
self.config = {
|
||||
"scan_dirs": config.get("scan_dirs", ["store", "oss"]),
|
||||
"exclude_patterns": config.get("exclude_patterns", ["__pycache__"]),
|
||||
"max_file_size": config.get("max_file_size", 102400),
|
||||
"report_format": config.get("report_format", "console")
|
||||
}
|
||||
|
||||
self.reviewer = CodeReviewer(self.config)
|
||||
Log.info("code-reviewer", "初始化完成")
|
||||
|
||||
def start(self):
|
||||
Log.info("code-reviewer", "插件已启动")
|
||||
|
||||
def stop(self):
|
||||
Log.error("code-reviewer", "插件已停止")
|
||||
|
||||
def check(self, dirs: list = None) -> dict:
|
||||
"""执行代码检查"""
|
||||
scan_dirs = dirs or self.config["scan_dirs"]
|
||||
return self.reviewer.run_check(scan_dirs)
|
||||
|
||||
|
||||
register_plugin_type("CodeReviewerPlugin", CodeReviewerPlugin)
|
||||
|
||||
|
||||
def New():
|
||||
return CodeReviewerPlugin()
|
||||
20
store/@{FutureOSS}/code-reviewer.disabled/manifest.json
Normal file
20
store/@{FutureOSS}/code-reviewer.disabled/manifest.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "code-reviewer",
|
||||
"version": "1.0.0",
|
||||
"author": "FutureOSS",
|
||||
"description": "代码审查器 - 提供 oss check 功能,自动扫描代码问题",
|
||||
"type": "tool"
|
||||
},
|
||||
"config": {
|
||||
"enabled": true,
|
||||
"args": {
|
||||
"scan_dirs": ["store", "oss"],
|
||||
"exclude_patterns": ["__pycache__", "*.pyc", "*.pyo"],
|
||||
"max_file_size": 102400,
|
||||
"report_format": "console"
|
||||
}
|
||||
},
|
||||
"dependencies": [],
|
||||
"permissions": ["*"]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,59 @@
|
||||
"""报告格式化器"""
|
||||
|
||||
|
||||
class ReportFormatter:
|
||||
"""报告格式化器"""
|
||||
|
||||
def __init__(self, format_type: str = "console"):
|
||||
self.format_type = format_type
|
||||
|
||||
def format(self, result: dict) -> str:
|
||||
"""格式化报告"""
|
||||
if self.format_type == "console":
|
||||
return self._format_console(result)
|
||||
elif self.format_type == "json":
|
||||
return self._format_json(result)
|
||||
return str(result)
|
||||
|
||||
def _format_console(self, result: dict) -> str:
|
||||
"""控制台格式"""
|
||||
lines = []
|
||||
lines.append("=" * 60)
|
||||
lines.append("代码审查报告")
|
||||
lines.append("=" * 60)
|
||||
lines.append(f"扫描文件: {result['files_scanned']}")
|
||||
lines.append(f"发现问题: {result['total_issues']}")
|
||||
lines.append(f"扫描时间: {result['scan_time']}s")
|
||||
lines.append("")
|
||||
|
||||
# 按严重程度分类
|
||||
critical = [i for i in result['issues'] if i['severity'] == 'critical']
|
||||
warning = [i for i in result['issues'] if i['severity'] == 'warning']
|
||||
info = [i for i in result['issues'] if i['severity'] == 'info']
|
||||
|
||||
lines.append(f"🔴 严重: {len(critical)}")
|
||||
lines.append(f"🟡 警告: {len(warning)}")
|
||||
lines.append(f"🔵 提示: {len(info)}")
|
||||
lines.append("")
|
||||
|
||||
if critical:
|
||||
lines.append("严重问题:")
|
||||
for issue in critical:
|
||||
lines.append(f" - {issue['file']}:{issue['line']} - {issue['message']}")
|
||||
lines.append("")
|
||||
|
||||
if warning:
|
||||
lines.append("警告:")
|
||||
for issue in warning[:10]: # 最多显示10个
|
||||
lines.append(f" - {issue['file']}:{issue['line']} - {issue['message']}")
|
||||
if len(warning) > 10:
|
||||
lines.append(f" ... 还有 {len(warning) - 10} 个警告")
|
||||
lines.append("")
|
||||
|
||||
lines.append("=" * 60)
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _format_json(self, result: dict) -> str:
|
||||
"""JSON 格式"""
|
||||
import json
|
||||
return json.dumps(result, indent=2, ensure_ascii=False)
|
||||
8
store/@{FutureOSS}/dashboard/SIGNATURE
Normal file
8
store/@{FutureOSS}/dashboard/SIGNATURE
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"signature": "vn4hpZQMQTX0d78Wlze2wtTHjN91qn1PIvsRTK7ZFVm8lZ3eQHrZz9X0uDWcKKjxf5FCI/UVKQOqLwYkHiGhcS7d7+v6UKKKIYph+aftHQRrEcOQtrSnrmDQrqSjEdL3mjkl0KTIwqkFySxVNn9ssmL16JCOtWpWpKU5CnKWVrbeEKvs6yZJrmVVr9C7iDGsNq0/aS3oPDI4vg1iaTYgg/2Sh1smJ0jNtE5EsCq78fcyUcSWTziwq8RnJvFsx8LP3cxacC1QuZIP3hTIrpnApAj0KqSTRDLKY7d7rsQAHgDlnbQfYVtA8x94x91R5ybeDpXwYPSwWMpb7P/7XBDJ5GKL56iFUCV0tceHNK9yyjaXdhf2oUTxfoC4ONOTnkmnP2pZ6vRLjd/0WX7qA0XUTmZtewWur1BnZeZwzOjI5K8IYCda5WKXLVyrH64XmBEAwkEu18LIO9xI+DnhbM7rR9/xO+cXHkOYtKgAJMHCzgi6o6tw/UgS9K0myoMeGg58gYaDIVbXpxpf3rHSyFQAwauI67oye7ZxNxJgKnnOtX92cpQLHDfML8psd+sAIuBazxqxe484qzF2k0F5ZZMP17V6Yd3UWUkvWMoKlktq14OwJ2Q67nrmt9OC+9Epzny4gkq/Q7ih85rGwMVxRvkKhxxLLelQLVIni363yOxn7UE=",
|
||||
"signer": "FutureOSS",
|
||||
"algorithm": "RSA-SHA256",
|
||||
"timestamp": 1775967256.7737296,
|
||||
"plugin_hash": "68f5ab432690beef86da1c167c704fdd6b60512a359e806516dce1c6be27b9c5",
|
||||
"author": "FutureOSS"
|
||||
}
|
||||
BIN
store/@{FutureOSS}/dashboard/__pycache__/main.cpython-313.pyc
Normal file
BIN
store/@{FutureOSS}/dashboard/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
91
store/@{FutureOSS}/dashboard/assets/css/dashboard.css
Normal file
91
store/@{FutureOSS}/dashboard/assets/css/dashboard.css
Normal file
@@ -0,0 +1,91 @@
|
||||
/* Dashboard 仪表盘样式 */
|
||||
|
||||
.dashboard {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.dashboard-header h2 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dashboard-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.dashboard-section h3 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 20px;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #666;
|
||||
}
|
||||
28
store/@{FutureOSS}/dashboard/config.json
Normal file
28
store/@{FutureOSS}/dashboard/config.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"refreshInterval": {
|
||||
"type": "number",
|
||||
"name": "刷新间隔",
|
||||
"description": "仪表盘数据自动刷新的间隔时间(秒)",
|
||||
"default": 2,
|
||||
"min": 1,
|
||||
"max": 60,
|
||||
"order": 1
|
||||
},
|
||||
"showDisk": {
|
||||
"type": "boolean",
|
||||
"name": "显示磁盘",
|
||||
"description": "是否在仪表盘显示磁盘使用率",
|
||||
"default": true,
|
||||
"order": 2
|
||||
},
|
||||
"diskThreshold": {
|
||||
"type": "number",
|
||||
"name": "磁盘警告阈值",
|
||||
"description": "磁盘使用率超过此值时显示警告颜色",
|
||||
"default": 80,
|
||||
"min": 50,
|
||||
"max": 95,
|
||||
"show_when": { "field": "showDisk", "value": true },
|
||||
"order": 3
|
||||
}
|
||||
}
|
||||
332
store/@{FutureOSS}/dashboard/main.py
Normal file
332
store/@{FutureOSS}/dashboard/main.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Dashboard 仪表盘插件"""
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import socket
|
||||
import subprocess
|
||||
import platform
|
||||
import psutil
|
||||
from collections import deque
|
||||
from oss.logger.logger import Log
|
||||
from oss.plugin.types import Plugin, Response, register_plugin_type
|
||||
|
||||
|
||||
class DashboardPlugin(Plugin):
|
||||
"""仪表盘插件 - 依赖 WebUI 容器"""
|
||||
|
||||
def __init__(self):
|
||||
self.webui = None
|
||||
self.views_dir = os.path.join(os.path.dirname(__file__), 'views')
|
||||
self._start_time = time.time() # 记录插件启动时间(即项目启动时间)
|
||||
self._history_len = 60
|
||||
self._cpu_history = deque(maxlen=self._history_len)
|
||||
self._ram_history = deque(maxlen=self._history_len)
|
||||
self._net_recv_history = deque(maxlen=self._history_len)
|
||||
self._net_sent_history = deque(maxlen=self._history_len)
|
||||
self._disk_read_history = deque(maxlen=self._history_len)
|
||||
self._disk_write_history = deque(maxlen=self._history_len)
|
||||
self._net_latency_history = deque(maxlen=self._history_len)
|
||||
self._last_net = None
|
||||
self._last_disk = None
|
||||
|
||||
def meta(self):
|
||||
from oss.plugin.types import Metadata, PluginConfig, Manifest
|
||||
return Manifest(
|
||||
metadata=Metadata(
|
||||
name="dashboard",
|
||||
version="2.0.0",
|
||||
author="FutureOSS",
|
||||
description="WebUI 仪表盘"
|
||||
),
|
||||
config=PluginConfig(enabled=True, args={}),
|
||||
dependencies=["http-api", "webui"]
|
||||
)
|
||||
|
||||
def set_webui(self, webui):
|
||||
self.webui = webui
|
||||
|
||||
def init(self, deps: dict = None):
|
||||
if self.webui:
|
||||
Log.info("dashboard", "已获取 WebUI 引用")
|
||||
self.webui.register_page(
|
||||
path='/dashboard',
|
||||
content_provider=self._render_content,
|
||||
nav_item={'icon': 'ri-dashboard-line', 'text': '仪表盘'}
|
||||
)
|
||||
if hasattr(self.webui, 'server') and self.webui.server:
|
||||
self.webui.server.router.get("/api/dashboard/stats", self._handle_stats_api)
|
||||
self.webui.server.router.get("/api/dashboard/history", self._handle_history_api)
|
||||
Log.info("dashboard", "已注册到 WebUI 导航")
|
||||
else:
|
||||
Log.warn("dashboard", "警告: 未找到 WebUI 依赖")
|
||||
|
||||
def _get_uptime_str(self):
|
||||
"""计算项目运行时间(从插件启动时算起)"""
|
||||
elapsed = time.time() - self._start_time
|
||||
days = int(elapsed // 86400)
|
||||
hours = int((elapsed % 86400) // 3600)
|
||||
minutes = int((elapsed % 3600) // 60)
|
||||
seconds = int(elapsed % 60)
|
||||
if days > 0:
|
||||
return f"{days}天{hours}时{minutes}分{seconds}秒"
|
||||
elif hours > 0:
|
||||
return f"{hours}时{minutes}分{seconds}秒"
|
||||
elif minutes > 0:
|
||||
return f"{minutes}分{seconds}秒"
|
||||
else:
|
||||
return f"{seconds}秒"
|
||||
|
||||
def _get_network_stats(self):
|
||||
try:
|
||||
net = psutil.net_io_counters()
|
||||
now = time.time()
|
||||
if self._last_net is None:
|
||||
self._last_net = (now, net.bytes_recv, net.bytes_sent)
|
||||
return {'recv_rate': 0, 'sent_rate': 0, 'total_recv': net.bytes_recv, 'total_sent': net.bytes_sent}
|
||||
elapsed = now - self._last_net[0]
|
||||
if elapsed <= 0: elapsed = 1
|
||||
recv_rate = (net.bytes_recv - self._last_net[1]) / elapsed
|
||||
sent_rate = (net.bytes_sent - self._last_net[2]) / elapsed
|
||||
self._last_net = (now, net.bytes_recv, net.bytes_sent)
|
||||
return {'recv_rate': round(recv_rate, 1), 'sent_rate': round(sent_rate, 1), 'total_recv': net.bytes_recv, 'total_sent': net.bytes_sent}
|
||||
except Exception:
|
||||
return {'recv_rate': 0, 'sent_rate': 0, 'total_recv': 0, 'total_sent': 0}
|
||||
|
||||
def _get_disk_io_stats(self):
|
||||
try:
|
||||
disk_io = psutil.disk_io_counters()
|
||||
if not disk_io:
|
||||
return {'read_rate': 0, 'write_rate': 0}
|
||||
now = time.time()
|
||||
if self._last_disk is None:
|
||||
self._last_disk = (now, disk_io.read_bytes, disk_io.write_bytes)
|
||||
return {'read_rate': 0, 'write_rate': 0}
|
||||
elapsed = now - self._last_disk[0]
|
||||
if elapsed <= 0: elapsed = 1
|
||||
read_rate = (disk_io.read_bytes - self._last_disk[1]) / elapsed
|
||||
write_rate = (disk_io.write_bytes - self._last_disk[2]) / elapsed
|
||||
self._last_disk = (now, disk_io.read_bytes, disk_io.write_bytes)
|
||||
return {'read_rate': round(read_rate, 1), 'write_rate': round(write_rate, 1)}
|
||||
except Exception:
|
||||
return {'read_rate': 0, 'write_rate': 0}
|
||||
|
||||
def _get_network_latency(self) -> float:
|
||||
"""测量到公共 DNS 8.8.8.8 的 TCP 连接延迟(真实网络波动)"""
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(2)
|
||||
start = time.time()
|
||||
s.connect(('8.8.8.8', 53))
|
||||
elapsed = (time.time() - start) * 1000 # 毫秒
|
||||
s.close()
|
||||
return round(elapsed, 1)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _get_network_interfaces(self):
|
||||
try:
|
||||
interfaces = []
|
||||
addrs = psutil.net_if_addrs()
|
||||
stats = psutil.net_if_stats()
|
||||
for name, addr_list in addrs.items():
|
||||
if name == 'lo':
|
||||
continue
|
||||
info = {'name': name, 'ip': 'N/A', 'mac': 'N/A', 'is_up': False, 'speed': 0}
|
||||
for addr in addr_list:
|
||||
if addr.family == socket.AF_INET:
|
||||
info['ip'] = addr.address
|
||||
elif hasattr(psutil, 'AF_LINK') and addr.family == psutil.AF_LINK:
|
||||
info['mac'] = addr.address
|
||||
if name in stats:
|
||||
info['is_up'] = stats[name].isup
|
||||
info['speed'] = stats[name].speed
|
||||
interfaces.append(info)
|
||||
return interfaces
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _get_load_info(self):
|
||||
try:
|
||||
load1, load5, load15 = os.getloadavg()
|
||||
return {'load1': round(load1, 2), 'load5': round(load5, 2), 'load15': round(load15, 2)}
|
||||
except (OSError, AttributeError):
|
||||
return {'load1': 0, 'load5': 0, 'load15': 0}
|
||||
|
||||
def _handle_stats_api(self, request):
|
||||
try:
|
||||
cpu_percent = psutil.cpu_percent(interval=0.3)
|
||||
mem = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage('/')
|
||||
net = self._get_network_stats()
|
||||
disk_io = self._get_disk_io_stats()
|
||||
load = self._get_load_info()
|
||||
latency = self._get_network_latency()
|
||||
|
||||
self._cpu_history.append(round(cpu_percent, 1))
|
||||
self._ram_history.append(round(mem.percent, 1))
|
||||
self._net_recv_history.append(net['recv_rate'])
|
||||
self._net_sent_history.append(net['sent_rate'])
|
||||
self._disk_read_history.append(disk_io['read_rate'])
|
||||
self._disk_write_history.append(disk_io['write_rate'])
|
||||
self._net_latency_history.append(latency)
|
||||
|
||||
uptime_str = self._get_uptime_str()
|
||||
|
||||
data = {
|
||||
'cpu': {'percent': round(cpu_percent, 1), 'cores': psutil.cpu_count(logical=True)},
|
||||
'ram': {'percent': round(mem.percent, 1), 'used': round(mem.used / (1024**3), 1), 'total': round(mem.total / (1024**3), 1)},
|
||||
'disk': {'percent': round(disk.percent, 1), 'used': round(disk.used / (1024**3), 1), 'total': round(disk.total / (1024**3), 1)},
|
||||
'network': net,
|
||||
'disk_io': disk_io,
|
||||
'load': load,
|
||||
'latency': latency,
|
||||
'processes': len(psutil.pids()),
|
||||
'uptime': uptime_str
|
||||
}
|
||||
return Response(status=200, headers={"Content-Type": "application/json"}, body=json.dumps(data))
|
||||
except Exception as e:
|
||||
return Response(status=500, headers={"Content-Type": "application/json"}, body=json.dumps({'error': str(e)}))
|
||||
|
||||
def _handle_history_api(self, request):
|
||||
try:
|
||||
data = {
|
||||
'cpu': list(self._cpu_history),
|
||||
'ram': list(self._ram_history),
|
||||
'net_recv': list(self._net_recv_history),
|
||||
'net_sent': list(self._net_sent_history),
|
||||
'disk_read': list(self._disk_read_history),
|
||||
'disk_write': list(self._disk_write_history),
|
||||
'latency': list(self._net_latency_history)
|
||||
}
|
||||
return Response(status=200, headers={"Content-Type": "application/json"}, body=json.dumps(data))
|
||||
except Exception as e:
|
||||
return Response(status=500, headers={"Content-Type": "application/json"}, body=json.dumps({'error': str(e)}))
|
||||
|
||||
def start(self):
|
||||
Log.info("dashboard", "仪表盘已启动")
|
||||
|
||||
def stop(self):
|
||||
Log.error("dashboard", "仪表盘已停止")
|
||||
|
||||
def _render_content(self) -> str:
|
||||
try:
|
||||
php_file = os.path.join(self.views_dir, 'dashboard.php')
|
||||
if not os.path.exists(php_file):
|
||||
return "<p>仪表盘视图文件丢失</p>"
|
||||
|
||||
cpu_percent = psutil.cpu_percent(interval=0.5)
|
||||
cpu_cores = psutil.cpu_count(logical=True)
|
||||
mem = psutil.virtual_memory()
|
||||
ram_percent = round(mem.percent, 1)
|
||||
ram_used_gb = round(mem.used / (1024**3), 1)
|
||||
ram_total_gb = round(mem.total / (1024**3), 1)
|
||||
disk = psutil.disk_usage('/')
|
||||
disk_percent = round(disk.percent, 1)
|
||||
disk_used_gb = round(disk.used / (1024**3), 1)
|
||||
disk_total_gb = round(disk.total / (1024**3), 1)
|
||||
net = self._get_network_stats()
|
||||
disk_io = self._get_disk_io_stats()
|
||||
load = self._get_load_info()
|
||||
net_interfaces = self._get_network_interfaces()
|
||||
processes = len(psutil.pids())
|
||||
|
||||
if disk_percent < 50:
|
||||
disk_color = 'gauge-green'
|
||||
elif disk_percent < 80:
|
||||
disk_color = 'gauge-orange'
|
||||
else:
|
||||
disk_color = 'gauge-blue'
|
||||
|
||||
circumference = 2 * 3.14159 * 52
|
||||
cpu_dash_offset = round(circumference - (cpu_percent / 100) * circumference, 1)
|
||||
ram_dash_offset = round(circumference - (ram_percent / 100) * circumference, 1)
|
||||
disk_dash_offset = round(circumference - (disk_percent / 100) * circumference, 1)
|
||||
|
||||
uptime_str = self._get_uptime_str()
|
||||
|
||||
def fmt_speed(bps):
|
||||
if bps >= 1024 * 1024:
|
||||
return f"{round(bps / (1024*1024), 1)} MB/s"
|
||||
elif bps >= 1024:
|
||||
return f"{round(bps / 1024, 1)} KB/s"
|
||||
else:
|
||||
return f"{round(bps, 0)} B/s"
|
||||
|
||||
variables = {
|
||||
'cpuPercent': int(cpu_percent),
|
||||
'cpuDashArray': str(circumference),
|
||||
'cpuDashOffset': str(cpu_dash_offset),
|
||||
'cpuCores': str(cpu_cores),
|
||||
'ramPercent': ram_percent,
|
||||
'ramDashArray': str(circumference),
|
||||
'ramDashOffset': str(ram_dash_offset),
|
||||
'ramUsed': f"{ram_used_gb} GB",
|
||||
'ramTotal': f"{ram_total_gb} GB",
|
||||
'diskPercent': disk_percent,
|
||||
'diskDashArray': str(circumference),
|
||||
'diskDashOffset': str(disk_dash_offset),
|
||||
'diskUsed': f"{disk_used_gb} GB",
|
||||
'diskTotal': f"{disk_total_gb} GB",
|
||||
'diskColorClass': disk_color,
|
||||
'uptime': uptime_str,
|
||||
'osName': f"{platform.system()} {platform.release()}",
|
||||
'pythonVersion': platform.python_version(),
|
||||
'phpVersion': self._get_php_version(),
|
||||
'hostname': platform.node(),
|
||||
'netRecvSpeed': fmt_speed(net['recv_rate']),
|
||||
'netSentSpeed': fmt_speed(net['sent_rate']),
|
||||
'diskReadSpeed': fmt_speed(disk_io['read_rate']),
|
||||
'diskWriteSpeed': fmt_speed(disk_io['write_rate']),
|
||||
'load1': str(load['load1']),
|
||||
'load5': str(load['load5']),
|
||||
'load15': str(load['load15']),
|
||||
'processes': str(processes),
|
||||
'netInterfaces': json.dumps(net_interfaces),
|
||||
}
|
||||
|
||||
return self._execute_php(php_file, variables)
|
||||
except Exception as e:
|
||||
return f"<p>仪表盘渲染出错: {e}</p>"
|
||||
|
||||
def _execute_php(self, php_file: str, variables: dict) -> str:
|
||||
php_vars = ""
|
||||
for key, value in variables.items():
|
||||
if isinstance(value, str):
|
||||
escaped = value.replace('\\', '\\\\').replace("'", "\\'").replace("\n", "\\n")
|
||||
php_vars += f"${key} = '{escaped}';\n"
|
||||
else:
|
||||
php_vars += f"${key} = {value};\n"
|
||||
|
||||
with open(php_file, 'r', encoding='utf-8') as f:
|
||||
php_content = f.read()
|
||||
|
||||
tmp_file = os.path.join(os.path.dirname(php_file), '.temp_dashboard.php')
|
||||
try:
|
||||
with open(tmp_file, 'w', encoding='utf-8') as f:
|
||||
f.write(f"<?php\n{php_vars}\n?>\n{php_content}")
|
||||
result = subprocess.run(
|
||||
["php", "-f", tmp_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
return result.stdout if result.returncode == 0 else f"<pre>{result.stderr}</pre>"
|
||||
finally:
|
||||
try:
|
||||
if os.path.exists(tmp_file):
|
||||
os.unlink(tmp_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _get_php_version() -> str:
|
||||
try:
|
||||
res = subprocess.run(['php', '-r', 'echo phpversion();'], capture_output=True, text=True, timeout=5)
|
||||
return res.stdout if res.returncode == 0 else 'N/A'
|
||||
except Exception:
|
||||
return 'N/A'
|
||||
|
||||
|
||||
register_plugin_type("DashboardPlugin", DashboardPlugin)
|
||||
|
||||
|
||||
def New():
|
||||
return DashboardPlugin()
|
||||
15
store/@{FutureOSS}/dashboard/manifest.json
Normal file
15
store/@{FutureOSS}/dashboard/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "dashboard",
|
||||
"version": "1.0.0",
|
||||
"author": "FutureOSS",
|
||||
"description": "WebUI 仪表盘",
|
||||
"type": "webui-extension"
|
||||
},
|
||||
"config": {
|
||||
"enabled": true,
|
||||
"args": {}
|
||||
},
|
||||
"dependencies": ["http-api", "webui"],
|
||||
"permissions": ["*"]
|
||||
}
|
||||
350
store/@{FutureOSS}/dashboard/views/dashboard.php
Normal file
350
store/@{FutureOSS}/dashboard/views/dashboard.php
Normal file
@@ -0,0 +1,350 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
.dashboard-container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
||||
.section-title { font-size: 18px; font-weight: 600; color: #00bcd4; margin-bottom: 16px; padding-left: 12px; border-left: 4px solid #3b82f6; }
|
||||
|
||||
.gauges-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 28px; }
|
||||
.gauge-card { background: #1e293b; border-radius: 12px; padding: 20px; display: flex; flex-direction: column; align-items: center; position: relative; }
|
||||
.gauge-card .label { font-size: 14px; color: #94a3b8; margin-bottom: 8px; }
|
||||
.gauge-circle { position: relative; width: 120px; height: 120px; }
|
||||
.gauge-circle svg { transform: rotate(-90deg); }
|
||||
.gauge-circle .bg { fill: none; stroke: #334155; stroke-width: 8; }
|
||||
.gauge-circle .progress { fill: none; stroke: #3b82f6; stroke-width: 8; stroke-linecap: round; transition: stroke-dashoffset 0.8s ease; }
|
||||
.gauge-circle .value { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 24px; font-weight: 700; color: #f1f5f9; }
|
||||
.gauge-circle .unit { font-size: 12px; color: #94a3b8; }
|
||||
.gauge-card .detail { margin-top: 8px; font-size: 12px; color: #64748b; }
|
||||
.gauge-green { stroke: #22c55e; }
|
||||
.gauge-orange { stroke: #f59e0b; }
|
||||
.gauge-blue { stroke: #3b82f6; }
|
||||
.gauge-red { stroke: #ef4444; }
|
||||
|
||||
.io-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-bottom: 28px; }
|
||||
.io-card { background: #1e293b; border-radius: 12px; padding: 20px; }
|
||||
.io-card .card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.io-card .card-header i { font-size: 20px; color: #3b82f6; }
|
||||
.io-card .card-header span { font-size: 15px; font-weight: 600; color: #e2e8f0; }
|
||||
.io-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #334155; }
|
||||
.io-row:last-child { border-bottom: none; }
|
||||
.io-row .io-label { color: #94a3b8; font-size: 13px; }
|
||||
.io-row .io-value { color: #f1f5f9; font-size: 14px; font-weight: 500; }
|
||||
|
||||
.info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; margin-bottom: 28px; }
|
||||
.info-card { background: #1e293b; border-radius: 12px; padding: 20px; }
|
||||
.info-card .card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.info-card .card-header i { font-size: 20px; color: #3b82f6; }
|
||||
.info-card .card-header span { font-size: 15px; font-weight: 600; color: #e2e8f0; }
|
||||
.info-table { width: 100%; }
|
||||
.info-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #334155; }
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-row .info-label { color: #94a3b8; font-size: 13px; }
|
||||
.info-row .info-value { color: #f1f5f9; font-size: 14px; font-weight: 500; }
|
||||
|
||||
.net-ifaces { margin-top: 12px; }
|
||||
.net-iface { background: #0f172a; border-radius: 8px; padding: 12px; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.net-iface .iface-name { font-weight: 600; color: #e2e8f0; }
|
||||
.net-iface .iface-info { font-size: 12px; color: #94a3b8; }
|
||||
.net-iface .iface-status { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
|
||||
.status-up { background: #064e3b; color: #34d399; }
|
||||
.status-down { background: #7f1d1d; color: #f87171; }
|
||||
|
||||
.chart-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 16px; margin-bottom: 28px; }
|
||||
.chart-card { background: #1e293b; border-radius: 12px; padding: 20px; }
|
||||
.chart-card .card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.chart-card .card-header i { font-size: 20px; color: #3b82f6; }
|
||||
.chart-card .card-header span { font-size: 15px; font-weight: 600; color: #e2e8f0; }
|
||||
.chart-wrapper { position: relative; height: 200px; }
|
||||
|
||||
.live-dot { width: 8px; height: 8px; background: #22c55e; border-radius: 50%; display: inline-block; margin-right: 6px; animation: pulse 2s infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(1.3); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="dashboard-container">
|
||||
|
||||
<div class="section-title"><span class="live-dot"></span>实时指标</div>
|
||||
<div class="gauges-grid">
|
||||
<div class="gauge-card">
|
||||
<div class="label">CPU 使用率</div>
|
||||
<div class="gauge-circle">
|
||||
<svg width="120" height="120"><circle class="bg" cx="60" cy="60" r="52" stroke-dasharray="<?= $cpuDashArray ?>"></circle><circle class="progress gauge-blue" id="cpu-gauge" cx="60" cy="60" r="52" stroke-dasharray="<?= $cpuDashArray ?>" stroke-dashoffset="<?= $cpuDashOffset ?>"></circle></svg>
|
||||
<div class="value"><span id="cpu-val"><?= $cpuPercent ?></span><span class="unit">%</span></div>
|
||||
</div>
|
||||
<div class="detail"><?= $cpuCores ?> 核心</div>
|
||||
</div>
|
||||
<div class="gauge-card">
|
||||
<div class="label">内存使用</div>
|
||||
<div class="gauge-circle">
|
||||
<svg width="120" height="120"><circle class="bg" cx="60" cy="60" r="52" stroke-dasharray="<?= $ramDashArray ?>"></circle><circle class="progress gauge-green" id="ram-gauge" cx="60" cy="60" r="52" stroke-dasharray="<?= $ramDashArray ?>" stroke-dashoffset="<?= $ramDashOffset ?>"></circle></svg>
|
||||
<div class="value"><span id="ram-val"><?= $ramPercent ?></span><span class="unit">%</span></div>
|
||||
</div>
|
||||
<div class="detail"><?= $ramUsed ?> / <?= $ramTotal ?></div>
|
||||
</div>
|
||||
<div class="gauge-card">
|
||||
<div class="label">磁盘使用</div>
|
||||
<div class="gauge-circle">
|
||||
<svg width="120" height="120"><circle class="bg" cx="60" cy="60" r="52" stroke-dasharray="<?= $diskDashArray ?>"></circle><circle class="progress <?= $diskColorClass ?>" id="disk-gauge" cx="60" cy="60" r="52" stroke-dasharray="<?= $diskDashArray ?>" stroke-dashoffset="<?= $diskDashOffset ?>"></circle></svg>
|
||||
<div class="value"><span id="disk-val"><?= $diskPercent ?></span><span class="unit">%</span></div>
|
||||
</div>
|
||||
<div class="detail"><?= $diskUsed ?> / <?= $diskTotal ?></div>
|
||||
</div>
|
||||
<div class="gauge-card">
|
||||
<div class="label">系统负载</div>
|
||||
<div class="gauge-circle">
|
||||
<svg width="120" height="120"><circle class="bg" cx="60" cy="60" r="52" stroke-dasharray="326.73"></circle><circle class="progress gauge-orange" cx="60" cy="60" r="52" stroke-dasharray="326.73" stroke-dashoffset="0"></circle></svg>
|
||||
<div class="value" style="font-size:16px" id="load-val"><?= $load1 ?></div>
|
||||
</div>
|
||||
<div class="detail">1m / 5m / 15m: <?= $load1 ?> / <?= $load5 ?> / <?= $load15 ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">网络 & 磁盘 I/O</div>
|
||||
<div class="io-grid">
|
||||
<div class="io-card">
|
||||
<div class="card-header"><i class="ri-global-line"></i><span>网络流量</span></div>
|
||||
<div class="io-row"><span class="io-label">下载速度</span><span class="io-value" id="net-recv"><?= $netRecvSpeed ?></span></div>
|
||||
<div class="io-row"><span class="io-label">上传速度</span><span class="io-value" id="net-sent"><?= $netSentSpeed ?></span></div>
|
||||
</div>
|
||||
<div class="io-card">
|
||||
<div class="card-header"><i class="ri-hard-drive-3-line"></i><span>磁盘 I/O</span></div>
|
||||
<div class="io-row"><span class="io-label">读取速度</span><span class="io-value" id="disk-read"><?= $diskReadSpeed ?></span></div>
|
||||
<div class="io-row"><span class="io-label">写入速度</span><span class="io-value" id="disk-write"><?= $diskWriteSpeed ?></span></div>
|
||||
</div>
|
||||
<div class="io-card">
|
||||
<div class="card-header"><i class="ri-stack-line"></i><span>系统概况</span></div>
|
||||
<div class="io-row"><span class="io-label">运行进程</span><span class="io-value" id="proc-count"><?= $processes ?></span></div>
|
||||
<div class="io-row"><span class="io-label">运行时间</span><span class="io-value" id="uptime-val"><?= $uptime ?></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">历史趋势</div>
|
||||
<div class="chart-grid">
|
||||
<div class="chart-card">
|
||||
<div class="card-header"><i class="ri-cpu-line"></i><span>CPU & 内存趋势</span></div>
|
||||
<div class="chart-wrapper"><canvas id="chart-cpu"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="card-header"><i class="ri-exchange-line"></i><span>网络吞吐</span></div>
|
||||
<div class="chart-wrapper"><canvas id="chart-net"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="card-header"><i class="ri-hard-drive-3-line"></i><span>磁盘读写</span></div>
|
||||
<div class="chart-wrapper"><canvas id="chart-disk-io"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="card-header"><i class="ri-pulse-line"></i><span>网络延迟</span></div>
|
||||
<div class="chart-wrapper"><canvas id="chart-latency"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">系统信息</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-card">
|
||||
<div class="card-header"><i class="ri-settings-4-line"></i><span>系统详情</span></div>
|
||||
<div class="info-table">
|
||||
<div class="info-row"><span class="info-label">主机名</span><span class="info-value"><?= $hostname ?></span></div>
|
||||
<div class="info-row"><span class="info-label">操作系统</span><span class="info-value"><?= $osName ?></span></div>
|
||||
<div class="info-row"><span class="info-label">Python</span><span class="info-value"><?= $pythonVersion ?></span></div>
|
||||
<div class="info-row"><span class="info-label">PHP</span><span class="info-value"><?= $phpVersion ?></span></div>
|
||||
<div class="info-row"><span class="info-label">运行时间</span><span class="info-value" id="uptime-info"><?= $uptime ?></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="card-header"><i class="ri-router-line"></i><span>网络接口</span></div>
|
||||
<div class="net-ifaces" id="net-ifaces">
|
||||
<script type="application/json" id="ifaces-data"><?= htmlspecialchars($netInterfaces, ENT_QUOTES, 'UTF-8') ?></script>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
const $ = id => document.getElementById(id);
|
||||
const circumference = 2 * Math.PI * 52;
|
||||
|
||||
Chart.defaults.color = '#94a3b8';
|
||||
Chart.defaults.borderColor = '#334155';
|
||||
Chart.defaults.font.size = 11;
|
||||
|
||||
const fmtBytes = v => {
|
||||
if (v >= 1048576) return (v/1048576).toFixed(1) + ' MB/s';
|
||||
if (v >= 1024) return (v/1024).toFixed(1) + ' KB/s';
|
||||
return Math.round(v) + ' B/s';
|
||||
};
|
||||
|
||||
const cpuChart = new Chart($('chart-cpu'), {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [
|
||||
{ label: 'CPU %', data: [], borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.1)', tension: 0.4, fill: true, pointRadius: 0 },
|
||||
{ label: '内存 %', data: [], borderColor: '#22c55e', backgroundColor: 'rgba(34,197,94,0.1)', tension: 0.4, fill: true, pointRadius: 0 }
|
||||
]},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
animation: { duration: 1500, easing: 'easeInOutCubic' },
|
||||
plugins: { legend: { display: true } },
|
||||
scales: { x: { display: false }, y: { min: 0, max: 100, grid: { color: '#334155' } } }
|
||||
}
|
||||
});
|
||||
|
||||
const netChart = new Chart($('chart-net'), {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [
|
||||
{ label: '下载', data: [], borderColor: '#06b6d4', tension: 0.4, fill: false, pointRadius: 0 },
|
||||
{ label: '上传', data: [], borderColor: '#f59e0b', tension: 0.4, fill: false, pointRadius: 0 }
|
||||
]},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
animation: { duration: 1500, easing: 'easeInOutCubic' },
|
||||
plugins: { legend: { display: true }, tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + fmtBytes(ctx.raw) } } },
|
||||
scales: { x: { display: false }, y: { grid: { color: '#334155' }, ticks: { callback: v => fmtBytes(v) } } }
|
||||
}
|
||||
});
|
||||
|
||||
const diskIoChart = new Chart($('chart-disk-io'), {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [
|
||||
{ label: '读取', data: [], borderColor: '#8b5cf6', tension: 0.4, fill: false, pointRadius: 0 },
|
||||
{ label: '写入', data: [], borderColor: '#ec4899', tension: 0.4, fill: false, pointRadius: 0 }
|
||||
]},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
animation: { duration: 1500, easing: 'easeInOutCubic' },
|
||||
plugins: { legend: { display: true }, tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + fmtBytes(ctx.raw) } } },
|
||||
scales: { x: { display: false }, y: { grid: { color: '#334155' }, ticks: { callback: v => fmtBytes(v) } } }
|
||||
}
|
||||
});
|
||||
|
||||
const latencyChart = new Chart($('chart-latency'), {
|
||||
type: 'line',
|
||||
data: { labels: [], datasets: [
|
||||
{ label: '延迟 ms', data: [], borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.1)', tension: 0.4, fill: true, pointRadius: 0 }
|
||||
]},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
animation: { duration: 1500, easing: 'easeInOutCubic' },
|
||||
plugins: { legend: { display: true } },
|
||||
scales: { x: { display: false }, y: { grid: { color: '#334155' }, beginAtZero: true } }
|
||||
}
|
||||
});
|
||||
|
||||
// 加载历史
|
||||
const MAX_POINTS = 10;
|
||||
|
||||
// 初始化空图表
|
||||
[cpuChart, netChart, diskIoChart, latencyChart].forEach(c => {
|
||||
for (let i = 0; i < MAX_POINTS; i++) c.data.labels.push('');
|
||||
});
|
||||
|
||||
fetch('/api/dashboard/history').then(r => r.json()).then(hist => {
|
||||
const data = {
|
||||
cpu: hist.cpu, ram: hist.ram,
|
||||
net_recv: hist.net_recv, net_sent: hist.net_sent,
|
||||
disk_read: hist.disk_read, disk_write: hist.disk_write,
|
||||
latency: hist.latency || []
|
||||
};
|
||||
const start = Math.max(0, data.cpu.length - MAX_POINTS);
|
||||
const slice = data.cpu.slice(start);
|
||||
|
||||
cpuChart.data.datasets[0].data = data.cpu.slice(start);
|
||||
cpuChart.data.datasets[1].data = data.ram.slice(start);
|
||||
netChart.data.datasets[0].data = data.net_recv.slice(start);
|
||||
netChart.data.datasets[1].data = data.net_sent.slice(start);
|
||||
diskIoChart.data.datasets[0].data = data.disk_read.slice(start);
|
||||
diskIoChart.data.datasets[1].data = data.disk_write.slice(start);
|
||||
latencyChart.data.datasets[0].data = data.latency.slice(start);
|
||||
|
||||
// 不足10个补默认值
|
||||
const pad = (chart, vals) => {
|
||||
const diff = MAX_POINTS - chart.data.datasets[0].data.length;
|
||||
for (let i = 0; i < diff; i++) {
|
||||
chart.data.datasets[0].data.push(vals[0]);
|
||||
if (chart.data.datasets[1]) chart.data.datasets[1].data.push(vals[1] ?? vals[0]);
|
||||
}
|
||||
};
|
||||
pad(cpuChart, [50, 50]);
|
||||
pad(netChart, [0, 0]);
|
||||
pad(diskIoChart, [0, 0]);
|
||||
pad(latencyChart, [0]);
|
||||
|
||||
cpuChart.update(); netChart.update(); diskIoChart.update(); latencyChart.update();
|
||||
}).catch(() => {
|
||||
// 加载失败也补默认
|
||||
[cpuChart, netChart, diskIoChart, latencyChart].forEach(c => {
|
||||
c.data.datasets.forEach(ds => {
|
||||
while (ds.data.length < MAX_POINTS) ds.data.push(0);
|
||||
});
|
||||
c.update();
|
||||
});
|
||||
});
|
||||
|
||||
// 渲染网络接口
|
||||
try {
|
||||
const el = $('ifaces-data');
|
||||
if (el) {
|
||||
const ifaces = JSON.parse(el.textContent);
|
||||
const container = $('net-ifaces');
|
||||
if (ifaces.length === 0) {
|
||||
container.innerHTML = '<div class="net-iface"><div class="iface-info">暂无网络接口</div></div>';
|
||||
} else {
|
||||
let html = '';
|
||||
ifaces.forEach(iface => {
|
||||
html += `<div class="net-iface"><div><div class="iface-name">${iface.name}</div><div class="iface-info">${iface.ip}</div></div><span class="iface-status ${iface.is_up ? 'status-up' : 'status-down'}">${iface.is_up ? 'UP' : 'DOWN'}</span></div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// 定时刷新
|
||||
setInterval(() => {
|
||||
fetch('/api/dashboard/stats').then(r => r.json()).then(d => {
|
||||
const setGauge = (id, pct) => {
|
||||
const el = $(id);
|
||||
if (el) el.setAttribute('stroke-dashoffset', circumference - (pct/100)*circumference);
|
||||
};
|
||||
setGauge('cpu-gauge', d.cpu.percent);
|
||||
setGauge('ram-gauge', d.ram.percent);
|
||||
setGauge('disk-gauge', d.disk.percent);
|
||||
$('cpu-val').textContent = d.cpu.percent;
|
||||
$('ram-val').textContent = d.ram.percent;
|
||||
$('disk-val').textContent = d.disk.percent;
|
||||
$('load-val').textContent = d.load.load1;
|
||||
$('net-recv').textContent = fmtBytes(d.network.recv_rate);
|
||||
$('net-sent').textContent = fmtBytes(d.network.sent_rate);
|
||||
$('disk-read').textContent = fmtBytes(d.disk_io.read_rate);
|
||||
$('disk-write').textContent = fmtBytes(d.disk_io.write_rate);
|
||||
$('proc-count').textContent = d.processes;
|
||||
$('uptime-val').textContent = d.uptime;
|
||||
$('uptime-info').textContent = d.uptime;
|
||||
|
||||
// 刷新:固定10个点,数据向左平滑滚动
|
||||
const pushChart = (chart, v1, v2) => {
|
||||
// 移除最左边旧数据
|
||||
chart.data.datasets[0].data.shift();
|
||||
// 新数据从右边加入
|
||||
chart.data.datasets[0].data.push(v1);
|
||||
if (chart.data.datasets[1]) {
|
||||
chart.data.datasets[1].data.shift();
|
||||
chart.data.datasets[1].data.push(v2);
|
||||
}
|
||||
// 触发 Chart.js 内置过渡动画
|
||||
chart.update('default');
|
||||
};
|
||||
pushChart(cpuChart, d.cpu.percent, d.ram.percent);
|
||||
pushChart(netChart, d.network.recv_rate, d.network.sent_rate);
|
||||
pushChart(diskIoChart, d.disk_io.read_rate, d.disk_io.write_rate);
|
||||
|
||||
// 网络延迟图
|
||||
latencyChart.data.datasets[0].data.shift();
|
||||
latencyChart.data.datasets[0].data.push(d.latency || 0);
|
||||
latencyChart.update('default');
|
||||
}).catch(() => {});
|
||||
}, 2000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
8
store/@{FutureOSS}/dependency/SIGNATURE
Normal file
8
store/@{FutureOSS}/dependency/SIGNATURE
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"signature": "JQaw//g6588907vGYH6SyqeXj9qHU5Azb7S/bjYm7rUrVsHqqIsIOEPB7IVsdf/wCnCdCa0LzTrEjmS6lKlEwXVjCCebhzyi64OJIXVOVckd2TJbREH0ZizO4KcEWgOqu56Ln3g8yMPHw5GylLABD5UN0q4F48PwUhram+cECu0SOY/bAHxYwi+nzJ0TcuES/J5cK480xv+NvxnylBhx1Udkkoiz9Y7b3pgglx+h57BuPEeHpJFbXQkXtty5Cf3sXzib0FEhicyIW1u5wmYSLz5yyLd/Pefavjfs6JrDG9J8gfPuestQzazQGsIMiQTy13DL8IDGAZ7AP2/mFQYrXuYLaBTxyhhMAkpfjIANzy+2pobeTZz2Cu4Sr6XMzXS4BkeCRDcHHBnttWVpp1+t5HpRgp3W8eiPcCzmUq6jo1cbd5zWGiR1gDEHePivmJaUi/bxlN0vyc7LjW7T+HuLUYhdSktbxv5BexMwcA7+2UHJzEnTVIc+xqoIT+ApPqqF2hLJFiAUdEJe8FRc/Bwihzh8tfM0xgYoqn8RQQ3eWVwVrK9vx0OZ8INumNZOyKPz8ZlGf3XAJv9UGUQ6Y42raYcDOFrgT+MS82tjAxf2nonm0/c3dhgNFZSy5Cfbvuqd9SYaxXejIcVni3MarVHZX3iKytOdv83cBtwPXRcfloc=",
|
||||
"signer": "FutureOSS",
|
||||
"algorithm": "RSA-SHA256",
|
||||
"timestamp": 1775969851.9656692,
|
||||
"plugin_hash": "aebef3fd9252245553bc458e4652b094839a5e64bde7cec13435ba1930a8dc0d",
|
||||
"author": "FutureOSS"
|
||||
}
|
||||
Binary file not shown.
8
store/@{FutureOSS}/hot-reload.disabled/SIGNATURE
Normal file
8
store/@{FutureOSS}/hot-reload.disabled/SIGNATURE
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"signature": "vBf0JPwb5GjyM9vyp4AuncQKp092RpA07RZh+guhF51OKlVI5PphQEEvtMSy2uBsQ0V0RohRid/gazvB5l02DTuyqt2NcjFyPIZj2wm1gfWtJZWBK+Hp11gIPq13qhxDjdi1bs7H+tTOhVHJHkcoU1TsZuUPU+UYOuONbQhdwB+eqEMbNzVrPBPxb12W1SxRBAo/58q+eGI1QvbTv0FBu4fw10vyySGzd51t0psrBqw9xovKSq47AV96ZJeFEJvbfBTfJTg26VOX0cxLS5dmel9+yMhmidJNvOoL3mlZG2C92Xe9hdZAFxaRhMV3QgNKx3s6C+TQRBNx3ttUtBAzxVcXsGhCE0C+CfvbIpuyGHfgarSPJoiIPyp02numgMztFzAdFc66stULEpB3rHBlosUbDNmeuIMNcbCdKlH6R94xuYMg8E699DO67AGxZwZcaUN/vYmAa2DiffVUFcCFXgzABPzctJTYqTaD51KGlMSMHTeMTN3XCWJ79nkxHvt0Lgb0kWljOhcVaGW2t4JUgfupUD1DIwiZ7AlEC3K3JijsqWS633+Saa/+tOI4/V5VzVtExJt46cM/BSETYlHQtA8eDDl6BhbjtnmMaHSjGF75sgiagtj0DYsOvzKLJUVMT4nFjidzb2sR5lN3/S3ZSmBTUYA5/fDgiMnSfZaK4HQ=",
|
||||
"signer": "FutureOSS",
|
||||
"algorithm": "RSA-SHA256",
|
||||
"timestamp": 1775964953.0432403,
|
||||
"plugin_hash": "3b226c4e5278ade1ec0997abfd553d4c07724b8e9f69f79acb57e20e0d352817",
|
||||
"author": "FutureOSS"
|
||||
}
|
||||
Binary file not shown.
@@ -5,6 +5,7 @@ import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Callable
|
||||
|
||||
from oss.logger.logger import Log
|
||||
from oss.plugin.types import Plugin, register_plugin_type
|
||||
|
||||
|
||||
@@ -138,7 +139,7 @@ class HotReloadPlugin(Plugin):
|
||||
elif change_type == "deleted":
|
||||
self.unload_plugin(plugin_name)
|
||||
except Exception as e:
|
||||
print(f"[hot-reload] 处理变化失败: {e}")
|
||||
Log.error("hot-reload", f"处理变化失败: {e}")
|
||||
|
||||
def load_plugin(self, plugin_dir: Path) -> bool:
|
||||
"""运行时加载插件"""
|
||||
Binary file not shown.
8
store/@{FutureOSS}/http-api/SIGNATURE
Normal file
8
store/@{FutureOSS}/http-api/SIGNATURE
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"signature": "0WK7Njn0KAUP+jfg/uuJxwW0/tWCF+WieK0N0T2crWbvutKQmEOtaNDHnjT6qFz1dcI4+ba3julE4fFi3W3xFiToMEP2VcPXe0WNQ9/kvKNTKSDbwadiBssf43TO1G9E1BxNMxVM91mN8iqybuy+VMdU0Esv2rJ5dcwwwsnT9NWot2RQLez75PRhmMtJpEWRUmrZn2r+u5QnQdjxucONq9Nhwxw0eheTxMCu8IDvIiO6QIWP5ErA/wUz+Hg6IoEZwcVif/lSN2EMqNGqPNR/nIWWVXo9CXWB9qMZZApgEnAZfKYGCAkLzSTwqG64T4iJh4deGxafyMhsONckqRaG82NRTLuzHMReP5+VAichuEGbHI7nxXFOFG7q1mgQQLmHm3LB577usAgCNCh5X3i8SMAj7Sutykxhj0ZyTqMnOfpwnzE2tsNisJF0/8Kw22k7dZChV1obOeLWXjy5InLjdm4hIWTp7wMPjSNWRMZGR+1aZHi9XA1GKd965/30jmo876EXX23xoTAN4ZRhZNlcQg710LhycNohggnQ7qzB9LsV3Ckgh7aY/V/hzND6bpRADCGu62sZtBye2P1yaaAorC8+hRaiJoXlV9Yukg+3yhfKC+qTbn307fI53kgcw1KMSeGGctfTYJUOfK8u0mYsGi50bnM+2Tz45YJiwwdOJJk=",
|
||||
"signer": "FutureOSS",
|
||||
"algorithm": "RSA-SHA256",
|
||||
"timestamp": 1775960645.890869,
|
||||
"plugin_hash": "ca13c933ffa2c5dd8874e3ad6f7b8dda5dd9a5f9c24be6aeb47228d65097a280",
|
||||
"author": "FutureOSS"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -23,9 +23,12 @@ class CorsMiddleware(Middleware):
|
||||
|
||||
class LoggerMiddleware(Middleware):
|
||||
"""日志中间件"""
|
||||
# 静默的路由(不打印日志)
|
||||
_silent_paths = {"/api/dashboard/stats", "/favicon.ico", "/health"}
|
||||
|
||||
def process(self, ctx: dict, next_fn: Callable) -> Optional[Response]:
|
||||
req = ctx.get("request")
|
||||
if req:
|
||||
if req and req.path not in self._silent_paths:
|
||||
print(f"[http-api] {req.method} {req.path}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -95,14 +95,17 @@ class HttpServer:
|
||||
self._send_response(resp)
|
||||
|
||||
def _send_response(self, resp: Response):
|
||||
self.send_response(resp.status)
|
||||
for k, v in resp.headers.items():
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
if isinstance(resp.body, str):
|
||||
self.wfile.write(resp.body.encode("utf-8"))
|
||||
else:
|
||||
self.wfile.write(resp.body)
|
||||
try:
|
||||
self.send_response(resp.status)
|
||||
for k, v in resp.headers.items():
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
if isinstance(resp.body, str):
|
||||
self.wfile.write(resp.body.encode("utf-8"))
|
||||
else:
|
||||
self.wfile.write(resp.body)
|
||||
except BrokenPipeError:
|
||||
pass # 忽略客户端断开
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
8
store/@{FutureOSS}/http-tcp/SIGNATURE
Normal file
8
store/@{FutureOSS}/http-tcp/SIGNATURE
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"signature": "Adt4Pa7dzXVC9LuotOb2hvUREP2sQyInReCfPRVnKLuD2IB+5Uk4BSCjt5EkUUcMiEwIYoefntc1Q0f4k/OL3F4WtKFrwb4G+WJZYuwSbYZ3l4wYtivMFTuP4PjIgz1/sWUfqHdd+jwOquM9a8+uiNaxiz+Ed9UmBCqiJXjbfiP5A5RlkUGO3evwuP51dhfo3BVU+YuVWzSWfVw8Ov9Wx1V0h7fEjPPYof1d9AP+yVnfLLfBeNL1T/VlpkogllRlcqOQm5w+s17sLhR6sQEBHHTsga7Nilh8/BMmXr3vFDrtPbPsOqVGzHvYOFFJf26geFgxowPJ5YxEL9FKp9NtOp0fsDsq6f74mES9nTg7v9uImL8zzYn774fpaIfbOL2CVqsCqzW+kYhNm7fsJD8SfmhwKR8tVEsYvqUiHqpzUwX/J7soD0jlN/ttUUCZREERRKIpumHNNxkcgLuTYsloeSrG935ZOSEt6QuWSg9+dlXgdi84UmE1TbU6Q6HKExopOJitYCUM1p21G5wcFgEn+o7zdkDUdCJEliG1QeqSHdhlo/QyLuH/7mZQOMdprHabggTUrmbrES78nT10XEFWjtUfKxuzQkWwozwYPx6cBdmO4OLYJ+C5u1hwgmVm6if6IbCPm0l/NGy8NUNjH0PxDdmPaUSdnvSLLwa6fwr5/h0=",
|
||||
"signer": "FutureOSS",
|
||||
"algorithm": "RSA-SHA256",
|
||||
"timestamp": 1775960645.9258935,
|
||||
"plugin_hash": "136d916944b4b1e37134b3b9807a8ea19fc9c4971c62d15cc11e019502de5617",
|
||||
"author": "FutureOSS"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
8
store/@{FutureOSS}/i18n.disabled/SIGNATURE
Normal file
8
store/@{FutureOSS}/i18n.disabled/SIGNATURE
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"signature": "N8pwPuJxnjP/hgMG4QLYQy7Z6e1P1KctYLJYoQniALDFT1qb11RDm1w4KUbzNIY82XM56B10zYF88dTQiGMrtbgoExE0gtUvmF3THvEd+aWhQ0m5/2war2w+j02BWH0TvJqxhb5nHCyhA4CknJANWp4wZr9EPjDseb+OhXC3GECKpChVrmM9/DWM6TtjlmGol14kq+jUnrS5EWNSa1hlsLzKIrS3Jf5fLaButDUr6YuQkATRKl6F41M8+JHJwVVw5D1fRSqCZ4xFWwN90Gtdd22JFSeB9iVE2Myb3UurPzTVvJ0B/JE9yxFDhA1B7PtuF/WeWlm060QRWdlwFfO9NjUJOeOGQstn34DUG2xL/q3yF66SjnHcHs67DqVq9lCQ961jQq0QveKunV4u8uBJd4IGH4MTq5W7Be8GDgSZcll5HLG3HBL+9XYf4mJzc7dh88Y0UV+dOabD2SJCwBmMxgzDx+Dx8RwWx7b9IYZvmXz6fxtXhqfV6AFq2oY/+4Xjwn4nq7VOCgx8PxLrUvmuacmCwlar/rXuvHT0YsN/XXmJK9o/3NYsNp/go8Vm0XW0btJ+FnQw4O4OKPvSSd+Ip+tk2rLi7CuZGi0WEVp2o23gUNLXoHkKFrtms02Et6zC9AFwP2gLF+NnaMWImup54owxgDos9s6l2ejTD653rYE=",
|
||||
"signer": "FutureOSS",
|
||||
"algorithm": "RSA-SHA256",
|
||||
"timestamp": 1775964953.002281,
|
||||
"plugin_hash": "55f90852ff6fbd82bc5a51ea4ebc2725f1316a7a5f9d423ee10a7e571aad339a",
|
||||
"author": "FutureOSS"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,7 @@
|
||||
"""i18n 国际化多语言支持插件"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from oss.logger.logger import Log
|
||||
from oss.plugin.types import Plugin, register_plugin_type
|
||||
from .i18n import I18nEngine
|
||||
from .middleware import I18nMiddleware
|
||||
@@ -66,8 +67,8 @@ class I18nPlugin(Plugin):
|
||||
# 初始化中间件
|
||||
self.middleware_handler = I18nMiddleware(self.engine, config)
|
||||
|
||||
print(f"[i18n] 已加载语言: {', '.join(supported_locales)}")
|
||||
print(f"[i18n] 默认语言: {default_locale}")
|
||||
Log.info("i18n", f"已加载语言: {', '.join(supported_locales)}")
|
||||
Log.info("i18n", f"默认语言: {default_locale}")
|
||||
|
||||
def start(self):
|
||||
"""启动插件
|
||||
@@ -83,11 +84,11 @@ class I18nPlugin(Plugin):
|
||||
http_api.router.get("/api/i18n/locales", self._locales_handler)
|
||||
http_api.router.get("/api/i18n/translate", self._translate_handler)
|
||||
http_api.router.post("/api/i18n/locale", self._change_locale_handler)
|
||||
print("[i18n] API 路由已注册")
|
||||
Log.info("i18n", "API 路由已注册")
|
||||
|
||||
def stop(self):
|
||||
"""停止插件"""
|
||||
print("[i18n] 插件已停止")
|
||||
Log.error("i18n", "插件已停止")
|
||||
|
||||
def health(self) -> bool:
|
||||
"""健康检查"""
|
||||
Binary file not shown.
8
store/@{FutureOSS}/json-codec/SIGNATURE
Normal file
8
store/@{FutureOSS}/json-codec/SIGNATURE
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"signature": "IQ8WAvKno6pRp71kIaxXPb7DzTajPeNOQ0FLZMVovufeyTRMbdSJ8z2zQPBPv9O2a1S9bucyZyhg54fNB2DdLfEnrAbmpepZ3CLrj3cn4KaLNGJjxGHYXWIsFXFvLaYIod/ZuFMYPlzDdwnHJwzHZnkGAmCLrJSR+XvuOqYu/xSZekD/nbMI0fj9VKjaH/S/vopEhq7IFioahVkiSokdYx5qkXYruOVAq3wCnk6O0uCNMfHiIaRhn5pEoQ+VOXcuKX5eOBEph8oXqb+ew1MB917Z1CpaLFuZTyp2Dy8OOmpXjBxfd5VYazH4ZvE9Q7VODHkRDVF2ApkPxTE1k490YvmNOHRamjcf1/mKyu7Myaemtz9oxvZFFiOMOaXBXGfe1wlnsbO832lURTpPu9WXQ6aoDEVp3TNuR/G/xYOXHcWhG1M4tIWW+1ZFcozkVw9cMYvwrVI9JEa89sueXQhJG9foW4nj0DJqmtXaXvcVHnpbFkIxcKFZ0rOMelJ7404XuDb07/sjliJuqCG9Gssmv7/DqNgIrcWUPg24U4UPWW2vWJaJq7HOrGrxFoOxpCT/G4A0WcAWVJrM5NojnfvBNswybSB2IIbspmPRDVtoHQ5a3YJqSLZdgugHh+MbGKlyDvPkQTkPLLE8nrP2F0LwWCq0cYeodE+zU0rZ6CHgAsc=",
|
||||
"signer": "FutureOSS",
|
||||
"algorithm": "RSA-SHA256",
|
||||
"timestamp": 1775964952.836965,
|
||||
"plugin_hash": "a7f7a20614a2e159e393a95c99b15a0a028724694bda3d089787cb41eceba7c4",
|
||||
"author": "FutureOSS"
|
||||
}
|
||||
Binary file not shown.
@@ -3,6 +3,7 @@ import json
|
||||
from typing import Any, Callable, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from oss.logger.logger import Log
|
||||
from oss.plugin.types import Plugin, register_plugin_type
|
||||
|
||||
|
||||
@@ -127,7 +128,7 @@ class JsonCodecPlugin(Plugin):
|
||||
|
||||
def start(self):
|
||||
"""启动"""
|
||||
print("[json-codec] JSON 编解码器已启动")
|
||||
Log.info("json-codec", "JSON 编解码器已启动")
|
||||
|
||||
def stop(self):
|
||||
"""停止"""
|
||||
|
||||
8
store/@{FutureOSS}/lifecycle.disabled/SIGNATURE
Normal file
8
store/@{FutureOSS}/lifecycle.disabled/SIGNATURE
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"signature": "nfM9Sj7VvV+L85zCvVcmIQY4qZ9FDdsk8MZf0LrO/ys1o6FCQ96Ixt1aB+2j6crOvXUBavnSRPk/LNaDs9r3eh49+Zfy5rEK+M0UyGjcawvEY4e/lO20UWy4iLw3JdSBo9nnFQC9eE8D6C9F2oM7YcqmT/sH0wYuyjCsa8tk6P/jy5/IdCwR6bo6AIQSpCnvyNcS9JPU19f603f0nl/siafXVozQxMS3wCLQ5EAoDz7atLevvQK7xAZCIIcCsre/sHTZ3a6O+BFlYYQ5w/giWlrl4aF7W7JJntOwpain39B0ktDRV96msbW744a1BFkcUw91W/2sRU7T9xplARjmhlRPGkdMTlj4PGyy394oaLwhx+uusx28C9+gWxp7pQZNo08LQ6dKmzog4fpUFD3EEyZBtPY2XYsILqKnGQVn3TLAaMmdoHdwoR6moLtR6BfD3ToRFV6vcNRTig8hTiS9GTzZeQtEtVkoSeAZphzxWfB7FunimDRpPxndDmvhervPUJ/uAVLcdorbDFB0RfvR3znUZrQkaw5YQZjP8mhUNyA6avyOBvGdt1i0bhZsc6CUMN4BrC+vOULiykyVGnk3B07XrMHNB8AGuqR8Ai/2DFglomfs/l07mz01HeUotRg3MezqF8aSkofpPTpRieeD9IeQgH03sOGdvXHDgDJB3Xc=",
|
||||
"signer": "FutureOSS",
|
||||
"algorithm": "RSA-SHA256",
|
||||
"timestamp": 1775960646.0212853,
|
||||
"plugin_hash": "a7d6c6e01a8dc5df868e34777233e33d984d01adedb8adcee24d6892600928a8",
|
||||
"author": "FutureOSS"
|
||||
}
|
||||
Binary file not shown.
8
store/@{FutureOSS}/log-terminal/SIGNATURE
Normal file
8
store/@{FutureOSS}/log-terminal/SIGNATURE
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"signature": "iSAdml6TdNMXoZmB7zsRN6jYb3GL8ufdfxA+gHL58R1z7qpxc13fQidyo/syRaGv+J7zLV2/8/e8qSSGhbtWn2p08iH8vIax5zTe3zfl8wBlhxnCkEQztd1FlfkERgNWpRToiGu8GV8o0Fq+Yej6C+OaO6EL69DkRxL8Kp2Jf/2jdUOCprErLyKm506zotXjcKEr9heSLNCD0DKRaQv1GnqLJclp9fXirVvJHDS26ttNx1srNhvjTjsGofzn6qQpGuddLXKi7FWKDAByEBjqzQOmQ2iB4NOIG012J4HKO1q3BajNj11xfWL6PnSzvrwj8IJbJIrbCzTPeFK3F6gj3JtAcaI6iQLhJ7VjOCbFhlOOoIJx/5CA3j9x+/DLXgjAnV6fiD0Q8VCaLTkXGQPwGXo7xq8ExkRt48sHI9nFI0+8fj6nXB1ANDHPlvg86eyHKG61WUIZOHd/Ag9foCZtoDFnKXYBnVeNweHaHBsJWpBOvbFjPkYRpRxvRvVd8oe5qmxS0eS5RLmIIpHnOvoGKQV5CoGXPmKB5FNxDRUH4llz9W4FpxtRaYoFFoYatT9Kvr+WPSok13XS1uMBybT2nc+nEZ/XR7LsNxajfZsyEjXwQbL8DsI9LXPW9gt10F6P/9ByWaTCD/4H8flwDFI4iqw/iVENip8vnilTQpowuOY=",
|
||||
"signer": "FutureOSS",
|
||||
"algorithm": "RSA-SHA256",
|
||||
"timestamp": 1775969593.8644652,
|
||||
"plugin_hash": "b38f028d1629d878dcfc32ac28747d5cea8e93ad832009b88cb3b69934fb3fa5",
|
||||
"author": "FutureOSS"
|
||||
}
|
||||
BIN
store/@{FutureOSS}/log-terminal/__pycache__/main.cpython-313.pyc
Normal file
BIN
store/@{FutureOSS}/log-terminal/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
36
store/@{FutureOSS}/log-terminal/config.json
Normal file
36
store/@{FutureOSS}/log-terminal/config.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"logSyncInterval": {
|
||||
"type": "number",
|
||||
"name": "日志同步间隔",
|
||||
"description": "日志自动同步的时间间隔(秒)",
|
||||
"default": 2,
|
||||
"min": 1,
|
||||
"max": 10,
|
||||
"order": 1
|
||||
},
|
||||
"sshPort": {
|
||||
"type": "number",
|
||||
"name": "SSH 端口",
|
||||
"description": "SSH 连接的默认端口",
|
||||
"default": 8022,
|
||||
"min": 1,
|
||||
"max": 65535,
|
||||
"order": 2
|
||||
},
|
||||
"autoInstallSSH": {
|
||||
"type": "boolean",
|
||||
"name": "自动安装 SSH",
|
||||
"description": "连接时自动检测并安装 SSH 服务",
|
||||
"default": true,
|
||||
"order": 3
|
||||
},
|
||||
"maxLogLines": {
|
||||
"type": "number",
|
||||
"name": "最大日志行数",
|
||||
"description": "日志界面最多显示的日志行数",
|
||||
"default": 1000,
|
||||
"min": 100,
|
||||
"max": 10000,
|
||||
"order": 4
|
||||
}
|
||||
}
|
||||
607
store/@{FutureOSS}/log-terminal/main.py
Normal file
607
store/@{FutureOSS}/log-terminal/main.py
Normal file
@@ -0,0 +1,607 @@
|
||||
"""LogTerminal 日志与终端插件"""
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from oss.logger.logger import Log
|
||||
from oss.plugin.types import Plugin, Response, register_plugin_type
|
||||
|
||||
|
||||
class LogTerminalPlugin(Plugin):
|
||||
"""日志与终端插件 - 提供日志查看和 SSH 终端功能"""
|
||||
|
||||
def __init__(self):
|
||||
self.webui = None
|
||||
self.http_api = None
|
||||
self.views_dir = os.path.join(os.path.dirname(__file__), 'views')
|
||||
self._log_buffer = []
|
||||
self._log_lock = threading.Lock()
|
||||
self._ssh_sessions = {}
|
||||
self._session_counter = 0
|
||||
self._log_sync_thread = None
|
||||
self._running = False
|
||||
|
||||
def meta(self):
|
||||
from oss.plugin.types import Metadata, PluginConfig, Manifest
|
||||
return Manifest(
|
||||
metadata=Metadata(
|
||||
name="log-terminal",
|
||||
version="1.0.0",
|
||||
author="FutureOSS",
|
||||
description="日志查看器与 SSH 终端"
|
||||
),
|
||||
config=PluginConfig(enabled=True, args={}),
|
||||
dependencies=["http-api", "webui"]
|
||||
)
|
||||
|
||||
def set_webui(self, webui):
|
||||
self.webui = webui
|
||||
|
||||
def set_http_api(self, http_api):
|
||||
self.http_api = http_api
|
||||
|
||||
def init(self, deps: dict = None):
|
||||
if self.webui:
|
||||
Log.info("log-terminal", "已获取 WebUI 引用")
|
||||
|
||||
# 注册日志查看页面
|
||||
self.webui.register_page(
|
||||
path='/logs',
|
||||
content_provider=self._render_logs,
|
||||
nav_item={'icon': 'ri-file-list-3-line', 'text': '日志'}
|
||||
)
|
||||
|
||||
# 注册终端页面
|
||||
self.webui.register_page(
|
||||
path='/terminal',
|
||||
content_provider=self._render_terminal,
|
||||
nav_item={'icon': 'ri-terminal-box-line', 'text': '终端'}
|
||||
)
|
||||
|
||||
Log.ok("log-terminal", "已注册日志与终端页面到 WebUI 导航")
|
||||
else:
|
||||
Log.warn("log-terminal", "警告: 未找到 WebUI 依赖")
|
||||
|
||||
# 注册 API 路由(通过 http-api)
|
||||
if self.http_api and self.http_api.router:
|
||||
self.http_api.router.get("/api/logs/get", self._handle_get_logs)
|
||||
self.http_api.router.post("/api/terminal/connect", self._handle_connect_ssh)
|
||||
self.http_api.router.post("/api/terminal/send", self._handle_send_command)
|
||||
self.http_api.router.post("/api/terminal/disconnect", self._handle_disconnect_ssh)
|
||||
self.http_api.router.get("/api/terminal/sessions", self._handle_list_sessions)
|
||||
Log.ok("log-terminal", "已注册 API 路由")
|
||||
else:
|
||||
Log.warn("log-terminal", "警告: 未找到 http-api 依赖")
|
||||
|
||||
def start(self):
|
||||
Log.info("log-terminal", "日志与终端插件启动中...")
|
||||
self._running = True
|
||||
|
||||
# 启动日志同步线程
|
||||
self._log_sync_thread = threading.Thread(target=self._log_sync_worker, daemon=True)
|
||||
self._log_sync_thread.start()
|
||||
|
||||
# 添加初始化日志
|
||||
self.add_log_entry("info", "log-terminal", "日志与终端插件已启动")
|
||||
self.add_log_entry("tip", "log-terminal", "日志查看: /logs | SSH 终端: /terminal")
|
||||
|
||||
# 尝试捕获系统日志输出
|
||||
self._hook_system_log()
|
||||
|
||||
Log.ok("log-terminal", "日志与终端插件已启动")
|
||||
|
||||
def _hook_system_log(self):
|
||||
"""拦截系统日志输出到我们的缓冲区"""
|
||||
try:
|
||||
from oss.logger.logger import Log as SystemLog
|
||||
|
||||
# 保存原始方法
|
||||
original_info = SystemLog.info
|
||||
original_warn = SystemLog.warn
|
||||
original_error = SystemLog.error
|
||||
original_tip = SystemLog.tip
|
||||
original_ok = SystemLog.ok
|
||||
|
||||
# 创建包装方法
|
||||
plugin_instance = self
|
||||
|
||||
@classmethod
|
||||
def wrapped_info(cls, tag: str, msg: str):
|
||||
original_info(tag, msg)
|
||||
plugin_instance.add_log_entry("info", tag, msg)
|
||||
|
||||
@classmethod
|
||||
def wrapped_warn(cls, tag: str, msg: str):
|
||||
original_warn(tag, msg)
|
||||
plugin_instance.add_log_entry("warn", tag, msg)
|
||||
|
||||
@classmethod
|
||||
def wrapped_error(cls, tag: str, msg: str):
|
||||
original_error(tag, msg)
|
||||
plugin_instance.add_log_entry("error", tag, msg)
|
||||
|
||||
@classmethod
|
||||
def wrapped_tip(cls, tag: str, msg: str):
|
||||
original_tip(tag, msg)
|
||||
plugin_instance.add_log_entry("tip", tag, msg)
|
||||
|
||||
@classmethod
|
||||
def wrapped_ok(cls, tag: str, msg: str):
|
||||
original_ok(tag, msg)
|
||||
plugin_instance.add_log_entry("ok", tag, msg)
|
||||
|
||||
# 替换方法(注意:这只影响未来的调用)
|
||||
SystemLog.info = wrapped_info
|
||||
SystemLog.warn = wrapped_warn
|
||||
SystemLog.error = wrapped_error
|
||||
SystemLog.tip = wrapped_tip
|
||||
SystemLog.ok = wrapped_ok
|
||||
|
||||
Log.info("log-terminal", "系统日志拦截器已安装")
|
||||
except Exception as e:
|
||||
Log.warn("log-terminal", f"无法拦截系统日志: {e}")
|
||||
|
||||
def stop(self):
|
||||
Log.info("log-terminal", "日志与终端插件停止中...")
|
||||
self._running = False
|
||||
|
||||
# 关闭所有 SSH 会话
|
||||
for session_id, session in list(self._ssh_sessions.items()):
|
||||
try:
|
||||
if 'process' in session:
|
||||
session['process'].terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._ssh_sessions.clear()
|
||||
Log.ok("log-terminal", "日志与终端插件已停止")
|
||||
|
||||
def _log_sync_worker(self):
|
||||
"""日志同步工作线程 - 持续捕获项目日志"""
|
||||
try:
|
||||
# 尝试从多个位置读取日志
|
||||
log_files = [
|
||||
'/var/log/syslog',
|
||||
'/var/log/messages',
|
||||
os.path.join(os.path.dirname(__file__), '..', '..', 'data', 'system.log'),
|
||||
]
|
||||
|
||||
last_positions = {}
|
||||
|
||||
while self._running:
|
||||
# 检查日志文件
|
||||
for log_file in log_files:
|
||||
if os.path.exists(log_file) and os.path.isfile(log_file):
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
# 获取文件位置
|
||||
if log_file not in last_positions:
|
||||
# 首次读取,跳到文件末尾
|
||||
f.seek(0, 2) # 2 = SEEK_END
|
||||
last_positions[log_file] = f.tell()
|
||||
else:
|
||||
f.seek(last_positions[log_file])
|
||||
|
||||
# 读取新行
|
||||
lines = f.readlines()
|
||||
if lines:
|
||||
last_positions[log_file] = f.tell()
|
||||
for line in lines[-50:]: # 每次最多读取50行
|
||||
line = line.strip()
|
||||
if line:
|
||||
self.add_log_entry("info", "system", line)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# 等待下一次同步
|
||||
time.sleep(2)
|
||||
|
||||
except Exception as e:
|
||||
Log.error("log-terminal", f"日志同步线程异常: {e}")
|
||||
|
||||
def add_log_entry(self, level: str, tag: str, message: str):
|
||||
"""向日志缓冲区添加日志条目"""
|
||||
import time
|
||||
timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||
entry = {
|
||||
'timestamp': timestamp,
|
||||
'level': level,
|
||||
'tag': tag,
|
||||
'message': message
|
||||
}
|
||||
with self._log_lock:
|
||||
self._log_buffer.append(entry)
|
||||
# 限制日志缓冲区大小
|
||||
if len(self._log_buffer) > 10000:
|
||||
self._log_buffer = self._log_buffer[-5000:]
|
||||
|
||||
def _get_logs(self, limit=100):
|
||||
"""获取日志列表"""
|
||||
with self._log_lock:
|
||||
return self._log_buffer[-limit:]
|
||||
|
||||
def _check_ssh_installed(self):
|
||||
"""检查 SSH 是否已安装"""
|
||||
try:
|
||||
result = subprocess.run(['which', 'sshd'], capture_output=True, text=True, timeout=5)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _install_ssh(self):
|
||||
"""自动安装 SSH 服务"""
|
||||
try:
|
||||
Log.info("log-terminal", "正在安装 SSH 服务...")
|
||||
# 检测包管理器
|
||||
for pkg_manager in ['apt-get', 'yum', 'dnf', 'pacman']:
|
||||
result = subprocess.run(['which', pkg_manager], capture_output=True, timeout=3)
|
||||
if result.returncode == 0:
|
||||
if pkg_manager == 'apt-get':
|
||||
subprocess.run([pkg_manager, 'update'], capture_output=True, timeout=30)
|
||||
result = subprocess.run(
|
||||
[pkg_manager, 'install', '-y', 'openssh-server'],
|
||||
capture_output=True, text=True, timeout=120
|
||||
)
|
||||
elif pkg_manager in ['yum', 'dnf']:
|
||||
result = subprocess.run(
|
||||
[pkg_manager, 'install', '-y', 'openssh-server'],
|
||||
capture_output=True, text=True, timeout=120
|
||||
)
|
||||
elif pkg_manager == 'pacman':
|
||||
result = subprocess.run(
|
||||
[pkg_manager, '-S', '--noconfirm', 'openssh'],
|
||||
capture_output=True, text=True, timeout=120
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
Log.ok("log-terminal", "SSH 服务安装成功")
|
||||
return True
|
||||
else:
|
||||
Log.error("log-terminal", f"SSH 服务安装失败: {result.stderr}")
|
||||
return False
|
||||
|
||||
Log.error("log-terminal", "未找到支持的包管理器")
|
||||
return False
|
||||
except Exception as e:
|
||||
Log.error("log-terminal", f"安装 SSH 服务时出错: {e}")
|
||||
return False
|
||||
|
||||
def _start_ssh_server(self, port=8022):
|
||||
"""启动 SSH 服务器"""
|
||||
try:
|
||||
# 检查 SSH 服务器是否已在运行
|
||||
result = subprocess.run(['pgrep', '-f', 'sshd'], capture_output=True, timeout=3)
|
||||
if result.returncode == 0:
|
||||
Log.tip("log-terminal", "SSH 服务器已在运行")
|
||||
return True
|
||||
|
||||
# 启动 SSH 服务器
|
||||
Log.info("log-terminal", f"正在启动 SSH 服务器 (端口: {port})...")
|
||||
subprocess.run(['sshd', '-p', str(port)], capture_output=True, timeout=10)
|
||||
|
||||
# 验证是否启动成功
|
||||
time.sleep(1)
|
||||
result = subprocess.run(['pgrep', '-f', f'sshd.*{port}'], capture_output=True, timeout=3)
|
||||
if result.returncode == 0:
|
||||
Log.ok("log-terminal", f"SSH 服务器已启动 (端口: {port})")
|
||||
return True
|
||||
else:
|
||||
Log.error("log-terminal", "SSH 服务器启动失败")
|
||||
return False
|
||||
except Exception as e:
|
||||
Log.error("log-terminal", f"启动 SSH 服务器时出错: {e}")
|
||||
return False
|
||||
|
||||
def _handle_connect_ssh(self, request):
|
||||
"""处理 SSH 连接请求"""
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
port = body.get('port', 8022)
|
||||
auto_install = body.get('auto_install', True)
|
||||
|
||||
# 检查 SSH 是否已安装
|
||||
if not self._check_ssh_installed():
|
||||
if auto_install:
|
||||
if not self._install_ssh():
|
||||
return Response(
|
||||
status=500,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': 'SSH 安装失败'})
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
status=400,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': 'SSH 未安装,请先安装 SSH 服务'})
|
||||
)
|
||||
|
||||
# 启动 SSH 服务器
|
||||
if not self._start_ssh_server(port):
|
||||
return Response(
|
||||
status=500,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': 'SSH 服务器启动失败'})
|
||||
)
|
||||
|
||||
# 创建新的终端会话 (使用 script 命令创建伪终端)
|
||||
self._session_counter += 1
|
||||
session_id = self._session_counter
|
||||
|
||||
try:
|
||||
# 使用 script 命令创建交互式终端
|
||||
process = subprocess.Popen(
|
||||
['script', '-q', '-c', '/bin/bash', '/dev/null'],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
self._ssh_sessions[session_id] = {
|
||||
'process': process,
|
||||
'created_at': time.time(),
|
||||
'port': port
|
||||
}
|
||||
|
||||
Log.info("log-terminal", f"SSH 终端会话 #{session_id} 已创建")
|
||||
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({
|
||||
'success': True,
|
||||
'session_id': session_id,
|
||||
'message': 'SSH 终端已连接'
|
||||
})
|
||||
)
|
||||
except Exception as e:
|
||||
Log.error("log-terminal", f"创建终端会话失败: {e}")
|
||||
return Response(
|
||||
status=500,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': str(e)})
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
Log.error("log-terminal", f"SSH 连接请求异常: {e}")
|
||||
return Response(
|
||||
status=500,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': str(e)})
|
||||
)
|
||||
|
||||
def _handle_send_command(self, request):
|
||||
"""处理发送命令到终端"""
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
session_id = body.get('session_id')
|
||||
command = body.get('command', '')
|
||||
|
||||
if session_id not in self._ssh_sessions:
|
||||
return Response(
|
||||
status=400,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': '会话不存在'})
|
||||
)
|
||||
|
||||
session = self._ssh_sessions[session_id]
|
||||
process = session['process']
|
||||
|
||||
# 发送命令
|
||||
process.stdin.write(command + '\n')
|
||||
process.stdin.flush()
|
||||
|
||||
# 读取输出
|
||||
time.sleep(0.5) # 等待命令执行
|
||||
output = ""
|
||||
try:
|
||||
while True:
|
||||
line = process.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
output += line
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({
|
||||
'success': True,
|
||||
'output': output
|
||||
})
|
||||
)
|
||||
except Exception as e:
|
||||
Log.error("log-terminal", f"发送命令时出错: {e}")
|
||||
return Response(
|
||||
status=500,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': str(e)})
|
||||
)
|
||||
|
||||
def _handle_disconnect_ssh(self, request):
|
||||
"""处理断开 SSH 连接"""
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
session_id = body.get('session_id')
|
||||
|
||||
if session_id in self._ssh_sessions:
|
||||
session = self._ssh_sessions[session_id]
|
||||
try:
|
||||
session['process'].terminate()
|
||||
except Exception:
|
||||
pass
|
||||
del self._ssh_sessions[session_id]
|
||||
Log.info("log-terminal", f"SSH 终端会话 #{session_id} 已断开")
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': True, 'message': '已断开连接'})
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
status=400,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': '会话不存在'})
|
||||
)
|
||||
except Exception as e:
|
||||
Log.error("log-terminal", f"断开连接时出错: {e}")
|
||||
return Response(
|
||||
status=500,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': str(e)})
|
||||
)
|
||||
|
||||
def _handle_list_sessions(self, request):
|
||||
"""列出所有 SSH 会话"""
|
||||
try:
|
||||
sessions = []
|
||||
for session_id, session in self._ssh_sessions.items():
|
||||
sessions.append({
|
||||
'session_id': session_id,
|
||||
'port': session['port'],
|
||||
'created_at': session['created_at'],
|
||||
'uptime': time.time() - session['created_at']
|
||||
})
|
||||
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': True, 'sessions': sessions})
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
status=500,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': str(e)})
|
||||
)
|
||||
|
||||
def _handle_get_logs(self, request):
|
||||
"""获取日志"""
|
||||
try:
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
# 解析路径中的查询参数
|
||||
parsed = urlparse(request.path)
|
||||
params = parse_qs(parsed.query)
|
||||
limit = int(params.get('limit', [100])[0])
|
||||
source = params.get('source', ['buffer'])[0] # buffer 或 file
|
||||
|
||||
logs = []
|
||||
|
||||
if source == 'buffer':
|
||||
# 从内存缓冲区获取
|
||||
logs = self._get_logs(limit)
|
||||
else:
|
||||
# 从系统日志文件获取
|
||||
logs = self._read_system_logs(limit)
|
||||
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': True, 'logs': logs})
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
status=500,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': str(e)})
|
||||
)
|
||||
|
||||
def _read_system_logs(self, limit=100):
|
||||
"""从系统日志文件读取日志"""
|
||||
logs = []
|
||||
log_files = [
|
||||
'/var/log/syslog',
|
||||
'/var/log/messages',
|
||||
'/var/log/kern.log',
|
||||
]
|
||||
|
||||
for log_file in log_files:
|
||||
if os.path.exists(log_file):
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
lines = f.readlines()
|
||||
for line in lines[-limit:]:
|
||||
line = line.strip()
|
||||
if line:
|
||||
# 尝试解析 syslog 格式
|
||||
# 格式: "Apr 12 10:30:45 hostname service[pid]: message"
|
||||
import re
|
||||
match = re.match(r'(\w+\s+\d+\s+\d+:\d+:\d+)\s+(\S+)\s+(\S+?)(?:\[\d+\])?:\s+(.*)', line)
|
||||
if match:
|
||||
logs.append({
|
||||
'timestamp': match.group(1),
|
||||
'level': 'info',
|
||||
'tag': match.group(3),
|
||||
'message': match.group(4)
|
||||
})
|
||||
else:
|
||||
logs.append({
|
||||
'timestamp': time.strftime('%b %d %H:%M:%S'),
|
||||
'level': 'info',
|
||||
'tag': 'system',
|
||||
'message': line
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return logs[-limit:]
|
||||
|
||||
def _render_logs(self) -> str:
|
||||
"""渲染日志查看界面"""
|
||||
try:
|
||||
php_file = os.path.join(self.views_dir, 'logs.php')
|
||||
if not os.path.exists(php_file):
|
||||
return "<p>日志视图文件丢失</p>"
|
||||
return self._execute_php(php_file, {})
|
||||
except Exception as e:
|
||||
return f"<p>日志视图渲染出错: {e}</p>"
|
||||
|
||||
def _render_terminal(self) -> str:
|
||||
"""渲染终端界面"""
|
||||
try:
|
||||
php_file = os.path.join(self.views_dir, 'terminal.php')
|
||||
if not os.path.exists(php_file):
|
||||
return "<p>终端视图文件丢失</p>"
|
||||
return self._execute_php(php_file, {})
|
||||
except Exception as e:
|
||||
return f"<p>终端视图渲染出错: {e}</p>"
|
||||
|
||||
def _execute_php(self, php_file: str, variables: dict) -> str:
|
||||
"""执行 PHP 文件"""
|
||||
php_vars = ""
|
||||
for key, value in variables.items():
|
||||
if isinstance(value, str):
|
||||
escaped = value.replace('\\', '\\\\').replace("'", "\\'").replace("\n", "\\n")
|
||||
php_vars += f"${key} = '{escaped}';\n"
|
||||
else:
|
||||
php_vars += f"${key} = {value};\n"
|
||||
|
||||
with open(php_file, 'r', encoding='utf-8') as f:
|
||||
php_content = f.read()
|
||||
|
||||
tmp_file = os.path.join(os.path.dirname(php_file), '.temp_lt.php')
|
||||
try:
|
||||
with open(tmp_file, 'w', encoding='utf-8') as f:
|
||||
f.write(f"<?php\n{php_vars}\n?>\n{php_content}")
|
||||
result = subprocess.run(
|
||||
["php", "-f", tmp_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
return result.stdout if result.returncode == 0 else f"<pre>{result.stderr}</pre>"
|
||||
finally:
|
||||
try:
|
||||
if os.path.exists(tmp_file):
|
||||
os.unlink(tmp_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
register_plugin_type("LogTerminalPlugin", LogTerminalPlugin)
|
||||
|
||||
|
||||
def New():
|
||||
return LogTerminalPlugin()
|
||||
15
store/@{FutureOSS}/log-terminal/manifest.json
Normal file
15
store/@{FutureOSS}/log-terminal/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "log-terminal",
|
||||
"version": "1.0.0",
|
||||
"author": "FutureOSS",
|
||||
"description": "日志查看器与 SSH 终端",
|
||||
"type": "webui-extension"
|
||||
},
|
||||
"config": {
|
||||
"enabled": true,
|
||||
"args": {}
|
||||
},
|
||||
"dependencies": ["http-api", "webui"],
|
||||
"permissions": ["*"]
|
||||
}
|
||||
217
store/@{FutureOSS}/log-terminal/views/logs.php
Normal file
217
store/@{FutureOSS}/log-terminal/views/logs.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
.log-container { max-width: 1400px; margin: 0 auto; padding: 20px; height: calc(100vh - 100px); display: flex; flex-direction: column; }
|
||||
.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.log-title { font-size: 18px; font-weight: 600; color: #00bcd4; display: flex; align-items: center; gap: 10px; }
|
||||
.log-title i { font-size: 24px; }
|
||||
.live-indicator { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; background: #064e3b; border-radius: 12px; font-size: 12px; color: #34d399; }
|
||||
.live-dot { width: 8px; height: 8px; background: #34d399; border-radius: 50%; animation: pulse 2s infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(1.3); } }
|
||||
|
||||
.log-controls { display: flex; gap: 10px; align-items: center; }
|
||||
.log-btn { padding: 6px 14px; background: #3b82f6; border: none; border-radius: 6px; color: white; cursor: pointer; font-size: 13px; transition: all 0.2s; display: flex; align-items: center; gap: 6px; }
|
||||
.log-btn:hover { background: #2563eb; }
|
||||
.log-btn.paused { background: #f59e0b; }
|
||||
.log-btn.paused:hover { background: #d97706; }
|
||||
|
||||
.log-filters { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.filter-btn { padding: 4px 12px; border-radius: 16px; font-size: 12px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; transition: all 0.2s; }
|
||||
.filter-btn:hover { background: #334155; }
|
||||
.filter-btn.active { background: #3b82f6; border-color: #3b82f6; color: white; }
|
||||
|
||||
.log-content { flex: 1; overflow-y: auto; background: #0f172a; border-radius: 10px; padding: 16px; font-family: 'Courier New', monospace; font-size: 13px; line-height: 1.6; }
|
||||
.log-content::-webkit-scrollbar { width: 8px; }
|
||||
.log-content::-webkit-scrollbar-track { background: #1e293b; border-radius: 4px; }
|
||||
.log-content::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; }
|
||||
.log-content::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
||||
|
||||
.log-entry { padding: 4px 0; border-bottom: 1px solid #1e293b; animation: fadeIn 0.3s ease-in; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.log-entry:last-child { border-bottom: none; }
|
||||
|
||||
.log-timestamp { color: #64748b; margin-right: 8px; }
|
||||
.log-level { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; margin-right: 8px; display: inline-block; min-width: 50px; text-align: center; }
|
||||
.log-tag { color: #3b82f6; margin-right: 8px; font-weight: 500; }
|
||||
.log-message { color: #e2e8f0; }
|
||||
|
||||
.log-level.info { background: #1e3a8a; color: #60a5fa; }
|
||||
.log-level.ok { background: #064e3b; color: #34d399; }
|
||||
.log-level.warn { background: #78350f; color: #fbbf24; }
|
||||
.log-level.error { background: #7f1d1d; color: #f87171; }
|
||||
.log-level.tip { background: #1e3a5f; color: #38bdf8; }
|
||||
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: #64748b; }
|
||||
.empty-state i { font-size: 64px; margin-bottom: 16px; opacity: 0.3; }
|
||||
.empty-state p { font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="log-container">
|
||||
<div class="log-header">
|
||||
<div class="log-title">
|
||||
<i class="ri-file-list-3-line"></i>
|
||||
<span>系统日志</span>
|
||||
<span class="live-indicator" id="live-indicator">
|
||||
<span class="live-dot"></span>
|
||||
实时同步
|
||||
</span>
|
||||
</div>
|
||||
<div class="log-controls">
|
||||
<button class="log-btn" id="clear-btn" onclick="clearLogs()">
|
||||
<i class="ri-delete-bin-line"></i>
|
||||
清空
|
||||
</button>
|
||||
<button class="log-btn" id="pause-btn" onclick="togglePause()">
|
||||
<i class="ri-pause-line" id="pause-icon"></i>
|
||||
<span id="pause-text">暂停</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-filters">
|
||||
<button class="filter-btn active" data-level="all" onclick="setFilter('all')">全部</button>
|
||||
<button class="filter-btn" data-level="info" onclick="setFilter('info')">信息</button>
|
||||
<button class="filter-btn" data-level="ok" onclick="setFilter('ok')">成功</button>
|
||||
<button class="filter-btn" data-level="warn" onclick="setFilter('warn')">警告</button>
|
||||
<button class="filter-btn" data-level="error" onclick="setFilter('error')">错误</button>
|
||||
<button class="filter-btn" data-level="tip" onclick="setFilter('tip')">提示</button>
|
||||
</div>
|
||||
|
||||
<div class="log-content" id="log-content">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<i class="ri-file-list-3-line"></i>
|
||||
<p>正在加载日志...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let isPaused = false;
|
||||
let currentFilter = 'all';
|
||||
let syncInterval = null;
|
||||
|
||||
function setFilter(level) {
|
||||
currentFilter = level;
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
if (btn.dataset.level === level) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
filterLogs();
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
isPaused = !isPaused;
|
||||
const pauseBtn = document.getElementById('pause-btn');
|
||||
const pauseIcon = document.getElementById('pause-icon');
|
||||
const pauseText = document.getElementById('pause-text');
|
||||
const indicator = document.getElementById('live-indicator');
|
||||
|
||||
if (isPaused) {
|
||||
pauseBtn.classList.add('paused');
|
||||
pauseIcon.className = 'ri-play-line';
|
||||
pauseText.textContent = '继续';
|
||||
indicator.style.opacity = '0.5';
|
||||
} else {
|
||||
pauseBtn.classList.remove('paused');
|
||||
pauseIcon.className = 'ri-pause-line';
|
||||
pauseText.textContent = '暂停';
|
||||
indicator.style.opacity = '1';
|
||||
fetchLogs();
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
const content = document.getElementById('log-content');
|
||||
content.innerHTML = '<div class="empty-state"><i class="ri-file-list-3-line"></i><p>日志已清空</p></div>';
|
||||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
if (isPaused) return;
|
||||
|
||||
try {
|
||||
// 先尝试从缓冲区获取
|
||||
const response = await fetch('/api/logs/get?limit=100&source=buffer');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// 如果缓冲区为空,尝试从系统日志读取
|
||||
if (!data.logs || data.logs.length === 0) {
|
||||
const fileResponse = await fetch('/api/logs/get?limit=100&source=file');
|
||||
const fileData = await fileResponse.json();
|
||||
|
||||
if (fileData.success) {
|
||||
renderLogs(fileData.logs || []);
|
||||
}
|
||||
} else {
|
||||
renderLogs(data.logs);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取日志失败:', error);
|
||||
// 错误时也要显示状态
|
||||
const content = document.getElementById('log-content');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
emptyState.style.display = 'block';
|
||||
emptyState.innerHTML = '<i class="ri-error-warning-line"></i><p>获取日志失败</p><p style="font-size: 12px; margin-top: 8px; opacity: 0.7;">' + error.message + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderLogs(logs) {
|
||||
const content = document.getElementById('log-content');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
|
||||
if (logs.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
emptyState.innerHTML = '<i class="ri-file-list-3-line"></i><p>暂无日志</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
const filteredLogs = currentFilter === 'all'
|
||||
? logs
|
||||
: logs.filter(log => log.level === currentFilter);
|
||||
|
||||
const html = filteredLogs.map(log => `
|
||||
<div class="log-entry" data-level="${log.level}">
|
||||
<span class="log-timestamp">${log.timestamp}</span>
|
||||
<span class="log-level ${log.level}">${log.level.toUpperCase()}</span>
|
||||
<span class="log-tag">[${log.tag}]</span>
|
||||
<span class="log-message">${escapeHtml(log.message)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
content.innerHTML = html;
|
||||
content.scrollTop = content.scrollHeight;
|
||||
}
|
||||
|
||||
function filterLogs() {
|
||||
const entries = document.querySelectorAll('.log-entry');
|
||||
entries.forEach(entry => {
|
||||
if (currentFilter === 'all' || entry.dataset.level === currentFilter) {
|
||||
entry.style.display = 'block';
|
||||
} else {
|
||||
entry.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchLogs();
|
||||
syncInterval = setInterval(fetchLogs, 2000); // 每2秒同步一次
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
288
store/@{FutureOSS}/log-terminal/views/terminal.php
Normal file
288
store/@{FutureOSS}/log-terminal/views/terminal.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
.terminal-container { max-width: 1400px; margin: 0 auto; padding: 20px; height: calc(100vh - 100px); display: flex; flex-direction: column; }
|
||||
.terminal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.terminal-title { font-size: 18px; font-weight: 600; color: #00bcd4; display: flex; align-items: center; gap: 10px; }
|
||||
.terminal-title i { font-size: 24px; }
|
||||
|
||||
.terminal-controls { display: flex; gap: 10px; }
|
||||
.term-btn { padding: 6px 14px; background: #3b82f6; border: none; border-radius: 6px; color: white; cursor: pointer; font-size: 13px; transition: all 0.2s; display: flex; align-items: center; gap: 6px; }
|
||||
.term-btn:hover { background: #2563eb; }
|
||||
.term-btn.connecting { background: #f59e0b; cursor: not-allowed; }
|
||||
.term-btn.disconnect { background: #ef4444; }
|
||||
.term-btn.disconnect:hover { background: #dc2626; }
|
||||
|
||||
.terminal-status { display: flex; align-items: center; gap: 8px; padding: 4px 12px; background: #1e293b; border-radius: 12px; font-size: 12px; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.status-dot.connected { background: #34d399; animation: pulse 2s infinite; }
|
||||
.status-dot.disconnected { background: #f87171; }
|
||||
.status-dot.connecting { background: #fbbf24; animation: pulse 1s infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(1.3); } }
|
||||
|
||||
.terminal-wrapper { flex: 1; background: #0f172a; border-radius: 10px; padding: 16px; display: flex; flex-direction: column; }
|
||||
.terminal-info { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #1e293b; margin-bottom: 12px; }
|
||||
.info-item { font-size: 12px; color: #94a3b8; display: flex; align-items: center; gap: 6px; }
|
||||
.info-item i { color: #3b82f6; }
|
||||
|
||||
.terminal-output { flex: 1; overflow-y: auto; font-family: 'Courier New', monospace; font-size: 13px; line-height: 1.6; color: #e2e8f0; margin-bottom: 12px; }
|
||||
.terminal-output::-webkit-scrollbar { width: 8px; }
|
||||
.terminal-output::-webkit-scrollbar-track { background: #1e293b; border-radius: 4px; }
|
||||
.terminal-output::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; }
|
||||
.terminal-output::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
||||
|
||||
.terminal-line { padding: 2px 0; }
|
||||
.terminal-line.command { color: #34d399; }
|
||||
.terminal-line.output { color: #e2e8f0; }
|
||||
.terminal-line.error { color: #f87171; }
|
||||
.terminal-line.info { color: #60a5fa; }
|
||||
.terminal-line.success { color: #34d399; }
|
||||
.terminal-line.warning { color: #fbbf24; }
|
||||
|
||||
.terminal-input-wrapper { display: flex; gap: 8px; align-items: center; padding: 8px; background: #1e293b; border-radius: 6px; }
|
||||
.terminal-prompt { color: #34d399; font-weight: 600; white-space: nowrap; }
|
||||
.terminal-input { flex: 1; background: transparent; border: none; color: #e2e8f0; font-family: 'Courier New', monospace; font-size: 13px; outline: none; }
|
||||
.terminal-input::placeholder { color: #64748b; }
|
||||
|
||||
.ssh-config { background: #1e293b; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
||||
.config-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||||
.config-row:last-child { margin-bottom: 0; }
|
||||
.config-label { font-size: 13px; color: #94a3b8; min-width: 100px; }
|
||||
.config-input { flex: 1; padding: 6px 12px; background: #0f172a; border: 1px solid #334155; border-radius: 6px; color: #e2e8f0; font-size: 13px; }
|
||||
.config-input:focus { outline: none; border-color: #3b82f6; }
|
||||
.config-checkbox { display: flex; align-items: center; gap: 8px; color: #e2e8f0; font-size: 13px; cursor: pointer; }
|
||||
.config-checkbox input[type="checkbox"] { cursor: pointer; }
|
||||
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: #64748b; }
|
||||
.empty-state i { font-size: 64px; margin-bottom: 16px; opacity: 0.3; }
|
||||
.empty-state p { font-size: 14px; margin-top: 8px; }
|
||||
|
||||
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="terminal-container">
|
||||
<div class="terminal-header">
|
||||
<div class="terminal-title">
|
||||
<i class="ri-terminal-box-line"></i>
|
||||
<span>SSH 终端</span>
|
||||
</div>
|
||||
<div class="terminal-controls">
|
||||
<div class="terminal-status">
|
||||
<span class="status-dot disconnected" id="status-dot"></span>
|
||||
<span id="status-text">未连接</span>
|
||||
</div>
|
||||
<button class="term-btn" id="connect-btn" onclick="connectSSH()">
|
||||
<i class="ri-plug-line"></i>
|
||||
连接
|
||||
</button>
|
||||
<button class="term-btn disconnect" id="disconnect-btn" onclick="disconnectSSH()" style="display: none;">
|
||||
<i class="ri-close-line"></i>
|
||||
断开
|
||||
</button>
|
||||
<button class="term-btn" id="clear-btn" onclick="clearTerminal()">
|
||||
<i class="ri-delete-bin-line"></i>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ssh-config" id="ssh-config">
|
||||
<div class="config-row">
|
||||
<span class="config-label"><i class="ri-settings-3-line"></i> SSH 端口:</span>
|
||||
<input type="number" class="config-input" id="ssh-port" value="8022" min="1" max="65535">
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label class="config-checkbox">
|
||||
<input type="checkbox" id="auto-install" checked>
|
||||
自动安装 SSH 服务
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="terminal-wrapper">
|
||||
<div class="terminal-info">
|
||||
<div class="info-item">
|
||||
<i class="ri-server-line"></i>
|
||||
<span>端口: <strong id="info-port">8022</strong></span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<i class="ri-time-line"></i>
|
||||
<span>运行时间: <strong id="info-uptime">-</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="terminal-output" id="terminal-output">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<i class="ri-terminal-box-line"></i>
|
||||
<p>点击"连接"按钮开始 SSH 终端会话</p>
|
||||
<p style="font-size: 12px; margin-top: 8px; opacity: 0.7;">支持自动安装 SSH 服务</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="terminal-input-wrapper" id="input-wrapper" style="display: none;">
|
||||
<span class="terminal-prompt">$</span>
|
||||
<input type="text" class="terminal-input" id="terminal-input" placeholder="输入命令..." onkeypress="handleKeyPress(event)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let sessionId = null;
|
||||
let isConnected = false;
|
||||
|
||||
function updateStatus(status) {
|
||||
const dot = document.getElementById('status-dot');
|
||||
const text = document.getElementById('status-text');
|
||||
const connectBtn = document.getElementById('connect-btn');
|
||||
const disconnectBtn = document.getElementById('disconnect-btn');
|
||||
const inputWrapper = document.getElementById('input-wrapper');
|
||||
const sshConfig = document.getElementById('ssh-config');
|
||||
|
||||
dot.className = 'status-dot ' + status;
|
||||
|
||||
if (status === 'connected') {
|
||||
text.textContent = '已连接';
|
||||
connectBtn.style.display = 'none';
|
||||
disconnectBtn.style.display = 'flex';
|
||||
inputWrapper.style.display = 'flex';
|
||||
sshConfig.style.display = 'none';
|
||||
isConnected = true;
|
||||
} else if (status === 'connecting') {
|
||||
text.textContent = '连接中...';
|
||||
connectBtn.classList.add('connecting');
|
||||
connectBtn.innerHTML = '<span class="spinner"></span> 连接中';
|
||||
} else {
|
||||
text.textContent = '未连接';
|
||||
connectBtn.style.display = 'flex';
|
||||
connectBtn.classList.remove('connecting');
|
||||
connectBtn.innerHTML = '<i class="ri-plug-line"></i> 连接';
|
||||
disconnectBtn.style.display = 'none';
|
||||
inputWrapper.style.display = 'none';
|
||||
sshConfig.style.display = 'block';
|
||||
isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function connectSSH() {
|
||||
const port = document.getElementById('ssh-port').value;
|
||||
const autoInstall = document.getElementById('auto-install').checked;
|
||||
|
||||
updateStatus('connecting');
|
||||
appendLine('info', '正在初始化 SSH 连接...');
|
||||
appendLine('info', `目标端口: ${port}`);
|
||||
|
||||
if (autoInstall) {
|
||||
appendLine('info', '自动安装 SSH: 已启用');
|
||||
appendLine('tip', '智能检测 SSH 服务状态...');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/terminal/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ port: parseInt(port), auto_install: autoInstall })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
sessionId = data.session_id;
|
||||
document.getElementById('info-port').textContent = port;
|
||||
document.getElementById('info-uptime').textContent = '刚刚';
|
||||
updateStatus('connected');
|
||||
appendLine('success', `✓ SSH 终端已连接 (会话 #${sessionId})`);
|
||||
appendLine('output', '输入命令开始操作...');
|
||||
appendLine('output', '');
|
||||
document.getElementById('terminal-input').focus();
|
||||
} else {
|
||||
updateStatus('disconnected');
|
||||
appendLine('error', `✗ 连接失败: ${data.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
updateStatus('disconnected');
|
||||
appendLine('error', `✗ 连接异常: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectSSH() {
|
||||
if (!sessionId) return;
|
||||
|
||||
try {
|
||||
await fetch('/api/terminal/disconnect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId })
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('断开连接失败:', error);
|
||||
}
|
||||
|
||||
sessionId = null;
|
||||
updateStatus('disconnected');
|
||||
appendLine('warning', 'SSH 终端已断开');
|
||||
}
|
||||
|
||||
async function sendCommand(command) {
|
||||
if (!sessionId || !command.trim()) return;
|
||||
|
||||
appendLine('command', `$ ${command}`);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/terminal/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId, command: command })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.output) {
|
||||
const lines = data.output.split('\n');
|
||||
lines.forEach(line => {
|
||||
if (line.trim()) {
|
||||
appendLine('output', line);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
appendLine('error', `执行命令失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyPress(event) {
|
||||
if (event.key === 'Enter') {
|
||||
const input = document.getElementById('terminal-input');
|
||||
const command = input.value.trim();
|
||||
if (command) {
|
||||
sendCommand(command);
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendLine(type, text) {
|
||||
const output = document.getElementById('terminal-output');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
|
||||
if (emptyState) {
|
||||
emptyState.style.display = 'none';
|
||||
}
|
||||
|
||||
const line = document.createElement('div');
|
||||
line.className = `terminal-line ${type}`;
|
||||
line.textContent = text;
|
||||
output.appendChild(line);
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
function clearTerminal() {
|
||||
const output = document.getElementById('terminal-output');
|
||||
output.innerHTML = '<div class="empty-state"><i class="ri-terminal-box-line"></i><p>终端已清空</p></div>';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
8
store/@{FutureOSS}/pkg-manager/SIGNATURE
Normal file
8
store/@{FutureOSS}/pkg-manager/SIGNATURE
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"signature": "hNzQ56uwgghPRTVm5YFA8fZp+1Y9TQ9fSDKLEY+KPFLddrxdnXZiE66XXWEVEj80pB5E/zJ0nDcpJYTe9+Mo4LQ++Qzt7yA+PMu8WZ/I39f1870FR/s+MuaiKWp0sT/NeyHRv/nHKi/FaZXWx+KsSbKatq4w088bNhyWahJg1RmTaCKAxv7ut9Uqn33m9teoeNt43AG/6ySfRQRfk0K1L7Yvf/9yJStDMAuTzFiQmhs4MZ58VzPh/Nrtj0G7N5mAjp9bZKa+EFqMLFBQlG5TDqWU8zFKBe27CsvSK7MthS3PGyzeGftm2O683hgClGdsgdK9kqwZ0eMOb5Jcesk4f0rWVODpCf2cfRPocrs401yKzVU3dStFw14Bq82SpQDRJ9EDU3lP8E4RqlmXEAzlGNoMsGSGth9gSWc4VpHn4ppVH5ftKk/AvJrpdFWyWe0jPnDODRKAIMn9sGiZUy6XqB0fGMoU0vpuvtLy6mtVmQglhsVE49XA5txAEWQncPUPxxjNoMdRo5RDlimRVNtXNcwKRb1z9V6ky1eOVKFHaPsp4Y+1mreZVUokaUBf8LG1qvFXjZuiYHRlffKSN3/yzRqhDnE5fCDu0wpjHe24dZ/PeQXbG2aAQlJQr15yh7p5dxTSiv+HeacwDqZPF8X/9Ey6xMflr1xGZpp9j9YeCtk=",
|
||||
"signer": "FutureOSS",
|
||||
"algorithm": "RSA-SHA256",
|
||||
"timestamp": 1775967812.6803007,
|
||||
"plugin_hash": "c0c56583082ca71e9a84ac2e976c22683573ec4e40387ee893ac42f31da62d4a",
|
||||
"author": "FutureOSS"
|
||||
}
|
||||
BIN
store/@{FutureOSS}/pkg-manager/__pycache__/main.cpython-313.pyc
Normal file
BIN
store/@{FutureOSS}/pkg-manager/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
485
store/@{FutureOSS}/pkg-manager/main.py
Normal file
485
store/@{FutureOSS}/pkg-manager/main.py
Normal file
@@ -0,0 +1,485 @@
|
||||
"""包管理插件 - 提供插件配置管理和商店界面"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from oss.logger.logger import Log
|
||||
from oss.plugin.types import Plugin, Response, register_plugin_type
|
||||
|
||||
|
||||
# Gitee 仓库配置
|
||||
GITEE_OWNER = "starlight-apk"
|
||||
GITEE_REPO = "future-oss"
|
||||
GITEE_BRANCH = "main"
|
||||
# 使用 raw 文件 URL(不走 API,无频率限制)
|
||||
GITEE_RAW_BASE = f"https://gitee.com/{GITEE_OWNER}/{GITEE_REPO}/raw/{GITEE_BRANCH}"
|
||||
GITEE_API_BASE = f"https://gitee.com/api/v5/repos/{GITEE_OWNER}/{GITEE_REPO}/contents"
|
||||
# Gitee Token(从环境变量读取,可选)
|
||||
GITEE_TOKEN = os.environ.get("GITEE_TOKEN", "")
|
||||
|
||||
|
||||
def _gitee_request(url: str, timeout: int = 15):
|
||||
"""Gitee 请求"""
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("User-Agent", "FutureOSS-PkgManager")
|
||||
if GITEE_TOKEN:
|
||||
# Gitee 使用私人令牌认证
|
||||
req.add_header("Authorization", f"token {GITEE_TOKEN}")
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
|
||||
|
||||
class PkgManagerPlugin(Plugin):
|
||||
"""包管理插件"""
|
||||
|
||||
def __init__(self):
|
||||
self.webui = None
|
||||
self.storage = None
|
||||
self.store_dir = Path("./store")
|
||||
self._remote_cache = None
|
||||
self._cache_time = 0
|
||||
self._cache_ttl = 300 # 5分钟缓存
|
||||
|
||||
def meta(self):
|
||||
from oss.plugin.types import Metadata, PluginConfig, Manifest
|
||||
return Manifest(
|
||||
metadata=Metadata(
|
||||
name="pkg-manager",
|
||||
version="1.0.0",
|
||||
author="FutureOSS",
|
||||
description="插件包管理器 - 配置管理和商店"
|
||||
),
|
||||
config=PluginConfig(enabled=True, args={}),
|
||||
dependencies=["http-api", "webui", "plugin-storage"]
|
||||
)
|
||||
|
||||
def set_webui(self, webui):
|
||||
self.webui = webui
|
||||
|
||||
def set_plugin_storage(self, storage):
|
||||
self.storage = storage
|
||||
|
||||
def init(self, deps: dict = None):
|
||||
"""init 阶段:注册页面到 WebUI"""
|
||||
if not self.webui:
|
||||
Log.warn("pkg-manager", "警告: 未找到 WebUI 依赖")
|
||||
return
|
||||
|
||||
self.webui.register_page(
|
||||
path='/packages',
|
||||
content_provider=self._packages_content,
|
||||
nav_item={'icon': 'ri-apps-line', 'text': '插件管理'}
|
||||
)
|
||||
self.webui.register_page(
|
||||
path='/store',
|
||||
content_provider=self._store_content,
|
||||
nav_item={'icon': 'ri-store-2-line', 'text': '插件商店'}
|
||||
)
|
||||
Log.info("pkg-manager", "已注册到 WebUI 导航")
|
||||
|
||||
def start(self):
|
||||
"""启动阶段:注册 API 路由"""
|
||||
if not self.webui or not hasattr(self.webui, 'server') or not self.webui.server:
|
||||
Log.warn("pkg-manager", "警告: WebUI 服务器未就绪")
|
||||
return
|
||||
|
||||
router = self.webui.server.router
|
||||
|
||||
# API - 已安装插件
|
||||
router.get("/api/plugins", self._handle_list_plugins)
|
||||
router.get("/api/plugins/:name/config", self._handle_get_config)
|
||||
router.post("/api/plugins/:name/config", self._handle_save_config)
|
||||
router.get("/api/plugins/:name/info", self._handle_get_plugin_info)
|
||||
router.post("/api/plugins/:name/uninstall", self._handle_uninstall)
|
||||
|
||||
# API - 远程商店
|
||||
router.get("/api/store/remote", self._handle_remote_store)
|
||||
router.post("/api/store/install", self._handle_store_install)
|
||||
|
||||
Log.info("pkg-manager", "包管理器已启动")
|
||||
|
||||
def stop(self):
|
||||
Log.error("pkg-manager", "包管理器已停止")
|
||||
|
||||
# ==================== 页面渲染 ====================
|
||||
|
||||
def _packages_content(self) -> str:
|
||||
return self._render_php_view('packages.php', {'pageTitle': '插件管理'})
|
||||
|
||||
def _store_content(self) -> str:
|
||||
return self._render_php_view('store.php', {'pageTitle': '插件商店'})
|
||||
|
||||
def _render_php_view(self, view_name: str, variables: dict) -> str:
|
||||
import subprocess
|
||||
|
||||
views_dir = os.path.join(os.path.dirname(__file__), 'views')
|
||||
php_file = os.path.join(views_dir, view_name)
|
||||
if not os.path.exists(php_file):
|
||||
return f"<h1>错误: 找不到 {view_name}</h1>"
|
||||
|
||||
php_vars = ""
|
||||
for key, value in variables.items():
|
||||
if isinstance(value, str):
|
||||
php_vars += f"${key} = '{value}';\n"
|
||||
else:
|
||||
php_vars += f"${key} = {json.dumps(value)};\n"
|
||||
|
||||
with open(php_file, 'r', encoding='utf-8') as f:
|
||||
php_content = f.read()
|
||||
|
||||
tmp_file = os.path.join(views_dir, '.temp_pkg.php')
|
||||
try:
|
||||
with open(tmp_file, 'w', encoding='utf-8') as f:
|
||||
f.write(f"<?php\n{php_vars}\n?>\n{php_content}")
|
||||
|
||||
result = subprocess.run(
|
||||
["php", "-f", tmp_file],
|
||||
capture_output=True, text=True, timeout=10, cwd=views_dir
|
||||
)
|
||||
return result.stdout if result.returncode == 0 else f"<pre>{result.stderr}</pre>"
|
||||
finally:
|
||||
try:
|
||||
if os.path.exists(tmp_file):
|
||||
os.unlink(tmp_file)
|
||||
except:
|
||||
pass
|
||||
|
||||
# ==================== API 处理 ====================
|
||||
|
||||
def _handle_list_plugins(self, request):
|
||||
"""列出所有已安装插件"""
|
||||
plugins = self._scan_all_plugins()
|
||||
return Response(status=200, headers={"Content-Type": "application/json"}, body=json.dumps(plugins, ensure_ascii=False))
|
||||
|
||||
def _handle_get_config(self, request):
|
||||
"""获取插件配置 schema + 当前值"""
|
||||
plugin_name = request.path_params.get('name', '')
|
||||
schema = self._load_config_schema(plugin_name)
|
||||
current = self._load_plugin_config(plugin_name)
|
||||
return Response(status=200, headers={"Content-Type": "application/json"}, body=json.dumps({
|
||||
"schema": schema,
|
||||
"current": current
|
||||
}, ensure_ascii=False))
|
||||
|
||||
def _handle_save_config(self, request):
|
||||
"""保存插件配置"""
|
||||
import json as json_mod
|
||||
try:
|
||||
body = json_mod.loads(request.body)
|
||||
plugin_name = request.path_params.get('name', '')
|
||||
self._save_plugin_config(plugin_name, body)
|
||||
return Response(status=200, headers={"Content-Type": "application/json"}, body='{"ok":true}')
|
||||
except Exception as e:
|
||||
return Response(status=500, headers={"Content-Type": "application/json"}, body=json.dumps({"error": str(e)}))
|
||||
|
||||
def _handle_get_plugin_info(self, request):
|
||||
"""获取插件详细信息"""
|
||||
plugin_name = request.path_params.get('name', '')
|
||||
info = self._get_plugin_detailed_info(plugin_name)
|
||||
return Response(status=200, headers={"Content-Type": "application/json"}, body=json.dumps(info, ensure_ascii=False))
|
||||
|
||||
def _handle_uninstall(self, request):
|
||||
"""卸载插件"""
|
||||
import shutil
|
||||
plugin_name = request.path_params.get('name', '')
|
||||
# 查找插件目录
|
||||
plugin_dir = self._find_plugin_dir(plugin_name)
|
||||
if not plugin_dir:
|
||||
return Response(status=404, body='{"error":"插件未安装"}')
|
||||
try:
|
||||
shutil.rmtree(plugin_dir)
|
||||
return Response(status=200, body='{"ok":true}')
|
||||
except Exception as e:
|
||||
return Response(status=500, body=json.dumps({"error": str(e)}))
|
||||
|
||||
def _handle_remote_store(self, request):
|
||||
"""从 Gitee API 获取远程插件列表"""
|
||||
try:
|
||||
plugins = self._fetch_remote_plugins()
|
||||
return Response(status=200, headers={"Content-Type": "application/json"}, body=json.dumps(plugins, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
return Response(status=500, body=json.dumps({"error": str(e)}))
|
||||
|
||||
def _handle_store_install(self, request):
|
||||
"""安装插件"""
|
||||
import json as json_mod
|
||||
try:
|
||||
body = json_mod.loads(request.body)
|
||||
name = body.get("name", "")
|
||||
author = body.get("author", "FutureOSS")
|
||||
success = self._install_from_gitee(name, author)
|
||||
return Response(status=200, body=json.dumps({"ok": success}))
|
||||
except Exception as e:
|
||||
return Response(status=500, body=json.dumps({"error": str(e)}))
|
||||
|
||||
# ==================== Gitee 远程商店 ====================
|
||||
|
||||
def _fetch_remote_plugins(self) -> list:
|
||||
"""从 Gitee 获取所有可用插件(带缓存+限速+重试)"""
|
||||
import time
|
||||
now = time.time()
|
||||
if self._remote_cache and (now - self._cache_time) < self._cache_ttl:
|
||||
return self._remote_cache
|
||||
|
||||
plugins = []
|
||||
try:
|
||||
store_url = f"{GITEE_API_BASE}/store"
|
||||
# 重试 3 次,每次间隔增加
|
||||
for attempt in range(3):
|
||||
try:
|
||||
with _gitee_request(store_url, timeout=15) as resp:
|
||||
dirs = json.loads(resp.read().decode("utf-8"))
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt < 2:
|
||||
time.sleep(1 + attempt)
|
||||
continue
|
||||
raise
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
for dir_info in dirs:
|
||||
if dir_info.get("type") != "dir":
|
||||
continue
|
||||
author = dir_info.get("name", "")
|
||||
if not author.startswith("@{"):
|
||||
continue
|
||||
|
||||
author_url = f"{GITEE_API_BASE}/store/{urllib.parse.quote(author, safe='')}"
|
||||
for attempt in range(3):
|
||||
try:
|
||||
with _gitee_request(author_url, timeout=15) as resp:
|
||||
plugin_dirs = json.loads(resp.read().decode("utf-8"))
|
||||
break
|
||||
except Exception:
|
||||
if attempt < 2:
|
||||
time.sleep(1 + attempt)
|
||||
continue
|
||||
raise
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
for plugin_dir in plugin_dirs:
|
||||
if plugin_dir.get("type") != "dir":
|
||||
continue
|
||||
plugin_name = plugin_dir.get("name", "")
|
||||
|
||||
manifest_url = f"{GITEE_API_BASE}/store/{urllib.parse.quote(author, safe='')}/{plugin_name}/manifest.json"
|
||||
manifest = {}
|
||||
for attempt in range(3):
|
||||
try:
|
||||
with _gitee_request(manifest_url, timeout=15) as resp:
|
||||
manifest = json.loads(resp.read().decode("utf-8"))
|
||||
break
|
||||
except Exception:
|
||||
if attempt < 2:
|
||||
time.sleep(1 + attempt)
|
||||
continue
|
||||
|
||||
plugins.append({
|
||||
"name": plugin_name,
|
||||
"author": author,
|
||||
"full_name": f"{author}/{plugin_name}",
|
||||
"metadata": manifest.get("metadata", {}),
|
||||
"dependencies": manifest.get("dependencies", []),
|
||||
"has_config": False,
|
||||
"is_installed": self._is_plugin_installed(plugin_name, author)
|
||||
})
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
self._remote_cache = plugins
|
||||
self._cache_time = now
|
||||
except Exception as e:
|
||||
Log.error("pkg-manager", f"获取远程插件列表失败: {e}")
|
||||
|
||||
return plugins
|
||||
|
||||
def _install_from_gitee(self, plugin_name: str, author: str) -> bool:
|
||||
"""从 Gitee 下载并安装插件(使用 raw URL)"""
|
||||
import shutil, time
|
||||
install_dir = self.store_dir / author / plugin_name
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# 获取目录结构(需要一次 API 调用)
|
||||
api_url = f"{GITEE_API_BASE}/store/{author}/{plugin_name}"
|
||||
with _gitee_request(api_url, timeout=15) as resp:
|
||||
items = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
for item in items:
|
||||
if item.get("type") == "file":
|
||||
# 使用 raw URL 下载文件(不走 API)
|
||||
filename = item.get("name")
|
||||
raw_url = f"{GITEE_RAW_BASE}/store/{author}/{plugin_name}/{filename}"
|
||||
local_file = install_dir / filename
|
||||
try:
|
||||
with _gitee_request(raw_url, timeout=15) as resp:
|
||||
content = resp.read()
|
||||
with open(local_file, 'wb') as f:
|
||||
f.write(content)
|
||||
except:
|
||||
pass
|
||||
elif item.get("type") == "dir":
|
||||
sub_dir = item.get("name")
|
||||
self._download_dir_raw(author, plugin_name, sub_dir, install_dir / sub_dir)
|
||||
time.sleep(0.3)
|
||||
|
||||
Log.info("pkg-manager", f"已安装: {author}/{plugin_name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
Log.error("pkg-manager", f"安装失败 {plugin_name}: {e}")
|
||||
if install_dir.exists():
|
||||
shutil.rmtree(install_dir)
|
||||
return False
|
||||
|
||||
def _download_dir_raw(self, author: str, plugin: str, sub_dir: str, local_dir: Path):
|
||||
"""使用 raw URL 递归下载子目录"""
|
||||
import time
|
||||
try:
|
||||
api_url = f"{GITEE_API_BASE}/store/{author}/{plugin}/{sub_dir}"
|
||||
with _gitee_request(api_url, timeout=15) as resp:
|
||||
items = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
for item in items:
|
||||
if item.get("type") == "file":
|
||||
filename = item.get("name")
|
||||
raw_url = f"{GITEE_RAW_BASE}/store/{author}/{plugin}/{sub_dir}/{filename}"
|
||||
try:
|
||||
with _gitee_request(raw_url, timeout=15) as resp:
|
||||
content = resp.read()
|
||||
with open(local_dir / filename, 'wb') as f:
|
||||
f.write(content)
|
||||
except:
|
||||
pass
|
||||
elif item.get("type") == "dir":
|
||||
self._download_dir_raw(author, plugin, f"{sub_dir}/{item.get('name')}", local_dir / item.get("name"))
|
||||
except:
|
||||
pass
|
||||
|
||||
# ==================== 辅助方法 ====================
|
||||
|
||||
def _scan_all_plugins(self) -> list:
|
||||
"""扫描本地已安装插件"""
|
||||
plugins = []
|
||||
if not self.store_dir.exists():
|
||||
return plugins
|
||||
|
||||
for author_dir in self.store_dir.iterdir():
|
||||
if author_dir.is_dir() and author_dir.name.startswith("@{"):
|
||||
for plugin_dir in author_dir.iterdir():
|
||||
if plugin_dir.is_dir() and (plugin_dir / "main.py").exists():
|
||||
manifest_path = plugin_dir / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
plugins.append({
|
||||
"name": plugin_dir.name,
|
||||
"full_name": f"{author_dir.name}/{plugin_dir.name}",
|
||||
"author": author_dir.name,
|
||||
"metadata": manifest.get("metadata", {}),
|
||||
"dependencies": manifest.get("dependencies", []),
|
||||
"has_config": (plugin_dir / "config.json").exists(),
|
||||
"is_installed": True
|
||||
})
|
||||
return plugins
|
||||
|
||||
def _is_plugin_installed(self, plugin_name: str, author: str) -> bool:
|
||||
"""检查插件是否已安装"""
|
||||
plugin_dir = self.store_dir / author / plugin_name
|
||||
return (plugin_dir / "main.py").exists()
|
||||
|
||||
def _find_plugin_dir(self, plugin_name: str) -> Path | None:
|
||||
"""查找插件目录"""
|
||||
if not self.store_dir.exists():
|
||||
return None
|
||||
for author_dir in self.store_dir.iterdir():
|
||||
if author_dir.is_dir():
|
||||
plugin_dir = author_dir / plugin_name
|
||||
if plugin_dir.exists() and (plugin_dir / "main.py").exists():
|
||||
return plugin_dir
|
||||
return None
|
||||
|
||||
def _load_config_schema(self, plugin_name: str) -> dict:
|
||||
"""加载插件 config.json schema"""
|
||||
plugin_dir = self._find_plugin_dir(plugin_name)
|
||||
if not plugin_dir:
|
||||
return {}
|
||||
schema_path = plugin_dir / "config.json"
|
||||
if not schema_path.exists():
|
||||
return {}
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
def _load_plugin_config(self, plugin_name: str) -> dict:
|
||||
"""加载插件当前配置"""
|
||||
schema = self._load_config_schema(plugin_name)
|
||||
defaults = {}
|
||||
for key, field_def in schema.items():
|
||||
defaults[key] = field_def.get("default")
|
||||
if self.storage:
|
||||
storage_instance = self.storage.get_storage("pkg-manager")
|
||||
user_config = storage_instance.get(f"plugin_config.{plugin_name}", {})
|
||||
defaults.update(user_config)
|
||||
return defaults
|
||||
|
||||
def _save_plugin_config(self, plugin_name: str, config: dict):
|
||||
"""保存插件配置"""
|
||||
if self.storage:
|
||||
storage_instance = self.storage.get_storage("pkg-manager")
|
||||
storage_instance.set(f"plugin_config.{plugin_name}", config)
|
||||
|
||||
def _get_plugin_detailed_info(self, plugin_name: str) -> dict:
|
||||
"""获取插件的依赖、事件、页面信息"""
|
||||
dependencies = []
|
||||
events = [] # 事件 = 功能描述
|
||||
plugin_dir = self._find_plugin_dir(plugin_name)
|
||||
|
||||
if plugin_dir:
|
||||
manifest_path = plugin_dir / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
dependencies = manifest.get("dependencies", [])
|
||||
# 从 manifest 的 metadata.description 或 type 中提取功能
|
||||
metadata = manifest.get("metadata", {})
|
||||
plugin_type = metadata.get("type", "")
|
||||
if plugin_type:
|
||||
events.append(f"类型: {plugin_type}")
|
||||
# 从 manifest config 推断功能
|
||||
config = manifest.get("config", {})
|
||||
if config.get("enabled"):
|
||||
events.append("已启用")
|
||||
|
||||
# 只返回该插件自己注册的页面(通过插件名匹配)
|
||||
pages = []
|
||||
if self.webui and hasattr(self.webui, 'server') and self.webui.server:
|
||||
for path, provider in self.webui.server.pages.items():
|
||||
# 检查 provider 是否属于该插件
|
||||
provider_name = getattr(provider, '__self__', None)
|
||||
if provider_name and isinstance(provider_name, PkgManagerPlugin):
|
||||
continue # 跳过自己的页面
|
||||
# 通过路径前缀判断(dashboard 注册 /dashboard)
|
||||
if path == f'/{plugin_name}' or path.startswith(f'/{plugin_name}/'):
|
||||
pages.append({"path": path})
|
||||
# 特殊处理:首页
|
||||
if plugin_name == 'webui' and path == '/':
|
||||
pages.append({"path": path})
|
||||
|
||||
return {
|
||||
"name": plugin_name,
|
||||
"dependencies": dependencies,
|
||||
"config_fields": list(self._load_config_schema(plugin_name).keys()),
|
||||
"pages": pages,
|
||||
"events": events
|
||||
}
|
||||
|
||||
|
||||
register_plugin_type("PkgManagerPlugin", PkgManagerPlugin)
|
||||
|
||||
|
||||
def New():
|
||||
return PkgManagerPlugin()
|
||||
15
store/@{FutureOSS}/pkg-manager/manifest.json
Normal file
15
store/@{FutureOSS}/pkg-manager/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "pkg-manager",
|
||||
"version": "1.0.0",
|
||||
"author": "FutureOSS",
|
||||
"description": "插件包管理器 - 配置管理和商店",
|
||||
"type": "webui-extension"
|
||||
},
|
||||
"config": {
|
||||
"enabled": true,
|
||||
"args": {}
|
||||
},
|
||||
"dependencies": ["http-api", "webui", "plugin-storage"],
|
||||
"permissions": ["*"]
|
||||
}
|
||||
337
store/@{FutureOSS}/pkg-manager/views/packages.php
Normal file
337
store/@{FutureOSS}/pkg-manager/views/packages.php
Normal file
@@ -0,0 +1,337 @@
|
||||
<div class="packages-page" x-data="packagesApp()" x-init="init()">
|
||||
<style>
|
||||
.packages-page { display: flex; height: calc(100vh - 40px); }
|
||||
.pkg-sidebar {
|
||||
width: 300px; min-width: 300px; background: #fff; border-right: 1px solid #e8ecf0;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.pkg-sidebar-header { padding: 20px; border-bottom: 1px solid #f0f0f0; }
|
||||
.pkg-sidebar-header h3 { font-size: 16px; font-weight: 600; color: #1a1a2e; }
|
||||
.pkg-search { padding: 12px 16px; border-bottom: 1px solid #f0f0f0; }
|
||||
.pkg-search input {
|
||||
width: 100%; padding: 8px 12px; border: 1px solid #e0e0e0;
|
||||
border-radius: 8px; font-size: 13px; outline: none; box-sizing: border-box;
|
||||
}
|
||||
.pkg-search input:focus { border-color: #4a90d9; }
|
||||
.pkg-list { flex: 1; overflow-y: auto; }
|
||||
.pkg-item {
|
||||
padding: 14px 16px; cursor: pointer; border-bottom: 1px solid #f8f8f8;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.pkg-item:hover { background: #f8f9fa; }
|
||||
.pkg-item.active { background: #eef4fb; border-left: 3px solid #4a90d9; }
|
||||
.pkg-item-name { font-size: 14px; font-weight: 500; color: #333; }
|
||||
.pkg-item-desc { font-size: 12px; color: #999; margin-top: 4px; }
|
||||
.pkg-item-meta { font-size: 11px; color: #666; margin-top: 4px; display: flex; gap: 6px; align-items: center; }
|
||||
.pkg-item-status { color: #2ecc71; }
|
||||
|
||||
.pkg-content { flex: 1; overflow-y: auto; padding: 24px 32px; background: #f9fafb; }
|
||||
.pkg-empty { display: flex; align-items: center; justify-content: center; height: 100%; color: #999; font-size: 15px; }
|
||||
|
||||
.pkg-config-header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.pkg-config-header h2 { font-size: 22px; font-weight: 600; color: #1a1a2e; }
|
||||
.pkg-config-header p { color: #888; font-size: 14px; margin-top: 4px; }
|
||||
|
||||
.pkg-info-bar { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.pkg-info-tag {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
|
||||
background: #fff; border-radius: 8px; font-size: 13px; color: #555; border: 1px solid #e8ecf0;
|
||||
}
|
||||
.pkg-info-tag i { font-size: 16px; }
|
||||
.pkg-info-tag .count {
|
||||
background: #4a90d9; color: #fff; border-radius: 10px; padding: 1px 7px; font-size: 11px;
|
||||
}
|
||||
|
||||
.config-section { background: #fff; border-radius: 12px; padding: 24px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
|
||||
.config-section h4 { font-size: 15px; font-weight: 600; color: #1a1a2e; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid #f0f0f0; }
|
||||
|
||||
.config-field { margin-bottom: 20px; }
|
||||
.config-field label { display: block; font-size: 13px; font-weight: 500; color: #333; margin-bottom: 6px; }
|
||||
.config-field .desc { font-size: 12px; color: #999; margin-bottom: 8px; }
|
||||
.config-field input[type="text"],
|
||||
.config-field input[type="number"],
|
||||
.config-field textarea,
|
||||
.config-field select {
|
||||
width: 100%; padding: 8px 12px; border: 1px solid #e0e0e0;
|
||||
border-radius: 8px; font-size: 13px; outline: none; transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.config-field input:focus, .config-field select:focus, .config-field textarea:focus { border-color: #4a90d9; }
|
||||
.config-field textarea { min-height: 80px; resize: vertical; }
|
||||
|
||||
.toggle { position: relative; display: inline-flex; align-items: center; gap: 10px; cursor: pointer; }
|
||||
.toggle input { display: none; }
|
||||
.toggle-slider { width: 44px; height: 24px; background: #ddd; border-radius: 12px; position: relative; transition: background 0.2s; }
|
||||
.toggle-slider::after {
|
||||
content: ''; position: absolute; width: 20px; height: 20px; background: #fff;
|
||||
border-radius: 50%; top: 2px; left: 2px; transition: transform 0.2s;
|
||||
}
|
||||
.toggle input:checked + .toggle-slider { background: #4a90d9; }
|
||||
.toggle input:checked + .toggle-slider::after { transform: translateX(20px); }
|
||||
|
||||
.radio-group, .checkbox-group { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.radio-option, .checkbox-option {
|
||||
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
|
||||
border: 1px solid #e0e0e0; border-radius: 8px; cursor: pointer;
|
||||
font-size: 13px; transition: all 0.15s;
|
||||
}
|
||||
.radio-option:hover, .checkbox-option:hover { border-color: #4a90d9; background: #f0f5fc; }
|
||||
.radio-option.selected, .checkbox-option.selected { border-color: #4a90d9; background: #eef4fb; color: #4a90d9; }
|
||||
|
||||
.action-btns { display: flex; gap: 12px; margin-top: 8px; }
|
||||
.save-btn { padding: 10px 24px; background: #4a90d9; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: background 0.2s; }
|
||||
.save-btn:hover { background: #3a7bc8; }
|
||||
.save-btn:disabled { background: #ccc; cursor: not-allowed; }
|
||||
.uninstall-btn { padding: 10px 24px; background: #fff; color: #e74c3c; border: 1px solid #e74c3c; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
|
||||
.uninstall-btn:hover { background: #fee; }
|
||||
|
||||
.status-msg { padding: 8px 12px; border-radius: 8px; font-size: 13px; margin-top: 12px; }
|
||||
.status-msg.success { background: #e8f8ef; color: #2ecc71; }
|
||||
.status-msg.error { background: #fde8e8; color: #e74c3c; }
|
||||
</style>
|
||||
|
||||
<!-- 左栏:已安装插件列表 -->
|
||||
<div class="pkg-sidebar">
|
||||
<div class="pkg-sidebar-header"><h3>已安装插件</h3></div>
|
||||
<div class="pkg-search">
|
||||
<input type="text" placeholder="搜索插件..." x-model="searchQuery" />
|
||||
</div>
|
||||
<div class="pkg-list">
|
||||
<template x-for="plugin in filteredPlugins" :key="plugin.name">
|
||||
<div class="pkg-item" :class="{ active: selectedPlugin?.name === plugin.name }"
|
||||
@click="selectPlugin(plugin)">
|
||||
<div class="pkg-item-name" x-text="plugin.metadata.name || plugin.name"></div>
|
||||
<div class="pkg-item-desc" x-text="plugin.metadata.description || '暂无描述'"></div>
|
||||
<div class="pkg-item-meta">
|
||||
<span x-text="'v' + (plugin.metadata.version || '?')"></span>
|
||||
<span style="color:#888;" x-text="'by ' + plugin.author"></span>
|
||||
<span x-show="plugin.has_config" style="color:#4a90d9;">⚙️</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右栏:配置面板 -->
|
||||
<div class="pkg-content">
|
||||
<template x-if="!selectedPlugin">
|
||||
<div class="pkg-empty">← 选择一个插件以查看配置</div>
|
||||
</template>
|
||||
|
||||
<template x-if="selectedPlugin">
|
||||
<div>
|
||||
<div class="pkg-config-header">
|
||||
<div>
|
||||
<h2 x-text="selectedPlugin.metadata.name || selectedPlugin.name"></h2>
|
||||
<p x-text="selectedPlugin.metadata.description"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 信息栏:依赖、页面、事件(只在有数据时显示) -->
|
||||
<div class="pkg-info-bar">
|
||||
<div class="pkg-info-tag" x-show="pluginDeps.length > 0">
|
||||
<i class="ri-plug-line"></i>
|
||||
<span>依赖:</span>
|
||||
<template x-for="dep in pluginDeps" :key="dep">
|
||||
<span class="count" x-text="dep"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="pkg-info-tag" x-show="pluginPages.length > 0">
|
||||
<i class="ri-pages-line"></i>
|
||||
<span>页面:</span>
|
||||
<template x-for="pg in pluginPages" :key="pg.path">
|
||||
<span class="count" x-text="pg.path"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="pkg-info-tag" x-show="pluginEvents.length > 0">
|
||||
<i class="ri-flashlight-line"></i>
|
||||
<span>事件:</span>
|
||||
<template x-for="evt in pluginEvents" :key="evt">
|
||||
<span class="count" x-text="evt"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配置表单 -->
|
||||
<div x-show="configSchema && Object.keys(configSchema).length > 0">
|
||||
<div class="config-section">
|
||||
<h4>⚙️ 配置</h4>
|
||||
<template x-for="[key, field] in sortedConfigFields" :key="key">
|
||||
<div class="config-field" x-show="isFieldVisible(key, field)">
|
||||
<label x-text="field.name || key"></label>
|
||||
<div class="desc" x-text="field.description"></div>
|
||||
|
||||
<template x-if="field.type === 'string'">
|
||||
<input type="text" x-model="configValues[key]" />
|
||||
</template>
|
||||
<template x-if="field.type === 'number'">
|
||||
<input type="number" x-model.number="configValues[key]" :min="field.min ?? 0" :max="field.max ?? 99999" />
|
||||
</template>
|
||||
<template x-if="field.type === 'boolean'">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" x-model="configValues[key]" />
|
||||
<span class="toggle-slider"></span>
|
||||
<span x-text="configValues[key] ? '已开启' : '已关闭'"></span>
|
||||
</label>
|
||||
</template>
|
||||
<template x-if="field.type === 'select'">
|
||||
<div class="radio-group">
|
||||
<template x-for="opt in field.options" :key="opt.value">
|
||||
<div class="radio-option" :class="{ selected: configValues[key] === opt.value }"
|
||||
@click="configValues[key] = opt.value">
|
||||
<span x-text="opt.label"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="field.type === 'list'">
|
||||
<div class="checkbox-group">
|
||||
<template x-for="opt in field.options" :key="opt.value">
|
||||
<div class="checkbox-option" :class="{ selected: (configValues[key] || []).includes(opt.value) }"
|
||||
@click="toggleListValue(key, opt.value)">
|
||||
<span x-text="opt.label"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="field.type === 'textarea'">
|
||||
<textarea x-model="configValues[key]"></textarea>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="action-btns">
|
||||
<button class="save-btn" @click="saveConfig()" :disabled="saving">
|
||||
<span x-show="!saving">💾 保存配置</span>
|
||||
<span x-show="saving">保存中...</span>
|
||||
</button>
|
||||
<button class="uninstall-btn" @click="uninstallPlugin()">
|
||||
🗑️ 卸载插件
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status-msg" :class="saveStatus.type" x-show="saveStatus.msg" x-text="saveStatus.msg"></div>
|
||||
</div>
|
||||
|
||||
<div x-show="!configSchema || Object.keys(configSchema).length === 0" class="config-section">
|
||||
<p style="color:#999;">该插件没有可配置的选项</p>
|
||||
<div class="action-btns">
|
||||
<button class="uninstall-btn" @click="uninstallPlugin()">🗑️ 卸载插件</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function packagesApp() {
|
||||
return {
|
||||
plugins: [],
|
||||
searchQuery: '',
|
||||
selectedPlugin: null,
|
||||
configSchema: {},
|
||||
configValues: {},
|
||||
pluginDeps: [],
|
||||
pluginPages: [],
|
||||
pluginEvents: [],
|
||||
saving: false,
|
||||
saveStatus: { type: '', msg: '' },
|
||||
|
||||
init() { this.loadPlugins(); },
|
||||
|
||||
async loadPlugins() {
|
||||
const res = await fetch('/api/plugins');
|
||||
this.plugins = await res.json();
|
||||
},
|
||||
|
||||
get filteredPlugins() {
|
||||
if (!this.searchQuery) return this.plugins;
|
||||
const q = this.searchQuery.toLowerCase();
|
||||
return this.plugins.filter(p =>
|
||||
(p.metadata.name || '').toLowerCase().includes(q) ||
|
||||
(p.metadata.description || '').toLowerCase().includes(q) ||
|
||||
p.name.toLowerCase().includes(q)
|
||||
);
|
||||
},
|
||||
|
||||
get sortedConfigFields() {
|
||||
if (!this.configSchema) return [];
|
||||
return Object.entries(this.configSchema).sort((a, b) => (a[1].order || 99) - (b[1].order || 99));
|
||||
},
|
||||
|
||||
async selectPlugin(plugin) {
|
||||
this.selectedPlugin = plugin;
|
||||
this.configSchema = {};
|
||||
this.configValues = {};
|
||||
this.pluginDeps = [];
|
||||
this.pluginPages = [];
|
||||
this.pluginEvents = [];
|
||||
|
||||
if (plugin.has_config) {
|
||||
const res = await fetch(`/api/plugins/${plugin.name}/config`);
|
||||
const data = await res.json();
|
||||
this.configSchema = data.schema || {};
|
||||
this.configValues = data.current || {};
|
||||
}
|
||||
|
||||
const infoRes = await fetch(`/api/plugins/${plugin.name}/info`);
|
||||
const info = await infoRes.json();
|
||||
this.pluginDeps = info.dependencies || [];
|
||||
this.pluginPages = info.pages || [];
|
||||
this.pluginEvents = info.events || [];
|
||||
},
|
||||
|
||||
isFieldVisible(key, field) {
|
||||
if (field.show_when) {
|
||||
return this.configValues[field.show_when.field] === field.show_when.value;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
toggleListValue(key, value) {
|
||||
if (!this.configValues[key]) this.configValues[key] = [];
|
||||
const idx = this.configValues[key].indexOf(value);
|
||||
if (idx >= 0) this.configValues[key].splice(idx, 1);
|
||||
else this.configValues[key].push(value);
|
||||
},
|
||||
|
||||
async saveConfig() {
|
||||
this.saving = true;
|
||||
this.saveStatus = {};
|
||||
try {
|
||||
const res = await fetch(`/api/plugins/${this.selectedPlugin.name}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.configValues)
|
||||
});
|
||||
if (res.ok) {
|
||||
this.saveStatus = { type: 'success', msg: '✅ 配置已保存' };
|
||||
} else {
|
||||
this.saveStatus = { type: 'error', msg: '❌ 保存失败' };
|
||||
}
|
||||
} catch (e) {
|
||||
this.saveStatus = { type: 'error', msg: '❌ 网络错误' };
|
||||
}
|
||||
this.saving = false;
|
||||
setTimeout(() => { this.saveStatus.msg = ''; }, 3000);
|
||||
},
|
||||
|
||||
async uninstallPlugin() {
|
||||
if (!confirm('确定要卸载 ' + (this.selectedPlugin.metadata.name || this.selectedPlugin.name) + ' 吗?\n卸载后需要重启 FutureOSS 才能完全生效。')) return;
|
||||
try {
|
||||
const res = await fetch(`/api/plugins/${this.selectedPlugin.name}/uninstall`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
alert('✅ 已卸载,请重启 FutureOSS');
|
||||
this.loadPlugins();
|
||||
this.selectedPlugin = null;
|
||||
} else {
|
||||
alert('❌ 卸载失败: ' + (data.error || '未知错误'));
|
||||
}
|
||||
} catch (e) { alert('❌ 网络错误'); }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
197
store/@{FutureOSS}/pkg-manager/views/store.php
Normal file
197
store/@{FutureOSS}/pkg-manager/views/store.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<div class="store-page" x-data="storeApp()" x-init="init()">
|
||||
<style>
|
||||
.store-page { display: flex; height: calc(100vh - 40px); }
|
||||
.store-sidebar {
|
||||
width: 220px; min-width: 220px; background: #fff; border-right: 1px solid #e8ecf0;
|
||||
display: flex; flex-direction: column; padding: 20px 0;
|
||||
}
|
||||
.store-sidebar-title { font-size: 14px; font-weight: 600; color: #1a1a2e; padding: 0 20px 12px; border-bottom: 1px solid #f0f0f0; }
|
||||
.store-filter { padding: 10px 20px; cursor: pointer; font-size: 13px; color: #555; transition: all 0.15s; }
|
||||
.store-filter:hover { background: #f8f9fa; }
|
||||
.store-filter.active { background: #eef4fb; color: #4a90d9; font-weight: 500; border-right: 3px solid #4a90d9; }
|
||||
.store-filter .count { float: right; background: #f0f0f0; border-radius: 10px; padding: 1px 8px; font-size: 11px; }
|
||||
.store-filter.active .count { background: #4a90d9; color: #fff; }
|
||||
|
||||
.store-main { flex: 1; overflow-y: auto; padding: 24px 32px; background: #f9fafb; }
|
||||
.store-header { margin-bottom: 24px; }
|
||||
.store-header h2 { font-size: 26px; font-weight: 600; color: #1a1a2e; }
|
||||
.store-header p { color: #888; font-size: 14px; margin-top: 4px; }
|
||||
|
||||
.store-search { margin-bottom: 20px; }
|
||||
.store-search input {
|
||||
width: 100%; max-width: 400px; padding: 10px 16px; border: 1px solid #e0e0e0;
|
||||
border-radius: 10px; font-size: 14px; outline: none; box-sizing: border-box;
|
||||
}
|
||||
.store-search input:focus { border-color: #4a90d9; }
|
||||
|
||||
.store-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
||||
|
||||
.store-card { background: #fff; border-radius: 14px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.03); display: flex; flex-direction: column; gap: 12px; }
|
||||
.store-card-header { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.store-card-name { font-size: 16px; font-weight: 600; color: #1a1a2e; }
|
||||
.store-card-version { font-size: 12px; color: #999; background: #f0f0f0; padding: 2px 8px; border-radius: 10px; }
|
||||
.store-card-desc { font-size: 13px; color: #666; flex: 1; line-height: 1.5; }
|
||||
.store-card-tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.store-card-tag { font-size: 11px; padding: 3px 8px; background: #f0f5fc; color: #4a90d9; border-radius: 6px; }
|
||||
.store-card-tag.installed { background: #e8f8ef; color: #2ecc71; }
|
||||
|
||||
.install-btn {
|
||||
padding: 8px 18px; border: none; border-radius: 8px; font-size: 13px;
|
||||
font-weight: 500; cursor: pointer; transition: all 0.2s; white-space: nowrap;
|
||||
}
|
||||
.install-btn.install { background: #4a90d9; color: #fff; }
|
||||
.install-btn.install:hover { background: #3a7bc8; }
|
||||
.install-btn.installed { background: #e8f8ef; color: #2ecc71; cursor: default; }
|
||||
.install-btn:disabled { background: #ccc; cursor: not-allowed; }
|
||||
|
||||
.store-empty { text-align: center; padding: 60px 20px; color: #999; }
|
||||
.store-empty i { font-size: 48px; margin-bottom: 12px; display: block; }
|
||||
|
||||
.store-loading { text-align: center; padding: 80px 20px; color: #666; }
|
||||
.store-loading i { font-size: 36px; animation: spin 1s linear infinite; display: block; margin-bottom: 16px; }
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
|
||||
<!-- 左栏:分类 -->
|
||||
<div class="store-sidebar">
|
||||
<div class="store-sidebar-title">分类</div>
|
||||
<div class="store-filter" :class="{ active: activeFilter === 'all' }" @click="activeFilter = 'all'">
|
||||
全部插件 <span class="count" x-text="plugins.length"></span>
|
||||
</div>
|
||||
<div class="store-filter" :class="{ active: activeFilter === 'available' }" @click="activeFilter = 'available'">
|
||||
可安装 <span class="count" x-text="plugins.filter(p => !p.is_installed).length"></span>
|
||||
</div>
|
||||
<div class="store-filter" :class="{ active: activeFilter === 'installed' }" @click="activeFilter = 'installed'">
|
||||
已安装 <span class="count" x-text="plugins.filter(p => p.is_installed).length"></span>
|
||||
</div>
|
||||
<div class="store-filter" :class="{ active: activeFilter === 'configurable' }" @click="activeFilter = 'configurable'">
|
||||
可配置 <span class="count" x-text="plugins.filter(p => p.has_config).length"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右栏:插件卡片列表 -->
|
||||
<div class="store-main">
|
||||
<div class="store-header">
|
||||
<h2>插件商店</h2>
|
||||
<p>浏览并安装插件来扩展功能</p>
|
||||
</div>
|
||||
|
||||
<!-- 加载中状态 -->
|
||||
<div class="store-loading" x-show="!loaded && !loadError">
|
||||
<i class="ri-loader-4-line"></i>
|
||||
<p>正在加载插件列表...</p>
|
||||
</div>
|
||||
|
||||
<!-- 加载失败状态 -->
|
||||
<div class="store-empty" x-show="loadError">
|
||||
<i class="ri-error-warning-line"></i>
|
||||
<p>加载失败,请稍后重试</p>
|
||||
</div>
|
||||
|
||||
<div class="store-search" x-show="loaded && !loadError">
|
||||
<input type="text" placeholder="搜索插件名称或描述..." x-model="searchQuery" />
|
||||
</div>
|
||||
|
||||
<div class="store-grid" x-show="loaded && !loadError && filteredPlugins.length > 0">
|
||||
<template x-for="plugin in filteredPlugins" :key="plugin.full_name">
|
||||
<div class="store-card">
|
||||
<div class="store-card-header">
|
||||
<div>
|
||||
<div class="store-card-name" x-text="plugin.metadata.name || plugin.name"></div>
|
||||
<div class="store-card-version" x-text="(plugin.metadata.version ? 'v' + plugin.metadata.version : '') + (plugin.author ? ' · ' + plugin.author : '')"></div>
|
||||
</div>
|
||||
<button class="install-btn" :class="plugin.is_installed ? 'installed' : 'install'"
|
||||
@click="!plugin.is_installed && installPlugin(plugin)"
|
||||
:disabled="loading">
|
||||
<span x-show="!plugin.is_installed && !loading">📦 安装</span>
|
||||
<span x-show="plugin.is_installed">✅ 已安装</span>
|
||||
<span x-show="loading">...</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="store-card-desc" x-text="plugin.metadata.description || '暂无描述'"></div>
|
||||
<div class="store-card-tags">
|
||||
<template x-for="dep in (plugin.dependencies || [])" :key="dep">
|
||||
<span class="store-card-tag" x-text="'🔌 ' + dep"></span>
|
||||
</template>
|
||||
<span class="store-card-tag" x-show="plugin.has_config">⚙️ 可配置</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="store-empty" x-show="loaded && !loadError && filteredPlugins.length === 0">
|
||||
<i class="ri-store-2-line"></i>
|
||||
<p x-text="plugins.length === 0 ? '无法连接 Gitee API,请检查网络或配置' : '没有找到匹配的插件'"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function storeApp() {
|
||||
return {
|
||||
plugins: [],
|
||||
searchQuery: '',
|
||||
activeFilter: 'all',
|
||||
loading: false,
|
||||
loaded: false,
|
||||
loadError: false,
|
||||
|
||||
init() { this.loadPlugins(); },
|
||||
|
||||
async loadPlugins() {
|
||||
this.loaded = false;
|
||||
this.loadError = false;
|
||||
try {
|
||||
const res = await fetch('/api/store/remote');
|
||||
if (!res.ok) throw new Error('API 返回错误');
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
this.plugins = data;
|
||||
} else {
|
||||
this.loadError = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取远程插件失败:', e);
|
||||
this.loadError = true;
|
||||
}
|
||||
this.loaded = true;
|
||||
},
|
||||
|
||||
get filteredPlugins() {
|
||||
let list = this.plugins;
|
||||
if (this.activeFilter === 'available') list = list.filter(p => !p.is_installed);
|
||||
else if (this.activeFilter === 'installed') list = list.filter(p => p.is_installed);
|
||||
else if (this.activeFilter === 'configurable') list = list.filter(p => p.has_config);
|
||||
|
||||
if (this.searchQuery) {
|
||||
const q = this.searchQuery.toLowerCase();
|
||||
list = list.filter(p =>
|
||||
(p.metadata.name || '').toLowerCase().includes(q) ||
|
||||
(p.metadata.description || '').toLowerCase().includes(q) ||
|
||||
p.name.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return list;
|
||||
},
|
||||
|
||||
async installPlugin(plugin) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch('/api/store/install', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: plugin.name, author: plugin.author })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
plugin.is_installed = true;
|
||||
alert('✅ 安装成功,请重启 FutureOSS 以启用插件');
|
||||
} else {
|
||||
alert('❌ 安装失败: ' + (data.error || '未知错误'));
|
||||
}
|
||||
} catch (e) { alert('❌ 网络错误'); }
|
||||
this.loading = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
8
store/@{FutureOSS}/pkg/SIGNATURE
Normal file
8
store/@{FutureOSS}/pkg/SIGNATURE
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"signature": "dXU/zN0Zge7OC8UZgWXZVhPn7LQyiKQw4iUVZAI0P4PA7zGed3cnXa7GFzVnUKxyZMKaOITcGeg7yIM9SWM7WRTWj3N1e6F/0ac6zQ57WgREUA2zc4w0/Vc742i0+KSrE1TkICZl1CTa1x3TG3VJQo0qw4FGPijKjJQIaA9yw+yLhm0dkMefZGVAuYRnupFvKxX1xar0vx6JPpoDmHxvU92PdzbR1ggsB5hOzIrvd3aVJ1U8GbogVhtaabToK9IXbX6qrTY32ffZpEOI5n0IqAvxZ81IUV3bwhf72nP6sedEEKJzgOGfqHhMalOpjsEHNiHnX3UgBfiXzeDn2zN0NevTGCGzvgQHc3/5o/Ct9wG8ujqlNLi37jXt1DrTnIF1IBsW73ltdaMvl4IgQ0Sln2Y7QMNt3CDtwNBSBiLUhTnMjPN7QVaCl7lMM0PJH5tWg3rlSdf7+LGUN535uMwrtEJEmhafo2lcApInEZryEmRcUb22Wl3xCqGgK5yk30QqGHCwY/h4fNhx2VE7LWIoD/jMJNH+TPXTzPPUGHOGB5zaR8v+qtohOwRYPewUkbEArg7qjsOgHerHziLYBY2yH1/4oi9/N7DYsgmRyHrl4siuo+5HtPar+q29yDORs5UgxK3VNHncElVWXQ9DGzIrm3Ffj610nw7kOiU58HrBjj8=",
|
||||
"signer": "FutureOSS",
|
||||
"algorithm": "RSA-SHA256",
|
||||
"timestamp": 1775964952.754572,
|
||||
"plugin_hash": "36a948e470e6cd7ac1b51a385f21fc615c36049e9cb3fac25cdaef8161063ef1",
|
||||
"author": "FutureOSS"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user