feat: Docker管理面板 - 支持容器管理、Compose管理、日志查看、镜像管理
This commit is contained in:
77
README.md
Normal file
77
README.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# 🐳 Docker Web Manager
|
||||||
|
|
||||||
|
轻量级 Docker Web 管理器,适合 NAS 环境使用。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- **容器管理**: 启动 / 停止 / 重启 / 删除容器
|
||||||
|
- **日志查看**: 实时查看容器日志,支持时间戳、行数选择
|
||||||
|
- **资源监控**: CPU、内存、网络使用率
|
||||||
|
- **Docker Compose**: 项目发现、启动/停止/重启、在线编辑
|
||||||
|
- **自动刷新**: 容器列表自动同步 Docker 状态
|
||||||
|
- **深色主题**: 专为 NAS 管理界面设计
|
||||||
|
|
||||||
|
## 快速部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建并启动
|
||||||
|
cd /home/zwbpc/docker-manager
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# 停止
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
访问: **http://your-nas:8080**
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
docker-manager/
|
||||||
|
├── backend/ # FastAPI 后端
|
||||||
|
│ ├── main.py # API 入口
|
||||||
|
│ ├── docker_client.py # Docker Socket 封装
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── frontend/
|
||||||
|
│ └── dist/
|
||||||
|
│ └── index.html # Vue SPA 单文件
|
||||||
|
├── docker-compose.yml # 一键部署
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compose 文件扫描路径
|
||||||
|
|
||||||
|
默认扫描 `/data/compose`,修改 `docker-compose.yml` 中的挂载路径即可:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- /your/path:/data/compose:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **后端**: FastAPI + Python `docker` 库
|
||||||
|
- **前端**: Vue 3 (CDN) + 原生 JavaScript
|
||||||
|
- **通信**: Docker Socket API
|
||||||
|
- **部署**: Docker + Docker Compose
|
||||||
|
|
||||||
|
## API 端点
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/containers` | 列出所有容器 |
|
||||||
|
| POST | `/api/containers/{id}/start` | 启动容器 |
|
||||||
|
| POST | `/api/containers/{id}/stop` | 停止容器 |
|
||||||
|
| POST | `/api/containers/{id}/restart` | 重启容器 |
|
||||||
|
| DELETE | `/api/containers/{id}` | 删除容器 |
|
||||||
|
| GET | `/api/containers/{id}/logs` | 获取日志 |
|
||||||
|
| GET | `/api/containers/{id}/stats` | 资源使用 |
|
||||||
|
| GET | `/api/compose/discover?path=` | 发现 Compose |
|
||||||
|
| POST | `/api/compose/up` | 启动项目 |
|
||||||
|
| POST | `/api/compose/down` | 停止项目 |
|
||||||
|
| POST | `/api/compose/restart` | 重启项目 |
|
||||||
|
| GET | `/api/compose/file?path=` | 读取文件 |
|
||||||
|
| POST | `/api/compose/file?path=` | 保存文件 |
|
||||||
19
backend/Dockerfile
Normal file
19
backend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装 docker CLI
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
docker.io \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY backend/ /app/
|
||||||
|
|
||||||
|
COPY frontend/dist /app/frontend/dist
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
435
backend/docker_client.py
Normal file
435
backend/docker_client.py
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
"""Docker Socket 客户端封装 - 使用 httpx 直接通过 Unix socket 通信"""
|
||||||
|
import httpx
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
SOCKET_PATH = "/var/run/docker.sock"
|
||||||
|
|
||||||
|
|
||||||
|
def _do_request(method: str, path: str, params: dict = None, data: str = None, raw: bool = False) -> dict | bytes:
|
||||||
|
"""通过 Unix socket 发送请求到 Docker API"""
|
||||||
|
url = f"http://localhost{path}"
|
||||||
|
headers = {}
|
||||||
|
if data:
|
||||||
|
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
|
|
||||||
|
# 使用 httpx 的 Unix socket 支持
|
||||||
|
transport = httpx.HTTPTransport(uds=SOCKET_PATH)
|
||||||
|
with httpx.Client(transport=transport, timeout=30) as client:
|
||||||
|
if method == "GET":
|
||||||
|
r = client.get(url, params=params)
|
||||||
|
elif method == "POST":
|
||||||
|
r = client.post(url, params=params, content=data, headers=headers)
|
||||||
|
elif method == "DELETE":
|
||||||
|
r = client.delete(url, params=params)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported method: {method}")
|
||||||
|
|
||||||
|
if r.status_code >= 400:
|
||||||
|
try:
|
||||||
|
err = r.json()
|
||||||
|
raise Exception(err.get("message", r.text))
|
||||||
|
except Exception:
|
||||||
|
raise Exception(r.text)
|
||||||
|
if r.content:
|
||||||
|
if raw:
|
||||||
|
return r.content
|
||||||
|
return r.json()
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def list_containers(all: bool = True) -> list[dict]:
|
||||||
|
"""列出所有容器"""
|
||||||
|
params = {"all": "true"} if all else {}
|
||||||
|
containers = _do_request("GET", "/containers/json", params)
|
||||||
|
return [_normalize_container(c) for c in containers]
|
||||||
|
|
||||||
|
|
||||||
|
def get_container(container_id: str) -> dict:
|
||||||
|
"""获取单个容器详情"""
|
||||||
|
data = _do_request("GET", f"/containers/{container_id}/json")
|
||||||
|
return _normalize_container(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_container(c: dict) -> dict:
|
||||||
|
"""标准化容器数据结构"""
|
||||||
|
ports = {}
|
||||||
|
for p in c.get("Ports", []) or []:
|
||||||
|
if p.get("PublicPort"):
|
||||||
|
ports[f"{p['PrivatePort']}/{p.get('Type', 'tcp')}"] = [{"HostIp": p.get("IP", "0.0.0.0"), "HostPort": str(p["PublicPort"])}]
|
||||||
|
else:
|
||||||
|
ports[f"{p['PrivatePort']}/{p.get('Type', 'tcp')}"] = []
|
||||||
|
return {
|
||||||
|
"id": c["Id"][:12],
|
||||||
|
"name": (c.get("Names") or ["/"])[0].lstrip("/"),
|
||||||
|
"image": c.get("Image", ""),
|
||||||
|
"state": c.get("State", "unknown"),
|
||||||
|
"status": c.get("Status", ""),
|
||||||
|
"ports": ports,
|
||||||
|
"created": c.get("Created", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def start_container(container_id: str) -> dict:
|
||||||
|
result = _do_request("POST", f"/containers/{container_id}/start")
|
||||||
|
return {"success": True, "data": result}
|
||||||
|
|
||||||
|
|
||||||
|
def stop_container(container_id: str) -> dict:
|
||||||
|
result = _do_request("POST", f"/containers/{container_id}/stop")
|
||||||
|
return {"success": True, "data": result}
|
||||||
|
|
||||||
|
|
||||||
|
def restart_container(container_id: str) -> dict:
|
||||||
|
result = _do_request("POST", f"/containers/{container_id}/restart")
|
||||||
|
return {"success": True, "data": result}
|
||||||
|
|
||||||
|
|
||||||
|
def remove_container(container_id: str, force: bool = False) -> dict:
|
||||||
|
result = _do_request("DELETE", f"/containers/{container_id}", {"force": "true"} if force else None)
|
||||||
|
return {"success": True, "data": result}
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_logs(container_id: str, tail: int = 100, timestamps: bool = False) -> dict:
|
||||||
|
"""获取容器日志,格式化输出更易读"""
|
||||||
|
params = {"stderr": "true", "stdout": "true", "tail": str(tail)}
|
||||||
|
if timestamps:
|
||||||
|
params["timestamps"] = "true"
|
||||||
|
raw_content = _do_request("GET", f"/containers/{container_id}/logs", params, raw=True)
|
||||||
|
|
||||||
|
# Docker 日志是二进制流,每条消息前面有 8 字节头
|
||||||
|
# 格式: [stream_type(1), 0, 0, 0, size(4 bytes big-endian), data...]
|
||||||
|
raw_lines = []
|
||||||
|
i = 0
|
||||||
|
while i < len(raw_content):
|
||||||
|
if i + 8 > len(raw_content):
|
||||||
|
break
|
||||||
|
stream_type = raw_content[i] # 1=stdout, 2=stderr
|
||||||
|
size = int.from_bytes(raw_content[i+4:i+8], byteorder='big')
|
||||||
|
i += 8
|
||||||
|
if i + size > len(raw_content):
|
||||||
|
raw_lines.append((1, raw_content[i:].decode('utf-8', errors='replace')))
|
||||||
|
break
|
||||||
|
raw_lines.append((stream_type, raw_content[i:i+size].decode('utf-8', errors='replace')))
|
||||||
|
i += size
|
||||||
|
|
||||||
|
# 解析并格式化每行日志
|
||||||
|
import re
|
||||||
|
formatted_lines = []
|
||||||
|
log_counter = 0
|
||||||
|
|
||||||
|
# 日志级别颜色标记 (终端显示)
|
||||||
|
LOG_LEVELS = {
|
||||||
|
'error': '🔴 ERROR',
|
||||||
|
'warn': '🟡 WARN',
|
||||||
|
'warning': '🟡 WARN',
|
||||||
|
'info': '🔵 INFO',
|
||||||
|
'debug': '⚪ DEBUG',
|
||||||
|
'trace': '⚪ TRACE',
|
||||||
|
}
|
||||||
|
|
||||||
|
# 常见时间戳格式模式
|
||||||
|
TIMESTAMP_PATTERNS = [
|
||||||
|
r'^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}', # ISO-like: 2024-01-01T12:00:00
|
||||||
|
r'^\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2}', # Apache: 01/Jan/2024:12:00:00
|
||||||
|
r'^\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}', # Syslog-like: Jan 1 12:00:00
|
||||||
|
]
|
||||||
|
|
||||||
|
for stream_type, raw_text in raw_lines:
|
||||||
|
# 分割多行日志
|
||||||
|
lines = raw_text.split('\n')
|
||||||
|
for line in lines:
|
||||||
|
line = line.rstrip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
log_counter += 1
|
||||||
|
|
||||||
|
# 判断日志来源
|
||||||
|
source = "out" if stream_type == 1 else "err"
|
||||||
|
|
||||||
|
# 尝试解析日志级别
|
||||||
|
level_tag = ""
|
||||||
|
for level, tag in LOG_LEVELS.items():
|
||||||
|
if level in line.lower():
|
||||||
|
level_tag = f"[{tag}] "
|
||||||
|
break
|
||||||
|
|
||||||
|
# 检测并标注时间戳
|
||||||
|
has_timestamp = False
|
||||||
|
for pattern in TIMESTAMP_PATTERNS:
|
||||||
|
if re.match(pattern, line):
|
||||||
|
has_timestamp = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# 格式化输出
|
||||||
|
if has_timestamp:
|
||||||
|
# 有时间戳的行,保持原格式加行号
|
||||||
|
formatted_lines.append(f"{log_counter:>4} │ {level_tag}{line}")
|
||||||
|
else:
|
||||||
|
# 无时间戳的行,添加来源和级别标记
|
||||||
|
formatted_lines.append(f"{log_counter:>4} │ [{source}] {level_tag}{line}")
|
||||||
|
|
||||||
|
if not formatted_lines:
|
||||||
|
return {"logs": "(无日志)"}
|
||||||
|
|
||||||
|
return {"logs": "\n".join(formatted_lines)}
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_stats(container_id: str) -> dict:
|
||||||
|
"""获取容器资源使用"""
|
||||||
|
stats = _do_request("GET", f"/containers/{container_id}/stats", {"stream": "false"})
|
||||||
|
# 计算 CPU 百分比
|
||||||
|
cpu_delta = stats.get("cpu_stats", {}).get("cpu_usage", {}).get("total_usage", 0) - \
|
||||||
|
stats.get("precpu_stats", {}).get("cpu_usage", {}).get("total_usage", 0)
|
||||||
|
system_delta = stats.get("cpu_stats", {}).get("system_cpu_usage", 0) - \
|
||||||
|
stats.get("precpu_stats", {}).get("system_cpu_usage", 0)
|
||||||
|
cpu_count = stats.get("cpu_stats", {}).get("online_cpus", 1)
|
||||||
|
cpu_percent = (cpu_delta / system_delta * cpu_count * 100.0) if system_delta > 0 else 0
|
||||||
|
|
||||||
|
# 内存
|
||||||
|
mem_usage = stats.get("memory_stats", {}).get("usage", 0)
|
||||||
|
mem_limit = stats.get("memory_stats", {}).get("limit", 1)
|
||||||
|
mem_percent = (mem_usage / mem_limit * 100.0) if mem_limit > 0 else 0
|
||||||
|
|
||||||
|
# 网络
|
||||||
|
networks = stats.get("networks", {}) or {}
|
||||||
|
rx = sum(n.get("rx_bytes", 0) for n in networks.values())
|
||||||
|
tx = sum(n.get("tx_bytes", 0) for n in networks.values())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cpu_percent": round(cpu_percent, 1),
|
||||||
|
"memory_usage": mem_usage,
|
||||||
|
"memory_limit": mem_limit,
|
||||||
|
"memory_percent": round(mem_percent, 1),
|
||||||
|
"network_rx": rx,
|
||||||
|
"network_tx": tx,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_system_info() -> dict:
|
||||||
|
"""获取系统信息"""
|
||||||
|
info = _do_request("GET", "/info")
|
||||||
|
version_info = _do_request("GET", "/version")
|
||||||
|
|
||||||
|
# 获取 CPU 和内存信息
|
||||||
|
try:
|
||||||
|
cpu_info = _do_request("GET", "/system/df") # 尝试用 /system/df
|
||||||
|
except Exception:
|
||||||
|
cpu_info = {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"containers": info.get("Containers", 0),
|
||||||
|
"running": info.get("ContainersRunning", 0),
|
||||||
|
"images": info.get("Images", 0),
|
||||||
|
"version": version_info.get("Version", "unknown"),
|
||||||
|
"cpu_count": info.get("NCPU", 0),
|
||||||
|
"memory_total": info.get("MemTotal", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def discover_compose_files(search_path: str) -> list[dict]:
|
||||||
|
"""发现目录下的 docker-compose.yml 文件"""
|
||||||
|
import os
|
||||||
|
results = []
|
||||||
|
for root, dirs, files in os.walk(search_path):
|
||||||
|
if "docker-compose.yml" in files or "docker-compose.yaml" in files:
|
||||||
|
compose_file = os.path.join(root, "docker-compose.yml")
|
||||||
|
if not os.path.exists(compose_file):
|
||||||
|
compose_file = os.path.join(root, "docker-compose.yaml")
|
||||||
|
results.append({
|
||||||
|
"name": os.path.basename(root),
|
||||||
|
"path": compose_file,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def compose_action(action: str, project_dir: str) -> dict:
|
||||||
|
"""对 Compose 项目执行动作(up/down/restart)"""
|
||||||
|
# 使用 docker compose 命令
|
||||||
|
import subprocess
|
||||||
|
cmd = ["docker", action.replace("restart", "restart" if action == "restart" else "restart")]
|
||||||
|
if action == "up":
|
||||||
|
cmd = ["docker", "compose", "-f", f"{project_dir}/docker-compose.yml", "up", "-d"]
|
||||||
|
elif action == "down":
|
||||||
|
cmd = ["docker", "compose", "-f", f"{project_dir}/docker-compose.yml", "down"]
|
||||||
|
elif action == "restart":
|
||||||
|
cmd = ["docker", "compose", "-f", f"{project_dir}/docker-compose.yml", "restart"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||||
|
return {"success": result.returncode == 0, "data": result.stdout, "error": result.stderr}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def read_compose_file(path: str) -> dict:
|
||||||
|
"""读取 Compose 文件内容"""
|
||||||
|
import aiofiles
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def _read():
|
||||||
|
async with aiofiles.open(path, "r") as f:
|
||||||
|
return await f.read()
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = asyncio.run(_read())
|
||||||
|
return {"success": True, "content": content}
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"success": False, "error": "文件不存在"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def write_compose_file(path: str, content: str) -> dict:
|
||||||
|
"""写入 Compose 文件内容"""
|
||||||
|
import aiofiles
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def _write():
|
||||||
|
async with aiofiles.open(path, "w") as f:
|
||||||
|
await f.write(content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(_write())
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# 兼容别名的包装函数
|
||||||
|
def list_compose_projects(search_path: str) -> list[dict]:
|
||||||
|
return discover_compose_files(search_path)
|
||||||
|
|
||||||
|
|
||||||
|
def compose_up(project_dir: str) -> dict:
|
||||||
|
return compose_action("up", project_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def compose_down(project_dir: str) -> dict:
|
||||||
|
return compose_action("down", project_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def compose_restart(project_dir: str) -> dict:
|
||||||
|
return compose_action("restart", project_dir)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 镜像管理 ==========
|
||||||
|
|
||||||
|
# 默认代理/镜像服务器列表 (中国常用镜像)
|
||||||
|
DEFAULT_MIRRORS = [
|
||||||
|
{"name": "Docker Hub (官方)", "prefix": ""},
|
||||||
|
{"name": "Azure 中国", "prefix": "dockerhub.azk8s.cn/"},
|
||||||
|
{"name": "中科大 USTC", "prefix": "docker.mirrors.ustc.edu.cn/"},
|
||||||
|
{"name": "百度 BJ", "prefix": "mirror.baidubce.com/"},
|
||||||
|
{"name": "腾讯云", "prefix": "ccr.ccs.tencentyun.com/"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def list_images() -> list[dict]:
|
||||||
|
"""列出本地镜像"""
|
||||||
|
images = _do_request("GET", "/images/json")
|
||||||
|
result = []
|
||||||
|
for img in images:
|
||||||
|
result.append({
|
||||||
|
"id": img.get("Id", "")[:12] if img.get("Id") else "",
|
||||||
|
"full_id": img.get("Id", ""),
|
||||||
|
"repo_tags": img.get("RepoTags") or [],
|
||||||
|
"size": img.get("Size", 0),
|
||||||
|
"created": img.get("Created", 0),
|
||||||
|
"virtual_size": img.get("VirtualSize", 0),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def search_images(query: str, limit: int = 20) -> list[dict]:
|
||||||
|
"""从 Docker Hub 搜索镜像"""
|
||||||
|
import httpx
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
# 使用 Docker Hub API v2 搜索
|
||||||
|
with httpx.Client(timeout=15) as client:
|
||||||
|
r = client.get(
|
||||||
|
"https://hub.docker.com/v2/search/repositories/",
|
||||||
|
params={"query": query, "page_size": limit}
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
for item in data.get("results", []):
|
||||||
|
results.append({
|
||||||
|
"name": item.get("repo_name", ""),
|
||||||
|
"description": item.get("short_description", ""),
|
||||||
|
"star_count": item.get("star_count", 0),
|
||||||
|
"pull_count": item.get("pull_count", 0),
|
||||||
|
"official": item.get("is_official", False),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
# 如果 API 失败,尝试使用 docker search 命令
|
||||||
|
import subprocess
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "search", "--limit", str(limit), query],
|
||||||
|
capture_output=True, text=True, timeout=30
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
lines = result.stdout.strip().split("\n")
|
||||||
|
if len(lines) > 1:
|
||||||
|
headers = lines[0].split()
|
||||||
|
for line in lines[1:]:
|
||||||
|
parts = line.split(maxsplit=4)
|
||||||
|
if len(parts) >= 4:
|
||||||
|
results.append({
|
||||||
|
"name": parts[0],
|
||||||
|
"description": parts[3] if len(parts) > 3 else "",
|
||||||
|
"star_count": int(parts[1]) if parts[1].isdigit() else 0,
|
||||||
|
"official": "[OK]" in parts[2] if len(parts) > 2 else False,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def pull_image(image_name: str, mirror_prefix: str = "") -> dict:
|
||||||
|
"""拉取镜像,支持指定镜像代理前缀"""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# 如果指定了镜像前缀,转换镜像名
|
||||||
|
full_image = image_name
|
||||||
|
if mirror_prefix:
|
||||||
|
# 例如: nginx -> dockerhub.azk8s.cn/library/nginx
|
||||||
|
if "/" not in image_name:
|
||||||
|
# 官方镜像,添加 library
|
||||||
|
full_image = f"{mirror_prefix}library/{image_name}"
|
||||||
|
else:
|
||||||
|
# 用户/组织镜像
|
||||||
|
full_image = f"{mirror_prefix}{image_name}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 使用 docker pull 命令
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "pull", full_image],
|
||||||
|
capture_output=True, text=True, timeout=600
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return {"success": True, "image": image_name, "pulled_as": full_image, "output": result.stdout}
|
||||||
|
else:
|
||||||
|
return {"success": False, "image": image_name, "error": result.stderr}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "image": image_name, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def remove_image(image_id: str, force: bool = False) -> dict:
|
||||||
|
"""删除本地镜像"""
|
||||||
|
import subprocess
|
||||||
|
try:
|
||||||
|
cmd = ["docker", "rmi"]
|
||||||
|
if force:
|
||||||
|
cmd.append("-f")
|
||||||
|
cmd.append(image_id)
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return {"success": True}
|
||||||
|
else:
|
||||||
|
return {"success": False, "error": result.stderr}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
195
backend/main.py
Normal file
195
backend/main.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""Docker Web Manager - FastAPI Backend"""
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
import os
|
||||||
|
|
||||||
|
from docker_client import (
|
||||||
|
list_containers, get_container, start_container, stop_container,
|
||||||
|
restart_container, remove_container, get_container_logs,
|
||||||
|
get_container_stats, list_compose_projects, compose_up, compose_down,
|
||||||
|
compose_restart, read_compose_file, write_compose_file,
|
||||||
|
discover_compose_files, get_system_info,
|
||||||
|
list_images, search_images, pull_image, remove_image, DEFAULT_MIRRORS,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = FastAPI(title="Docker Web Manager", version="1.0.0")
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 路由 ==========
|
||||||
|
@app.get("/api/system")
|
||||||
|
def api_system():
|
||||||
|
"""Docker 系统信息"""
|
||||||
|
return get_system_info()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/containers")
|
||||||
|
def api_list_containers(all: bool = True):
|
||||||
|
"""列出所有容器"""
|
||||||
|
return list_containers(all=all)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/containers/{container_id}")
|
||||||
|
def api_get_container(container_id: str):
|
||||||
|
"""获取容器详情"""
|
||||||
|
return get_container(container_id)
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerAction(BaseModel):
|
||||||
|
force: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/containers/{container_id}/start")
|
||||||
|
def api_start(container_id: str):
|
||||||
|
"""启动容器"""
|
||||||
|
return start_container(container_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/containers/{container_id}/stop")
|
||||||
|
def api_stop(container_id: str):
|
||||||
|
"""停止容器"""
|
||||||
|
return stop_container(container_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/containers/{container_id}/restart")
|
||||||
|
def api_restart(container_id: str):
|
||||||
|
"""重启容器"""
|
||||||
|
return restart_container(container_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/containers/{container_id}")
|
||||||
|
def api_remove(container_id: str, force: bool = False):
|
||||||
|
"""删除容器"""
|
||||||
|
return remove_container(container_id, force=force)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/containers/{container_id}/logs")
|
||||||
|
def api_logs(container_id: str, tail: int = 100, timestamps: bool = False):
|
||||||
|
"""获取容器日志"""
|
||||||
|
logs = get_container_logs(container_id, tail=tail, timestamps=timestamps)
|
||||||
|
return {"container_id": container_id, "logs": logs}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/containers/{container_id}/stats")
|
||||||
|
def api_stats(container_id: str):
|
||||||
|
"""获取容器资源使用"""
|
||||||
|
return get_container_stats(container_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Docker Compose ==========
|
||||||
|
@app.get("/api/compose/projects")
|
||||||
|
def api_compose_list():
|
||||||
|
"""列出 Compose 项目"""
|
||||||
|
return list_compose_projects()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/compose/discover")
|
||||||
|
def api_compose_discover(path: str = "/data/compose"):
|
||||||
|
"""发现 Compose 文件"""
|
||||||
|
return discover_compose_files(path)
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeAction(BaseModel):
|
||||||
|
project_dir: str
|
||||||
|
project_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/compose/up")
|
||||||
|
def api_compose_up(data: ComposeAction):
|
||||||
|
"""启动 Compose 项目"""
|
||||||
|
return compose_up(data.project_dir, data.project_name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/compose/down")
|
||||||
|
def api_compose_down(data: ComposeAction):
|
||||||
|
"""停止 Compose 项目"""
|
||||||
|
return compose_down(data.project_dir, data.project_name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/compose/restart")
|
||||||
|
def api_compose_restart(data: ComposeAction):
|
||||||
|
"""重启 Compose 项目"""
|
||||||
|
return compose_restart(data.project_dir, data.project_name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/compose/file")
|
||||||
|
def api_compose_read(path: str):
|
||||||
|
"""读取 Compose 文件"""
|
||||||
|
return read_compose_file(path)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/compose/file")
|
||||||
|
def api_compose_write(path: str, content: str):
|
||||||
|
"""写入 Compose 文件"""
|
||||||
|
return write_compose_file(path, content)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 镜像管理 ==========
|
||||||
|
@app.get("/api/images")
|
||||||
|
def api_list_images():
|
||||||
|
"""列出本地镜像"""
|
||||||
|
return list_images()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/images/search")
|
||||||
|
def api_search_images(q: str = "", limit: int = 20):
|
||||||
|
"""搜索 Docker Hub 镜像"""
|
||||||
|
if not q:
|
||||||
|
return []
|
||||||
|
return search_images(q, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/images/pull")
|
||||||
|
def api_pull_image(image: str, mirror: str = ""):
|
||||||
|
"""拉取镜像(异步)"""
|
||||||
|
# 实际拉取在后台进行,这里返回操作状态
|
||||||
|
return pull_image(image, mirror_prefix=mirror)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/images/{image_id}")
|
||||||
|
def api_remove_image(image_id: str, force: bool = False):
|
||||||
|
"""删除本地镜像"""
|
||||||
|
return remove_image(image_id, force=force)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/images/mirrors")
|
||||||
|
def api_get_mirrors():
|
||||||
|
"""获取可用镜像源列表"""
|
||||||
|
return DEFAULT_MIRRORS
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 前端静态文件 ==========
|
||||||
|
frontend_path = os.environ.get("FRONTEND_DIST", "/app/frontend/dist")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def root():
|
||||||
|
"""前端入口"""
|
||||||
|
index_path = os.path.join(frontend_path, "index.html")
|
||||||
|
if os.path.exists(index_path):
|
||||||
|
return FileResponse(index_path)
|
||||||
|
return {"message": "Docker Web Manager API - Frontend not built"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/{path:path}")
|
||||||
|
def frontend_static(path: str):
|
||||||
|
"""前端路由"""
|
||||||
|
file_path = os.path.join(frontend_path, path)
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
return FileResponse(file_path)
|
||||||
|
index_path = os.path.join(frontend_path, "index.html")
|
||||||
|
if os.path.exists(index_path):
|
||||||
|
return FileResponse(index_path)
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
5
backend/requirements.txt
Normal file
5
backend/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi==0.109.2
|
||||||
|
uvicorn[standard]==0.27.1
|
||||||
|
httpx==0.25.2
|
||||||
|
python-multipart==0.0.9
|
||||||
|
aiofiles==23.2.1
|
||||||
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
docker-manager:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
container_name: docker-manager
|
||||||
|
ports:
|
||||||
|
- "8090:8080"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- /data/compose:/data/compose:ro
|
||||||
|
environment:
|
||||||
|
- DOCKER_HOST=unix:///var/run/docker.sock
|
||||||
|
- FRONTEND_DIST=/app/frontend/dist
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "com.docker.manager=true"
|
||||||
|
|
||||||
|
networks: {}
|
||||||
440
frontend/dist/index.html
vendored
Normal file
440
frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Docker Manager</title>
|
||||||
|
<script src="https://unpkg.com/vue@3.4.15/dist/vue.global.prod.js"></script>
|
||||||
|
<script src="https://unpkg.com/axios@1.6.7/dist/axios.min.js"></script>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
:root{--bg:#0f1117;--card:#1a1d27;--border:#2a2d3a;--text:#e4e4e7;--text-dim:#71717a;--accent:#6366f1;--accent-hover:#818cf8;--green:#22c55e;--red:#ef4444;--yellow:#eab308;--orange:#f97316}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||||
|
.app{display:flex;min-height:100vh}
|
||||||
|
.sidebar{width:220px;background:var(--card);border-right:1px solid var(--border);display:flex;flex-direction:column;position:fixed;height:100vh;overflow-y:auto}
|
||||||
|
.sidebar .logo{padding:20px;font-size:18px;font-weight:700;color:var(--accent);border-bottom:1px solid var(--border)}
|
||||||
|
.sidebar .logo span{color:var(--text)}
|
||||||
|
.sidebar nav{flex:1;padding:12px 0}
|
||||||
|
.sidebar .nav-item{display:flex;align-items:center;gap:10px;padding:12px 20px;color:var(--text-dim);cursor:pointer;transition:all .15s;font-size:14px;border-left:3px solid transparent}
|
||||||
|
.sidebar .nav-item:hover{color:var(--text);background:rgba(99,102,241,.08)}
|
||||||
|
.sidebar .nav-item.active{color:var(--accent);background:rgba(99,102,241,.12);border-left-color:var(--accent)}
|
||||||
|
.sidebar .nav-item .icon{font-size:18px}
|
||||||
|
.main{margin-left:220px;flex:1;padding:24px}
|
||||||
|
.page-header{margin-bottom:24px}
|
||||||
|
.page-header h1{font-size:24px;font-weight:600}
|
||||||
|
.page-header p{color:var(--text-dim);font-size:14px;margin-top:4px}
|
||||||
|
.sys-bar{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:24px}
|
||||||
|
.sys-card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px}
|
||||||
|
.sys-card .label{font-size:12px;color:var(--text-dim);margin-bottom:4px}
|
||||||
|
.sys-card .value{font-size:22px;font-weight:600}
|
||||||
|
.sys-card .sub{font-size:11px;color:var(--text-dim)}
|
||||||
|
.container-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px}
|
||||||
|
.container-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;transition:border-color .2s}
|
||||||
|
.container-card:hover{border-color:var(--accent)}
|
||||||
|
.container-card .top{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:10px;gap:10px}
|
||||||
|
.container-card .name{font-size:15px;font-weight:600;word-break:break-all}
|
||||||
|
.container-card .image{font-size:12px;color:var(--text-dim);margin-top:2px;word-break:break-all}
|
||||||
|
.container-card .id{font-size:11px;color:var(--text-dim);font-family:monospace;margin-top:4px}
|
||||||
|
.status-badge{padding:3px 10px;border-radius:20px;font-size:12px;font-weight:500;flex-shrink:0}
|
||||||
|
.status-running{background:rgba(34,197,94,.15);color:var(--green)}
|
||||||
|
.status-stopped,.status-exited{background:rgba(239,68,68,.15);color:var(--red)}
|
||||||
|
.status-paused{background:rgba(234,179,8,.15);color:var(--yellow)}
|
||||||
|
.status-created{background:rgba(99,102,241,.15);color:var(--accent)}
|
||||||
|
.container-card .ports{font-size:12px;color:var(--text-dim);margin:8px 0;word-break:break-all}
|
||||||
|
.container-card .actions{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
|
||||||
|
.btn{padding:6px 14px;border-radius:6px;border:none;cursor:pointer;font-size:13px;font-weight:500;transition:all .15s;display:inline-flex;align-items:center;gap:5px}
|
||||||
|
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-hover)}
|
||||||
|
.btn-success{background:rgba(34,197,94,.15);color:var(--green)}.btn-success:hover{background:rgba(34,197,94,.25)}
|
||||||
|
.btn-danger{background:rgba(239,68,68,.15);color:var(--red)}.btn-danger:hover{background:rgba(239,68,68,.25)}
|
||||||
|
.btn-warning{background:rgba(234,179,8,.15);color:var(--yellow)}.btn-warning:hover{background:rgba(234,179,8,.25)}
|
||||||
|
.btn-ghost{background:transparent;color:var(--text-dim);border:1px solid var(--border)}.btn-ghost:hover{background:var(--border);color:var(--text)}
|
||||||
|
.btn:disabled{opacity:.4;cursor:not-allowed}
|
||||||
|
.btn-sm{padding:4px 10px;font-size:12px}
|
||||||
|
.logs-panel{background:var(--card);border:1px solid var(--border);border-radius:12px;overflow:hidden}
|
||||||
|
.logs-toolbar{padding:12px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px}
|
||||||
|
.logs-toolbar h3{font-size:15px}
|
||||||
|
.logs-content{background:#0d0f14;padding:16px;font-family:'Fira Code','Cascadia Code',monospace;font-size:13px;line-height:1.6;max-height:60vh;overflow-y:auto;white-space:pre-wrap;word-break:break-all;color:#a1a1aa}
|
||||||
|
.logs-content::-webkit-scrollbar{width:6px}.logs-content::-webkit-scrollbar-track{background:transparent}.logs-content::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||||||
|
.compose-list{display:flex;flex-direction:column;gap:12px}
|
||||||
|
.compose-item{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px}
|
||||||
|
.compose-item .ci-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
|
||||||
|
.compose-item .ci-name{font-size:16px;font-weight:600}
|
||||||
|
.compose-item .ci-path{font-size:12px;color:var(--text-dim);font-family:monospace;margin-top:4px}
|
||||||
|
.compose-item .ci-actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
.discover-form{display:flex;gap:12px;margin-bottom:20px;flex-wrap:wrap}
|
||||||
|
.discover-form input{flex:1;min-width:200px;padding:10px 14px;background:var(--card);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;outline:none}
|
||||||
|
.discover-form input:focus{border-color:var(--accent)}
|
||||||
|
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:20px}
|
||||||
|
.stat-card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px}
|
||||||
|
.stat-card .sc-label{font-size:12px;color:var(--text-dim);margin-bottom:6px}
|
||||||
|
.stat-card .sc-value{font-size:20px;font-weight:600}
|
||||||
|
.progress-bar{height:6px;background:var(--border);border-radius:3px;margin-top:8px;overflow:hidden}
|
||||||
|
.progress-bar .fill{height:100%;border-radius:3px;transition:width .3s}
|
||||||
|
.fill-cpu{background:var(--accent)}.fill-mem{background:var(--orange)}
|
||||||
|
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.7);display:flex;align-items:center;justify-content:center;z-index:100;padding:20px}
|
||||||
|
.modal{background:var(--card);border:1px solid var(--border);border-radius:16px;width:100%;max-width:900px;max-height:90vh;display:flex;flex-direction:column;overflow:hidden}
|
||||||
|
.modal-header{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
|
||||||
|
.modal-header h2{font-size:16px}
|
||||||
|
.close-btn{background:none;border:none;color:var(--text-dim);font-size:24px;cursor:pointer;padding:0;line-height:1}
|
||||||
|
.close-btn:hover{color:var(--text)}
|
||||||
|
.loading{text-align:center;padding:60px;color:var(--text-dim)}
|
||||||
|
.empty{text-align:center;padding:60px;color:var(--text-dim)}
|
||||||
|
.toast{position:fixed;bottom:24px;right:24px;padding:12px 20px;border-radius:10px;font-size:14px;z-index:200;max-width:360px;animation:slideIn .2s ease}
|
||||||
|
.toast-success{background:rgba(34,197,94,.9);color:#fff}.toast-error{background:rgba(239,68,68,.9);color:#fff}.toast-info{background:rgba(99,102,241,.9);color:#fff}
|
||||||
|
@keyframes slideIn{from{transform:translateY(20px);opacity:0}to{transform:none;opacity:1}}
|
||||||
|
.refresh-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-dim)}
|
||||||
|
.toggle{width:36px;height:20px;background:var(--border);border-radius:10px;cursor:pointer;position:relative;transition:background .2s}
|
||||||
|
.toggle.on{background:var(--accent)}
|
||||||
|
.toggle::after{content:'';position:absolute;width:16px;height:16px;background:#fff;border-radius:50%;top:2px;left:2px;transition:transform .2s}
|
||||||
|
.toggle.on::after{transform:translateX(16px)}
|
||||||
|
#compose-editor{width:100%;min-height:500px;background:#0d0f14;color:#e4e4e7;border:none;border-radius:0 0 16px 16px;padding:16px;font-family:'Fira Code',monospace;font-size:14px;line-height:1.7;resize:vertical;outline:none}
|
||||||
|
#compose-editor::-webkit-scrollbar{width:6px}#compose-editor::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||||||
|
::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||||||
|
@media(max-width:768px){.sidebar{width:60px}.sidebar .logo span,.sidebar .nav-item span{display:none}.sidebar .logo{text-align:center}.sidebar .nav-item{justify-content:center;padding:14px}.main{margin-left:60px}.container-grid{grid-template-columns:1fr}}
|
||||||
|
/* Images View */
|
||||||
|
.images-section{margin-bottom:32px}
|
||||||
|
.images-section h2{font-size:16px;font-weight:600;margin-bottom:12px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px}
|
||||||
|
.search-bar{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap}
|
||||||
|
.search-bar input{flex:1;min-width:200px;padding:10px 14px;background:var(--card);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;outline:none}
|
||||||
|
.search-bar input:focus{border-color:var(--accent}
|
||||||
|
.search-bar select{padding:10px 14px;background:var(--card);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;outline:none}
|
||||||
|
.image-list{display:flex;flex-direction:column;gap:8px}
|
||||||
|
.image-item{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:var(--card);border:1px solid var(--border);border-radius:10px;gap:12px}
|
||||||
|
.image-item:hover{border-color:var(--accent)}
|
||||||
|
.image-info{flex:1;min-width:0}
|
||||||
|
.image-name{font-size:14px;font-weight:600;word-break:break-all}
|
||||||
|
.image-tags{font-size:12px;color:var(--text-dim);margin-top:2px;word-break:break-all}
|
||||||
|
.image-meta{font-size:11px;color:var(--text-dim);margin-top:4px;display:flex;gap:12px}
|
||||||
|
.image-actions{display:flex;gap:6px;flex-shrink:0}
|
||||||
|
.pull-form{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap;align-items:center}
|
||||||
|
.pull-form input{flex:1;min-width:200px;padding:10px 14px;background:var(--card);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;outline:none}
|
||||||
|
.pull-form input:focus{border-color:var(--accent}
|
||||||
|
.pull-form select{padding:10px 14px;background:var(--card);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;outline:none}
|
||||||
|
.pull-status{padding:12px 16px;background:var(--card);border:1px solid var(--border);border-radius:8px;font-size:13px;font-family:monospace}
|
||||||
|
.search-results{margin-top:16px}
|
||||||
|
.search-result-item{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;background:var(--card);border:1px solid var(--border);border-radius:8px;margin-bottom:8px}
|
||||||
|
.search-result-item:hover{border-color:var(--accent)}
|
||||||
|
.sr-name{font-size:14px;font-weight:600}
|
||||||
|
.sr-desc{font-size:12px;color:var(--text-dim);margin-top:2px}
|
||||||
|
.sr-stats{font-size:11px;color:var(--text-dim);margin-top:4px}
|
||||||
|
.official-badge{background:rgba(99,102,241,.15);color:var(--accent);padding:2px 6px;border-radius:4px;font-size:11px}
|
||||||
|
.editor-toolbar{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:var(--card);border-bottom:1px solid var(--border);flex-wrap:wrap;gap:10px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div v-if="toast.show" :class="['toast','toast-'+toast.type]">{{toast.msg}}</div>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="logo">🐳 <span>Docker Manager</span></div>
|
||||||
|
<nav>
|
||||||
|
<div :class="['nav-item',{active:view==='containers'}]" @click="view='containers'"><span class="icon">📦</span><span>容器</span></div>
|
||||||
|
<div :class="['nav-item',{active:view==='images'}]" @click="view='images'"><span class="icon">💾</span><span>镜像</span></div>
|
||||||
|
<div :class="['nav-item',{active:view==='compose'}]" @click="view='compose'"><span class="icon">📁</span><span>Compose</span></div>
|
||||||
|
<div :class="['nav-item',{active:view==='logs'}]" @click="view='logs'"><span class="icon">📜</span><span>日志</span></div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<!-- Containers -->
|
||||||
|
<div v-if="view==='containers'">
|
||||||
|
<div class="page-header"><h1>容器管理</h1><p>共 {{containers.length}} 个容器 / {{runningCount}} 运行中</p></div>
|
||||||
|
<div class="sys-bar">
|
||||||
|
<div class="sys-card"><div class="label">容器总数</div><div class="value">{{sysInfo.containers}}</div><div class="sub">{{sysInfo.running}} 运行中</div></div>
|
||||||
|
<div class="sys-card"><div class="label">镜像数量</div><div class="value">{{sysInfo.images}}</div></div>
|
||||||
|
<div class="sys-card"><div class="label">Docker 版本</div><div class="value" style="font-size:16px">{{sysInfo.version}}</div></div>
|
||||||
|
<div class="sys-card"><div class="label">CPU / 内存</div><div class="value" style="font-size:16px">{{sysInfo.cpu_count}}核 / {{fmtMem(sysInfo.memory_total)}}</div></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:flex-end;margin-bottom:16px;">
|
||||||
|
<div class="refresh-toggle"><span>自动刷新</span><div :class="['toggle',{on:autoRefresh}]" @click="autoRefresh=!autoRefresh"></div></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="loading">加载中</div>
|
||||||
|
<div v-else-if="containers.length===0" class="empty">暂无容器</div>
|
||||||
|
<div v-else class="container-grid">
|
||||||
|
<div v-for="c in containers" :key="c.id" class="container-card">
|
||||||
|
<div class="top">
|
||||||
|
<div>
|
||||||
|
<div class="name">{{c.name}}</div>
|
||||||
|
<div class="image">{{c.image}}</div>
|
||||||
|
<div class="id">ID: {{c.id}}</div>
|
||||||
|
</div>
|
||||||
|
<span :class="['status-badge','status-'+c.state]">{{c.state}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ports">{{fmtPorts(c.ports)}}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button v-if="c.state==='running'" class="btn btn-warning btn-sm" @click="doAction('restart',c.id)" :disabled="actionLoading[c.id]">⟳ 重启</button>
|
||||||
|
<button v-if="c.state==='running'" class="btn btn-success btn-sm" @click="doAction('stop',c.id)" :disabled="actionLoading[c.id]">■ 停止</button>
|
||||||
|
<button v-else class="btn btn-success btn-sm" @click="doAction('start',c.id)" :disabled="actionLoading[c.id]">▶ 启动</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="openLogs(c)">📜 日志</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="openStats(c)">📊 监控</button>
|
||||||
|
<button class="btn btn-danger btn-sm" @click="confirmRemove(c)">🗑 删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compose -->
|
||||||
|
<div v-if="view==='compose'">
|
||||||
|
<div class="page-header"><h1>Docker Compose</h1><p>管理和编辑 Compose 项目</p></div>
|
||||||
|
<div class="discover-form">
|
||||||
|
<input v-model="discoverPath" placeholder="输入路径搜索 docker-compose.yml,如 /data/compose">
|
||||||
|
<button class="btn btn-primary" @click="discoverCompose">🔍 搜索</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="composeLoading" class="loading">加载中</div>
|
||||||
|
<div v-else-if="discoveredFiles.length===0&&!composeLoading" class="empty">点击搜索发现 Compose 项目</div>
|
||||||
|
<div class="compose-list">
|
||||||
|
<div v-for="f in discoveredFiles" :key="f.path" class="compose-item">
|
||||||
|
<div class="ci-top">
|
||||||
|
<div>
|
||||||
|
<div class="ci-name">{{f.name||f.path.split('/').pop()}}</div>
|
||||||
|
<div class="ci-path">{{f.path}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ci-actions">
|
||||||
|
<button class="btn btn-primary btn-sm" @click="editCompose(f.path)">✏️ 编辑</button>
|
||||||
|
<button class="btn btn-success btn-sm" @click="composeAction('up',f.path)">▶ 启动</button>
|
||||||
|
<button class="btn btn-warning btn-sm" @click="composeAction('down',f.path)">■ 停止</button>
|
||||||
|
<button class="btn btn-warning btn-sm" @click="composeAction('restart',f.path)">⟳ 重启</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Images -->
|
||||||
|
<div v-if="view==='images'">
|
||||||
|
<div class="page-header"><h1>镜像管理</h1><p>管理本地镜像、搜索和下载 Docker 镜像</p></div>
|
||||||
|
|
||||||
|
<!-- 下载镜像 -->
|
||||||
|
<div class="images-section">
|
||||||
|
<h2>📥 下载镜像</h2>
|
||||||
|
<div class="pull-form">
|
||||||
|
<input v-model="pullImageName" placeholder="输入镜像名,如 nginx, redis, mysql">
|
||||||
|
<select v-model="pullMirror">
|
||||||
|
<option v-for="m in mirrors" :key="m.prefix" :value="m.prefix">{{m.name}}</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" @click="doPullImage" :disabled="!pullImageName||pulling">
|
||||||
|
{{pulling?'⏳ 下载中...':'⬇️ 下载'}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="pullStatus" :class="['pull-status',pullStatus.ok?'ok':'err']">{{pullStatus.msg}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索镜像 -->
|
||||||
|
<div class="images-section">
|
||||||
|
<h2>🔍 搜索镜像</h2>
|
||||||
|
<div class="search-bar">
|
||||||
|
<input v-model="searchQuery" @keyup.enter="doSearchImages" placeholder="输入镜像名搜索 Docker Hub">
|
||||||
|
<button class="btn btn-primary" @click="doSearchImages" :disabled="!searchQuery||searching">🔍 搜索</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="searching" class="loading">搜索中...</div>
|
||||||
|
<div v-else-if="searchResults.length>0" class="search-results">
|
||||||
|
<div v-for="r in searchResults" :key="r.name" class="search-result-item">
|
||||||
|
<div>
|
||||||
|
<div class="sr-name">{{r.name}} <span v-if="r.official" class="official-badge">官方</span></div>
|
||||||
|
<div class="sr-desc">{{r.description||'(无描述)'}}</div>
|
||||||
|
<div class="sr-stats">⭐ {{r.star_count}} | 📥 {{r.pull_count}}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-success btn-sm" @click="quickPull(r.name)">⬇️ 下载</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 本地镜像 -->
|
||||||
|
<div class="images-section">
|
||||||
|
<h2>💾 本地镜像 ({{localImages.length}})</h2>
|
||||||
|
<div v-if="imagesLoading" class="loading">加载中...</div>
|
||||||
|
<div v-else-if="localImages.length===0" class="empty">暂无本地镜像</div>
|
||||||
|
<div v-else class="image-list">
|
||||||
|
<div v-for="img in localImages" :key="img.id" class="image-item">
|
||||||
|
<div class="image-info">
|
||||||
|
<div class="image-name">{{img.repo_tags&&img.repo_tags[0]?img.repo_tags[0]:img.id}}</div>
|
||||||
|
<div v-if="img.repo_tags&&img.repo_tags.length>1" class="image-tags">{{img.repo_tags.slice(1).join(', ')}}</div>
|
||||||
|
<div class="image-meta">
|
||||||
|
<span>ID: {{img.id}}</span>
|
||||||
|
<span>大小: {{fmtBytes(img.size)}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="image-actions">
|
||||||
|
<button class="btn btn-danger btn-sm" @click="doRemoveImage(img.id)">🗑 删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logs -->
|
||||||
|
<div v-if="view==='logs'">
|
||||||
|
<div class="page-header"><h1>日志查看</h1><p>实时查看容器日志</p></div>
|
||||||
|
<div style="margin-bottom:16px;display:flex;gap:12px;flex-wrap:wrap;align-items:center;">
|
||||||
|
<select v-model="logContainerId" @change="loadLogs" style="padding:8px 12px;background:var(--card);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;outline:none;min-width:200px;">
|
||||||
|
<option value="">-- 选择容器 --</option>
|
||||||
|
<option v-for="c in containers" :key="c.id" :value="c.id">{{c.name}} ({{c.id}})</option>
|
||||||
|
</select>
|
||||||
|
<div class="refresh-toggle"><span>自动刷新</span><div :class="['toggle',{on:logAutoRefresh}]" @click="logAutoRefresh=!logAutoRefresh"></div></div>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="loadLogs" :disabled="!logContainerId">🔄 刷新</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="logContent=''">🗑 清空</button>
|
||||||
|
</div>
|
||||||
|
<div class="logs-panel">
|
||||||
|
<div class="logs-toolbar"><h3>📜 {{logContainerName}}</h3><span style="font-size:12px;color:var(--text-dim)">最后 {{logTail}} 行</span></div>
|
||||||
|
<div class="logs-content" ref="logContentEl">{{logContent||'选择容器查看日志'}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logs Modal -->
|
||||||
|
<div v-if="m.logs" class="modal-overlay" @click.self="m.logs=false">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header"><h2>📜 {{m.logsName}} - 日志</h2><button class="close-btn" @click="m.logs=false">×</button></div>
|
||||||
|
<div class="logs-panel">
|
||||||
|
<div class="logs-toolbar">
|
||||||
|
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
|
||||||
|
<select v-model="m.logTail" @change="loadModalLogs" style="padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;">
|
||||||
|
<option value="50">50行</option><option value="100">100行</option><option value="200">200行</option><option value="500">500行</option>
|
||||||
|
</select>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-dim);"><input type="checkbox" v-model="m.logTimestamps"> 时间戳</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="loadModalLogs">🔄 刷新</button>
|
||||||
|
</div>
|
||||||
|
<div class="logs-content">{{m.logsContent||'(无日志)'}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Modal -->
|
||||||
|
<div v-if="m.stats" class="modal-overlay" @click.self="m.stats=false">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header"><h2>📊 {{m.statsName}} - 资源监控</h2><button class="close-btn" @click="m.stats=false">×</button></div>
|
||||||
|
<div style="padding:20px;overflow-y:auto;max-height:70vh;">
|
||||||
|
<div v-if="m.statsData" class="stats-grid">
|
||||||
|
<div class="stat-card"><div class="sc-label">CPU 使用率</div><div class="sc-value">{{m.statsData.cpu_percent}}%</div><div class="progress-bar"><div class="fill fill-cpu" :style="{width:m.statsData.cpu_percent+'%'}"></div></div></div>
|
||||||
|
<div class="stat-card"><div class="sc-label">内存使用</div><div class="sc-value">{{m.statsData.memory_percent.toFixed(1)}}%</div><div style="font-size:12px;color:var(--text-dim);margin-top:4px;">{{fmtBytes(m.statsData.memory_usage)}} / {{fmtBytes(m.statsData.memory_limit)}}</div><div class="progress-bar"><div class="fill fill-mem" :style="{width:m.statsData.memory_percent+'%'}"></div></div></div>
|
||||||
|
<div class="stat-card"><div class="sc-label">网络接收</div><div class="sc-value">{{fmtBytes(m.statsData.network_rx)}}</div></div>
|
||||||
|
<div class="stat-card"><div class="sc-label">网络发送</div><div class="sc-value">{{fmtBytes(m.statsData.network_tx)}}</div></div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="loading">加载中</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Editor Modal -->
|
||||||
|
<div v-if="m.editor" class="modal-overlay" @click.self="m.editor=false">
|
||||||
|
<div class="modal" style="max-width:1000px;">
|
||||||
|
<div class="modal-header"><h2>✏️ {{m.editorPath}}</h2><button class="close-btn" @click="m.editor=false">×</button></div>
|
||||||
|
<div class="editor-toolbar">
|
||||||
|
<span style="font-size:13px;color:var(--text-dim);">{{m.editorPath}}</span>
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<button class="btn btn-primary btn-sm" @click="saveCompose" :disabled="saving">💾 保存</button>
|
||||||
|
<button class="btn btn-success btn-sm" @click="composeEditorAction('up')">▶ 启动</button>
|
||||||
|
<button class="btn btn-warning btn-sm" @click="composeEditorAction('down')">■ 停止</button>
|
||||||
|
<button class="btn btn-warning btn-sm" @click="composeEditorAction('restart')">⟳ 重启</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea id="compose-editor" v-model="m.editorContent" spellcheck="false"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirm Remove Modal -->
|
||||||
|
<div v-if="m.confirmRemove" class="modal-overlay" @click.self="m.confirmRemove=null">
|
||||||
|
<div class="modal" style="max-width:400px;">
|
||||||
|
<div class="modal-header"><h2>⚠️ 确认删除</h2><button class="close-btn" @click="m.confirmRemove=null">×</button></div>
|
||||||
|
<div style="padding:20px;">
|
||||||
|
<p style="margin-bottom:16px;">确定要删除容器 <strong>{{m.confirmRemove.name}}</strong> 吗?</p>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;font-size:14px;margin-bottom:16px;"><input type="checkbox" v-model="forceRemove"> 强制删除 (force)</label>
|
||||||
|
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
||||||
|
<button class="btn btn-ghost" @click="m.confirmRemove=null">取消</button>
|
||||||
|
<button class="btn btn-danger" @click="doRemove">确认删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const{createApp,ref,computed,onMounted,onUnmounted,watch,nextTick}=Vue;
|
||||||
|
const api=axios.create({baseURL:'/api',timeout:15000});
|
||||||
|
const logTail=ref(100);
|
||||||
|
// Images
|
||||||
|
const localImages=ref([]),imagesLoading=ref(false),mirrors=ref([]);
|
||||||
|
const searchQuery=ref(''),searchResults=ref([]),searching=ref(false);
|
||||||
|
const pullImageName=ref(''),pullMirror=ref(''),pulling=ref(false),pullStatus=ref(null);
|
||||||
|
createApp({
|
||||||
|
setup(){
|
||||||
|
const view=ref('containers'),loading=ref(false),containers=ref([]);
|
||||||
|
const sysInfo=ref({containers:0,running:0,images:0,version:'',cpu_count:0,memory_total:0});
|
||||||
|
const actionLoading=ref({}),autoRefresh=ref(true);
|
||||||
|
const composeLoading=ref(false),discoveredFiles=ref([]),discoverPath=ref('/data/compose');
|
||||||
|
const logContainerId=ref(''),logContent=ref(''),logAutoRefresh=ref(false),logContentEl=ref(null);
|
||||||
|
const toast=ref({show:false,type:'info',msg:''}),saving=ref(false),forceRemove=ref(false);
|
||||||
|
const m=ref({logs:false,logsName:'',logsId:'',logsContent:'',logTail:100,logTimestamps:false,stats:false,statsName:'',statsId:'',statsData:null,editor:false,editorPath:'',editorContent:'',confirmRemove:null});
|
||||||
|
let refreshTimer=null,logTimer=null;
|
||||||
|
const runningCount=computed(()=>containers.value.filter(c=>c.state==='running').length);
|
||||||
|
const logContainerName=computed(()=>{const c=containers.value.find(x=>x.id===logContainerId.value);return c?c.name:'日志';});
|
||||||
|
|
||||||
|
function showToast(msg,type='info',dur=2500){toast.value={show:true,type,msg};setTimeout(()=>{toast.value.show=false;},dur);}
|
||||||
|
|
||||||
|
async function getContainers(){try{const r=await api.get('/containers');containers.value=r.data;}catch(e){console.error(e);}}
|
||||||
|
async function getSystemInfo(){try{const r=await api.get('/system');sysInfo.value=r.data;}catch(e){console.error(e);}}
|
||||||
|
async function loadAll(){loading.value=true;await Promise.all([getContainers(),getSystemInfo()]);loading.value=false;}
|
||||||
|
|
||||||
|
function startRefresh(){clearInterval(refreshTimer);if(autoRefresh.value)refreshTimer=setInterval(loadAll,5000);}
|
||||||
|
watch(autoRefresh,startRefresh);
|
||||||
|
|
||||||
|
async function doAction(type,id){
|
||||||
|
actionLoading.value[id]=true;
|
||||||
|
try{
|
||||||
|
const r=await api.post(`/containers/${id}/${type}`);
|
||||||
|
if(r.data.success!==false){showToast('操作成功','success');await getContainers();}
|
||||||
|
else{showToast(r.data.error||'操作失败','error');}
|
||||||
|
}catch(e){showToast('操作失败: '+e.message,'error');}
|
||||||
|
actionLoading.value[id]=false;
|
||||||
|
}
|
||||||
|
function confirmRemove(c){m.value.confirmRemove=c;forceRemove.value=false;}
|
||||||
|
async function doRemove(){
|
||||||
|
const c=m.value.confirmRemove;m.value.confirmRemove=null;
|
||||||
|
try{const r=await api.delete(`/containers/${c.id}?force=${forceRemove.value}`);if(r.data.success){showToast('删除成功','success');await getContainers();}else{showToast(r.data.error||'删除失败','error');}}
|
||||||
|
catch(e){showToast('删除失败: '+e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLogs(c){
|
||||||
|
m.value.logs=true;m.value.logsName=c.name;m.value.logsId=c.id;m.value.logTail=100;m.value.logTimestamps=false;m.value.logsContent='加载中...';
|
||||||
|
await loadModalLogs();
|
||||||
|
}
|
||||||
|
async function loadModalLogs(){if(!m.value.logsId)return;try{const r=await api.get(`/containers/${m.value.logsId}/logs`,{params:{tail:m.value.logTail,timestamps:m.value.logTimestamps}});m.value.logsContent=r.data.logs||'(无日志)';}catch(e){m.value.logsContent='加载失败: '+e.message;}}
|
||||||
|
|
||||||
|
async function loadLogs(){if(!logContainerId.value){logContent.value='选择容器查看日志';return;}try{const r=await api.get(`/containers/${logContainerId.value}/logs`,{params:{tail:logTail.value}});logContent.value=r.data.logs||'(无日志)';await nextTick();const el=document.querySelector('.logs-content');if(el)el.scrollTop=el.scrollHeight;}catch(e){logContent.value='加载失败: '+e.message;}}
|
||||||
|
function startLogRefresh(){clearInterval(logTimer);if(logAutoRefresh.value&&logContainerId.value)logTimer=setInterval(loadLogs,5000);}
|
||||||
|
watch(logAutoRefresh,startLogRefresh);
|
||||||
|
watch(logContainerId,()=>{startLogRefresh();loadLogs();});
|
||||||
|
|
||||||
|
async function openStats(c){m.value.stats=true;m.value.statsName=c.name;m.value.statsId=c.id;m.value.statsData=null;try{const r=await api.get(`/containers/${c.id}/stats`);m.value.statsData=r.data;}catch(e){m.value.statsData={cpu_percent:0,memory_usage:0,memory_limit:1,memory_percent:0,network_rx:0,network_tx:0};}}
|
||||||
|
|
||||||
|
async function discoverCompose(){composeLoading.value=true;discoveredFiles.value=[];try{const r=await api.get('/compose/discover',{params:{path:discoverPath.value}});discoveredFiles.value=r.data;}catch(e){showToast('搜索失败: '+e.message,'error');}composeLoading.value=false;}
|
||||||
|
async function composeAction(type,path){try{const r=await api.post(`/compose/${type}`,{project_dir:path});if(r.data.success){showToast('操作成功','success');}else{showToast(r.data.error||'操作失败','error');}}catch(e){showToast('操作失败: '+e.message,'error');}}
|
||||||
|
async function editCompose(path){m.value.editor=true;m.value.editorPath=path;m.value.editorContent='加载中...';try{const r=await api.get('/compose/file',{params:{path}});if(r.data.success){m.value.editorContent=r.data.content;}else{m.value.editorContent='# 加载失败: '+r.data.error;}}catch(e){m.value.editorContent='# 加载失败: '+e.message;}}
|
||||||
|
async function saveCompose(){saving.value=true;try{const r=await api.post('/compose/file',null,{params:{path:m.value.editorPath},data:m.value.editorContent,headers:{'Content-Type':'text/plain'}});if(r.data.success){showToast('保存成功','success');}else{showToast(r.data.error||'保存失败','error');}}catch(e){showToast('保存失败: '+e.message,'error');}saving.value=false;}
|
||||||
|
async function composeEditorAction(type){await saveCompose();await composeAction(type,m.value.editorPath);}
|
||||||
|
|
||||||
|
// Images
|
||||||
|
async function loadImages(){imagesLoading.value=true;try{const r=await api.get('/images');localImages.value=r.data;}catch(e){console.error(e);}imagesLoading.value=false;}
|
||||||
|
async function loadMirrors(){try{const r=await api.get('/images/mirrors');mirrors.value=r.data;if(mirrors.value.length>0)pullMirror.value=mirrors.value[0].prefix;}catch(e){console.error(e);}}
|
||||||
|
async function doSearchImages(){if(!searchQuery.value)return;searching.value=true;searchResults.value=[];try{const r=await api.get('/images/search',{params:{q:searchQuery.value,limit:20}});searchResults.value=r.data;}catch(e){showToast('搜索失败: '+e.message,'error');}searching.value=false;}
|
||||||
|
async function doPullImage(){if(!pullImageName.value)return;pulling.value=true;pullStatus.value=null;try{const r=await api.post(`/images/pull?image=${encodeURIComponent(pullImageName.value)}&mirror=${encodeURIComponent(pullMirror.value)}`);if(r.data.success){pullStatus.value={ok:true,msg:'✓ 下载成功: '+r.data.output};showToast('下载成功','success');await loadImages();}else{pullStatus.value={ok:false,msg:'✗ 下载失败: '+r.data.error};showToast('下载失败: '+r.data.error,'error');}}catch(e){pullStatus.value={ok:false,msg:'✗ 错误: '+e.message};showToast('下载失败: '+e.message,'error');}pulling.value=false;}
|
||||||
|
function quickPull(name){pullImageName.value=name;doPullImage();}
|
||||||
|
async function doRemoveImage(id){if(!confirm('确定要删除这个镜像吗?'))return;try{const r=await api.delete(`/images/${id}`);if(r.data.success){showToast('删除成功','success');await loadImages();}else{showToast(r.data.error||'删除失败','error');}}catch(e){showToast('删除失败: '+e.message,'error');}}
|
||||||
|
|
||||||
|
watch(()=>view.value,(v)=>{if(v==='images'){loadImages();loadMirrors();}});
|
||||||
|
|
||||||
|
function fmtPorts(ports){if(!ports||Object.keys(ports).length===0)return'无端口映射';return Object.entries(ports).map(([k,v])=>{if(Array.isArray(v)&&v.length>0)return`${k}->${v.map(x=>x.HostPort).join(',')}`;return k;}).join(', ');}
|
||||||
|
function fmtBytes(b){if(!b)return'0 B';const u=['B','KB','MB','GB','TB'];let i=0;while(b>=1024&&i<u.length-1){b/=1024;i++;}return b.toFixed(1)+' '+u[i];}
|
||||||
|
function fmtMem(b){if(!b)return'0 B';return fmtBytes(b);}
|
||||||
|
|
||||||
|
onMounted(()=>{loadAll();startRefresh();});
|
||||||
|
onUnmounted(()=>{clearInterval(refreshTimer);clearInterval(logTimer);});
|
||||||
|
return{view,loading,containers,sysInfo,actionLoading,autoRefresh,composeLoading,discoveredFiles,discoverPath,logContainerId,logContent,logAutoRefresh,logContentEl,toast,m,saving,forceRemove,runningCount,logContainerName,logTail,doAction,confirmRemove,doRemove,openLogs,loadLogs,loadModalLogs,openStats,discoverCompose,composeAction,editCompose,saveCompose,composeEditorAction,fmtPorts,fmtBytes,fmtMem,localImages,imagesLoading,mirrors,searchQuery,searchResults,searching,pullImageName,pullMirror,pulling,pullStatus,doSearchImages,doPullImage,quickPull,doRemoveImage};
|
||||||
|
}
|
||||||
|
}).mount('#app');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user