feat: GPU power display, font enlargement, countdown timer, dim color fix, miner_uid login

This commit is contained in:
ngfchl
2026-06-20 10:29:27 +08:00
parent 7217974395
commit 6ebad2a065
3 changed files with 74 additions and 46 deletions
+1 -1
View File
@@ -8,6 +8,6 @@
# 删除此文件可重新配置
machine_name: my-pc
server: 192.168.1.100:8080
server: 127.0.0.1:8080
interval: 3
reconnect_delay: 3
+9 -1
View File
@@ -1109,6 +1109,14 @@ func tryNvidiaSMI() []GPUInfo {
} else {
fan = parseFloat(fanStr)
}
powerStr := strings.TrimSpace(parts[9])
powerStr = strings.Trim(powerStr, "[]")
var power float64
if powerStr == "N/A" || powerStr == "" {
power = 0
} else {
power = parseFloat(powerStr)
}
gpus = append(gpus, GPUInfo{
Name: parts[1],
VRAMTotalMB: parseFloat(parts[2]),
@@ -1117,7 +1125,7 @@ func tryNvidiaSMI() []GPUInfo {
LoadPercent: parseFloat(parts[6]),
CoreClock: parseFloat(parts[7]),
MemClock: parseFloat(parts[8]),
Power: parseFloat(parts[9]),
Power: power,
FanSpeed: fan,
})
}
+64 -44
View File
@@ -11,7 +11,7 @@
:root {
--bg: #0a0d13; --card: #111520; --sec: #161b28; --border: #1e253a;
--accent: #00e5a0; --warn: #ffb020; --danger: #ff4060;
--text: #e2e6ef; --dim: #5a627a; --dim-bar: #1e2336;
--text: #e2e6ef; --dim: #7a829a; --dim-bar: #1e2336;
--mono: 'JetBrains Mono','Menlo',monospace;
}
* { margin:0; padding:0; box-sizing:border-box }
@@ -65,35 +65,35 @@
.device-card { background:var(--card); border:1px solid var(--border); border-radius:10px; transition:opacity .3s,border-color .2s,box-shadow .2s }
.device-card:hover { border-color:#28304a; box-shadow:0 4px 24px rgba(0,0,0,.2) }
.device-card.offline { opacity:.55 } .device-card.offline:hover { opacity:.75 }
.dc-hd { display:flex; align-items:center; justify-content:space-between; padding:8px 14px 0; gap:6px }
.dc-name { font-size:12px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
.dc-time { font-size:9px; color:var(--dim); font-family:var(--mono); flex-shrink:0 }
.dc-sub { padding:1px 14px 0; font-size:9px; color:var(--dim) }
.dc-hd { display:flex; align-items:center; justify-content:space-between; padding:10px 16px 0; gap:6px }
.dc-name { font-size:13px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
.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) }
.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 { background:var(--sec); border-radius:6px; padding:8px 12px }
.sec-tt { font-size:10px; font-weight:600; letter-spacing:.8px; text-transform:uppercase; color:var(--dim); margin-bottom:4px }
.sec-tt .hl { color:var(--text) }
.hw-row { display:flex; align-items:center; gap:6px }
.hw-row .sec-tt { margin-bottom:0 }
.tag-on { background:rgba(0,229,160,.12); color:var(--accent); border:none; font-size:7px; padding:0 5px; line-height:16px; border-radius:8px }
.tag-off { background:rgba(255,64,96,.1); color:var(--danger); border:none; font-size:7px; padding:0 5px; line-height:16px; border-radius:8px }
.info-row { display:flex; justify-content:space-between; align-items:center; font-size:9px; line-height:1.8 }
.tag-on { background:rgba(0,229,160,.12); color:var(--accent); border:none; font-size:8px; padding:0 6px; line-height:18px; border-radius:8px }
.tag-off { background:rgba(255,64,96,.1); color:var(--danger); border:none; font-size:8px; padding:0 6px; line-height:18px; border-radius:8px }
.info-row { display:flex; justify-content:space-between; align-items:center; font-size:10px; line-height:1.8 }
.info-row .label { color:var(--dim) } .info-row .value { font-family:var(--mono); font-weight:500 }
.gpu-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:4px }
.gpu-block { background:rgba(0,0,0,.2); border-radius:5px; padding:5px; cursor:pointer; transition:background .2s,transform .15s }
.gpu-block { 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:10px; font-weight:700; font-family:var(--mono) }
.gb-fan { font-size:9px; color:var(--dim); font-family:var(--mono); display:flex; align-items:center; gap:2px }
.gb-bar { position:relative; height:12px; background:var(--dim-bar); border-radius:3px; overflow:hidden; margin-bottom:2px }
.gb-temp { font-size:11px; font-weight:700; font-family:var(--mono) }
.gb-fan { font-size:10px; 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:7px; font-weight:700; font-family:var(--mono); color:#fff; text-shadow:0 1px 2px rgba(0,0,0,.6) }
.gb-info { display:flex; justify-content:space-between; font-size:8px; font-family:var(--mono); margin-top:2px }
.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:9px; font-family:var(--mono); margin-top:2px }
.gpu-popover .ant-popover { width:100%!important; left:0!important }
.gpu-popover .ant-popover-inner { width:100%!important }
@@ -115,20 +115,20 @@
.gpu-pop-row .info-row .label { color:var(--dim); white-space:nowrap }
.gpu-pop-row .info-row .value { color:var(--text); font-family:var(--mono); font-weight:500; white-space:nowrap }
.m-hr-row { display:flex; align-items:center; gap:5px; margin-bottom:3px }
.m-hr-row { display:flex; align-items:center; gap:5px; margin-bottom:4px }
.m-hr-row:last-child { margin-bottom:0 }
.m-hr-lbl { width:20px; text-align:right; font-family:var(--mono); font-size:8px; color:var(--dim); flex-shrink:0 }
.m-hr-bar { flex:1; height:5px; background:var(--dim-bar); border-radius:3px; overflow:hidden }
.m-hr-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:65px; text-align:right; font-family:var(--mono); font-size:8px; font-weight:500; flex-shrink:0 }
.m-status { display:flex; align-items:center; gap:5px; font-size:9px; margin-bottom:4px }
.m-agent,.m-country { color:var(--dim); font-size:8px; font-family:var(--mono); margin-left:auto }
.m-shares { display:flex; justify-content:space-between; margin-top:4px; font-family:var(--mono); font-size:8px }
.m-hr-val { min-width:70px; text-align:right; font-family:var(--mono); font-size:9px; font-weight:500; flex-shrink:0 }
.m-status { display:flex; align-items:center; gap:5px; font-size:10px; margin-bottom:5px }
.m-agent,.m-country { color:var(--dim); font-size:9px; font-family:var(--mono); margin-left:auto }
.m-shares { display:flex; justify-content:space-between; margin-top:5px; 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-times { display:flex; gap:10px; margin-top:4px; font-size:8px }
.m-times { display:flex; gap:10px; margin-top:5px; font-size:9px }
.m-times span { color:var(--dim) } .m-times .v { font-family:var(--mono) }
.chart-box { background:rgba(0,0,0,.15); border-radius:4px; padding:5px 6px; margin-top:5px; overflow:hidden }
.chart-lbl { font-size:7px; color:var(--dim); font-family:var(--mono); margin-bottom:3px }
.chart-box { background:rgba(0,0,0,.15); border-radius:4px; padding:6px 8px; margin-top:6px; overflow:hidden }
.chart-lbl { font-size:8px; color:var(--dim); font-family:var(--mono); margin-bottom:3px }
.chart-empty { color:var(--dim); font-size:8px; text-align:center; padding:10px 0 }
.off-actions { display:flex; justify-content:flex-end; padding-top:0 }
@@ -222,7 +222,7 @@
<!-- 全部在线 -->
<template v-for="name in onlineDevices">
<div class="device-card" :key="name">
<div class="dc-hd"><span class="dc-name">{{ name }}</span><span class="dc-time">{{ ago(devices[name].info.last_seen) }}</span></div>
<div class="dc-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>
<a-row :gutter="[12,0]" type="flex" class="dc-bd">
<a-col :xs="24" :sm="24" :md="12" class="dc-col dc-left" style="flex:1 1 0">
@@ -243,7 +243,7 @@
<template v-for="name in hwOnlyDevices">
<div class="sec-div" :key="'sep-hw-'+name" v-if="name===hwOnlyDevices[0] && onlineDevices.length>0">{{ hwOnlyDevices.length }} 台仅硬件在线</div>
<div class="device-card" :key="name">
<div class="dc-hd"><span class="dc-name">{{ name }}</span><span class="dc-time">{{ ago(devices[name].info.last_seen) }}</span></div>
<div class="dc-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">
<cpu-section :cpu="getLive(name).cpu||{}" :info="devices[name].info" :is-on="true"></cpu-section>
@@ -257,7 +257,7 @@
<template v-for="name in poolOnlyDevices">
<div class="sec-div" :key="'sep-pool-'+name" v-if="name===poolOnlyDevices[0]">{{ poolOnlyDevices.length }} 台仅矿池在线</div>
<div class="device-card offline" :key="name">
<div class="dc-hd"><span class="dc-name">{{ name }}</span><span class="dc-time">{{ ago(devices[name].info.last_seen) }}</span></div>
<div class="dc-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>
@@ -270,7 +270,7 @@
<div class="device-card offline" :key="name">
<div class="dc-hd">
<span class="dc-name">{{ name }}</span>
<span class="dc-time">{{ ago(devices[name].info.last_seen) }}</span>
<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">
@@ -299,10 +299,12 @@ Vue.component('cpu-section',{
props:['cpu','info','isOn'],
template:`<div class="sec">
<div class="sec-tt">CPU · <span class="hl">{{ model }}</span></div>
<div class="info-row"><span class="label">核心/线程</span><span class="value">{{ pc }} / {{ lc }}</span>
<span class="label" style="margin-left:auto">温度</span><span class="value" :style="{color:tempColor,marginLeft:'4px'}">{{ tmpStr }}</span>
<span class="label" style="margin-left:8px">频率</span><span class="value" style="margin-left:4px">{{ freq }} MHz</span></div>
<a-progress :percent="pct" :stroke-color="barColor" :stroke-width="5" size="small" :format="fmtPct" />
<div style="display:flex;align-items:center;gap:8px">
<a-progress :percent="pct" :stroke-color="barColor" :stroke-width="6" size="small" :format="fmtPct" style="flex:1;min-width:0" />
<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>
</div>
</div>`,
computed:{
model:function(){return this.cpu.model||(this.info&&this.info.cpu_model)||'Unknown'},
@@ -321,16 +323,19 @@ Vue.component('cpu-section',{
Vue.component('mem-section',{
props:['mem','isOn'],
template:`<div class="sec"><div class="sec-tt">MEMORY</div>
<div v-if="mem.total_bytes"><div class="info-row"><span class="label">{{ used }} / {{ total }}</span><span class="value" :style="{color:barColor}">{{ pct }}%</span></div>
<a-progress :percent="pct" :stroke-color="barColor" :stroke-width="5" size="small" :format="fmtPct" /></div>
<div v-else style="font-size:9px;color:var(--dim)">无数据</div></div>`,
<div v-if="mem.total_bytes" style="display:flex;align-items:center;gap:8px">
<a-progress :percent="pct" :stroke-color="barColor" :stroke-width="6" size="small" :format="fmtPct" style="flex:1;min-width:0" />
<span class="value" style="font-size:10px;white-space:nowrap;color:var(--dim)">{{ used }}/{{ total }}</span>
<span class="value" :style="{color:barColor,fontSize:'10px',whiteSpace:'nowrap'}">{{ pct }}%</span>
</div>
<div v-else style="font-size:10px;color:var(--dim)">无数据</div></div>`,
computed:{total:function(){return fmtB(this.mem.total_bytes)},used:function(){return fmtB(this.mem.used_bytes)},pct:function(){return Math.round(this.mem.percent||0)},barColor:function(){return pColor(this.pct)}},
methods:{fmtPct:function(v){return v+'%'}}
});
Vue.component('gpu-section',{
props:['gpus','expandedGpu'],
template:`<div class="sec"><div class="sec-tt">GPU · <span class="hl">{{ gpus.length }} 张</span></div>
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</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">
@@ -352,10 +357,13 @@ Vue.component('gpu-section',{
<div class="gb-top"><span class="gb-temp" :style="{color:tmpColor(g)}">{{ tmpStr(g) }}</span>
<span class="gb-fan"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4m0 12v4m-7.07-3.93l2.83-2.83m8.48-8.48l2.83-2.83M2 12h4m12 0h4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83"/></svg>{{ fanStr(g) }}</span></div>
<div class="gb-bar"><div class="gb-fill" :style="{width:(g.load_percent||0).toFixed(0)+'%',background:pColor(g.load_percent||0)}"></div><span class="gb-pct">{{ (g.load_percent||0).toFixed(0) }}%</span></div>
<div class="gb-info"><span :style="{color:pColor(vramPct(g))}">{{ vramPct(g).toFixed(0) }}%</span><span style="color:var(--dim)">{{ vramStr(g) }}</span></div>
<div class="gb-info"><span style="color:var(--dim)">{{ vramStr(g) }}</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)}
},
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'},
@@ -421,7 +429,7 @@ function buildChartSvg(hist){
new Vue({
el:'#app',
data:function(){return{loginForm:null,loginLoading:false,loginErr:'',loginConn:'',authed:false,wsOnline:false,devices:{},expandedGpu:null,clock:'',ws:null}},
data:function(){return{loginForm:null,loginLoading:false,loginErr:'',loginConn:'',authed:false,wsOnline:false,devices:{},expandedGpu:null,clock:'',ws:null,refreshing:false}},
created:function(){this.loginForm=this.$form.createForm(this)},
computed:{
deviceCount:function(){return Object.keys(this.devices).length},
@@ -436,7 +444,19 @@ new Vue({
methods:{
getLive:function(name){var e=this.devices[name];if(!e||!e.live)return{};var l=e.live;if(typeof l==='string')try{l=JSON.parse(l)}catch(ex){}return l||{}},
isPoolOnline:function(name){var e=this.devices[name];return e&&e.mining&&e.mining.status==='online'},
ago:ago, toggleGpu:function(i){this.expandedGpu=i},
ago:ago,
showRefresh:function(){var self=this;this.refreshing=true;setTimeout(function(){self.refreshing=false},500)},
countdown:function(iso){
if(!iso)return'—';
var d=new Date(iso);if(isNaN(d))return'—';
var s=Math.floor((Date.now()-d.getTime())/1000);
if(s<0)return'刚刚';
if(s<60)return s+'秒前';
if(s<3600)return Math.floor(s/60)+'分钟前';
if(s<86400)return Math.floor(s/3600)+'小时前';
return Math.floor(s/86400)+'天前';
},
toggleGpu:function(i){this.expandedGpu=i},
deleteDevice:function(n){var self=this;this.$confirm({title:'确定删除 '+n+' 的设备记录?',onOk:function(){if(self.ws&&self.ws.readyState===1)self.ws.send(JSON.stringify({action:'delete',machine:n}))}})},
handleLogin:function(e){e.preventDefault();var self=this;this.loginForm.validateFields(function(err,values){if(err)return;self.loginLoading=true;self.loginErr='';self.loginConn='';localStorage.setItem('mm_id',values.miner_id);localStorage.setItem('mm_pw',values.password);self.connectWs(values.miner_id,values.password)})},
sendAuth:function(mid,pw){if(this.ws&&this.ws.readyState===WebSocket.OPEN)this.ws.send(JSON.stringify({type:'auth',miner_id:mid,password:pw}))},
@@ -453,9 +473,9 @@ new Vue({
case'auth_required':this.loginConn='服务已连接,正在认证...';var mid=this.loginForm.getFieldValue('miner_id')||localStorage.getItem('mm_id');var pw=this.loginForm.getFieldValue('password')||localStorage.getItem('mm_pw');this.sendAuth(mid,pw);break;
case'auth_ok':this.authed=true;this.loginLoading=false;break;
case'auth_fail':this.loginErr=msg.msg||'设备ID或密码错误';this.loginLoading=false;localStorage.removeItem('mm_pw');break;
case'snapshot':this.devices={};if(msg.devices)this.devices=Object.assign({},msg.devices);if(this.authed)this.wsOnline=true;break;
case'device':var prev=this.devices[msg.machine];this.$set(this.devices,msg.machine,msg.device);if(prev&&prev.hash_history&&(!msg.device.hash_history||!msg.device.hash_history.length))this.$set(this.devices[msg.machine],'hash_history',prev.hash_history);break;
case'mining':var mi=msg.mining||{};var oAt=mi.opened_at>0?new Date(mi.opened_at).toISOString():null;var lAc=mi.last_active>0?new Date(mi.last_active).toISOString():null;if(this.devices[msg.machine]){this.$set(this.devices[msg.machine],'mining',mi);if(msg.hash_history)this.$set(this.devices[msg.machine],'hash_history',msg.hash_history);var dInf=this.devices[msg.machine].info||{};if(!dInf.first_seen&&oAt)dInf.first_seen=oAt;if(!dInf.last_seen&&lAc)dInf.last_seen=lAc;this.$set(this.devices[msg.machine],'info',dInf)}else{this.$set(this.devices,msg.machine,{info:{machine_name:msg.machine,first_seen:oAt,last_seen:lAc},online:false,mining:mi,hash_history:msg.hash_history})}break;
case'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;
}
}