561 lines
43 KiB
HTML
561 lines
43 KiB
HTML
<!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>
|