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:
2026-09-08 01:25:54 +08:00
parent e929d4842e
commit 90bc4bd398
46 changed files with 14841 additions and 57 deletions
+2
View File
@@ -1,4 +1,6 @@
node_modules/ node_modules/
out/ out/
dist/ dist/
release/
.temp/
.idea/ .idea/
+1
View File
@@ -0,0 +1 @@
electron_mirror=https://npmmirror.com/mirrors/electron/
+20
View File
@@ -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"]
+234
View File
@@ -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)
+38
View File
@@ -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"
+290
View File
@@ -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());
+3
View File
@@ -0,0 +1,3 @@
flask==3.0.3
flask-cors==4.0.1
mysql-connector-python==8.4.0
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

+3362
View File
File diff suppressed because it is too large Load Diff
+33 -1
View File
@@ -6,16 +6,48 @@
"scripts": { "scripts": {
"start": "electron-vite preview", "start": "electron-vite preview",
"dev": "electron-vite dev", "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", "license": "MIT",
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"electron": "^44.2.0", "electron": "^44.2.0",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
"vite": "^7.3.6" "vite": "^7.3.6"
}, },
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"ssh2": "^1.17.0" "ssh2": "^1.17.0"
+1200 -35
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -6,8 +6,85 @@ contextBridge.exposeInMainWorld('panel', {
disconnect: () => ipcRenderer.invoke('ssh:disconnect'), disconnect: () => ipcRenderer.invoke('ssh:disconnect'),
getSession: () => ipcRenderer.invoke('ssh:session'), getSession: () => ipcRenderer.invoke('ssh:session'),
exec: (cmd, timeout) => ipcRenderer.invoke('ssh:exec', { cmd, timeout }), 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), saveCredential: (cred) => ipcRenderer.invoke('cred:save', cred),
listCredentials: () => ipcRenderer.invoke('cred:list'), listCredentials: () => ipcRenderer.invoke('cred:list'),
deleteCredential: (key) => ipcRenderer.invoke('cred:delete', key), 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),
}) })
+27 -2
View File
@@ -1,21 +1,46 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import Login from './pages/Login' import Login from './pages/Login'
import Panel from './pages/Panel' import Panel from './pages/Panel'
export default function App() { export default function App() {
const [session, setSession] = useState(null) const [session, setSession] = useState(null)
const [dockerReady, setDockerReady] = useState(false)
// 登录成功后切换到面板页 // 登录成功后切换到面板页
const handleConnected = (session) => setSession(session) const handleConnected = (session) => setSession(session)
// 断开连接,回到登录页 // 断开连接,回到登录页
const handleLogout = async () => { const handleLogout = async () => {
await window.panel.dockerDisconnect()
await window.panel.disconnect() await window.panel.disconnect()
setDockerReady(false)
setSession(null) 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 ? ( return session ? (
<Panel session={session} onLogout={handleLogout} /> <Panel session={session} dockerReady={dockerReady} onLogout={handleLogout} />
) : ( ) : (
<Login onConnected={handleConnected} /> <Login onConnected={handleConnected} />
) )
+14 -3
View File
@@ -11,9 +11,20 @@ export default function Login({ onConnected }) {
const [status, setStatus] = useState('') const [status, setStatus] = useState('')
const [saved, setSaved] = useState([]) const [saved, setSaved] = useState([])
// 加载已保存的服务器列表 // 加载已保存的服务器列表,并自动填充最近一次的登录信息
useEffect(() => { 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 ( return (
<div className="wrap"> <div className="wrap">
<div className="card"> <div className="card">
<h1>SSH Panel</h1> <h1>WFJ Panel</h1>
<p className="subtitle">连接到你的服务器</p> <p className="subtitle">连接到你的服务器</p>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="field-row"> <div className="field-row">
+121
View File
@@ -78,3 +78,124 @@ main .hint {
font-size: 15px; font-size: 15px;
margin-top: 140px; 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;
}
+124 -6
View File
@@ -1,16 +1,53 @@
import { useState } from 'react' import { useState, useEffect, useCallback } from 'react'
import Overview from './overview/Overview' 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' 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 [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 ( return (
<> <>
<header className="menu"> <header className="menu">
<span className="brand">SSH Panel</span> <span className="brand">WFJ Panel</span>
<nav> <nav>
{NAV_ITEMS.map((item) => ( {NAV_ITEMS.map((item) => (
<a <a
@@ -23,6 +60,14 @@ export default function Panel({ session, onLogout }) {
))} ))}
</nav> </nav>
<div className="right"> <div className="right">
<button
className="btn btn-menu btn-update"
onClick={handleCheckUpdate}
disabled={updateInfo?.checking}
title={`当前版本: ${currentVersion}`}
>
{updateInfo?.checking ? '检查中...' : `更新`}
</button>
<span> <span>
{session.username}@{session.host} {session.username}@{session.host}
</span> </span>
@@ -33,9 +78,82 @@ export default function Panel({ session, onLogout }) {
</header> </header>
<main> <main>
{active === '概览' && <Overview />} {active === '概览' && <Overview />}
{active === '容器' && <p className="hint">容器功能待开发</p>} {active === '文件' && <FileManager />}
{active === '终端' && <p className="hint">终端功能待开发</p>} {active === '容器' && <ContainerPanel dockerReady={dockerReady} />}
{active === '终端' && <TerminalPanel />}
{active === '防火墙' && <Firewall />}
</main> </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; 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 { .proc-table td.num {
text-align: right; text-align: right;
font-variant-numeric: tabular-nums; 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 { .proc-table tbody tr {
transition: background 0.12s ease; 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; ' + 'elif command -v apk >/dev/null 2>&1; then $SUDO apk add procps; ' +
'else echo "无法识别服务器的包管理器"; exit 1; fi' '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( 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) 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 countResult = await window.panel.exec('ps -e --no-headers | wc -l')
const total = countResult.ok ? Number(countResult.stdout.trim()) : null 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 } return { procs, total }
} }
@@ -48,9 +76,10 @@ export default function ProcessCard() {
const [procs, setProcs] = useState([]) const [procs, setProcs] = useState([])
const [total, setTotal] = useState(null) const [total, setTotal] = useState(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const [confirmPid, setConfirmPid] = useState(null) // 二次确认中的 PID const [confirmPid, setConfirmPid] = useState(null)
const [feedback, setFeedback] = useState(null) // { ok, text } const [feedback, setFeedback] = useState(null)
const [refreshKey, setRefreshKey] = useState(0) // 杀死进程后立即刷新列表 const [refreshKey, setRefreshKey] = useState(0)
const [sortBy, setSortBy] = useState('cpu') // 'cpu' | 'mem'
const timers = useRef([]) const timers = useRef([])
// 数据加载 // 数据加载
@@ -60,7 +89,7 @@ export default function ProcessCard() {
const tick = async () => { const tick = async () => {
try { try {
const d = await fetchProcesses() const d = await fetchProcesses(sortBy)
if (alive) { if (alive) {
setProcs(d.procs) setProcs(d.procs)
setTotal(d.total) setTotal(d.total)
@@ -77,9 +106,8 @@ export default function ProcessCard() {
alive = false alive = false
clearInterval(timer) clearInterval(timer)
} }
}, [metric.status, refreshKey]) }, [metric.status, refreshKey, sortBy])
// 组件卸载时清理所有未完成的定时器
useEffect(() => () => timers.current.forEach(clearTimeout), []) useEffect(() => () => timers.current.forEach(clearTimeout), [])
const schedule = (fn, ms) => { const schedule = (fn, ms) => {
@@ -92,7 +120,6 @@ export default function ProcessCard() {
} }
const handleKill = async (p) => { const handleKill = async (p) => {
// 第一次点击:进入确认状态,3 秒内不确认则自动取消
if (confirmPid !== p.pid) { if (confirmPid !== p.pid) {
setConfirmPid(p.pid) setConfirmPid(p.pid)
schedule(() => setConfirmPid(null), 3000) schedule(() => setConfirmPid(null), 3000)
@@ -111,10 +138,14 @@ export default function ProcessCard() {
} }
} }
const subtitle = total !== null
? `共 ${total} 个 · 按${sortBy === 'cpu' ? ' CPU' : '内存'}占用排序`
: `按${sortBy === 'cpu' ? ' CPU' : '内存'}占用排序`
return ( return (
<CardShell <CardShell
title="进程" title="进程"
subtitle={total !== null ? `共 ${total} 个 · CPU 占用前 12` : 'CPU 占用前 12'} subtitle={subtitle}
status={metric.status} status={metric.status}
message={metric.message} message={metric.message}
installable={metric.installable} installable={metric.installable}
@@ -131,15 +162,26 @@ export default function ProcessCard() {
<th>进程</th> <th>进程</th>
<th>PID</th> <th>PID</th>
<th>用户</th> <th>用户</th>
<th className="num">CPU%</th> <th
<th className="num">内存%</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> <th className="kill-cell"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{procs.length === 0 ? ( {procs.length === 0 ? (
<tr> <tr>
<td colSpan="6">加载中...</td> <td colSpan="7">加载中...</td>
</tr> </tr>
) : ( ) : (
procs.map((p) => ( procs.map((p) => (
@@ -149,6 +191,15 @@ export default function ProcessCard() {
<td>{p.user}</td> <td>{p.user}</td>
<td className="num">{p.cpu}</td> <td className="num">{p.cpu}</td>
<td className="num">{p.mem}</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"> <td className="kill-cell">
<button <button
className={`kill-btn ${confirmPid === p.pid ? 'confirm' : ''}`} 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>
)
}
+16
View File
@@ -122,6 +122,22 @@
background: var(--brand-soft); 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 { .btn-menu {
height: 30px; height: 30px;