1590 lines
54 KiB
HTML
1590 lines
54 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="preconnect" href="https://fonts.googleapis.com">
|
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap"
|
|
rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--bg: #0a0d13;
|
|
--card: #111520;
|
|
--sec: #161b28;
|
|
--border: #1e253a;
|
|
--border-l: #28304a;
|
|
--accent: #00e5a0;
|
|
--warn: #ffb020;
|
|
--danger: #ff4060;
|
|
--info: #4090ff;
|
|
--text: #e2e6ef;
|
|
--dim: #5a627a;
|
|
--dim-bar: #1e2336;
|
|
--radius: 14px;
|
|
--sec-r: 10px;
|
|
--sec-pad: 14px 16px;
|
|
--gap: 10px;
|
|
--font: 'Outfit', sans-serif;
|
|
--mono: 'JetBrains Mono', monospace;
|
|
--circ: 263.89;
|
|
}
|
|
|
|
*, *::before, *::after {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box
|
|
}
|
|
|
|
body {
|
|
font-family: var(--font);
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
min-height: 100vh;
|
|
line-height: 1.5
|
|
}
|
|
|
|
body::before {
|
|
content: '';
|
|
position: fixed;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
z-index: 0;
|
|
background: radial-gradient(ellipse at 10% 50%, rgba(0, 229, 160, .03) 0%, transparent 55%),
|
|
radial-gradient(ellipse at 90% 10%, rgba(64, 144, 255, .02) 0%, transparent 45%)
|
|
}
|
|
|
|
/* ═══ Login ═══ */
|
|
.login-wrap {
|
|
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: var(--radius);
|
|
padding: 40px 36px;
|
|
width: 380px;
|
|
max-width: 90vw;
|
|
animation: fadeIn .4s ease
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(16px)
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: none
|
|
}
|
|
}
|
|
|
|
.login-title {
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
letter-spacing: 2.5px;
|
|
color: var(--accent);
|
|
text-align: center;
|
|
margin-bottom: 4px
|
|
}
|
|
|
|
.login-sub {
|
|
font-size: 13px;
|
|
color: var(--dim);
|
|
text-align: center;
|
|
margin-bottom: 28px
|
|
}
|
|
|
|
.login-label {
|
|
font-size: 12px;
|
|
color: var(--dim);
|
|
margin-bottom: 6px;
|
|
display: block
|
|
}
|
|
|
|
.login-input {
|
|
width: 100%;
|
|
padding: 10px 14px;
|
|
background: var(--sec);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
color: var(--text);
|
|
font-family: var(--mono);
|
|
font-size: 13px;
|
|
outline: none;
|
|
transition: border-color .2s
|
|
}
|
|
|
|
.login-input:focus {
|
|
border-color: var(--accent)
|
|
}
|
|
|
|
.login-input::placeholder {
|
|
color: var(--dim)
|
|
}
|
|
|
|
.login-field + .login-field {
|
|
margin-top: 16px
|
|
}
|
|
|
|
.login-btn {
|
|
width: 100%;
|
|
padding: 11px 0;
|
|
margin-top: 24px;
|
|
background: var(--accent);
|
|
color: var(--bg);
|
|
border: 0;
|
|
border-radius: 8px;
|
|
font-family: var(--font);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
letter-spacing: 1.5px;
|
|
cursor: pointer;
|
|
transition: opacity .2s
|
|
}
|
|
|
|
.login-btn:hover {
|
|
opacity: .85
|
|
}
|
|
|
|
.login-btn:disabled {
|
|
opacity: .4;
|
|
cursor: not-allowed
|
|
}
|
|
|
|
.login-err {
|
|
color: var(--danger);
|
|
font-size: 12px;
|
|
text-align: center;
|
|
margin-top: 14px;
|
|
min-height: 18px
|
|
}
|
|
|
|
.login-conn {
|
|
color: var(--dim);
|
|
font-size: 11px;
|
|
text-align: center;
|
|
margin-top: 12px
|
|
}
|
|
|
|
/* ═══ Header ═══ */
|
|
.header {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 32px;
|
|
height: 52px;
|
|
background: rgba(10, 13, 19, .92);
|
|
backdrop-filter: blur(20px);
|
|
border-bottom: 1px solid var(--border)
|
|
}
|
|
|
|
.header h1 {
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
letter-spacing: 2.5px;
|
|
color: var(--accent)
|
|
}
|
|
|
|
.hmid {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
font-size: 13px;
|
|
color: var(--dim)
|
|
}
|
|
|
|
.dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
transition: background .3s, box-shadow .3s
|
|
}
|
|
|
|
.dot.on {
|
|
background: var(--accent);
|
|
box-shadow: 0 0 6px var(--accent)
|
|
}
|
|
|
|
.dot.off {
|
|
background: var(--danger);
|
|
box-shadow: 0 0 6px var(--danger)
|
|
}
|
|
|
|
.sep {
|
|
color: var(--border-l)
|
|
}
|
|
|
|
#clock {
|
|
font-family: var(--mono);
|
|
font-size: 12px;
|
|
color: var(--dim)
|
|
}
|
|
|
|
/* ═══ Dashboard ═══ */
|
|
.dashboard {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: flex;
|
|
gap: 16px;
|
|
padding: 20px 28px 0
|
|
}
|
|
|
|
.dash-card {
|
|
flex: 1;
|
|
background: var(--card);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 16px 20px
|
|
}
|
|
|
|
.dash-title {
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
letter-spacing: 1.5px;
|
|
text-transform: uppercase;
|
|
color: var(--dim);
|
|
margin-bottom: 12px
|
|
}
|
|
|
|
.dash-body {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 20px
|
|
}
|
|
|
|
.dash-donut {
|
|
width: 72px;
|
|
height: 72px;
|
|
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, stroke .3s
|
|
}
|
|
|
|
.dash-d-val {
|
|
font-family: var(--mono);
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
fill: var(--text)
|
|
}
|
|
|
|
.dash-stats {
|
|
display: flex;
|
|
gap: 18px
|
|
}
|
|
|
|
.dash-stat {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 2px
|
|
}
|
|
|
|
.dash-num {
|
|
font-family: var(--mono);
|
|
font-size: 20px;
|
|
font-weight: 700
|
|
}
|
|
|
|
.dash-num.on {
|
|
color: var(--accent)
|
|
}
|
|
|
|
.dash-num.off {
|
|
color: var(--danger)
|
|
}
|
|
|
|
.dash-label {
|
|
font-size: 10px;
|
|
color: var(--dim)
|
|
}
|
|
|
|
@media (max-width: 700px) {
|
|
.dashboard {
|
|
flex-direction: column;
|
|
padding: 12px
|
|
}
|
|
}
|
|
|
|
/* ═══ Empty ═══ */
|
|
.empty {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 50vh;
|
|
gap: 16px;
|
|
color: var(--dim);
|
|
position: relative;
|
|
z-index: 1
|
|
}
|
|
|
|
.pbox {
|
|
position: relative;
|
|
width: 72px;
|
|
height: 72px
|
|
}
|
|
|
|
.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: 10px;
|
|
height: 10px;
|
|
margin: -5px;
|
|
background: var(--accent);
|
|
border-radius: 50%
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0% {
|
|
transform: scale(.35);
|
|
opacity: 1
|
|
}
|
|
100% {
|
|
transform: scale(2.4);
|
|
opacity: 0
|
|
}
|
|
}
|
|
|
|
/* ═══ Grid — full width adaptive ═══ */
|
|
.grid {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: grid;
|
|
grid-template-columns:repeat(auto-fill, minmax(760px, 1fr));
|
|
align-items: start;
|
|
gap: 14px;
|
|
padding: 16px 20px 36px
|
|
}
|
|
|
|
/* ═══ Card ═══ */
|
|
.card {
|
|
background: var(--card);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
overflow: hidden;
|
|
transition: opacity .3s, border-color .25s, box-shadow .25s
|
|
}
|
|
|
|
.card:hover {
|
|
border-color: var(--border-l);
|
|
box-shadow: 0 4px 32px rgba(0, 0, 0, .25)
|
|
}
|
|
|
|
.card.offline {
|
|
opacity: .55
|
|
}
|
|
|
|
.card.offline:hover {
|
|
opacity: .75
|
|
}
|
|
|
|
.card.birth {
|
|
animation: cardIn .4s ease both
|
|
}
|
|
|
|
@keyframes cardIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(12px)
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: none
|
|
}
|
|
}
|
|
|
|
.card-hd {
|
|
padding: 8px 12px 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 6px
|
|
}
|
|
|
|
.card-id {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
min-width: 0
|
|
}
|
|
|
|
.card-id h3 {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis
|
|
}
|
|
|
|
.card-tm {
|
|
font-size: 9px;
|
|
color: var(--dim);
|
|
font-family: var(--mono);
|
|
flex-shrink: 0
|
|
}
|
|
|
|
.card-sub {
|
|
padding: 1px 12px 0;
|
|
font-size: 9px;
|
|
color: var(--dim)
|
|
}
|
|
|
|
.badge {
|
|
font-size: 7px;
|
|
font-weight: 700;
|
|
letter-spacing: .8px;
|
|
text-transform: uppercase;
|
|
padding: 2px 6px;
|
|
border-radius: 10px;
|
|
flex-shrink: 0;
|
|
transition: background .3s, color .3s
|
|
}
|
|
|
|
.badge-on {
|
|
background: rgba(0, 229, 160, .12);
|
|
color: var(--accent)
|
|
}
|
|
|
|
.badge-off {
|
|
background: rgba(255, 64, 96, .1);
|
|
color: var(--danger)
|
|
}
|
|
|
|
/* ═══ Card body — horizontal flex ═══ */
|
|
.card-bd {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0;
|
|
padding: 5px 0 0
|
|
}
|
|
|
|
.card-col {
|
|
flex: 1 1 320px;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px;
|
|
padding: 0 12px 8px
|
|
}
|
|
|
|
.card-left {
|
|
border-right: 1px solid var(--border)
|
|
}
|
|
|
|
@media (max-width: 700px) {
|
|
.card-bd {
|
|
flex-direction: column
|
|
}
|
|
|
|
.card-left {
|
|
border-right: none;
|
|
border-bottom: 1px solid var(--border);
|
|
padding-bottom: 14px
|
|
}
|
|
}
|
|
|
|
/* ═══ Section ═══ */
|
|
.sec {
|
|
background: var(--sec);
|
|
border-radius: 6px;
|
|
padding: 6px 10px
|
|
}
|
|
|
|
.sec-tt {
|
|
font-size: 8px;
|
|
font-weight: 600;
|
|
letter-spacing: .8px;
|
|
text-transform: uppercase;
|
|
color: var(--dim);
|
|
margin-bottom: 3px
|
|
}
|
|
|
|
.sec-tt .hl {
|
|
color: var(--text)
|
|
}
|
|
|
|
.sr {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 8px
|
|
}
|
|
|
|
.sr .sl {
|
|
color: var(--dim);
|
|
font-size: 9px;
|
|
flex-shrink: 0
|
|
}
|
|
|
|
.sr .sv {
|
|
font-family: var(--mono);
|
|
font-size: 9px;
|
|
font-weight: 500
|
|
}
|
|
|
|
.bar {
|
|
height: 5px;
|
|
background: var(--dim-bar);
|
|
border-radius: 3px;
|
|
overflow: hidden
|
|
}
|
|
|
|
.bar .bf {
|
|
height: 100%;
|
|
border-radius: 3px;
|
|
transition: width .5s ease;
|
|
min-width: 2px
|
|
}
|
|
|
|
.bar.mini {
|
|
height: 3px;
|
|
border-radius: 2px
|
|
}
|
|
|
|
.bar.mini .bf {
|
|
border-radius: 2px
|
|
}
|
|
|
|
/* ═══ CPU ═══ */
|
|
.cpu-top {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center
|
|
}
|
|
|
|
.gauge {
|
|
width: 44px;
|
|
height: 44px;
|
|
flex-shrink: 0
|
|
}
|
|
|
|
.g-track {
|
|
fill: none;
|
|
stroke: var(--dim-bar);
|
|
stroke-width: 8
|
|
}
|
|
|
|
.g-fill {
|
|
fill: none;
|
|
stroke-width: 8;
|
|
stroke-dasharray: var(--circ);
|
|
stroke-linecap: round;
|
|
transform: rotate(-90deg);
|
|
transform-origin: center;
|
|
transition: stroke-dashoffset .5s ease, stroke .3s
|
|
}
|
|
|
|
.g-val {
|
|
font-family: var(--mono);
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
fill: var(--text)
|
|
}
|
|
|
|
.g-lbl {
|
|
font-size: 5px;
|
|
fill: var(--dim);
|
|
letter-spacing: .6px
|
|
}
|
|
|
|
.cpu-inf {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px
|
|
}
|
|
|
|
.cores {
|
|
display: flex;
|
|
gap: 1px;
|
|
flex-wrap: wrap;
|
|
margin-top: 4px
|
|
}
|
|
|
|
.cbar {
|
|
flex: 1;
|
|
min-width: 4px;
|
|
height: 8px;
|
|
background: var(--dim-bar);
|
|
border-radius: 1px;
|
|
position: relative;
|
|
overflow: hidden
|
|
}
|
|
|
|
.cfill {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
border-radius: 2px;
|
|
transition: height .4s ease, background .3s
|
|
}
|
|
|
|
/* ═══ Memory ═══ */
|
|
.mem-hd {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: baseline;
|
|
margin-bottom: 3px
|
|
}
|
|
|
|
.mem-hd .mp {
|
|
font-family: var(--mono);
|
|
font-size: 12px;
|
|
font-weight: 700
|
|
}
|
|
|
|
/* ═══ GPU ═══ */
|
|
.gpu-table-wrap {
|
|
overflow-x: auto
|
|
}
|
|
|
|
.gpu-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 8px;
|
|
font-family: var(--mono)
|
|
}
|
|
|
|
.gpu-table th {
|
|
text-align: left;
|
|
font-size: 7px;
|
|
font-weight: 600;
|
|
letter-spacing: .4px;
|
|
text-transform: uppercase;
|
|
color: var(--dim);
|
|
padding: 2px 4px;
|
|
border-bottom: 1px solid var(--border);
|
|
white-space: nowrap
|
|
}
|
|
|
|
.gpu-table td {
|
|
padding: 2px 4px;
|
|
border-bottom: 1px solid rgba(30, 37, 58, .5);
|
|
white-space: nowrap
|
|
}
|
|
|
|
.gpu-table tr:last-child td {
|
|
border-bottom: none
|
|
}
|
|
|
|
.gpu-table tr:hover td {
|
|
background: rgba(0, 229, 160, .03)
|
|
}
|
|
|
|
.gpu-idx {
|
|
color: var(--dim);
|
|
font-size: 10px
|
|
}
|
|
|
|
.gpu-nm {
|
|
font-family: var(--font);
|
|
font-weight: 500;
|
|
font-size: 11px
|
|
}
|
|
|
|
.gpu-vram-cell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
min-width: 60px
|
|
}
|
|
|
|
/* ═══ Mining ═══ */
|
|
.m-status {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 10px;
|
|
margin-bottom: 6px
|
|
}
|
|
|
|
.m-agent {
|
|
color: var(--dim);
|
|
font-size: 9px;
|
|
font-family: var(--mono);
|
|
margin-left: auto
|
|
}
|
|
|
|
.m-country {
|
|
color: var(--dim);
|
|
font-size: 9px;
|
|
font-family: var(--mono);
|
|
margin-left: auto
|
|
}
|
|
|
|
.m-hr-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
margin-bottom: 4px
|
|
}
|
|
|
|
.m-hr-row:last-of-type {
|
|
margin-bottom: 0
|
|
}
|
|
|
|
.m-hr-lbl {
|
|
width: 22px;
|
|
text-align: right;
|
|
font-family: var(--mono);
|
|
font-size: 8px;
|
|
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: 4px;
|
|
transition: width .4s ease
|
|
}
|
|
|
|
.m-hr-val {
|
|
min-width: 70px;
|
|
text-align: right;
|
|
font-family: var(--mono);
|
|
font-size: 9px;
|
|
font-weight: 500;
|
|
flex-shrink: 0
|
|
}
|
|
|
|
.m-shares {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-top: 6px;
|
|
font-family: var(--mono);
|
|
font-size: 9px
|
|
}
|
|
|
|
.m-sh.valid {
|
|
color: var(--accent)
|
|
}
|
|
|
|
.m-sh.stale {
|
|
color: var(--warn)
|
|
}
|
|
|
|
.m-sh.invalid {
|
|
color: var(--danger)
|
|
}
|
|
|
|
.m-chart {
|
|
background: rgba(0, 0, 0, .15);
|
|
border-radius: 5px;
|
|
padding: 6px 8px;
|
|
margin-top: 6px;
|
|
overflow: hidden
|
|
}
|
|
|
|
.chart-lbl {
|
|
font-size: 8px;
|
|
color: var(--dim);
|
|
font-family: var(--mono);
|
|
margin-bottom: 4px
|
|
}
|
|
|
|
.chart-empty {
|
|
color: var(--dim);
|
|
font-size: 9px;
|
|
text-align: center;
|
|
padding: 14px 0
|
|
}
|
|
|
|
/* ═══ Misc ═══ */
|
|
.hw-status {
|
|
padding: 6px 10px
|
|
}
|
|
|
|
.hw-status-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px
|
|
}
|
|
|
|
.hw-status-row .sec-tt {
|
|
margin-bottom: 0
|
|
}
|
|
|
|
.sec-div {
|
|
grid-column: 1/-1;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
padding: 16px 0 2px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
letter-spacing: 1.5px;
|
|
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)
|
|
}
|
|
|
|
.off-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
padding-top: 0
|
|
}
|
|
|
|
.btn-del {
|
|
padding: 2px 8px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
background: 0;
|
|
color: var(--dim);
|
|
font-family: var(--font);
|
|
font-size: 9px;
|
|
cursor: pointer;
|
|
transition: all .2s
|
|
}
|
|
|
|
.btn-del:hover {
|
|
border-color: var(--danger);
|
|
color: var(--danger);
|
|
background: rgba(255, 64, 96, .06)
|
|
}
|
|
|
|
::-webkit-scrollbar {
|
|
width: 6px
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: var(--bg)
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: var(--border);
|
|
border-radius: 3px
|
|
}
|
|
|
|
@media (max-width: 520px) {
|
|
.grid {
|
|
grid-template-columns:1fr;
|
|
padding: 12px;
|
|
gap: 14px
|
|
}
|
|
|
|
.header {
|
|
padding: 0 14px
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- Login -->
|
|
<div class="login-wrap" id="login-wrap">
|
|
<div class="login-box">
|
|
<div class="login-title">SYSTEM MONITOR</div>
|
|
<div class="login-sub">输入设备ID和密码以登录</div>
|
|
<form id="login-form" autocomplete="off">
|
|
<div class="login-field">
|
|
<label class="login-label">设备 ID</label>
|
|
<input class="login-input" id="login-id" type="text" placeholder="miner_id" autocomplete="off"
|
|
autofocus>
|
|
</div>
|
|
<div class="login-field">
|
|
<label class="login-label">密码</label>
|
|
<input class="login-input" id="login-pw" type="password" placeholder="password" autocomplete="off">
|
|
</div>
|
|
<button class="login-btn" id="login-btn" type="submit">登 录</button>
|
|
</form>
|
|
<div class="login-err" id="login-err"></div>
|
|
<div class="login-conn" id="login-conn"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- App -->
|
|
<header class="header" id="app-header" style="display:none">
|
|
<h1>SYSTEM MONITOR</h1>
|
|
<div class="hmid">
|
|
<span class="dot off" id="ws-dot"></span>
|
|
<span id="ws-txt">连接中...</span>
|
|
<span class="sep">│</span>
|
|
<span id="cnt">0 台设备</span>
|
|
</div>
|
|
<span id="clock"></span>
|
|
</header>
|
|
<div class="dashboard" id="dashboard" style="display:none">
|
|
<div class="dash-card">
|
|
<div class="dash-title">硬件状态</div>
|
|
<div class="dash-body">
|
|
<svg class="dash-donut" id="hw-donut" viewBox="0 0 100 100"></svg>
|
|
<div class="dash-stats">
|
|
<div class="dash-stat"><span class="dash-num" id="hw-total">0</span><span class="dash-label">总计</span></div>
|
|
<div class="dash-stat"><span class="dash-num on" id="hw-on">0</span><span class="dash-label">在线</span></div>
|
|
<div class="dash-stat"><span class="dash-num off" id="hw-off">0</span><span class="dash-label">离线</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="dash-card">
|
|
<div class="dash-title">矿池状态</div>
|
|
<div class="dash-body">
|
|
<svg class="dash-donut" id="pool-donut" viewBox="0 0 100 100"></svg>
|
|
<div class="dash-stats">
|
|
<div class="dash-stat"><span class="dash-num" id="pool-total">0</span><span class="dash-label">总计</span></div>
|
|
<div class="dash-stat"><span class="dash-num on" id="pool-on">0</span><span class="dash-label">在线</span></div>
|
|
<div class="dash-stat"><span class="dash-num off" id="pool-off">0</span><span class="dash-label">离线</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="empty" id="empty" style="display:none">
|
|
<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">等待设备连接</h2>
|
|
<p style="font-size:13px">启动客户端程序以开始监控</p>
|
|
</div>
|
|
<main class="grid" id="grid" style="display:none"></main>
|
|
|
|
<script>
|
|
(function () {
|
|
"use strict";
|
|
|
|
var C = 263.89, devices = {}, cards = {}, sepNode = null, ws, rt, pending = false, authed = false;
|
|
|
|
function $(id) {
|
|
return document.getElementById(id)
|
|
}
|
|
|
|
/* ═══ Utils ═══ */
|
|
function esc(s) {
|
|
if (s == null) return '';
|
|
var d = document.createElement('div');
|
|
d.textContent = String(s);
|
|
return d.innerHTML
|
|
}
|
|
|
|
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 >= 1e15) return (v / 1e15).toFixed(2) + ' PH/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 pC(p) {
|
|
return p >= 80 ? 'var(--danger)' : p >= 60 ? 'var(--warn)' : 'var(--accent)'
|
|
}
|
|
|
|
function tT(t) {
|
|
if (t == null || t <= 0) return '<span style="color:var(--dim)">N/A</span>';
|
|
var c = t >= 85 ? 'var(--danger)' : t >= 70 ? 'var(--warn)' : 'var(--text)';
|
|
return '<span style="color:' + c + '">' + t.toFixed(1) + '°C</span>'
|
|
}
|
|
|
|
function ago(iso) {
|
|
if (!iso) return '\u2014';
|
|
var d = new Date(iso);
|
|
if (isNaN(d)) return '\u2014';
|
|
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) + ' 天前'
|
|
}
|
|
|
|
function fTm(iso) {
|
|
if (!iso) return '\u2014';
|
|
var d = new Date(iso);
|
|
if (isNaN(d)) return '\u2014';
|
|
|
|
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 sr(l, v) {
|
|
return '<div class="sr"><span class="sl">' + l + '</span><span class="sv">' + v + '</span></div>'
|
|
}
|
|
|
|
/* ═══ Login ═══ */
|
|
var loginForm = $('login-form'), loginIdInput = $('login-id'), loginPwInput = $('login-pw');
|
|
var loginBtn = $('login-btn'), loginErr = $('login-err'), loginConn = $('login-conn');
|
|
var savedId = localStorage.getItem('mm_id') || '';
|
|
var savedPw = localStorage.getItem('mm_pw') || '';
|
|
if (savedId) loginIdInput.value = savedId;
|
|
if (savedPw) loginPwInput.value = savedPw;
|
|
|
|
loginForm.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
loginErr.textContent = '';
|
|
var mid = loginIdInput.value.trim(), pw = loginPwInput.value;
|
|
if (!mid) {
|
|
loginErr.textContent = '请输入设备ID';
|
|
return
|
|
}
|
|
if (!pw) {
|
|
loginErr.textContent = '请输入密码';
|
|
return
|
|
}
|
|
loginBtn.disabled = true;
|
|
loginBtn.textContent = '登录中...';
|
|
loginConn.textContent = '';
|
|
localStorage.setItem('mm_id', mid);
|
|
localStorage.setItem('mm_pw', pw);
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) wsConnect(mid, pw); else sendAuth(mid, pw);
|
|
});
|
|
|
|
function sendAuth(mid, pw) {
|
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({
|
|
type: 'auth',
|
|
miner_id: mid,
|
|
password: pw
|
|
}))
|
|
}
|
|
|
|
function loginFailed(msg) {
|
|
loginBtn.disabled = false;
|
|
loginBtn.textContent = '登 录';
|
|
loginErr.textContent = msg || '认证失败';
|
|
localStorage.removeItem('mm_pw');
|
|
loginPwInput.value = '';
|
|
loginPwInput.focus()
|
|
}
|
|
|
|
function loginSuccess() {
|
|
authed = true;
|
|
localStorage.setItem('mm_id', loginIdInput.value.trim());
|
|
localStorage.setItem('mm_pw', loginPwInput.value);
|
|
$('login-wrap').style.display = 'none';
|
|
$('app-header').style.display = '';
|
|
$('dashboard').style.display = '';
|
|
$('empty').style.display = '';
|
|
$('grid').style.display = '';
|
|
}
|
|
|
|
/* ═══ WebSocket ═══ */
|
|
function wsConnect() {
|
|
var p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
ws = new WebSocket(p + '//' + location.host + '/ws/monitor');
|
|
ws.onopen = function () {
|
|
loginConn.textContent = '';
|
|
setTimeout(function () {
|
|
if (!authed) sendAuth(loginIdInput.value.trim(), loginPwInput.value)
|
|
}, 1500);
|
|
};
|
|
ws.onmessage = function (e) {
|
|
try {
|
|
handleMsg(JSON.parse(e.data))
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
};
|
|
ws.onclose = function () {
|
|
if (authed) {
|
|
$('ws-dot').className = 'dot off';
|
|
$('ws-txt').textContent = '已断开';
|
|
rt = setTimeout(wsConnect, 3000)
|
|
} else {
|
|
loginConn.textContent = '连接已断开';
|
|
loginBtn.disabled = false;
|
|
loginBtn.textContent = '登 录'
|
|
}
|
|
};
|
|
ws.onerror = function () {
|
|
ws.close()
|
|
};
|
|
}
|
|
|
|
/* ═══ Messages ═══ */
|
|
function handleMsg(msg) {
|
|
switch (msg.type) {
|
|
case'auth_required':
|
|
loginConn.textContent = '服务已连接,正在认证...';
|
|
sendAuth(loginIdInput.value.trim(), loginPwInput.value);
|
|
break;
|
|
case'auth_ok':
|
|
loginSuccess();
|
|
break;
|
|
case'auth_fail':
|
|
loginFailed(msg.msg || '设备ID或密码错误');
|
|
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 = devices[msg.machine];
|
|
devices[msg.machine] = msg.device;
|
|
if (prev && prev.hash_history && (!msg.device.hash_history || !msg.device.hash_history.length)) devices[msg.machine].hash_history = prev.hash_history;
|
|
scheduleRender();
|
|
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 (devices[msg.machine]) {
|
|
devices[msg.machine].mining = mi;
|
|
if (msg.hash_history) devices[msg.machine].hash_history = msg.hash_history;
|
|
var dInf = devices[msg.machine].info || {};
|
|
if (!dInf.first_seen && oAt) dInf.first_seen = oAt;
|
|
if (!dInf.last_seen && lAc) dInf.last_seen = lAc;
|
|
devices[msg.machine].info = dInf;
|
|
} else devices[msg.machine] = {
|
|
info: {machine_name: msg.machine, first_seen: oAt, last_seen: lAc},
|
|
online: false,
|
|
mining: mi,
|
|
hash_history: msg.hash_history
|
|
};
|
|
scheduleRender();
|
|
break;
|
|
case'remove':
|
|
if (cards[msg.machine]) {
|
|
cards[msg.machine].remove();
|
|
delete cards[msg.machine]
|
|
}
|
|
delete devices[msg.machine];
|
|
scheduleRender();
|
|
break;
|
|
}
|
|
}
|
|
|
|
/* ═══ Render ═══ */
|
|
function scheduleRender() {
|
|
if (pending) return;
|
|
pending = true;
|
|
requestAnimationFrame(function () {
|
|
pending = false;
|
|
render()
|
|
})
|
|
}
|
|
|
|
function renderDashboard(hwOn, hwOff, poolOn, poolOff) {
|
|
$('dashboard').style.display = '';
|
|
$('hw-total').textContent = hwOn + hwOff;
|
|
$('hw-on').textContent = hwOn;
|
|
$('hw-off').textContent = hwOff;
|
|
$('pool-total').textContent = poolOn + poolOff;
|
|
$('pool-on').textContent = poolOn;
|
|
$('pool-off').textContent = poolOff;
|
|
|
|
donut('hw-donut', hwOn, hwOff);
|
|
donut('pool-donut', poolOn, poolOff);
|
|
}
|
|
|
|
function donut(id, a, b) {
|
|
var el = $(id), C = 263.89, total = a + b;
|
|
var pctA = total > 0 ? a / total : 0;
|
|
var pctB = total > 0 ? b / total : 0;
|
|
var offA = C * (1 - pctA);
|
|
var offB = C * (1 - pctA - pctB);
|
|
var val = total > 0 ? Math.round(pctA * 100) : 0;
|
|
el.innerHTML = '<circle class="dash-d-track" cx="50" cy="50" r="42"/>'
|
|
+ '<circle class="dash-d-fill" cx="50" cy="50" r="42" style="stroke-dasharray:' + C + ';stroke-dashoffset:' + offA.toFixed(1) + ';stroke:var(--accent)"/>'
|
|
+ '<circle class="dash-d-fill" cx="50" cy="50" r="42" style="stroke-dasharray:' + C + ';stroke-dashoffset:' + offB.toFixed(1) + ';stroke:var(--danger)"/>'
|
|
+ '<text class="dash-d-val" x="50" y="48" text-anchor="middle" dominant-baseline="middle">' + val + '%</text>'
|
|
+ '<text x="50" y="62" text-anchor="middle" fill="var(--dim)" font-size="8" font-family="var(--mono)">在线率</text>';
|
|
}
|
|
|
|
function render() {
|
|
if (!authed) return;
|
|
var names = Object.keys(devices), on = [], off = [], i, n;
|
|
var poolOn = 0, poolOff = 0;
|
|
for (i = 0; i < names.length; i++) {
|
|
n = names[i];
|
|
(devices[n] && devices[n].online ? on : off).push(n)
|
|
}
|
|
on.sort();
|
|
off.sort(function (a, b) {
|
|
var ta = (devices[b] && devices[b].info && devices[b].info.last_seen) || '';
|
|
var tb = (devices[a] && devices[a].info && devices[a].info.last_seen) || '';
|
|
return ta.localeCompare(tb)
|
|
});
|
|
|
|
$('cnt').textContent = names.length ? names.length + ' 台设备' + (on.length ? ' · ' + on.length + ' 在线' : '') : '0 台设备';
|
|
if (!names.length) {
|
|
$('empty').style.display = 'flex';
|
|
$('grid').style.display = 'none';
|
|
$('dashboard').style.display = 'none';
|
|
return
|
|
}
|
|
$('empty').style.display = 'none';
|
|
$('grid').style.display = 'grid';
|
|
|
|
for (i = 0; i < names.length; i++) {
|
|
n = names[i];
|
|
var d = devices[n];
|
|
if (d && d.mining && d.mining.status === 'online') poolOn++; else poolOff++
|
|
}
|
|
renderDashboard(on.length, off.length, poolOn, poolOff);
|
|
|
|
var grid = $('grid'), liveSet = {};
|
|
for (i = 0; i < on.length; i++) liveSet[on[i]] = true;
|
|
for (i = 0; i < off.length; i++) liveSet[off[i]] = true;
|
|
for (n in cards) if (!liveSet[n]) {
|
|
cards[n].remove();
|
|
delete cards[n]
|
|
}
|
|
|
|
for (i = 0; i < on.length; i++) placeCard(grid, on[i], true);
|
|
if (on.length > 0 && off.length > 0) {
|
|
if (!sepNode) {
|
|
sepNode = document.createElement('div');
|
|
sepNode.className = 'sec-div'
|
|
}
|
|
sepNode.textContent = off.length + ' 台离线设备';
|
|
grid.appendChild(sepNode);
|
|
} else if (sepNode) {
|
|
sepNode.remove();
|
|
sepNode = null
|
|
}
|
|
for (i = 0; i < off.length; i++) placeCard(grid, off[i], false);
|
|
}
|
|
|
|
/* ═══ Card ═══ */
|
|
function placeCard(grid, name, isOn) {
|
|
var e = devices[name];
|
|
if (!e) return;
|
|
var el = cards[name];
|
|
if (!el) {
|
|
el = document.createElement('div');
|
|
el.className = 'card birth' + (isOn ? '' : ' offline');
|
|
el.innerHTML = buildShell(name, e, isOn);
|
|
el.addEventListener('animationend', function () {
|
|
el.classList.remove('birth')
|
|
}, {once: true});
|
|
cards[name] = el;
|
|
}
|
|
el.classList.toggle('offline', !isOn);
|
|
var tm = el.querySelector('.card-tm');
|
|
var info = e.info || {};
|
|
if (tm) tm.textContent = ago(info.last_seen);
|
|
|
|
var hwDot = el.querySelector('.hw-dot');
|
|
if (hwDot) hwDot.className = 'dot ' + (isOn ? 'on' : 'off');
|
|
var hwBadge = el.querySelector('.hw-badge');
|
|
if (hwBadge) {
|
|
hwBadge.className = 'badge ' + (isOn ? 'badge-on' : 'badge-off');
|
|
hwBadge.textContent = isOn ? '在线' : '离线'
|
|
}
|
|
|
|
var mining = e.mining || {};
|
|
var mOn = mining.status === 'online';
|
|
var mDot = el.querySelector('.m-dot');
|
|
if (mDot) mDot.className = 'dot ' + (mOn ? 'on' : 'off');
|
|
var mBadge = el.querySelector('.m-badge');
|
|
if (mBadge) {
|
|
mBadge.className = 'badge ' + (mOn ? 'badge-on' : 'badge-off');
|
|
mBadge.textContent = mOn ? '在线' : '离线'
|
|
}
|
|
|
|
var live = e.live || {};
|
|
var left = el.querySelector('.card-left');
|
|
if (left) left.innerHTML = buildLeft(live.cpu || {}, live.memory || {}, live.gpus || [], info.gpus, info, isOn);
|
|
var right = el.querySelector('.card-right');
|
|
if (right) right.innerHTML = buildRight(name, e.mining, e.hash_history, info, isOn);
|
|
|
|
grid.appendChild(el);
|
|
}
|
|
|
|
function buildShell(name, e, isOn) {
|
|
var info = e.info || {};
|
|
var mb = info.motherboard || {};
|
|
var h = '<div class="card-hd"><h3>' + esc(name) + '</h3><span class="card-tm">' + ago(info.last_seen) + '</span></div>';
|
|
if (mb.manufacturer || mb.product) h += '<div class="card-sub">' + esc(mb.manufacturer) + ' ' + esc(mb.product) + '</div>';
|
|
h += '<div class="card-bd"><div class="card-col card-left"></div><div class="card-col card-right"></div></div>';
|
|
return h;
|
|
}
|
|
|
|
/* ═══ Left Column: Hardware Status + CPU + Memory + GPU ═══ */
|
|
function buildLeft(cpu, mem, gpus, infoGPUs, info, isOn) {
|
|
var h = '<div class="sec hw-status"><div class="hw-status-row"><span class="sec-tt" style="margin-bottom:0">硬件状态</span><span class="dot ' + (isOn ? 'on' : 'off') + ' hw-dot"></span><span class="badge ' + (isOn ? 'badge-on' : 'badge-off') + ' hw-badge">' + (isOn ? '在线' : '离线') + '</span></div></div>';
|
|
return h + buildCPU(cpu, info, isOn) + buildMem(mem, isOn) + buildGPU(gpus, infoGPUs, isOn);
|
|
}
|
|
|
|
function buildCPU(cpu, info, isOn) {
|
|
var model = cpu.model || info.cpu_model || 'Unknown';
|
|
var pc = cpu.cores_physical || info.cpu_cores_physical || '?';
|
|
var lc = cpu.cores_logical || info.cpu_cores_logical || '?';
|
|
var freq = cpu.freq_current_mhz || 0, pct = cpu.usage_total || 0, cores = cpu.usage_per_core || [],
|
|
tmp = cpu.temperature;
|
|
var h = '<div class="sec"><div class="sec-tt">CPU \u00B7 <span class="hl">' + esc(model) + '</span></div>';
|
|
if (isOn && (pct > 0 || cores.length > 0)) {
|
|
var uo = C * (1 - pct / 100);
|
|
h += '<div class="cpu-top"><svg class="gauge" viewBox="0 0 100 100"><circle class="g-track" cx="50" cy="50" r="42"/><circle class="g-fill" cx="50" cy="50" r="42" style="stroke-dashoffset:' + uo.toFixed(1) + ';stroke:' + pC(pct) + '"/><text class="g-val" x="50" y="46" text-anchor="middle" dominant-baseline="middle">' + pct.toFixed(1) + '%</text><text class="g-lbl" x="50" y="62" text-anchor="middle">CPU</text></svg><div class="cpu-inf">' + sr('核心/线程', pc + ' / ' + lc) + sr('频率', Math.round(freq) + ' MHz') + sr('温度', tT(tmp)) + '</div></div>';
|
|
if (cores.length) {
|
|
h += '<div class="cores">';
|
|
for (var i = 0; i < cores.length; i++) h += '<div class="cbar" title="Core ' + i + ': ' + cores[i].toFixed(1) + '%"><div class="cfill" style="height:' + cores[i] + '%;background:' + pC(cores[i]) + '"></div></div>';
|
|
h += '</div>'
|
|
}
|
|
} else {
|
|
h += sr('核心/线程', pc + ' / ' + lc);
|
|
if (freq > 0) h += sr('频率', Math.round(freq) + ' MHz')
|
|
}
|
|
return h + '</div>';
|
|
}
|
|
|
|
function buildMem(mem, isOn) {
|
|
var h = '<div class="sec"><div class="sec-tt">MEMORY</div>';
|
|
if (isOn && mem.total_bytes) {
|
|
var pct = mem.percent || 0;
|
|
h += '<div class="mem-hd"><span style="font-size:10px">' + fmtB(mem.used_bytes) + ' / ' + fmtB(mem.total_bytes) + '</span><span class="mp" style="color:' + pC(pct) + '">' + pct.toFixed(1) + '%</span></div><div class="bar"><div class="bf" style="width:' + pct + '%;background:' + pC(pct) + '"></div></div>'
|
|
} else if (mem.total_bytes) h += '<div style="font-size:10px;color:var(--dim)">总计 ' + fmtB(mem.total_bytes) + '</div>';
|
|
else h += '<div style="font-size:10px;color:var(--dim)">无数据</div>';
|
|
return h + '</div>';
|
|
}
|
|
|
|
function buildGPU(gpus, infoGPUs, isOn) {
|
|
var list = isOn ? gpus : (infoGPUs || []);
|
|
if (!list.length) return '<div class="sec"><div class="sec-tt">GPU</div><div style="color:var(--dim);font-size:11px">未检测到</div></div>';
|
|
var h = '<div class="sec"><div class="sec-tt">GPU \u00B7 <span class="hl">' + list.length + ' \u5F20</span></div>';
|
|
h += '<div class="gpu-table-wrap"><table class="gpu-table"><thead><tr>'
|
|
+ '<th>\u7F16\u53F7</th><th>\u8D1F\u8F7D</th><th>\u6E29\u5EA6</th><th>\u529F\u8017</th><th>\u6838\u5FC3</th><th>\u663E\u5B58</th><th>\u7528\u91CF</th><th>\u98CE\u6247</th>'
|
|
+ '</tr></thead><tbody>';
|
|
for (var i = 0; i < list.length; i++) {
|
|
var g = list[i], gl = g.load_percent || 0, tmp = g.temperature || 0,
|
|
pwr = g.power || g.power_watts || 0, cc = g.core_clock || g.core_clock_mhz || 0,
|
|
mc = g.mem_clock || g.mem_clock_mhz || 0, vt = g.vram_total || g.vram_total_mb || 0,
|
|
vu = g.vram_used || g.vram_used_mb || 0, fan = g.fan_speed || 0;
|
|
h += '<tr>'
|
|
+ '<td class="gpu-idx">GPU' + (i + 1) + '</td>'
|
|
+ '<td style="color:' + pC(gl) + '">' + gl.toFixed(0) + '%</td>'
|
|
+ '<td>' + tT(tmp > 0 ? tmp : null) + '</td>'
|
|
+ '<td>' + Math.round(pwr) + ' W</td>'
|
|
+ '<td>' + Math.round(cc) + ' MHz</td>'
|
|
+ '<td>' + Math.round(mc) + ' MHz</td>';
|
|
if (vt > 0) {
|
|
var vp = vu / vt * 100;
|
|
h += '<td><div class="gpu-vram-cell"><span>' + (vu / 1024).toFixed(1) + '/' + (vt / 1024).toFixed(0) + 'G</span>'
|
|
+ '<div class="bar mini"><div class="bf" style="width:' + vp + '%;background:' + pC(vp) + '"></div></div></div></td>';
|
|
} else {
|
|
h += '<td>\u2014</td>';
|
|
}
|
|
h += '<td>' + (fan > 0 ? Math.round(fan) + '%' : '\u2014') + '</td>';
|
|
h += '</tr>';
|
|
}
|
|
h += '</tbody></table></div>';
|
|
return h + '</div>';
|
|
}
|
|
|
|
/* ═══ Right Column: Mining Status + Mining ═══ */
|
|
function buildRight(name, mining, hist, info, isOn) {
|
|
var h = '';
|
|
if (mining) {
|
|
var mOn = mining.status === 'online';
|
|
h += '<div class="sec hw-status"><div class="hw-status-row"><span class="sec-tt" style="margin-bottom:0">矿池状态</span><span class="dot ' + (mOn ? 'on' : 'off') + ' m-dot"></span><span class="badge ' + (mOn ? 'badge-on' : 'badge-off') + ' m-badge">' + (mOn ? '在线' : '离线') + '</span></div></div>';
|
|
h += buildMining(mining, hist);
|
|
} else {
|
|
h += '<div class="sec hw-status"><div class="hw-status-row"><span class="sec-tt" style="margin-bottom:0">矿池状态</span><span class="dot off m-dot"></span><span class="badge badge-off m-badge">离线</span></div></div>';
|
|
}
|
|
if (!isOn) {
|
|
h += '<div class="off-actions"><button class="btn-del" onclick="window.__del(\'' + esc(name) + '\')">删除记录</button></div>';
|
|
}
|
|
return h;
|
|
}
|
|
|
|
function buildMining(m, hist) {
|
|
if (!m) return '';
|
|
var sOn = m.status === 'online';
|
|
var maxHr = Math.max(m.hashrate_30m || 0, m.hashrate_3h || 0, m.hashrate_24h || 0) || 1;
|
|
var h = '<div class="sec"><div class="sec-tt">MINING \u00B7 <span class="hl">' + (m.coin || '').toUpperCase() + '</span></div>';
|
|
|
|
/* 状态 */
|
|
h += '<div class="m-status"><span class="dot ' + (sOn ? 'on' : 'off') + '"></span>'
|
|
+ '<span style="color:' + (sOn ? 'var(--accent)' : 'var(--danger)') + ';font-weight:500">' + (sOn ? '\u5728\u7EBF' : '\u79BB\u7EBF') + '</span>';
|
|
if (m.agent) h += '<span class="m-agent">' + esc(m.agent) + '</span>';
|
|
if (m.country) h += '<span class="m-country">' + esc(m.country) + '</span>';
|
|
h += '</div>';
|
|
|
|
/* 算力条 */
|
|
h += hrBar('30m', m.hashrate_30m || 0, maxHr) + hrBar('3h', m.hashrate_3h || 0, maxHr) + hrBar('24h', m.hashrate_24h || 0, maxHr);
|
|
|
|
/* 时间信息 */
|
|
h += '<div class="m-times" style="display:flex;gap:12px;margin-top:6px;font-size:9px">';
|
|
h += '<span><span style="color:var(--dim)">\u6D3B\u8DC3 </span><span style="font-family:var(--mono)">' + fmtMsTs(m.last_active) + '</span></span>';
|
|
h += '<span><span style="color:var(--dim)">\u5F00\u59CB </span><span style="font-family:var(--mono)">' + fmtMsTs(m.opened_at) + '</span></span>';
|
|
if (m.last_active > 0 && m.opened_at > 0) {
|
|
var durH = ((m.last_active - m.opened_at) / 3600000).toFixed(1);
|
|
h += '<span><span style="color:var(--dim)">\u8FD0\u884C </span><span style="font-family:var(--mono)">' + durH + ' h</span></span>';
|
|
}
|
|
h += '</div>';
|
|
|
|
/* Shares */
|
|
h += '<div class="m-shares"><span class="m-sh valid">' + (m.valid || 0) + ' \u6709\u6548</span>'
|
|
+ '<span class="m-sh stale">' + (m.stale || 0) + ' \u8FC7\u671F</span>'
|
|
+ '<span class="m-sh invalid">' + (m.invalid || 0) + ' \u65E0\u6548</span></div>';
|
|
|
|
/* 走势图 */
|
|
h += '<div class="m-chart"><div class="chart-lbl">\u7B97\u529B\u8D70\u52BF (30m \u5747\u503C)</div>' + buildChart(hist) + '</div>';
|
|
|
|
return h + '</div>';
|
|
}
|
|
|
|
/* ★ 毫秒时间戳 → 可读文本 */
|
|
function fmtMsTs(ms) {
|
|
if (!ms || ms <= 0) return '<span style="color:var(--dim)">\u2014</span>';
|
|
var d = new Date(ms);
|
|
if (isNaN(d)) return '<span style="color:var(--dim)">\u2014</span>';
|
|
|
|
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 hrBar(l, v, mx) {
|
|
var p = Math.min(100, v / mx * 100);
|
|
return '<div class="m-hr-row"><span class="m-hr-lbl">' + l + '</span><div class="m-hr-bar"><div class="m-hr-fill" style="width:' + p.toFixed(1) + '%;background:' + pC(p) + '"></div></div><span class="m-hr-val" style="color:' + pC(p) + '">' + fmtHR(v) + '</span></div>'
|
|
}
|
|
|
|
/* ═══ SVG Chart ═══ */
|
|
function buildChart(hist) {
|
|
if (!hist || hist.length < 2) return '<div class="chart-empty">数据采集中 (' + ((hist && hist.length) || 0) + '/2)...</div>';
|
|
var W = 420, H = 70, pad = {t: 10, b: 12, l: 44, 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);
|
|
var svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" style="width:100%;height:' + H + 'px;display:block"><defs><linearGradient id="' + gid + '" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#00e5a0" stop-opacity=".2"/><stop offset="100%" stop-color="#00e5a0" stop-opacity=".01"/></linearGradient></defs>';
|
|
var gs = [.25, .5, .75];
|
|
for (i = 0; i < gs.length; i++) {
|
|
var gy = (pad.t + cH * (1 - gs[i])).toFixed(1);
|
|
svg += '<line x1="' + pad.l + '" y1="' + gy + '" x2="' + (W - pad.r) + '" y2="' + gy + '" stroke="#1a1f30" stroke-width="1"/>'
|
|
}
|
|
svg += '<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="3" fill="#00e5a0" stroke="#111520" stroke-width="1.5"/>';
|
|
svg += '<text x="' + (pad.l - 3) + '" y="' + (pad.t + 2) + '" fill="#5a627a" font-size="7" font-family="JetBrains Mono,monospace" text-anchor="end">' + fmtHR(hi) + '</text>';
|
|
svg += '<text x="' + (pad.l - 3) + '" y="' + (H - pad.b + 2) + '" fill="#5a627a" font-size="7" font-family="JetBrains Mono,monospace" text-anchor="end">' + fmtHR(lo) + '</text>';
|
|
svg += '<text x="' + pad.l + '" y="' + (H - 1) + '" fill="#5a627a" font-size="7" font-family="JetBrains Mono,monospace">' + fT(t0) + '</text>';
|
|
svg += '<text x="' + (W - pad.r) + '" y="' + (H - 1) + '" fill="#5a627a" font-size="7" font-family="JetBrains Mono,monospace" text-anchor="end">' + fT(tN) + '</text></svg>';
|
|
return svg;
|
|
}
|
|
|
|
/* ═══ Delete + Clock ═══ */
|
|
window.__del = function (n) {
|
|
if (!confirm('确定删除 ' + n + ' 的设备记录?')) return;
|
|
if (ws && ws.readyState === 1) ws.send(JSON.stringify({action: 'delete', machine: n}))
|
|
};
|
|
setInterval(function () {
|
|
$('clock').textContent = new Date().toLocaleTimeString('zh-CN', {hour12: false});
|
|
for (var n in cards) {
|
|
var el = cards[n], tm = el.querySelector('.card-tm');
|
|
if (tm && devices[n] && devices[n].info) tm.textContent = ago(devices[n].info.last_seen)
|
|
}
|
|
}, 1000);
|
|
|
|
wsConnect();
|
|
if (savedId && savedPw) {
|
|
loginBtn.disabled = true;
|
|
loginBtn.textContent = '自动登录中...';
|
|
loginConn.textContent = '';
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html> |