831 lines
47 KiB
HTML
831 lines
47 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 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>
|