From a389173e52369941d9c7d53e00bf61e4e9708bcc Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Mon, 23 Mar 2026 17:52:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Docker=E7=AE=A1=E7=90=86=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=20-=20=E6=94=AF=E6=8C=81=E5=AE=B9=E5=99=A8=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=80=81Compose=E7=AE=A1=E7=90=86=E3=80=81=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=9F=A5=E7=9C=8B=E3=80=81=E9=95=9C=E5=83=8F=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 77 +++++++ backend/Dockerfile | 19 ++ backend/docker_client.py | 435 ++++++++++++++++++++++++++++++++++++++ backend/main.py | 195 +++++++++++++++++ backend/requirements.txt | 5 + docker-compose.yml | 21 ++ frontend/dist/index.html | 440 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 1192 insertions(+) create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/docker_client.py create mode 100644 backend/main.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/dist/index.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..c337b29 --- /dev/null +++ b/README.md @@ -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=` | 保存文件 | diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b1d69b0 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/docker_client.py b/backend/docker_client.py new file mode 100644 index 0000000..5f6e48a --- /dev/null +++ b/backend/docker_client.py @@ -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)} diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..b2ac2ee --- /dev/null +++ b/backend/main.py @@ -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") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..e452084 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..37e6152 --- /dev/null +++ b/docker-compose.yml @@ -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: {} diff --git a/frontend/dist/index.html b/frontend/dist/index.html new file mode 100644 index 0000000..1a1fa77 --- /dev/null +++ b/frontend/dist/index.html @@ -0,0 +1,440 @@ + + + + + + Docker Manager + + + + + +
+
{{toast.msg}}
+ +
+ + +
+ +
+ +
+
容器总数
{{sysInfo.containers}}
{{sysInfo.running}} 运行中
+
镜像数量
{{sysInfo.images}}
+
Docker 版本
{{sysInfo.version}}
+
CPU / 内存
{{sysInfo.cpu_count}}核 / {{fmtMem(sysInfo.memory_total)}}
+
+
+
自动刷新
+
+
加载中
+
暂无容器
+
+
+
+
+
{{c.name}}
+
{{c.image}}
+
ID: {{c.id}}
+
+ {{c.state}} +
+
{{fmtPorts(c.ports)}}
+
+ + + + + + +
+
+
+
+ + +
+ +
+ + +
+
加载中
+
点击搜索发现 Compose 项目
+
+
+
+
+
{{f.name||f.path.split('/').pop()}}
+
{{f.path}}
+
+
+
+ + + + +
+
+
+
+ + +
+ + + +
+

📥 下载镜像

+
+ + + +
+
{{pullStatus.msg}}
+
+ + +
+

🔍 搜索镜像

+ +
搜索中...
+
+
+
+
{{r.name}} 官方
+
{{r.description||'(无描述)'}}
+
⭐ {{r.star_count}} | 📥 {{r.pull_count}}
+
+ +
+
+
+ + +
+

💾 本地镜像 ({{localImages.length}})

+
加载中...
+
暂无本地镜像
+
+
+
+
{{img.repo_tags&&img.repo_tags[0]?img.repo_tags[0]:img.id}}
+
{{img.repo_tags.slice(1).join(', ')}}
+
+ ID: {{img.id}} + 大小: {{fmtBytes(img.size)}} +
+
+
+ +
+
+
+
+
+ + +
+ +
+ +
自动刷新
+ + +
+
+

📜 {{logContainerName}}

最后 {{logTail}} 行
+
{{logContent||'选择容器查看日志'}}
+
+
+
+
+ + + + + + + + + + + + +
+ + + +