Files
BitMonitor/cmd/server/templates/index.html
T

1757 lines
64 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.staticfile.net/ant-design-vue/1.7.8/antd.min.css">
<script src="https://cdn.staticfile.net/vue/2.7.16/vue.min.js"></script>
<script src="https://cdn.staticfile.net/ant-design-vue/1.7.8/antd.min.js"></script>
<style>
:root {
--bg: #0d1117;
--card: #161b26;
--sec: #1c2233;
--border: #252d42;
--accent: #00e5a0;
--warn: #ffb020;
--danger: #ff4060;
--text: #e8ecf4;
--dim: #8a92a8;
--dim-bar: #252d42;
--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)
}
.hmid .ant-spin {
vertical-align: middle;
margin-right: 2px
}
.app-header > span:last-child,
.app-header > div:last-child {
white-space: nowrap;
display: flex;
align-items: center;
gap: 6px
}
.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: 16px;
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(450px, 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: 10px 16px 0;
gap: 6px
}
.dc-name {
font-size: 15px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap
}
.dc-time {
font-size: 12px;
color: var(--dim);
font-family: var(--mono);
flex-shrink: 0;
display: flex;
align-items: center;
gap: 4px
}
.dc-spinner {
position: relative;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center
}
.dc-spinner-txt {
position: absolute;
font-size: 8px;
font-weight: 700;
font-family: var(--mono);
color: var(--text);
z-index: 1
}
.ant-spin-dot {
color: var(--accent) !important
}
.ant-spin-text {
color: var(--text) !important;
font-size: 12px !important;
font-family: var(--mono) !important
}
.dc-sub {
padding: 1px 16px 0;
font-size: 11px;
color: var(--dim)
}
.dc-sub {
padding: 1px 16px 0;
font-size: 10px;
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)
}
@media (max-width: 767px) {
.dc-left {
border-right: none;
border-bottom: 1px solid var(--border);
margin-bottom: 8px
}
.dc-col {
flex: none !important;
width: 100%
}
.dashboard {
display: none !important
}
}
.dc-gpu-full {
padding: 0 6px 8px
}
.dc-gpu-full .sec {
margin: 0
}
.dc-mobile-mining {
display: none
}
.sec {
background: var(--sec);
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 6px;
}
.mining-sec {
background: var(--sec);
border: 1px solid var(--border)
}
.mining-sec:hover {
background: var(--sec);
border-color: var(--border)
}
.sec-tt {
font-size: 11px;
font-weight: 600;
letter-spacing: .8px;
text-transform: uppercase;
color: var(--dim);
margin-bottom: 4px
}
.sec-tt .hl {
color: var(--text);
font-size: 8px;
}
.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: 9px;
padding: 0 7px;
line-height: 20px;
border-radius: 8px
}
.tag-off {
background: rgba(255, 64, 96, .1);
color: var(--danger);
border: none;
font-size: 9px;
padding: 0 7px;
line-height: 20px;
border-radius: 8px
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
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: 6px;
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: 12px;
font-weight: 700;
font-family: var(--mono)
}
.gb-fan {
font-size: 11px;
color: var(--dim);
font-family: var(--mono);
display: flex;
align-items: center;
gap: 2px
}
.gb-bar {
position: relative;
height: 14px;
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: 8px;
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: 10px;
font-family: var(--mono);
margin-top: 2px
}
.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: 4px
}
.m-hr-row:last-child {
margin-bottom: 0
}
.m-hr-lbl {
width: 22px;
text-align: right;
font-family: var(--mono);
font-size: 9px;
color: var(--dim);
flex-shrink: 0
}
.m-hr-bar {
flex: 1;
height: 6px;
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: 70px;
text-align: right;
font-family: var(--mono);
font-size: 11px;
font-weight: 500;
flex-shrink: 0;
color: var(--text)
}
.m-status {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
margin-bottom: 5px;
color: var(--text)
}
.m-agent, .m-country {
color: var(--text);
font-size: 10px;
font-family: var(--mono);
margin-left: auto
}
.m-shares {
display: flex;
justify-content: space-between;
margin-top: 5px;
font-family: var(--mono);
font-size: 11px;
color: var(--text)
}
.m-sh.valid {
color: var(--accent)
}
.m-sh.stale {
color: var(--warn)
}
.m-sh.invalid {
color: var(--danger)
}
.m-times {
display: flex;
justify-content: space-between;
gap: 10px;
margin-top: 5px;
font-size: 10px;
color: var(--text);
}
.m-times span {
color: var(--text)
}
.m-times .v {
font-family: var(--mono);
color: var(--text)
}
.chart-box {
background: rgba(0, 0, 0, .25);
border-radius: 4px;
padding: 6px 0;
margin-top: 6px;
overflow: hidden
}
.chart-lbl {
font-size: 10px;
color: var(--text);
font-family: var(--mono);
margin-bottom: 3px;
padding-left: 6px
}
.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-progress {
display: block !important;
width: 100% !important
}
.ant-progress .ant-progress-outer {
display: block !important
}
.ant-progress .ant-progress-inner {
display: block !important
}
.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-input>
</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 :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 :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,12]" type="flex" class="dc-bd">
<a-col :xs="24" :sm="12" :md="12" class="dc-col dc-left"
style="flex:1 1 0;margin-top:8px;padding-right:8px;margin-left:8px">
<div class="sec">
<div class="hw-row"><span class="sec-tt">硬件状态</span><span
class="dot on"></span><span class="tag-on">在线</span></div>
</div>
<hw-section :cpu="getLive(name).cpu||{}" :memory="getLive(name).memory||{}"
:info="devices[name].info" :is-on="true"></hw-section>
</a-col>
<a-col :xs="24" :sm="12" :md="12" class="dc-col dc-right"
style="flex:1 1 0;margin-top:8px;padding-left:6px;margin-right: 6px;">
<div class="sec hw-status">
<div class="hw-row"><span class="sec-tt">矿池状态</span><span class="dot"
:class="isPoolOnline(name)?'on':'off'"></span><span
:class="isPoolOnline(name)?'tag-on':'tag-off'">{{ isPoolOnline(name)?'在线':'离线' }}</span><span
style="margin-left:auto;font-size:8px;color:var(--dim);font-family:var(--mono)">{{ miningRuntime(name) }} {{ miningCountry(name) }}</span>
</div>
</div>
<mining-section :mining="devices[name].mining"
:history="devices[name].hash_history"></mining-section>
</a-col>
</a-row>
<div class="dc-gpu-full" style="margin-top:4px">
<gpu-section :gpus="getLive(name).gpus||[]" :expanded-gpu="expandedGpu"
@toggle-gpu="toggleGpu"></gpu-section>
</div>
<div v-if="devices[name].hash_history && devices[name].hash_history.length > 1" class="dc-gpu-full">
<div class="sec mining-sec" style="padding:8px 12px">
<div class="chart-lbl">算力走势 (30m 均值)</div>
<div v-html="buildChartSvg(devices[name].hash_history)"></div>
</div>
</div>
<div class="dc-mobile-mining">
<a-popover trigger="click" placement="bottom" overlayClassName="gpu-popover">
<template slot="content">
<div class="gpu-pop-body" style="min-width:280px">
<mining-section :mining="devices[name].mining"
:history="devices[name].hash_history"></mining-section>
</div>
</template>
<a-button size="small" type="primary" ghost>
<a-icon type="bar-chart"/>
矿池详情
</a-button>
</a-popover>
</div>
</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">
<hw-section :cpu="getLive(name).cpu||{}" :memory="getLive(name).memory||{}"
:info="devices[name].info" :is-on="true"></hw-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('hw-section', {
props: ['cpu', 'memory', 'info', 'isOn'],
template: `<div class="sec" style="display:flex;flex-direction:column;justify-content:space-between;height:100%">
<div>
<div class="sec-tt">CPU · <span class="hl" style="font-size:8px">{{ cpuModel }}</span></div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<span class="value" style="font-size:10px;white-space:nowrap;color:var(--dim)">{{ pc }}/{{ lc }}</span>
<span class="value" :style="{color:tempColor,fontSize:'10px',whiteSpace:'nowrap'}">{{ tmpStr }}</span>
<span class="value" style="font-size:10px;white-space:nowrap;color:var(--dim)">{{ freq }}MHz</span>
<span class="value" style="margin-left:auto;font-size:10px;font-family:var(--mono);font-weight:500" :style="{color:cpuBarColor}">{{ cpuPct }}%</span>
</div>
<div style="display:block"><a-progress :percent="cpuPct" :stroke-color="cpuBarColor" :stroke-width="5" size="small" :show-info="false" /></div>
</div>
<div style="margin-top:auto">
<div class="sec-tt" style="margin-top:6px">MEMORY</div>
<div v-if="memory.total_bytes">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<span class="value" style="font-size:10px;white-space:nowrap;color:var(--dim)">{{ usedMem }} / {{ totalMem }}</span>
<span class="value" :style="{color:memBarColor,fontSize:'10px',whiteSpace:'nowrap',marginLeft:'auto'}">{{ memPct }}%</span>
</div>
<div style="display:block"><a-progress :percent="memPct" :stroke-color="memBarColor" :stroke-width="5" size="small" :show-info="false" /></div>
</div>
<div v-else style="font-size:10px;color:var(--dim)">无数据</div>
</div>
</div>`,
computed: {
cpuModel: 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) || '?'
},
cpuPct: 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
},
cpuBarColor: function () {
return pColor(this.cpuPct)
},
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'
},
totalMem: function () {
return fmtB(this.memory.total_bytes)
},
usedMem: function () {
return fmtB(this.memory.used_bytes)
},
memPct: function () {
return Math.round(this.memory.percent || 0)
},
memBarColor: function () {
return pColor(this.memPct)
}
}
});
Vue.component('gpu-section', {
props: ['gpus', 'expandedGpu'],
template: `<div class="sec"><div class="sec-tt">GPU · <span class="hl">{{ gpus.length }} 张</span><span style="margin-left:auto;font-size:9px;color:var(--text);font-family:var(--mono)">{{ totalPower }}W / {{ totalPowerMax }}W</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">{{ (gg.util_memory||0).toFixed(0) }}%</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 / {{ Math.round(gg.power_limit||0) }}W</span></div>
<div class="gd-item"><span class="gd-l">功耗范围</span><span class="gd-v">{{ Math.round(gg.power||0) }}W / {{ Math.round(gg.power_max||0) }}W</span></div>
<div class="gd-item"><span class="gd-l">风扇</span><span class="gd-v">{{ fanStr(gg) }}</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.clocks_max_graphics||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">{{ (gg.util_encoder||0).toFixed(0) }}%</span></div>
<div class="gd-item"><span class="gd-l">解码器</span><span class="gd-v">{{ (gg.util_decoder||0).toFixed(0) }}%</span></div>
<div class="gd-item"><span class="gd-l">性能状态</span><span class="gd-v">{{ gg.pstate||'N/A' }}</span></div>
<div class="gd-item"><span class="gd-l">计算模式</span><span class="gd-v">{{ gg.compute_mode||'N/A' }}</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 v-if="gg.driver_version" style="margin-top:2px;font-size:8px;color:var(--dim)">
<span>驱动: {{ gg.driver_version }}</span>
</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:var(--dim)">{{ Math.round(g.core_clock_mhz||0) }}MHz</span><span style="color:var(--text)">{{ Math.round(g.power||0) }}W</span></div>
</div>
</a-popover>
</div></div></div>`,
computed: {
totalPower: function () {
var t = 0;
for (var i = 0; i < this.gpus.length; i++) t += this.gpus[i].power || 0;
return Math.round(t)
},
totalPowerMax: function () {
var t = 0;
for (var i = 0; i < this.gpus.length; i++) t += this.gpus[i].power_max || this.gpus[i].power_limit || 0;
return Math.round(t)
}
},
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 () {
var card = this.$el.closest('.device-card');
if (card) {
var w = card.offsetWidth;
this.$nextTick(function () {
var pop = document.querySelector('.gpu-popover .ant-popover');
if (pop) pop.style.width = w + 'px';
});
}
return 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 mining-sec">
<div class="sec-tt">{{ coin }} · <span class="hl">{{ mining.agent||'' }}</span><span style="margin-left:6px;font-size:8px;color:var(--dim)"></span></div>
<div style="display:flex;gap:8px;margin:6px 0">
<div v-for="hr in hrs" :key="hr.l" style="flex:1;min-width:0;text-align:center">
<div style="font-size:8px;color:var(--dim);margin-bottom:2px">{{ hr.l }}</div>
<div style="font-size:12px;font-weight:500;font-family:var(--mono)" :style="{color:pColor(hr.p)}">{{ hr.v }}</div>
</div>
</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="m-times" style="display:grid;grid-template-columns:1fr 1fr;gap:4px 12px;font-size:10px">
<span style="color:var(--dim)">开始</span><span style="color:var(--dim)">活跃</span>
<span class="v">{{ fmtMsTs(mining.opened_at) }}</span><span class="v">{{ fmtMsTs(mining.last_active) }}</span>
</div>
</div></div><div v-else class="sec mining-sec"><div class="sec-tt" style="color:var(--dim)">暂无矿池数据</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 = 45, pad = {t: 8, b: 10, l: 42, r: 8}, 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%;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="#8a92a8" font-size="8" font-family="var(--mono)" text-anchor="end">' + fmtHR(hi) + '</text>'
+ '<text x="' + (pad.l - 3) + '" y="' + (H - pad.b + 2) + '" fill="#8a92a8" font-size="8" 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'
},
miningRuntime(name) {
var e = this.devices[name];
if (!e || !e.mining) return '';
var m = e.mining;
if (m.last_active > 0 && m.opened_at > 0) return ((m.last_active - m.opened_at) / 3600000).toFixed(1) + 'h';
return ''
},
miningCountry(name) {
var e = this.devices[name];
return e && e.mining ? e.mining.country || '' : ''
},
ago: ago,
toggleGpu: function (i) {
this.expandedGpu = i
},
buildChartSvg: buildChartSvg,
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.wsOnline = true;
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':
var k;
for (k in devices) delete devices[k];
if (msg.devices) for (k in msg.devices) devices[k] = msg.devices[k];
for (k in cards) {
cards[k].remove();
delete cards[k]
}
if (sepNode) {
sepNode.remove();
sepNode = null
}
if (authed) {
$('ws-dot').className = 'dot on';
$('ws-txt').textContent = '已连接'
}
scheduleRender();
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>