1731 lines
64 KiB
HTML
1731 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)
|
|
}
|
|
|
|
.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-sub {
|
|
padding: 1px 16px 0;
|
|
font-size: 11px;
|
|
color: var(--dim)
|
|
}
|
|
|
|
.dc-time {
|
|
font-size: 10px;
|
|
color: var(--dim);
|
|
font-family: var(--mono);
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px
|
|
}
|
|
|
|
.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">{{ countdown(devices[name].info.last_seen) }}<a-icon
|
|
type="loading" style="font-size:10px;color:var(--accent)" spin/></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">{{ countdown(devices[name].info.last_seen) }}<a-icon
|
|
v-if="refreshing" type="loading" style="font-size:10px;color:var(--accent)"/></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">{{ countdown(devices[name].info.last_seen) }}<a-icon
|
|
v-if="refreshing" type="loading" style="font-size:10px;color:var(--accent)"/></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">{{ countdown(devices[name].info.last_seen) }}<a-icon v-if="refreshing"
|
|
type="loading"
|
|
style="font-size:10px;color:var(--accent)"/></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,
|
|
refreshing: true,
|
|
countdownVal: 30
|
|
}
|
|
},
|
|
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,
|
|
showRefresh: function () {
|
|
var self = this;
|
|
self.refreshing = true;
|
|
self.countdownVal = 30;
|
|
setTimeout(function () {
|
|
self.refreshing = false
|
|
}, 600)
|
|
},
|
|
countdown: function (iso) {
|
|
return this.countdownVal + 's'
|
|
},
|
|
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.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;
|
|
this.showRefresh();
|
|
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);
|
|
this.showRefresh();
|
|
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
|
|
})
|
|
}
|
|
this.showRefresh();
|
|
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);
|
|
setInterval(function () {
|
|
if (self.authed && self.countdownVal > 0) {
|
|
self.countdownVal--;
|
|
if (self.countdownVal <= 0) {
|
|
self.countdownVal = 30;
|
|
self.refreshing = true;
|
|
setTimeout(function () {
|
|
self.refreshing = false
|
|
}, 600)
|
|
}
|
|
}
|
|
}, 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>
|