新增简易的8080面板😊

This commit is contained in:
Falck
2026-04-17 23:15:15 +08:00
parent c38d2f66d1
commit 9d19d09821
465 changed files with 9235 additions and 35285 deletions

View 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"
}

View File

@@ -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):

View 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"
}

View File

@@ -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)

View File

@@ -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):

View File

@@ -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("熔断器已触发")
```

View File

@@ -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()

View File

@@ -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": []
}

View 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"
}

View 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

View 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

View 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

View 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 []

View 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

View 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()

View 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": ["*"]
}

View File

@@ -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)

View 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"
}

View 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;
}

View 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
}
}

View 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()

View 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": ["*"]
}

View 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>

View 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"
}

View 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"
}

View File

@@ -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:
"""运行时加载插件"""

View 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"
}

View File

@@ -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

View File

@@ -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

View 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"
}

View 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"
}

View File

@@ -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:
"""健康检查"""

View 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"
}

View File

@@ -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):
"""停止"""

View 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"
}

View 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"
}

View 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
}
}

View 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()

View 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": ["*"]
}

View 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>

View 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>

View 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"
}

View 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()

View 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": ["*"]
}

View 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>

View 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>

View 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