feat: mining section rework, GPU filter, chart as full-width row, progress bars separate line, contrast fix

This commit is contained in:
ngfchl
2026-06-20 16:46:21 +08:00
parent d39713a196
commit 2c103af9d8
3 changed files with 173 additions and 72 deletions
+32 -15
View File
@@ -143,12 +143,16 @@ type GPUInfo struct {
MemClock float64 `json:"mem_clock_mhz"`
Power float64 `json:"power"`
PowerLimit float64 `json:"power_limit"`
PowerMax float64 `json:"power_max"`
FanSpeed float64 `json:"fan_speed"`
PState string `json:"pstate"`
Persistence string `json:"persistence_mode"`
ComputeMode string `json:"compute_mode"`
DriverVersion string `json:"driver_version"`
CudaVersion string `json:"cuda_version"`
UtilMemory float64 `json:"util_memory"`
UtilEncoder float64 `json:"util_encoder"`
UtilDecoder float64 `json:"util_decoder"`
ClocksMaxGfx float64 `json:"clocks_max_graphics"`
ClocksMaxMem float64 `json:"clocks_max_memory"`
}
// ═══════════════════════════════════════════════
@@ -1220,20 +1224,33 @@ func tryNvidiaSMI() []GPUInfo {
parts[i] = strings.Trim(parts[i], "[]")
}
// 0:index 1:name 2:uuid 3:bus_id 4:driver 5:vbios
// 6:temp_gpu 7:temp_mem 8:fan 9:power 10:power_limit
// 11:power_default 12:power_min 13:power_max
// 14:clk_gfx 15:clk_sm 16:clk_mem 17:clk_max_gfx 18:clk_max_mem
// 19:util_gpu 20:util_mem 21:util_enc 22:util_dec
// 23:vram_total 24:vram_used 25:vram_free
// 26:ecc_corr 27:ecc_uncorr 28:compute_mode 29:pstate
gpus = append(gpus, GPUInfo{
Name: cleanStr(parts[1]),
VRAMTotalMB: parseFloat(parts[23]),
VRAMUsedMB: parseFloat(parts[24]),
Temperature: parseFloat(parts[6]),
LoadPercent: parseFloat(parts[19]),
CoreClock: parseFloat(parts[14]),
MemClock: parseFloat(parts[16]),
Power: parseFloatSafe(parts[9]),
PowerLimit: parseFloatSafe(parts[10]),
FanSpeed: parseFloatSafe(parts[8]),
PState: cleanStr(parts[29]),
ComputeMode: cleanStr(parts[28]),
DriverVersion: cleanStr(parts[4]),
Name: cleanStr(parts[1]),
DriverVersion: cleanStr(parts[4]),
Temperature: parseFloat(parts[6]),
FanSpeed: parseFloat(parts[8]),
Power: parseFloat(parts[9]),
PowerLimit: parseFloat(parts[10]),
PowerMax: parseFloat(parts[13]),
CoreClock: parseFloat(parts[14]),
MemClock: parseFloat(parts[16]),
ClocksMaxGfx: parseFloat(parts[17]),
ClocksMaxMem: parseFloat(parts[18]),
LoadPercent: parseFloat(parts[19]),
UtilMemory: parseFloat(parts[20]),
UtilEncoder: parseFloat(parts[21]),
UtilDecoder: parseFloat(parts[22]),
VRAMTotalMB: parseFloat(parts[23]),
VRAMUsedMB: parseFloat(parts[24]),
ComputeMode: cleanStr(parts[28]),
PState: cleanStr(parts[29]),
})
}
return gpus
+65 -15
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
@@ -374,6 +375,55 @@ func (h *Hub) saveYAML() {
os.WriteFile(h.devicesAbs, append([]byte(hdr), out...), 0644)
}
func filterGPURaw(raw json.RawMessage) json.RawMessage {
var data map[string]interface{}
if json.Unmarshal(raw, &data) != nil {
return raw
}
gpusRaw, ok := data["gpus"]
if !ok {
return raw
}
gpus, ok := gpusRaw.([]interface{})
if !ok {
return raw
}
var filtered []interface{}
for _, g := range gpus {
gpu, ok := g.(map[string]interface{})
if !ok {
continue
}
gpuName, _ := gpu["name"].(string)
if isVirtualGPU(gpuName) {
continue
}
filtered = append(filtered, g)
}
data["gpus"] = filtered
out, _ := json.Marshal(data)
return out
}
func isVirtualGPU(name string) bool {
name = strings.ToLower(name)
blacklist := []string{
"microsoft",
"basic render",
"vmware",
"virtual",
"hyper-v",
"parallels",
"virtualbox",
}
for _, v := range blacklist {
if strings.Contains(name, v) {
return true
}
}
return false
}
func (h *Hub) UpdateDevice(raw json.RawMessage) {
var p AgentPayload
if json.Unmarshal(raw, &p) != nil || p.MachineName == "" {
@@ -398,7 +448,7 @@ func (h *Hub) UpdateDevice(raw json.RawMessage) {
rec.GPUs = append(rec.GPUs, GPURec{Name: g.Name, VRAM: g.VRAM})
}
cp := *rec
h.live[name] = raw
h.live[name] = filterGPURaw(raw)
var md *MiningData
var hist []HashratePoint
if m, ok := h.miningData[name]; ok {
@@ -416,10 +466,23 @@ func (h *Hub) UpdateDevice(raw json.RawMessage) {
h.saveYAML()
h.broadcast(BrowserMsg{
Type: "device", Machine: name,
Device: &BrowserDevice{Info: &cp, Live: raw, Online: true, Mining: md, History: hist},
Device: &BrowserDevice{Info: &cp, Live: filterGPURaw(raw), Online: true, Mining: md, History: hist},
})
}
func (h *Hub) DeleteDevice(name string) {
h.mu.Lock()
_, ok := h.records[name]
delete(h.records, name)
delete(h.live, name)
h.mu.Unlock()
if ok {
h.saveYAML()
h.broadcast(BrowserMsg{Type: "remove", Machine: name})
logInfo("delete", "%s", name)
}
}
func (h *Hub) MarkOffline(name string) {
h.mu.Lock()
_, had := h.live[name]
@@ -449,19 +512,6 @@ func (h *Hub) MarkOffline(name string) {
}
}
func (h *Hub) DeleteDevice(name string) {
h.mu.Lock()
_, ok := h.records[name]
delete(h.records, name)
delete(h.live, name)
h.mu.Unlock()
if ok {
h.saveYAML()
h.broadcast(BrowserMsg{Type: "remove", Machine: name})
logInfo("delete", "%s", name)
}
}
func (h *Hub) AddMonitor(c *websocket.Conn) {
h.mu.Lock()
h.monitors[c] = struct{}{}
+76 -42
View File
@@ -4,9 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Hardware Monitor</title>
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/ant-design-vue/1.7.8/antd.min.css">
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.7.16/vue.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/ant-design-vue/1.7.8/antd.min.js"></script>
<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: #0a0d13; --card: #111520; --sec: #161b28; --border: #1e253a;
@@ -60,7 +60,7 @@
.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(600px,1fr)); gap:12px }
.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) }
@@ -72,8 +72,11 @@
.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) }
.dc-gpu-full { padding:0 12px 8px }
.dc-mobile-mining { display:none }
.sec { background:var(--sec); border-radius:6px; padding:8px 12px }
.mining-sec { background:#1a2030; border:1px solid #2a3548 }
.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 }
@@ -83,7 +86,8 @@
.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-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(90px,1fr)); gap:4px }
@media (max-width:767px) { .gpu-grid { grid-template-columns:repeat(auto-fill,minmax(80px,1fr)) } }
.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) }
@@ -95,7 +99,6 @@
.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 }
.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 }
@@ -120,15 +123,15 @@
.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: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-hr-val { min-width:70px; text-align:right; font-family:var(--mono); font-size:10px; font-weight:500; flex-shrink:0; color:var(--text) }
.m-status { display:flex; align-items:center; gap:5px; font-size:10px; margin-bottom:5px; color:var(--text) }
.m-agent,.m-country { color:var(--text); 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:10px; 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; 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:6px 8px; margin-top:6px; overflow:hidden }
.chart-lbl { font-size:8px; color:var(--dim); font-family:var(--mono); margin-bottom:3px }
.m-times { display:flex; gap:10px; margin-top:5px; font-size:9px; 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 8px; margin-top:6px; overflow:hidden }
.chart-lbl { font-size:9px; color:var(--text); 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 }
@@ -229,13 +232,26 @@
<div class="sec"><div class="hw-row"><span class="sec-tt">硬件状态</span><span class="dot on"></span><span class="tag-on">在线</span></div></div>
<cpu-section :cpu="getLive(name).cpu||{}" :info="devices[name].info" :is-on="true"></cpu-section>
<mem-section :mem="getLive(name).memory||{}" :is-on="true"></mem-section>
<gpu-section :gpus="getLive(name).gpus||[]" :expanded-gpu="expandedGpu" @toggle-gpu="toggleGpu"></gpu-section>
</a-col>
<a-col :xs="0" :sm="0" :md="12" class="dc-col dc-right" style="flex:1 1 0">
<div class="sec"><div class="hw-row"><span class="sec-tt">矿池状态</span><span class="dot on"></span><span class="tag-on">在线</span></div></div>
<div class="sec hw-status"><div class="hw-row"><span class="sec-tt">矿池状态</span><span class="dot on"></span><span class="tag-on">在线</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">
<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"><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>
@@ -299,12 +315,13 @@ Vue.component('cpu-section',{
props:['cpu','info','isOn'],
template:`<div class="sec">
<div class="sec-tt">CPU · <span class="hl">{{ model }}</span></div>
<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" />
<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:barColor}">{{ pct }}%</span>
</div>
<div style="display:block"><a-progress :percent="pct" :stroke-color="barColor" :stroke-width="5" size="small" :format="fmtPct" /></div>
</div>`,
computed:{
model:function(){return this.cpu.model||(this.info&&this.info.cpu_model)||'Unknown'},
@@ -323,10 +340,12 @@ 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" 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 v-if="mem.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)">{{ used }} / {{ total }}</span>
<span class="value" :style="{color:barColor,fontSize:'10px',whiteSpace:'nowrap',marginLeft:'auto'}">{{ pct }}%</span>
</div>
<div style="display:block"><a-progress :percent="pct" :stroke-color="barColor" :stroke-width="5" size="small" :format="fmtPct" /></div>
</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)}},
@@ -335,7 +354,7 @@ Vue.component('mem-section',{
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</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 / {{ 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">
@@ -343,20 +362,23 @@ Vue.component('gpu-section',{
<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.core_clock_mhz||0) }} MHz</span></div>
<div class="gd-item"><span class="gd-l">显存频率</span><span class="gd-v">{{ Math.round(gg.mem_clock_mhz||0) }} MHz</span></div>
<div class="gd-item"><span class="gd-l">功耗范围</span><span class="gd-v">{{ 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.persistence_mode||'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);display:flex;gap:8px">
<div v-if="gg.driver_version" style="margin-top:2px;font-size:8px;color:var(--dim)">
<span>驱动: {{ gg.driver_version }}</span>
<span v-if="gg.cuda_version">CUDA: {{ gg.cuda_version }}</span>
</div>
</div>
</div></template>
@@ -369,7 +391,8 @@ Vue.component('gpu-section',{
</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)}
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)+'°':'—'},
@@ -380,7 +403,17 @@ Vue.component('gpu-section',{
vramStr:function(g){var t=(g||{}).vram_total||(g||{}).vram_total_mb||0,u=(g||{}).vram_used||(g||{}).vram_used_mb||0;return t>0?(u/1024).toFixed(1)+'/'+(t/1024).toFixed(0)+'G':'—'},
fanStr:function(g){var f=(g||{}).fan_speed||0;return f>0?Math.round(f)+'%':'—'},
fmtPct:function(v){return v+'%'},
getPopContainer:function(){return this.$el.closest('.device-card')||document.body},
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)}
@@ -389,21 +422,19 @@ Vue.component('gpu-section',{
Vue.component('mining-section',{
props:['mining','history'],
template:`<div v-if="mining"><div class="sec">
<div class="sec-tt">MINING · <span class="hl">{{ coin }}</span></div>
<div class="m-status"><span class="dot" :class="sOn?'on':'off'"></span>
<span :style="{color:sOn?'#00e5a0':'#ff4060',fontWeight:500,fontSize:'9px'}">{{ sOn?'在线':'离线' }}</span>
<span class="m-agent">{{ mining.agent||'' }}</span><span class="m-country" v-if="mining.country">{{ mining.country }}</span></div>
<div v-for="hr in hrs" :key="hr.l" class="m-hr-row"><span class="m-hr-lbl">{{ hr.l }}</span>
<div class="m-hr-bar"><div class="m-hr-fill" :style="{width:hr.p+'%',background:pColor(hr.p)}"></div></div>
<span class="m-hr-val" :style="{color:pColor(hr.p)}">{{ hr.v }}</span></div>
template:`<div v-if="mining"><div class="sec mining-sec">
<div class="sec-tt"><span class="hl">{{ coin }}</span><span style="margin-left:6px;font-size:8px;color:var(--dim)">{{ mining.agent||'' }}</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-times"><span>活跃 <span class="v">{{ fmtMsTs(mining.last_active) }}</span></span>
<span>开始 <span class="v">{{ fmtMsTs(mining.opened_at) }}</span></span>
<span v-if="durH">运行 <span class="v">{{ durH }} h</span></span></div>
<span>开始 <span class="v">{{ fmtMsTs(mining.opened_at) }}</span></span></div>
<div class="m-shares"><span class="m-sh valid">{{ mining.valid||0 }} 有效</span>
<span class="m-sh stale">{{ mining.stale||0 }} 过期</span><span class="m-sh invalid">{{ mining.invalid||0 }} 无效</span></div>
<div class="chart-box"><div class="chart-lbl">算力走势 (30m 均值)</div><div v-html="chartHtml"></div></div>
</div></div><div v-else class="sec"><div class="sec-tt">MINING</div><div style="color:var(--dim);font-size:9px;text-align:center;padding:10px 0">暂无矿池数据</div></div>`,
</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'},
@@ -451,6 +482,8 @@ 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'},
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;this.refreshing=true;setTimeout(function(){self.refreshing=false},500)},
countdown:function(iso){
@@ -464,6 +497,7 @@ new Vue({
return Math.floor(s/86400)+'天前';
},
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}))},