feat: WFJ Panel v0.1.0 - 完整功能版
- SSH 服务器连接管理 + 登录页自动预填 - 服务器概览监控(CPU/内存/硬盘/网络/进程,进程卡片支持排序和端口显示) - 远程文件管理(浏览/上传/下载/新建/删除,实时进度条) - 交互式终端(xterm.js + ssh2 PTY) - Docker 容器管理(三连接架构,概览/容器列表/镜像/应用商店/任务中心/配置/网络/卷) - 应用商店一键部署(Redis/MySQL/Halo,在线模板同步,数据驱动 options/showWhen/extraArgs) - Docker 镜像源配置(daemon.json 编辑 + 自动重启) - Docker 强制停止修复(三步强制停止 + 轮询确认) - 系统防火墙管理(firewalld/ufw/iptables 自动检测 + GUI 管理) - 客户端版本检查(/api/version 接口 + 更新弹窗) - 品牌名统一为 WFJ Panel - electron-builder 打包配置(NSIS 安装包,国内镜像) - .npmrc 配置 Electron 国内镜像
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
node_modules/
|
||||
out/
|
||||
dist/
|
||||
release/
|
||||
.temp/
|
||||
.idea/
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
COPY app.py .
|
||||
|
||||
ENV DB_HOST=127.0.0.1
|
||||
ENV DB_PORT=3306
|
||||
ENV DB_USER=root
|
||||
ENV DB_PASSWORD=123456
|
||||
ENV DB_NAME=appstore
|
||||
ENV API_PORT=3001
|
||||
ENV API_TOKEN=123456
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
应用商店 HTTP API 服务
|
||||
从 MySQL 读取应用模板数据,返回 JSON 给 Electron 客户端
|
||||
所有数据库连接参数通过环境变量配置
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from flask import Flask, jsonify, request
|
||||
from flask_cors import CORS
|
||||
import mysql.connector
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# 允许跨域(Electron 渲染进程直接 fetch 时需要)
|
||||
CORS(app)
|
||||
|
||||
# ─── 数据库配置(环境变量) ───
|
||||
DB_HOST = os.environ.get('DB_HOST', '127.0.0.1')
|
||||
DB_PORT = int(os.environ.get('DB_PORT', '3306'))
|
||||
DB_USER = os.environ.get('DB_USER', 'root')
|
||||
DB_PASSWORD = os.environ.get('DB_PASSWORD', '123456')
|
||||
DB_NAME = os.environ.get('DB_NAME', 'appstore')
|
||||
|
||||
# API 监听端口
|
||||
API_PORT = int(os.environ.get('API_PORT', '3001'))
|
||||
|
||||
# API 鉴权 token(可选,客户端通过 Authorization header 传递)
|
||||
API_TOKEN = os.environ.get('API_TOKEN', '')
|
||||
|
||||
|
||||
def get_db():
|
||||
"""获取数据库连接"""
|
||||
return mysql.connector.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
user=DB_USER,
|
||||
password=DB_PASSWORD,
|
||||
database=DB_NAME,
|
||||
charset='utf8mb4'
|
||||
)
|
||||
|
||||
|
||||
def check_token():
|
||||
"""校验 API token,未配置 token 时跳过"""
|
||||
if not API_TOKEN:
|
||||
return True
|
||||
auth = request.headers.get('Authorization', '')
|
||||
return auth == f'Bearer {API_TOKEN}'
|
||||
|
||||
|
||||
def fetch_template_list():
|
||||
"""查询所有模板(列表摘要)"""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
'SELECT id, app_key, name, description, icon, category, '
|
||||
'image, container_name, sort_order, is_active '
|
||||
'FROM app_templates WHERE is_active = 1 ORDER BY sort_order, id'
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
|
||||
def fetch_template_detail(app_key):
|
||||
"""查询单个模板的完整详情(含子表)"""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 主表
|
||||
cursor.execute('SELECT * FROM app_templates WHERE app_key = %s AND is_active = 1', (app_key,))
|
||||
tpl = cursor.fetchone()
|
||||
if not tpl:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
# 子表批量查询
|
||||
sub_tables = ['app_ports', 'app_volumes', 'app_envs', 'app_mkdirs', 'app_write_files', 'app_params']
|
||||
for table in sub_tables:
|
||||
cursor.execute(f'SELECT * FROM {table} WHERE template_id = %s ORDER BY sort_order', (tpl['id'],))
|
||||
rows = cursor.fetchall()
|
||||
# 序列化:去掉 template_id 和 id,JSON 友好
|
||||
cleaned = []
|
||||
for r in rows:
|
||||
item = {k: v for k, v in r.items() if k not in ('id', 'template_id')}
|
||||
cleaned.append(item)
|
||||
# 子表名映射到 deploy 对应字段
|
||||
field_map = {
|
||||
'app_ports': 'ports',
|
||||
'app_volumes': 'volumes',
|
||||
'app_envs': 'env',
|
||||
'app_mkdirs': 'mkdirs',
|
||||
'app_write_files': 'writeFiles',
|
||||
'app_params': 'params'
|
||||
}
|
||||
tpl[field_map[table]] = cleaned
|
||||
|
||||
# 删除不需要的主表字段
|
||||
tpl.pop('id', None)
|
||||
tpl.pop('is_active', None)
|
||||
tpl.pop('sort_order', None)
|
||||
tpl.pop('created_at', None)
|
||||
tpl.pop('updated_at', None)
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return tpl
|
||||
|
||||
|
||||
def fetch_all_templates_full():
|
||||
"""一次性查询所有模板的完整详情"""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 所有主表记录
|
||||
cursor.execute(
|
||||
'SELECT * FROM app_templates WHERE is_active = 1 ORDER BY sort_order, id'
|
||||
)
|
||||
templates = cursor.fetchall()
|
||||
|
||||
sub_tables = ['app_ports', 'app_volumes', 'app_envs', 'app_mkdirs', 'app_write_files', 'app_params']
|
||||
field_map = {
|
||||
'app_ports': 'ports',
|
||||
'app_volumes': 'volumes',
|
||||
'app_envs': 'env',
|
||||
'app_mkdirs': 'mkdirs',
|
||||
'app_write_files': 'writeFiles',
|
||||
'app_params': 'params'
|
||||
}
|
||||
|
||||
for tpl in templates:
|
||||
tpl_id = tpl['id']
|
||||
for table in sub_tables:
|
||||
cursor.execute(
|
||||
f'SELECT * FROM {table} WHERE template_id = %s ORDER BY sort_order',
|
||||
(tpl_id,)
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
cleaned = []
|
||||
for r in rows:
|
||||
item = {k: v for k, v in r.items() if k not in ('id', 'template_id')}
|
||||
cleaned.append(item)
|
||||
tpl[field_map[table]] = cleaned
|
||||
|
||||
# 清理主表多余字段
|
||||
tpl.pop('id', None)
|
||||
tpl.pop('is_active', None)
|
||||
tpl.pop('sort_order', None)
|
||||
tpl.pop('created_at', None)
|
||||
tpl.pop('updated_at', None)
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return templates
|
||||
|
||||
|
||||
# ─── API 路由 ───
|
||||
|
||||
@app.route('/api/apps', methods=['GET'])
|
||||
def list_apps():
|
||||
"""获取所有应用模板列表(完整详情)"""
|
||||
if not check_token():
|
||||
return jsonify({'ok': False, 'error': '未授权'}), 401
|
||||
|
||||
try:
|
||||
templates = fetch_all_templates_full()
|
||||
return jsonify({'ok': True, 'data': templates, 'count': len(templates)})
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/apps/<app_key>', methods=['GET'])
|
||||
def get_app(app_key):
|
||||
"""获取单个应用模板详情"""
|
||||
if not check_token():
|
||||
return jsonify({'ok': False, 'error': '未授权'}), 401
|
||||
|
||||
try:
|
||||
tpl = fetch_template_detail(app_key)
|
||||
if not tpl:
|
||||
return jsonify({'ok': False, 'error': '模板不存在'}), 404
|
||||
return jsonify({'ok': True, 'data': tpl})
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/health', methods=['GET'])
|
||||
def health():
|
||||
"""健康检查"""
|
||||
return jsonify({'ok': True, 'status': 'running'})
|
||||
|
||||
|
||||
@app.route('/api/version', methods=['GET'])
|
||||
def get_version():
|
||||
"""获取客户端最新版本信息"""
|
||||
if not check_token():
|
||||
return jsonify({'ok': False, 'error': '未授权'}), 401
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
'SELECT version, download_url, release_notes, is_latest, '
|
||||
'created_at FROM app_version WHERE is_latest = 1 ORDER BY id DESC LIMIT 1'
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return jsonify({'ok': False, 'error': '暂无版本信息'}), 404
|
||||
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'data': {
|
||||
'version': row['version'],
|
||||
'downloadUrl': row['download_url'],
|
||||
'releaseNotes': row['release_notes'] or '',
|
||||
'isLatest': bool(row['is_latest']),
|
||||
'createdAt': row['created_at'].isoformat() if row['created_at'] else None,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(f'[AppStore API] DB: {DB_HOST}:{DB_PORT}/{DB_NAME}')
|
||||
print(f'[AppStore API] Listen on :{API_PORT}')
|
||||
print(f'[AppStore API] Token auth: {"on" if API_TOKEN else "off"}')
|
||||
app.run(host='0.0.0.0', port=API_PORT, debug=False)
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# 应用商店 API 服务一键部署脚本
|
||||
# 在服务器 192.168.153.153 上执行
|
||||
# 功能:建表 → 构建镜像 → 启动容器
|
||||
# 用法:bash deploy.sh
|
||||
# ============================================================
|
||||
|
||||
set -e
|
||||
|
||||
echo "========== 1/4 创建数据库和表 =========="
|
||||
# 注意:MySQL 容器已在运行,3306 端口映射到宿主机
|
||||
mysql -h 127.0.0.1 -P 3306 -u root -p${MYSQL_ROOT_PASSWORD:-123456} < init.sql
|
||||
echo "数据库和表创建完成"
|
||||
|
||||
echo "========== 2/4 构建 API 镜像 =========="
|
||||
docker build -t appstore-api:latest .
|
||||
|
||||
echo "========== 3/4 停止旧容器(如有) =========="
|
||||
docker rm -f appstore-api 2>/dev/null || true
|
||||
|
||||
echo "========== 4/4 启动 API 容器 =========="
|
||||
docker run -d \
|
||||
--name appstore-api \
|
||||
--restart always \
|
||||
-p 3001:3001 \
|
||||
-e DB_HOST=127.0.0.1 \
|
||||
-e DB_PORT=3306 \
|
||||
-e DB_USER=root \
|
||||
-e DB_PASSWORD=${MYSQL_ROOT_PASSWORD:-123456} \
|
||||
-e DB_NAME=appstore \
|
||||
-e API_PORT=3001 \
|
||||
appstore-api:latest
|
||||
|
||||
echo ""
|
||||
echo "========== 部署完成 =========="
|
||||
echo "API 地址: http://$(hostname -I | awk '{print $1}'):3001/api/apps"
|
||||
echo "健康检查: http://$(hostname -I | awk '{print $1}'):3001/api/health"
|
||||
@@ -0,0 +1,290 @@
|
||||
-- ============================================================
|
||||
-- 应用商店数据库 —— 建表 + 初始数据
|
||||
-- 数据库: my_panel
|
||||
-- 字符集: utf8mb4
|
||||
-- ============================================================
|
||||
|
||||
-- ─── 主表 ───
|
||||
CREATE TABLE IF NOT EXISTS app_templates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
app_key VARCHAR(64) NOT NULL UNIQUE COMMENT '唯一标识 (redis / mysql)',
|
||||
name VARCHAR(128) NOT NULL COMMENT '显示名称',
|
||||
tagline VARCHAR(256) DEFAULT NULL COMMENT '一句话描述',
|
||||
description TEXT DEFAULT NULL COMMENT '详细描述',
|
||||
icon VARCHAR(32) DEFAULT NULL COMMENT '图标标识 (redis / mysql)',
|
||||
category VARCHAR(64) DEFAULT NULL COMMENT '分类',
|
||||
image VARCHAR(256) NOT NULL COMMENT 'Docker 镜像',
|
||||
container_name VARCHAR(128) NOT NULL COMMENT '容器名称',
|
||||
network VARCHAR(128) DEFAULT '' COMMENT 'Docker 网络',
|
||||
restart VARCHAR(32) DEFAULT 'always' COMMENT '重启策略',
|
||||
hostname VARCHAR(128) DEFAULT '' COMMENT '容器主机名',
|
||||
workdir VARCHAR(256) DEFAULT '' COMMENT '工作目录',
|
||||
command TEXT DEFAULT NULL COMMENT '启动命令',
|
||||
extra_args TEXT DEFAULT NULL COMMENT '额外 docker run 参数模板 (含 {{}} 占位符)',
|
||||
sort_order INT DEFAULT 0 COMMENT '排序权重',
|
||||
is_active TINYINT(1) DEFAULT 1 COMMENT '是否上架 1=是 0=否',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应用模板主表';
|
||||
|
||||
-- ─── 子表:端口映射 ───
|
||||
CREATE TABLE IF NOT EXISTS app_ports (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_id INT NOT NULL,
|
||||
host_port VARCHAR(16) NOT NULL COMMENT '宿主机端口',
|
||||
container_port VARCHAR(16) NOT NULL COMMENT '容器端口',
|
||||
protocol VARCHAR(8) DEFAULT 'tcp' COMMENT '协议',
|
||||
sort_order INT DEFAULT 0,
|
||||
FOREIGN KEY (template_id) REFERENCES app_templates(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ─── 子表:挂载卷 ───
|
||||
CREATE TABLE IF NOT EXISTS app_volumes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_id INT NOT NULL,
|
||||
host_path VARCHAR(512) NOT NULL COMMENT '宿主机路径',
|
||||
container_path VARCHAR(512) NOT NULL COMMENT '容器路径',
|
||||
mode VARCHAR(4) DEFAULT 'rw' COMMENT '读写模式',
|
||||
sort_order INT DEFAULT 0,
|
||||
FOREIGN KEY (template_id) REFERENCES app_templates(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ─── 子表:环境变量 ───
|
||||
CREATE TABLE IF NOT EXISTS app_envs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_id INT NOT NULL,
|
||||
env_key VARCHAR(256) NOT NULL COMMENT '变量名',
|
||||
env_value TEXT DEFAULT NULL COMMENT '变量值 (含 {{}} 占位符)',
|
||||
sort_order INT DEFAULT 0,
|
||||
FOREIGN KEY (template_id) REFERENCES app_templates(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ─── 子表:自动创建目录 ───
|
||||
CREATE TABLE IF NOT EXISTS app_mkdirs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_id INT NOT NULL,
|
||||
dir_path VARCHAR(512) NOT NULL COMMENT '宿主机目录',
|
||||
sort_order INT DEFAULT 0,
|
||||
FOREIGN KEY (template_id) REFERENCES app_templates(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ─── 子表:自动写入配置文件 ───
|
||||
CREATE TABLE IF NOT EXISTS app_write_files (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_id INT NOT NULL,
|
||||
file_path VARCHAR(512) NOT NULL COMMENT '宿主机文件路径',
|
||||
file_content LONGTEXT NOT NULL COMMENT '文件内容 (含 {{}} 占位符)',
|
||||
sort_order INT DEFAULT 0,
|
||||
FOREIGN KEY (template_id) REFERENCES app_templates(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ─── 子表:用户参数 ───
|
||||
CREATE TABLE IF NOT EXISTS app_params (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_id INT NOT NULL,
|
||||
param_key VARCHAR(64) NOT NULL COMMENT '参数键',
|
||||
label VARCHAR(128) NOT NULL COMMENT '显示标签',
|
||||
type VARCHAR(16) DEFAULT 'text' COMMENT 'text/password/number',
|
||||
placeholder VARCHAR(256) DEFAULT NULL,
|
||||
default_value VARCHAR(256) DEFAULT '' COMMENT '默认值',
|
||||
required TINYINT(1) DEFAULT 0 COMMENT '是否必填',
|
||||
options TEXT DEFAULT NULL COMMENT '下拉选项 JSON 数组 [{"value":"","label":""}]',
|
||||
show_when VARCHAR(128) DEFAULT NULL COMMENT '条件显示 param_key:value',
|
||||
sort_order INT DEFAULT 0,
|
||||
FOREIGN KEY (template_id) REFERENCES app_templates(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 初始数据:Redis 模板
|
||||
-- ============================================================
|
||||
INSERT INTO app_templates (app_key, name, tagline, description, icon, category, image, container_name, network, restart, hostname, workdir, command, sort_order, is_active)
|
||||
VALUES ('redis', 'Redis', '高性能内存数据库 / 缓存',
|
||||
'Redis 是开源的内存数据结构存储,用作数据库、缓存、消息队列和流引擎。支持字符串、哈希、列表、集合等多种数据结构。',
|
||||
'redis', '缓存', 'redis:8.10.0', 'redis', '', 'always', 'redis', '/data',
|
||||
'redis-server /etc/redis/redis.conf', 1, 1);
|
||||
|
||||
SET @redis_id = LAST_INSERT_ID();
|
||||
|
||||
INSERT INTO app_ports (template_id, host_port, container_port, protocol, sort_order) VALUES
|
||||
(@redis_id, '6379', '6379', 'tcp', 0);
|
||||
|
||||
INSERT INTO app_volumes (template_id, host_path, container_path, mode, sort_order) VALUES
|
||||
(@redis_id, '/opt/redis/data', '/data', 'rw', 0),
|
||||
(@redis_id, '/opt/redis/conf/redis.conf', '/etc/redis/redis.conf', 'rw', 1),
|
||||
(@redis_id, '/opt/redis/logs', '/logs', 'rw', 2);
|
||||
|
||||
INSERT INTO app_mkdirs (template_id, dir_path, sort_order) VALUES
|
||||
(@redis_id, '/opt/redis/data', 0),
|
||||
(@redis_id, '/opt/redis/conf', 1),
|
||||
(@redis_id, '/opt/redis/logs', 2);
|
||||
|
||||
INSERT INTO app_write_files (template_id, file_path, file_content, sort_order) VALUES
|
||||
(@redis_id, '/opt/redis/conf/redis.conf',
|
||||
'# Redis 配置文件
|
||||
bind 0.0.0.0
|
||||
protected-mode no
|
||||
port 6379
|
||||
tcp-backlog 511
|
||||
timeout 0
|
||||
tcp-keepalive 300
|
||||
daemonize no
|
||||
supervised no
|
||||
pidfile /var/run/redis_6379.pid
|
||||
loglevel notice
|
||||
logfile ""
|
||||
databases 16
|
||||
always-show-logo yes
|
||||
save 900 1
|
||||
save 300 10
|
||||
save 60 10000
|
||||
stop-writes-on-bgsave-error yes
|
||||
rdbcompression yes
|
||||
rdbchecksum yes
|
||||
dbfilename dump.rdb
|
||||
dir /data
|
||||
appendonly no
|
||||
appendfilename "appendonly.aof"
|
||||
appendfsync everysec
|
||||
no-appendfsync-on-rewrite no
|
||||
auto-aof-rewrite-percentage 100
|
||||
auto-aof-rewrite-min-size 64mb
|
||||
# 密码(部署时替换,留空则无密码)
|
||||
{{REDIS_PASSWORD_LINE}}', 0);
|
||||
|
||||
INSERT INTO app_params (template_id, param_key, label, type, placeholder, default_value, required, sort_order) VALUES
|
||||
(@redis_id, 'REDIS_PASSWORD', 'Redis 密码', 'password', '留空则无密码', '', 0, 0),
|
||||
(@redis_id, 'PORT', '宿主机端口', 'number', '6379', '6379', 1, 1);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 初始数据:MySQL 模板
|
||||
-- ============================================================
|
||||
INSERT INTO app_templates (app_key, name, tagline, description, icon, category, image, container_name, network, restart, hostname, workdir, command, sort_order, is_active)
|
||||
VALUES ('mysql', 'MySQL', '关系型数据库',
|
||||
'MySQL 是最流行的开源关系型数据库管理系统,支持事务、存储过程、触发器、视图等完整 SQL 功能。此模板基于 MySQL 8.4 LTS。',
|
||||
'mysql', '数据库', 'mysql:8.4.11', 'mysql', '', 'always', 'mysql', '/', '', 2, 1);
|
||||
|
||||
SET @mysql_id = LAST_INSERT_ID();
|
||||
|
||||
INSERT INTO app_ports (template_id, host_port, container_port, protocol, sort_order) VALUES
|
||||
(@mysql_id, '3306', '3306', 'tcp', 0);
|
||||
|
||||
INSERT INTO app_volumes (template_id, host_path, container_path, mode, sort_order) VALUES
|
||||
(@mysql_id, '/opt/mysql/data', '/var/lib/mysql', 'rw', 0),
|
||||
(@mysql_id, '/opt/mysql/conf/my.cnf', '/etc/my.cnf', 'rw', 1),
|
||||
(@mysql_id, '/opt/mysql/log', '/var/log/mysql', 'rw', 2),
|
||||
(@mysql_id, '/etc/timezone', '/etc/timezone', 'ro', 3),
|
||||
(@mysql_id, '/etc/localtime', '/etc/localtime', 'ro', 4);
|
||||
|
||||
INSERT INTO app_envs (template_id, env_key, env_value, sort_order) VALUES
|
||||
(@mysql_id, 'MYSQL_ROOT_PASSWORD', '{{MYSQL_ROOT_PASSWORD}}', 0);
|
||||
|
||||
INSERT INTO app_mkdirs (template_id, dir_path, sort_order) VALUES
|
||||
(@mysql_id, '/opt/mysql/data', 0),
|
||||
(@mysql_id, '/opt/mysql/conf', 1),
|
||||
(@mysql_id, '/opt/mysql/log', 2);
|
||||
|
||||
INSERT INTO app_write_files (template_id, file_path, file_content, sort_order) VALUES
|
||||
(@mysql_id, '/opt/mysql/conf/my.cnf',
|
||||
'# MySQL 配置文件
|
||||
[mysqld]
|
||||
pid-file = /var/run/mysqld/mysqld.pid
|
||||
socket = /var/run/mysqld/mysqld.sock
|
||||
datadir = /var/lib/mysql
|
||||
secure-file-priv= NULL
|
||||
|
||||
# 字符集
|
||||
character-set-server = utf8mb4
|
||||
collation-server = utf8mb4_unicode_ci
|
||||
|
||||
# 最大连接数
|
||||
max_connections = 200
|
||||
|
||||
# Innodb
|
||||
innodb_buffer_pool_size = 128M
|
||||
innodb_log_file_size = 48M
|
||||
innodb_flush_log_at_trx_commit = 1
|
||||
|
||||
# 日志
|
||||
log-error = /var/log/mysql/error.log
|
||||
slow_query_log = 1
|
||||
slow_query_log_file = /var/log/mysql/slow.log
|
||||
long_query_time = 2
|
||||
|
||||
# 端口
|
||||
port = 3306
|
||||
|
||||
[client]
|
||||
default-character-set = utf8mb4
|
||||
|
||||
[mysql]
|
||||
default-character-set = utf8mb4', 0);
|
||||
|
||||
INSERT INTO app_params (template_id, param_key, label, type, placeholder, default_value, required, sort_order) VALUES
|
||||
(@mysql_id, 'MYSQL_ROOT_PASSWORD', 'Root 密码', 'password', '设置 root 用户密码', '', 1, 0),
|
||||
(@mysql_id, 'PORT', '宿主机端口', 'number', '3306', '3306', 1, 1);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 初始数据:Halo 模板
|
||||
-- ============================================================
|
||||
INSERT INTO app_templates (app_key, name, tagline, description, icon, category, image, container_name, network, restart, hostname, workdir, command, extra_args, sort_order, is_active)
|
||||
VALUES ('halo', 'Halo', '强大易用的开源建站工具',
|
||||
'Halo 是一款开源的建站工具,支持博客、CMS、企业官网等多种场景。内置主题管理、插件系统、多用户、SEO 等功能。社区版基于开源仓库构建,免费使用。',
|
||||
'halo', '建站', 'registry.fit2cloud.com/halo/halo:2.26', 'halo', '', 'always', 'halo', '', '', '{{HALO_DB_ARGS}}', 3, 1);
|
||||
|
||||
SET @halo_id = LAST_INSERT_ID();
|
||||
|
||||
INSERT INTO app_ports (template_id, host_port, container_port, protocol, sort_order) VALUES
|
||||
(@halo_id, '{{PORT}}', '8090', 'tcp', 0);
|
||||
|
||||
INSERT INTO app_volumes (template_id, host_path, container_path, mode, sort_order) VALUES
|
||||
(@halo_id, '/opt/halo/data', '/root/.halo2', 'rw', 0);
|
||||
|
||||
INSERT INTO app_envs (template_id, env_key, env_value, sort_order) VALUES
|
||||
(@halo_id, 'JVM_OPTS', '-Xmx{{JVM_MAX}} -Xms{{JVM_MIN}}', 0);
|
||||
|
||||
INSERT INTO app_mkdirs (template_id, dir_path, sort_order) VALUES
|
||||
(@halo_id, '/opt/halo/data', 0);
|
||||
|
||||
INSERT INTO app_params (template_id, param_key, label, type, placeholder, default_value, required, options, show_when, sort_order) VALUES
|
||||
(@halo_id, 'PORT', '宿主机端口', 'number', '8090', '8090', 1, NULL, NULL, 0),
|
||||
(@halo_id, 'JVM_MAX', 'JVM 最大内存', 'text', '256m', '256m', 1, NULL, NULL, 1),
|
||||
(@halo_id, 'JVM_MIN', 'JVM 初始内存', 'text', '256m', '256m', 1, NULL, NULL, 2),
|
||||
(@halo_id, 'DB_TYPE', '数据库', 'select', 'H2 内置数据库', 'h2', 1, '[{"value":"h2","label":"H2 内置数据库(零配置)"},{"value":"mysql","label":"MySQL 外部数据库"}]', NULL, 3),
|
||||
(@halo_id, 'DB_HOST', 'MySQL 地址', 'text', '127.0.0.1', '127.0.0.1', 0, NULL, 'DB_TYPE:mysql', 4),
|
||||
(@halo_id, 'DB_PORT', 'MySQL 端口', 'number', '3306', '3306', 0, NULL, 'DB_TYPE:mysql', 5),
|
||||
(@halo_id, 'DB_NAME', 'MySQL 库名', 'text', 'halodb', 'halodb', 0, NULL, 'DB_TYPE:mysql', 6),
|
||||
(@halo_id, 'DB_USER', 'MySQL 用户名', 'text', 'root', 'root', 0, NULL, 'DB_TYPE:mysql', 7),
|
||||
(@halo_id, 'DB_PASSWORD', 'MySQL 密码', 'password', '数据库密码', '', 0, NULL, 'DB_TYPE:mysql', 8);
|
||||
|
||||
-- Halo 的 extra_args ({{HALO_DB_ARGS}}) 由前端 tplWithDerived 函数动态解析,
|
||||
-- 根据 DB_TYPE 参数自动生成 MySQL 连接环境变量或留空(H2 模式)
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 客户端版本管理表
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS app_version (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
version VARCHAR(32) NOT NULL UNIQUE COMMENT '版本号 (如 v0.0.1)',
|
||||
download_url VARCHAR(512) NOT NULL COMMENT '下载地址',
|
||||
release_notes TEXT DEFAULT NULL COMMENT '更新说明',
|
||||
is_latest TINYINT(1) DEFAULT 0 COMMENT '是否为最新版本 1=是 0=否',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户端版本管理';
|
||||
|
||||
-- 初始版本数据:v0.0.1
|
||||
INSERT INTO app_version (version, download_url, release_notes, is_latest, created_at)
|
||||
VALUES ('v0.0.1', 'https://114.66.55.63:3001/download/wfj-panel-v0.0.1.exe',
|
||||
'WFJ Panel 首个版本 v0.0.1
|
||||
|
||||
功能列表:
|
||||
- SSH 远程服务器连接管理
|
||||
- 服务器概览监控(CPU/内存/硬盘/网络/进程)
|
||||
- 远程文件管理(上传/下载/新建/删除)
|
||||
- 交互式终端(xterm.js)
|
||||
- Docker 容器管理(概览/容器列表/镜像/应用商店/任务中心/配置/网络/卷)
|
||||
- 应用商店一键部署(Redis/MySQL/Halo)
|
||||
- 系统防火墙管理(firewalld/ufw/iptables)', 1, NOW());
|
||||
@@ -0,0 +1,3 @@
|
||||
flask==3.0.3
|
||||
flask-cors==4.0.1
|
||||
mysql-connector-python==8.4.0
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
Generated
+3362
File diff suppressed because it is too large
Load Diff
+33
-1
@@ -6,16 +6,48 @@
|
||||
"scripts": {
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build"
|
||||
"build": "electron-vite build",
|
||||
"build:win": "electron-vite build && electron-builder --win --x64"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.wfj.panel",
|
||||
"productName": "WFJ Panel",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"out/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
],
|
||||
"icon": "build/icon.png"
|
||||
},
|
||||
"npmRebuild": false,
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "WFJ Panel"
|
||||
}
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"electron": "^44.2.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-vite": "^5.0.0",
|
||||
"vite": "^7.3.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"ssh2": "^1.17.0"
|
||||
|
||||
+1197
-32
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,85 @@ contextBridge.exposeInMainWorld('panel', {
|
||||
disconnect: () => ipcRenderer.invoke('ssh:disconnect'),
|
||||
getSession: () => ipcRenderer.invoke('ssh:session'),
|
||||
exec: (cmd, timeout) => ipcRenderer.invoke('ssh:exec', { cmd, timeout }),
|
||||
onDisconnected: (callback) => {
|
||||
const handler = (_event, data) => callback(data)
|
||||
ipcRenderer.on('ssh:disconnected', handler)
|
||||
return () => ipcRenderer.removeListener('ssh:disconnected', handler)
|
||||
},
|
||||
// 凭据(密码加密保存在本机)
|
||||
saveCredential: (cred) => ipcRenderer.invoke('cred:save', cred),
|
||||
listCredentials: () => ipcRenderer.invoke('cred:list'),
|
||||
deleteCredential: (key) => ipcRenderer.invoke('cred:delete', key),
|
||||
// SFTP 文件管理
|
||||
sftpList: (remotePath) => ipcRenderer.invoke('sftp:list', { remotePath }),
|
||||
sftpUpload: (localPath, remotePath) => ipcRenderer.invoke('sftp:upload', { localPath, remotePath }),
|
||||
sftpDownload: (remotePath, localPath) => ipcRenderer.invoke('sftp:download', { remotePath, localPath }),
|
||||
sftpSelectLocalFile: () => ipcRenderer.invoke('sftp:selectLocalFile'),
|
||||
sftpSelectSavePath: (defaultName) => ipcRenderer.invoke('sftp:selectSavePath', { defaultName }),
|
||||
sftpSelectDownloadDir: () => ipcRenderer.invoke('sftp:selectDownloadDir'),
|
||||
sftpMkdir: (remotePath) => ipcRenderer.invoke('sftp:mkdir', { remotePath }),
|
||||
sftpDelete: (remotePath, isDirectory, recursive) => ipcRenderer.invoke('sftp:delete', { remotePath, isDirectory, recursive }),
|
||||
// SFTP 进度监听(渲染进程注册回调,主进程通过 webContents.send 推送进度)
|
||||
onSftpProgress: (callback) => {
|
||||
const handler = (_event, data) => callback(data)
|
||||
ipcRenderer.on('sftp:progress', handler)
|
||||
return () => ipcRenderer.removeListener('sftp:progress', handler)
|
||||
},
|
||||
// 交互式终端 (Shell with PTY)
|
||||
shellStart: (cols, rows) => ipcRenderer.invoke('shell:start', { cols, rows }),
|
||||
shellWrite: (data) => ipcRenderer.invoke('shell:write', { data }),
|
||||
shellResize: (cols, rows) => ipcRenderer.invoke('shell:resize', { cols, rows }),
|
||||
shellClose: () => ipcRenderer.invoke('shell:close'),
|
||||
onShellData: (callback) => {
|
||||
const handler = (_event, data) => callback(data)
|
||||
ipcRenderer.on('shell:data', handler)
|
||||
return () => ipcRenderer.removeListener('shell:data', handler)
|
||||
},
|
||||
onShellClose: (callback) => {
|
||||
const handler = () => callback()
|
||||
ipcRenderer.on('shell:close', handler)
|
||||
return () => ipcRenderer.removeListener('shell:close', handler)
|
||||
},
|
||||
// Docker 专用 SSH 连接(主 SSH 连接成功后自动建立)
|
||||
dockerDisconnect: () => ipcRenderer.invoke('docker:disconnect'),
|
||||
dockerExec: (cmd, timeout) => ipcRenderer.invoke('docker:exec', { cmd, timeout }),
|
||||
dockerDeployApp: (deploy, paramValues) =>
|
||||
ipcRenderer.invoke('docker:deployApp', { deploy, paramValues }),
|
||||
// 部署前检查 SSH 连接数
|
||||
dockerCheckConnections: () => ipcRenderer.invoke('docker:checkConnections'),
|
||||
// 列出所有部署任务
|
||||
dockerListTasks: () => ipcRenderer.invoke('docker:listTasks'),
|
||||
// 监听部署任务更新(后台任务模式)
|
||||
onDeployTaskUpdate: (callback) => {
|
||||
const handler = (_event, data) => callback(data)
|
||||
ipcRenderer.on('deploy:taskUpdate', handler)
|
||||
return () => ipcRenderer.removeListener('deploy:taskUpdate', handler)
|
||||
},
|
||||
onDockerReady: (callback) => {
|
||||
const handler = () => callback()
|
||||
ipcRenderer.on('docker:ready', handler)
|
||||
return () => ipcRenderer.removeListener('docker:ready', handler)
|
||||
},
|
||||
onDockerDisconnected: (callback) => {
|
||||
const handler = (_event, data) => callback(data)
|
||||
ipcRenderer.on('docker:disconnected', handler)
|
||||
return () => ipcRenderer.removeListener('docker:disconnected', handler)
|
||||
},
|
||||
// ───── 在线应用商店 ─────
|
||||
// 从远程 API 同步模板到本地缓存
|
||||
appstoreSync: () => ipcRenderer.invoke('appstore:sync'),
|
||||
// 获取本地缓存的在线模板
|
||||
appstoreGetLocal: () => ipcRenderer.invoke('appstore:getLocal'),
|
||||
// 获取/设置 API 地址
|
||||
appstoreGetApiUrl: () => ipcRenderer.invoke('appstore:getApiUrl'),
|
||||
appstoreSetApiUrl: (url) => ipcRenderer.invoke('appstore:setApiUrl', url),
|
||||
// 检查服务器上已安装的应用(docker ps -a 容器名列表)
|
||||
appstoreCheckInstalled: () => ipcRenderer.invoke('appstore:checkInstalled'),
|
||||
// ───── 客户端版本管理 ─────
|
||||
// 检查是否有新版本
|
||||
appCheckUpdate: () => ipcRenderer.invoke('app:checkUpdate'),
|
||||
// 获取当前版本号
|
||||
appGetVersion: () => ipcRenderer.invoke('app:getVersion'),
|
||||
// 用系统浏览器打开 URL
|
||||
appOpenUrl: (url) => ipcRenderer.invoke('app:openUrl', url),
|
||||
})
|
||||
|
||||
@@ -1,21 +1,46 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import Login from './pages/Login'
|
||||
import Panel from './pages/Panel'
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState(null)
|
||||
const [dockerReady, setDockerReady] = useState(false)
|
||||
|
||||
// 登录成功后切换到面板页
|
||||
const handleConnected = (session) => setSession(session)
|
||||
|
||||
// 断开连接,回到登录页
|
||||
const handleLogout = async () => {
|
||||
await window.panel.dockerDisconnect()
|
||||
await window.panel.disconnect()
|
||||
setDockerReady(false)
|
||||
setSession(null)
|
||||
}
|
||||
|
||||
// 监听主 SSH 连接断开
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
const cleanup = window.panel.onDisconnected((data) => {
|
||||
alert(`SSH 连接已断开${data?.message ? ':' + data.message : ''}`)
|
||||
setDockerReady(false)
|
||||
setSession(null)
|
||||
})
|
||||
return cleanup
|
||||
}, [session])
|
||||
|
||||
// 监听 Docker 连接就绪 / 断开
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
const readyCleanup = window.panel.onDockerReady(() => setDockerReady(true))
|
||||
const discCleanup = window.panel.onDockerDisconnected(() => setDockerReady(false))
|
||||
return () => {
|
||||
readyCleanup()
|
||||
discCleanup()
|
||||
}
|
||||
}, [session])
|
||||
|
||||
return session ? (
|
||||
<Panel session={session} onLogout={handleLogout} />
|
||||
<Panel session={session} dockerReady={dockerReady} onLogout={handleLogout} />
|
||||
) : (
|
||||
<Login onConnected={handleConnected} />
|
||||
)
|
||||
|
||||
@@ -11,9 +11,20 @@ export default function Login({ onConnected }) {
|
||||
const [status, setStatus] = useState('')
|
||||
const [saved, setSaved] = useState([])
|
||||
|
||||
// 加载已保存的服务器列表
|
||||
// 加载已保存的服务器列表,并自动填充最近一次的登录信息
|
||||
useEffect(() => {
|
||||
window.panel.listCredentials().then(setSaved)
|
||||
window.panel.listCredentials().then((list) => {
|
||||
setSaved(list)
|
||||
// 如果有保存的服务器,自动填充第一条(最近保存的)
|
||||
if (list.length > 0) {
|
||||
const last = list[0]
|
||||
setHost(last.host)
|
||||
setPort(String(last.port))
|
||||
setUsername(last.username)
|
||||
setPassword(last.password)
|
||||
setRemember(true)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 点击已保存的服务器,自动填充表单
|
||||
@@ -60,7 +71,7 @@ export default function Login({ onConnected }) {
|
||||
return (
|
||||
<div className="wrap">
|
||||
<div className="card">
|
||||
<h1>SSH Panel</h1>
|
||||
<h1>WFJ Panel</h1>
|
||||
<p className="subtitle">连接到你的服务器</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="field-row">
|
||||
|
||||
@@ -78,3 +78,124 @@ main .hint {
|
||||
font-size: 15px;
|
||||
margin-top: 140px;
|
||||
}
|
||||
|
||||
/* ---- 更新按钮 ---- */
|
||||
.btn-update {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.btn-update:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-update:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---- 更新弹窗 ---- */
|
||||
.update-dialog {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
padding: 28px 32px;
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.update-dialog-header h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.update-dialog-body {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.update-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.update-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.update-icon-error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--status-critical);
|
||||
}
|
||||
|
||||
.update-icon-available {
|
||||
background: rgba(91, 110, 245, 0.1);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.update-icon-latest {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: var(--status-good);
|
||||
}
|
||||
|
||||
.update-message {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.update-detail {
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.update-version-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.update-version-old {
|
||||
color: var(--ink-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.update-version-new {
|
||||
color: var(--brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.update-notes {
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--ink-secondary);
|
||||
background: var(--surface-dim);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.update-dialog-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,53 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Overview from './overview/Overview'
|
||||
import FileManager from './files/FileManager'
|
||||
import ContainerPanel from './container/ContainerPanel'
|
||||
import TerminalPanel from './terminal/TerminalPanel'
|
||||
import Firewall from './firewall/Firewall'
|
||||
import './Panel.css'
|
||||
|
||||
const NAV_ITEMS = ['概览', '容器', '终端']
|
||||
const NAV_ITEMS = ['概览', '文件', '容器', '终端', '防火墙']
|
||||
|
||||
export default function Panel({ session, onLogout }) {
|
||||
export default function Panel({ session, dockerReady, onLogout }) {
|
||||
const [active, setActive] = useState('概览')
|
||||
const [currentVersion, setCurrentVersion] = useState('v0.0.1')
|
||||
const [updateInfo, setUpdateInfo] = useState(null) // null | { checking | result }
|
||||
const [showUpdateDialog, setShowUpdateDialog] = useState(false)
|
||||
|
||||
// 获取当前版本号
|
||||
useEffect(() => {
|
||||
window.panel.appGetVersion().then((r) => {
|
||||
if (r.ok) setCurrentVersion(r.version)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 检查更新
|
||||
const handleCheckUpdate = useCallback(async () => {
|
||||
setUpdateInfo({ checking: true })
|
||||
try {
|
||||
const r = await window.panel.appCheckUpdate()
|
||||
if (r.ok) {
|
||||
setUpdateInfo({ result: r })
|
||||
setShowUpdateDialog(true)
|
||||
} else {
|
||||
setUpdateInfo({ result: r })
|
||||
setShowUpdateDialog(true)
|
||||
}
|
||||
} catch (err) {
|
||||
setUpdateInfo({ result: { ok: false, error: err.message } })
|
||||
setShowUpdateDialog(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 打开下载页
|
||||
const handleDownload = useCallback((url) => {
|
||||
window.panel.appOpenUrl(url)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="menu">
|
||||
<span className="brand">SSH Panel</span>
|
||||
<span className="brand">WFJ Panel</span>
|
||||
<nav>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<a
|
||||
@@ -23,6 +60,14 @@ export default function Panel({ session, onLogout }) {
|
||||
))}
|
||||
</nav>
|
||||
<div className="right">
|
||||
<button
|
||||
className="btn btn-menu btn-update"
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={updateInfo?.checking}
|
||||
title={`当前版本: ${currentVersion}`}
|
||||
>
|
||||
{updateInfo?.checking ? '检查中...' : `更新`}
|
||||
</button>
|
||||
<span>
|
||||
{session.username}@{session.host}
|
||||
</span>
|
||||
@@ -33,9 +78,82 @@ export default function Panel({ session, onLogout }) {
|
||||
</header>
|
||||
<main>
|
||||
{active === '概览' && <Overview />}
|
||||
{active === '容器' && <p className="hint">容器功能待开发</p>}
|
||||
{active === '终端' && <p className="hint">终端功能待开发</p>}
|
||||
{active === '文件' && <FileManager />}
|
||||
{active === '容器' && <ContainerPanel dockerReady={dockerReady} />}
|
||||
{active === '终端' && <TerminalPanel />}
|
||||
{active === '防火墙' && <Firewall />}
|
||||
</main>
|
||||
|
||||
{/* 更新检查弹窗 */}
|
||||
{showUpdateDialog && updateInfo?.result && (
|
||||
<div className="confirm-overlay" onClick={() => setShowUpdateDialog(false)}>
|
||||
<div className="update-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="update-dialog-header">
|
||||
<h3>版本更新</h3>
|
||||
</div>
|
||||
<div className="update-dialog-body">
|
||||
{!updateInfo.result.ok ? (
|
||||
<div className="update-content">
|
||||
<div className="update-icon update-icon-error">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="update-message">检查更新失败</p>
|
||||
<span className="update-detail">{updateInfo.result.error || '未知错误'}</span>
|
||||
</div>
|
||||
) : updateInfo.result.hasUpdate ? (
|
||||
<div className="update-content">
|
||||
<div className="update-icon update-icon-available">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
<polyline points="12 7 12 13 16 11" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="update-message">发现新版本!</p>
|
||||
<div className="update-version-info">
|
||||
<span className="update-version-old">{updateInfo.result.currentVersion}</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ opacity: 0.5 }}>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
<span className="update-version-new">{updateInfo.result.latestVersion}</span>
|
||||
</div>
|
||||
{updateInfo.result.releaseNotes && (
|
||||
<pre className="update-notes">{updateInfo.result.releaseNotes}</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="update-content">
|
||||
<div className="update-icon update-icon-latest">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="update-message">已是最新版本</p>
|
||||
<span className="update-detail">当前版本: {updateInfo.result.currentVersion}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="update-dialog-footer">
|
||||
<button className="btn btn-ghost" onClick={() => setShowUpdateDialog(false)}>
|
||||
关闭
|
||||
</button>
|
||||
{updateInfo.result.ok && updateInfo.result.hasUpdate && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handleDownload(updateInfo.result.downloadUrl)}
|
||||
>
|
||||
前往下载
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
/* ============================================================
|
||||
应用商店
|
||||
============================================================ */
|
||||
.appstore-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.appstore-header h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.appstore-header p {
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* ---- 应用卡片网格 ---- */
|
||||
.appstore-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.app-card:hover {
|
||||
border-color: var(--brand);
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ---- 应用图标 ---- */
|
||||
.app-icon-box,
|
||||
.app-icon-large {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-icon-box {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.app-icon-large {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.app-icon-redis {
|
||||
background: rgba(220, 50, 47, 0.08);
|
||||
color: #dc352f;
|
||||
}
|
||||
|
||||
.app-icon-mysql {
|
||||
background: rgba(0, 110, 160, 0.08);
|
||||
color: #006e9f;
|
||||
}
|
||||
|
||||
.app-icon-halo {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
/* ---- 应用卡片信息 ---- */
|
||||
.app-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-card-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.app-card-tagline {
|
||||
font-size: 13px;
|
||||
color: var(--ink-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.app-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-card-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
background: var(--surface-dim);
|
||||
border-radius: 10px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.app-card-image {
|
||||
font-size: 11px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
应用详情页
|
||||
============================================================ */
|
||||
.appstore-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.appstore-back {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* ---- 详情头部 ---- */
|
||||
.app-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.app-detail-info h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--ink-strong);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.app-tagline {
|
||||
font-size: 14px;
|
||||
color: var(--ink-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.app-category {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 3px 10px;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.app-description {
|
||||
font-size: 14px;
|
||||
color: var(--ink-secondary);
|
||||
line-height: 1.7;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* ---- 配置预览 ---- */
|
||||
.app-config-preview {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.app-config-preview h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.config-key {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.config-val {
|
||||
font-size: 13px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--ink-strong);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.config-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.config-section-title {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.config-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.config-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px;
|
||||
background: var(--surface-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.row-host {
|
||||
color: var(--ink-strong);
|
||||
font-weight: 500;
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-arrow {
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-container {
|
||||
color: var(--brand);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.row-proto {
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
background: var(--card-bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-command {
|
||||
color: var(--ink-strong);
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---- 参数表单 ---- */
|
||||
.app-params-form {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.app-params-form h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.param-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.param-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.param-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-secondary);
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.param-required {
|
||||
color: var(--status-critical);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.param-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--ink);
|
||||
background: var(--card-bg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.param-input:focus {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* ---- 部署按钮区 ---- */
|
||||
.app-deploy-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
部署进度弹窗
|
||||
============================================================ */
|
||||
.deploy-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.deploy-dialog {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
width: 90%;
|
||||
max-width: 560px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.deploy-header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.deploy-header h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.deploy-body {
|
||||
padding: 20px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ---- 步骤指示器 ---- */
|
||||
.deploy-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.deploy-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--hairline-strong);
|
||||
background: var(--card-bg);
|
||||
color: var(--ink-muted);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.deploy-step.done .step-indicator {
|
||||
background: var(--status-good);
|
||||
border-color: var(--status-good);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.deploy-step.current .step-indicator {
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-soft);
|
||||
}
|
||||
|
||||
.deploy-step.error .step-indicator {
|
||||
background: var(--status-critical);
|
||||
border-color: var(--status-critical);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.step-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--brand-soft);
|
||||
border-top-color: var(--brand);
|
||||
border-radius: 50%;
|
||||
animation: step-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes step-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.step-pending {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--hairline-strong);
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: 12px;
|
||||
color: var(--ink-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.deploy-step.done .step-label {
|
||||
color: var(--status-good);
|
||||
}
|
||||
|
||||
.deploy-step.current .step-label {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.deploy-step.error .step-label {
|
||||
color: var(--status-critical);
|
||||
}
|
||||
|
||||
/* ---- 日志区 ---- */
|
||||
.deploy-log {
|
||||
background: #1e1e2e;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 16px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.deploy-log-line {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: #cdd6f4;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.deploy-log-line.log-error .log-text {
|
||||
color: #f38ba8;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #585b70;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-text {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ---- 底部 ---- */
|
||||
.deploy-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
SSH 连接数警告弹窗
|
||||
============================================================ */
|
||||
.conn-warning-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.conn-warning-icon {
|
||||
color: var(--status-warning, #ed8936);
|
||||
}
|
||||
|
||||
.conn-warning-text {
|
||||
font-size: 14px;
|
||||
color: var(--ink-secondary);
|
||||
line-height: 1.8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.conn-warning-text strong {
|
||||
color: var(--status-critical);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
部署已提交提示弹窗
|
||||
============================================================ */
|
||||
.deploy-submitted {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.deploy-submitted-icon {
|
||||
color: var(--status-good);
|
||||
}
|
||||
|
||||
.deploy-submitted p {
|
||||
font-size: 15px;
|
||||
color: var(--ink-strong);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.deploy-submitted span {
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.deploy-submitted-error {
|
||||
color: var(--status-critical);
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.deploy-submitted-error-icon {
|
||||
color: var(--status-critical);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
在线商店:顶部操作栏
|
||||
============================================================ */
|
||||
.appstore-header-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.appstore-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sync-msg {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sync-msg-ok {
|
||||
color: var(--status-good);
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
}
|
||||
|
||||
.sync-msg-error {
|
||||
color: var(--status-critical);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.sync-info {
|
||||
color: var(--ink-muted);
|
||||
background: var(--surface-dim);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
已安装应用标注
|
||||
============================================================ */
|
||||
.app-card-installed {
|
||||
opacity: 0.75;
|
||||
border-color: var(--status-good);
|
||||
}
|
||||
|
||||
.app-card-installed:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.app-card-installed-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: var(--status-good);
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.app-installed-badge {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: var(--status-good);
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 来源标签(内置/在线) */
|
||||
.app-source-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.app-card-source {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.app-card-source-builtin {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.app-card-source-online {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
/* 已安装提示 */
|
||||
.app-installed-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background: rgba(34, 197, 94, 0.06);
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
border-radius: var(--radius-card);
|
||||
color: var(--status-good);
|
||||
}
|
||||
|
||||
.app-installed-notice svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-installed-notice span {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
API 设置弹窗
|
||||
============================================================ */
|
||||
.api-settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.api-settings-form label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.api-settings-hint {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
按钮通用样式补充
|
||||
============================================================ */
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
select 下拉框
|
||||
============================================================ */
|
||||
.param-select {
|
||||
cursor: pointer;
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
参数提示信息
|
||||
============================================================ */
|
||||
.param-hint {
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-top: 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.param-hint-warn {
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
color: #d97706;
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.param-hint-ok {
|
||||
background: rgba(34, 197, 94, 0.06);
|
||||
color: var(--status-good);
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Docker 配置页
|
||||
============================================================ */
|
||||
.docker-config-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.docker-config-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.docker-config-card h4 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mirror-textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--ink);
|
||||
background: var(--surface-dim);
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.mirror-textarea:focus {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.mirror-hint {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
margin-top: 6px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.docker-config-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.docker-config-status {
|
||||
font-size: 13px;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.docker-config-status-ok {
|
||||
color: var(--status-good);
|
||||
background: rgba(34, 197, 94, 0.06);
|
||||
}
|
||||
|
||||
.docker-config-status-error {
|
||||
color: var(--status-critical);
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import './AppStore.css'
|
||||
import { APP_TEMPLATES } from './appTemplates'
|
||||
|
||||
// ---- 应用图标 ----
|
||||
function AppIcon({ type }) {
|
||||
const icons = {
|
||||
redis: (
|
||||
<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
</svg>
|
||||
),
|
||||
mysql: (
|
||||
<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5" />
|
||||
<path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3" />
|
||||
</svg>
|
||||
),
|
||||
halo: (
|
||||
<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 3v3M12 18v3M3 12h3M18 12h3" />
|
||||
</svg>
|
||||
),
|
||||
default: (
|
||||
<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M3 9h18M9 21V9" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
return icons[type] || icons.default
|
||||
}
|
||||
|
||||
// SSH 连接数警告弹窗
|
||||
function ConnectionWarning({ count, onConfirm, onCancel }) {
|
||||
return (
|
||||
<div className="deploy-overlay">
|
||||
<div className="deploy-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="deploy-header">
|
||||
<h3>SSH 连接数警告</h3>
|
||||
</div>
|
||||
<div className="deploy-body">
|
||||
<div className="conn-warning-body">
|
||||
<div className="conn-warning-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="conn-warning-text">
|
||||
当前服务器已有 <strong>{count}</strong> 个 SSH 连接,部署任务将再建立 1 条连接。
|
||||
<br />
|
||||
连接数过多可能导致服务器拒绝新连接,是否继续部署?
|
||||
</p>
|
||||
</div>
|
||||
<div className="deploy-footer">
|
||||
<button className="btn btn-ghost" onClick={onCancel}>取消</button>
|
||||
<button className="btn btn-primary" onClick={onConfirm}>继续部署</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 部署已提交提示
|
||||
function DeploySubmitted({ appName, error, onClose, onViewTasks }) {
|
||||
return (
|
||||
<div className="deploy-overlay" onClick={onClose}>
|
||||
<div className="deploy-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="deploy-header">
|
||||
<h3>{error ? '部署提交失败' : '部署任务已提交'}</h3>
|
||||
</div>
|
||||
<div className="deploy-body">
|
||||
<div className="deploy-submitted">
|
||||
<div className={`deploy-submitted-icon ${error ? 'deploy-submitted-error-icon' : ''}`}>
|
||||
{error ? (
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{error ? (
|
||||
<>
|
||||
<p><strong>{appName}</strong> 部署任务提交失败。</p>
|
||||
<span className="deploy-submitted-error">{error}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p><strong>{appName}</strong> 部署任务已在后台开始执行。</p>
|
||||
<span>可前往「任务中心」实时查看部署进度。</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="deploy-footer">
|
||||
<button className="btn btn-ghost" onClick={onClose}>{error ? '关闭' : '继续浏览商店'}</button>
|
||||
{!error && (
|
||||
<button className="btn btn-primary" onClick={onViewTasks}>查看任务</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 主组件 ----
|
||||
export default function AppStore({ onDeployed, onViewTasks }) {
|
||||
const [selectedApp, setSelectedApp] = useState(null) // 选中的应用模板
|
||||
const [paramValues, setParamValues] = useState({})
|
||||
const [deploying, setDeploying] = useState(false) // 正在检查连接数/提交中
|
||||
const [connWarning, setConnWarning] = useState(null) // { count } 或 null
|
||||
const [deploySubmitted, setDeploySubmitted] = useState(null) // { appName } 或 null
|
||||
|
||||
// 在线商店状态
|
||||
const [onlineTemplates, setOnlineTemplates] = useState([])
|
||||
const [syncedAt, setSyncedAt] = useState(null)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [syncMsg, setSyncMsg] = useState(null) // { type: 'ok'|'error', text }
|
||||
const [installedApps, setInstalledApps] = useState([]) // 已安装容器名列表
|
||||
|
||||
const syncMsgTimer = useRef(null)
|
||||
|
||||
// 初始化:加载本地缓存 + 获取 API 地址 + 检查已安装
|
||||
useEffect(() => {
|
||||
// 加载本地缓存在线模板
|
||||
window.panel.appstoreGetLocal().then((r) => {
|
||||
if (r.ok && r.templates) {
|
||||
setOnlineTemplates(r.templates)
|
||||
setSyncedAt(r.syncedAt)
|
||||
}
|
||||
})
|
||||
|
||||
// 检查已安装应用
|
||||
refreshInstalled()
|
||||
}, [])
|
||||
|
||||
// 刷新已安装列表
|
||||
const refreshInstalled = useCallback(async () => {
|
||||
try {
|
||||
const r = await window.panel.appstoreCheckInstalled()
|
||||
if (r.ok) {
|
||||
setInstalledApps(r.installed || [])
|
||||
}
|
||||
} catch {
|
||||
// Docker 未连接时忽略
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 合并内置模板和在线模板(去重:按 containerName 去重,内置优先)
|
||||
const allTemplates = (() => {
|
||||
const seen = new Set()
|
||||
const result = []
|
||||
// 内置模板优先
|
||||
for (const t of APP_TEMPLATES) {
|
||||
const cn = t.deploy.containerName
|
||||
if (!seen.has(cn)) {
|
||||
seen.add(cn)
|
||||
result.push({ ...t, source: 'builtin' })
|
||||
}
|
||||
}
|
||||
// 在线模板补充
|
||||
for (const t of onlineTemplates) {
|
||||
const cn = t.deploy?.containerName
|
||||
if (cn && !seen.has(cn)) {
|
||||
seen.add(cn)
|
||||
result.push({ ...t, source: 'online' })
|
||||
}
|
||||
}
|
||||
return result
|
||||
})()
|
||||
|
||||
// 判断某个模板是否已安装
|
||||
const isInstalled = useCallback((template) => {
|
||||
const cn = template.deploy?.containerName
|
||||
if (!cn) return false
|
||||
return installedApps.includes(cn)
|
||||
}, [installedApps])
|
||||
|
||||
// 同步在线商店
|
||||
const handleSync = useCallback(async () => {
|
||||
setSyncing(true)
|
||||
setSyncMsg(null)
|
||||
try {
|
||||
const r = await window.panel.appstoreSync()
|
||||
if (r.ok) {
|
||||
setOnlineTemplates(r.templates)
|
||||
setSyncedAt(r.syncedAt)
|
||||
setSyncMsg({ type: 'ok', text: `同步成功,共 ${r.templates.length} 个在线模板` })
|
||||
// 同步后刷新已安装状态
|
||||
refreshInstalled()
|
||||
} else {
|
||||
setSyncMsg({ type: 'error', text: r.error || '同步失败' })
|
||||
}
|
||||
} catch (err) {
|
||||
setSyncMsg({ type: 'error', text: err.message || '同步异常' })
|
||||
}
|
||||
setSyncing(false)
|
||||
// 5 秒后清除提示
|
||||
if (syncMsgTimer.current) clearTimeout(syncMsgTimer.current)
|
||||
syncMsgTimer.current = setTimeout(() => setSyncMsg(null), 5000)
|
||||
}, [refreshInstalled])
|
||||
|
||||
|
||||
|
||||
// 选中应用时初始化参数
|
||||
useEffect(() => {
|
||||
if (!selectedApp) return
|
||||
const defaults = {}
|
||||
for (const param of selectedApp.deploy.params || []) {
|
||||
defaults[param.key] = param.default || ''
|
||||
}
|
||||
// Halo + MySQL 联动:如果 MySQL 容器已安装,自动填充默认值
|
||||
if (selectedApp.id === 'halo' && installedApps.includes('mysql')) {
|
||||
defaults['DB_TYPE'] = 'mysql'
|
||||
defaults['DB_HOST'] = '127.0.0.1'
|
||||
defaults['DB_PORT'] = '3306'
|
||||
defaults['DB_NAME'] = 'halodb'
|
||||
defaults['DB_USER'] = 'root'
|
||||
}
|
||||
setParamValues(defaults)
|
||||
}, [selectedApp, installedApps])
|
||||
|
||||
// 执行部署(先检查连接数)
|
||||
const handleDeploy = useCallback(async () => {
|
||||
setDeploying(true)
|
||||
|
||||
// 检查 SSH 连接数
|
||||
let connCount = 0
|
||||
try {
|
||||
const r = await window.panel.dockerCheckConnections()
|
||||
if (r.ok) {
|
||||
connCount = r.count
|
||||
}
|
||||
} catch {
|
||||
// 检查失败不阻塞部署
|
||||
}
|
||||
|
||||
// 连接数 >= 8 时弹窗警告(服务器 SSH MaxStartups 通常 10:30:100)
|
||||
if (connCount >= 8) {
|
||||
setConnWarning({ count: connCount })
|
||||
setDeploying(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 直接提交部署
|
||||
await submitDeploy()
|
||||
}, [selectedApp, paramValues])
|
||||
|
||||
// 确认继续部署(从警告弹窗过来)
|
||||
const confirmDeploy = async () => {
|
||||
setConnWarning(null)
|
||||
await submitDeploy()
|
||||
}
|
||||
|
||||
// 提交部署到后台
|
||||
const submitDeploy = async () => {
|
||||
setDeploying(true)
|
||||
|
||||
const result = await window.panel.dockerDeployApp(selectedApp.deploy, paramValues)
|
||||
|
||||
setDeploying(false)
|
||||
|
||||
if (result.ok) {
|
||||
// 部署任务已提交到后台
|
||||
setDeploySubmitted({ appName: selectedApp.name })
|
||||
if (onDeployed) onDeployed()
|
||||
} else {
|
||||
// 提交失败
|
||||
setDeploySubmitted({
|
||||
appName: selectedApp.name,
|
||||
error: result.message || '部署任务提交失败',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const closeSubmitted = () => {
|
||||
setDeploySubmitted(null)
|
||||
setSelectedApp(null)
|
||||
// 部署后刷新已安装状态
|
||||
refreshInstalled()
|
||||
}
|
||||
|
||||
// ---- 渲染 ----
|
||||
|
||||
// 连接数警告弹窗
|
||||
if (connWarning) {
|
||||
return (
|
||||
<ConnectionWarning
|
||||
count={connWarning.count}
|
||||
onConfirm={confirmDeploy}
|
||||
onCancel={() => setConnWarning(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 部署已提交提示
|
||||
if (deploySubmitted) {
|
||||
return (
|
||||
<DeploySubmitted
|
||||
appName={deploySubmitted.appName}
|
||||
error={deploySubmitted.error}
|
||||
onClose={closeSubmitted}
|
||||
onViewTasks={() => {
|
||||
closeSubmitted()
|
||||
if (onViewTasks) onViewTasks()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 应用详情 + 参数表单
|
||||
if (selectedApp) {
|
||||
const app = selectedApp
|
||||
const d = app.deploy
|
||||
const installed = isInstalled(app)
|
||||
|
||||
return (
|
||||
<div className="appstore-detail">
|
||||
{/* 返回按钮 */}
|
||||
<button className="btn btn-ghost btn-sm appstore-back" onClick={() => setSelectedApp(null)}>
|
||||
← 返回商店
|
||||
</button>
|
||||
|
||||
{/* 应用信息 */}
|
||||
<div className="app-detail-header">
|
||||
<div className={`app-icon-large app-icon-${app.icon}`}>
|
||||
<AppIcon type={app.icon} />
|
||||
</div>
|
||||
<div className="app-detail-info">
|
||||
<h2>
|
||||
{app.name}
|
||||
{installed && <span className="app-installed-badge">已安装</span>}
|
||||
</h2>
|
||||
<p className="app-tagline">{app.tagline}</p>
|
||||
<span className="app-category">{app.category}</span>
|
||||
<span className="app-source-tag">{app.source === 'builtin' ? '内置' : '在线'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="app-description">{app.description}</p>
|
||||
|
||||
{/* 配置预览 */}
|
||||
<div className="app-config-preview">
|
||||
<h4>部署配置预览</h4>
|
||||
<div className="config-grid">
|
||||
<div className="config-item">
|
||||
<span className="config-key">镜像</span>
|
||||
<span className="config-val">{d.image}</span>
|
||||
</div>
|
||||
<div className="config-item">
|
||||
<span className="config-key">容器名</span>
|
||||
<span className="config-val">{d.containerName}</span>
|
||||
</div>
|
||||
<div className="config-item">
|
||||
<span className="config-key">重启策略</span>
|
||||
<span className="config-val">{d.restart}</span>
|
||||
</div>
|
||||
{d.hostname && (
|
||||
<div className="config-item">
|
||||
<span className="config-key">主机名</span>
|
||||
<span className="config-val">{d.hostname}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 端口映射 */}
|
||||
{d.ports.length > 0 && (
|
||||
<div className="config-section">
|
||||
<span className="config-section-title">端口映射</span>
|
||||
<div className="config-rows">
|
||||
{d.ports.map((p, i) => (
|
||||
<div key={i} className="config-row">
|
||||
<span className="row-host">{p.host}</span>
|
||||
<span className="row-arrow">:</span>
|
||||
<span className="row-container">{p.container}</span>
|
||||
<span className="row-proto">{p.protocol}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 挂载卷 */}
|
||||
{d.volumes.length > 0 && (
|
||||
<div className="config-section">
|
||||
<span className="config-section-title">挂载卷</span>
|
||||
<div className="config-rows">
|
||||
{d.volumes.map((v, i) => (
|
||||
<div key={i} className="config-row">
|
||||
<span className="row-host" title={v.host}>{v.host}</span>
|
||||
<span className="row-arrow">:</span>
|
||||
<span className="row-container">{v.container}</span>
|
||||
<span className="row-proto">{v.mode}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 环境变量 */}
|
||||
{d.env && d.env.length > 0 && (
|
||||
<div className="config-section">
|
||||
<span className="config-section-title">环境变量</span>
|
||||
<div className="config-rows">
|
||||
{d.env.map((e, i) => (
|
||||
<div key={i} className="config-row">
|
||||
<span className="row-host">{e.key}</span>
|
||||
<span className="row-arrow">=</span>
|
||||
<span className="row-container">{e.value.includes('{{') ? '(部署时输入)' : e.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 启动命令 */}
|
||||
{d.command && (
|
||||
<div className="config-section">
|
||||
<span className="config-section-title">启动命令</span>
|
||||
<div className="config-rows">
|
||||
<div className="config-row">
|
||||
<code className="row-command">{d.command}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 参数输入表单 */}
|
||||
{installed ? (
|
||||
<div className="app-installed-notice">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
<span>该应用已安装,无法重复部署。如需重新部署,请先在容器列表中删除同名容器。</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-params-form">
|
||||
<h4>部署参数</h4>
|
||||
{(d.params || []).filter((param) => {
|
||||
// showWhen 条件渲染
|
||||
if (param.showWhen) {
|
||||
return paramValues[param.showWhen.param] === param.showWhen.equals
|
||||
}
|
||||
return true
|
||||
}).map((param) => (
|
||||
<div key={param.key} className="param-row">
|
||||
<label className="param-label">
|
||||
{param.label}
|
||||
{param.required && <span className="param-required">*</span>}
|
||||
</label>
|
||||
{param.type === 'select' ? (
|
||||
<select
|
||||
className="param-input param-select"
|
||||
value={paramValues[param.key] || ''}
|
||||
onChange={(e) => setParamValues((prev) => ({ ...prev, [param.key]: e.target.value }))}
|
||||
>
|
||||
{(param.options || []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={param.type === 'password' ? 'password' : 'text'}
|
||||
className="param-input"
|
||||
placeholder={param.placeholder}
|
||||
value={paramValues[param.key] || ''}
|
||||
onChange={(e) => setParamValues((prev) => ({ ...prev, [param.key]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Halo MySQL 联动提示 */}
|
||||
{selectedApp.id === 'halo' && paramValues['DB_TYPE'] === 'mysql' && !installedApps.includes('mysql') && (
|
||||
<div className="param-hint param-hint-warn">
|
||||
检测到服务器未安装 MySQL 容器。请先在应用商店部署 MySQL,或切换为 H2 数据库。
|
||||
</div>
|
||||
)}
|
||||
{selectedApp.id === 'halo' && paramValues['DB_TYPE'] === 'mysql' && installedApps.includes('mysql') && (
|
||||
<div className="param-hint param-hint-ok">
|
||||
已检测到 MySQL 容器,已自动填充连接参数。请确认密码正确。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 部署按钮 */}
|
||||
<div className="app-deploy-actions">
|
||||
<button className="btn btn-ghost" onClick={() => setSelectedApp(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleDeploy}
|
||||
disabled={installed || deploying || (d.params || []).some((p) => {
|
||||
if (!p.required) return false
|
||||
if (p.showWhen && paramValues[p.showWhen.param] !== p.showWhen.equals) return false
|
||||
return !paramValues[p.key]
|
||||
})}
|
||||
>
|
||||
{installed ? '已安装' : deploying ? '提交中...' : '一键部署'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 应用商店列表
|
||||
return (
|
||||
<div className="appstore-list">
|
||||
<div className="appstore-header">
|
||||
<div className="appstore-header-row">
|
||||
<div>
|
||||
<h3>应用商店</h3>
|
||||
<p>选择应用一键部署,自动拉取镜像、创建目录、写入配置文件并启动容器</p>
|
||||
</div>
|
||||
<div className="appstore-actions">
|
||||
{syncMsg && (
|
||||
<span className={`sync-msg sync-msg-${syncMsg.type}`}>{syncMsg.text}</span>
|
||||
)}
|
||||
{syncedAt && !syncMsg && !syncing && (
|
||||
<span className="sync-msg sync-info">上次同步: {new Date(syncedAt).toLocaleString('zh-CN')}</span>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? '同步中...' : '更新商店'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="appstore-grid">
|
||||
{allTemplates.map((app) => {
|
||||
const installed = isInstalled(app)
|
||||
return (
|
||||
<div
|
||||
key={app.deploy.containerName || app.id}
|
||||
className={`app-card ${installed ? 'app-card-installed' : ''}`}
|
||||
onClick={() => setSelectedApp(app)}
|
||||
>
|
||||
<div className={`app-icon-box app-icon-${app.icon}`}>
|
||||
<AppIcon type={app.icon} />
|
||||
</div>
|
||||
<div className="app-card-info">
|
||||
<div className="app-card-name">
|
||||
{app.name}
|
||||
{installed && <span className="app-card-installed-badge">已安装</span>}
|
||||
</div>
|
||||
<div className="app-card-tagline">{app.tagline}</div>
|
||||
<div className="app-card-meta">
|
||||
<span className="app-card-tag">{app.category}</span>
|
||||
<span className="app-card-image">{app.image}</span>
|
||||
<span className={`app-card-source app-card-source-${app.source}`}>
|
||||
{app.source === 'builtin' ? '内置' : '在线'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
/* ============================================================
|
||||
创建容器弹窗
|
||||
============================================================ */
|
||||
.cc-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.cc-dialog {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
width: 92%;
|
||||
max-width: 760px;
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- 头部 ---- */
|
||||
.cc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cc-header h2 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cc-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.cc-close:hover {
|
||||
background: var(--surface-dim);
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
/* ---- 模式切换 ---- */
|
||||
.cc-mode-switch {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cc-mode-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: var(--ink-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.cc-mode-btn:hover {
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.cc-mode-btn.active {
|
||||
color: var(--brand);
|
||||
border-bottom-color: var(--brand);
|
||||
}
|
||||
|
||||
/* ---- 内容区 ---- */
|
||||
.cc-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
/* ---- 通用 section ---- */
|
||||
.cc-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.cc-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cc-loading-hint {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* ---- 命令模式 ---- */
|
||||
.cc-cmd-input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: var(--radius-card);
|
||||
font-size: 13px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--ink);
|
||||
background: var(--surface-dim);
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cc-cmd-input:focus {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 1px;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.cc-cmd-input::placeholder {
|
||||
color: var(--ink-muted);
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.cc-cmd-error {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: var(--status-critical);
|
||||
font-size: 13px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.cc-cmd-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* ---- 解析预览 ---- */
|
||||
.cc-parsed-preview,
|
||||
.cc-preview-section {
|
||||
margin-top: 16px;
|
||||
background: var(--surface-dim);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.cc-parsed-title,
|
||||
.cc-preview-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-secondary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ---- 镜像选择 ---- */
|
||||
.cc-image-select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--card-bg);
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cc-image-select:focus {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.cc-image-manual {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--ink);
|
||||
background: var(--card-bg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cc-image-manual:focus {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* ---- 分区导航 ---- */
|
||||
.cc-section-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.cc-nav-btn {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: var(--ink-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.cc-nav-btn:hover {
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.cc-nav-btn.active {
|
||||
color: var(--brand);
|
||||
border-bottom-color: var(--brand);
|
||||
}
|
||||
|
||||
/* ---- 表单 ---- */
|
||||
.cc-form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cc-form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cc-form-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-secondary);
|
||||
width: 90px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.cc-form-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--card-bg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cc-form-input:focus {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* ---- 子标题(端口/卷/环境变量) ---- */
|
||||
.cc-section-subtitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cc-section-subtitle > span {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.cc-add-btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
color: var(--brand);
|
||||
background: var(--brand-soft);
|
||||
border: 1px solid rgba(91, 110, 245, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.cc-add-btn:hover {
|
||||
background: var(--brand-soft-hover);
|
||||
}
|
||||
|
||||
.cc-empty-hint {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
background: var(--surface-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ---- 行布局(端口/卷/环境变量) ---- */
|
||||
.cc-port-row,
|
||||
.cc-volume-row,
|
||||
.cc-env-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cc-port-row input,
|
||||
.cc-volume-row input,
|
||||
.cc-env-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--ink);
|
||||
background: var(--card-bg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cc-port-row input:focus,
|
||||
.cc-volume-row input:focus,
|
||||
.cc-env-row input:focus {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.cc-port-row select,
|
||||
.cc-volume-row select {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--card-bg);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.cc-arrow {
|
||||
font-size: 14px;
|
||||
color: var(--ink-muted);
|
||||
flex-shrink: 0;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.cc-remove-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.cc-remove-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--status-critical);
|
||||
}
|
||||
|
||||
/* ---- 底部 ---- */
|
||||
.cc-footer {
|
||||
border-top: 1px solid var(--hairline);
|
||||
padding: 14px 24px;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface-dim);
|
||||
}
|
||||
|
||||
.cc-create-error {
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: var(--status-critical);
|
||||
font-size: 13px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.cc-cmd-preview {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cc-cmd-preview-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-muted);
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.cc-cmd-preview-code {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--ink);
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
max-height: 60px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cc-footer-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
可视化配置展示
|
||||
============================================================ */
|
||||
.cc-parsed-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cc-parsed-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cc-parsed-group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.cc-parsed-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cc-parsed-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.cc-parsed-label {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cc-parsed-value {
|
||||
font-size: 12px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--ink-strong);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cc-value-image {
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- 端口/卷/环境变量 表格行 ---- */
|
||||
.cc-parsed-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cc-parsed-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.cc-port-host,
|
||||
.cc-vol-host {
|
||||
color: var(--ink-strong);
|
||||
font-weight: 500;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cc-arrow-icon {
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cc-port-container,
|
||||
.cc-vol-container {
|
||||
color: var(--brand);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cc-port-proto {
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
background: var(--surface-dim);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.cc-vol-mode {
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
background: var(--surface-dim);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.cc-env-key {
|
||||
color: var(--ink-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cc-eq {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.cc-env-value {
|
||||
color: var(--ink-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import './ContainerCreate.css'
|
||||
import { parseDockerRun, buildDockerRun } from './cmdParser'
|
||||
|
||||
// ---- 默认配置 ----
|
||||
const DEFAULT_CONFIG = {
|
||||
image: '',
|
||||
name: '',
|
||||
ports: [],
|
||||
volumes: [],
|
||||
env: [],
|
||||
network: '',
|
||||
restart: '',
|
||||
hostname: '',
|
||||
workdir: '',
|
||||
user: '',
|
||||
memory: '',
|
||||
cpu: '',
|
||||
command: '',
|
||||
extraArgs: [],
|
||||
}
|
||||
|
||||
export default function ContainerCreate({ onClose, onCreated }) {
|
||||
const [mode, setMode] = useState('image') // 'image' | 'command'
|
||||
const [images, setImages] = useState([])
|
||||
const [networks, setNetworks] = useState([])
|
||||
const [imgLoading, setImgLoading] = useState(true)
|
||||
const [config, setConfig] = useState(DEFAULT_CONFIG)
|
||||
const [cmdInput, setCmdInput] = useState('')
|
||||
const [cmdError, setCmdError] = useState(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState(null)
|
||||
const [activeSection, setActiveSection] = useState('basic')
|
||||
const dialogRef = useRef(null)
|
||||
|
||||
// ---- 获取镜像列表和网络列表 ----
|
||||
useEffect(() => {
|
||||
// 并行获取
|
||||
setImgLoading(true)
|
||||
window.panel.dockerExec('docker images --format "{{json .}}"', 15000).then((r) => {
|
||||
if (r.ok && r.code === 0) {
|
||||
const list = (r.stdout || '').trim().split('\n').filter(Boolean).map((line) => {
|
||||
try { return JSON.parse(line) } catch { return null }
|
||||
}).filter(Boolean)
|
||||
setImages(list)
|
||||
}
|
||||
setImgLoading(false)
|
||||
})
|
||||
|
||||
window.panel.dockerExec('docker network ls --format "{{.Name}}"', 10000).then((r) => {
|
||||
if (r.ok && r.code === 0) {
|
||||
const list = (r.stdout || '').trim().split('\n').filter(Boolean)
|
||||
setNetworks(list)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ---- 命令模式:解析输入并同步配置 ----
|
||||
useEffect(() => {
|
||||
if (mode !== 'command') return
|
||||
if (!cmdInput.trim()) {
|
||||
setConfig(DEFAULT_CONFIG)
|
||||
setCmdError(null)
|
||||
return
|
||||
}
|
||||
// 只在包含 docker run 或 docker create 时才解析
|
||||
if (!/^docker\s+run\s/i.test(cmdInput.trim()) && !/^docker\s+create\s/i.test(cmdInput.trim())) {
|
||||
setCmdError('命令必须以 docker run 或 docker create 开头')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const parsed = parseDockerRun(cmdInput)
|
||||
setConfig(parsed)
|
||||
setCmdError(null)
|
||||
} catch (err) {
|
||||
setCmdError('解析失败: ' + err.message)
|
||||
}
|
||||
}, [cmdInput, mode])
|
||||
|
||||
// ---- ESC 关闭 ----
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
// ---- 配置更新辅助 ----
|
||||
const updateConfig = useCallback((key, value) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }))
|
||||
}, [])
|
||||
|
||||
const updateArrayItem = useCallback((key, index, field, value) => {
|
||||
setConfig((prev) => {
|
||||
const arr = [...prev[key]]
|
||||
arr[index] = { ...arr[index], [field]: value }
|
||||
return { ...prev, [key]: arr }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const addArrayItem = useCallback((key, template) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: [...prev[key], template] }))
|
||||
}, [])
|
||||
|
||||
const removeArrayItem = useCallback((key, index) => {
|
||||
setConfig((prev) => {
|
||||
const arr = [...prev[key]]
|
||||
arr.splice(index, 1)
|
||||
return { ...prev, [key]: arr }
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ---- 创建容器 ----
|
||||
const handleCreate = useCallback(async () => {
|
||||
setCreating(true)
|
||||
setCreateError(null)
|
||||
|
||||
let cmd
|
||||
if (mode === 'command') {
|
||||
// 命令模式:直接使用输入的命令,但确保 -d
|
||||
const raw = cmdInput.trim()
|
||||
if (!/^docker\s+(run|create)\s/i.test(raw)) {
|
||||
setCreateError('命令必须以 docker run 或 docker create 开头')
|
||||
setCreating(false)
|
||||
return
|
||||
}
|
||||
// 如果没有 -d 或 --detach,加上 -d
|
||||
if (!/\s-d(\s|$)/.test(raw) && !/--detach(\s|$)/.test(raw)) {
|
||||
cmd = raw.replace(/^(docker\s+(run|create))\s/i, '$1 -d ')
|
||||
} else {
|
||||
cmd = raw
|
||||
}
|
||||
} else {
|
||||
// 镜像模式:从配置构建命令
|
||||
if (!config.image) {
|
||||
setCreateError('请选择或输入镜像名称')
|
||||
setCreating(false)
|
||||
return
|
||||
}
|
||||
cmd = buildDockerRun(config)
|
||||
}
|
||||
|
||||
const r = await window.panel.dockerExec(cmd, 60000)
|
||||
|
||||
if (r.ok && r.code === 0) {
|
||||
onCreated()
|
||||
} else {
|
||||
setCreateError(r.stderr || r.message || '创建容器失败')
|
||||
}
|
||||
setCreating(false)
|
||||
}, [mode, cmdInput, config, onCreated])
|
||||
|
||||
// ---- 可视化预览命令 ----
|
||||
const previewCmd = mode === 'command' ? cmdInput : buildDockerRun(config)
|
||||
|
||||
// ---- 获取镜像名 ----
|
||||
const getImageName = (img) => {
|
||||
const repo = img.Repository || img.repository || ''
|
||||
const tag = img.Tag || img.tag || ''
|
||||
if (!repo || repo === '<none>') return ''
|
||||
return tag && tag !== '<none>' ? `${repo}:${tag}` : repo
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: 'basic', label: '基本配置' },
|
||||
{ id: 'ports', label: '端口映射' },
|
||||
{ id: 'volumes', label: '挂载卷' },
|
||||
{ id: 'env', label: '环境变量' },
|
||||
{ id: 'advanced', label: '高级配置' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="cc-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="cc-dialog"
|
||||
ref={dialogRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="cc-header">
|
||||
<h2>创建容器</h2>
|
||||
<button className="cc-close" onClick={onClose} title="关闭 (ESC)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 模式切换 */}
|
||||
<div className="cc-mode-switch">
|
||||
<button
|
||||
className={`cc-mode-btn ${mode === 'image' ? 'active' : ''}`}
|
||||
onClick={() => setMode('image')}
|
||||
>
|
||||
依据镜像创建
|
||||
</button>
|
||||
<button
|
||||
className={`cc-mode-btn ${mode === 'command' ? 'active' : ''}`}
|
||||
onClick={() => setMode('command')}
|
||||
>
|
||||
命令创建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className="cc-body">
|
||||
{mode === 'command' ? (
|
||||
/* ---- 命令创建模式 ---- */
|
||||
<div className="cc-command-mode">
|
||||
<div className="cc-section">
|
||||
<div className="cc-section-title">
|
||||
<span>输入 docker run 命令</span>
|
||||
</div>
|
||||
<textarea
|
||||
className="cc-cmd-input"
|
||||
value={cmdInput}
|
||||
onChange={(e) => setCmdInput(e.target.value)}
|
||||
placeholder="docker run -d --name myapp -p 8080:80 -v /data:/app/data -e TZ=Asia/Shanghai nginx:latest"
|
||||
rows={4}
|
||||
autoFocus
|
||||
/>
|
||||
{cmdError && <div className="cc-cmd-error">{cmdError}</div>}
|
||||
{!cmdError && cmdInput.trim() && (
|
||||
<div className="cc-cmd-hint">已解析配置,下方可查看可视化预览</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 解析后的可视化预览 */}
|
||||
{config.image && (
|
||||
<div className="cc-parsed-preview">
|
||||
<div className="cc-parsed-title">解析结果预览</div>
|
||||
<ParsedConfigDisplay config={config} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* ---- 镜像创建模式 ---- */
|
||||
<div className="cc-image-mode">
|
||||
{/* 镜像选择 */}
|
||||
<div className="cc-section">
|
||||
<div className="cc-section-title">
|
||||
<span>选择镜像</span>
|
||||
{imgLoading && <span className="cc-loading-hint">加载中...</span>}
|
||||
</div>
|
||||
<select
|
||||
className="cc-image-select"
|
||||
value={config.image}
|
||||
onChange={(e) => updateConfig('image', e.target.value)}
|
||||
>
|
||||
<option value="">-- 选择镜像 --</option>
|
||||
{images.map((img, idx) => {
|
||||
const name = getImageName(img)
|
||||
if (!name) return null
|
||||
return (
|
||||
<option key={idx} value={name}>
|
||||
{name} ({img.Size || img.size || '?'})
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
<input
|
||||
className="cc-image-manual"
|
||||
type="text"
|
||||
placeholder="或手动输入镜像名 (如 nginx:latest)"
|
||||
value={config.image}
|
||||
onChange={(e) => updateConfig('image', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分区导航 */}
|
||||
<div className="cc-section-nav">
|
||||
{sections.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
className={`cc-nav-btn ${activeSection === s.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveSection(s.id)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ---- 基本配置 ---- */}
|
||||
{activeSection === 'basic' && (
|
||||
<div className="cc-form-section">
|
||||
<div className="cc-form-row">
|
||||
<label className="cc-form-label">容器名称</label>
|
||||
<input
|
||||
className="cc-form-input"
|
||||
type="text"
|
||||
placeholder="my-container (可选)"
|
||||
value={config.name}
|
||||
onChange={(e) => updateConfig('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="cc-form-row">
|
||||
<label className="cc-form-label">重启策略</label>
|
||||
<select
|
||||
className="cc-form-input"
|
||||
value={config.restart}
|
||||
onChange={(e) => updateConfig('restart', e.target.value)}
|
||||
>
|
||||
<option value="">不自动重启</option>
|
||||
<option value="no">no</option>
|
||||
<option value="always">always</option>
|
||||
<option value="unless-stopped">unless-stopped</option>
|
||||
<option value="on-failure">on-failure</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="cc-form-row">
|
||||
<label className="cc-form-label">网络</label>
|
||||
<select
|
||||
className="cc-form-input"
|
||||
value={config.network}
|
||||
onChange={(e) => updateConfig('network', e.target.value)}
|
||||
>
|
||||
<option value="">默认 (bridge)</option>
|
||||
{networks.map((net) => (
|
||||
<option key={net} value={net}>{net}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="cc-form-row">
|
||||
<label className="cc-form-label">主机名</label>
|
||||
<input
|
||||
className="cc-form-input"
|
||||
type="text"
|
||||
placeholder="hostname (可选)"
|
||||
value={config.hostname}
|
||||
onChange={(e) => updateConfig('hostname', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="cc-form-row">
|
||||
<label className="cc-form-label">启动命令</label>
|
||||
<input
|
||||
className="cc-form-input"
|
||||
type="text"
|
||||
placeholder="覆盖容器默认启动命令 (可选)"
|
||||
value={config.command}
|
||||
onChange={(e) => updateConfig('command', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- 端口映射 ---- */}
|
||||
{activeSection === 'ports' && (
|
||||
<div className="cc-form-section">
|
||||
<div className="cc-section-subtitle">
|
||||
<span>端口映射</span>
|
||||
<button
|
||||
className="cc-add-btn"
|
||||
onClick={() => addArrayItem('ports', { host: '', container: '', protocol: 'tcp', hostIp: '' })}
|
||||
>
|
||||
+ 添加
|
||||
</button>
|
||||
</div>
|
||||
{config.ports.length === 0 ? (
|
||||
<div className="cc-empty-hint">尚未添加端口映射</div>
|
||||
) : (
|
||||
config.ports.map((port, idx) => (
|
||||
<div key={idx} className="cc-port-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="宿主机端口"
|
||||
value={port.host}
|
||||
onChange={(e) => updateArrayItem('ports', idx, 'host', e.target.value)}
|
||||
/>
|
||||
<span className="cc-arrow">:</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="容器端口"
|
||||
value={port.container}
|
||||
onChange={(e) => updateArrayItem('ports', idx, 'container', e.target.value)}
|
||||
/>
|
||||
<select
|
||||
value={port.protocol}
|
||||
onChange={(e) => updateArrayItem('ports', idx, 'protocol', e.target.value)}
|
||||
>
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
</select>
|
||||
<button
|
||||
className="cc-remove-btn"
|
||||
onClick={() => removeArrayItem('ports', idx)}
|
||||
title="删除"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- 挂载卷 ---- */}
|
||||
{activeSection === 'volumes' && (
|
||||
<div className="cc-form-section">
|
||||
<div className="cc-section-subtitle">
|
||||
<span>挂载卷</span>
|
||||
<button
|
||||
className="cc-add-btn"
|
||||
onClick={() => addArrayItem('volumes', { host: '', container: '', mode: 'rw' })}
|
||||
>
|
||||
+ 添加
|
||||
</button>
|
||||
</div>
|
||||
{config.volumes.length === 0 ? (
|
||||
<div className="cc-empty-hint">尚未添加挂载卷</div>
|
||||
) : (
|
||||
config.volumes.map((vol, idx) => (
|
||||
<div key={idx} className="cc-volume-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="宿主机路径"
|
||||
value={vol.host}
|
||||
onChange={(e) => updateArrayItem('volumes', idx, 'host', e.target.value)}
|
||||
/>
|
||||
<span className="cc-arrow">:</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="容器内路径"
|
||||
value={vol.container}
|
||||
onChange={(e) => updateArrayItem('volumes', idx, 'container', e.target.value)}
|
||||
/>
|
||||
<select
|
||||
value={vol.mode}
|
||||
onChange={(e) => updateArrayItem('volumes', idx, 'mode', e.target.value)}
|
||||
>
|
||||
<option value="rw">读写 (rw)</option>
|
||||
<option value="ro">只读 (ro)</option>
|
||||
</select>
|
||||
<button
|
||||
className="cc-remove-btn"
|
||||
onClick={() => removeArrayItem('volumes', idx)}
|
||||
title="删除"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- 环境变量 ---- */}
|
||||
{activeSection === 'env' && (
|
||||
<div className="cc-form-section">
|
||||
<div className="cc-section-subtitle">
|
||||
<span>环境变量</span>
|
||||
<button
|
||||
className="cc-add-btn"
|
||||
onClick={() => addArrayItem('env', { key: '', value: '' })}
|
||||
>
|
||||
+ 添加
|
||||
</button>
|
||||
</div>
|
||||
{config.env.length === 0 ? (
|
||||
<div className="cc-empty-hint">尚未添加环境变量</div>
|
||||
) : (
|
||||
config.env.map((env, idx) => (
|
||||
<div key={idx} className="cc-env-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="变量名 (KEY)"
|
||||
value={env.key}
|
||||
onChange={(e) => updateArrayItem('env', idx, 'key', e.target.value)}
|
||||
/>
|
||||
<span className="cc-arrow">=</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="值 (value)"
|
||||
value={env.value}
|
||||
onChange={(e) => updateArrayItem('env', idx, 'value', e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="cc-remove-btn"
|
||||
onClick={() => removeArrayItem('env', idx)}
|
||||
title="删除"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- 高级配置 ---- */}
|
||||
{activeSection === 'advanced' && (
|
||||
<div className="cc-form-section">
|
||||
<div className="cc-form-row">
|
||||
<label className="cc-form-label">工作目录</label>
|
||||
<input
|
||||
className="cc-form-input"
|
||||
type="text"
|
||||
placeholder="/app (可选)"
|
||||
value={config.workdir}
|
||||
onChange={(e) => updateConfig('workdir', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="cc-form-row">
|
||||
<label className="cc-form-label">运行用户</label>
|
||||
<input
|
||||
className="cc-form-input"
|
||||
type="text"
|
||||
placeholder="uid:gid 或用户名 (可选)"
|
||||
value={config.user}
|
||||
onChange={(e) => updateConfig('user', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="cc-form-row">
|
||||
<label className="cc-form-label">内存限制</label>
|
||||
<input
|
||||
className="cc-form-input"
|
||||
type="text"
|
||||
placeholder="512m 或 1g (可选)"
|
||||
value={config.memory}
|
||||
onChange={(e) => updateConfig('memory', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="cc-form-row">
|
||||
<label className="cc-form-label">CPU 限制</label>
|
||||
<input
|
||||
className="cc-form-input"
|
||||
type="text"
|
||||
placeholder="1.5 (可选)"
|
||||
value={config.cpu}
|
||||
onChange={(e) => updateConfig('cpu', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="cc-form-row">
|
||||
<label className="cc-form-label">DNS</label>
|
||||
<input
|
||||
className="cc-form-input"
|
||||
type="text"
|
||||
placeholder="8.8.8.8 (可选)"
|
||||
value={config.dns}
|
||||
onChange={(e) => updateConfig('dns', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配置可视化预览 */}
|
||||
<div className="cc-preview-section">
|
||||
<div className="cc-preview-title">配置可视化</div>
|
||||
<ParsedConfigDisplay config={config} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部:命令预览 + 创建按钮 */}
|
||||
<div className="cc-footer">
|
||||
{createError && <div className="cc-create-error">{createError}</div>}
|
||||
<div className="cc-cmd-preview">
|
||||
<span className="cc-cmd-preview-label">命令预览</span>
|
||||
<code className="cc-cmd-preview-code">{previewCmd || '(请先配置容器)'}</code>
|
||||
</div>
|
||||
<div className="cc-footer-actions">
|
||||
<button className="btn btn-ghost" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleCreate}
|
||||
disabled={creating || (mode === 'image' && !config.image) || (mode === 'command' && !cmdInput.trim())}
|
||||
>
|
||||
{creating ? '创建中...' : '创建容器'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 可视化配置展示组件 ----
|
||||
function ParsedConfigDisplay({ config }) {
|
||||
const items = []
|
||||
|
||||
if (config.image) items.push({ label: '镜像', value: config.image, type: 'image' })
|
||||
if (config.name) items.push({ label: '名称', value: config.name })
|
||||
if (config.restart) items.push({ label: '重启策略', value: config.restart })
|
||||
if (config.network) items.push({ label: '网络', value: config.network })
|
||||
if (config.hostname) items.push({ label: '主机名', value: config.hostname })
|
||||
if (config.workdir) items.push({ label: '工作目录', value: config.workdir })
|
||||
if (config.user) items.push({ label: '用户', value: config.user })
|
||||
if (config.memory) items.push({ label: '内存限制', value: config.memory })
|
||||
if (config.cpu) items.push({ label: 'CPU 限制', value: config.cpu })
|
||||
if (config.dns) items.push({ label: 'DNS', value: config.dns })
|
||||
if (config.command) items.push({ label: '启动命令', value: config.command })
|
||||
if (config.privileged) items.push({ label: '特权模式', value: '是' })
|
||||
|
||||
return (
|
||||
<div className="cc-parsed-config">
|
||||
{/* 基本信息 */}
|
||||
{items.length > 0 && (
|
||||
<div className="cc-parsed-group">
|
||||
<div className="cc-parsed-group-title">基本信息</div>
|
||||
<div className="cc-parsed-items">
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="cc-parsed-item">
|
||||
<span className="cc-parsed-label">{item.label}</span>
|
||||
<span className={`cc-parsed-value ${item.type === 'image' ? 'cc-value-image' : ''}`}>
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 端口映射 */}
|
||||
{config.ports.length > 0 && (
|
||||
<div className="cc-parsed-group">
|
||||
<div className="cc-parsed-group-title">端口映射 ({config.ports.length})</div>
|
||||
<div className="cc-parsed-table">
|
||||
{config.ports.map((port, idx) => {
|
||||
const host = [port.hostIp, port.host].filter(Boolean).join(':')
|
||||
const hostDisplay = host || '(随机)'
|
||||
return (
|
||||
<div key={idx} className="cc-parsed-row">
|
||||
<span className="cc-port-host">{hostDisplay}</span>
|
||||
<span className="cc-arrow-icon">{'->'}</span>
|
||||
<span className="cc-port-container">{port.container}</span>
|
||||
<span className="cc-port-proto">{port.protocol}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 挂载卷 */}
|
||||
{config.volumes.length > 0 && (
|
||||
<div className="cc-parsed-group">
|
||||
<div className="cc-parsed-group-title">挂载卷 ({config.volumes.length})</div>
|
||||
<div className="cc-parsed-table">
|
||||
{config.volumes.map((vol, idx) => (
|
||||
<div key={idx} className="cc-parsed-row">
|
||||
<span className="cc-vol-host" title={vol.host}>{vol.host || '(匿名卷)'}</span>
|
||||
<span className="cc-arrow-icon">{'->'}</span>
|
||||
<span className="cc-vol-container">{vol.container}</span>
|
||||
<span className="cc-vol-mode">{vol.mode}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 环境变量 */}
|
||||
{config.env.length > 0 && (
|
||||
<div className="cc-parsed-group">
|
||||
<div className="cc-parsed-group-title">环境变量 ({config.env.length})</div>
|
||||
<div className="cc-parsed-table">
|
||||
{config.env.map((env, idx) => (
|
||||
<div key={idx} className="cc-parsed-row">
|
||||
<span className="cc-env-key">{env.key}</span>
|
||||
<span className="cc-eq">=</span>
|
||||
<span className="cc-env-value">{env.value || '(空)'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 && config.ports.length === 0 && config.volumes.length === 0 && config.env.length === 0 && (
|
||||
<div className="cc-empty-hint">无配置</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/* ============================================================
|
||||
容器列表
|
||||
============================================================ */
|
||||
.container-list-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ---- 工具栏 ---- */
|
||||
.container-list-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.filter-checkbox input {
|
||||
cursor: pointer;
|
||||
accent-color: var(--brand);
|
||||
}
|
||||
|
||||
.list-error-msg {
|
||||
font-size: 13px;
|
||||
color: var(--status-critical);
|
||||
}
|
||||
|
||||
/* ---- 表格容器 ---- */
|
||||
.container-table-wrap {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 280px);
|
||||
}
|
||||
|
||||
.container-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.container-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.container-table th {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: var(--surface-dim);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.container-table td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
color: var(--ink);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.container-table tbody tr:hover {
|
||||
background: var(--surface-dim);
|
||||
}
|
||||
|
||||
.container-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* ---- 单元格 ---- */
|
||||
.cell-name {
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-image {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--ink-secondary);
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-ports {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--brand);
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-created {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- 状态标签 ---- */
|
||||
.container-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: var(--status-good);
|
||||
}
|
||||
|
||||
.status-stopped {
|
||||
background: rgba(156, 163, 175, 0.15);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.status-created {
|
||||
background: rgba(91, 110, 245, 0.1);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.status-restarting {
|
||||
background: rgba(249, 115, 22, 0.1);
|
||||
color: var(--status-serious);
|
||||
}
|
||||
|
||||
.status-paused {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--status-warning);
|
||||
}
|
||||
|
||||
.status-unknown {
|
||||
background: var(--surface-dim);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
/* ---- 操作按钮 ---- */
|
||||
.cell-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.container-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.action-start {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: var(--status-good);
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.action-start:hover:not(:disabled) {
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
}
|
||||
|
||||
.action-stop {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--status-warning);
|
||||
border-color: rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.action-stop:hover:not(:disabled) {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
}
|
||||
|
||||
.action-restart {
|
||||
background: rgba(91, 110, 245, 0.08);
|
||||
color: var(--brand);
|
||||
border-color: rgba(91, 110, 245, 0.2);
|
||||
}
|
||||
|
||||
.action-restart:hover:not(:disabled) {
|
||||
background: var(--brand-soft-hover);
|
||||
}
|
||||
|
||||
.action-log {
|
||||
background: transparent;
|
||||
color: var(--ink-secondary);
|
||||
border-color: var(--hairline-strong);
|
||||
}
|
||||
|
||||
.action-log:hover:not(:disabled) {
|
||||
color: var(--brand);
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-soft);
|
||||
}
|
||||
|
||||
.action-delete {
|
||||
background: transparent;
|
||||
color: var(--status-critical);
|
||||
border-color: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.action-delete:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
/* ---- 确认删除弹窗 ---- */
|
||||
.confirm-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
padding: 28px 32px;
|
||||
max-width: 420px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.confirm-dialog h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.confirm-dialog p {
|
||||
font-size: 14px;
|
||||
color: var(--ink-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.confirm-dialog p code {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--brand);
|
||||
background: var(--brand-soft);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.confirm-warning {
|
||||
color: var(--status-critical) !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import './ContainerList.css'
|
||||
import DockerLog from './DockerLog'
|
||||
import ContainerCreate from './ContainerCreate'
|
||||
|
||||
export default function ContainerList({ dockerReady }) {
|
||||
const [containers, setContainers] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const [acting, setActing] = useState({}) // { containerId: true/false }
|
||||
const [logContainer, setLogContainer] = useState(null) // { id, name } or null
|
||||
const [confirmDelete, setConfirmDelete] = useState(null) // { id, name } or null
|
||||
const [onlyRunning, setOnlyRunning] = useState(false)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const autoRefreshRef = useRef(null)
|
||||
|
||||
const fetchContainers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
// 使用 JSON 格式输出,方便解析
|
||||
const cmd = onlyRunning
|
||||
? 'docker ps --format "{{json .}}"'
|
||||
: 'docker ps -a --format "{{json .}}"'
|
||||
|
||||
const r = await window.panel.dockerExec(cmd, 15000)
|
||||
setLoading(false)
|
||||
|
||||
if (!r.ok || r.code !== 0) {
|
||||
setError(r.message || r.stderr || '获取容器列表失败')
|
||||
setContainers([])
|
||||
return
|
||||
}
|
||||
|
||||
const lines = (r.stdout || '').trim().split('\n').filter(Boolean)
|
||||
const list = lines.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
setContainers(list)
|
||||
}, [onlyRunning])
|
||||
|
||||
// 初始加载 + refreshKey 变化时重新加载
|
||||
useEffect(() => {
|
||||
fetchContainers()
|
||||
}, [fetchContainers, refreshKey])
|
||||
|
||||
// 自动刷新(每 10 秒)
|
||||
useEffect(() => {
|
||||
if (autoRefreshRef.current) clearInterval(autoRefreshRef.current)
|
||||
autoRefreshRef.current = setInterval(() => {
|
||||
// 不显示 loading 状态的静默刷新
|
||||
const cmd = onlyRunning
|
||||
? 'docker ps --format "{{json .}}"'
|
||||
: 'docker ps -a --format "{{json .}}"'
|
||||
window.panel.dockerExec(cmd, 15000).then((r) => {
|
||||
if (r.ok && r.code === 0) {
|
||||
const lines = (r.stdout || '').trim().split('\n').filter(Boolean)
|
||||
const list = lines.map((line) => {
|
||||
try { return JSON.parse(line) } catch { return null }
|
||||
}).filter(Boolean)
|
||||
setContainers(list)
|
||||
}
|
||||
})
|
||||
}, 10000)
|
||||
return () => {
|
||||
if (autoRefreshRef.current) clearInterval(autoRefreshRef.current)
|
||||
}
|
||||
}, [onlyRunning])
|
||||
|
||||
// ---- 容器操作 ----
|
||||
const doAction = useCallback(async (containerId, action) => {
|
||||
setActing((prev) => ({ ...prev, [containerId]: true }))
|
||||
const cmdMap = {
|
||||
start: 'docker start',
|
||||
stop: 'docker stop',
|
||||
restart: 'docker restart',
|
||||
}
|
||||
const r = await window.panel.dockerExec(`${cmdMap[action]} ${containerId}`, 30000)
|
||||
setActing((prev) => ({ ...prev, [containerId]: false }))
|
||||
|
||||
if (r.ok && r.code === 0) {
|
||||
// 刷新列表
|
||||
setRefreshKey((k) => k + 1)
|
||||
} else {
|
||||
setError(`${action} 失败: ${r.stderr || r.message || '未知错误'}`)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const doDelete = useCallback(async (containerId) => {
|
||||
setActing((prev) => ({ ...prev, [containerId]: true }))
|
||||
// 先 stop 再 rm,避免 "device is busy"
|
||||
const r = await window.panel.dockerExec(
|
||||
`docker rm -f ${containerId} 2>&1`,
|
||||
30000
|
||||
)
|
||||
setActing((prev) => ({ ...prev, [containerId]: false }))
|
||||
setConfirmDelete(null)
|
||||
|
||||
if (r.ok && r.code === 0) {
|
||||
setRefreshKey((k) => k + 1)
|
||||
} else {
|
||||
setError(`删除失败: ${r.stderr || r.message || '未知错误'}`)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 格式化容器状态
|
||||
const formatStatus = (status) => {
|
||||
if (!status) return '-'
|
||||
if (status.startsWith('Up')) return { text: '运行中', className: 'status-running' }
|
||||
if (status.startsWith('Exited')) return { text: '已停止', className: 'status-stopped' }
|
||||
if (status.startsWith('Created')) return { text: '已创建', className: 'status-created' }
|
||||
if (status.startsWith('Restarting')) return { text: '重启中', className: 'status-restarting' }
|
||||
if (status.startsWith('Pausing') || status.startsWith('Paused')) return { text: '已暂停', className: 'status-paused' }
|
||||
return { text: status, className: 'status-unknown' }
|
||||
}
|
||||
|
||||
// 解析容器名(docker ps --format 的 Names 字段)
|
||||
const getContainerName = (c) => {
|
||||
// Names 可能是逗号分隔的多个名称,取第一个
|
||||
const names = (c.Names || c.names || '').split(',')
|
||||
return names[0] || c.ID?.slice(0, 12) || '-'
|
||||
}
|
||||
|
||||
const getContainerId = (c) => c.ID || c.Id || c.id || ''
|
||||
|
||||
return (
|
||||
<div className="container-list-panel">
|
||||
{/* 工具栏 */}
|
||||
<div className="container-list-toolbar">
|
||||
<div className="toolbar-left">
|
||||
<label className="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyRunning}
|
||||
onChange={(e) => setOnlyRunning(e.target.checked)}
|
||||
/>
|
||||
<span>仅显示运行中</span>
|
||||
</label>
|
||||
{error && <span className="list-error-msg">{error}</span>}
|
||||
</div>
|
||||
<div className="toolbar-right">
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
+ 创建容器
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setRefreshKey((k) => k + 1)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 容器表格 */}
|
||||
{loading && containers.length === 0 ? (
|
||||
<div className="container-loading">
|
||||
<div className="docker-spinner" />
|
||||
<span>正在获取容器列表...</span>
|
||||
</div>
|
||||
) : containers.length === 0 ? (
|
||||
<div className="container-empty">
|
||||
<p>{onlyRunning ? '当前没有运行中的容器' : '当前没有任何容器'}</p>
|
||||
<button className="btn btn-ghost" onClick={() => setRefreshKey((k) => k + 1)}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="container-table-wrap">
|
||||
<table className="container-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>镜像</th>
|
||||
<th>状态</th>
|
||||
<th>端口</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{containers.map((c) => {
|
||||
const id = getContainerId(c)
|
||||
const name = getContainerName(c)
|
||||
const statusInfo = formatStatus(c.Status || c.status)
|
||||
const isRunning = (c.Status || c.status || '').startsWith('Up')
|
||||
const busy = acting[id]
|
||||
|
||||
return (
|
||||
<tr key={id}>
|
||||
<td className="cell-name" title={name}>{name}</td>
|
||||
<td className="cell-image" title={c.Image || c.image}>{c.Image || c.image || '-'}</td>
|
||||
<td>
|
||||
<span className={`container-status ${statusInfo.className}`}>
|
||||
{statusInfo.text}
|
||||
</span>
|
||||
</td>
|
||||
<td className="cell-ports" title={c.Ports || c.ports}>
|
||||
{(c.Ports || c.ports || '').trim() || '-'}
|
||||
</td>
|
||||
<td className="cell-created" title={c.CreatedAt || c.createdat}>
|
||||
{c.CreatedAt || c.Created || c.createdat || '-'}
|
||||
</td>
|
||||
<td className="cell-actions">
|
||||
<div className="container-actions">
|
||||
{!isRunning && (
|
||||
<button
|
||||
className="action-btn action-start"
|
||||
onClick={() => doAction(id, 'start')}
|
||||
disabled={busy}
|
||||
title="启动"
|
||||
>
|
||||
{busy ? '...' : '启动'}
|
||||
</button>
|
||||
)}
|
||||
{isRunning && (
|
||||
<>
|
||||
<button
|
||||
className="action-btn action-stop"
|
||||
onClick={() => doAction(id, 'stop')}
|
||||
disabled={busy}
|
||||
title="停止"
|
||||
>
|
||||
{busy ? '...' : '停止'}
|
||||
</button>
|
||||
<button
|
||||
className="action-btn action-restart"
|
||||
onClick={() => doAction(id, 'restart')}
|
||||
disabled={busy}
|
||||
title="重启"
|
||||
>
|
||||
{busy ? '...' : '重启'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="action-btn action-log"
|
||||
onClick={() => setLogContainer({ id, name })}
|
||||
title="查看日志"
|
||||
>
|
||||
日志
|
||||
</button>
|
||||
<button
|
||||
className="action-btn action-delete"
|
||||
onClick={() => setConfirmDelete({ id, name })}
|
||||
disabled={busy}
|
||||
title="删除"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 日志弹窗 */}
|
||||
{logContainer && (
|
||||
<DockerLog
|
||||
containerId={logContainer.id}
|
||||
containerName={logContainer.name}
|
||||
onClose={() => setLogContainer(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{confirmDelete && (
|
||||
<div className="confirm-overlay" onClick={() => setConfirmDelete(null)}>
|
||||
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>确认删除容器</h3>
|
||||
<p>确定要删除容器 <code>{confirmDelete.name}</code> 吗?</p>
|
||||
<p className="confirm-warning">此操作不可撤销,容器将被强制停止并删除。</p>
|
||||
<div className="confirm-actions">
|
||||
<button className="btn btn-ghost" onClick={() => setConfirmDelete(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => doDelete(confirmDelete.id)}
|
||||
disabled={acting[confirmDelete.id]}
|
||||
>
|
||||
{acting[confirmDelete.id] ? '删除中...' : '确认删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 创建容器弹窗 */}
|
||||
{showCreate && (
|
||||
<ContainerCreate
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={() => {
|
||||
setShowCreate(false)
|
||||
setRefreshKey((k) => k + 1)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/* ============================================================
|
||||
容器模块
|
||||
============================================================ */
|
||||
.container-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ---- Docker 状态栏 ---- */
|
||||
.docker-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.docker-status-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.docker-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.docker-status-text {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.docker-version-tag {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
padding: 2px 8px;
|
||||
background: var(--surface-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.docker-status-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---- 容器子菜单 ---- */
|
||||
.container-submenu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 4px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.submenu-left {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.submenu-item {
|
||||
padding: 8px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: var(--ink-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.submenu-item:hover {
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.submenu-item.active {
|
||||
color: var(--brand);
|
||||
border-bottom-color: var(--brand);
|
||||
}
|
||||
|
||||
/* ---- 容器概览 ---- */
|
||||
.container-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overview-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overview-stat-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--ink-strong);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-value.stat-path {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ---- 配置文件卡片 ---- */
|
||||
.overview-config-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.config-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.config-label {
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-path {
|
||||
font-size: 13px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--brand);
|
||||
background: var(--brand-soft);
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.config-content-wrap {
|
||||
padding: 16px 20px;
|
||||
overflow: auto;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.config-content {
|
||||
font-size: 13px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--ink);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---- 加载 / 空状态 ---- */
|
||||
.container-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 40px;
|
||||
justify-content: center;
|
||||
color: var(--ink-muted);
|
||||
font-size: 14px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.container-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 40px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 14px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.container-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 15px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
/* ---- Docker 未运行时的大卡片 ---- */
|
||||
.docker-check-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 40px 32px;
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.docker-check-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.docker-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--track);
|
||||
border-top-color: var(--brand);
|
||||
border-radius: 50%;
|
||||
animation: docker-spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes docker-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.docker-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.docker-ok .docker-icon {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: var(--status-good);
|
||||
}
|
||||
|
||||
.docker-missing .docker-icon {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--status-warning);
|
||||
}
|
||||
|
||||
.docker-error .docker-icon {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--status-critical);
|
||||
}
|
||||
|
||||
.docker-check-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.docker-check-info h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.docker-check-info p {
|
||||
font-size: 14px;
|
||||
color: var(--ink-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.docker-check-info .btn {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import './ContainerPanel.css'
|
||||
import ContainerList from './ContainerList'
|
||||
import ImageList from './ImageList'
|
||||
import AppStore from './AppStore'
|
||||
import TaskCenter from './TaskCenter'
|
||||
import DockerConfig from './DockerConfig'
|
||||
import NetworkPanel from './NetworkPanel'
|
||||
import VolumePanel from './VolumePanel'
|
||||
|
||||
export default function ContainerPanel({ dockerReady }) {
|
||||
// docker 状态:checking | running | stopped | notInstalled | error
|
||||
const [dockerStatus, setDockerStatus] = useState('checking')
|
||||
const [dockerVersion, setDockerVersion] = useState('')
|
||||
const [acting, setActing] = useState(false) // 启动/停止中
|
||||
|
||||
// 容器子菜单
|
||||
const [subTab, setSubTab] = useState('概览')
|
||||
|
||||
// 概览数据
|
||||
const [overview, setOverview] = useState(null)
|
||||
const [overviewLoading, setOverviewLoading] = useState(false)
|
||||
|
||||
// 检测 Docker 状态(走 docker 专用连接)
|
||||
const checkDocker = useCallback(async () => {
|
||||
if (!dockerReady) {
|
||||
setDockerStatus('error')
|
||||
return
|
||||
}
|
||||
setDockerStatus('checking')
|
||||
|
||||
// 检测 docker 命令是否存在(用 timeout 防止卡死)
|
||||
const r = await window.panel.dockerExec('timeout 10 command -v docker 2>/dev/null && docker --version')
|
||||
if (!r.ok) {
|
||||
setDockerStatus('error')
|
||||
return
|
||||
}
|
||||
if (r.code !== 0) {
|
||||
setDockerStatus('notInstalled')
|
||||
return
|
||||
}
|
||||
|
||||
const verLine = (r.stdout || '').trim().split('\n').pop()
|
||||
setDockerVersion(verLine || 'Docker')
|
||||
|
||||
// 检测 daemon 是否运行(用 timeout 防止 docker info 卡死)
|
||||
const dr = await window.panel.dockerExec('timeout 10 docker info --format "{{.ServerVersion}}" 2>/dev/null')
|
||||
if (dr.ok && dr.code === 0 && dr.stdout.trim()) {
|
||||
setDockerStatus('running')
|
||||
} else {
|
||||
setDockerStatus('stopped')
|
||||
}
|
||||
}, [dockerReady])
|
||||
|
||||
// 等待 Docker 状态变化(轮询,最多 maxWait 毫秒)
|
||||
const waitForDockerState = useCallback(async (expectRunning, maxWait = 30000) => {
|
||||
const interval = 2000
|
||||
const steps = Math.ceil(maxWait / interval)
|
||||
for (let i = 0; i < steps; i++) {
|
||||
await new Promise((r) => setTimeout(r, interval))
|
||||
const dr = await window.panel.dockerExec('timeout 5 docker info --format "{{.ServerVersion}}" 2>/dev/null')
|
||||
const isRunning = dr.ok && dr.code === 0 && dr.stdout.trim()
|
||||
if (expectRunning === isRunning) return true
|
||||
}
|
||||
return false
|
||||
}, [])
|
||||
|
||||
// 启动 Docker
|
||||
const startDocker = useCallback(async () => {
|
||||
setActing(true)
|
||||
// nohup 防止 SSH channel 关闭时 systemctl 被杀
|
||||
await window.panel.dockerExec('nohup systemctl start docker >/dev/null 2>&1 &', 10000)
|
||||
// 轮询等待 Docker 启动(最多等 30 秒)
|
||||
await waitForDockerState(true, 30000)
|
||||
await checkDocker()
|
||||
setActing(false)
|
||||
}, [checkDocker, waitForDockerState])
|
||||
|
||||
// 停止 Docker(强制)
|
||||
const stopDocker = useCallback(async () => {
|
||||
setActing(true)
|
||||
// 第一步:先杀所有运行中的容器(防止 dockerd 等待容器退出而卡死)
|
||||
await window.panel.dockerExec('docker stop $(docker ps -q) 2>/dev/null; true', 15000)
|
||||
// 第二步:systemctl kill 发 SIGKILL,不会像 stop 那样等待优雅退出
|
||||
await window.panel.dockerExec('systemctl kill --kill-who=all docker 2>&1; true', 10000)
|
||||
// 第三步:兜底——直接 kill dockerd 进程
|
||||
await window.panel.dockerExec('kill -9 $(pidof dockerd) 2>/dev/null; true', 5000)
|
||||
// 轮询等待 Docker 停止(最多等 15 秒)
|
||||
await waitForDockerState(false, 15000)
|
||||
await checkDocker()
|
||||
setActing(false)
|
||||
}, [checkDocker, waitForDockerState])
|
||||
|
||||
// dockerReady 变为 true 时自动检测
|
||||
useEffect(() => {
|
||||
if (dockerReady) {
|
||||
checkDocker()
|
||||
} else {
|
||||
// Docker 连接未就绪时,5 秒后超时报错,避免永远卡在"检测中"
|
||||
const timer = setTimeout(() => setDockerStatus('error'), 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [dockerReady, checkDocker])
|
||||
|
||||
// 监听 docker 连接断开
|
||||
useEffect(() => {
|
||||
const cleanup = window.panel.onDockerDisconnected(() => {
|
||||
setDockerStatus('error')
|
||||
})
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
// 加载容器概览
|
||||
const loadOverview = useCallback(async () => {
|
||||
if (dockerStatus !== 'running') return
|
||||
setOverviewLoading(true)
|
||||
|
||||
// 一条命令拿到所有概览数据
|
||||
const cmd = [
|
||||
'echo "CONTAINERS=$(docker ps -q 2>/dev/null | wc -l)"',
|
||||
'echo "IMAGES=$(docker images -q 2>/dev/null | wc -l)"',
|
||||
'echo "MEM=$(docker stats --no-stream --format "{{.MemUsage}}" 2>/dev/null | head -1)"',
|
||||
'echo "CPU=$(docker stats --no-stream --format "{{.CPUPerc}}" 2>/dev/null | head -1)"',
|
||||
'echo "CACHE=$(docker system df --format "{{.Size}}" 2>/dev/null | head -1)"',
|
||||
'echo "DOCKER_ROOT=$(docker info --format "{{.DockerRootDir}}" 2>/dev/null)"',
|
||||
'echo "CONFIG_PATH=$(ls /etc/docker/daemon.json 2>/dev/null && echo /etc/docker/daemon.json || echo "")"',
|
||||
].join(' && ')
|
||||
|
||||
const r = await window.panel.dockerExec(cmd, 15000)
|
||||
setOverviewLoading(false)
|
||||
|
||||
if (!r.ok || r.code !== 0) {
|
||||
setOverview(null)
|
||||
return
|
||||
}
|
||||
|
||||
const lines = (r.stdout || '').trim().split('\n')
|
||||
const data = {}
|
||||
for (const line of lines) {
|
||||
const idx = line.indexOf('=')
|
||||
if (idx > 0) {
|
||||
const key = line.slice(0, idx)
|
||||
const val = line.slice(idx + 1)
|
||||
data[key] = val.trim()
|
||||
}
|
||||
}
|
||||
|
||||
// 读取配置文件内容
|
||||
let configContent = ''
|
||||
if (data.CONFIG_PATH) {
|
||||
const cr = await window.panel.dockerExec(`cat '${data.CONFIG_PATH}' 2>/dev/null`, 5000)
|
||||
if (cr.ok && cr.code === 0) {
|
||||
configContent = cr.stdout.trim() || '(空文件)'
|
||||
}
|
||||
}
|
||||
|
||||
setOverview({
|
||||
containers: parseInt(data.CONTAINERS) || 0,
|
||||
images: parseInt(data.IMAGES) || 0,
|
||||
memUsage: data.MEM || '-',
|
||||
cpuUsage: data.CPU || '-',
|
||||
cache: data.CACHE || '-',
|
||||
dockerRoot: data.DOCKER_ROOT || '/var/lib/docker',
|
||||
configPath: data.CONFIG_PATH || '未找到',
|
||||
configContent,
|
||||
})
|
||||
}, [dockerStatus])
|
||||
|
||||
// 切到概览 tab 且 docker 运行中时自动加载
|
||||
useEffect(() => {
|
||||
if (subTab === '概览' && dockerStatus === 'running' && !overview) {
|
||||
loadOverview()
|
||||
}
|
||||
}, [subTab, dockerStatus, overview, loadOverview])
|
||||
|
||||
// ---- 渲染 ----
|
||||
|
||||
const statusLabel = {
|
||||
checking: { text: '检测中...', color: 'var(--ink-muted)' },
|
||||
running: { text: '运行中', color: 'var(--status-good)' },
|
||||
stopped: { text: '已停止', color: 'var(--status-warning)' },
|
||||
notInstalled: { text: '未安装', color: 'var(--ink-muted)' },
|
||||
error: { text: '连接异常', color: 'var(--status-critical)' },
|
||||
}
|
||||
|
||||
const cur = statusLabel[dockerStatus] || statusLabel.error
|
||||
|
||||
return (
|
||||
<div className="container-panel">
|
||||
{/* Docker 状态栏 */}
|
||||
<div className="docker-status-bar">
|
||||
<div className="docker-status-left">
|
||||
<span className="docker-status-dot" style={{ background: cur.color }} />
|
||||
<span className="docker-status-text" style={{ color: cur.color }}>
|
||||
Docker {cur.text}
|
||||
</span>
|
||||
{dockerVersion && dockerStatus === 'running' && (
|
||||
<span className="docker-version-tag">{dockerVersion}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="docker-status-actions">
|
||||
{dockerStatus === 'running' && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={stopDocker} disabled={acting}>
|
||||
{acting ? '停止中...' : '停止'}
|
||||
</button>
|
||||
)}
|
||||
{dockerStatus === 'stopped' && (
|
||||
<button className="btn btn-primary btn-sm" onClick={startDocker} disabled={acting}>
|
||||
{acting ? '启动中...' : '启动'}
|
||||
</button>
|
||||
)}
|
||||
{(dockerStatus === 'error' || dockerStatus === 'notInstalled') && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={checkDocker}>
|
||||
重新检测
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 容器子菜单 */}
|
||||
{dockerStatus === 'running' && (
|
||||
<>
|
||||
<div className="container-submenu">
|
||||
<div className="submenu-left">
|
||||
{['概览', '容器列表', '镜像', '应用商店', '任务中心', '配置', '网络', '卷'].map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`submenu-item ${subTab === tab ? 'active' : ''}`}
|
||||
onClick={() => setSubTab(tab)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 概览内容 */}
|
||||
{subTab === '概览' && (
|
||||
<div className="container-overview">
|
||||
{overviewLoading ? (
|
||||
<div className="container-loading">
|
||||
<div className="docker-spinner" />
|
||||
<span>正在获取 Docker 概览数据...</span>
|
||||
</div>
|
||||
) : overview ? (
|
||||
<>
|
||||
<div className="overview-stats-grid">
|
||||
<div className="overview-stat-card">
|
||||
<span className="stat-label">运行中的容器</span>
|
||||
<span className="stat-value">{overview.containers}</span>
|
||||
</div>
|
||||
<div className="overview-stat-card">
|
||||
<span className="stat-label">镜像个数</span>
|
||||
<span className="stat-value">{overview.images}</span>
|
||||
</div>
|
||||
<div className="overview-stat-card">
|
||||
<span className="stat-label">CPU 占用</span>
|
||||
<span className="stat-value">{overview.cpuUsage}</span>
|
||||
</div>
|
||||
<div className="overview-stat-card">
|
||||
<span className="stat-label">内存占用</span>
|
||||
<span className="stat-value">{overview.memUsage}</span>
|
||||
</div>
|
||||
<div className="overview-stat-card">
|
||||
<span className="stat-label">Docker 缓存</span>
|
||||
<span className="stat-value">{overview.cache}</span>
|
||||
</div>
|
||||
<div className="overview-stat-card">
|
||||
<span className="stat-label">存储路径</span>
|
||||
<span className="stat-value stat-path">{overview.dockerRoot}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overview-config-card">
|
||||
<div className="config-header">
|
||||
<span className="config-label">配置文件位置</span>
|
||||
<code className="config-path">{overview.configPath}</code>
|
||||
</div>
|
||||
<div className="config-content-wrap">
|
||||
<pre className="config-content">
|
||||
{overview.configContent || '(无配置文件或内容为空)'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="container-empty">
|
||||
<p>获取概览数据失败,请重试</p>
|
||||
<button className="btn btn-ghost" onClick={loadOverview}>重试</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 容器列表 */}
|
||||
{subTab === '容器列表' && (
|
||||
<ContainerList dockerReady={dockerReady} />
|
||||
)}
|
||||
|
||||
{/* 镜像列表 */}
|
||||
{subTab === '镜像' && (
|
||||
<ImageList dockerReady={dockerReady} />
|
||||
)}
|
||||
|
||||
{/* 应用商店 */}
|
||||
{subTab === '应用商店' && (
|
||||
<AppStore onViewTasks={() => setSubTab('任务中心')} />
|
||||
)}
|
||||
|
||||
{/* 任务中心 */}
|
||||
{subTab === '任务中心' && (
|
||||
<TaskCenter />
|
||||
)}
|
||||
|
||||
{/* Docker 配置 */}
|
||||
{subTab === '配置' && (
|
||||
<DockerConfig />
|
||||
)}
|
||||
|
||||
{/* 网络 */}
|
||||
{subTab === '网络' && (
|
||||
<NetworkPanel dockerReady={dockerReady} />
|
||||
)}
|
||||
|
||||
{/* 卷 */}
|
||||
{subTab === '卷' && (
|
||||
<VolumePanel dockerReady={dockerReady} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Docker 未运行时的大卡片提示 */}
|
||||
{dockerStatus !== 'running' && dockerStatus !== 'checking' && (
|
||||
<div className="docker-check-card">
|
||||
{dockerStatus === 'checking' ? (
|
||||
<div className="docker-check-status">
|
||||
<div className="docker-spinner" />
|
||||
<span>正在检测 Docker 环境...</span>
|
||||
</div>
|
||||
) : dockerStatus === 'notInstalled' ? (
|
||||
<div className="docker-check-status docker-missing">
|
||||
<div className="docker-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="docker-check-info">
|
||||
<h3>Docker 未安装</h3>
|
||||
<p>服务器尚未安装 Docker,请先安装后使用容器功能</p>
|
||||
</div>
|
||||
</div>
|
||||
) : dockerStatus === 'stopped' ? (
|
||||
<div className="docker-check-status docker-missing">
|
||||
<div className="docker-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="docker-check-info">
|
||||
<h3>Docker 守护进程未运行</h3>
|
||||
<p>Docker 已安装但未启动,请点击上方「启动」按钮</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="docker-check-status docker-error">
|
||||
<div className="docker-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="docker-check-info">
|
||||
<h3>Docker 连接异常</h3>
|
||||
<p>Docker 专用 SSH 连接不可用,请检查网络或重新连接</p>
|
||||
<button className="btn btn-ghost" onClick={checkDocker}>重试</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
// Docker 配置页:镜像加速源读写
|
||||
export default function DockerConfig() {
|
||||
const [mirrorText, setMirrorText] = useState('')
|
||||
const [originalMirror, setOriginalMirror] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [status, setStatus] = useState(null) // { type: 'ok'|'error', text }
|
||||
const [configPath, setConfigPath] = useState('/etc/docker/daemon.json')
|
||||
|
||||
// 加载当前配置
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 先查找配置文件路径
|
||||
const pathResult = await window.panel.dockerExec('ls /etc/docker/daemon.json 2>/dev/null && echo "EXISTS" || echo "NOT_EXISTS"')
|
||||
let exists = false
|
||||
if (pathResult.ok && (pathResult.stdout || '').includes('EXISTS')) {
|
||||
exists = true
|
||||
setConfigPath('/etc/docker/daemon.json')
|
||||
} else {
|
||||
// 可能 Docker root 模式路径不同
|
||||
const altResult = await window.panel.dockerExec('docker info --format "{{.DockerRootDir}}" 2>/dev/null')
|
||||
setConfigPath('/etc/docker/daemon.json')
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
// 读取现有配置文件
|
||||
const readResult = await window.panel.dockerExec('cat /etc/docker/daemon.json 2>/dev/null')
|
||||
if (readResult.ok && readResult.stdout.trim()) {
|
||||
const raw = readResult.stdout.trim()
|
||||
setMirrorText(raw)
|
||||
setOriginalMirror(raw)
|
||||
} else {
|
||||
// 文件存在但为空
|
||||
const defaultConfig = JSON.stringify({ "registry-mirrors": [] }, null, 2)
|
||||
setMirrorText(defaultConfig)
|
||||
setOriginalMirror('')
|
||||
}
|
||||
} else {
|
||||
// 文件不存在,给一个默认模板
|
||||
const defaultConfig = JSON.stringify({
|
||||
"registry-mirrors": [
|
||||
"https://docker.1panel.live",
|
||||
"https://hub.1panel.dev",
|
||||
"https://docker.1ms.run"
|
||||
],
|
||||
"dns": ["223.5.5.5", "114.114.114.114"]
|
||||
}, null, 2)
|
||||
setMirrorText(defaultConfig)
|
||||
setOriginalMirror('')
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
}, [loadConfig])
|
||||
|
||||
// 保存配置
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setStatus(null)
|
||||
|
||||
// 验证 JSON 格式
|
||||
try {
|
||||
JSON.parse(mirrorText)
|
||||
} catch (err) {
|
||||
setStatus({ type: 'error', text: 'JSON 格式错误: ' + err.message })
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 写入配置文件
|
||||
const writeCmd = `mkdir -p /etc/docker && cat > /etc/docker/daemon.json << 'DOCKERCONFIG_EOF'\n${mirrorText}\nDOCKERCONFIG_EOF`
|
||||
const writeResult = await window.panel.dockerExec(writeCmd, 10000)
|
||||
if (!writeResult.ok || writeResult.code !== 0) {
|
||||
setStatus({ type: 'error', text: '写入配置文件失败' })
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 重启 Docker 生效
|
||||
setStatus({ type: 'ok', text: '配置已保存,正在重启 Docker 服务...' })
|
||||
const restartResult = await window.panel.dockerExec('systemctl restart docker 2>&1', 30000)
|
||||
if (restartResult.ok && restartResult.code === 0) {
|
||||
setStatus({ type: 'ok', text: '配置已保存,Docker 已重启,镜像加速源生效' })
|
||||
setOriginalMirror(mirrorText)
|
||||
} else {
|
||||
setStatus({ type: 'error', text: '配置已保存但 Docker 重启失败: ' + (restartResult.stderr || restartResult.message || '未知错误') })
|
||||
}
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
// 恢复默认
|
||||
const handleReset = () => {
|
||||
const defaultConfig = JSON.stringify({
|
||||
"registry-mirrors": [
|
||||
"https://docker.1panel.live",
|
||||
"https://hub.1panel.dev",
|
||||
"https://docker.1ms.run"
|
||||
],
|
||||
"dns": ["223.5.5.5", "114.114.114.114"]
|
||||
}, null, 2)
|
||||
setMirrorText(defaultConfig)
|
||||
setStatus(null)
|
||||
}
|
||||
|
||||
const hasChanges = mirrorText !== originalMirror
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container-loading">
|
||||
<div className="docker-spinner" />
|
||||
<span>正在加载 Docker 配置...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="docker-config-page">
|
||||
<div className="docker-config-card">
|
||||
<h4>Docker 配置</h4>
|
||||
<p className="mirror-hint">
|
||||
编辑 <code>/etc/docker/daemon.json</code>,配置镜像加速源和 DNS。保存后自动重启 Docker 生效。
|
||||
</p>
|
||||
<textarea
|
||||
className="mirror-textarea"
|
||||
value={mirrorText}
|
||||
onChange={(e) => { setMirrorText(e.target.value); setStatus(null) }}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="docker-config-actions">
|
||||
<button className="btn btn-ghost" onClick={handleReset}>
|
||||
恢复默认
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
{saving ? '保存中...' : '保存并重启 Docker'}
|
||||
</button>
|
||||
</div>
|
||||
{status && (
|
||||
<div className={`docker-config-status docker-config-status-${status.type}`}>
|
||||
{status.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/* ============================================================
|
||||
Docker 日志弹窗
|
||||
============================================================ */
|
||||
.log-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.log-dialog {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
width: 90%;
|
||||
max-width: 900px;
|
||||
height: 80vh;
|
||||
max-height: 700px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- 头部 ---- */
|
||||
.log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.log-icon {
|
||||
color: var(--ink-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.log-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.log-id {
|
||||
font-size: 12px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--ink-muted);
|
||||
background: var(--surface-dim);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.log-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.log-close:hover {
|
||||
background: var(--surface-dim);
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
/* ---- 工具栏 ---- */
|
||||
.log-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
flex-shrink: 0;
|
||||
background: var(--surface-dim);
|
||||
}
|
||||
|
||||
.log-toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.log-tail-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.log-tail-select {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--card-bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.log-tail-select:focus {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.log-follow-label,
|
||||
.log-autoscroll-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.log-follow-label input,
|
||||
.log-autoscroll-label input {
|
||||
cursor: pointer;
|
||||
accent-color: var(--brand);
|
||||
}
|
||||
|
||||
/* ---- 错误提示 ---- */
|
||||
.log-error-bar {
|
||||
padding: 8px 20px;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: var(--status-critical);
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- 日志内容区 ---- */
|
||||
.log-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: #1e1e2e;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.log-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
font-size: 12.5px;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
color: #cdd6f4;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
padding: 16px 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 深色背景下的 spinner */
|
||||
.log-loading .docker-spinner {
|
||||
border-color: #45475a;
|
||||
border-top-color: var(--brand);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import './DockerLog.css'
|
||||
|
||||
export default function DockerLog({ containerId, containerName, onClose }) {
|
||||
const [logs, setLogs] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tailLines, setTailLines] = useState(200)
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
const [follow, setFollow] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const logBodyRef = useRef(null)
|
||||
const followTimerRef = useRef(null)
|
||||
|
||||
const fetchLogs = useCallback(async (isFollow = false) => {
|
||||
const cmd = `docker logs --tail ${tailLines}${isFollow ? ' -f' : ''} ${containerId} 2>&1`
|
||||
const r = await window.panel.dockerExec(cmd, isFollow ? 30000 : 15000)
|
||||
|
||||
if (!r.ok) {
|
||||
setError(r.message || '获取日志失败')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (r.code !== 0 && !r.stdout) {
|
||||
setError(r.stderr || '获取日志失败')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLogs(r.stdout || r.stderr || '(无日志输出)')
|
||||
setLoading(false)
|
||||
setError(null)
|
||||
}, [containerId, tailLines])
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
fetchLogs(false)
|
||||
}, [fetchLogs])
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (autoScroll && logBodyRef.current) {
|
||||
logBodyRef.current.scrollTop = logBodyRef.current.scrollHeight
|
||||
}
|
||||
}, [logs, autoScroll])
|
||||
|
||||
// follow 模式:定时拉取
|
||||
useEffect(() => {
|
||||
if (!follow) {
|
||||
if (followTimerRef.current) {
|
||||
clearInterval(followTimerRef.current)
|
||||
followTimerRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fetchLogs(false) // 立即拉一次
|
||||
followTimerRef.current = setInterval(() => {
|
||||
fetchLogs(false)
|
||||
}, 3000)
|
||||
|
||||
return () => {
|
||||
if (followTimerRef.current) {
|
||||
clearInterval(followTimerRef.current)
|
||||
followTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [follow, fetchLogs])
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!logBodyRef.current) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = logBodyRef.current
|
||||
const atBottom = scrollHeight - scrollTop - clientHeight < 50
|
||||
setAutoScroll(atBottom)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="log-overlay" onClick={onClose}>
|
||||
<div className="log-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 日志头部 */}
|
||||
<div className="log-header">
|
||||
<div className="log-title">
|
||||
<span className="log-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="8" y1="13" x2="16" y2="13" />
|
||||
<line x1="8" y1="17" x2="13" y2="17" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="log-name">{containerName}</span>
|
||||
<span className="log-id">{containerId.slice(0, 12)}</span>
|
||||
</div>
|
||||
<button className="log-close" onClick={onClose} title="关闭 (ESC)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 日志工具栏 */}
|
||||
<div className="log-toolbar">
|
||||
<div className="log-toolbar-left">
|
||||
<label className="log-tail-label">
|
||||
尾部
|
||||
<select
|
||||
value={tailLines}
|
||||
onChange={(e) => setTailLines(Number(e.target.value))}
|
||||
className="log-tail-select"
|
||||
>
|
||||
<option value={100}>100 行</option>
|
||||
<option value={200}>200 行</option>
|
||||
<option value={500}>500 行</option>
|
||||
<option value={1000}>1000 行</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="log-follow-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={follow}
|
||||
onChange={(e) => setFollow(e.target.checked)}
|
||||
/>
|
||||
<span>实时跟踪</span>
|
||||
</label>
|
||||
<label className="log-autoscroll-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoScroll}
|
||||
onChange={(e) => setAutoScroll(e.target.checked)}
|
||||
/>
|
||||
<span>自动滚动</span>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => fetchLogs(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '加载中...' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="log-error-bar">{error}</div>
|
||||
)}
|
||||
|
||||
{/* 日志内容 */}
|
||||
<div className="log-body" ref={logBodyRef} onScroll={handleScroll}>
|
||||
{loading ? (
|
||||
<div className="log-loading">
|
||||
<div className="docker-spinner" />
|
||||
<span>正在获取日志...</span>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="log-content">{logs}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/* ============================================================
|
||||
镜像列表
|
||||
============================================================ */
|
||||
.image-list-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ---- 工具栏 ---- */
|
||||
.image-list-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
/* ---- 表格容器 ---- */
|
||||
.image-table-wrap {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 280px);
|
||||
}
|
||||
|
||||
.image-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.image-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.image-table th {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: var(--surface-dim);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-table td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
color: var(--ink);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.image-table tbody tr:hover {
|
||||
background: var(--surface-dim);
|
||||
}
|
||||
|
||||
.image-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* ---- 单元格 ---- */
|
||||
.image-table .cell-name {
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-table .cell-id {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.image-table .cell-size {
|
||||
font-weight: 500;
|
||||
color: var(--ink-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-table .cell-created {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
拉取镜像弹窗
|
||||
============================================================ */
|
||||
.pull-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.pull-dialog {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
width: 90%;
|
||||
max-width: 560px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pull-header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pull-header h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pull-body {
|
||||
padding: 20px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 输入区 */
|
||||
.pull-input-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.pull-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.pull-input {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--ink);
|
||||
background: var(--card-bg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pull-input:focus {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* 常用镜像建议 */
|
||||
.pull-suggestions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pull-suggest-label {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.pull-suggest-btn {
|
||||
font-size: 12px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-dim);
|
||||
color: var(--brand);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.pull-suggest-btn:hover {
|
||||
background: var(--brand-soft);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.pull-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* 状态栏 */
|
||||
.pull-status-bar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pull-status-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pull-status-done {
|
||||
color: var(--status-good);
|
||||
}
|
||||
|
||||
.pull-status-error {
|
||||
color: var(--status-critical);
|
||||
}
|
||||
|
||||
/* 日志区 */
|
||||
.pull-log-area {
|
||||
background: #1e1e2e;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 16px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pull-log-content {
|
||||
font-size: 12px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: #cdd6f4;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import './ImageList.css'
|
||||
|
||||
export default function ImageList({ dockerReady }) {
|
||||
const [images, setImages] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const [acting, setActing] = useState({}) // { imageId: true/false }
|
||||
const [confirmDelete, setConfirmDelete] = useState(null) // { id, name } or null
|
||||
|
||||
// 拉取镜像相关状态
|
||||
const [pullModal, setPullModal] = useState(false) // 是否显示拉取弹窗
|
||||
const [pullName, setPullName] = useState('') // 用户输入的镜像名
|
||||
const [pulling, setPulling] = useState(false) // 正在拉取
|
||||
const [pullLog, setPullLog] = useState('') // 拉取日志
|
||||
const [pullDone, setPullDone] = useState(false) // 拉取完成
|
||||
const [pullError, setPullError] = useState(null) // 拉取失败
|
||||
|
||||
const fetchImages = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const cmd = 'docker images --format "{{json .}}"'
|
||||
|
||||
const r = await window.panel.dockerExec(cmd, 15000)
|
||||
setLoading(false)
|
||||
|
||||
if (!r.ok || r.code !== 0) {
|
||||
setError(r.message || r.stderr || '获取镜像列表失败')
|
||||
setImages([])
|
||||
return
|
||||
}
|
||||
|
||||
const lines = (r.stdout || '').trim().split('\n').filter(Boolean)
|
||||
const list = lines.map((line) => {
|
||||
try { return JSON.parse(line) } catch { return null }
|
||||
}).filter(Boolean)
|
||||
|
||||
setImages(list)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchImages()
|
||||
}, [fetchImages, refreshKey])
|
||||
|
||||
const doDelete = useCallback(async (imageId) => {
|
||||
setActing((prev) => ({ ...prev, [imageId]: true }))
|
||||
// 强制删除镜像(即使有容器引用也尝试删除,但会失败给提示)
|
||||
const r = await window.panel.dockerExec(
|
||||
`docker rmi -f ${imageId} 2>&1`,
|
||||
30000
|
||||
)
|
||||
setActing((prev) => ({ ...prev, [imageId]: false }))
|
||||
setConfirmDelete(null)
|
||||
|
||||
if (r.ok && r.code === 0) {
|
||||
setRefreshKey((k) => k + 1)
|
||||
} else {
|
||||
setError(`删除失败: ${r.stdout || r.stderr || r.message || '未知错误'}`)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 拉取镜像
|
||||
const doPull = useCallback(async () => {
|
||||
if (!pullName.trim()) return
|
||||
setPulling(true)
|
||||
setPullLog('')
|
||||
setPullDone(false)
|
||||
setPullError(null)
|
||||
|
||||
const r = await window.panel.dockerExec(`docker pull ${pullName.trim()} 2>&1`, 300000)
|
||||
setPulling(false)
|
||||
|
||||
if (r.ok && r.code === 0) {
|
||||
setPullLog(r.stdout || '拉取成功')
|
||||
setPullDone(true)
|
||||
setRefreshKey((k) => k + 1)
|
||||
} else {
|
||||
const errMsg = r.stderr || r.stdout || r.message || '未知错误'
|
||||
setPullLog(errMsg)
|
||||
setPullError(errMsg)
|
||||
}
|
||||
}, [pullName])
|
||||
|
||||
const closePullModal = () => {
|
||||
setPullModal(false)
|
||||
setPullName('')
|
||||
setPulling(false)
|
||||
setPullLog('')
|
||||
setPullDone(false)
|
||||
setPullError(null)
|
||||
}
|
||||
|
||||
// 格式化镜像大小
|
||||
const formatSize = (size) => {
|
||||
if (!size) return '-'
|
||||
return size
|
||||
}
|
||||
|
||||
const getImageId = (img) => img.ID || img.Id || img.id || ''
|
||||
const getImageName = (img) => {
|
||||
const repo = img.Repository || img.repository || ''
|
||||
const tag = img.Tag || img.tag || ''
|
||||
if (!repo) return img.ID?.slice(7, 19) || '-'
|
||||
return tag ? `${repo}:${tag}` : repo
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="image-list-panel">
|
||||
{/* 工具栏 */}
|
||||
<div className="image-list-toolbar">
|
||||
<div className="toolbar-left">
|
||||
{error && <span className="list-error-msg">{error}</span>}
|
||||
</div>
|
||||
<div className="toolbar-right">
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setPullModal(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
拉取镜像
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setRefreshKey((k) => k + 1)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 镜像表格 */}
|
||||
{loading && images.length === 0 ? (
|
||||
<div className="container-loading">
|
||||
<div className="docker-spinner" />
|
||||
<span>正在获取镜像列表...</span>
|
||||
</div>
|
||||
) : images.length === 0 ? (
|
||||
<div className="container-empty">
|
||||
<p>当前没有任何镜像</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-primary" onClick={() => setPullModal(true)}>
|
||||
拉取镜像
|
||||
</button>
|
||||
<button className="btn btn-ghost" onClick={() => setRefreshKey((k) => k + 1)}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="image-table-wrap">
|
||||
<table className="image-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>镜像</th>
|
||||
<th>ID</th>
|
||||
<th>大小</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{images.map((img) => {
|
||||
const id = getImageId(img)
|
||||
const name = getImageName(img)
|
||||
const shortId = id.replace('sha256:', '').slice(0, 12)
|
||||
const busy = acting[id]
|
||||
|
||||
return (
|
||||
<tr key={id}>
|
||||
<td className="cell-name" title={name}>{name}</td>
|
||||
<td className="cell-id">{shortId}</td>
|
||||
<td className="cell-size">{formatSize(img.Size || img.size)}</td>
|
||||
<td className="cell-created">{img.CreatedAt || img.createdat || '-'}</td>
|
||||
<td className="cell-actions">
|
||||
<button
|
||||
className="action-btn action-delete"
|
||||
onClick={() => setConfirmDelete({ id, name })}
|
||||
disabled={busy}
|
||||
title="删除镜像"
|
||||
>
|
||||
{busy ? '...' : '删除'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{confirmDelete && (
|
||||
<div className="confirm-overlay" onClick={() => setConfirmDelete(null)}>
|
||||
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>确认删除镜像</h3>
|
||||
<p>确定要删除镜像 <code>{confirmDelete.name}</code> 吗?</p>
|
||||
<p className="confirm-warning">如果有容器正在使用此镜像,删除将失败。此操作不可撤销。</p>
|
||||
<div className="confirm-actions">
|
||||
<button className="btn btn-ghost" onClick={() => setConfirmDelete(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => doDelete(confirmDelete.id)}
|
||||
disabled={acting[confirmDelete.id]}
|
||||
>
|
||||
{acting[confirmDelete.id] ? '删除中...' : '确认删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 拉取镜像弹窗 */}
|
||||
{pullModal && (
|
||||
<div className="pull-overlay" onClick={pulling ? undefined : closePullModal}>
|
||||
<div className="pull-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="pull-header">
|
||||
<h3>拉取镜像</h3>
|
||||
</div>
|
||||
<div className="pull-body">
|
||||
{!pulling && !pullLog && (
|
||||
<>
|
||||
<div className="pull-input-row">
|
||||
<label className="pull-label">镜像名称</label>
|
||||
<input
|
||||
type="text"
|
||||
className="pull-input"
|
||||
placeholder="例如:nginx:latest, redis:7, mysql:8.4"
|
||||
value={pullName}
|
||||
onChange={(e) => setPullName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && pullName.trim()) doPull()
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="pull-suggestions">
|
||||
<span className="pull-suggest-label">常用镜像:</span>
|
||||
{['nginx:latest', 'redis:7-alpine', 'mysql:8.4', 'postgres:16', 'node:20-alpine'].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className="pull-suggest-btn"
|
||||
onClick={() => setPullName(s)}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pull-actions">
|
||||
<button className="btn btn-ghost" onClick={closePullModal}>取消</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={doPull}
|
||||
disabled={!pullName.trim()}
|
||||
>
|
||||
开始拉取
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 拉取中 / 完成后 / 失败后的日志 */}
|
||||
{(pulling || pullLog) && (
|
||||
<>
|
||||
<div className="pull-status-bar">
|
||||
{pulling && (
|
||||
<div className="pull-status-info">
|
||||
<div className="docker-spinner" />
|
||||
<span>正在拉取 {pullName}...</span>
|
||||
</div>
|
||||
)}
|
||||
{pullDone && (
|
||||
<div className="pull-status-info pull-status-done">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
<span>镜像 {pullName} 拉取成功</span>
|
||||
</div>
|
||||
)}
|
||||
{pullError && !pullDone && (
|
||||
<div className="pull-status-info pull-status-error">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
<span>拉取失败</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pull-log-area">
|
||||
<pre className="pull-log-content">{pullLog || '等待输出...'}</pre>
|
||||
</div>
|
||||
|
||||
{!pulling && (
|
||||
<div className="pull-actions">
|
||||
{pullDone && (
|
||||
<button className="btn btn-ghost" onClick={() => {
|
||||
setPullLog('')
|
||||
setPullDone(false)
|
||||
setPullName('')
|
||||
}}>
|
||||
继续拉取
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={closePullModal}>
|
||||
{pullDone ? '完成' : '关闭'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/* ============================================================
|
||||
Docker 网络管理
|
||||
============================================================ */
|
||||
.network-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ---- 工具栏 ---- */
|
||||
.network-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
/* ---- 表格 ---- */
|
||||
.network-table-wrap {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 280px);
|
||||
}
|
||||
|
||||
.network-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.network-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.network-table th {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: var(--surface-dim);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.network-table td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
color: var(--ink);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.network-table tbody tr:hover {
|
||||
background: var(--surface-dim);
|
||||
}
|
||||
|
||||
.network-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.network-table .cell-name {
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.network-table .cell-id,
|
||||
.network-table .cell-driver,
|
||||
.network-table .cell-scope {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--ink-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.network-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ---- 系统网络徽章 ---- */
|
||||
.network-sys-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-muted);
|
||||
background: var(--surface-dim);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- 创建网络弹窗 ---- */
|
||||
.network-create-dialog {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
padding: 28px 32px;
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.network-create-dialog h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.network-create-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.network-create-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.network-create-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.network-create-input {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.network-create-input:focus {
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.network-create-select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.network-create-error {
|
||||
font-size: 13px;
|
||||
color: var(--status-critical);
|
||||
padding: 8px 12px;
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ---- 网络详情弹窗 ---- */
|
||||
.network-detail-dialog {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
padding: 24px 28px;
|
||||
max-width: 640px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.network-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.network-detail-header h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.network-detail-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 40px;
|
||||
justify-content: center;
|
||||
color: var(--ink-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.network-detail-empty {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.network-detail-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.detail-section h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detail-key {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.detail-val {
|
||||
font-size: 14px;
|
||||
color: var(--ink-strong);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-val.mono {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 详情 - 容器列表 */
|
||||
.detail-container-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-container-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.detail-container-name {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--ink-strong);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-container-id {
|
||||
font-size: 11px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.detail-container-ip {
|
||||
font-size: 12px;
|
||||
color: var(--brand);
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import './NetworkPanel.css'
|
||||
|
||||
// Docker 网络管理:列表 / 创建 / 删除 / 查看详情
|
||||
export default function NetworkPanel({ dockerReady }) {
|
||||
const [networks, setNetworks] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const [acting, setActing] = useState({}) // { networkId: true/false }
|
||||
const [confirmDelete, setConfirmDelete] = useState(null) // { id, name } or null
|
||||
const [detailNetwork, setDetailNetwork] = useState(null) // { id, name } or null
|
||||
const [detailData, setDetailData] = useState(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
// 创建网络弹窗
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({ name: '', driver: 'bridge', subnet: '', gateway: '' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState(null)
|
||||
|
||||
const fetchNetworks = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const cmd = 'docker network ls --format "{{json .}}"'
|
||||
|
||||
const r = await window.panel.dockerExec(cmd, 15000)
|
||||
setLoading(false)
|
||||
|
||||
if (!r.ok || r.code !== 0) {
|
||||
setError(r.message || r.stderr || '获取网络列表失败')
|
||||
setNetworks([])
|
||||
return
|
||||
}
|
||||
|
||||
const lines = (r.stdout || '').trim().split('\n').filter(Boolean)
|
||||
const list = lines.map((line) => {
|
||||
try { return JSON.parse(line) } catch { return null }
|
||||
}).filter(Boolean)
|
||||
|
||||
setNetworks(list)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchNetworks()
|
||||
}, [fetchNetworks, refreshKey])
|
||||
|
||||
// 获取网络详情
|
||||
const fetchDetail = useCallback(async (networkId) => {
|
||||
setDetailLoading(true)
|
||||
setDetailData(null)
|
||||
const r = await window.panel.dockerExec(`docker network inspect ${networkId} --format "{{json .}}" 2>&1`, 10000)
|
||||
setDetailLoading(false)
|
||||
|
||||
if (r.ok && r.code === 0 && r.stdout.trim()) {
|
||||
try {
|
||||
const data = JSON.parse(r.stdout.trim())
|
||||
setDetailData(data)
|
||||
} catch {
|
||||
setDetailData(null)
|
||||
}
|
||||
} else {
|
||||
setDetailData(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 打开详情弹窗
|
||||
const openDetail = (id, name) => {
|
||||
setDetailNetwork({ id, name })
|
||||
fetchDetail(id)
|
||||
}
|
||||
|
||||
// 删除网络
|
||||
const doDelete = useCallback(async (networkId) => {
|
||||
setActing((prev) => ({ ...prev, [networkId]: true }))
|
||||
const r = await window.panel.dockerExec(`docker network rm ${networkId} 2>&1`, 15000)
|
||||
setActing((prev) => ({ ...prev, [networkId]: false }))
|
||||
setConfirmDelete(null)
|
||||
|
||||
if (r.ok && r.code === 0) {
|
||||
setRefreshKey((k) => k + 1)
|
||||
} else {
|
||||
setError(`删除失败: ${r.stdout || r.stderr || r.message || '未知错误'}`)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 创建网络
|
||||
const doCreate = useCallback(async () => {
|
||||
if (!createForm.name.trim()) return
|
||||
setCreating(true)
|
||||
setCreateError(null)
|
||||
|
||||
let cmd = `docker network create --driver ${createForm.driver}`
|
||||
if (createForm.subnet.trim()) {
|
||||
cmd += ` --subnet ${createForm.subnet.trim()}`
|
||||
}
|
||||
if (createForm.gateway.trim()) {
|
||||
cmd += ` --gateway ${createForm.gateway.trim()}`
|
||||
}
|
||||
cmd += ` ${createForm.name.trim()}`
|
||||
|
||||
const r = await window.panel.dockerExec(`${cmd} 2>&1`, 15000)
|
||||
setCreating(false)
|
||||
|
||||
if (r.ok && r.code === 0) {
|
||||
setShowCreate(false)
|
||||
setCreateForm({ name: '', driver: 'bridge', subnet: '', gateway: '' })
|
||||
setRefreshKey((k) => k + 1)
|
||||
} else {
|
||||
setCreateError(r.stdout || r.stderr || r.message || '创建失败')
|
||||
}
|
||||
}, [createForm])
|
||||
|
||||
const closeCreate = () => {
|
||||
setShowCreate(false)
|
||||
setCreateForm({ name: '', driver: 'bridge', subnet: '', gateway: '' })
|
||||
setCreateError(null)
|
||||
}
|
||||
|
||||
const getNetworkId = (n) => n.ID || n.Id || n.id || ''
|
||||
const getNetworkName = (n) => n.Name || n.name || getNetworkId(n).slice(0, 12) || '-'
|
||||
|
||||
// 预设网络不可删除(bridge, host, none 是 Docker 内置网络)
|
||||
const isSystem = (n) => {
|
||||
const name = getNetworkName(n)
|
||||
return ['bridge', 'host', 'none'].includes(name)
|
||||
}
|
||||
|
||||
// 格式化详情 JSON 显示
|
||||
const renderDetail = () => {
|
||||
if (detailLoading) {
|
||||
return (
|
||||
<div className="network-detail-loading">
|
||||
<div className="docker-spinner" />
|
||||
<span>正在获取网络详情...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!detailData) {
|
||||
return <p className="network-detail-empty">无法获取网络详情</p>
|
||||
}
|
||||
const sections = []
|
||||
|
||||
// 基本信息
|
||||
sections.push(
|
||||
<div key="basic" className="detail-section">
|
||||
<h4>基本信息</h4>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item"><span className="detail-key">名称</span><span className="detail-val">{detailData.Name || '-'}</span></div>
|
||||
<div className="detail-item"><span className="detail-key">ID</span><span className="detail-val mono">{(detailData.Id || '').slice(0, 19) || '-'}</span></div>
|
||||
<div className="detail-item"><span className="detail-key">驱动</span><span className="detail-val">{detailData.Driver || '-'}</span></div>
|
||||
<div className="detail-item"><span className="detail-key">作用域</span><span className="detail-val">{detailData.Scope || '-'}</span></div>
|
||||
<div className="detail-item"><span className="detail-key">启用 IPv6</span><span className="detail-val">{detailData.EnableIPv6 ? '是' : '否'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// IPAM 配置(子网网关)
|
||||
const ipam = detailData.IPAM
|
||||
if (ipam && ipam.Config && ipam.Config.length > 0) {
|
||||
sections.push(
|
||||
<div key="ipam" className="detail-section">
|
||||
<h4>IPAM 配置</h4>
|
||||
{ipam.Config.map((c, i) => (
|
||||
<div key={i} className="detail-grid">
|
||||
{c.Subnet && <div className="detail-item"><span className="detail-key">子网</span><span className="detail-val mono">{c.Subnet}</span></div>}
|
||||
{c.Gateway && <div className="detail-item"><span className="detail-key">网关</span><span className="detail-val mono">{c.Gateway}</span></div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 连接的容器
|
||||
const containers = detailData.Containers
|
||||
if (containers && Object.keys(containers).length > 0) {
|
||||
sections.push(
|
||||
<div key="containers" className="detail-section">
|
||||
<h4>连接的容器 ({Object.keys(containers).length})</h4>
|
||||
<div className="detail-container-list">
|
||||
{Object.entries(containers).map(([cid, info]) => (
|
||||
<div key={cid} className="detail-container-item">
|
||||
<span className="detail-container-name">{info.Name || '-'}</span>
|
||||
<span className="detail-container-id mono">{cid.slice(0, 19)}</span>
|
||||
{info.IPv4Address && <span className="detail-container-ip mono">{info.IPv4Address}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <div className="network-detail-body">{sections}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="network-panel">
|
||||
{/* 工具栏 */}
|
||||
<div className="network-toolbar">
|
||||
<div className="toolbar-left">
|
||||
{error && <span className="list-error-msg">{error}</span>}
|
||||
</div>
|
||||
<div className="toolbar-right">
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setShowCreate(true)}>
|
||||
+ 创建网络
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setRefreshKey((k) => k + 1)} disabled={loading}>
|
||||
{loading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 网络表格 */}
|
||||
{loading && networks.length === 0 ? (
|
||||
<div className="container-loading">
|
||||
<div className="docker-spinner" />
|
||||
<span>正在获取网络列表...</span>
|
||||
</div>
|
||||
) : networks.length === 0 ? (
|
||||
<div className="container-empty">
|
||||
<p>当前没有任何网络</p>
|
||||
<button className="btn btn-ghost" onClick={() => setRefreshKey((k) => k + 1)}>刷新</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="network-table-wrap">
|
||||
<table className="network-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>网络名称</th>
|
||||
<th>ID</th>
|
||||
<th>驱动</th>
|
||||
<th>作用域</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{networks.map((n) => {
|
||||
const id = getNetworkId(n)
|
||||
const name = getNetworkName(n)
|
||||
const shortId = id.replace('sha256:', '').slice(0, 12)
|
||||
const busy = acting[id]
|
||||
const sys = isSystem(n)
|
||||
|
||||
return (
|
||||
<tr key={id}>
|
||||
<td className="cell-name" title={name}>
|
||||
{name}
|
||||
{sys && <span className="network-sys-badge">系统</span>}
|
||||
</td>
|
||||
<td className="cell-id">{shortId}</td>
|
||||
<td className="cell-driver">{n.Driver || n.driver || '-'}</td>
|
||||
<td className="cell-scope">{n.Scope || n.scope || '-'}</td>
|
||||
<td className="cell-actions">
|
||||
<div className="network-actions">
|
||||
<button className="action-btn action-log" onClick={() => openDetail(id, name)} title="查看详情">
|
||||
详情
|
||||
</button>
|
||||
<button
|
||||
className="action-btn action-delete"
|
||||
onClick={() => setConfirmDelete({ id, name })}
|
||||
disabled={busy || sys}
|
||||
title={sys ? '系统网络不可删除' : '删除网络'}
|
||||
>
|
||||
{busy ? '...' : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{confirmDelete && (
|
||||
<div className="confirm-overlay" onClick={() => setConfirmDelete(null)}>
|
||||
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>确认删除网络</h3>
|
||||
<p>确定要删除网络 <code>{confirmDelete.name}</code> 吗?</p>
|
||||
<p className="confirm-warning">如果有容器正在使用此网络,删除将失败。此操作不可撤销。</p>
|
||||
<div className="confirm-actions">
|
||||
<button className="btn btn-ghost" onClick={() => setConfirmDelete(null)}>取消</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => doDelete(confirmDelete.id)}
|
||||
disabled={acting[confirmDelete.id]}
|
||||
>
|
||||
{acting[confirmDelete.id] ? '删除中...' : '确认删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 创建网络弹窗 */}
|
||||
{showCreate && (
|
||||
<div className="confirm-overlay" onClick={creating ? undefined : closeCreate}>
|
||||
<div className="network-create-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>创建网络</h3>
|
||||
<div className="network-create-body">
|
||||
<div className="network-create-row">
|
||||
<label className="network-create-label">网络名称 <span className="param-required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
className="network-create-input"
|
||||
placeholder="例如:my-network"
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="network-create-row">
|
||||
<label className="network-create-label">驱动</label>
|
||||
<select
|
||||
className="network-create-input network-create-select"
|
||||
value={createForm.driver}
|
||||
onChange={(e) => setCreateForm((prev) => ({ ...prev, driver: e.target.value }))}
|
||||
>
|
||||
<option value="bridge">bridge(桥接)</option>
|
||||
<option value="host">host(主机)</option>
|
||||
<option value="macvlan">macvlan</option>
|
||||
<option value="none">none(无)</option>
|
||||
</select>
|
||||
</div>
|
||||
{createForm.driver === 'bridge' && (
|
||||
<>
|
||||
<div className="network-create-row">
|
||||
<label className="network-create-label">子网(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="network-create-input"
|
||||
placeholder="例如:172.20.0.0/16"
|
||||
value={createForm.subnet}
|
||||
onChange={(e) => setCreateForm((prev) => ({ ...prev, subnet: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="network-create-row">
|
||||
<label className="network-create-label">网关(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="network-create-input"
|
||||
placeholder="例如:172.20.0.1"
|
||||
value={createForm.gateway}
|
||||
onChange={(e) => setCreateForm((prev) => ({ ...prev, gateway: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{createError && <div className="network-create-error">{createError}</div>}
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button className="btn btn-ghost" onClick={closeCreate} disabled={creating}>取消</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={doCreate}
|
||||
disabled={creating || !createForm.name.trim()}
|
||||
>
|
||||
{creating ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 网络详情弹窗 */}
|
||||
{detailNetwork && (
|
||||
<div className="confirm-overlay" onClick={() => { setDetailNetwork(null); setDetailData(null) }}>
|
||||
<div className="network-detail-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="network-detail-header">
|
||||
<h3>网络详情 — {detailNetwork.name}</h3>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => { setDetailNetwork(null); setDetailData(null) }}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
{renderDetail()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/* ===== 任务中心 ===== */
|
||||
.task-center {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.tc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tc-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tc-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tc-stat-item {
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tc-stat-running { color: var(--status-warning); }
|
||||
.tc-stat-done { color: var(--status-good); }
|
||||
.tc-stat-error { color: var(--status-critical); }
|
||||
|
||||
.tc-refresh {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ===== 加载中 ===== */
|
||||
.tc-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 0;
|
||||
color: var(--ink-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ===== 空状态 ===== */
|
||||
.tc-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 48px 0;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.tc-empty svg {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.tc-empty p {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-normal);
|
||||
}
|
||||
|
||||
.tc-empty span {
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* ===== 任务列表 ===== */
|
||||
.tc-task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ===== 任务卡片 ===== */
|
||||
.tc-task-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.tc-task-card:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.tc-task-done {
|
||||
border-left: 3px solid var(--status-good);
|
||||
}
|
||||
|
||||
.tc-task-error {
|
||||
border-left: 3px solid var(--status-critical);
|
||||
}
|
||||
|
||||
.tc-task-running {
|
||||
border-left: 3px solid var(--status-warning);
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.tc-task-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tc-task-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tc-task-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tc-task-running .tc-task-dot {
|
||||
animation: tc-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes tc-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.tc-task-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.tc-task-id {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.tc-task-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tc-task-time {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.tc-task-status {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tc-chevron {
|
||||
color: var(--ink-muted);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.tc-chevron-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 卡片展开内容 */
|
||||
.tc-task-body {
|
||||
padding: 0 16px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* 步骤进度 */
|
||||
.tc-steps {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 14px 0 10px;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tc-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
padding: 0 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 步骤之间的连接线 */
|
||||
.tc-step:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--border, #e0e0e0);
|
||||
}
|
||||
|
||||
.tc-step-indicator {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tc-step-pending {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tc-step-done .tc-step-indicator {
|
||||
color: var(--status-good);
|
||||
}
|
||||
|
||||
.tc-step-running .tc-step-indicator {
|
||||
color: var(--status-warning);
|
||||
}
|
||||
|
||||
.tc-step-error .tc-step-indicator {
|
||||
color: var(--status-critical);
|
||||
}
|
||||
|
||||
.tc-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--status-warning);
|
||||
border-radius: 50%;
|
||||
animation: tc-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes tc-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.tc-step-text {
|
||||
font-size: 13px;
|
||||
color: var(--ink-normal);
|
||||
}
|
||||
|
||||
.tc-step-done .tc-step-text {
|
||||
color: var(--status-good);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tc-step-running .tc-step-text {
|
||||
color: var(--status-warning);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tc-step-error .tc-step-text {
|
||||
color: var(--status-critical);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 日志区 */
|
||||
.tc-logs {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: var(--surface-inset, #f6f7f9);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.tc-log-line {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.tc-log-time {
|
||||
color: var(--ink-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tc-log-text {
|
||||
color: var(--ink-normal);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tc-log-error .tc-log-text {
|
||||
color: var(--status-critical);
|
||||
}
|
||||
|
||||
.tc-log-empty {
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
/* 错误信息 */
|
||||
.tc-error-box {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(229, 57, 53, 0.08);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tc-error-label {
|
||||
color: var(--status-critical);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tc-error-text {
|
||||
color: var(--status-critical);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 成功信息 */
|
||||
.tc-success-box {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(46, 160, 67, 0.08);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tc-success-label {
|
||||
color: var(--status-good);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tc-container-id {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
color: var(--ink-strong);
|
||||
background: var(--surface);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import './TaskCenter.css'
|
||||
|
||||
const STATUS_MAP = {
|
||||
pending: { label: '等待中', color: 'var(--ink-muted)', icon: 'clock' },
|
||||
running: { label: '部署中', color: 'var(--status-warning)', icon: 'spinner' },
|
||||
done: { label: '已完成', color: 'var(--status-good)', icon: 'check' },
|
||||
error: { label: '失败', color: 'var(--status-critical)', icon: 'cross' },
|
||||
}
|
||||
|
||||
const STEP_ORDER = ['拉取镜像', '创建目录', '写入配置', '创建容器', '完成']
|
||||
|
||||
function StatusIcon({ status }) {
|
||||
if (status === 'running') {
|
||||
return <div className="tc-spinner" />
|
||||
}
|
||||
if (status === 'done') {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return <span className="tc-step-pending" />
|
||||
}
|
||||
|
||||
function TaskCard({ task, expanded, onToggle }) {
|
||||
const statusInfo = STATUS_MAP[task.status] || STATUS_MAP.pending
|
||||
const logEndRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded && logEndRef.current) {
|
||||
logEndRef.current.scrollTop = logEndRef.current.scrollHeight
|
||||
}
|
||||
}, [expanded, task.logs])
|
||||
|
||||
return (
|
||||
<div className={`tc-task-card tc-task-${task.status}`}>
|
||||
{/* 任务头部 */}
|
||||
<div className="tc-task-header" onClick={onToggle}>
|
||||
<div className="tc-task-left">
|
||||
<span className={`tc-task-dot tc-dot-${task.status}`} style={{ background: statusInfo.color }} />
|
||||
<span className="tc-task-name">{task.appName}</span>
|
||||
<span className="tc-task-id">#{task.id}</span>
|
||||
</div>
|
||||
<div className="tc-task-right">
|
||||
<span className="tc-task-time">{new Date(task.startTime).toLocaleTimeString()}</span>
|
||||
<span className="tc-task-status" style={{ color: statusInfo.color }}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
<svg
|
||||
className={`tc-chevron ${expanded ? 'tc-chevron-open' : ''}`}
|
||||
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 展开内容 */}
|
||||
{expanded && (
|
||||
<div className="tc-task-body">
|
||||
{/* 步骤进度 */}
|
||||
<div className="tc-steps">
|
||||
{STEP_ORDER.map((stepName) => {
|
||||
const stepObj = task.steps.find((s) => s.name === stepName)
|
||||
const stepStatus = stepObj ? stepObj.status : 'pending'
|
||||
return (
|
||||
<div key={stepName} className={`tc-step tc-step-${stepStatus}`}>
|
||||
<div className="tc-step-indicator">
|
||||
<StatusIcon status={stepStatus} />
|
||||
</div>
|
||||
<span className="tc-step-text">{stepName}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 日志 */}
|
||||
<div className="tc-logs" ref={logEndRef}>
|
||||
{task.logs.length === 0 ? (
|
||||
<div className="tc-log-empty">暂无日志</div>
|
||||
) : (
|
||||
task.logs.map((log, idx) => (
|
||||
<div key={idx} className={`tc-log-line ${log.message.includes('失败') || log.message.includes('异常') ? 'tc-log-error' : ''}`}>
|
||||
<span className="tc-log-time">{log.time}</span>
|
||||
<span className="tc-log-text">{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{task.error && (
|
||||
<div className="tc-error-box">
|
||||
<span className="tc-error-label">错误:</span>
|
||||
<span className="tc-error-text">{task.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 容器 ID */}
|
||||
{task.containerId && (
|
||||
<div className="tc-success-box">
|
||||
<span className="tc-success-label">容器 ID:</span>
|
||||
<code className="tc-container-id">{task.containerId.slice(0, 12)}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TaskCenter() {
|
||||
const [tasks, setTasks] = useState([])
|
||||
const [expandedId, setExpandedId] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// 初始加载任务列表
|
||||
useEffect(() => {
|
||||
loadTasks()
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
// 监听任务更新
|
||||
useEffect(() => {
|
||||
const cleanup = window.panel.onDeployTaskUpdate((updatedTask) => {
|
||||
setTasks((prev) => {
|
||||
const idx = prev.findIndex((t) => t.id === updatedTask.id)
|
||||
if (idx >= 0) {
|
||||
const next = [...prev]
|
||||
next[idx] = updatedTask
|
||||
return next
|
||||
}
|
||||
return [updatedTask, ...prev]
|
||||
})
|
||||
// 正在运行的任务自动展开
|
||||
if (updatedTask.status === 'running') {
|
||||
setExpandedId(updatedTask.id)
|
||||
}
|
||||
})
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
const loadTasks = async () => {
|
||||
const r = await window.panel.dockerListTasks()
|
||||
if (r.ok && r.tasks) {
|
||||
setTasks(r.tasks)
|
||||
// 自动展开最新的运行中任务
|
||||
const running = r.tasks.find((t) => t.status === 'running')
|
||||
if (running) {
|
||||
setExpandedId(running.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按时间倒序排列
|
||||
const sortedTasks = [...tasks].sort((a, b) => b.id - a.id)
|
||||
|
||||
const stats = {
|
||||
total: tasks.length,
|
||||
running: tasks.filter((t) => t.status === 'running').length,
|
||||
done: tasks.filter((t) => t.status === 'done').length,
|
||||
error: tasks.filter((t) => t.status === 'error').length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="task-center">
|
||||
<div className="tc-header">
|
||||
<h3>部署任务中心</h3>
|
||||
<div className="tc-stats">
|
||||
<span className="tc-stat-item">共 {stats.total} 个</span>
|
||||
<span className="tc-stat-item tc-stat-running">进行中 {stats.running}</span>
|
||||
<span className="tc-stat-item tc-stat-done">已完成 {stats.done}</span>
|
||||
<span className="tc-stat-item tc-stat-error">失败 {stats.error}</span>
|
||||
</div>
|
||||
<button className="btn btn-ghost btn-sm tc-refresh" onClick={loadTasks}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="tc-loading">
|
||||
<div className="docker-spinner" />
|
||||
<span>加载任务列表...</span>
|
||||
</div>
|
||||
) : sortedTasks.length === 0 ? (
|
||||
<div className="tc-empty">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
<p>暂无部署任务</p>
|
||||
<span>在应用商店部署应用后,任务将显示在这里</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tc-task-list">
|
||||
{sortedTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
expanded={expandedId === task.id}
|
||||
onToggle={() => setExpandedId(expandedId === task.id ? null : task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/* ============================================================
|
||||
Docker 卷管理
|
||||
============================================================ */
|
||||
.volume-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ---- 工具栏 ---- */
|
||||
.volume-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
/* ---- 表格 ---- */
|
||||
.volume-table-wrap {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 280px);
|
||||
}
|
||||
|
||||
.volume-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.volume-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.volume-table th {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: var(--surface-dim);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.volume-table td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
color: var(--ink);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.volume-table tbody tr:hover {
|
||||
background: var(--surface-dim);
|
||||
}
|
||||
|
||||
.volume-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.volume-table .cell-name {
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.volume-table .cell-driver {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--ink-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.volume-table .cell-mountpoint {
|
||||
font-size: 12px;
|
||||
color: var(--ink-secondary);
|
||||
max-width: 360px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-mountpoint.mono {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
/* ---- 创建卷弹窗 ---- */
|
||||
.volume-create-dialog {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
padding: 28px 32px;
|
||||
max-width: 440px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.volume-create-dialog h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.volume-create-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.volume-create-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.volume-create-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.volume-create-input {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.volume-create-input:focus {
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.volume-create-hint {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.volume-create-hint code {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--brand);
|
||||
background: var(--brand-soft);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.volume-create-error {
|
||||
font-size: 13px;
|
||||
color: var(--status-critical);
|
||||
padding: 8px 12px;
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import './VolumePanel.css'
|
||||
|
||||
// Docker 卷管理:列表 / 创建 / 删除
|
||||
export default function VolumePanel({ dockerReady }) {
|
||||
const [volumes, setVolumes] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const [acting, setActing] = useState({}) // { volumeName: true/false }
|
||||
const [confirmDelete, setConfirmDelete] = useState(null) // { name } or null
|
||||
|
||||
// 创建卷弹窗
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [volumeName, setVolumeName] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState(null)
|
||||
|
||||
const fetchVolumes = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const cmd = 'docker volume ls --format "{{json .}}"'
|
||||
|
||||
const r = await window.panel.dockerExec(cmd, 15000)
|
||||
setLoading(false)
|
||||
|
||||
if (!r.ok || r.code !== 0) {
|
||||
setError(r.message || r.stderr || '获取卷列表失败')
|
||||
setVolumes([])
|
||||
return
|
||||
}
|
||||
|
||||
const lines = (r.stdout || '').trim().split('\n').filter(Boolean)
|
||||
const list = lines.map((line) => {
|
||||
try { return JSON.parse(line) } catch { return null }
|
||||
}).filter(Boolean)
|
||||
|
||||
setVolumes(list)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchVolumes()
|
||||
}, [fetchVolumes, refreshKey])
|
||||
|
||||
// 删除卷
|
||||
const doDelete = useCallback(async (volumeName) => {
|
||||
setActing((prev) => ({ ...prev, [volumeName]: true }))
|
||||
const r = await window.panel.dockerExec(`docker volume rm ${volumeName} 2>&1`, 15000)
|
||||
setActing((prev) => ({ ...prev, [volumeName]: false }))
|
||||
setConfirmDelete(null)
|
||||
|
||||
if (r.ok && r.code === 0) {
|
||||
setRefreshKey((k) => k + 1)
|
||||
} else {
|
||||
setError(`删除失败: ${r.stdout || r.stderr || r.message || '未知错误'}`)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 创建卷
|
||||
const doCreate = useCallback(async () => {
|
||||
if (!volumeName.trim()) return
|
||||
setCreating(true)
|
||||
setCreateError(null)
|
||||
|
||||
const r = await window.panel.dockerExec(`docker volume create ${volumeName.trim()} 2>&1`, 15000)
|
||||
setCreating(false)
|
||||
|
||||
if (r.ok && r.code === 0) {
|
||||
setShowCreate(false)
|
||||
setVolumeName('')
|
||||
setRefreshKey((k) => k + 1)
|
||||
} else {
|
||||
setCreateError(r.stdout || r.stderr || r.message || '创建失败')
|
||||
}
|
||||
}, [volumeName])
|
||||
|
||||
const closeCreate = () => {
|
||||
setShowCreate(false)
|
||||
setVolumeName('')
|
||||
setCreateError(null)
|
||||
}
|
||||
|
||||
const getVolumeName = (v) => v.Name || v.name || v.VolumeName || '-'
|
||||
|
||||
return (
|
||||
<div className="volume-panel">
|
||||
{/* 工具栏 */}
|
||||
<div className="volume-toolbar">
|
||||
<div className="toolbar-left">
|
||||
{error && <span className="list-error-msg">{error}</span>}
|
||||
</div>
|
||||
<div className="toolbar-right">
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setShowCreate(true)}>
|
||||
+ 创建卷
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setRefreshKey((k) => k + 1)} disabled={loading}>
|
||||
{loading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 卷表格 */}
|
||||
{loading && volumes.length === 0 ? (
|
||||
<div className="container-loading">
|
||||
<div className="docker-spinner" />
|
||||
<span>正在获取卷列表...</span>
|
||||
</div>
|
||||
) : volumes.length === 0 ? (
|
||||
<div className="container-empty">
|
||||
<p>当前没有任何卷</p>
|
||||
<button className="btn btn-ghost" onClick={() => setRefreshKey((k) => k + 1)}>刷新</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="volume-table-wrap">
|
||||
<table className="volume-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>卷名称</th>
|
||||
<th>驱动</th>
|
||||
<th>挂载路径</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{volumes.map((v) => {
|
||||
const name = getVolumeName(v)
|
||||
const driver = v.Driver || v.driver || '-'
|
||||
const mountpoint = v.Mountpoint || v.mountpoint || '-'
|
||||
const busy = acting[name]
|
||||
|
||||
return (
|
||||
<tr key={name}>
|
||||
<td className="cell-name" title={name}>{name}</td>
|
||||
<td className="cell-driver">{driver}</td>
|
||||
<td className="cell-mountpoint mono" title={mountpoint}>{mountpoint}</td>
|
||||
<td className="cell-actions">
|
||||
<button
|
||||
className="action-btn action-delete"
|
||||
onClick={() => setConfirmDelete({ name })}
|
||||
disabled={busy}
|
||||
title="删除卷"
|
||||
>
|
||||
{busy ? '...' : '删除'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{confirmDelete && (
|
||||
<div className="confirm-overlay" onClick={() => setConfirmDelete(null)}>
|
||||
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>确认删除卷</h3>
|
||||
<p>确定要删除卷 <code>{confirmDelete.name}</code> 吗?</p>
|
||||
<p className="confirm-warning">如果有容器正在使用此卷,删除将失败。此操作不可撤销。</p>
|
||||
<div className="confirm-actions">
|
||||
<button className="btn btn-ghost" onClick={() => setConfirmDelete(null)}>取消</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => doDelete(confirmDelete.name)}
|
||||
disabled={acting[confirmDelete.name]}
|
||||
>
|
||||
{acting[confirmDelete.name] ? '删除中...' : '确认删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 创建卷弹窗 */}
|
||||
{showCreate && (
|
||||
<div className="confirm-overlay" onClick={creating ? undefined : closeCreate}>
|
||||
<div className="volume-create-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>创建卷</h3>
|
||||
<div className="volume-create-body">
|
||||
<div className="volume-create-row">
|
||||
<label className="volume-create-label">卷名称 <span className="param-required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
className="volume-create-input"
|
||||
placeholder="例如:my-volume"
|
||||
value={volumeName}
|
||||
onChange={(e) => setVolumeName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && volumeName.trim()) doCreate()
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="volume-create-hint">
|
||||
卷将由 Docker 默认驱动(local)创建,存储在 <code>/var/lib/docker/volumes/</code> 下。
|
||||
</div>
|
||||
{createError && <div className="volume-create-error">{createError}</div>}
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button className="btn btn-ghost" onClick={closeCreate} disabled={creating}>取消</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={doCreate}
|
||||
disabled={creating || !volumeName.trim()}
|
||||
>
|
||||
{creating ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// ============================================================
|
||||
// 应用商店模板 —— 固化的容器编排配置
|
||||
// 基于服务器现有 redis / mysql 容器的实际配置提取
|
||||
// 用户可一键部署,自动拉取镜像、创建目录、启动容器
|
||||
// ============================================================
|
||||
|
||||
export const APP_TEMPLATES = [
|
||||
{
|
||||
id: 'redis',
|
||||
name: 'Redis',
|
||||
icon: 'redis',
|
||||
tagline: '高性能内存数据库 / 缓存',
|
||||
image: 'redis:8.10.0',
|
||||
description: 'Redis 是开源的内存数据结构存储,用作数据库、缓存、消息队列和流引擎。支持字符串、哈希、列表、集合等多种数据结构。',
|
||||
category: '缓存',
|
||||
deploy: {
|
||||
image: 'redis:8.10.0',
|
||||
containerName: 'redis',
|
||||
ports: [
|
||||
{ host: '6379', container: '6379', protocol: 'tcp' },
|
||||
],
|
||||
volumes: [
|
||||
{ host: '/opt/redis/data', container: '/data', mode: 'rw' },
|
||||
{ host: '/opt/redis/conf/redis.conf', container: '/etc/redis/redis.conf', mode: 'rw' },
|
||||
{ host: '/opt/redis/logs', container: '/logs', mode: 'rw' },
|
||||
],
|
||||
env: [],
|
||||
network: '',
|
||||
restart: 'always',
|
||||
hostname: 'redis',
|
||||
workdir: '/data',
|
||||
// Redis 需要配置文件 + 启动命令
|
||||
// 密码不通过命令行传递(避免空密码时参数解析错误),写入配置文件
|
||||
command: 'redis-server /etc/redis/redis.conf',
|
||||
// 部署前自动创建的目录
|
||||
mkdirs: [
|
||||
'/opt/redis/data',
|
||||
'/opt/redis/conf',
|
||||
'/opt/redis/logs',
|
||||
],
|
||||
// 部署前自动写入的文件
|
||||
writeFiles: [
|
||||
{
|
||||
path: '/opt/redis/conf/redis.conf',
|
||||
content: `# Redis 配置文件
|
||||
bind 0.0.0.0
|
||||
protected-mode no
|
||||
port 6379
|
||||
tcp-backlog 511
|
||||
timeout 0
|
||||
tcp-keepalive 300
|
||||
daemonize no
|
||||
supervised no
|
||||
pidfile /var/run/redis_6379.pid
|
||||
loglevel notice
|
||||
logfile ""
|
||||
databases 16
|
||||
always-show-logo yes
|
||||
save 900 1
|
||||
save 300 10
|
||||
save 60 10000
|
||||
stop-writes-on-bgsave-error yes
|
||||
rdbcompression yes
|
||||
rdbchecksum yes
|
||||
dbfilename dump.rdb
|
||||
dir /data
|
||||
appendonly no
|
||||
appendfilename "appendonly.aof"
|
||||
appendfsync everysec
|
||||
no-appendfsync-on-rewrite no
|
||||
auto-aof-rewrite-percentage 100
|
||||
auto-aof-rewrite-min-size 64mb
|
||||
# 密码(部署时替换,留空则无密码)
|
||||
{{REDIS_PASSWORD_LINE}}`,
|
||||
},
|
||||
],
|
||||
// 需要用户输入的参数(部署时弹出表单)
|
||||
params: [
|
||||
{
|
||||
key: 'REDIS_PASSWORD',
|
||||
label: 'Redis 密码',
|
||||
type: 'password',
|
||||
placeholder: '留空则无密码',
|
||||
default: '',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: 'PORT',
|
||||
label: '宿主机端口',
|
||||
type: 'number',
|
||||
placeholder: '6379',
|
||||
default: '6379',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mysql',
|
||||
name: 'MySQL',
|
||||
icon: 'mysql',
|
||||
tagline: '关系型数据库',
|
||||
image: 'mysql:8.4.11',
|
||||
description: 'MySQL 是最流行的开源关系型数据库管理系统,支持事务、存储过程、触发器、视图等完整 SQL 功能。此模板基于 MySQL 8.4 LTS。',
|
||||
category: '数据库',
|
||||
deploy: {
|
||||
image: 'mysql:8.4.11',
|
||||
containerName: 'mysql',
|
||||
ports: [
|
||||
{ host: '3306', container: '3306', protocol: 'tcp' },
|
||||
],
|
||||
volumes: [
|
||||
{ host: '/opt/mysql/data', container: '/var/lib/mysql', mode: 'rw' },
|
||||
{ host: '/opt/mysql/conf/my.cnf', container: '/etc/my.cnf', mode: 'rw' },
|
||||
{ host: '/opt/mysql/log', container: '/var/log/mysql', mode: 'rw' },
|
||||
{ host: '/etc/timezone', container: '/etc/timezone', mode: 'ro' },
|
||||
{ host: '/etc/localtime', container: '/etc/localtime', mode: 'ro' },
|
||||
],
|
||||
env: [
|
||||
{ key: 'MYSQL_ROOT_PASSWORD', value: '{{MYSQL_ROOT_PASSWORD}}' },
|
||||
],
|
||||
network: '',
|
||||
restart: 'always',
|
||||
hostname: 'mysql',
|
||||
workdir: '/',
|
||||
command: '',
|
||||
mkdirs: [
|
||||
'/opt/mysql/data',
|
||||
'/opt/mysql/conf',
|
||||
'/opt/mysql/log',
|
||||
],
|
||||
writeFiles: [
|
||||
{
|
||||
path: '/opt/mysql/conf/my.cnf',
|
||||
content: `# MySQL 配置文件
|
||||
[mysqld]
|
||||
pid-file = /var/run/mysqld/mysqld.pid
|
||||
socket = /var/run/mysqld/mysqld.sock
|
||||
datadir = /var/lib/mysql
|
||||
secure-file-priv= NULL
|
||||
|
||||
# 字符集
|
||||
character-set-server = utf8mb4
|
||||
collation-server = utf8mb4_unicode_ci
|
||||
|
||||
# 最大连接数
|
||||
max_connections = 200
|
||||
|
||||
# Innodb
|
||||
innodb_buffer_pool_size = 128M
|
||||
innodb_log_file_size = 48M
|
||||
innodb_flush_log_at_trx_commit = 1
|
||||
|
||||
# 日志
|
||||
log-error = /var/log/mysql/error.log
|
||||
slow_query_log = 1
|
||||
slow_query_log_file = /var/log/mysql/slow.log
|
||||
long_query_time = 2
|
||||
|
||||
# 端口
|
||||
port = 3306
|
||||
|
||||
[client]
|
||||
default-character-set = utf8mb4
|
||||
|
||||
[mysql]
|
||||
default-character-set = utf8mb4`,
|
||||
},
|
||||
],
|
||||
params: [
|
||||
{
|
||||
key: 'MYSQL_ROOT_PASSWORD',
|
||||
label: 'Root 密码',
|
||||
type: 'password',
|
||||
placeholder: '设置 root 用户密码',
|
||||
default: '',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'PORT',
|
||||
label: '宿主机端口',
|
||||
type: 'number',
|
||||
placeholder: '3306',
|
||||
default: '3306',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,293 @@
|
||||
// ============================================================
|
||||
// docker run 命令解析器 / 构建器
|
||||
// 支持 --flag value / --flag=value / -p value / -itp value 等形式
|
||||
// ============================================================
|
||||
|
||||
// ---- 分词器:按空格分割,但尊重引号 ----
|
||||
function tokenize(input) {
|
||||
const tokens = []
|
||||
let current = ''
|
||||
let inQuote = null
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const ch = input[i]
|
||||
if (inQuote) {
|
||||
if (ch === inQuote) {
|
||||
inQuote = null
|
||||
} else {
|
||||
current += ch
|
||||
}
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
inQuote = ch
|
||||
} else if (ch === ' ' || ch === '\t' || ch === '\n') {
|
||||
if (current) {
|
||||
tokens.push(current)
|
||||
current = ''
|
||||
}
|
||||
} else {
|
||||
current += ch
|
||||
}
|
||||
}
|
||||
if (current) tokens.push(current)
|
||||
return tokens
|
||||
}
|
||||
|
||||
// ---- 解析端口映射 ----
|
||||
// 8080:80 / 8080:80/tcp / 0.0.0.0:8080:80 / 0.0.0.0:8080:80/udp / 80
|
||||
function parsePortMapping(str) {
|
||||
if (!str) return { host: '', container: '', protocol: 'tcp' }
|
||||
const protocol = str.endsWith('/udp') ? 'udp' : 'tcp'
|
||||
const cleaned = str.replace(/\/(tcp|udp)$/, '')
|
||||
const parts = cleaned.split(':')
|
||||
if (parts.length >= 3) {
|
||||
return { hostIp: parts[0], host: parts[1], container: parts[2], protocol }
|
||||
} else if (parts.length === 2) {
|
||||
return { host: parts[0], container: parts[1], protocol }
|
||||
} else {
|
||||
return { host: '', container: parts[0], protocol }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 解析挂载卷 ----
|
||||
// /host:/container / /host:/container:ro / /host:/container:rw
|
||||
function parseVolumeMount(str) {
|
||||
if (!str) return { host: '', container: '', mode: 'rw' }
|
||||
const parts = str.split(':')
|
||||
if (parts.length >= 3) {
|
||||
return { host: parts[0], container: parts[1], mode: parts[2] }
|
||||
} else if (parts.length === 2) {
|
||||
return { host: parts[0], container: parts[1], mode: 'rw' }
|
||||
} else {
|
||||
return { host: '', container: parts[0], mode: 'rw' }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 解析环境变量 ----
|
||||
// KEY=value / KEY
|
||||
function parseEnvVar(str) {
|
||||
if (!str) return { key: '', value: '' }
|
||||
const eqIdx = str.indexOf('=')
|
||||
if (eqIdx > 0) {
|
||||
return { key: str.slice(0, eqIdx), value: str.slice(eqIdx + 1) }
|
||||
}
|
||||
return { key: str, value: '' }
|
||||
}
|
||||
|
||||
// ---- 值类型短标志处理 ----
|
||||
function applyValueFlag(flag, value, config) {
|
||||
switch (flag) {
|
||||
case 'p': config.ports.push(parsePortMapping(value)); break
|
||||
case 'v': config.volumes.push(parseVolumeMount(value)); break
|
||||
case 'e': config.env.push(parseEnvVar(value)); break
|
||||
case 'w': config.workdir = value; break
|
||||
case 'm': config.memory = value; break
|
||||
case 'u': config.user = value; break
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 值类型长标志映射 ----
|
||||
const VALUE_FLAGS = {
|
||||
name: 'name', publish: 'ports', p: 'ports',
|
||||
volume: 'volumes', v: 'volumes',
|
||||
env: 'env', e: 'env',
|
||||
network: 'network', net: 'network',
|
||||
restart: 'restart',
|
||||
hostname: 'hostname',
|
||||
workdir: 'workdir', w: 'workdir',
|
||||
memory: 'memory', m: 'memory',
|
||||
cpus: 'cpu', cpu: 'cpu',
|
||||
user: 'user', u: 'user',
|
||||
addhost: 'addHost',
|
||||
label: 'labels', l: 'labels',
|
||||
dns: 'dns',
|
||||
}
|
||||
|
||||
// ---- 布尔型长标志 ----
|
||||
const BOOL_FLAGS = {
|
||||
detach: 'detach', d: 'detach',
|
||||
interactive: 'interactive', i: 'interactive',
|
||||
tty: 'tty', t: 'tty',
|
||||
rm: 'rm',
|
||||
privileged: 'privileged',
|
||||
}
|
||||
|
||||
// ---- 主解析函数 ----
|
||||
export function parseDockerRun(input) {
|
||||
const tokens = tokenize((input || '').trim())
|
||||
let i = 0
|
||||
|
||||
// 跳过 docker run
|
||||
if (tokens[i] === 'docker') i++
|
||||
if (tokens[i] === 'run') i++
|
||||
|
||||
const config = {
|
||||
image: '',
|
||||
name: '',
|
||||
ports: [],
|
||||
volumes: [],
|
||||
env: [],
|
||||
labels: [],
|
||||
network: '',
|
||||
restart: '',
|
||||
hostname: '',
|
||||
workdir: '',
|
||||
user: '',
|
||||
memory: '',
|
||||
cpu: '',
|
||||
dns: '',
|
||||
addHost: '',
|
||||
detach: false,
|
||||
interactive: false,
|
||||
tty: false,
|
||||
rm: false,
|
||||
privileged: false,
|
||||
command: '',
|
||||
extraArgs: [],
|
||||
}
|
||||
|
||||
const positional = []
|
||||
|
||||
while (i < tokens.length) {
|
||||
const tok = tokens[i]
|
||||
|
||||
if (tok.startsWith('--')) {
|
||||
// 长标志
|
||||
const eqIdx = tok.indexOf('=')
|
||||
let flag, value
|
||||
if (eqIdx > 0) {
|
||||
flag = tok.slice(2, eqIdx)
|
||||
value = tok.slice(eqIdx + 1)
|
||||
} else {
|
||||
flag = tok.slice(2)
|
||||
value = null
|
||||
}
|
||||
|
||||
if (VALUE_FLAGS[flag]) {
|
||||
const target = VALUE_FLAGS[flag]
|
||||
if (value !== null) {
|
||||
applyLongValueFlag(config, target, value)
|
||||
} else {
|
||||
i++
|
||||
if (i < tokens.length) applyLongValueFlag(config, target, tokens[i])
|
||||
}
|
||||
} else if (BOOL_FLAGS[flag]) {
|
||||
config[BOOL_FLAGS[flag]] = true
|
||||
} else {
|
||||
config.extraArgs.push(tok)
|
||||
if (value === null && i + 1 < tokens.length && !tokens[i + 1].startsWith('-')) {
|
||||
config.extraArgs.push(tokens[i + 1])
|
||||
i++
|
||||
}
|
||||
}
|
||||
} else if (tok.startsWith('-') && tok.length > 1) {
|
||||
// 短标志(可能是组合如 -it, -di, -p 8080:80)
|
||||
let j = 1
|
||||
while (j < tok.length) {
|
||||
const c = tok[j]
|
||||
if (c === 'd') { config.detach = true; j++ }
|
||||
else if (c === 'i') { config.interactive = true; j++ }
|
||||
else if (c === 't') { config.tty = true; j++ }
|
||||
else if (['p', 'v', 'e', 'w', 'm', 'u'].includes(c)) {
|
||||
const rest = tok.slice(j + 1)
|
||||
if (rest) {
|
||||
applyValueFlag(c, rest, config)
|
||||
} else {
|
||||
i++
|
||||
if (i < tokens.length) applyValueFlag(c, tokens[i], config)
|
||||
}
|
||||
j = tok.length // 消费完
|
||||
} else {
|
||||
config.extraArgs.push('-' + c)
|
||||
j++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 位置参数
|
||||
positional.push(tok)
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
if (positional.length > 0) {
|
||||
config.image = positional[0]
|
||||
config.command = positional.slice(1).join(' ')
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
function applyLongValueFlag(config, target, value) {
|
||||
switch (target) {
|
||||
case 'name': config.name = value; break
|
||||
case 'ports': config.ports.push(parsePortMapping(value)); break
|
||||
case 'volumes': config.volumes.push(parseVolumeMount(value)); break
|
||||
case 'env': config.env.push(parseEnvVar(value)); break
|
||||
case 'network': config.network = value; break
|
||||
case 'restart': config.restart = value; break
|
||||
case 'hostname': config.hostname = value; break
|
||||
case 'workdir': config.workdir = value; break
|
||||
case 'memory': config.memory = value; break
|
||||
case 'cpu': config.cpu = value; break
|
||||
case 'user': config.user = value; break
|
||||
case 'dns': config.dns = value; break
|
||||
case 'addHost': config.addHost = value; break
|
||||
case 'labels': config.labels.push(value); break
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 从配置构建 docker run 命令 ----
|
||||
export function buildDockerRun(config) {
|
||||
const parts = ['docker', 'run', '-d']
|
||||
|
||||
if (config.name) parts.push('--name', config.name)
|
||||
if (config.restart) parts.push('--restart', config.restart)
|
||||
if (config.network) parts.push('--network', config.network)
|
||||
if (config.hostname) parts.push('--hostname', config.hostname)
|
||||
if (config.workdir) parts.push('-w', config.workdir)
|
||||
if (config.user) parts.push('-u', config.user)
|
||||
if (config.memory) parts.push('-m', config.memory)
|
||||
if (config.cpu) parts.push('--cpus', config.cpu)
|
||||
if (config.dns) parts.push('--dns', config.dns)
|
||||
if (config.addHost) parts.push('--add-host', config.addHost)
|
||||
|
||||
for (const port of config.ports) {
|
||||
if (!port.container) continue
|
||||
let p = ''
|
||||
if (port.hostIp) p += port.hostIp + ':'
|
||||
if (port.host) p += port.host + ':'
|
||||
p += port.container
|
||||
if (port.protocol && port.protocol !== 'tcp') p += '/' + port.protocol
|
||||
parts.push('-p', p)
|
||||
}
|
||||
|
||||
for (const vol of config.volumes) {
|
||||
if (!vol.container) continue
|
||||
let v = ''
|
||||
if (vol.host) v += vol.host + ':'
|
||||
v += vol.container
|
||||
if (vol.mode && vol.mode !== 'rw') v += ':' + vol.mode
|
||||
parts.push('-v', v)
|
||||
}
|
||||
|
||||
for (const env of config.env) {
|
||||
if (!env.key) continue
|
||||
if (env.value !== undefined && env.value !== '') {
|
||||
parts.push('-e', env.key + '=' + env.value)
|
||||
} else {
|
||||
parts.push('-e', env.key)
|
||||
}
|
||||
}
|
||||
|
||||
if (config.rm) parts.push('--rm')
|
||||
if (config.privileged) parts.push('--privileged')
|
||||
|
||||
if (config.extraArgs && config.extraArgs.length > 0) {
|
||||
parts.push(...config.extraArgs)
|
||||
}
|
||||
|
||||
if (config.image) parts.push(config.image)
|
||||
if (config.command) parts.push(config.command)
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/* ============================================================
|
||||
文件管理器
|
||||
============================================================ */
|
||||
.file-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ---- 工具栏 ---- */
|
||||
.fm-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fm-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fm-path input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--ink);
|
||||
background: var(--card-bg);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.fm-path input:focus {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 2px var(--brand-soft);
|
||||
}
|
||||
|
||||
/* ---- 操作按钮行 ---- */
|
||||
.fm-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 选中状态信息 */
|
||||
.fm-selection-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--brand);
|
||||
font-weight: 500;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.fm-clear-selection {
|
||||
border: none;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.fm-clear-selection:hover {
|
||||
background: var(--brand-soft-hover);
|
||||
}
|
||||
|
||||
/* 小按钮变体 */
|
||||
.btn-sm {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---- 进度条 ---- */
|
||||
.fm-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background: var(--brand-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fm-progress-label {
|
||||
white-space: nowrap;
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fm-progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--track);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.fm-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--brand);
|
||||
border-radius: 3px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.fm-progress-pct {
|
||||
white-space: nowrap;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 12px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
/* ---- 消息提示 ---- */
|
||||
.fm-message {
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fm-message-success {
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
color: var(--success-text);
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.fm-message-error {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: var(--status-critical);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* ---- 文件列表 ---- */
|
||||
.fm-list-container {
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
overflow: hidden;
|
||||
background: var(--card-bg);
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.fm-loading,
|
||||
.fm-empty {
|
||||
padding: 60px 0;
|
||||
text-align: center;
|
||||
color: var(--ink-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fm-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fm-table thead tr {
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.fm-table th {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--ink-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* checkbox 列 */
|
||||
.fm-col-check {
|
||||
width: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fm-col-check input[type="checkbox"] {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--brand);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.fm-table td.fm-col-check {
|
||||
padding: 9px 8px;
|
||||
}
|
||||
|
||||
.fm-table tbody tr {
|
||||
border-bottom: 1px solid var(--divider);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.fm-table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.fm-table tbody tr:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.fm-row-selected {
|
||||
background: var(--brand-soft) !important;
|
||||
}
|
||||
|
||||
.fm-row-selected:hover {
|
||||
background: var(--brand-soft-hover) !important;
|
||||
}
|
||||
|
||||
.fm-table td {
|
||||
padding: 9px 14px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.fm-col-name {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.fm-col-size {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--ink-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fm-col-perms {
|
||||
width: 120px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fm-col-time {
|
||||
width: 160px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fm-icon-folder,
|
||||
.fm-icon-file {
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fm-folder-name {
|
||||
font-weight: 500;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.fm-file-name {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ---- 新建文件夹对话框 ---- */
|
||||
.fm-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.fm-modal {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
padding: 24px;
|
||||
width: 380px;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.fm-modal h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.fm-modal input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.fm-modal input:focus {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 2px var(--brand-soft);
|
||||
}
|
||||
|
||||
.fm-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import './FileManager.css'
|
||||
|
||||
// 格式化文件大小
|
||||
function formatSize(bytes) {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return (bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i]
|
||||
}
|
||||
|
||||
// 格式化时间戳
|
||||
function formatTime(mtime) {
|
||||
if (!mtime) return '-'
|
||||
const d = new Date(mtime * 1000)
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
// 从 longname 提取权限字符串(如 "drwxr-xr-x")
|
||||
function getPerms(longname) {
|
||||
if (!longname) return '-'
|
||||
return longname.slice(0, 10)
|
||||
}
|
||||
|
||||
export default function FileManager() {
|
||||
const [cwd, setCwd] = useState('/')
|
||||
const [files, setFiles] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [selected, setSelected] = useState(new Set()) // 多选:Set<filename>
|
||||
const [lastSelected, setLastSelected] = useState(null)
|
||||
const [history, setHistory] = useState(['/'])
|
||||
const [historyIdx, setHistoryIdx] = useState(0)
|
||||
|
||||
// 多选模式
|
||||
const [multiSelectMode, setMultiSelectMode] = useState(false)
|
||||
// 删除模式
|
||||
const [deleteMode, setDeleteMode] = useState(false)
|
||||
|
||||
// 传输状态
|
||||
const [transferring, setTransferring] = useState(false)
|
||||
const [progress, setProgress] = useState(null)
|
||||
const [transferMsg, setTransferMsg] = useState('')
|
||||
|
||||
// 新建文件夹对话框
|
||||
const [showMkdir, setShowMkdir] = useState(false)
|
||||
const [mkdirName, setMkdirName] = useState('')
|
||||
|
||||
const progressCleanup = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
progressCleanup.current = window.panel.onSftpProgress((data) => {
|
||||
setProgress(data)
|
||||
})
|
||||
return () => {
|
||||
if (progressCleanup.current) progressCleanup.current()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 退出多选/删除模式时清空选择
|
||||
useEffect(() => {
|
||||
if (!multiSelectMode && !deleteMode) {
|
||||
setSelected(new Set())
|
||||
setLastSelected(null)
|
||||
}
|
||||
}, [multiSelectMode, deleteMode])
|
||||
|
||||
// 加载目录
|
||||
const loadDir = useCallback(async (dir) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setSelected(new Set())
|
||||
setLastSelected(null)
|
||||
const res = await window.panel.sftpList(dir)
|
||||
setLoading(false)
|
||||
if (res.ok) {
|
||||
setFiles(res.list)
|
||||
setCwd(dir)
|
||||
} else {
|
||||
setError(res.message || '读取目录失败')
|
||||
setFiles([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadDir('/')
|
||||
}, [loadDir])
|
||||
|
||||
// 导航
|
||||
const navigateTo = (dir) => {
|
||||
const newHistory = history.slice(0, historyIdx + 1)
|
||||
newHistory.push(dir)
|
||||
setHistory(newHistory)
|
||||
setHistoryIdx(newHistory.length - 1)
|
||||
loadDir(dir)
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
if (historyIdx > 0) {
|
||||
const newIdx = historyIdx - 1
|
||||
setHistoryIdx(newIdx)
|
||||
loadDir(history[newIdx])
|
||||
}
|
||||
}
|
||||
|
||||
const goForward = () => {
|
||||
if (historyIdx < history.length - 1) {
|
||||
const newIdx = historyIdx + 1
|
||||
setHistoryIdx(newIdx)
|
||||
loadDir(history[newIdx])
|
||||
}
|
||||
}
|
||||
|
||||
const goUp = () => {
|
||||
if (cwd === '/') return
|
||||
const parts = cwd.split('/').filter(Boolean)
|
||||
parts.pop()
|
||||
const parent = '/' + parts.join('/')
|
||||
navigateTo(parent || '/')
|
||||
}
|
||||
|
||||
// 拼接远程路径
|
||||
const joinPath = (base, name) => {
|
||||
return base === '/' ? '/' + name : base + '/' + name
|
||||
}
|
||||
|
||||
// 是否处于选择模式(多选或删除)
|
||||
const selectMode = multiSelectMode || deleteMode
|
||||
|
||||
// 单击:非选择模式下目录直接进入,文件忽略;选择模式下选中
|
||||
const handleRowClick = (item, e) => {
|
||||
if (!selectMode) {
|
||||
if (item.isDirectory) {
|
||||
navigateTo(joinPath(cwd, item.filename))
|
||||
}
|
||||
return
|
||||
}
|
||||
// 选择模式下点击选中
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(item.filename)) next.delete(item.filename)
|
||||
else next.add(item.filename)
|
||||
return next
|
||||
})
|
||||
setLastSelected(item.filename)
|
||||
} else if (e.shiftKey && lastSelected) {
|
||||
const indices = files.map((f) => f.filename)
|
||||
const start = indices.indexOf(lastSelected)
|
||||
const end = indices.indexOf(item.filename)
|
||||
if (start === -1 || end === -1) return
|
||||
const [from, to] = start <= end ? [start, end] : [end, start]
|
||||
const range = new Set()
|
||||
for (let i = from; i <= to; i++) range.add(indices[i])
|
||||
setSelected(range)
|
||||
} else {
|
||||
setSelected(new Set([item.filename]))
|
||||
setLastSelected(item.filename)
|
||||
}
|
||||
}
|
||||
|
||||
// 双击:目录进入(多选模式下也生效)
|
||||
const handleDoubleClick = (item) => {
|
||||
if (item.isDirectory) {
|
||||
navigateTo(joinPath(cwd, item.filename))
|
||||
}
|
||||
}
|
||||
|
||||
// 选择模式下 checkbox 切换
|
||||
const handleCheckboxChange = (item, e) => {
|
||||
e.stopPropagation()
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(item.filename)) next.delete(item.filename)
|
||||
else next.add(item.filename)
|
||||
return next
|
||||
})
|
||||
setLastSelected(item.filename)
|
||||
}
|
||||
|
||||
// 全选/取消全选
|
||||
const handleSelectAll = () => {
|
||||
if (selected.size === files.length) {
|
||||
setSelected(new Set())
|
||||
} else {
|
||||
setSelected(new Set(files.map((f) => f.filename)))
|
||||
}
|
||||
}
|
||||
|
||||
// 获取选中的文件项
|
||||
const selectedItems = files.filter((f) => selected.has(f.filename))
|
||||
|
||||
// 上传文件(支持多文件)
|
||||
const handleUpload = async () => {
|
||||
setError('')
|
||||
setTransferMsg('')
|
||||
const fileRes = await window.panel.sftpSelectLocalFile()
|
||||
if (!fileRes.ok) return
|
||||
|
||||
const localPaths = fileRes.paths
|
||||
setTransferring(true)
|
||||
|
||||
let okCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const localPath of localPaths) {
|
||||
const filename = localPath.split(/[/\\]/).pop()
|
||||
const remotePath = joinPath(cwd, filename)
|
||||
setProgress({ action: 'upload', filename, transferred: 0, total: 0 })
|
||||
const res = await window.panel.sftpUpload(localPath, remotePath)
|
||||
if (res.ok) {
|
||||
okCount++
|
||||
} else {
|
||||
failCount++
|
||||
setError(`${filename} 上传失败:${res.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
setTransferring(false)
|
||||
setProgress(null)
|
||||
|
||||
if (failCount === 0) {
|
||||
setTransferMsg(`上传完成: ${okCount} 个文件`)
|
||||
} else {
|
||||
setTransferMsg(`上传完成: ${okCount} 成功, ${failCount} 失败`)
|
||||
}
|
||||
loadDir(cwd)
|
||||
}
|
||||
|
||||
// 下载文件(支持多选)
|
||||
const handleDownload = async () => {
|
||||
setError('')
|
||||
setTransferMsg('')
|
||||
if (selected.size === 0) {
|
||||
setError('请先选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
const fileItems = selectedItems.filter((f) => !f.isDirectory)
|
||||
if (fileItems.length === 0) {
|
||||
setError('只能下载文件,不能下载目录')
|
||||
return
|
||||
}
|
||||
|
||||
if (fileItems.length === 1) {
|
||||
const saveRes = await window.panel.sftpSelectSavePath(fileItems[0].filename)
|
||||
if (!saveRes.ok) return
|
||||
setTransferring(true)
|
||||
setProgress({ action: 'download', filename: fileItems[0].filename, transferred: 0, total: 0 })
|
||||
const remotePath = joinPath(cwd, fileItems[0].filename)
|
||||
const res = await window.panel.sftpDownload(remotePath, saveRes.path)
|
||||
setTransferring(false)
|
||||
setProgress(null)
|
||||
if (res.ok) {
|
||||
setTransferMsg(`下载完成: ${fileItems[0].filename} (${formatSize(res.transferred)})`)
|
||||
} else {
|
||||
setError(res.message || '下载失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const dirRes = await window.panel.sftpSelectDownloadDir()
|
||||
if (!dirRes.ok) return
|
||||
|
||||
setTransferring(true)
|
||||
let okCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const item of fileItems) {
|
||||
const remotePath = joinPath(cwd, item.filename)
|
||||
const localPath = dirRes.path.replace(/[/\\]$/, '') + '\\' + item.filename
|
||||
setProgress({ action: 'download', filename: item.filename, transferred: 0, total: 0 })
|
||||
const res = await window.panel.sftpDownload(remotePath, localPath)
|
||||
if (res.ok) {
|
||||
okCount++
|
||||
} else {
|
||||
failCount++
|
||||
setError(`${item.filename} 下载失败:${res.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
setTransferring(false)
|
||||
setProgress(null)
|
||||
if (failCount === 0) {
|
||||
setTransferMsg(`下载完成: ${okCount} 个文件`)
|
||||
} else {
|
||||
setTransferMsg(`下载完成: ${okCount} 成功, ${failCount} 失败`)
|
||||
}
|
||||
}
|
||||
|
||||
// 新建文件夹
|
||||
const handleMkdir = async () => {
|
||||
if (!mkdirName.trim()) return
|
||||
const remotePath = joinPath(cwd, mkdirName.trim())
|
||||
const res = await window.panel.sftpMkdir(remotePath)
|
||||
if (res.ok) {
|
||||
setShowMkdir(false)
|
||||
setMkdirName('')
|
||||
loadDir(cwd)
|
||||
} else {
|
||||
setError(res.message || '创建目录失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 进入删除模式
|
||||
const handleDeleteClick = () => {
|
||||
if (!deleteMode) {
|
||||
setDeleteMode(true)
|
||||
setMultiSelectMode(false)
|
||||
return
|
||||
}
|
||||
handleDelete()
|
||||
}
|
||||
|
||||
// 取消删除模式
|
||||
const handleCancelDelete = () => {
|
||||
setDeleteMode(false)
|
||||
setSelected(new Set())
|
||||
}
|
||||
|
||||
// 删除文件/目录(支持递归删除非空目录,弹窗确认)
|
||||
const handleDelete = async () => {
|
||||
setError('')
|
||||
if (selected.size === 0) {
|
||||
setError('请先选择文件或目录')
|
||||
return
|
||||
}
|
||||
|
||||
const hasDir = selectedItems.some((f) => f.isDirectory)
|
||||
const names = Array.from(selected).join('、')
|
||||
|
||||
if (!hasDir) {
|
||||
// 全是文件,直接确认
|
||||
if (!confirm(`确定删除 ${selected.size} 个文件 (${names}) 吗?`)) return
|
||||
} else {
|
||||
// 包含目录,询问递归
|
||||
const msg =
|
||||
selected.size === 1 && hasDir
|
||||
? `文件夹 "${names}" 非空时需要递归删除。\n\n点击"确定"递归删除(包含所有子文件和子文件夹)\n点击"取消"中止删除`
|
||||
: `${selected.size} 个项目 (${names}),其中包含文件夹。\n\n点击"确定"递归删除(包含所有子文件和子文件夹)\n点击"取消"中止删除`
|
||||
if (!confirm(msg)) return
|
||||
}
|
||||
|
||||
let okCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const item of selectedItems) {
|
||||
const remotePath = joinPath(cwd, item.filename)
|
||||
const res = await window.panel.sftpDelete(remotePath, item.isDirectory, item.isDirectory)
|
||||
if (res.ok) {
|
||||
okCount++
|
||||
} else {
|
||||
failCount++
|
||||
setError(`${item.filename} 删除失败:${res.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
setTransferMsg(`已删除 ${okCount} 个项目`)
|
||||
} else {
|
||||
setTransferMsg(`删除: ${okCount} 成功, ${failCount} 失败`)
|
||||
}
|
||||
setSelected(new Set())
|
||||
setDeleteMode(false)
|
||||
loadDir(cwd)
|
||||
}
|
||||
|
||||
const progressPct = progress && progress.total > 0
|
||||
? Math.round((progress.transferred / progress.total) * 100)
|
||||
: 0
|
||||
|
||||
const hasSelected = selected.size > 0
|
||||
|
||||
return (
|
||||
<div className="file-manager">
|
||||
{/* 工具栏 */}
|
||||
<div className="fm-toolbar">
|
||||
<button className="btn btn-ghost btn-sm" onClick={goBack} disabled={historyIdx === 0}>
|
||||
← 后退
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={goForward} disabled={historyIdx >= history.length - 1}>
|
||||
前进 →
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={goUp} disabled={cwd === '/'}>
|
||||
上级
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => loadDir(cwd)}>
|
||||
刷新
|
||||
</button>
|
||||
<div className="fm-path">
|
||||
<input value={cwd} readOnly />
|
||||
</div>
|
||||
<button
|
||||
className={`btn btn-sm ${multiSelectMode ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => {
|
||||
setMultiSelectMode((v) => !v)
|
||||
if (!multiSelectMode) setDeleteMode(false)
|
||||
}}
|
||||
disabled={deleteMode}
|
||||
>
|
||||
{multiSelectMode ? '退出多选' : '多选'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="fm-actions">
|
||||
<button className="btn btn-primary btn-sm" onClick={handleUpload} disabled={transferring}>
|
||||
上传文件
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleDownload}
|
||||
disabled={transferring || (selectMode && !hasSelected)}
|
||||
>
|
||||
下载{selected.size > 1 ? ` (${selected.size})` : ''}
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setShowMkdir(true)} disabled={transferring}>
|
||||
新建文件夹
|
||||
</button>
|
||||
{deleteMode ? (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={transferring || !hasSelected}
|
||||
>
|
||||
确认删除{selected.size > 0 ? ` (${selected.size})` : ''}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={handleCancelDelete}
|
||||
disabled={transferring}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={transferring}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
{multiSelectMode && (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={handleSelectAll}
|
||||
disabled={transferring}
|
||||
>
|
||||
{selected.size === files.length && files.length > 0 ? '取消全选' : '全选'}
|
||||
</button>
|
||||
)}
|
||||
{selectMode && hasSelected && (
|
||||
<span className="fm-selection-info">
|
||||
已选 {selected.size} 项
|
||||
<button className="fm-clear-selection" onClick={() => setSelected(new Set())}>
|
||||
取消选择
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 传输进度条 */}
|
||||
{transferring && progress && (
|
||||
<div className="fm-progress">
|
||||
<span className="fm-progress-label">
|
||||
{progress.action === 'upload' ? '上传中' : '下载中'}: {progress.filename}
|
||||
</span>
|
||||
<div className="fm-progress-bar">
|
||||
<div className="fm-progress-fill" style={{ width: progressPct + '%' }} />
|
||||
</div>
|
||||
<span className="fm-progress-pct">
|
||||
{progressPct}% {progress.total > 0 && `(${formatSize(progress.transferred)} / ${formatSize(progress.total)})`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 传输结果消息 */}
|
||||
{transferMsg && !transferring && (
|
||||
<div className="fm-message fm-message-success">{transferMsg}</div>
|
||||
)}
|
||||
{error && !transferring && (
|
||||
<div className="fm-message fm-message-error">{error}</div>
|
||||
)}
|
||||
|
||||
{/* 文件列表 */}
|
||||
<div className="fm-list-container">
|
||||
{loading ? (
|
||||
<div className="fm-loading">正在读取目录...</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="fm-empty">目录为空</div>
|
||||
) : (
|
||||
<table className="fm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{selectMode && (
|
||||
<th className="fm-col-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.size === files.length && files.length > 0}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className="fm-col-name">名称</th>
|
||||
<th className="fm-col-size">大小</th>
|
||||
<th className="fm-col-perms">权限</th>
|
||||
<th className="fm-col-time">修改时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((item) => (
|
||||
<tr
|
||||
key={item.filename}
|
||||
className={selected.has(item.filename) ? 'fm-row-selected' : ''}
|
||||
onClick={(e) => handleRowClick(item, e)}
|
||||
onDoubleClick={() => handleDoubleClick(item)}
|
||||
>
|
||||
{selectMode && (
|
||||
<td className="fm-col-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(item.filename)}
|
||||
onChange={(e) => handleCheckboxChange(item, e)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="fm-col-name">
|
||||
<span className={item.isDirectory ? 'fm-icon-folder' : 'fm-icon-file'}>
|
||||
{item.isDirectory ? '📁' : '📄'}
|
||||
</span>
|
||||
<span className={item.isDirectory ? 'fm-folder-name' : 'fm-file-name'}>
|
||||
{item.filename}
|
||||
</span>
|
||||
</td>
|
||||
<td className="fm-col-size">{item.isDirectory ? '-' : formatSize(item.attrs.size)}</td>
|
||||
<td className="fm-col-perms">{getPerms(item.longname)}</td>
|
||||
<td className="fm-col-time">{formatTime(item.attrs.mtime)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 新建文件夹对话框 */}
|
||||
{showMkdir && (
|
||||
<div className="fm-modal-overlay" onClick={() => setShowMkdir(false)}>
|
||||
<div className="fm-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>新建文件夹</h3>
|
||||
<input
|
||||
type="text"
|
||||
value={mkdirName}
|
||||
onChange={(e) => setMkdirName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleMkdir()}
|
||||
placeholder="文件夹名称"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="fm-modal-actions">
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setShowMkdir(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleMkdir}>
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
/* ============================================================
|
||||
系统防火墙管理
|
||||
============================================================ */
|
||||
.firewall-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ---- 状态栏 ---- */
|
||||
.firewall-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.firewall-status-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.firewall-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.firewall-status-text {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.firewall-zone-tag {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
padding: 2px 8px;
|
||||
background: var(--surface-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.firewall-status-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.firewall-error-msg {
|
||||
font-size: 13px;
|
||||
color: var(--status-critical);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--status-warning);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
}
|
||||
|
||||
/* ---- 内容区 ---- */
|
||||
.firewall-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ---- 卡片 ---- */
|
||||
.firewall-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.firewall-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.firewall-card-header h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.firewall-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.firewall-quick-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
color: var(--brand);
|
||||
background: var(--brand-soft);
|
||||
border: 1px solid rgba(91, 110, 245, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.firewall-quick-btn:hover:not(:disabled) {
|
||||
background: var(--brand-soft-hover);
|
||||
}
|
||||
|
||||
.firewall-quick-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---- 标签芯片 ---- */
|
||||
.firewall-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.firewall-chips-empty {
|
||||
font-size: 13px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.firewall-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px 5px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.firewall-chip-service {
|
||||
background: rgba(91, 110, 245, 0.08);
|
||||
border: 1px solid rgba(91, 110, 245, 0.2);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.firewall-chip-port {
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
color: var(--status-good);
|
||||
}
|
||||
|
||||
.chip-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip-label.mono {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.chip-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s ease, background 0.15s ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chip-remove:hover:not(:disabled) {
|
||||
opacity: 1;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: var(--status-critical);
|
||||
}
|
||||
|
||||
.chip-remove:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---- 未安装防火墙 ---- */
|
||||
.firewall-empty-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 40px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.firewall-empty-icon {
|
||||
color: var(--ink-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.firewall-empty-card p {
|
||||
font-size: 14px;
|
||||
color: var(--ink-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.firewall-empty-hint {
|
||||
color: var(--ink-muted) !important;
|
||||
font-size: 13px !important;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
/* ---- 已停止提示 ---- */
|
||||
.firewall-stopped-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--ink-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ---- iptables / ufw 规则展示 ---- */
|
||||
.iptables-rules-wrap {
|
||||
padding: 16px 20px;
|
||||
overflow: auto;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.iptables-rules {
|
||||
font-size: 13px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--ink);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---- 添加端口弹窗 ---- */
|
||||
.firewall-add-dialog {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
padding: 28px 32px;
|
||||
max-width: 440px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.firewall-add-dialog h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-strong);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.firewall-add-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.firewall-add-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.firewall-add-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.firewall-add-input {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.firewall-add-input:focus {
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.firewall-add-select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.firewall-add-error {
|
||||
font-size: 13px;
|
||||
color: var(--status-critical);
|
||||
padding: 8px 12px;
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import './Firewall.css'
|
||||
|
||||
// 系统防火墙管理:自动检测 firewalld / iptables / ufw,支持查看规则、开关服务、放行端口
|
||||
export default function Firewall() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [firewallType, setFirewallType] = useState(null) // 'firewalld' | 'iptables' | 'ufw' | null
|
||||
const [running, setRunning] = useState(false)
|
||||
const [zones, setZones] = useState([])
|
||||
const [activeZone, setActiveZone] = useState('')
|
||||
const [services, setServices] = useState([])
|
||||
const [ports, setPorts] = useState([])
|
||||
const [iptablesRules, setIptablesRules] = useState([])
|
||||
const [error, setError] = useState(null)
|
||||
const [acting, setActing] = useState(false)
|
||||
|
||||
// 添加端口弹窗
|
||||
const [showAddPort, setShowAddPort] = useState(false)
|
||||
const [newPort, setNewPort] = useState('')
|
||||
const [newProtocol, setNewProtocol] = useState('tcp')
|
||||
const [addError, setAddError] = useState(null)
|
||||
|
||||
// 检测防火墙类型和状态
|
||||
const detect = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// 一次性检测 firewalld、ufw、iptables
|
||||
const cmd = [
|
||||
'echo "===FIREWALLD==="',
|
||||
'command -v firewall-cmd >/dev/null 2>&1 && echo "INSTALLED" || echo "NOT_INSTALLED"',
|
||||
'systemctl is-active firewalld 2>/dev/null || echo "inactive"',
|
||||
'echo "===UFW==="',
|
||||
'command -v ufw >/dev/null 2>&1 && echo "INSTALLED" || echo "NOT_INSTALLED"',
|
||||
'ufw status 2>/dev/null | head -1 || echo "inactive"',
|
||||
'echo "===IPTABLES==="',
|
||||
'command -v iptables >/dev/null 2>&1 && echo "INSTALLED" || echo "NOT_INSTALLED"',
|
||||
].join(' && ')
|
||||
|
||||
const r = await window.panel.exec(cmd, 10000)
|
||||
setLoading(false)
|
||||
|
||||
if (!r.ok || r.code !== 0) {
|
||||
setError('检测防火墙状态失败')
|
||||
setFirewallType(null)
|
||||
return
|
||||
}
|
||||
|
||||
const output = r.stdout || ''
|
||||
const sections = output.split(/===\w+===/).map((s) => s.trim())
|
||||
const firewalldSection = sections[1] || ''
|
||||
const ufwSection = sections[2] || ''
|
||||
const iptablesSection = sections[3] || ''
|
||||
|
||||
const firewalldInstalled = firewalldSection.includes('INSTALLED')
|
||||
const firewalldActive = firewalldSection.includes('active') && !firewalldSection.includes('inactive')
|
||||
const ufwInstalled = ufwSection.includes('INSTALLED')
|
||||
const iptablesInstalled = iptablesSection.includes('INSTALLED')
|
||||
|
||||
if (firewalldInstalled) {
|
||||
setFirewallType('firewalld')
|
||||
setRunning(firewalldActive)
|
||||
if (firewalldActive) {
|
||||
await loadFirewalldDetails()
|
||||
}
|
||||
} else if (ufwInstalled) {
|
||||
setFirewallType('ufw')
|
||||
const ufwActive = ufwSection.includes('active')
|
||||
setRunning(ufwActive)
|
||||
if (ufwActive) {
|
||||
await loadUfwDetails()
|
||||
}
|
||||
} else if (iptablesInstalled) {
|
||||
setFirewallType('iptables')
|
||||
setRunning(true)
|
||||
await loadIptablesRules()
|
||||
} else {
|
||||
setFirewallType(null)
|
||||
setRunning(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 加载 firewalld 详情(区域、服务、端口)
|
||||
const loadFirewalldDetails = useCallback(async () => {
|
||||
setError(null)
|
||||
|
||||
const zoneR = await window.panel.exec('firewall-cmd --get-default-zone 2>/dev/null', 5000)
|
||||
const zone = (zoneR.stdout || '').trim()
|
||||
setActiveZone(zone || 'public')
|
||||
|
||||
const zonesR = await window.panel.exec('firewall-cmd --get-zones 2>/dev/null', 5000)
|
||||
const zoneList = (zonesR.stdout || '').trim().split(/\s+/).filter(Boolean)
|
||||
setZones(zoneList)
|
||||
|
||||
const svcR = await window.panel.exec(`firewall-cmd --zone=${zone} --list-services 2>/dev/null`, 5000)
|
||||
const svcList = (svcR.stdout || '').trim().split(/\s+/).filter(Boolean)
|
||||
setServices(svcList)
|
||||
|
||||
const portR = await window.panel.exec(`firewall-cmd --zone=${zone} --list-ports 2>/dev/null`, 5000)
|
||||
const portList = (portR.stdout || '').trim().split(/\s+/).filter(Boolean)
|
||||
setPorts(portList)
|
||||
}, [])
|
||||
|
||||
// 加载 ufw 规则
|
||||
const loadUfwDetails = useCallback(async () => {
|
||||
setError(null)
|
||||
const r = await window.panel.exec('ufw status numbered 2>/dev/null', 5000)
|
||||
if (r.ok && r.stdout) {
|
||||
const lines = r.stdout.trim().split('\n').filter((l) => l.trim())
|
||||
const ruleLines = lines.filter((l) => /^\s*\[/.test(l))
|
||||
setIptablesRules(ruleLines)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 加载 iptables 规则
|
||||
const loadIptablesRules = useCallback(async () => {
|
||||
setError(null)
|
||||
const r = await window.panel.exec('iptables -L -n --line-numbers 2>/dev/null', 5000)
|
||||
if (r.ok && r.stdout) {
|
||||
const lines = r.stdout.trim().split('\n').filter((l) => l.trim())
|
||||
setIptablesRules(lines)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 开启/关闭防火墙
|
||||
const toggleFirewall = useCallback(async () => {
|
||||
setActing(true)
|
||||
setError(null)
|
||||
|
||||
let cmd = ''
|
||||
if (firewallType === 'firewalld') {
|
||||
cmd = running
|
||||
? 'systemctl stop firewalld 2>&1'
|
||||
: 'systemctl start firewalld 2>&1'
|
||||
} else if (firewallType === 'ufw') {
|
||||
cmd = running
|
||||
? 'ufw disable 2>&1'
|
||||
: 'echo "y" | ufw enable 2>&1'
|
||||
}
|
||||
|
||||
if (!cmd) {
|
||||
setActing(false)
|
||||
return
|
||||
}
|
||||
|
||||
const r = await window.panel.exec(cmd, 15000)
|
||||
setActing(false)
|
||||
|
||||
if (r.ok && r.code === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
await detect()
|
||||
} else {
|
||||
setError(r.stderr || r.stdout || '操作失败')
|
||||
}
|
||||
}, [firewallType, running, detect])
|
||||
|
||||
// 添加端口(firewalld)
|
||||
const addPort = useCallback(async () => {
|
||||
if (!newPort.trim()) return
|
||||
setActing(true)
|
||||
setAddError(null)
|
||||
|
||||
const proto = newProtocol === 'tcp/udp' ? '' : `/${newProtocol}`
|
||||
const cmd = `firewall-cmd --zone=${activeZone} --add-port=${newPort.trim()}${proto ? '' : '/tcp /udp'} --permanent 2>&1 && firewall-cmd --reload 2>&1`
|
||||
|
||||
// 简化:直接使用 port/proto 格式
|
||||
let portArg
|
||||
if (newProtocol === 'tcp/udp') {
|
||||
// firewalld 不支持同时 tcp+udp 一次添加,需分两次
|
||||
const r1 = await window.panel.exec(`firewall-cmd --zone=${activeZone} --add-port=${newPort.trim()}/tcp --permanent 2>&1`, 5000)
|
||||
const r2 = await window.panel.exec(`firewall-cmd --zone=${activeZone} --add-port=${newPort.trim()}/udp --permanent 2>&1`, 5000)
|
||||
if (r1.ok && r1.code === 0 && r2.ok && r2.code === 0) {
|
||||
await window.panel.exec('firewall-cmd --reload 2>&1', 5000)
|
||||
setActing(false)
|
||||
setShowAddPort(false)
|
||||
setNewPort('')
|
||||
await loadFirewalldDetails()
|
||||
return
|
||||
} else {
|
||||
setActing(false)
|
||||
setAddError(r1.stderr || r2.stderr || '添加端口失败')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
portArg = `${newPort.trim()}/${newProtocol}`
|
||||
}
|
||||
|
||||
const cmd2 = `firewall-cmd --zone=${activeZone} --add-port=${portArg} --permanent 2>&1 && firewall-cmd --reload 2>&1`
|
||||
const r = await window.panel.exec(cmd2, 10000)
|
||||
setActing(false)
|
||||
|
||||
if (r.ok && r.code === 0) {
|
||||
setShowAddPort(false)
|
||||
setNewPort('')
|
||||
await loadFirewalldDetails()
|
||||
} else {
|
||||
setAddError(r.stderr || r.stdout || '添加端口失败')
|
||||
}
|
||||
}, [newPort, newProtocol, activeZone, loadFirewalldDetails])
|
||||
|
||||
// 移除端口(firewalld)
|
||||
const removePort = useCallback(async (portStr) => {
|
||||
setActing(true)
|
||||
const [portNum, proto] = portStr.split('/')
|
||||
const cmd = `firewall-cmd --zone=${activeZone} --remove-port=${portNum}/${proto} --permanent 2>&1 && firewall-cmd --reload 2>&1`
|
||||
const r = await window.panel.exec(cmd, 10000)
|
||||
setActing(false)
|
||||
|
||||
if (r.ok && r.code === 0) {
|
||||
await loadFirewalldDetails()
|
||||
} else {
|
||||
setError(r.stderr || r.stdout || '移除端口失败')
|
||||
}
|
||||
}, [activeZone, loadFirewalldDetails])
|
||||
|
||||
// 添加服务(firewalld)
|
||||
const addService = useCallback(async (svcName) => {
|
||||
setActing(true)
|
||||
const cmd = `firewall-cmd --zone=${activeZone} --add-service=${svcName} --permanent 2>&1 && firewall-cmd --reload 2>&1`
|
||||
const r = await window.panel.exec(cmd, 10000)
|
||||
setActing(false)
|
||||
|
||||
if (r.ok && r.code === 0) {
|
||||
await loadFirewalldDetails()
|
||||
} else {
|
||||
setError(r.stderr || r.stdout || '添加服务失败')
|
||||
}
|
||||
}, [activeZone, loadFirewalldDetails])
|
||||
|
||||
// 移除服务(firewalld)
|
||||
const removeService = useCallback(async (svcName) => {
|
||||
setActing(true)
|
||||
const cmd = `firewall-cmd --zone=${activeZone} --remove-service=${svcName} --permanent 2>&1 && firewall-cmd --reload 2>&1`
|
||||
const r = await window.panel.exec(cmd, 10000)
|
||||
setActing(false)
|
||||
|
||||
if (r.ok && r.code === 0) {
|
||||
await loadFirewalldDetails()
|
||||
} else {
|
||||
setError(r.stderr || r.stdout || '移除服务失败')
|
||||
}
|
||||
}, [activeZone, loadFirewalldDetails])
|
||||
|
||||
useEffect(() => {
|
||||
detect()
|
||||
}, [detect])
|
||||
|
||||
// ---- 渲染 ----
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="firewall-page">
|
||||
<div className="container-loading">
|
||||
<div className="docker-spinner" />
|
||||
<span>正在检测防火墙状态...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!firewallType) {
|
||||
return (
|
||||
<div className="firewall-page">
|
||||
<div className="firewall-status-bar">
|
||||
<div className="firewall-status-left">
|
||||
<span className="firewall-status-dot" style={{ background: 'var(--ink-muted)' }} />
|
||||
<span className="firewall-status-text" style={{ color: 'var(--ink-muted)' }}>
|
||||
未检测到防火墙
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="firewall-empty-card">
|
||||
<div className="firewall-empty-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M12 2L4 6v6c0 5 3.5 9 8 10 4.5-1 8-5 8-10V6l-8-4z" />
|
||||
<line x1="9" y1="12" x2="15" y2="12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>服务器未安装 firewalld、ufw 或 iptables,无法管理防火墙。</p>
|
||||
<p className="firewall-empty-hint">建议安装 firewalld:yum install -y firewalld 或 apt install -y firewalld</p>
|
||||
<button className="btn btn-ghost" onClick={detect}>重新检测</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const typeLabel = { firewalld: 'firewalld', ufw: 'UFW', iptables: 'iptables' }
|
||||
const typeColor = running ? 'var(--status-good)' : 'var(--status-warning)'
|
||||
|
||||
return (
|
||||
<div className="firewall-page">
|
||||
{/* 状态栏 */}
|
||||
<div className="firewall-status-bar">
|
||||
<div className="firewall-status-left">
|
||||
<span className="firewall-status-dot" style={{ background: typeColor }} />
|
||||
<span className="firewall-status-text" style={{ color: typeColor }}>
|
||||
{typeLabel[firewallType]} {running ? '运行中' : '已停止'}
|
||||
</span>
|
||||
{firewallType === 'firewalld' && running && activeZone && (
|
||||
<span className="firewall-zone-tag">默认区域: {activeZone}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="firewall-status-actions">
|
||||
{error && <span className="firewall-error-msg">{error}</span>}
|
||||
<button className="btn btn-ghost btn-sm" onClick={detect} disabled={acting}>刷新</button>
|
||||
{firewallType !== 'iptables' && (
|
||||
<button
|
||||
className={`btn btn-sm ${running ? 'btn-warning' : 'btn-primary'}`}
|
||||
onClick={toggleFirewall}
|
||||
disabled={acting}
|
||||
>
|
||||
{acting ? '处理中...' : running ? '关闭防火墙' : '开启防火墙'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* firewalld 管理面板 */}
|
||||
{firewallType === 'firewalld' && running && (
|
||||
<div className="firewall-content">
|
||||
{/* 已放行服务 */}
|
||||
<div className="firewall-card">
|
||||
<div className="firewall-card-header">
|
||||
<h4>已放行服务</h4>
|
||||
<div className="firewall-card-actions">
|
||||
{['ssh', 'http', 'https'].filter((s) => !services.includes(s)).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className="firewall-quick-btn"
|
||||
onClick={() => addService(s)}
|
||||
disabled={acting}
|
||||
>
|
||||
+ {s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="firewall-chips">
|
||||
{services.length === 0 ? (
|
||||
<span className="firewall-chips-empty">暂无放行服务</span>
|
||||
) : (
|
||||
services.map((svc) => (
|
||||
<div key={svc} className="firewall-chip firewall-chip-service">
|
||||
<span className="chip-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M3 9h18M9 21V9" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="chip-label">{svc}</span>
|
||||
<button
|
||||
className="chip-remove"
|
||||
onClick={() => removeService(svc)}
|
||||
disabled={acting}
|
||||
title="移除服务"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已放行端口 */}
|
||||
<div className="firewall-card">
|
||||
<div className="firewall-card-header">
|
||||
<h4>已放行端口</h4>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => { setShowAddPort(true); setAddError(null) }}
|
||||
disabled={acting}
|
||||
>
|
||||
+ 添加端口
|
||||
</button>
|
||||
</div>
|
||||
<div className="firewall-chips">
|
||||
{ports.length === 0 ? (
|
||||
<span className="firewall-chips-empty">暂无放行端口</span>
|
||||
) : (
|
||||
ports.map((port) => (
|
||||
<div key={port} className="firewall-chip firewall-chip-port">
|
||||
<span className="chip-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="chip-label mono">{port}</span>
|
||||
<button
|
||||
className="chip-remove"
|
||||
onClick={() => removePort(port)}
|
||||
disabled={acting}
|
||||
title="移除端口"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* firewalld 未运行提示 */}
|
||||
{firewallType === 'firewalld' && !running && (
|
||||
<div className="firewall-stopped-card">
|
||||
<p>firewalld 已安装但未运行,点击上方「开启防火墙」启动。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UFW / iptables 规则列表 */}
|
||||
{(firewallType === 'ufw' || firewallType === 'iptables') && running && (
|
||||
<div className="firewall-card">
|
||||
<div className="firewall-card-header">
|
||||
<h4>{firewallType === 'ufw' ? 'UFW 规则列表' : 'iptables 规则列表'}</h4>
|
||||
<button className="btn btn-ghost btn-sm" onClick={firewallType === 'ufw' ? loadUfwDetails : loadIptablesRules} disabled={acting}>
|
||||
刷新规则
|
||||
</button>
|
||||
</div>
|
||||
<div className="iptables-rules-wrap">
|
||||
{iptablesRules.length === 0 ? (
|
||||
<span className="firewall-chips-empty">暂无规则</span>
|
||||
) : (
|
||||
<pre className="iptables-rules">{iptablesRules.join('\n')}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加端口弹窗 */}
|
||||
{showAddPort && (
|
||||
<div className="confirm-overlay" onClick={acting ? undefined : () => setShowAddPort(false)}>
|
||||
<div className="firewall-add-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>添加放行端口</h3>
|
||||
<div className="firewall-add-body">
|
||||
<div className="firewall-add-row">
|
||||
<label className="firewall-add-label">端口号 <span className="param-required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
className="firewall-add-input"
|
||||
placeholder="例如:8080 或 3000-3100"
|
||||
value={newPort}
|
||||
onChange={(e) => setNewPort(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newPort.trim()) addPort()
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="firewall-add-row">
|
||||
<label className="firewall-add-label">协议</label>
|
||||
<select
|
||||
className="firewall-add-input firewall-add-select"
|
||||
value={newProtocol}
|
||||
onChange={(e) => setNewProtocol(e.target.value)}
|
||||
>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
<option value="tcp/udp">TCP + UDP</option>
|
||||
</select>
|
||||
</div>
|
||||
{addError && <div className="firewall-add-error">{addError}</div>}
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button className="btn btn-ghost" onClick={() => setShowAddPort(false)} disabled={acting}>取消</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={addPort}
|
||||
disabled={acting || !newPort.trim()}
|
||||
>
|
||||
{acting ? '添加中...' : '确认添加'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -256,11 +256,60 @@
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.proc-table th:nth-child(6),
|
||||
.proc-table td:nth-child(6) {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.proc-table th:nth-child(7),
|
||||
.proc-table td:nth-child(7) {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
/* 排序表头 */
|
||||
.proc-table th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.proc-table th.sortable:hover {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.proc-table th.sort-active {
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.proc-table td.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 端口列 */
|
||||
.proc-table td.port-cell {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.port-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
margin-left: 4px;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.port-empty {
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.proc-table tbody tr {
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ const INSTALL_CMD =
|
||||
'elif command -v apk >/dev/null 2>&1; then $SUDO apk add procps; ' +
|
||||
'else echo "无法识别服务器的包管理器"; exit 1; fi'
|
||||
|
||||
async function fetchProcesses() {
|
||||
async function fetchProcesses(sortBy) {
|
||||
// 根据 sortBy 选择排序字段
|
||||
const sortFlag = sortBy === 'mem' ? '-pmem' : '-pcpu'
|
||||
const psResult = await window.panel.exec(
|
||||
'ps -eo pid,user,pcpu,pmem,comm --sort=-pcpu --no-headers | head -n 12'
|
||||
`ps -eo pid,user,pcpu,pmem,comm --sort=${sortFlag} --no-headers | head -n 12`
|
||||
)
|
||||
if (!psResult.ok) throw new Error(psResult.message)
|
||||
|
||||
@@ -36,6 +38,32 @@ async function fetchProcesses() {
|
||||
const countResult = await window.panel.exec('ps -e --no-headers | wc -l')
|
||||
const total = countResult.ok ? Number(countResult.stdout.trim()) : null
|
||||
|
||||
// 获取监听端口(ss 命令优先,fallback netstat)
|
||||
let portMap = {}
|
||||
const ssResult = await window.panel.exec(
|
||||
"ss -tlnp 2>/dev/null | awk 'NR>1{print $4\" \"$NF}' || netstat -tlnp 2>/dev/null | awk 'NR>2{print $4\" \"$NF}'"
|
||||
)
|
||||
if (ssResult.ok && ssResult.code === 0) {
|
||||
ssResult.stdout.split('\n').forEach((line) => {
|
||||
line = line.trim()
|
||||
if (!line) return
|
||||
// 格式: "0.0.0.0:8080 users:((\"nginx\",pid=1234,fd=6))"
|
||||
const portMatch = line.match(/:(\d+)\s/)
|
||||
const pidMatch = line.match(/pid=(\d+)/)
|
||||
if (portMatch && pidMatch) {
|
||||
const pid = pidMatch[1]
|
||||
const port = portMatch[1]
|
||||
if (!portMap[pid]) portMap[pid] = []
|
||||
if (!portMap[pid].includes(port)) portMap[pid].push(port)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 将端口信息附加到进程
|
||||
procs.forEach((p) => {
|
||||
p.ports = portMap[p.pid] || []
|
||||
})
|
||||
|
||||
return { procs, total }
|
||||
}
|
||||
|
||||
@@ -48,9 +76,10 @@ export default function ProcessCard() {
|
||||
const [procs, setProcs] = useState([])
|
||||
const [total, setTotal] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [confirmPid, setConfirmPid] = useState(null) // 二次确认中的 PID
|
||||
const [feedback, setFeedback] = useState(null) // { ok, text }
|
||||
const [refreshKey, setRefreshKey] = useState(0) // 杀死进程后立即刷新列表
|
||||
const [confirmPid, setConfirmPid] = useState(null)
|
||||
const [feedback, setFeedback] = useState(null)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const [sortBy, setSortBy] = useState('cpu') // 'cpu' | 'mem'
|
||||
const timers = useRef([])
|
||||
|
||||
// 数据加载
|
||||
@@ -60,7 +89,7 @@ export default function ProcessCard() {
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const d = await fetchProcesses()
|
||||
const d = await fetchProcesses(sortBy)
|
||||
if (alive) {
|
||||
setProcs(d.procs)
|
||||
setTotal(d.total)
|
||||
@@ -77,9 +106,8 @@ export default function ProcessCard() {
|
||||
alive = false
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [metric.status, refreshKey])
|
||||
}, [metric.status, refreshKey, sortBy])
|
||||
|
||||
// 组件卸载时清理所有未完成的定时器
|
||||
useEffect(() => () => timers.current.forEach(clearTimeout), [])
|
||||
|
||||
const schedule = (fn, ms) => {
|
||||
@@ -92,7 +120,6 @@ export default function ProcessCard() {
|
||||
}
|
||||
|
||||
const handleKill = async (p) => {
|
||||
// 第一次点击:进入确认状态,3 秒内不确认则自动取消
|
||||
if (confirmPid !== p.pid) {
|
||||
setConfirmPid(p.pid)
|
||||
schedule(() => setConfirmPid(null), 3000)
|
||||
@@ -111,10 +138,14 @@ export default function ProcessCard() {
|
||||
}
|
||||
}
|
||||
|
||||
const subtitle = total !== null
|
||||
? `共 ${total} 个 · 按${sortBy === 'cpu' ? ' CPU' : '内存'}占用排序`
|
||||
: `按${sortBy === 'cpu' ? ' CPU' : '内存'}占用排序`
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
title="进程"
|
||||
subtitle={total !== null ? `共 ${total} 个 · CPU 占用前 12` : 'CPU 占用前 12'}
|
||||
subtitle={subtitle}
|
||||
status={metric.status}
|
||||
message={metric.message}
|
||||
installable={metric.installable}
|
||||
@@ -131,15 +162,26 @@ export default function ProcessCard() {
|
||||
<th>进程</th>
|
||||
<th>PID</th>
|
||||
<th>用户</th>
|
||||
<th className="num">CPU%</th>
|
||||
<th className="num">内存%</th>
|
||||
<th
|
||||
className={`num sortable ${sortBy === 'cpu' ? 'sort-active' : ''}`}
|
||||
onClick={() => setSortBy('cpu')}
|
||||
>
|
||||
CPU% {sortBy === 'cpu' ? '↓' : ''}
|
||||
</th>
|
||||
<th
|
||||
className={`num sortable ${sortBy === 'mem' ? 'sort-active' : ''}`}
|
||||
onClick={() => setSortBy('mem')}
|
||||
>
|
||||
内存% {sortBy === 'mem' ? '↓' : ''}
|
||||
</th>
|
||||
<th className="num">端口</th>
|
||||
<th className="kill-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{procs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="6">加载中...</td>
|
||||
<td colSpan="7">加载中...</td>
|
||||
</tr>
|
||||
) : (
|
||||
procs.map((p) => (
|
||||
@@ -149,6 +191,15 @@ export default function ProcessCard() {
|
||||
<td>{p.user}</td>
|
||||
<td className="num">{p.cpu}</td>
|
||||
<td className="num">{p.mem}</td>
|
||||
<td className="num port-cell">
|
||||
{p.ports.length > 0 ? (
|
||||
p.ports.map((port) => (
|
||||
<span key={port} className="port-badge">{port}</span>
|
||||
))
|
||||
) : (
|
||||
<span className="port-empty">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="kill-cell">
|
||||
<button
|
||||
className={`kill-btn ${confirmPid === p.pid ? 'confirm' : ''}`}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
.terminal-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 76px - 28px - 28px);
|
||||
min-height: 400px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-card);
|
||||
overflow: hidden;
|
||||
background: #1a1d29;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.terminal-status {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.terminal-status-connecting {
|
||||
color: var(--status-warning);
|
||||
}
|
||||
|
||||
.terminal-status-connected {
|
||||
color: var(--status-good);
|
||||
}
|
||||
|
||||
.terminal-status-error {
|
||||
color: var(--status-critical);
|
||||
}
|
||||
|
||||
.terminal-status-closed {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* xterm 内部样式微调 */
|
||||
.terminal-container .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport {
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import './TerminalPanel.css'
|
||||
|
||||
export default function TerminalPanel() {
|
||||
const containerRef = useRef(null)
|
||||
const termRef = useRef(null)
|
||||
const fitRef = useRef(null)
|
||||
const [status, setStatus] = useState('connecting') // connecting | connected | error | closed
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
let disposed = false
|
||||
|
||||
// 创建 xterm 实例
|
||||
const term = new Terminal({
|
||||
fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace",
|
||||
fontSize: 14,
|
||||
cursorBlink: true,
|
||||
theme: {
|
||||
background: '#1a1d29',
|
||||
foreground: '#e5e7eb',
|
||||
cursor: '#5b6ef5',
|
||||
selectionBackground: 'rgba(91, 110, 245, 0.3)',
|
||||
},
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.open(containerRef.current)
|
||||
fitAddon.fit()
|
||||
|
||||
termRef.current = term
|
||||
fitRef.current = fitAddon
|
||||
|
||||
// 先注册监听器,再启动 Shell,避免遗漏首批数据
|
||||
let shellStarted = false
|
||||
|
||||
// 监听 Shell 数据 → 写入 xterm
|
||||
const removeDataListener = window.panel.onShellData((data) => {
|
||||
if (disposed) return
|
||||
const u8 = data instanceof Uint8Array ? data : new Uint8Array(data)
|
||||
term.write(u8)
|
||||
})
|
||||
|
||||
// 监听 Shell 关闭
|
||||
const removeCloseListener = window.panel.onShellClose(() => {
|
||||
if (disposed) return
|
||||
setStatus('closed')
|
||||
term.write('\r\n\x1b[33m[SSH 连接已关闭]\x1b[0m\r\n')
|
||||
})
|
||||
|
||||
// xterm 输入 → 发送到 Shell
|
||||
const inputDisposable = term.onData((data) => {
|
||||
if (!disposed) window.panel.shellWrite(data)
|
||||
})
|
||||
|
||||
// 窗口大小变化 → 重新 fit + 通知服务器
|
||||
const onResize = () => {
|
||||
fitAddon.fit()
|
||||
if (shellStarted && !disposed) {
|
||||
window.panel.shellResize(term.cols, term.rows)
|
||||
}
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
// 启动 SSH Shell(延迟一帧,确保 StrictMode 第一次 unmount 时
|
||||
// disposed 已被设为 true,不会真正发起连接)
|
||||
const startTimer = setTimeout(async () => {
|
||||
if (disposed) return
|
||||
const { cols, rows } = term
|
||||
const res = await window.panel.shellStart(cols, rows)
|
||||
if (disposed) {
|
||||
// 在等待期间组件已被卸载(StrictMode 第一次),关闭刚建立的 Shell
|
||||
if (res.ok) window.panel.shellClose()
|
||||
return
|
||||
}
|
||||
if (!res.ok) {
|
||||
setStatus('error')
|
||||
setErrorMsg(res.message || '连接终端失败')
|
||||
return
|
||||
}
|
||||
shellStarted = true
|
||||
setStatus('connected')
|
||||
}, 0)
|
||||
|
||||
// 组件卸载时清理
|
||||
return () => {
|
||||
disposed = true
|
||||
clearTimeout(startTimer)
|
||||
window.removeEventListener('resize', onResize)
|
||||
inputDisposable.dispose()
|
||||
removeDataListener()
|
||||
removeCloseListener()
|
||||
window.panel.shellClose()
|
||||
term.dispose()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 手动重连
|
||||
const handleReconnect = async () => {
|
||||
if (!termRef.current) return
|
||||
setStatus('connecting')
|
||||
setErrorMsg('')
|
||||
termRef.current.clear()
|
||||
termRef.current.write('\r\n\x1b[36m[正在重新连接...]\x1b[0m\r\n')
|
||||
const { cols, rows } = termRef.current
|
||||
const res = await window.panel.shellStart(cols, rows)
|
||||
if (!res.ok) {
|
||||
setStatus('error')
|
||||
setErrorMsg(res.message || '连接终端失败')
|
||||
} else {
|
||||
setStatus('connected')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="terminal-wrap">
|
||||
<div className="terminal-header">
|
||||
<span className={`terminal-status terminal-status-${status}`}>
|
||||
{status === 'connecting' && '正在连接...'}
|
||||
{status === 'connected' && '已连接'}
|
||||
{status === 'error' && `错误: ${errorMsg}`}
|
||||
{status === 'closed' && '已断开'}
|
||||
</span>
|
||||
{status !== 'connected' && status !== 'connecting' && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={handleReconnect}>
|
||||
重新连接
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="terminal-container" ref={containerRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -122,6 +122,22 @@
|
||||
background: var(--brand-soft);
|
||||
}
|
||||
|
||||
/* 危险按钮(删除确认) */
|
||||
.btn-danger {
|
||||
background: #e53e3e;
|
||||
color: #ffffff;
|
||||
border-color: #e53e3e;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #c53030;
|
||||
border-color: #c53030;
|
||||
}
|
||||
|
||||
.btn-danger:active:not(:disabled) {
|
||||
background: #9b2c2c;
|
||||
}
|
||||
|
||||
/* 深色导航栏上的按钮变体 */
|
||||
.btn-menu {
|
||||
height: 30px;
|
||||
|
||||
Reference in New Issue
Block a user