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

561 lines
43 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 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}
/* Browser View */
.browser-toolbar{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap;align-items:center}
.browser-toolbar 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}
.browser-toolbar input:focus{border-color:var(--accent}
.path-bar{display:flex;align-items:center;gap:4px;padding:8px 12px;background:var(--card);border:1px solid var(--border);border-radius:8px;margin-bottom:16px;font-size:13px;overflow-x:auto;white-space:nowrap}
.path-bar .path-sep{color:var(--text-dim)}
.path-segment{padding:2px 6px;border-radius:4px;cursor:pointer;color:var(--text-dim)}
.path-segment:hover{background:var(--border);color:var(--text)}
.file-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,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)}
.file-item .fi-icon{font-size:24px}
.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}
.file-item.folder{background:rgba(99,102,241,.08);border-color:rgba(99,102,241,.2)}
.mounts-list{display:flex;flex-direction:column;gap:8px;margin-bottom:20px}
.mount-item{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;background:var(--card);border:1px solid var(--border);border-radius:8px;font-size:13px}
.mount-item .mi-container{font-weight:500}
.mount-item .mi-path{color:var(--text-dim);font-family:monospace;font-size:12px;margin-top:2px}
.file-editor-modal .modal{height:80vh;display:flex;flex-direction:column}
.file-editor-modal textarea{flex:1;min-height:400px}
</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==='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 :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>
<!-- Browser -->
<div v-if="view==='browser'">
<div class="page-header"><h1>文件浏览</h1><p>浏览和编辑服务器上的文件</p></div>
<!-- 快速路径:容器挂载点 -->
<div class="images-section">
<h2>📦 容器挂载点</h2>
<div v-if="mounts.length===0" class="empty">暂无挂载点</div>
<div v-else class="mounts-list">
<div v-for="m in mounts" :key="m.container_id+m.destination" class="mount-item">
<div>
<div class="mi-container">{{m.container_name}}</div>
<div class="mi-path">{{m.source}} → {{m.destination}} {{m.rw?'':'🔒'}}</div>
</div>
<button class="btn btn-primary btn-sm" @click="browsePath=m.source;loadDir()">📂 打开</button>
</div>
</div>
</div>
<!-- 手动输入路径 -->
<div class="browser-toolbar">
<input v-model="browsePath" @keyup.enter="loadDir" placeholder="输入完整路径,如 /data/compose">
<button class="btn btn-primary" @click="loadDir">📂 浏览</button>
</div>
<!-- 路径导航 -->
<div v-if="currentPath" class="path-bar">
<span v-for="(seg,i) in pathSegments" :key="i">
<span v-if="i>0" class="path-sep">/</span>
<span class="path-segment" @click="navigateTo(i)">{{seg||'根目录'}}</span>
</span>
</div>
<!-- 文件列表 -->
<div v-if="dirLoading" class="loading">加载中...</div>
<div v-else-if="dirError" class="empty" style="color:var(--red)">{{dirError}}</div>
<div v-else-if="dirItems.length===0" class="empty">目录为空</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 class="fi-meta">目录</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>
<!-- 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>
<!-- File Editor Modal -->
<div v-if="m.fileEditor" class="modal-overlay file-editor-modal" @click.self="m.fileEditor=false">
<div class="modal" style="max-width:90vw;">
<div class="modal-header">
<h2>✏️ {{m.fileName}} <span style="font-size:12px;color:var(--text-dim);font-weight:normal">{{m.filePath}}</span></h2>
<button class="close-btn" @click="m.fileEditor=false">×</button>
</div>
<div class="editor-toolbar">
<span style="font-size:13px;color:var(--text-dim);">{{m.filePath}}</span>
<div style="display:flex;gap:8px;">
<button class="btn btn-ghost btn-sm" @click="m.fileEditor=false">取消</button>
<button class="btn btn-primary btn-sm" @click="saveFile" :disabled="fileSaving">💾 保存</button>
</div>
</div>
<textarea v-if="!m.fileBinary" id="file-editor-content" 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>
</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);
// Browser
const browsePath=ref('/data/compose'),currentPath=ref(''),dirItems=ref([]),dirLoading=ref(false),dirError=ref('');
const mounts=ref([]);
const fileSaving=ref(false);
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,fileEditor:false,filePath:'',fileName:'',fileContent:'',fileBinary:false,fileBinaryMsg:''});
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');}}
// 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':'🐳','git':'📦'};return icons[ext]||'📄';}
async function openFile(item){m.value.filePath=item.path;m.value.fileName=item.name;m.value.fileContent='加载中...';m.value.fileBinary=false;showToast('正在加载文件...','info',1000);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;}
watch(()=>view.value,(v)=>{if(v==='images'){loadImages();loadMirrors();}if(v==='browser'){loadMounts();if(!browsePath.value)browsePath.value='/data/compose';loadDir();}});
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,browsePath,currentPath,dirItems,dirLoading,dirError,mounts,pathSegments,navigateTo,goUp,getFileIcon,openFile,saveFile,loadDir,loadMounts};
}
}).mount('#app');
</script>
</body>
</html>