Files
docker-manage/frontend/dist/index.html

831 lines
47 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Docker Panel</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>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#0a0e17;
--sidebar-bg:#111827;
--card:#1a1f2e;
--card-hover:#222938;
--border:#2d3548;
--text:#f1f5f9;
--text-dim:#94a3b8;
--accent:#6366f1;
--accent-light:#818cf8;
--accent-bg:rgba(99,102,241,0.12);
--green:#10b981;
--green-bg:rgba(16,185,129,0.12);
--red:#ef4444;
--red-bg:rgba(239,68,68,0.12);
--yellow:#f59e0b;
--yellow-bg:rgba(245,158,11,0.12);
--orange:#f97316;
}
body{font-family:'Inter','PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;font-size:14px;line-height:1.5}
a{color:inherit;text-decoration:none}
.app{display:flex;min-height:100vh}
/* Sidebar */
.sidebar{width:240px;background:var(--sidebar-bg);border-right:1px solid var(--border);display:flex;flex-direction:column;position:fixed;height:100vh;z-index:50}
.sidebar-header{padding:20px 24px;border-bottom:1px solid var(--border)}
.sidebar-header .logo{display:flex;align-items:center;gap:12px}
.sidebar-header .logo-icon{width:36px;height:36px;background:linear-gradient(135deg,var(--accent),var(--accent-light));border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:20px}
.sidebar-header .logo-text{font-size:18px;font-weight:700;color:var(--text)}
.sidebar-header .logo-text span{color:var(--accent)}
.sidebar-nav{flex:1;padding:16px 12px;overflow-y:auto}
.nav-section{margin-bottom:24px}
.nav-section-title{font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.5px;padding:0 12px;margin-bottom:8px}
.nav-item{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:8px;color:var(--text-dim);cursor:pointer;transition:all .15s;font-size:14px;margin-bottom:2px}
.nav-item:hover{background:var(--card);color:var(--text)}
.nav-item.active{background:var(--accent-bg);color:var(--accent)}
.nav-item .icon{font-size:18px;width:24px;text-align:center}
.nav-item .badge{padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;margin-left:auto}
.nav-item .badge.green{background:var(--green-bg);color:var(--green)}
.nav-item .badge.red{background:var(--red-bg);color:var(--red)}
/* Main Content */
.main{margin-left:240px;flex:1;padding:24px 32px;min-height:100vh}
/* Page Header */
.page-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:24px}
.page-header h1{font-size:24px;font-weight:700;margin-bottom:4px}
.page-header p{color:var(--text-dim);font-size:14px}
/* Cards */
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px;transition:all .2s}
.card:hover{border-color:var(--accent)}
.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}
.card-title{font-size:14px;font-weight:600;color:var(--text)}
.card-subtitle{font-size:12px;color:var(--text-dim);margin-top:2px}
/* Dashboard Grid */
.dashboard-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:24px}
.stat-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px;position:relative;overflow:hidden}
.stat-card::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--accent),var(--accent-light))}
.stat-card.green::before{background:linear-gradient(90deg,var(--green),#34d399)}
.stat-card.yellow::before{background:linear-gradient(90deg,var(--yellow),#fbbf24)}
.stat-card.red::before{background:linear-gradient(90deg,var(--red),#f87171)}
.stat-card .stat-icon{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:20px;margin-bottom:12px}
.stat-card .stat-icon.blue{background:var(--accent-bg);color:var(--accent)}
.stat-card .stat-icon.green{background:var(--green-bg);color:var(--green)}
.stat-card .stat-icon.yellow{background:var(--yellow-bg);color:var(--yellow)}
.stat-card .stat-icon.red{background:var(--red-bg);color:var(--red)}
.stat-card .stat-value{font-size:28px;font-weight:700;margin-bottom:4px}
.stat-card .stat-label{font-size:13px;color:var(--text-dim)}
/* Charts */
.charts-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px;margin-bottom:24px}
.chart-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px}
.chart-card .chart-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}
.chart-card .chart-title{font-size:14px;font-weight:600}
.chart-card canvas{height:180px!important}
/* Container Grid */
.container-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}
.container-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;transition:all .2s;cursor:pointer}
.container-card:hover{border-color:var(--accent);transform:translateY(-2px)}
.container-card .c-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px}
.container-card .c-info{flex:1;min-width:0}
.container-card .c-name{font-size:15px;font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:8px}
.container-card .c-image{font-size:12px;color:var(--text-dim);word-break:break-all}
.container-card .c-id{font-size:11px;color:var(--text-dim);font-family:monospace;margin-top:4px}
.status-badge{padding:4px 10px;border-radius:20px;font-size:12px;font-weight:500;flex-shrink:0}
.status-running{background:var(--green-bg);color:var(--green)}
.status-stopped,.status-exited{background:var(--red-bg);color:var(--red)}
.status-paused{background:var(--yellow-bg);color:var(--yellow)}
.status-created{background:var(--accent-bg);color:var(--accent)}
.container-card .c-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:12px 0;padding:12px;background:var(--bg);border-radius:8px}
.container-card .c-stat{text-align:center}
.container-card .c-stat-value{font-size:14px;font-weight:600}
.container-card .c-stat-label{font-size:10px;color:var(--text-dim);text-transform:uppercase}
.container-card .c-ports{font-size:12px;color:var(--text-dim);margin-bottom:12px;word-break:break-all}
.container-card .c-actions{display:flex;gap:8px;flex-wrap:wrap}
/* Buttons */
.btn{padding:8px 14px;border-radius:8px;border:none;cursor:pointer;font-size:13px;font-weight:500;transition:all .15s;display:inline-flex;align-items:center;gap:6px}
.btn-sm{padding:6px 10px;font-size:12px}
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-light)}
.btn-success{background:var(--green-bg);color:var(--green)}.btn-success:hover{background:rgba(16,185,129,0.2)}
.btn-danger{background:var(--red-bg);color:var(--red)}.btn-danger:hover{background:rgba(239,68,68,0.2)}
.btn-warning{background:var(--yellow-bg);color:var(--yellow)}.btn-warning:hover{background:rgba(245,158,11,0.2)}
.btn-ghost{background:transparent;color:var(--text-dim);border:1px solid var(--border)}.btn-ghost:hover{background:var(--card);color:var(--text)}
.btn:disabled{opacity:.4;cursor:not-allowed}
/* Forms */
input,select,textarea{background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:10px 14px;font-size:14px;outline:none;transition:border-color .2s}
input:focus,select:focus,textarea:focus{border-color:var(--accent)}
input::placeholder{color:var(--text-dim)}
/* Progress Bars */
.progress{height:6px;background:var(--border);border-radius:3px;overflow:hidden}
.progress-bar{height:100%;border-radius:3px;transition:width .3s}
.progress-cpu{background:linear-gradient(90deg,var(--accent),var(--accent-light))}
.progress-mem{background:linear-gradient(90deg,var(--green),#34d399)}
/* Modals */
.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;font-weight:600}
.close-btn{background:none;border:none;color:var(--text-dim);font-size:28px;cursor:pointer;padding:0;line-height:1}
.close-btn:hover{color:var(--text)}
.modal-body{padding:20px;overflow-y:auto;flex:1}
.modal-footer{padding:16px 20px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:10px}
/* Stats Modal */
.stats-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}
.stat-box{background:var(--bg);border-radius:10px;padding:16px}
.stat-box .sb-label{font-size:12px;color:var(--text-dim);margin-bottom:8px}
.stat-box .sb-value{font-size:24px;font-weight:700}
.stat-box .sb-sub{font-size:11px;color:var(--text-dim);margin-top:4px}
.stat-chart{margin-top:16px;height:150px}
/* Logs */
.logs-panel{background:#0d0f14;border-radius:10px;overflow:hidden}
.logs-toolbar{padding:12px 16px;background:var(--card);border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px}
.logs-content{padding:16px;font-family:'Fira Code','Cascadia Code',monospace;font-size:12px;line-height:1.7;max-height:50vh;overflow-y:auto;color:#a1a1aa;white-space:pre-wrap;word-break:break-all}
/* File Browser */
.file-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}
.file-item{display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--card);border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:all .15s}
.file-item:hover{border-color:var(--accent);transform:translateY(-2px)}
.file-item.folder{background:var(--accent-bg);border-color:rgba(99,102,241,0.3)}
.file-item .fi-icon{font-size:28px}
.file-item .fi-info{flex:1;min-width:0}
.file-item .fi-name{font-size:14px;font-weight:500;word-break:break-all}
.file-item .fi-meta{font-size:11px;color:var(--text-dim);margin-top:2px}
/* Images */
.image-item{display:flex;justify-content:space-between;align-items:center;padding:14px 16px;background:var(--card);border:1px solid var(--border);border-radius:10px;margin-bottom:8px}
.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}
/* Utility */
.loading{text-align:center;padding:60px;color:var(--text-dim)}
.empty{text-align:center;padding:60px;color:var(--text-dim)}
.text-green{color:var(--green)}.text-red{color:var(--red)}.text-yellow{color:var(--yellow)}.text-dim{color:var(--text-dim)}
.flex{display:flex}.gap-8{gap:8px}.gap-12{gap:12px}.gap-16{gap:16px}.items-center{align-items:center}
.mt-4{margin-top:16px}.mb-4{margin-bottom:16px}
.justify-between{justify-content:space-between}
/* Toggle */
.refresh-toggle{display:flex;align-items:center;gap:8px}
.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)}
/* Toast */
.toast{position:fixed;bottom:24px;right:24px;padding:14px 20px;border-radius:10px;font-size:14px;z-index:200;max-width:360px;animation:slideIn .2s ease}
.toast-success{background:var(--green);color:#fff}.toast-error{background:var(--red);color:#fff}.toast-info{background:var(--accent);color:#fff}
@keyframes slideIn{from{transform:translateY(20px);opacity:0}to{transform:none;opacity:1}}
/* Responsive */
@media(max-width:1200px){.dashboard-grid{grid-template-columns:repeat(2,1fr)}.charts-grid{grid-template-columns:1fr}}
@media(max-width:768px){
.sidebar{transform:translateX(-100%);transition:transform .3s}
.main{margin-left:0;padding:16px}
.dashboard-grid{grid-template-columns:1fr}
}
</style>
</head>
<body>
<div id="app">
<div v-if="toast.show" :class="['toast','toast-'+toast.type]">{{toast.msg}}</div>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">
<div class="logo-icon">🐳</div>
<div class="logo-text">Docker<span>Panel</span></div>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-section-title">概览</div>
<div :class="['nav-item',{active:view==='dashboard'}]" @click="view='dashboard'">
<span class="icon">📊</span><span>仪表盘</span>
<span v-if="runningCount>0" class="badge green">{{runningCount}}</span>
</div>
</div>
<div class="nav-section">
<div class="nav-section-title">容器</div>
<div :class="['nav-item',{active:view==='containers'}]" @click="view='containers'">
<span class="icon">📦</span><span>容器列表</span>
<span class="badge green">{{containers.length}}</span>
</div>
</div>
<div class="nav-section">
<div class="nav-section-title">镜像</div>
<div :class="['nav-item',{active:view==='images'}]" @click="view='images'">
<span class="icon">💾</span><span>镜像管理</span>
</div>
</div>
<div class="nav-section">
<div class="nav-section-title">存储</div>
<div :class="['nav-item',{active:view==='browser'}]" @click="view='browser'">
<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>
<div class="nav-section">
<div class="nav-section-title">系统</div>
<div :class="['nav-item',{active:view==='logs'}]" @click="view='logs'">
<span class="icon">📜</span><span>日志</span>
</div>
</div>
</nav>
</aside>
<!-- Main -->
<main class="main">
<!-- Dashboard View -->
<div v-if="view==='dashboard'">
<div class="page-header">
<div>
<h1>仪表盘</h1>
<p>实时监控 Docker 容器状态</p>
</div>
<div class="flex gap-8">
<div class="refresh-toggle">
<span style="font-size:13px;color:var(--text-dim);margin-right:8px">自动刷新</span>
<div :class="['toggle',{on:autoRefresh}]" @click="autoRefresh=!autoRefresh"></div>
</div>
<button class="btn btn-ghost btn-sm" @click="loadAll">🔄 刷新</button>
</div>
</div>
<!-- Stats Cards -->
<div class="dashboard-grid">
<div class="stat-card">
<div class="stat-icon blue">📦</div>
<div class="stat-value">{{containers.length}}</div>
<div class="stat-label">容器总数</div>
</div>
<div class="stat-card green">
<div class="stat-icon green">▶️</div>
<div class="stat-value">{{runningCount}}</div>
<div class="stat-label">运行中</div>
</div>
<div class="stat-card yellow">
<div class="stat-icon yellow">⏸️</div>
<div class="stat-value">{{containers.length - runningCount}}</div>
<div class="stat-label">已停止</div>
</div>
<div class="stat-card red">
<div class="stat-icon red">💾</div>
<div class="stat-value">{{sysInfo.images || 0}}</div>
<div class="stat-label">镜像数量</div>
</div>
</div>
<!-- Charts -->
<div class="charts-grid">
<div class="chart-card">
<div class="chart-header">
<div class="chart-title">📈 系统资源使用</div>
<div style="font-size:12px;color:var(--text-dim)">{{sysInfo.cpu_count}} 核 / {{fmtMem(sysInfo.memory_total)}}</div>
</div>
<canvas ref="systemChart"></canvas>
</div>
<div class="chart-card">
<div class="chart-header">
<div class="chart-title">🐳 容器状态分布</div>
</div>
<canvas ref="containerChart"></canvas>
</div>
</div>
<!-- Running Containers -->
<div class="card">
<div class="card-header">
<div class="card-title">🚀 运行中的容器</div>
<button class="btn btn-primary btn-sm" @click="view='containers'">查看全部</button>
</div>
<div class="container-grid">
<div v-for="c in containers.filter(x=>x.state==='running').slice(0,6)" :key="c.id" class="container-card" @click="openStats(c)">
<div class="c-header">
<div class="c-info">
<div class="c-name">{{c.name}}</div>
<div class="c-image">{{c.image}}</div>
</div>
<span :class="['status-badge','status-'+c.state]">{{c.state}}</span>
</div>
<div class="c-ports">{{fmtPorts(c.ports)}}</div>
</div>
</div>
</div>
</div>
<!-- Containers View -->
<div v-if="view==='containers'">
<div class="page-header">
<div>
<h1>容器管理</h1>
<p>共 {{containers.length}} 个容器 / {{runningCount}} 运行中</p>
</div>
<div class="flex gap-8 items-center">
<div class="refresh-toggle">
<span style="font-size:13px;color:var(--text-dim);margin-right:8px">自动刷新</span>
<div :class="['toggle',{on:autoRefresh}]" @click="autoRefresh=!autoRefresh"></div>
</div>
<button class="btn btn-ghost btn-sm" @click="loadAll">🔄 刷新</button>
</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="c-header">
<div class="c-info">
<div class="c-name">{{c.name}}</div>
<div class="c-image">{{c.image}}</div>
<div class="c-id">ID: {{c.id}}</div>
</div>
<span :class="['status-badge','status-'+c.state]">{{c.state}}</span>
</div>
<div class="c-ports">{{fmtPorts(c.ports)}}</div>
<div class="c-actions">
<button v-if="c.state==='running'" class="btn btn-warning btn-sm" @click="doAction('restart',c.id)"></button>
<button v-if="c.state==='running'" class="btn btn-danger btn-sm" @click="doAction('stop',c.id)"></button>
<button v-else class="btn btn-success btn-sm" @click="doAction('start',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-ghost btn-sm" @click="confirmRemove(c)">🗑</button>
</div>
</div>
</div>
</div>
<!-- Images View -->
<div v-if="view==='images'">
<div class="page-header"><h1>镜像管理</h1><p>管理本地镜像、搜索和下载 Docker 镜像</p></div>
<!-- Pull Image -->
<div class="card mb-4">
<div class="card-title mb-4">📥 下载镜像</div>
<div class="flex gap-8">
<input v-model="pullImageName" placeholder="输入镜像名,如 nginx, redis" style="flex:1">
<select v-model="pullMirror" style="width:180px">
<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" :style="{marginTop:'12px',padding:'12px',borderRadius:'8px',background: pullStatus.ok?'var(--green-bg)':'var(--red-bg)',color: pullStatus.ok?'var(--green)':'var(--red)'}">{{pullStatus.msg}}</div>
</div>
<!-- Search -->
<div class="card mb-4">
<div class="card-title mb-4">🔍 搜索镜像</div>
<div class="flex gap-8">
<input v-model="searchQuery" @keyup.enter="doSearchImages" placeholder="输入镜像名搜索 Docker Hub" style="flex:1">
<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" style="margin-top:16px">
<div v-for="r in searchResults" :key="r.name" class="image-item">
<div class="image-info">
<div class="image-name">{{r.name}} <span v-if="r.official" style="background:var(--accent-bg);color:var(--accent);padding:2px 6px;border-radius:4px;font-size:11px">官方</span></div>
<div class="image-tags">{{r.description||'(无描述)'}} · ⭐ {{r.star_count}} · 📥 {{r.pull_count}}</div>
</div>
<button class="btn btn-success btn-sm" @click="quickPull(r.name)">⬇️</button>
</div>
</div>
</div>
<!-- Local Images -->
<div class="card">
<div class="card-title mb-4">💾 本地镜像 ({{localImages.length}})</div>
<div v-if="imagesLoading" class="loading">加载中...</div>
<div v-else-if="localImages.length===0" class="empty">暂无本地镜像</div>
<div v-else>
<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 class="image-tags">ID: {{img.id}} · 大小: {{fmtBytes(img.size)}}</div>
</div>
<button class="btn btn-danger btn-sm" @click="doRemoveImage(img.id)">🗑</button>
</div>
</div>
</div>
</div>
<!-- Browser View -->
<div v-if="view==='browser'">
<div class="page-header"><h1>文件浏览</h1><p>浏览和编辑服务器上的文件</p></div>
<!-- Mounts -->
<div class="card mb-4">
<div class="card-title mb-4">📦 容器挂载点</div>
<div v-if="mounts.length===0" class="text-dim">暂无挂载点</div>
<div v-else style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:8px">
<div v-for="m in mounts" :key="m.container_id+m.destination" class="flex gap-8 items-center" style="padding:10px 14px;background:var(--bg);border-radius:8px">
<div style="flex:1">
<div style="font-size:13px;font-weight:500">{{m.container_name}}</div>
<div style="font-size:11px;color:var(--text-dim);word-break:break-all">{{m.source}}</div>
</div>
<button class="btn btn-primary btn-sm" @click="browsePath=m.source;loadDir()">📂</button>
</div>
</div>
</div>
<!-- Path Input -->
<div class="flex gap-8 mb-4">
<input v-model="browsePath" @keyup.enter="loadDir" placeholder="输入完整路径,如 /data/compose" style="flex:1">
<button class="btn btn-primary" @click="loadDir">📂 浏览</button>
</div>
<!-- Path Bar -->
<div v-if="currentPath" style="display:flex;align-items:center;gap:4px;padding:10px 14px;background:var(--card);border-radius:8px;margin-bottom:16px;font-size:13px;overflow-x:auto;white-space:nowrap">
<span v-for="(seg,i) in pathSegments" :key="i">
<span v-if="i>0" style="color:var(--text-dim)">/</span>
<span @click="navigateTo(i)" style="padding:2px 6px;border-radius:4px;cursor:pointer;color:var(--text-dim)">{{seg||'根目录'}}</span>
</span>
</div>
<!-- Files -->
<div v-if="dirLoading" class="loading">加载中...</div>
<div v-else-if="dirError" class="empty" style="color:var(--red)">{{dirError}}</div>
<div v-else class="file-grid">
<div v-if="currentPath && currentPath!=='/'" class="file-item folder" @click="goUp">
<div class="fi-icon">⬆️</div>
<div class="fi-info"><div class="fi-name">上级目录</div></div>
</div>
<div v-for="item in dirItems.filter(i=>i.is_dir)" :key="item.path" class="file-item folder" @click="browsePath=item.path;loadDir()">
<div class="fi-icon">📁</div>
<div class="fi-info"><div class="fi-name">{{item.name}}</div></div>
</div>
<div v-for="item in dirItems.filter(i=>!i.is_dir)" :key="item.path" class="file-item" @click="openFile(item)">
<div class="fi-icon">{{getFileIcon(item.name)}}</div>
<div class="fi-info">
<div class="fi-name">{{item.name}}</div>
<div class="fi-meta">{{fmtBytes(item.size)}}</div>
</div>
</div>
</div>
</div>
<!-- Compose View -->
<div v-if="view==='compose'">
<div class="page-header"><h1>Docker Compose</h1><p>管理和编辑 Compose 项目</p></div>
<div class="flex gap-8 mb-4">
<input v-model="discoverPath" placeholder="输入路径搜索 docker-compose.yml如 /data/compose" style="flex:1">
<button class="btn btn-primary" @click="discoverCompose">🔍 搜索</button>
</div>
<div v-if="composeLoading" class="loading">加载中...</div>
<div v-else-if="discoveredFiles.length===0" class="empty">输入路径搜索 Compose 项目</div>
<div v-else>
<div v-for="f in discoveredFiles" :key="f.path" class="card mb-4">
<div class="flex justify-between items-center mb-4">
<div>
<div style="font-size:15px;font-weight:600">{{f.name||f.path.split('/').pop()}}</div>
<div style="font-size:12px;color:var(--text-dim);margin-top:2px">{{f.path}}</div>
</div>
</div>
<div class="flex gap-8">
<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-ghost btn-sm" @click="composeAction('restart',f.path)">⟳ 重启</button>
</div>
</div>
</div>
</div>
<!-- Logs View -->
<div v-if="view==='logs'">
<div class="page-header"><h1>日志查看</h1><p>实时查看容器日志</p></div>
<div class="flex gap-8 mb-4" style="flex-wrap:wrap">
<select v-model="logContainerId" @change="loadLogs" style="min-width:200px">
<option value="">-- 选择容器 --</option>
<option v-for="c in containers" :key="c.id" :value="c.id">{{c.name}}</option>
</select>
<div class="refresh-toggle">
<span style="font-size:13px;color:var(--text-dim)">自动刷新</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">{{logContent||'选择容器查看日志'}}</div>
</div>
</div>
</main>
</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 class="modal-body">
<div v-if="m.statsData" class="stats-grid">
<div class="stat-box">
<div class="sb-label">CPU 使用率</div>
<div class="sb-value" style="color:var(--accent)">{{m.statsData.cpu_percent}}%</div>
<div class="progress" style="margin-top:8px"><div class="progress-bar progress-cpu" :style="{width:m.statsData.cpu_percent+'%'}"></div></div>
</div>
<div class="stat-box">
<div class="sb-label">内存使用</div>
<div class="sb-value" style="color:var(--green)">{{m.statsData.memory_percent.toFixed(1)}}%</div>
<div style="font-size:11px;color:var(--text-dim);margin-top:4px">{{fmtBytes(m.statsData.memory_usage)}} / {{fmtBytes(m.statsData.memory_limit)}}</div>
<div class="progress" style="margin-top:8px"><div class="progress-bar progress-mem" :style="{width:m.statsData.memory_percent+'%'}"></div></div>
</div>
<div class="stat-box">
<div class="sb-label">网络接收</div>
<div class="sb-value">{{fmtBytes(m.statsData.network_rx)}}</div>
</div>
<div class="stat-box">
<div class="sb-label">网络发送</div>
<div class="sb-value">{{fmtBytes(m.statsData.network_tx)}}</div>
</div>
</div>
<div v-else class="loading">加载中...</div>
</div>
</div>
</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 class="flex gap-8 items-center">
<select v-model="m.logTail" @change="loadModalLogs" style="padding:6px 10px;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>
<!-- 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.split('/').pop()}}</h2><button class="close-btn" @click="m.editor=false">×</button></div>
<div style="padding:12px 20px;background:var(--bg);border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
<span style="font-size:12px;color:var(--text-dim)">{{m.editorPath}}</span>
<div class="flex gap-8">
<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>
</div>
</div>
<textarea id="compose-editor" v-model="m.editorContent" spellcheck="false" style="flex:1;min-height:500px;width:100%;background:#0d0f14;color:#e4e4e7;border:none;padding:16px;font-family:'Fira Code',monospace;font-size:14px;line-height:1.7;resize:vertical;outline:none;"></textarea>
</div>
</div>
<!-- File Editor Modal -->
<div v-if="m.fileEditor" class="modal-overlay" @click.self="m.fileEditor=false">
<div class="modal" style="max-width:90vw;height:80vh;display:flex;flex-direction:column">
<div class="modal-header"><h2>✏️ {{m.fileName}}</h2><button class="close-btn" @click="m.fileEditor=false">×</button></div>
<div style="padding:12px 20px;background:var(--bg);border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
<span style="font-size:12px;color:var(--text-dim)">{{m.filePath}}</span>
<button class="btn btn-primary btn-sm" @click="saveFile" :disabled="fileSaving">💾 保存</button>
</div>
<textarea v-if="!m.fileBinary" v-model="m.fileContent" spellcheck="false" style="flex:1;min-height:400px;width:100%;background:#0d0f14;color:#e4e4e7;border:none;padding:16px;font-family:'Fira Code',monospace;font-size:14px;line-height:1.7;resize:vertical;outline:none;"></textarea>
<div v-else style="padding:20px;text-align:center;color:var(--text-dim)">{{m.fileBinaryMsg}}</div>
</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 class="modal-body">
<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 class="flex gap-8 justify-between">
<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);
// State
const view=ref('dashboard');
const loading=ref(false);
const containers=ref([]);
const sysInfo=ref({containers:0,running:0,images:0,version:'',cpu_count:0,memory_total:0});
const actionLoading=ref({});
const autoRefresh=ref(true);
// Images
const localImages=ref([]);
const imagesLoading=ref(false);
const mirrors=ref([]);
const searchQuery=ref('');
const searchResults=ref([]);
const searching=ref(false);
const pullImageName=ref('');
const pullMirror=ref('');
const pulling=ref(false);
const pullStatus=ref(null);
// Browser
const browsePath=ref('/data/compose');
const currentPath=ref('');
const dirItems=ref([]);
const dirLoading=ref(false);
const dirError=ref('');
const mounts=ref([]);
const fileSaving=ref(false);
// Compose
const composeLoading=ref(false);
const discoveredFiles=ref([]);
const discoverPath=ref('/data/compose');
const saving=ref(false);
const forceRemove=ref(false);
// Logs
const logContainerId=ref('');
const logContent=ref('');
const logAutoRefresh=ref(false);
const logContainerName=computed(()=>{const c=containers.value.find(x=>x.id===logContainerId.value);return c?c.name:'日志';});
// Modals
const m=ref({
logs:false,logsName:'',logsId:'',logsContent:'',logTail:100,logTimestamps:false,
stats:false,statsName:'',statsId:'',statsData:null,
editor:false,editorPath:'',editorContent:'',
fileEditor:false,filePath:'',fileName:'',fileContent:'',fileBinary:false,fileBinaryMsg:'',
confirmRemove:null
});
let refreshTimer=null;
let logTimer=null;
let systemChart=null;
let containerChart=null;
const runningCount=computed(()=>containers.value.filter(c=>c.state==='running').length);
// Toast
const toast=ref({show:false,type:'info',msg:''});
function showToast(msg,type='info',dur=2500){toast.value={show:true,type,msg};setTimeout(()=>{toast.value.show=false;},dur);}
// API
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;updateCharts();}
// Container Actions
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');}
}
// Stats
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){showToast('加载失败: '+e.message,'error');}
}
// Logs
async function openLogs(c){
m.value.logs=true;m.value.logsName=c.name;m.value.logsId=c.id;m.value.logTail=100;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||'(无日志)';}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();});
// 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:'✓ 下载成功'};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');}}
// Browser
async function loadMounts(){try{const r=await api.get('/browser/mounts');mounts.value=r.data;}catch(e){console.error(e);}}
async function loadDir(){if(!browsePath.value){showToast('请输入路径','error');return;}dirLoading.value=true;dirError.value='';dirItems.value=[];currentPath.value='';try{const r=await api.get('/browser/ls',{params:{path:browsePath.value}});if(r.data.success){currentPath.value=browsePath.value;dirItems.value=r.data.items||[];}else{dirError.value=r.data.error||'加载失败';}}catch(e){dirError.value='加载失败: '+e.message;}dirLoading.value=false;}
function navigateTo(idx){const parts=pathSegments.value;const newPath=parts.slice(0,idx+1).join('/');browsePath.value=newPath||'/';loadDir();}
function goUp(){if(!currentPath.value)return;const parts=currentPath.value.split('/').filter(p=>p);parts.pop();browsePath.value='/'+parts.join('/');loadDir();}
const pathSegments=computed(()=>currentPath.value?currentPath.value.split('/').filter(p=>p):[]);
function getFileIcon(name){const ext=name.split('.').pop().toLowerCase();const icons={'yml':'📄','yaml':'📄','json':'📋','txt':'📝','md':'📝','sh':'📜','py':'🐍','js':'📜','html':'🌐','css':'🎨','conf':'⚙️','cfg':'⚙️','ini':'⚙️','log':'📜','env':'🔐','gitignore':'🔐','dockerfile':'🐳'};return icons[ext]||'📄';}
async function openFile(item){m.value.filePath=item.path;m.value.fileName=item.name;m.value.fileContent='加载中...';m.value.fileBinary=false;try{const r=await api.get('/browser/cat',{params:{path:item.path}});if(r.data.success){m.value.fileContent=r.data.content;m.value.fileBinary=false;}else{if(r.data.binary){m.value.fileBinary=true;m.value.fileBinaryMsg=r.data.error;}else{m.value.fileContent='# '+r.data.error;m.value.fileBinary=false;}}}catch(e){m.value.fileContent='# 加载失败: '+e.message;}m.value.fileEditor=true;}
async function saveFile(){fileSaving.value=true;try{const r=await api.post('/browser/save',null,{params:{path:m.value.filePath},data:m.value.fileContent,headers:{'Content-Type':'text/plain'}});if(r.data.success){showToast('保存成功','success');m.value.fileEditor=false;}else{showToast(r.data.error||'保存失败','error');}}catch(e){showToast('保存失败: '+e.message,'error');}fileSaving.value=false;}
// Compose
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);}
// Charts
function updateCharts(){
nextTick(()=>{
// System Resource Chart
const systemCtx=document.querySelector('[ref="systemChart"]');
if(systemCtx){
if(systemChart){systemChart.destroy();}
systemChart=new Chart(systemCtx,{
type:'bar',
data:{
labels:['CPU 核心','内存'],
datasets:[{
label:'使用率',
data:[30,45],
backgroundColor:['rgba(99,102,241,0.8)','rgba(16,185,129,0.8)'],
borderRadius:6
}]
},
options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,max:100,ticks:{color:'#94a3b8'}},x:{ticks:{color:'#94a3b8'}}}}}
});
}
// Container Status Chart
const containerCtx=document.querySelector('[ref="containerChart"]');
if(containerCtx){
if(containerChart){containerChart.destroy();}
const running=containers.value.filter(c=>c.state==='running').length;
const stopped=containers.value.length-running;
containerChart=new Chart(containerCtx,{
type:'doughnut',
data:{
labels:['运行中','已停止'],
datasets:[{
data:[running,stopped],
backgroundColor:['rgba(16,185,129,0.9)','rgba(239,68,68,0.9)'],
borderWidth:0
}]
},
options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{color:'#94a3b8',padding:20}}}}
});
}
});
}
// Utils
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);}
// Watchers
watch(()=>view.value,(v)=>{
if(v==='images'){loadImages();loadMirrors();}
if(v==='browser'){loadMounts();if(!browsePath.value)browsePath.value='/data/compose';loadDir();}
if(v==='dashboard'){loadAll();}
});
// Refresh
function startRefresh(){clearInterval(refreshTimer);if(autoRefresh.value)refreshTimer=setInterval(loadAll,5000);}
watch(autoRefresh,startRefresh);
// Mount
onMounted(()=>{loadAll();startRefresh();});
onUnmounted(()=>{clearInterval(refreshTimer);clearInterval(logTimer);});
</script>
</body>
</html>