feat: Docker管理面板 - 支持容器管理、Compose管理、日志查看、镜像管理

This commit is contained in:
OpenClaw Bot
2026-03-23 17:52:40 +08:00
commit a389173e52
7 changed files with 1192 additions and 0 deletions

77
README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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>