468 lines
40 KiB
HTML
468 lines
40 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Hardware Monitor</title>
|
|
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/ant-design-vue/1.7.8/antd.min.css">
|
|
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.7.16/vue.min.js"></script>
|
|
<script src="https://cdn.bootcdn.net/ajax/libs/ant-design-vue/1.7.8/antd.min.js"></script>
|
|
<style>
|
|
:root {
|
|
--bg: #0a0d13; --card: #111520; --sec: #161b28; --border: #1e253a;
|
|
--accent: #00e5a0; --warn: #ffb020; --danger: #ff4060;
|
|
--text: #e2e6ef; --dim: #5a627a; --dim-bar: #1e2336;
|
|
--mono: 'JetBrains Mono','Menlo',monospace;
|
|
}
|
|
* { margin:0; padding:0; box-sizing:border-box }
|
|
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); min-height:100vh }
|
|
::-webkit-scrollbar { width:5px } ::-webkit-scrollbar-track { background:var(--bg) } ::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px }
|
|
|
|
.login-overlay { position:fixed; inset:0; z-index:200; display:flex; align-items:center; justify-content:center; background:rgba(10,13,19,.97); backdrop-filter:blur(30px) }
|
|
.login-box { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:32px 28px; width:360px; max-width:90vw }
|
|
.login-title { font-size:13px; font-weight:700; letter-spacing:2px; color:var(--accent); text-align:center; margin-bottom:4px }
|
|
.login-sub { font-size:12px; color:var(--dim); text-align:center; margin-bottom:20px }
|
|
.login-box .ant-input,.login-box .ant-input-password .ant-input { background:var(--sec); border-color:var(--border); color:var(--text); font-family:var(--mono); font-size:12px }
|
|
.login-box .ant-input:focus { border-color:var(--accent) }
|
|
.login-box .ant-input::placeholder { color:var(--dim) }
|
|
.login-box .ant-btn-primary { background:var(--accent); border-color:var(--accent); color:var(--bg); font-weight:600; letter-spacing:1px; margin-top:16px }
|
|
.login-box .ant-btn-primary:hover { background:#00cc8e; border-color:#00cc8e }
|
|
.login-box .ant-form-item-label > label { color:var(--dim); font-size:11px }
|
|
.login-err { color:var(--danger); font-size:11px; text-align:center; margin-top:10px; min-height:16px }
|
|
.login-conn { color:var(--dim); font-size:10px; text-align:center; margin-top:8px }
|
|
|
|
.app-header { position:sticky; top:0; z-index:100; display:flex; align-items:center; justify-content:space-between; padding:0 24px; height:44px; background:rgba(10,13,19,.92); backdrop-filter:blur(20px); border-bottom:1px solid var(--border) }
|
|
.app-header h1 { font-size:13px; font-weight:700; letter-spacing:2px; color:var(--accent) }
|
|
.hmid { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--dim) }
|
|
.dot { width:6px; height:6px; border-radius:50%; flex-shrink:0; transition:background .3s,box-shadow .3s }
|
|
.dot.on { background:var(--accent); box-shadow:0 0 5px var(--accent) }
|
|
.dot.off { background:var(--danger); box-shadow:0 0 5px var(--danger) }
|
|
#clock { font-family:var(--mono); font-size:11px; color:var(--dim) }
|
|
|
|
.dashboard { padding:14px 20px 0 }
|
|
.dash-card { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:12px 16px; height:100% }
|
|
.dash-title { font-size:9px; font-weight:600; letter-spacing:1.2px; text-transform:uppercase; color:var(--dim); margin-bottom:8px }
|
|
.dash-body { display:flex; align-items:center; gap:16px }
|
|
.dash-donut { width:56px; height:56px; flex-shrink:0 }
|
|
.dash-d-track { fill:none; stroke:var(--dim-bar); stroke-width:10 }
|
|
.dash-d-fill { fill:none; stroke-width:10; stroke-linecap:round; transform:rotate(-90deg); transform-origin:center; transition:stroke-dashoffset .6s ease }
|
|
.dash-d-val { font-family:var(--mono); font-size:12px; font-weight:700; fill:var(--text) }
|
|
.dash-stats { display:flex; gap:14px }
|
|
.dash-stat { display:flex; flex-direction:column; align-items:center; gap:1px }
|
|
.dash-num { font-family:var(--mono); font-size:16px; font-weight:700 }
|
|
.dash-num.on { color:var(--accent) } .dash-num.off { color:var(--danger) }
|
|
.dash-label { font-size:9px; color:var(--dim) }
|
|
|
|
.empty-wrap { display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:50vh; gap:12px; color:var(--dim) }
|
|
.pbox { position:relative; width:60px; height:60px }
|
|
.ring { position:absolute; inset:0; border:2px solid var(--accent); border-radius:50%; animation:pulse 2.4s ease-out infinite }
|
|
.ring:nth-child(2) { animation-delay:.7s } .ring:nth-child(3) { animation-delay:1.4s }
|
|
.pdot { position:absolute; top:50%; left:50%; width:8px; height:8px; margin:-4px; background:var(--accent); border-radius:50% }
|
|
@keyframes pulse { 0%{transform:scale(.35);opacity:1} 100%{transform:scale(2.4);opacity:0} }
|
|
|
|
.device-grid { padding:12px 20px 32px; display:grid; grid-template-columns:repeat(auto-fill,minmax(420px,1fr)); gap:12px }
|
|
.device-grid > .device-card { min-width:0 }
|
|
.device-card { background:var(--card); border:1px solid var(--border); border-radius:10px; transition:opacity .3s,border-color .2s,box-shadow .2s }
|
|
.device-card:hover { border-color:#28304a; box-shadow:0 4px 24px rgba(0,0,0,.2) }
|
|
.device-card.offline { opacity:.55 } .device-card.offline:hover { opacity:.75 }
|
|
.dc-hd { display:flex; align-items:center; justify-content:space-between; padding:8px 14px 0; gap:6px }
|
|
.dc-name { font-size:12px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
|
|
.dc-time { font-size:9px; color:var(--dim); font-family:var(--mono); flex-shrink:0 }
|
|
.dc-sub { padding:1px 14px 0; font-size:9px; color:var(--dim) }
|
|
.dc-bd { padding:5px 0 0 }
|
|
.dc-col { padding:0 12px 8px; flex:1 1 0; min-width:0; display:flex; flex-direction:column }
|
|
.dc-left { border-right:1px solid var(--border) }
|
|
|
|
.sec { background:var(--sec); border-radius:6px; padding:6px 10px }
|
|
.sec-tt { font-size:8px; font-weight:600; letter-spacing:.8px; text-transform:uppercase; color:var(--dim); margin-bottom:3px }
|
|
.sec-tt .hl { color:var(--text) }
|
|
.hw-row { display:flex; align-items:center; gap:6px }
|
|
.hw-row .sec-tt { margin-bottom:0 }
|
|
.tag-on { background:rgba(0,229,160,.12); color:var(--accent); border:none; font-size:7px; padding:0 5px; line-height:16px; border-radius:8px }
|
|
.tag-off { background:rgba(255,64,96,.1); color:var(--danger); border:none; font-size:7px; padding:0 5px; line-height:16px; border-radius:8px }
|
|
.info-row { display:flex; justify-content:space-between; align-items:center; font-size:9px; line-height:1.8 }
|
|
.info-row .label { color:var(--dim) } .info-row .value { font-family:var(--mono); font-weight:500 }
|
|
|
|
.gpu-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:4px }
|
|
.gpu-block { background:rgba(0,0,0,.2); border-radius:5px; padding:5px; cursor:pointer; transition:background .2s,transform .15s }
|
|
.gpu-block:hover { background:rgba(0,229,160,.06); transform:translateY(-1px) }
|
|
.gpu-active { background:rgba(0,229,160,.12); border:1px solid var(--accent); transform:translateY(-1px) }
|
|
.gb-top { display:flex; justify-content:space-between; align-items:center; margin-bottom:3px; gap:3px }
|
|
.gb-temp { font-size:10px; font-weight:700; font-family:var(--mono) }
|
|
.gb-fan { font-size:9px; color:var(--dim); font-family:var(--mono); display:flex; align-items:center; gap:2px }
|
|
.gb-bar { position:relative; height:12px; background:var(--dim-bar); border-radius:3px; overflow:hidden; margin-bottom:2px }
|
|
.gb-fill { height:100%; border-radius:3px; transition:width .5s ease }
|
|
.gb-pct { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; font-size:7px; font-weight:700; font-family:var(--mono); color:#fff; text-shadow:0 1px 2px rgba(0,0,0,.6) }
|
|
.gb-info { display:flex; justify-content:space-between; font-size:8px; font-family:var(--mono); margin-top:2px }
|
|
|
|
.gpu-popover .ant-popover { width:100%!important; left:0!important }
|
|
.gpu-popover .ant-popover-inner { width:100%!important }
|
|
.gpu-popover .ant-popover-inner-content { background:var(--card)!important; border:1px solid var(--border); border-radius:10px; padding:10px 12px; box-shadow:0 8px 32px rgba(0,0,0,.5); box-sizing:border-box }
|
|
.gpu-popover .ant-popover-arrow { display:none }
|
|
.gpu-pop-body { min-width:300px; max-height:420px; overflow-y:auto; scroll-behavior:smooth }
|
|
.gpu-pop-row { padding:4px 6px; border-radius:5px; border:1px solid transparent; transition:background .15s,border-color .15s; margin-bottom:1px }
|
|
.gpu-pop-row:last-child { margin-bottom:0 }
|
|
.gpu-pop-row:hover { background:rgba(0,229,160,.04) }
|
|
.gpu-pop-active { background:rgba(0,229,160,.08); border-color:var(--accent) }
|
|
.gpu-pop-row-hd { display:flex; align-items:center; gap:6px; margin-bottom:2px; padding-bottom:2px; border-bottom:1px solid var(--border) }
|
|
.gpu-pop-name { font-size:10px; font-weight:600; color:var(--accent); font-family:var(--mono); white-space:nowrap }
|
|
.gpu-pop-model { font-size:9px; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:160px }
|
|
.gpu-pop-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:1px 6px; white-space:nowrap }
|
|
.gpu-pop-grid .gd-item { font-size:10px; white-space:nowrap }
|
|
.gpu-pop-grid .gd-l { font-size:9px; color:var(--dim); white-space:nowrap }
|
|
.gpu-pop-grid .gd-v { font-size:10px; color:var(--text); font-family:var(--mono); font-weight:500; white-space:nowrap }
|
|
.gpu-pop-row .info-row { font-size:10px; white-space:nowrap; line-height:1.6 }
|
|
.gpu-pop-row .info-row .label { color:var(--dim); white-space:nowrap }
|
|
.gpu-pop-row .info-row .value { color:var(--text); font-family:var(--mono); font-weight:500; white-space:nowrap }
|
|
|
|
.m-hr-row { display:flex; align-items:center; gap:5px; margin-bottom:3px }
|
|
.m-hr-row:last-child { margin-bottom:0 }
|
|
.m-hr-lbl { width:20px; text-align:right; font-family:var(--mono); font-size:8px; color:var(--dim); flex-shrink:0 }
|
|
.m-hr-bar { flex:1; height:5px; background:var(--dim-bar); border-radius:3px; overflow:hidden }
|
|
.m-hr-fill { height:100%; border-radius:3px; transition:width .4s ease }
|
|
.m-hr-val { min-width:65px; text-align:right; font-family:var(--mono); font-size:8px; font-weight:500; flex-shrink:0 }
|
|
.m-status { display:flex; align-items:center; gap:5px; font-size:9px; margin-bottom:4px }
|
|
.m-agent,.m-country { color:var(--dim); font-size:8px; font-family:var(--mono); margin-left:auto }
|
|
.m-shares { display:flex; justify-content:space-between; margin-top:4px; font-family:var(--mono); font-size:8px }
|
|
.m-sh.valid { color:var(--accent) } .m-sh.stale { color:var(--warn) } .m-sh.invalid { color:var(--danger) }
|
|
.m-times { display:flex; gap:10px; margin-top:4px; font-size:8px }
|
|
.m-times span { color:var(--dim) } .m-times .v { font-family:var(--mono) }
|
|
.chart-box { background:rgba(0,0,0,.15); border-radius:4px; padding:5px 6px; margin-top:5px; overflow:hidden }
|
|
.chart-lbl { font-size:7px; color:var(--dim); font-family:var(--mono); margin-bottom:3px }
|
|
.chart-empty { color:var(--dim); font-size:8px; text-align:center; padding:10px 0 }
|
|
|
|
.off-actions { display:flex; justify-content:flex-end; padding-top:0 }
|
|
.btn-del { padding:2px 6px; border:1px solid var(--border); border-radius:4px; background:0; color:var(--dim); font-size:8px; cursor:pointer; transition:all .2s }
|
|
.btn-del:hover { border-color:var(--danger); color:var(--danger) }
|
|
.sec-div { grid-column:1/-1; display:flex; align-items:center; gap:12px; padding:12px 0 2px; font-size:10px; font-weight:600; letter-spacing:1.2px; text-transform:uppercase; color:var(--dim) }
|
|
.sec-div::before,.sec-div::after { content:''; flex:1; height:1px; background:linear-gradient(to right,transparent,var(--border),transparent) }
|
|
|
|
.ant-tag { border:none!important }
|
|
.ant-progress-text { color:var(--text)!important; font-family:var(--mono)!important; font-size:11px!important }
|
|
.ant-progress-status-success .ant-progress-text { color:var(--text)!important }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
<div class="login-overlay" v-if="!authed">
|
|
<div class="login-box">
|
|
<div class="login-title">SYSTEM MONITOR</div>
|
|
<div class="login-sub">输入 Miner UID 和密码以登录</div>
|
|
<a-form :form="loginForm" @submit.prevent="handleLogin">
|
|
<a-form-item><a-input v-decorator="['miner_id',{rules:[{required:true,message:'请输入 Miner UID'}]}]" placeholder="Miner UID" size="small" /></a-form-item>
|
|
<a-form-item><a-input-password v-decorator="['password',{rules:[{required:true,message:'请输入密码'}]}]" placeholder="password" size="small" /></a-form-item>
|
|
<a-button type="primary" block size="small" html-type="submit" :loading="loginLoading">登 录</a-button>
|
|
</a-form>
|
|
<div class="login-err">{{ loginErr }}</div>
|
|
<div class="login-conn">{{ loginConn }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<template v-if="authed">
|
|
<div class="app-header">
|
|
<h1>SYSTEM MONITOR</h1>
|
|
<div class="hmid">
|
|
<span class="dot" :class="wsOnline?'on':'off'"></span>
|
|
<span>{{ wsOnline ? '已连接' : '已断开' }}</span>
|
|
<span style="color:#28304a">│</span>
|
|
<span>{{ deviceCount }} 台设备 · {{ onlineCount }} 在线</span>
|
|
</div>
|
|
<span id="clock">{{ clock }}</span>
|
|
</div>
|
|
|
|
<div class="dashboard" v-if="deviceCount > 0">
|
|
<a-row :gutter="[12,12]">
|
|
<a-col :xs="24" :sm="24" :md="12">
|
|
<div class="dash-card">
|
|
<div class="dash-title">硬件状态</div>
|
|
<div class="dash-body">
|
|
<svg class="dash-donut" viewBox="0 0 100 100">
|
|
<circle class="dash-d-track" cx="50" cy="50" r="42"/>
|
|
<circle class="dash-d-fill" cx="50" cy="50" r="42" :stroke-dasharray="263.89" :stroke-dashoffset="263.89*(1-(onlineCount/deviceCount))" stroke="#00e5a0"/>
|
|
<circle class="dash-d-fill" cx="50" cy="50" r="42" :stroke-dasharray="263.89" :stroke-dashoffset="263.89*(1-(onlineCount/deviceCount)-(deviceCount-onlineCount)/deviceCount)" stroke="#ff4060"/>
|
|
<text class="dash-d-val" x="50" y="48" text-anchor="middle" dominant-baseline="middle">{{ deviceCount>0?Math.round(onlineCount/deviceCount*100):0 }}%</text>
|
|
<text x="50" y="62" text-anchor="middle" fill="#5a627a" font-size="8" font-family="var(--mono)">在线率</text>
|
|
</svg>
|
|
<div class="dash-stats">
|
|
<div class="dash-stat"><span class="dash-num">{{ deviceCount }}</span><span class="dash-label">总计</span></div>
|
|
<div class="dash-stat"><span class="dash-num on">{{ onlineCount }}</span><span class="dash-label">在线</span></div>
|
|
<div class="dash-stat"><span class="dash-num off">{{ deviceCount - onlineCount }}</span><span class="dash-label">离线</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</a-col>
|
|
<a-col :xs="24" :sm="24" :md="12">
|
|
<div class="dash-card">
|
|
<div class="dash-title">矿池状态</div>
|
|
<div class="dash-body">
|
|
<svg class="dash-donut" viewBox="0 0 100 100">
|
|
<circle class="dash-d-track" cx="50" cy="50" r="42"/>
|
|
<circle class="dash-d-fill" cx="50" cy="50" r="42" :stroke-dasharray="263.89" :stroke-dashoffset="263.89*(1-(poolOnline/deviceCount))" stroke="#00e5a0"/>
|
|
<text class="dash-d-val" x="50" y="48" text-anchor="middle" dominant-baseline="middle">{{ deviceCount>0?Math.round(poolOnline/deviceCount*100):0 }}%</text>
|
|
<text x="50" y="62" text-anchor="middle" fill="#5a627a" font-size="8" font-family="var(--mono)">在线率</text>
|
|
</svg>
|
|
<div class="dash-stats">
|
|
<div class="dash-stat"><span class="dash-num">{{ deviceCount }}</span><span class="dash-label">总计</span></div>
|
|
<div class="dash-stat"><span class="dash-num on">{{ poolOnline }}</span><span class="dash-label">在线</span></div>
|
|
<div class="dash-stat"><span class="dash-num off">{{ deviceCount - poolOnline }}</span><span class="dash-label">离线</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</a-col>
|
|
</a-row>
|
|
</div>
|
|
|
|
<div class="empty-wrap" v-if="deviceCount === 0">
|
|
<div class="pbox"><div class="ring"></div><div class="ring"></div><div class="ring"></div><div class="pdot"></div></div>
|
|
<h2 style="font-weight:500;font-size:14px">等待设备连接</h2>
|
|
<p style="font-size:12px">启动客户端程序以开始监控</p>
|
|
</div>
|
|
|
|
<div class="device-grid" v-if="deviceCount > 0">
|
|
<!-- 全部在线 -->
|
|
<template v-for="name in onlineDevices">
|
|
<div class="device-card" :key="name">
|
|
<div class="dc-hd"><span class="dc-name">{{ name }}</span><span class="dc-time">{{ ago(devices[name].info.last_seen) }}</span></div>
|
|
<div class="dc-sub" v-if="devices[name].info.motherboard && (devices[name].info.motherboard.manufacturer || devices[name].info.motherboard.product)">{{ devices[name].info.motherboard.manufacturer }} {{ devices[name].info.motherboard.product }}</div>
|
|
<a-row :gutter="[12,0]" type="flex" class="dc-bd">
|
|
<a-col :xs="24" :sm="24" :md="12" class="dc-col dc-left" style="flex:1 1 0">
|
|
<div class="sec"><div class="hw-row"><span class="sec-tt">硬件状态</span><span class="dot on"></span><span class="tag-on">在线</span></div></div>
|
|
<cpu-section :cpu="getLive(name).cpu||{}" :info="devices[name].info" :is-on="true"></cpu-section>
|
|
<mem-section :mem="getLive(name).memory||{}" :is-on="true"></mem-section>
|
|
<gpu-section :gpus="getLive(name).gpus||[]" :expanded-gpu="expandedGpu" @toggle-gpu="toggleGpu"></gpu-section>
|
|
</a-col>
|
|
<a-col :xs="0" :sm="0" :md="12" class="dc-col dc-right" style="flex:1 1 0">
|
|
<div class="sec"><div class="hw-row"><span class="sec-tt">矿池状态</span><span class="dot on"></span><span class="tag-on">在线</span></div></div>
|
|
<mining-section :mining="devices[name].mining" :history="devices[name].hash_history"></mining-section>
|
|
</a-col>
|
|
</a-row>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- 仅硬件在线 -->
|
|
<template v-for="name in hwOnlyDevices">
|
|
<div class="sec-div" :key="'sep-hw-'+name" v-if="name===hwOnlyDevices[0] && onlineDevices.length>0">{{ hwOnlyDevices.length }} 台仅硬件在线</div>
|
|
<div class="device-card" :key="name">
|
|
<div class="dc-hd"><span class="dc-name">{{ name }}</span><span class="dc-time">{{ ago(devices[name].info.last_seen) }}</span></div>
|
|
<div class="dc-sub" v-if="devices[name].info.motherboard && (devices[name].info.motherboard.manufacturer || devices[name].info.motherboard.product)">{{ devices[name].info.motherboard.manufacturer }} {{ devices[name].info.motherboard.product }}</div>
|
|
<div class="dc-bd" style="padding:4px 14px 8px">
|
|
<cpu-section :cpu="getLive(name).cpu||{}" :info="devices[name].info" :is-on="true"></cpu-section>
|
|
<mem-section :mem="getLive(name).memory||{}" :is-on="true"></mem-section>
|
|
<gpu-section :gpus="getLive(name).gpus||[]" :expanded-gpu="expandedGpu" @toggle-gpu="toggleGpu"></gpu-section>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- 仅矿池在线 -->
|
|
<template v-for="name in poolOnlyDevices">
|
|
<div class="sec-div" :key="'sep-pool-'+name" v-if="name===poolOnlyDevices[0]">{{ poolOnlyDevices.length }} 台仅矿池在线</div>
|
|
<div class="device-card offline" :key="name">
|
|
<div class="dc-hd"><span class="dc-name">{{ name }}</span><span class="dc-time">{{ ago(devices[name].info.last_seen) }}</span></div>
|
|
<div class="dc-bd" style="padding:4px 14px 8px">
|
|
<mining-section :mining="devices[name].mining" :history="devices[name].hash_history"></mining-section>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- 完全离线 -->
|
|
<template v-for="name in fullOffDevices">
|
|
<div class="sec-div" :key="'sep-off-'+name" v-if="name===fullOffDevices[0]">{{ fullOffDevices.length }} 台离线设备</div>
|
|
<div class="device-card offline" :key="name">
|
|
<div class="dc-hd">
|
|
<span class="dc-name">{{ name }}</span>
|
|
<span class="dc-time">{{ ago(devices[name].info.last_seen) }}</span>
|
|
</div>
|
|
<div class="dc-bd" style="padding:4px 14px 8px;display:flex;align-items:center;justify-content:space-between">
|
|
<div style="display:flex;align-items:center;gap:6px">
|
|
<span class="sec-tt" style="margin-bottom:0">矿池状态</span>
|
|
<span class="dot off"></span>
|
|
<span class="tag-off">离线</span>
|
|
</div>
|
|
<button class="btn-del" @click="deleteDevice(name)">删除</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<script>
|
|
Vue.use(antd);
|
|
var C=263.89;
|
|
function pColor(p){ return p>=80?'#ff4060':p>=60?'#ffb020':'#00e5a0' }
|
|
function fmtB(b){ if(!b||b<=0)return'0 B'; var u=['B','KB','MB','GB','TB'],i=0,v=b; while(v>=1024&&i<u.length-1){v/=1024;i++} return v.toFixed(1)+' '+u[i] }
|
|
function fmtHR(v){ if(!v||v<=0)return'0 H/s'; if(v>=1e12)return(v/1e12).toFixed(2)+' TH/s'; if(v>=1e9)return(v/1e9).toFixed(2)+' GH/s'; if(v>=1e6)return(v/1e6).toFixed(2)+' MH/s'; if(v>=1e3)return(v/1e3).toFixed(2)+' KH/s'; return v.toFixed(0)+' H/s' }
|
|
function fmtMsTs(ms){ if(!ms||ms<=0)return'—'; var d=new Date(ms); if(isNaN(d))return'—'; function p(n){return String(n).padStart(2,'0')} return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes()) }
|
|
function ago(iso){ if(!iso)return'—'; var d=new Date(iso); if(isNaN(d))return'—'; var s=(Date.now()-d.getTime())/1000; if(s<0)return'刚刚'; if(s<60)return Math.floor(s)+'秒前'; if(s<3600)return Math.floor(s/60)+'分钟前'; if(s<86400)return Math.floor(s/3600)+'小时前'; return Math.floor(s/86400)+'天前' }
|
|
|
|
Vue.component('cpu-section',{
|
|
props:['cpu','info','isOn'],
|
|
template:`<div class="sec">
|
|
<div class="sec-tt">CPU · <span class="hl">{{ model }}</span></div>
|
|
<div class="info-row"><span class="label">核心/线程</span><span class="value">{{ pc }} / {{ lc }}</span>
|
|
<span class="label" style="margin-left:auto">温度</span><span class="value" :style="{color:tempColor,marginLeft:'4px'}">{{ tmpStr }}</span>
|
|
<span class="label" style="margin-left:8px">频率</span><span class="value" style="margin-left:4px">{{ freq }} MHz</span></div>
|
|
<a-progress :percent="pct" :stroke-color="barColor" :stroke-width="5" size="small" :format="fmtPct" />
|
|
</div>`,
|
|
computed:{
|
|
model:function(){return this.cpu.model||(this.info&&this.info.cpu_model)||'Unknown'},
|
|
pc:function(){return this.cpu.cores_physical||(this.info&&this.info.cpu_cores_physical)||'?'},
|
|
lc:function(){return this.cpu.cores_logical||(this.info&&this.info.cpu_cores_logical)||'?'},
|
|
pct:function(){return Math.min(100,Math.round(this.cpu.usage_total||0))},
|
|
freq:function(){return Math.round(this.cpu.freq_current_mhz||0)},
|
|
tmp:function(){return this.cpu.temperature||0},
|
|
barColor:function(){return pColor(this.pct)},
|
|
tempColor:function(){var t=this.tmp;return t>=85?'#ff4060':t>=70?'#ffb020':'#e2e6ef'},
|
|
tmpStr:function(){var t=this.tmp;return t>0?t.toFixed(1)+'°C':'N/A'}
|
|
},
|
|
methods:{fmtPct:function(v){return v+'%'}}
|
|
});
|
|
|
|
Vue.component('mem-section',{
|
|
props:['mem','isOn'],
|
|
template:`<div class="sec"><div class="sec-tt">MEMORY</div>
|
|
<div v-if="mem.total_bytes"><div class="info-row"><span class="label">{{ used }} / {{ total }}</span><span class="value" :style="{color:barColor}">{{ pct }}%</span></div>
|
|
<a-progress :percent="pct" :stroke-color="barColor" :stroke-width="5" size="small" :format="fmtPct" /></div>
|
|
<div v-else style="font-size:9px;color:var(--dim)">无数据</div></div>`,
|
|
computed:{total:function(){return fmtB(this.mem.total_bytes)},used:function(){return fmtB(this.mem.used_bytes)},pct:function(){return Math.round(this.mem.percent||0)},barColor:function(){return pColor(this.pct)}},
|
|
methods:{fmtPct:function(v){return v+'%'}}
|
|
});
|
|
|
|
Vue.component('gpu-section',{
|
|
props:['gpus','expandedGpu'],
|
|
template:`<div class="sec"><div class="sec-tt">GPU · <span class="hl">{{ gpus.length }} 张</span></div>
|
|
<div class="gpu-grid"><div class="gpu-block" :class="{'gpu-active':expandedGpu===i}" v-for="(g,i) in gpus" :key="i">
|
|
<a-popover trigger="click" placement="bottom" :visible="expandedGpu===i" @visibleChange="onVis(i,$event)" overlayClassName="gpu-popover" :getPopupContainer="getPopContainer">
|
|
<template slot="content"><div class="gpu-pop-body">
|
|
<div v-for="(gg,ii) in gpus" :key="ii" class="gpu-pop-row" :class="{'gpu-pop-active':expandedGpu===ii}" :id="'gpu-pop-'+ii">
|
|
<div class="gpu-pop-row-hd"><span class="gpu-pop-name">GPU{{ ii+1 }}</span><span class="gpu-pop-model">{{ gg.name||'N/A' }}</span></div>
|
|
<div class="gpu-pop-grid">
|
|
<div class="gd-item"><span class="gd-l">负载</span><span class="gd-v" :style="{color:pColor(gg.load_percent||0)}">{{ (gg.load_percent||0).toFixed(1) }}%</span></div>
|
|
<div class="gd-item"><span class="gd-l">温度</span><span class="gd-v" :style="{color:tmpColor(gg)}">{{ tmpStr(gg) }}</span></div>
|
|
<div class="gd-item"><span class="gd-l">功耗</span><span class="gd-v">{{ Math.round(gg.power||0) }} W</span></div>
|
|
<div class="gd-item"><span class="gd-l">核心频率</span><span class="gd-v">{{ Math.round(gg.core_clock_mhz||0) }} MHz</span></div>
|
|
<div class="gd-item"><span class="gd-l">显存频率</span><span class="gd-v">{{ Math.round(gg.mem_clock_mhz||0) }} MHz</span></div>
|
|
<div class="gd-item"><span class="gd-l">风扇</span><span class="gd-v">{{ fanStr(gg) }}</span></div>
|
|
</div>
|
|
<div v-if="vramTotal(gg)>0" style="margin-top:2px"><div class="info-row" style="margin-bottom:1px"><span class="label">显存</span><span class="value">{{ vramUsed(gg) }} / {{ vramTotal(gg) }} GB</span></div>
|
|
<a-progress :percent="Math.round(vramPct(gg))" :stroke-color="pColor(vramPct(gg))" :stroke-width="4" size="small" :format="fmtPct" /></div>
|
|
</div>
|
|
</div></template>
|
|
<div @click.stop="toggle(i)">
|
|
<div class="gb-top"><span class="gb-temp" :style="{color:tmpColor(g)}">{{ tmpStr(g) }}</span>
|
|
<span class="gb-fan"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4m0 12v4m-7.07-3.93l2.83-2.83m8.48-8.48l2.83-2.83M2 12h4m12 0h4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83"/></svg>{{ fanStr(g) }}</span></div>
|
|
<div class="gb-bar"><div class="gb-fill" :style="{width:(g.load_percent||0).toFixed(0)+'%',background:pColor(g.load_percent||0)}"></div><span class="gb-pct">{{ (g.load_percent||0).toFixed(0) }}%</span></div>
|
|
<div class="gb-info"><span :style="{color:pColor(vramPct(g))}">{{ vramPct(g).toFixed(0) }}%</span><span style="color:var(--dim)">{{ vramStr(g) }}</span></div>
|
|
</div>
|
|
</a-popover>
|
|
</div></div></div>`,
|
|
methods:{
|
|
pColor:pColor, tmpStr:function(g){var t=(g||{}).temperature||0;return t>0?t.toFixed(0)+'°':'—'},
|
|
tmpColor:function(g){var t=(g||{}).temperature||0;return t>=85?'#ff4060':t>=70?'#ffb020':'#e2e6ef'},
|
|
vramPct:function(g){var t=(g||{}).vram_total||(g||{}).vram_total_mb||0,u=(g||{}).vram_used||(g||{}).vram_used_mb||0;return t>0?u/t*100:0},
|
|
vramTotal:function(g){var t=(g||{}).vram_total||(g||{}).vram_total_mb||0;return t>0?(t/1024).toFixed(1):0},
|
|
vramUsed:function(g){var u=(g||{}).vram_used||(g||{}).vram_used_mb||0;return u>0?(u/1024).toFixed(1):0},
|
|
vramStr:function(g){var t=(g||{}).vram_total||(g||{}).vram_total_mb||0,u=(g||{}).vram_used||(g||{}).vram_used_mb||0;return t>0?(u/1024).toFixed(1)+'/'+(t/1024).toFixed(0)+'G':'—'},
|
|
fanStr:function(g){var f=(g||{}).fan_speed||0;return f>0?Math.round(f)+'%':'—'},
|
|
fmtPct:function(v){return v+'%'},
|
|
getPopContainer:function(){return this.$el.closest('.device-card')||document.body},
|
|
toggle:function(i){var next=this.expandedGpu===i?null:i;this.$emit('toggle-gpu',next);
|
|
if(next!==null){var self=this;this.$nextTick(function(){setTimeout(function(){var el=document.getElementById('gpu-pop-'+next);if(el){var c=document.querySelector('.gpu-pop-body');if(c){c.scrollTop=el.offsetTop-c.clientHeight/2+el.clientHeight/2}}},50)})}},
|
|
onVis:function(i,vis){if(!vis&&this.expandedGpu===i)this.$emit('toggle-gpu',null)}
|
|
}
|
|
});
|
|
|
|
Vue.component('mining-section',{
|
|
props:['mining','history'],
|
|
template:`<div v-if="mining"><div class="sec">
|
|
<div class="sec-tt">MINING · <span class="hl">{{ coin }}</span></div>
|
|
<div class="m-status"><span class="dot" :class="sOn?'on':'off'"></span>
|
|
<span :style="{color:sOn?'#00e5a0':'#ff4060',fontWeight:500,fontSize:'9px'}">{{ sOn?'在线':'离线' }}</span>
|
|
<span class="m-agent">{{ mining.agent||'' }}</span><span class="m-country" v-if="mining.country">{{ mining.country }}</span></div>
|
|
<div v-for="hr in hrs" :key="hr.l" class="m-hr-row"><span class="m-hr-lbl">{{ hr.l }}</span>
|
|
<div class="m-hr-bar"><div class="m-hr-fill" :style="{width:hr.p+'%',background:pColor(hr.p)}"></div></div>
|
|
<span class="m-hr-val" :style="{color:pColor(hr.p)}">{{ hr.v }}</span></div>
|
|
<div class="m-times"><span>活跃 <span class="v">{{ fmtMsTs(mining.last_active) }}</span></span>
|
|
<span>开始 <span class="v">{{ fmtMsTs(mining.opened_at) }}</span></span>
|
|
<span v-if="durH">运行 <span class="v">{{ durH }} h</span></span></div>
|
|
<div class="m-shares"><span class="m-sh valid">{{ mining.valid||0 }} 有效</span>
|
|
<span class="m-sh stale">{{ mining.stale||0 }} 过期</span><span class="m-sh invalid">{{ mining.invalid||0 }} 无效</span></div>
|
|
<div class="chart-box"><div class="chart-lbl">算力走势 (30m 均值)</div><div v-html="chartHtml"></div></div>
|
|
</div></div><div v-else class="sec"><div class="sec-tt">MINING</div><div style="color:var(--dim);font-size:9px;text-align:center;padding:10px 0">暂无矿池数据</div></div>`,
|
|
computed:{
|
|
coin:function(){return((this.mining||{}).coin||'').toUpperCase()},
|
|
sOn:function(){return(this.mining||{}).status==='online'},
|
|
hrs:function(){var m=this.mining||{},mx=Math.max(m.hashrate_30m||0,m.hashrate_3h||0,m.hashrate_24h||0)||1;return[{l:'30m',p:Math.min(100,(m.hashrate_30m||0)/mx*100),v:fmtHR(m.hashrate_30m||0)},{l:'3h',p:Math.min(100,(m.hashrate_3h||0)/mx*100),v:fmtHR(m.hashrate_3h||0)},{l:'24h',p:Math.min(100,(m.hashrate_24h||0)/mx*100),v:fmtHR(m.hashrate_24h||0)}]},
|
|
durH:function(){var m=this.mining||{};if(m.last_active>0&&m.opened_at>0)return((m.last_active-m.opened_at)/3600000).toFixed(1);return''},
|
|
chartHtml:function(){return buildChartSvg(this.history)}
|
|
},
|
|
methods:{pColor:pColor,fmtMsTs:fmtMsTs}
|
|
});
|
|
|
|
function buildChartSvg(hist){
|
|
if(!hist||hist.length<2)return'<div class="chart-empty">数据采集中 ('+((hist&&hist.length)||0)+'/2)...</div>';
|
|
var W=420,H=50,pad={t:7,b:9,l:38,r:5},cW=W-pad.l-pad.r,cH=H-pad.t-pad.b;
|
|
var vals=[],i;for(i=0;i<hist.length;i++)vals.push(hist[i].v);
|
|
var lo=vals[0],hi=vals[0];for(i=1;i<vals.length;i++){if(vals[i]<lo)lo=vals[i];if(vals[i]>hi)hi=vals[i]}
|
|
var rng=hi-lo||1;lo-=rng*.1;hi+=rng*.1;if(hi<=lo){lo-=1;hi+=1}
|
|
var step=cW/(hist.length-1),pts=[];for(i=0;i<vals.length;i++)pts.push({x:pad.l+i*step,y:pad.t+cH*(1-(vals[i]-lo)/(hi-lo))});
|
|
var line='';for(i=0;i<pts.length;i++)line+=(i?'L':'M')+pts[i].x.toFixed(1)+','+pts[i].y.toFixed(1)+' ';
|
|
var area=line+'L'+pts[pts.length-1].x.toFixed(1)+','+H+' L'+pts[0].x.toFixed(1)+','+H+' Z';
|
|
var gid='g'+(Math.random()*1e6|0),last=pts[pts.length-1];
|
|
function fT(d){return String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')}
|
|
var t0=new Date(hist[0].t),tN=new Date(hist[hist.length-1].t);
|
|
return '<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:'+H+'px;display:block"><defs><linearGradient id="'+gid+'" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#00e5a0" stop-opacity=".2"/><stop offset="100%" stop-color="#00e5a0" stop-opacity=".01"/></linearGradient></defs>'
|
|
+'<path d="'+area+'" fill="url(#'+gid+')"/><path d="'+line+'" fill="none" stroke="#00e5a0" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/><circle cx="'+last.x+'" cy="'+last.y+'" r="2.5" fill="#00e5a0" stroke="#111520" stroke-width="1"/>'
|
|
+'<text x="'+(pad.l-3)+'" y="'+(pad.t+2)+'" fill="#5a627a" font-size="6" font-family="var(--mono)" text-anchor="end">'+fmtHR(hi)+'</text>'
|
|
+'<text x="'+(pad.l-3)+'" y="'+(H-pad.b+2)+'" fill="#5a627a" font-size="6" font-family="var(--mono)" text-anchor="end">'+fmtHR(lo)+'</text>'
|
|
+'<text x="'+pad.l+'" y="'+(H-1)+'" fill="#5a627a" font-size="6" font-family="var(--mono)">'+fT(t0)+'</text>'
|
|
+'<text x="'+(W-pad.r)+'" y="'+(H-1)+'" fill="#5a627a" font-size="6" font-family="var(--mono)" text-anchor="end">'+fT(tN)+'</text></svg>';
|
|
}
|
|
|
|
new Vue({
|
|
el:'#app',
|
|
data:function(){return{loginForm:null,loginLoading:false,loginErr:'',loginConn:'',authed:false,wsOnline:false,devices:{},expandedGpu:null,clock:'',ws:null}},
|
|
created:function(){this.loginForm=this.$form.createForm(this)},
|
|
computed:{
|
|
deviceCount:function(){return Object.keys(this.devices).length},
|
|
onlineCount:function(){var c=0,d=this.devices;for(var n in d)if(d[n]&&d[n].online)c++;return c},
|
|
poolOnline:function(){var c=0,d=this.devices;for(var n in d)if(d[n]&&d[n].mining&&d[n].mining.status==='online')c++;return c},
|
|
onlineDevices:function(){var r=[],d=this.devices;for(var n in d)if(d[n]&&d[n].online&&this.isPoolOnline(n))r.push(n);r.sort();return r},
|
|
hwOnlyDevices:function(){var r=[],d=this.devices;for(var n in d)if(d[n]&&d[n].online&&!this.isPoolOnline(n))r.push(n);r.sort();return r},
|
|
poolOnlyDevices:function(){var r=[],d=this.devices;for(var n in d)if(d[n]&&!d[n].online&&this.isPoolOnline(n))r.push(n);r.sort();return r},
|
|
fullOffDevices:function(){var r=[],d=this.devices;for(var n in d)if(d[n]&&!d[n].online&&!this.isPoolOnline(n))r.push(n);
|
|
r.sort(function(a,b){var ta=(d[b]&&d[b].info&&d[b].info.last_seen)||'';var tb=(d[a]&&d[a].info&&d[a].info.last_seen)||'';return ta.localeCompare(tb)});return r}
|
|
},
|
|
methods:{
|
|
getLive:function(name){var e=this.devices[name];if(!e||!e.live)return{};var l=e.live;if(typeof l==='string')try{l=JSON.parse(l)}catch(ex){}return l||{}},
|
|
isPoolOnline:function(name){var e=this.devices[name];return e&&e.mining&&e.mining.status==='online'},
|
|
ago:ago, toggleGpu:function(i){this.expandedGpu=i},
|
|
deleteDevice:function(n){var self=this;this.$confirm({title:'确定删除 '+n+' 的设备记录?',onOk:function(){if(self.ws&&self.ws.readyState===1)self.ws.send(JSON.stringify({action:'delete',machine:n}))}})},
|
|
handleLogin:function(e){e.preventDefault();var self=this;this.loginForm.validateFields(function(err,values){if(err)return;self.loginLoading=true;self.loginErr='';self.loginConn='';localStorage.setItem('mm_id',values.miner_id);localStorage.setItem('mm_pw',values.password);self.connectWs(values.miner_id,values.password)})},
|
|
sendAuth:function(mid,pw){if(this.ws&&this.ws.readyState===WebSocket.OPEN)this.ws.send(JSON.stringify({type:'auth',miner_id:mid,password:pw}))},
|
|
connectWs:function(mid,pw){
|
|
var self=this;var p=location.protocol==='https:'?'wss:':'ws:';
|
|
this.ws=new WebSocket(p+'//'+location.host+'/ws/monitor');
|
|
this.ws.onopen=function(){self.loginConn='';setTimeout(function(){if(!self.authed)self.sendAuth(mid,pw)},1500)};
|
|
this.ws.onmessage=function(e){try{self.handleMsg(JSON.parse(e.data))}catch(err){console.error(err)}};
|
|
this.ws.onclose=function(){if(self.authed){self.wsOnline=false;self.rt=setTimeout(function(){self.connectWs(mid,pw)},3000)}else{self.loginConn='连接已断开';self.loginLoading=false}};
|
|
this.ws.onerror=function(){self.ws.close()};
|
|
},
|
|
handleMsg:function(msg){
|
|
switch(msg.type){
|
|
case'auth_required':this.loginConn='服务已连接,正在认证...';var mid=this.loginForm.getFieldValue('miner_id')||localStorage.getItem('mm_id');var pw=this.loginForm.getFieldValue('password')||localStorage.getItem('mm_pw');this.sendAuth(mid,pw);break;
|
|
case'auth_ok':this.authed=true;this.loginLoading=false;break;
|
|
case'auth_fail':this.loginErr=msg.msg||'设备ID或密码错误';this.loginLoading=false;localStorage.removeItem('mm_pw');break;
|
|
case'snapshot':this.devices={};if(msg.devices)this.devices=Object.assign({},msg.devices);if(this.authed)this.wsOnline=true;break;
|
|
case'device':var prev=this.devices[msg.machine];this.$set(this.devices,msg.machine,msg.device);if(prev&&prev.hash_history&&(!msg.device.hash_history||!msg.device.hash_history.length))this.$set(this.devices[msg.machine],'hash_history',prev.hash_history);break;
|
|
case'mining':var mi=msg.mining||{};var oAt=mi.opened_at>0?new Date(mi.opened_at).toISOString():null;var lAc=mi.last_active>0?new Date(mi.last_active).toISOString():null;if(this.devices[msg.machine]){this.$set(this.devices[msg.machine],'mining',mi);if(msg.hash_history)this.$set(this.devices[msg.machine],'hash_history',msg.hash_history);var dInf=this.devices[msg.machine].info||{};if(!dInf.first_seen&&oAt)dInf.first_seen=oAt;if(!dInf.last_seen&&lAc)dInf.last_seen=lAc;this.$set(this.devices[msg.machine],'info',dInf)}else{this.$set(this.devices,msg.machine,{info:{machine_name:msg.machine,first_seen:oAt,last_seen:lAc},online:false,mining:mi,hash_history:msg.hash_history})}break;
|
|
case'remove':this.$delete(this.devices,msg.machine);this.expandedGpu=null;break;
|
|
}
|
|
}
|
|
},
|
|
mounted:function(){var self=this;setInterval(function(){self.clock=new Date().toLocaleTimeString('zh-CN',{hour12:false})},1000);var savedId=localStorage.getItem('mm_id')||'';var savedPw=localStorage.getItem('mm_pw')||'';if(savedId&&savedPw){this.$nextTick(function(){self.loginForm.setFieldsValue({miner_id:savedId,password:savedPw});self.loginLoading=true;self.connectWs(savedId,savedPw)})}}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|