新增简易的8080面板😊
This commit is contained in:
8
store/@{FutureOSS}/log-terminal/SIGNATURE
Normal file
8
store/@{FutureOSS}/log-terminal/SIGNATURE
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"signature": "iSAdml6TdNMXoZmB7zsRN6jYb3GL8ufdfxA+gHL58R1z7qpxc13fQidyo/syRaGv+J7zLV2/8/e8qSSGhbtWn2p08iH8vIax5zTe3zfl8wBlhxnCkEQztd1FlfkERgNWpRToiGu8GV8o0Fq+Yej6C+OaO6EL69DkRxL8Kp2Jf/2jdUOCprErLyKm506zotXjcKEr9heSLNCD0DKRaQv1GnqLJclp9fXirVvJHDS26ttNx1srNhvjTjsGofzn6qQpGuddLXKi7FWKDAByEBjqzQOmQ2iB4NOIG012J4HKO1q3BajNj11xfWL6PnSzvrwj8IJbJIrbCzTPeFK3F6gj3JtAcaI6iQLhJ7VjOCbFhlOOoIJx/5CA3j9x+/DLXgjAnV6fiD0Q8VCaLTkXGQPwGXo7xq8ExkRt48sHI9nFI0+8fj6nXB1ANDHPlvg86eyHKG61WUIZOHd/Ag9foCZtoDFnKXYBnVeNweHaHBsJWpBOvbFjPkYRpRxvRvVd8oe5qmxS0eS5RLmIIpHnOvoGKQV5CoGXPmKB5FNxDRUH4llz9W4FpxtRaYoFFoYatT9Kvr+WPSok13XS1uMBybT2nc+nEZ/XR7LsNxajfZsyEjXwQbL8DsI9LXPW9gt10F6P/9ByWaTCD/4H8flwDFI4iqw/iVENip8vnilTQpowuOY=",
|
||||
"signer": "FutureOSS",
|
||||
"algorithm": "RSA-SHA256",
|
||||
"timestamp": 1775969593.8644652,
|
||||
"plugin_hash": "b38f028d1629d878dcfc32ac28747d5cea8e93ad832009b88cb3b69934fb3fa5",
|
||||
"author": "FutureOSS"
|
||||
}
|
||||
BIN
store/@{FutureOSS}/log-terminal/__pycache__/main.cpython-313.pyc
Normal file
BIN
store/@{FutureOSS}/log-terminal/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
36
store/@{FutureOSS}/log-terminal/config.json
Normal file
36
store/@{FutureOSS}/log-terminal/config.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"logSyncInterval": {
|
||||
"type": "number",
|
||||
"name": "日志同步间隔",
|
||||
"description": "日志自动同步的时间间隔(秒)",
|
||||
"default": 2,
|
||||
"min": 1,
|
||||
"max": 10,
|
||||
"order": 1
|
||||
},
|
||||
"sshPort": {
|
||||
"type": "number",
|
||||
"name": "SSH 端口",
|
||||
"description": "SSH 连接的默认端口",
|
||||
"default": 8022,
|
||||
"min": 1,
|
||||
"max": 65535,
|
||||
"order": 2
|
||||
},
|
||||
"autoInstallSSH": {
|
||||
"type": "boolean",
|
||||
"name": "自动安装 SSH",
|
||||
"description": "连接时自动检测并安装 SSH 服务",
|
||||
"default": true,
|
||||
"order": 3
|
||||
},
|
||||
"maxLogLines": {
|
||||
"type": "number",
|
||||
"name": "最大日志行数",
|
||||
"description": "日志界面最多显示的日志行数",
|
||||
"default": 1000,
|
||||
"min": 100,
|
||||
"max": 10000,
|
||||
"order": 4
|
||||
}
|
||||
}
|
||||
607
store/@{FutureOSS}/log-terminal/main.py
Normal file
607
store/@{FutureOSS}/log-terminal/main.py
Normal file
@@ -0,0 +1,607 @@
|
||||
"""LogTerminal 日志与终端插件"""
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from oss.logger.logger import Log
|
||||
from oss.plugin.types import Plugin, Response, register_plugin_type
|
||||
|
||||
|
||||
class LogTerminalPlugin(Plugin):
|
||||
"""日志与终端插件 - 提供日志查看和 SSH 终端功能"""
|
||||
|
||||
def __init__(self):
|
||||
self.webui = None
|
||||
self.http_api = None
|
||||
self.views_dir = os.path.join(os.path.dirname(__file__), 'views')
|
||||
self._log_buffer = []
|
||||
self._log_lock = threading.Lock()
|
||||
self._ssh_sessions = {}
|
||||
self._session_counter = 0
|
||||
self._log_sync_thread = None
|
||||
self._running = False
|
||||
|
||||
def meta(self):
|
||||
from oss.plugin.types import Metadata, PluginConfig, Manifest
|
||||
return Manifest(
|
||||
metadata=Metadata(
|
||||
name="log-terminal",
|
||||
version="1.0.0",
|
||||
author="FutureOSS",
|
||||
description="日志查看器与 SSH 终端"
|
||||
),
|
||||
config=PluginConfig(enabled=True, args={}),
|
||||
dependencies=["http-api", "webui"]
|
||||
)
|
||||
|
||||
def set_webui(self, webui):
|
||||
self.webui = webui
|
||||
|
||||
def set_http_api(self, http_api):
|
||||
self.http_api = http_api
|
||||
|
||||
def init(self, deps: dict = None):
|
||||
if self.webui:
|
||||
Log.info("log-terminal", "已获取 WebUI 引用")
|
||||
|
||||
# 注册日志查看页面
|
||||
self.webui.register_page(
|
||||
path='/logs',
|
||||
content_provider=self._render_logs,
|
||||
nav_item={'icon': 'ri-file-list-3-line', 'text': '日志'}
|
||||
)
|
||||
|
||||
# 注册终端页面
|
||||
self.webui.register_page(
|
||||
path='/terminal',
|
||||
content_provider=self._render_terminal,
|
||||
nav_item={'icon': 'ri-terminal-box-line', 'text': '终端'}
|
||||
)
|
||||
|
||||
Log.ok("log-terminal", "已注册日志与终端页面到 WebUI 导航")
|
||||
else:
|
||||
Log.warn("log-terminal", "警告: 未找到 WebUI 依赖")
|
||||
|
||||
# 注册 API 路由(通过 http-api)
|
||||
if self.http_api and self.http_api.router:
|
||||
self.http_api.router.get("/api/logs/get", self._handle_get_logs)
|
||||
self.http_api.router.post("/api/terminal/connect", self._handle_connect_ssh)
|
||||
self.http_api.router.post("/api/terminal/send", self._handle_send_command)
|
||||
self.http_api.router.post("/api/terminal/disconnect", self._handle_disconnect_ssh)
|
||||
self.http_api.router.get("/api/terminal/sessions", self._handle_list_sessions)
|
||||
Log.ok("log-terminal", "已注册 API 路由")
|
||||
else:
|
||||
Log.warn("log-terminal", "警告: 未找到 http-api 依赖")
|
||||
|
||||
def start(self):
|
||||
Log.info("log-terminal", "日志与终端插件启动中...")
|
||||
self._running = True
|
||||
|
||||
# 启动日志同步线程
|
||||
self._log_sync_thread = threading.Thread(target=self._log_sync_worker, daemon=True)
|
||||
self._log_sync_thread.start()
|
||||
|
||||
# 添加初始化日志
|
||||
self.add_log_entry("info", "log-terminal", "日志与终端插件已启动")
|
||||
self.add_log_entry("tip", "log-terminal", "日志查看: /logs | SSH 终端: /terminal")
|
||||
|
||||
# 尝试捕获系统日志输出
|
||||
self._hook_system_log()
|
||||
|
||||
Log.ok("log-terminal", "日志与终端插件已启动")
|
||||
|
||||
def _hook_system_log(self):
|
||||
"""拦截系统日志输出到我们的缓冲区"""
|
||||
try:
|
||||
from oss.logger.logger import Log as SystemLog
|
||||
|
||||
# 保存原始方法
|
||||
original_info = SystemLog.info
|
||||
original_warn = SystemLog.warn
|
||||
original_error = SystemLog.error
|
||||
original_tip = SystemLog.tip
|
||||
original_ok = SystemLog.ok
|
||||
|
||||
# 创建包装方法
|
||||
plugin_instance = self
|
||||
|
||||
@classmethod
|
||||
def wrapped_info(cls, tag: str, msg: str):
|
||||
original_info(tag, msg)
|
||||
plugin_instance.add_log_entry("info", tag, msg)
|
||||
|
||||
@classmethod
|
||||
def wrapped_warn(cls, tag: str, msg: str):
|
||||
original_warn(tag, msg)
|
||||
plugin_instance.add_log_entry("warn", tag, msg)
|
||||
|
||||
@classmethod
|
||||
def wrapped_error(cls, tag: str, msg: str):
|
||||
original_error(tag, msg)
|
||||
plugin_instance.add_log_entry("error", tag, msg)
|
||||
|
||||
@classmethod
|
||||
def wrapped_tip(cls, tag: str, msg: str):
|
||||
original_tip(tag, msg)
|
||||
plugin_instance.add_log_entry("tip", tag, msg)
|
||||
|
||||
@classmethod
|
||||
def wrapped_ok(cls, tag: str, msg: str):
|
||||
original_ok(tag, msg)
|
||||
plugin_instance.add_log_entry("ok", tag, msg)
|
||||
|
||||
# 替换方法(注意:这只影响未来的调用)
|
||||
SystemLog.info = wrapped_info
|
||||
SystemLog.warn = wrapped_warn
|
||||
SystemLog.error = wrapped_error
|
||||
SystemLog.tip = wrapped_tip
|
||||
SystemLog.ok = wrapped_ok
|
||||
|
||||
Log.info("log-terminal", "系统日志拦截器已安装")
|
||||
except Exception as e:
|
||||
Log.warn("log-terminal", f"无法拦截系统日志: {e}")
|
||||
|
||||
def stop(self):
|
||||
Log.info("log-terminal", "日志与终端插件停止中...")
|
||||
self._running = False
|
||||
|
||||
# 关闭所有 SSH 会话
|
||||
for session_id, session in list(self._ssh_sessions.items()):
|
||||
try:
|
||||
if 'process' in session:
|
||||
session['process'].terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._ssh_sessions.clear()
|
||||
Log.ok("log-terminal", "日志与终端插件已停止")
|
||||
|
||||
def _log_sync_worker(self):
|
||||
"""日志同步工作线程 - 持续捕获项目日志"""
|
||||
try:
|
||||
# 尝试从多个位置读取日志
|
||||
log_files = [
|
||||
'/var/log/syslog',
|
||||
'/var/log/messages',
|
||||
os.path.join(os.path.dirname(__file__), '..', '..', 'data', 'system.log'),
|
||||
]
|
||||
|
||||
last_positions = {}
|
||||
|
||||
while self._running:
|
||||
# 检查日志文件
|
||||
for log_file in log_files:
|
||||
if os.path.exists(log_file) and os.path.isfile(log_file):
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
# 获取文件位置
|
||||
if log_file not in last_positions:
|
||||
# 首次读取,跳到文件末尾
|
||||
f.seek(0, 2) # 2 = SEEK_END
|
||||
last_positions[log_file] = f.tell()
|
||||
else:
|
||||
f.seek(last_positions[log_file])
|
||||
|
||||
# 读取新行
|
||||
lines = f.readlines()
|
||||
if lines:
|
||||
last_positions[log_file] = f.tell()
|
||||
for line in lines[-50:]: # 每次最多读取50行
|
||||
line = line.strip()
|
||||
if line:
|
||||
self.add_log_entry("info", "system", line)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# 等待下一次同步
|
||||
time.sleep(2)
|
||||
|
||||
except Exception as e:
|
||||
Log.error("log-terminal", f"日志同步线程异常: {e}")
|
||||
|
||||
def add_log_entry(self, level: str, tag: str, message: str):
|
||||
"""向日志缓冲区添加日志条目"""
|
||||
import time
|
||||
timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||
entry = {
|
||||
'timestamp': timestamp,
|
||||
'level': level,
|
||||
'tag': tag,
|
||||
'message': message
|
||||
}
|
||||
with self._log_lock:
|
||||
self._log_buffer.append(entry)
|
||||
# 限制日志缓冲区大小
|
||||
if len(self._log_buffer) > 10000:
|
||||
self._log_buffer = self._log_buffer[-5000:]
|
||||
|
||||
def _get_logs(self, limit=100):
|
||||
"""获取日志列表"""
|
||||
with self._log_lock:
|
||||
return self._log_buffer[-limit:]
|
||||
|
||||
def _check_ssh_installed(self):
|
||||
"""检查 SSH 是否已安装"""
|
||||
try:
|
||||
result = subprocess.run(['which', 'sshd'], capture_output=True, text=True, timeout=5)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _install_ssh(self):
|
||||
"""自动安装 SSH 服务"""
|
||||
try:
|
||||
Log.info("log-terminal", "正在安装 SSH 服务...")
|
||||
# 检测包管理器
|
||||
for pkg_manager in ['apt-get', 'yum', 'dnf', 'pacman']:
|
||||
result = subprocess.run(['which', pkg_manager], capture_output=True, timeout=3)
|
||||
if result.returncode == 0:
|
||||
if pkg_manager == 'apt-get':
|
||||
subprocess.run([pkg_manager, 'update'], capture_output=True, timeout=30)
|
||||
result = subprocess.run(
|
||||
[pkg_manager, 'install', '-y', 'openssh-server'],
|
||||
capture_output=True, text=True, timeout=120
|
||||
)
|
||||
elif pkg_manager in ['yum', 'dnf']:
|
||||
result = subprocess.run(
|
||||
[pkg_manager, 'install', '-y', 'openssh-server'],
|
||||
capture_output=True, text=True, timeout=120
|
||||
)
|
||||
elif pkg_manager == 'pacman':
|
||||
result = subprocess.run(
|
||||
[pkg_manager, '-S', '--noconfirm', 'openssh'],
|
||||
capture_output=True, text=True, timeout=120
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
Log.ok("log-terminal", "SSH 服务安装成功")
|
||||
return True
|
||||
else:
|
||||
Log.error("log-terminal", f"SSH 服务安装失败: {result.stderr}")
|
||||
return False
|
||||
|
||||
Log.error("log-terminal", "未找到支持的包管理器")
|
||||
return False
|
||||
except Exception as e:
|
||||
Log.error("log-terminal", f"安装 SSH 服务时出错: {e}")
|
||||
return False
|
||||
|
||||
def _start_ssh_server(self, port=8022):
|
||||
"""启动 SSH 服务器"""
|
||||
try:
|
||||
# 检查 SSH 服务器是否已在运行
|
||||
result = subprocess.run(['pgrep', '-f', 'sshd'], capture_output=True, timeout=3)
|
||||
if result.returncode == 0:
|
||||
Log.tip("log-terminal", "SSH 服务器已在运行")
|
||||
return True
|
||||
|
||||
# 启动 SSH 服务器
|
||||
Log.info("log-terminal", f"正在启动 SSH 服务器 (端口: {port})...")
|
||||
subprocess.run(['sshd', '-p', str(port)], capture_output=True, timeout=10)
|
||||
|
||||
# 验证是否启动成功
|
||||
time.sleep(1)
|
||||
result = subprocess.run(['pgrep', '-f', f'sshd.*{port}'], capture_output=True, timeout=3)
|
||||
if result.returncode == 0:
|
||||
Log.ok("log-terminal", f"SSH 服务器已启动 (端口: {port})")
|
||||
return True
|
||||
else:
|
||||
Log.error("log-terminal", "SSH 服务器启动失败")
|
||||
return False
|
||||
except Exception as e:
|
||||
Log.error("log-terminal", f"启动 SSH 服务器时出错: {e}")
|
||||
return False
|
||||
|
||||
def _handle_connect_ssh(self, request):
|
||||
"""处理 SSH 连接请求"""
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
port = body.get('port', 8022)
|
||||
auto_install = body.get('auto_install', True)
|
||||
|
||||
# 检查 SSH 是否已安装
|
||||
if not self._check_ssh_installed():
|
||||
if auto_install:
|
||||
if not self._install_ssh():
|
||||
return Response(
|
||||
status=500,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': 'SSH 安装失败'})
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
status=400,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': 'SSH 未安装,请先安装 SSH 服务'})
|
||||
)
|
||||
|
||||
# 启动 SSH 服务器
|
||||
if not self._start_ssh_server(port):
|
||||
return Response(
|
||||
status=500,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': 'SSH 服务器启动失败'})
|
||||
)
|
||||
|
||||
# 创建新的终端会话 (使用 script 命令创建伪终端)
|
||||
self._session_counter += 1
|
||||
session_id = self._session_counter
|
||||
|
||||
try:
|
||||
# 使用 script 命令创建交互式终端
|
||||
process = subprocess.Popen(
|
||||
['script', '-q', '-c', '/bin/bash', '/dev/null'],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
self._ssh_sessions[session_id] = {
|
||||
'process': process,
|
||||
'created_at': time.time(),
|
||||
'port': port
|
||||
}
|
||||
|
||||
Log.info("log-terminal", f"SSH 终端会话 #{session_id} 已创建")
|
||||
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({
|
||||
'success': True,
|
||||
'session_id': session_id,
|
||||
'message': 'SSH 终端已连接'
|
||||
})
|
||||
)
|
||||
except Exception as e:
|
||||
Log.error("log-terminal", f"创建终端会话失败: {e}")
|
||||
return Response(
|
||||
status=500,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': str(e)})
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
Log.error("log-terminal", f"SSH 连接请求异常: {e}")
|
||||
return Response(
|
||||
status=500,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': str(e)})
|
||||
)
|
||||
|
||||
def _handle_send_command(self, request):
|
||||
"""处理发送命令到终端"""
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
session_id = body.get('session_id')
|
||||
command = body.get('command', '')
|
||||
|
||||
if session_id not in self._ssh_sessions:
|
||||
return Response(
|
||||
status=400,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': '会话不存在'})
|
||||
)
|
||||
|
||||
session = self._ssh_sessions[session_id]
|
||||
process = session['process']
|
||||
|
||||
# 发送命令
|
||||
process.stdin.write(command + '\n')
|
||||
process.stdin.flush()
|
||||
|
||||
# 读取输出
|
||||
time.sleep(0.5) # 等待命令执行
|
||||
output = ""
|
||||
try:
|
||||
while True:
|
||||
line = process.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
output += line
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({
|
||||
'success': True,
|
||||
'output': output
|
||||
})
|
||||
)
|
||||
except Exception as e:
|
||||
Log.error("log-terminal", f"发送命令时出错: {e}")
|
||||
return Response(
|
||||
status=500,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': str(e)})
|
||||
)
|
||||
|
||||
def _handle_disconnect_ssh(self, request):
|
||||
"""处理断开 SSH 连接"""
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
session_id = body.get('session_id')
|
||||
|
||||
if session_id in self._ssh_sessions:
|
||||
session = self._ssh_sessions[session_id]
|
||||
try:
|
||||
session['process'].terminate()
|
||||
except Exception:
|
||||
pass
|
||||
del self._ssh_sessions[session_id]
|
||||
Log.info("log-terminal", f"SSH 终端会话 #{session_id} 已断开")
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': True, 'message': '已断开连接'})
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
status=400,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': '会话不存在'})
|
||||
)
|
||||
except Exception as e:
|
||||
Log.error("log-terminal", f"断开连接时出错: {e}")
|
||||
return Response(
|
||||
status=500,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': str(e)})
|
||||
)
|
||||
|
||||
def _handle_list_sessions(self, request):
|
||||
"""列出所有 SSH 会话"""
|
||||
try:
|
||||
sessions = []
|
||||
for session_id, session in self._ssh_sessions.items():
|
||||
sessions.append({
|
||||
'session_id': session_id,
|
||||
'port': session['port'],
|
||||
'created_at': session['created_at'],
|
||||
'uptime': time.time() - session['created_at']
|
||||
})
|
||||
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': True, 'sessions': sessions})
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
status=500,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': str(e)})
|
||||
)
|
||||
|
||||
def _handle_get_logs(self, request):
|
||||
"""获取日志"""
|
||||
try:
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
# 解析路径中的查询参数
|
||||
parsed = urlparse(request.path)
|
||||
params = parse_qs(parsed.query)
|
||||
limit = int(params.get('limit', [100])[0])
|
||||
source = params.get('source', ['buffer'])[0] # buffer 或 file
|
||||
|
||||
logs = []
|
||||
|
||||
if source == 'buffer':
|
||||
# 从内存缓冲区获取
|
||||
logs = self._get_logs(limit)
|
||||
else:
|
||||
# 从系统日志文件获取
|
||||
logs = self._read_system_logs(limit)
|
||||
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': True, 'logs': logs})
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
status=500,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': False, 'error': str(e)})
|
||||
)
|
||||
|
||||
def _read_system_logs(self, limit=100):
|
||||
"""从系统日志文件读取日志"""
|
||||
logs = []
|
||||
log_files = [
|
||||
'/var/log/syslog',
|
||||
'/var/log/messages',
|
||||
'/var/log/kern.log',
|
||||
]
|
||||
|
||||
for log_file in log_files:
|
||||
if os.path.exists(log_file):
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
lines = f.readlines()
|
||||
for line in lines[-limit:]:
|
||||
line = line.strip()
|
||||
if line:
|
||||
# 尝试解析 syslog 格式
|
||||
# 格式: "Apr 12 10:30:45 hostname service[pid]: message"
|
||||
import re
|
||||
match = re.match(r'(\w+\s+\d+\s+\d+:\d+:\d+)\s+(\S+)\s+(\S+?)(?:\[\d+\])?:\s+(.*)', line)
|
||||
if match:
|
||||
logs.append({
|
||||
'timestamp': match.group(1),
|
||||
'level': 'info',
|
||||
'tag': match.group(3),
|
||||
'message': match.group(4)
|
||||
})
|
||||
else:
|
||||
logs.append({
|
||||
'timestamp': time.strftime('%b %d %H:%M:%S'),
|
||||
'level': 'info',
|
||||
'tag': 'system',
|
||||
'message': line
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return logs[-limit:]
|
||||
|
||||
def _render_logs(self) -> str:
|
||||
"""渲染日志查看界面"""
|
||||
try:
|
||||
php_file = os.path.join(self.views_dir, 'logs.php')
|
||||
if not os.path.exists(php_file):
|
||||
return "<p>日志视图文件丢失</p>"
|
||||
return self._execute_php(php_file, {})
|
||||
except Exception as e:
|
||||
return f"<p>日志视图渲染出错: {e}</p>"
|
||||
|
||||
def _render_terminal(self) -> str:
|
||||
"""渲染终端界面"""
|
||||
try:
|
||||
php_file = os.path.join(self.views_dir, 'terminal.php')
|
||||
if not os.path.exists(php_file):
|
||||
return "<p>终端视图文件丢失</p>"
|
||||
return self._execute_php(php_file, {})
|
||||
except Exception as e:
|
||||
return f"<p>终端视图渲染出错: {e}</p>"
|
||||
|
||||
def _execute_php(self, php_file: str, variables: dict) -> str:
|
||||
"""执行 PHP 文件"""
|
||||
php_vars = ""
|
||||
for key, value in variables.items():
|
||||
if isinstance(value, str):
|
||||
escaped = value.replace('\\', '\\\\').replace("'", "\\'").replace("\n", "\\n")
|
||||
php_vars += f"${key} = '{escaped}';\n"
|
||||
else:
|
||||
php_vars += f"${key} = {value};\n"
|
||||
|
||||
with open(php_file, 'r', encoding='utf-8') as f:
|
||||
php_content = f.read()
|
||||
|
||||
tmp_file = os.path.join(os.path.dirname(php_file), '.temp_lt.php')
|
||||
try:
|
||||
with open(tmp_file, 'w', encoding='utf-8') as f:
|
||||
f.write(f"<?php\n{php_vars}\n?>\n{php_content}")
|
||||
result = subprocess.run(
|
||||
["php", "-f", tmp_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
return result.stdout if result.returncode == 0 else f"<pre>{result.stderr}</pre>"
|
||||
finally:
|
||||
try:
|
||||
if os.path.exists(tmp_file):
|
||||
os.unlink(tmp_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
register_plugin_type("LogTerminalPlugin", LogTerminalPlugin)
|
||||
|
||||
|
||||
def New():
|
||||
return LogTerminalPlugin()
|
||||
15
store/@{FutureOSS}/log-terminal/manifest.json
Normal file
15
store/@{FutureOSS}/log-terminal/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "log-terminal",
|
||||
"version": "1.0.0",
|
||||
"author": "FutureOSS",
|
||||
"description": "日志查看器与 SSH 终端",
|
||||
"type": "webui-extension"
|
||||
},
|
||||
"config": {
|
||||
"enabled": true,
|
||||
"args": {}
|
||||
},
|
||||
"dependencies": ["http-api", "webui"],
|
||||
"permissions": ["*"]
|
||||
}
|
||||
217
store/@{FutureOSS}/log-terminal/views/logs.php
Normal file
217
store/@{FutureOSS}/log-terminal/views/logs.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
.log-container { max-width: 1400px; margin: 0 auto; padding: 20px; height: calc(100vh - 100px); display: flex; flex-direction: column; }
|
||||
.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.log-title { font-size: 18px; font-weight: 600; color: #00bcd4; display: flex; align-items: center; gap: 10px; }
|
||||
.log-title i { font-size: 24px; }
|
||||
.live-indicator { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; background: #064e3b; border-radius: 12px; font-size: 12px; color: #34d399; }
|
||||
.live-dot { width: 8px; height: 8px; background: #34d399; border-radius: 50%; animation: pulse 2s infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(1.3); } }
|
||||
|
||||
.log-controls { display: flex; gap: 10px; align-items: center; }
|
||||
.log-btn { padding: 6px 14px; background: #3b82f6; border: none; border-radius: 6px; color: white; cursor: pointer; font-size: 13px; transition: all 0.2s; display: flex; align-items: center; gap: 6px; }
|
||||
.log-btn:hover { background: #2563eb; }
|
||||
.log-btn.paused { background: #f59e0b; }
|
||||
.log-btn.paused:hover { background: #d97706; }
|
||||
|
||||
.log-filters { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.filter-btn { padding: 4px 12px; border-radius: 16px; font-size: 12px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; transition: all 0.2s; }
|
||||
.filter-btn:hover { background: #334155; }
|
||||
.filter-btn.active { background: #3b82f6; border-color: #3b82f6; color: white; }
|
||||
|
||||
.log-content { flex: 1; overflow-y: auto; background: #0f172a; border-radius: 10px; padding: 16px; font-family: 'Courier New', monospace; font-size: 13px; line-height: 1.6; }
|
||||
.log-content::-webkit-scrollbar { width: 8px; }
|
||||
.log-content::-webkit-scrollbar-track { background: #1e293b; border-radius: 4px; }
|
||||
.log-content::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; }
|
||||
.log-content::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
||||
|
||||
.log-entry { padding: 4px 0; border-bottom: 1px solid #1e293b; animation: fadeIn 0.3s ease-in; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.log-entry:last-child { border-bottom: none; }
|
||||
|
||||
.log-timestamp { color: #64748b; margin-right: 8px; }
|
||||
.log-level { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; margin-right: 8px; display: inline-block; min-width: 50px; text-align: center; }
|
||||
.log-tag { color: #3b82f6; margin-right: 8px; font-weight: 500; }
|
||||
.log-message { color: #e2e8f0; }
|
||||
|
||||
.log-level.info { background: #1e3a8a; color: #60a5fa; }
|
||||
.log-level.ok { background: #064e3b; color: #34d399; }
|
||||
.log-level.warn { background: #78350f; color: #fbbf24; }
|
||||
.log-level.error { background: #7f1d1d; color: #f87171; }
|
||||
.log-level.tip { background: #1e3a5f; color: #38bdf8; }
|
||||
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: #64748b; }
|
||||
.empty-state i { font-size: 64px; margin-bottom: 16px; opacity: 0.3; }
|
||||
.empty-state p { font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="log-container">
|
||||
<div class="log-header">
|
||||
<div class="log-title">
|
||||
<i class="ri-file-list-3-line"></i>
|
||||
<span>系统日志</span>
|
||||
<span class="live-indicator" id="live-indicator">
|
||||
<span class="live-dot"></span>
|
||||
实时同步
|
||||
</span>
|
||||
</div>
|
||||
<div class="log-controls">
|
||||
<button class="log-btn" id="clear-btn" onclick="clearLogs()">
|
||||
<i class="ri-delete-bin-line"></i>
|
||||
清空
|
||||
</button>
|
||||
<button class="log-btn" id="pause-btn" onclick="togglePause()">
|
||||
<i class="ri-pause-line" id="pause-icon"></i>
|
||||
<span id="pause-text">暂停</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-filters">
|
||||
<button class="filter-btn active" data-level="all" onclick="setFilter('all')">全部</button>
|
||||
<button class="filter-btn" data-level="info" onclick="setFilter('info')">信息</button>
|
||||
<button class="filter-btn" data-level="ok" onclick="setFilter('ok')">成功</button>
|
||||
<button class="filter-btn" data-level="warn" onclick="setFilter('warn')">警告</button>
|
||||
<button class="filter-btn" data-level="error" onclick="setFilter('error')">错误</button>
|
||||
<button class="filter-btn" data-level="tip" onclick="setFilter('tip')">提示</button>
|
||||
</div>
|
||||
|
||||
<div class="log-content" id="log-content">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<i class="ri-file-list-3-line"></i>
|
||||
<p>正在加载日志...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let isPaused = false;
|
||||
let currentFilter = 'all';
|
||||
let syncInterval = null;
|
||||
|
||||
function setFilter(level) {
|
||||
currentFilter = level;
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
if (btn.dataset.level === level) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
filterLogs();
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
isPaused = !isPaused;
|
||||
const pauseBtn = document.getElementById('pause-btn');
|
||||
const pauseIcon = document.getElementById('pause-icon');
|
||||
const pauseText = document.getElementById('pause-text');
|
||||
const indicator = document.getElementById('live-indicator');
|
||||
|
||||
if (isPaused) {
|
||||
pauseBtn.classList.add('paused');
|
||||
pauseIcon.className = 'ri-play-line';
|
||||
pauseText.textContent = '继续';
|
||||
indicator.style.opacity = '0.5';
|
||||
} else {
|
||||
pauseBtn.classList.remove('paused');
|
||||
pauseIcon.className = 'ri-pause-line';
|
||||
pauseText.textContent = '暂停';
|
||||
indicator.style.opacity = '1';
|
||||
fetchLogs();
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
const content = document.getElementById('log-content');
|
||||
content.innerHTML = '<div class="empty-state"><i class="ri-file-list-3-line"></i><p>日志已清空</p></div>';
|
||||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
if (isPaused) return;
|
||||
|
||||
try {
|
||||
// 先尝试从缓冲区获取
|
||||
const response = await fetch('/api/logs/get?limit=100&source=buffer');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// 如果缓冲区为空,尝试从系统日志读取
|
||||
if (!data.logs || data.logs.length === 0) {
|
||||
const fileResponse = await fetch('/api/logs/get?limit=100&source=file');
|
||||
const fileData = await fileResponse.json();
|
||||
|
||||
if (fileData.success) {
|
||||
renderLogs(fileData.logs || []);
|
||||
}
|
||||
} else {
|
||||
renderLogs(data.logs);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取日志失败:', error);
|
||||
// 错误时也要显示状态
|
||||
const content = document.getElementById('log-content');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
emptyState.style.display = 'block';
|
||||
emptyState.innerHTML = '<i class="ri-error-warning-line"></i><p>获取日志失败</p><p style="font-size: 12px; margin-top: 8px; opacity: 0.7;">' + error.message + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderLogs(logs) {
|
||||
const content = document.getElementById('log-content');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
|
||||
if (logs.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
emptyState.innerHTML = '<i class="ri-file-list-3-line"></i><p>暂无日志</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
const filteredLogs = currentFilter === 'all'
|
||||
? logs
|
||||
: logs.filter(log => log.level === currentFilter);
|
||||
|
||||
const html = filteredLogs.map(log => `
|
||||
<div class="log-entry" data-level="${log.level}">
|
||||
<span class="log-timestamp">${log.timestamp}</span>
|
||||
<span class="log-level ${log.level}">${log.level.toUpperCase()}</span>
|
||||
<span class="log-tag">[${log.tag}]</span>
|
||||
<span class="log-message">${escapeHtml(log.message)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
content.innerHTML = html;
|
||||
content.scrollTop = content.scrollHeight;
|
||||
}
|
||||
|
||||
function filterLogs() {
|
||||
const entries = document.querySelectorAll('.log-entry');
|
||||
entries.forEach(entry => {
|
||||
if (currentFilter === 'all' || entry.dataset.level === currentFilter) {
|
||||
entry.style.display = 'block';
|
||||
} else {
|
||||
entry.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchLogs();
|
||||
syncInterval = setInterval(fetchLogs, 2000); // 每2秒同步一次
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
288
store/@{FutureOSS}/log-terminal/views/terminal.php
Normal file
288
store/@{FutureOSS}/log-terminal/views/terminal.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
.terminal-container { max-width: 1400px; margin: 0 auto; padding: 20px; height: calc(100vh - 100px); display: flex; flex-direction: column; }
|
||||
.terminal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.terminal-title { font-size: 18px; font-weight: 600; color: #00bcd4; display: flex; align-items: center; gap: 10px; }
|
||||
.terminal-title i { font-size: 24px; }
|
||||
|
||||
.terminal-controls { display: flex; gap: 10px; }
|
||||
.term-btn { padding: 6px 14px; background: #3b82f6; border: none; border-radius: 6px; color: white; cursor: pointer; font-size: 13px; transition: all 0.2s; display: flex; align-items: center; gap: 6px; }
|
||||
.term-btn:hover { background: #2563eb; }
|
||||
.term-btn.connecting { background: #f59e0b; cursor: not-allowed; }
|
||||
.term-btn.disconnect { background: #ef4444; }
|
||||
.term-btn.disconnect:hover { background: #dc2626; }
|
||||
|
||||
.terminal-status { display: flex; align-items: center; gap: 8px; padding: 4px 12px; background: #1e293b; border-radius: 12px; font-size: 12px; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.status-dot.connected { background: #34d399; animation: pulse 2s infinite; }
|
||||
.status-dot.disconnected { background: #f87171; }
|
||||
.status-dot.connecting { background: #fbbf24; animation: pulse 1s infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(1.3); } }
|
||||
|
||||
.terminal-wrapper { flex: 1; background: #0f172a; border-radius: 10px; padding: 16px; display: flex; flex-direction: column; }
|
||||
.terminal-info { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #1e293b; margin-bottom: 12px; }
|
||||
.info-item { font-size: 12px; color: #94a3b8; display: flex; align-items: center; gap: 6px; }
|
||||
.info-item i { color: #3b82f6; }
|
||||
|
||||
.terminal-output { flex: 1; overflow-y: auto; font-family: 'Courier New', monospace; font-size: 13px; line-height: 1.6; color: #e2e8f0; margin-bottom: 12px; }
|
||||
.terminal-output::-webkit-scrollbar { width: 8px; }
|
||||
.terminal-output::-webkit-scrollbar-track { background: #1e293b; border-radius: 4px; }
|
||||
.terminal-output::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; }
|
||||
.terminal-output::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
||||
|
||||
.terminal-line { padding: 2px 0; }
|
||||
.terminal-line.command { color: #34d399; }
|
||||
.terminal-line.output { color: #e2e8f0; }
|
||||
.terminal-line.error { color: #f87171; }
|
||||
.terminal-line.info { color: #60a5fa; }
|
||||
.terminal-line.success { color: #34d399; }
|
||||
.terminal-line.warning { color: #fbbf24; }
|
||||
|
||||
.terminal-input-wrapper { display: flex; gap: 8px; align-items: center; padding: 8px; background: #1e293b; border-radius: 6px; }
|
||||
.terminal-prompt { color: #34d399; font-weight: 600; white-space: nowrap; }
|
||||
.terminal-input { flex: 1; background: transparent; border: none; color: #e2e8f0; font-family: 'Courier New', monospace; font-size: 13px; outline: none; }
|
||||
.terminal-input::placeholder { color: #64748b; }
|
||||
|
||||
.ssh-config { background: #1e293b; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
||||
.config-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||||
.config-row:last-child { margin-bottom: 0; }
|
||||
.config-label { font-size: 13px; color: #94a3b8; min-width: 100px; }
|
||||
.config-input { flex: 1; padding: 6px 12px; background: #0f172a; border: 1px solid #334155; border-radius: 6px; color: #e2e8f0; font-size: 13px; }
|
||||
.config-input:focus { outline: none; border-color: #3b82f6; }
|
||||
.config-checkbox { display: flex; align-items: center; gap: 8px; color: #e2e8f0; font-size: 13px; cursor: pointer; }
|
||||
.config-checkbox input[type="checkbox"] { cursor: pointer; }
|
||||
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: #64748b; }
|
||||
.empty-state i { font-size: 64px; margin-bottom: 16px; opacity: 0.3; }
|
||||
.empty-state p { font-size: 14px; margin-top: 8px; }
|
||||
|
||||
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="terminal-container">
|
||||
<div class="terminal-header">
|
||||
<div class="terminal-title">
|
||||
<i class="ri-terminal-box-line"></i>
|
||||
<span>SSH 终端</span>
|
||||
</div>
|
||||
<div class="terminal-controls">
|
||||
<div class="terminal-status">
|
||||
<span class="status-dot disconnected" id="status-dot"></span>
|
||||
<span id="status-text">未连接</span>
|
||||
</div>
|
||||
<button class="term-btn" id="connect-btn" onclick="connectSSH()">
|
||||
<i class="ri-plug-line"></i>
|
||||
连接
|
||||
</button>
|
||||
<button class="term-btn disconnect" id="disconnect-btn" onclick="disconnectSSH()" style="display: none;">
|
||||
<i class="ri-close-line"></i>
|
||||
断开
|
||||
</button>
|
||||
<button class="term-btn" id="clear-btn" onclick="clearTerminal()">
|
||||
<i class="ri-delete-bin-line"></i>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ssh-config" id="ssh-config">
|
||||
<div class="config-row">
|
||||
<span class="config-label"><i class="ri-settings-3-line"></i> SSH 端口:</span>
|
||||
<input type="number" class="config-input" id="ssh-port" value="8022" min="1" max="65535">
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label class="config-checkbox">
|
||||
<input type="checkbox" id="auto-install" checked>
|
||||
自动安装 SSH 服务
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="terminal-wrapper">
|
||||
<div class="terminal-info">
|
||||
<div class="info-item">
|
||||
<i class="ri-server-line"></i>
|
||||
<span>端口: <strong id="info-port">8022</strong></span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<i class="ri-time-line"></i>
|
||||
<span>运行时间: <strong id="info-uptime">-</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="terminal-output" id="terminal-output">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<i class="ri-terminal-box-line"></i>
|
||||
<p>点击"连接"按钮开始 SSH 终端会话</p>
|
||||
<p style="font-size: 12px; margin-top: 8px; opacity: 0.7;">支持自动安装 SSH 服务</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="terminal-input-wrapper" id="input-wrapper" style="display: none;">
|
||||
<span class="terminal-prompt">$</span>
|
||||
<input type="text" class="terminal-input" id="terminal-input" placeholder="输入命令..." onkeypress="handleKeyPress(event)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let sessionId = null;
|
||||
let isConnected = false;
|
||||
|
||||
function updateStatus(status) {
|
||||
const dot = document.getElementById('status-dot');
|
||||
const text = document.getElementById('status-text');
|
||||
const connectBtn = document.getElementById('connect-btn');
|
||||
const disconnectBtn = document.getElementById('disconnect-btn');
|
||||
const inputWrapper = document.getElementById('input-wrapper');
|
||||
const sshConfig = document.getElementById('ssh-config');
|
||||
|
||||
dot.className = 'status-dot ' + status;
|
||||
|
||||
if (status === 'connected') {
|
||||
text.textContent = '已连接';
|
||||
connectBtn.style.display = 'none';
|
||||
disconnectBtn.style.display = 'flex';
|
||||
inputWrapper.style.display = 'flex';
|
||||
sshConfig.style.display = 'none';
|
||||
isConnected = true;
|
||||
} else if (status === 'connecting') {
|
||||
text.textContent = '连接中...';
|
||||
connectBtn.classList.add('connecting');
|
||||
connectBtn.innerHTML = '<span class="spinner"></span> 连接中';
|
||||
} else {
|
||||
text.textContent = '未连接';
|
||||
connectBtn.style.display = 'flex';
|
||||
connectBtn.classList.remove('connecting');
|
||||
connectBtn.innerHTML = '<i class="ri-plug-line"></i> 连接';
|
||||
disconnectBtn.style.display = 'none';
|
||||
inputWrapper.style.display = 'none';
|
||||
sshConfig.style.display = 'block';
|
||||
isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function connectSSH() {
|
||||
const port = document.getElementById('ssh-port').value;
|
||||
const autoInstall = document.getElementById('auto-install').checked;
|
||||
|
||||
updateStatus('connecting');
|
||||
appendLine('info', '正在初始化 SSH 连接...');
|
||||
appendLine('info', `目标端口: ${port}`);
|
||||
|
||||
if (autoInstall) {
|
||||
appendLine('info', '自动安装 SSH: 已启用');
|
||||
appendLine('tip', '智能检测 SSH 服务状态...');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/terminal/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ port: parseInt(port), auto_install: autoInstall })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
sessionId = data.session_id;
|
||||
document.getElementById('info-port').textContent = port;
|
||||
document.getElementById('info-uptime').textContent = '刚刚';
|
||||
updateStatus('connected');
|
||||
appendLine('success', `✓ SSH 终端已连接 (会话 #${sessionId})`);
|
||||
appendLine('output', '输入命令开始操作...');
|
||||
appendLine('output', '');
|
||||
document.getElementById('terminal-input').focus();
|
||||
} else {
|
||||
updateStatus('disconnected');
|
||||
appendLine('error', `✗ 连接失败: ${data.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
updateStatus('disconnected');
|
||||
appendLine('error', `✗ 连接异常: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectSSH() {
|
||||
if (!sessionId) return;
|
||||
|
||||
try {
|
||||
await fetch('/api/terminal/disconnect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId })
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('断开连接失败:', error);
|
||||
}
|
||||
|
||||
sessionId = null;
|
||||
updateStatus('disconnected');
|
||||
appendLine('warning', 'SSH 终端已断开');
|
||||
}
|
||||
|
||||
async function sendCommand(command) {
|
||||
if (!sessionId || !command.trim()) return;
|
||||
|
||||
appendLine('command', `$ ${command}`);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/terminal/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId, command: command })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.output) {
|
||||
const lines = data.output.split('\n');
|
||||
lines.forEach(line => {
|
||||
if (line.trim()) {
|
||||
appendLine('output', line);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
appendLine('error', `执行命令失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyPress(event) {
|
||||
if (event.key === 'Enter') {
|
||||
const input = document.getElementById('terminal-input');
|
||||
const command = input.value.trim();
|
||||
if (command) {
|
||||
sendCommand(command);
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendLine(type, text) {
|
||||
const output = document.getElementById('terminal-output');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
|
||||
if (emptyState) {
|
||||
emptyState.style.display = 'none';
|
||||
}
|
||||
|
||||
const line = document.createElement('div');
|
||||
line.className = `terminal-line ${type}`;
|
||||
line.textContent = text;
|
||||
output.appendChild(line);
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
function clearTerminal() {
|
||||
const output = document.getElementById('terminal-output');
|
||||
output.innerHTML = '<div class="empty-state"><i class="ri-terminal-box-line"></i><p>终端已清空</p></div>';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user