178 lines
27 KiB
JavaScript
178 lines
27 KiB
JavaScript
'use strict';
|
||
const $ = id => document.getElementById(id);
|
||
const colors = {green:'#b3ee80',blue:'#6bbcff',purple:'#b4a1f4',amber:'#f5be6a',red:'#ff8c86'};
|
||
const initialSection=location.hash.slice(1);
|
||
let latest = null, selectedRange = '24h', historyData = null, eventPage = 0, eventParams = new URLSearchParams({days:'7',severity:'problem',view:'issues'});
|
||
let eventData = null, statusBusy = false, historySequence = 0, eventsBusy = false, eventSequence = 0, eventController = null;
|
||
function el(tag, cls, text) { const e=document.createElement(tag);if(cls)e.className=cls;if(text!==undefined)e.textContent=text;return e; }
|
||
function icon(name) {const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');const use=document.createElementNS(svg.namespaceURI,'use');use.setAttribute('href','#'+name);svg.append(use);return svg;}
|
||
function valid(v) {return v!==null&&v!==undefined&&Number.isFinite(Number(v));}
|
||
function num(v, digits=0) {return valid(v)?Number(v).toFixed(digits):'—';}
|
||
function when(v, full=true) {if(!v)return '—';const d=typeof v==='number'?new Date(v*1000):new Date(v);if(Number.isNaN(d.getTime()))return '时间未知';return new Intl.DateTimeFormat('zh-CN',{timeZone:'Asia/Shanghai',...(full?{year:'numeric',month:'2-digit',day:'2-digit'}:{}),hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}).format(d);}
|
||
function duration(s){if(!valid(s))return '—';s=Math.max(0,Math.round(s));const d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);return `${d?d+' 天 ':''}${h} 小时 ${m} 分钟`;}
|
||
function toast(text){$('toast').textContent=text;$('toast').hidden=false;clearTimeout(toast.timer);toast.timer=setTimeout(()=>$('toast').hidden=true,4500);}
|
||
async function api(path){const response=await fetch(path,{signal:AbortSignal.timeout(9000),cache:'no-store'});if(!response.ok)throw new Error('HTTP '+response.status);return response.json();}
|
||
function meter(id,value){$(id).style.width=Math.min(100,Math.max(0,Number(value)||0))+'%';}
|
||
function metric(id,value,unit,digits=0){$(id).replaceChildren(document.createTextNode(num(value,digits)),el('small','',unit));}
|
||
function connectionLost(message){$('connection').replaceChildren(el('i','dot'),document.createTextNode('连接中断'));$('connection').firstChild.style.background=colors.red;$('health-banner').className='health-banner error';$('health-title').textContent='暂时无法确认设备状态';$('health-description').textContent=message||'网页与设备的连接已中断。下面保留的是上次数据,请勿当作实时状态。';$('health-icon').replaceChildren(icon('alert').firstChild);}
|
||
function renderStatus(data){
|
||
if(!data.ts){connectionLost('采集器正在启动,暂未取得实时数据,请稍候。');return;}
|
||
const readingAnchor=captureSectionAnchor();
|
||
latest=data;
|
||
$('host-name').textContent=data.host||'设备';$('last-update').textContent=when(data.ts,false);
|
||
if(data.stale){connectionLost('设备采样超过 35 秒未更新,当前状态未知。');return;}
|
||
$('connection').replaceChildren(el('i','dot'),document.createTextNode('实时连接 · 10 秒'));
|
||
const issues=data.issues||[],error=issues.some(x=>x.severity==='ERROR'),warn=issues.length>0;
|
||
$('health-banner').className='health-banner'+(error?' error':warn?' warning':'');
|
||
const onlyRetries=warn&&issues.every(x=>x.category==='pcie_correctable')&&data.gpus.length>=data.expected_gpu_count;
|
||
$('health-title').textContent=error?'设备存在故障,需要处理':onlyRetries?'显卡还能用,但连接需要检查':warn?'设备需要关注':'设备运行正常';
|
||
$('health-description').textContent=onlyRetries?'显卡当前在线,主板与显卡之间仍有通信重试。可以继续观察,安排空闲时检查连接。':warn?`${issues.length} 项需要关注,具体原因和处理建议见下方。`:`${data.gpus.length}/${data.expected_gpu_count} 块计算显卡在线 · 当前采样未发现异常`;
|
||
$('health-icon').replaceChildren(icon(warn?'alert':'check').firstChild);
|
||
const list=$('current-issues');list.hidden=!issues.length;list.replaceChildren();
|
||
issues.forEach(item=>{const card=el('article','issue'+(item.severity==='ERROR'?' error':''));const device=item.component_label?item.component_label+':':'';card.append(el('strong','',device+item.title),el('p','',item.meaning+' '+item.detail),el('small','', '怎么处理:'+item.action));list.append(card);});
|
||
$('nav-alert').hidden=!issues.length;$('nav-alert').textContent=String(issues.length);
|
||
metric('cpu-value',data.cpu,'%',1);meter('cpu-bar',data.cpu);$('cpu-threads').textContent=(data.cpu_sockets?data.cpu_sockets+' 路 CPU · ':'')+(data.cpu_threads||'—')+' 线程';$('cpu-temp').textContent=num(data.cpu_temp)+' °C';$('cpu-temp').title=data.cpu_name;
|
||
metric('memory-value',data.memory,'%');meter('memory-bar',data.memory);$('memory-used').textContent=`${num(data.memory_total_gib-data.memory_available_gib,1)} / ${num(data.memory_total_gib,1)} GiB`;$('memory-free').textContent=num(data.memory_available_gib,1)+' GiB 可用';
|
||
metric('gpu-count',data.gpus.length,`/ ${data.expected_gpu_count} 在线`);$('gpu-online-text').replaceChildren(el('i','dot'),document.createTextNode(data.gpus.length>=data.expected_gpu_count?'全部预期显卡可用':'显卡数量少于预期'));$('gpu-online-text').firstChild.style.background=data.gpus.length>=data.expected_gpu_count?colors.green:colors.red;$('gpu-type').textContent=data.gpus[0]?.name?.includes('P100')?'Tesla P100':'NVIDIA';
|
||
const gpuSelect=$('gpu-expected-count');
|
||
if(gpuSelect.dataset.pending===String(data.expected_gpu_count)){delete gpuSelect.dataset.pending;delete gpuSelect.dataset.dirty;}
|
||
if(!gpuSelect.dataset.dirty&&!gpuSelect.dataset.saving)gpuSelect.value=String(data.expected_gpu_count);
|
||
gpuSelect.disabled=!!gpuSelect.dataset.saving;$('gpu-config-save').disabled=!!gpuSelect.dataset.saving;
|
||
$('gpu-check-mode').textContent=data.gpu_count_only?'按所选数量检查,支持更换显卡':'当前按显卡身份检查';
|
||
const disks=Object.values(data.smart||{}),diskBad=!data.hardware_stale&&disks.some(x=>x.passed===false||x.critical_warning),diskKnown=!data.hardware_stale&&disks.length&&disks.every(x=>x.available&&x.passed===true);
|
||
$('disk-health').textContent=diskBad?'需要检查':diskKnown?'健康正常':'状态未知';$('disk-health').style.color=diskBad?colors.red:diskKnown?colors.green:colors.amber;
|
||
$('disk-health-detail').textContent=diskKnown?'SMART 检查通过':diskBad?'请展开磁盘相关日志':'尚未取得完整 SMART 数据';$('disk-space').textContent=`已用 ${num(data.disk_used_gib,0)} / ${num(data.disk_total_gib,0)} GiB`;$('disk-temp').textContent=num(data.disk_temp)+' °C';
|
||
renderGPUs(data);
|
||
$('uptime').textContent=duration(data.uptime_s);['disk-read','disk-write','net-rx','net-tx'].forEach(id=>$(id).textContent=num(data.rates?.[id.replace('-','_')],2)+' MiB/s');
|
||
const start=data.history_started_at?when(data.history_started_at):'本次部署';
|
||
$('history-note').textContent=`CPU、内存和显卡使用率从 ${start} 开始记录。已有的显卡温度、功耗历史已补录(旧记录约 15 分钟一条);空白表示该时段没有采样。曲线为时段均值。`;
|
||
restoreSectionAnchor(readingAnchor);queueNavigation();
|
||
}
|
||
function renderGPUs(data){
|
||
const holder=$('gpu-grid');holder.replaceChildren();
|
||
holder.classList.toggle('single',Math.max(data.gpus.length,data.expected_gpu_count)===1);
|
||
const gpus=[...data.gpus].sort((a,b)=>a.uuid.localeCompare(b.uuid));
|
||
gpus.forEach((gpu,index)=>{
|
||
const card=el('article','gpu-card');card.style.setProperty('--accent',index%2?colors.amber:colors.blue);
|
||
const head=el('div','gpu-head');const state=el('span','gpu-state');state.append(el('i','dot'),document.createTextNode('在线'));head.append(el('span','gpu-label','GPU '+String(index+1).padStart(2,'0')),el('h3','',gpu.name?.replace('Tesla ','')||'NVIDIA GPU'),state);
|
||
const content=el('div','gpu-content'),util=el('div','gpu-util');util.append(document.createTextNode(num(gpu.utilization)),el('small','','%'),el('span','',valid(gpu.utilization)&&gpu.utilization>5?'正在计算':'空闲 / 低负载'));
|
||
const numbers=el('div','gpu-numbers');[['温度',gpu.temperature_c,'°C'],['功耗',gpu.power_w,'W']].forEach(([label,value,unit])=>{const cell=el('div','gpu-number'),strong=el('strong','',num(value));strong.append(el('small','',unit));if(unit==='°C'&&value>=data.gpu_warn_c)strong.style.color=colors.red;cell.append(el('span','',label),strong);numbers.append(cell);});content.append(util,numbers);
|
||
const vram=el('div','gpu-vram');vram.append(el('span','','显存占用'),el('strong','',`${num(valid(gpu.memory_used_mib)?gpu.memory_used_mib/1024:null,1)} / ${num(valid(gpu.memory_total_mib)?gpu.memory_total_mib/1024:null,0)} GiB`));
|
||
const bar=el('div','meter'),fill=el('i');fill.style.width=(gpu.memory_total_mib?Math.min(100,gpu.memory_used_mib/gpu.memory_total_mib*100):0)+'%';bar.append(fill);
|
||
const meta=el('div','gpu-meta');meta.append(el('span','',gpu.pci.replace(/^00000000:/,'0000:')),el('span','',gpu.uuid.slice(0,16)+'…'),el('span','',`功耗上限 ${num(gpu.power_limit_w)}W`));meta.title=gpu.uuid;
|
||
card.append(head,content,vram,bar,meta);holder.append(card);
|
||
});
|
||
for(let i=gpus.length;i<data.expected_gpu_count;i++){const card=el('article','gpu-card');card.style.setProperty('--accent',colors.red);card.append(el('h3','','显卡未就绪'),el('p','','当前未读取到全部预期显卡。请查看顶部异常说明和下方故障日志。'));holder.append(card);}
|
||
}
|
||
async function loadStatus(){if(statusBusy)return;statusBusy=true;try{renderStatus(await api('/api/status'));}catch(error){connectionLost();}finally{statusBusy=false;}}
|
||
function svgNode(tag,attributes={},text){const e=document.createElementNS('http://www.w3.org/2000/svg',tag);Object.entries(attributes).forEach(([k,v])=>e.setAttribute(k,v));if(text!==undefined)e.textContent=text;return e;}
|
||
function chart(id,legendId,series,data,unit,maxValue){
|
||
const container=$(id),legend=$(legendId);container.replaceChildren();legend.replaceChildren();
|
||
series.forEach(s=>{const item=el('span','legend-item'),swatch=el('i','legend-swatch');swatch.style.background=s.color;item.append(swatch,document.createTextNode(s.name));legend.append(item);});
|
||
const width=560,height=190,left=36,right=12,top=10,bottom=27,w=width-left-right,h=height-top-bottom;
|
||
const svg=svgNode('svg',{viewBox:`0 0 ${width} ${height}`,preserveAspectRatio:'none','aria-hidden':'true'});
|
||
const values=series.flatMap(s=>data.points.map(p=>s.value(p)).filter(valid));
|
||
const ceiling=Math.max(maxValue||50,Math.ceil(Math.max(0,...values)/25)*25);
|
||
const x=t=>left+(t-data.start)/(data.end-data.start)*w,y=v=>top+h-(v/ceiling)*h;
|
||
for(let i=0;i<=4;i++){const v=ceiling*i/4,yy=y(v);svg.append(svgNode('line',{x1:left,y1:yy,x2:width-right,y2:yy,class:'grid-line'}),svgNode('text',{x:left-8,y:yy+3,'text-anchor':'end'},num(v)));}
|
||
for(let i=0;i<=4;i++){const t=data.start+(data.end-data.start)*i/4;const label=new Intl.DateTimeFormat('zh-CN',{timeZone:'Asia/Shanghai',...(data.end-data.start>86400?{month:'2-digit',day:'2-digit'}:{hour:'2-digit',minute:'2-digit',hour12:false})}).format(new Date(t*1000));svg.append(svgNode('text',{x:x(t),y:height-6,'text-anchor':i===0?'start':i===4?'end':'middle'},label));}
|
||
series.forEach(s=>{
|
||
const points=data.points.filter(p=>valid(s.value(p)));let path='',last=null;
|
||
points.forEach(p=>{const gap=last? p.ts-last.ts:0;const maxGap=p.live||last?.live?Math.max(data.step*3,45):Math.max(data.step*3,1250);path+=(last&&gap<=maxGap?'L':'M')+x(p.ts).toFixed(2)+','+y(s.value(p)).toFixed(2);last=p;});
|
||
if(path)svg.append(svgNode('path',{d:path,stroke:s.color,class:'series-line'}));
|
||
if(points.length<4)points.forEach(p=>svg.append(svgNode('circle',{cx:x(p.ts),cy:y(s.value(p)),r:2.4,fill:s.color})));
|
||
});
|
||
if(unit==='°C'){svg.append(svgNode('line',{x1:left,x2:width-right,y1:y(85),y2:y(85),stroke:colors.amber,'stroke-width':'.7','stroke-dasharray':'5 6',opacity:'.4'}));}
|
||
const cursor=svgNode('line',{x1:0,x2:0,y1:top,y2:height-bottom,class:'cursor',visibility:'hidden'}),overlay=svgNode('rect',{x:left,y:top,width:w,height:h,class:'chart-overlay'});svg.append(cursor,overlay);container.append(svg);
|
||
const tooltip=el('div','chart-tooltip');tooltip.hidden=true;container.append(tooltip);
|
||
if(!values.length){const empty=el('div','chart-empty');empty.append(el('span','','这个时段还没有性能数据'),el('small','','采集运行后,曲线会自动出现'));container.append(empty);}
|
||
overlay.addEventListener('pointermove',event=>{
|
||
const bounds=svg.getBoundingClientRect(),px=Math.max(left,Math.min(width-right,(event.clientX-bounds.left)/bounds.width*width));
|
||
const t=data.start+(px-left)/w*(data.end-data.start);let closest=null;
|
||
data.points.forEach(p=>{if(!closest||Math.abs(p.ts-t)<Math.abs(closest.ts-t))closest=p;});
|
||
cursor.setAttribute('x1',px);cursor.setAttribute('x2',px);cursor.setAttribute('visibility','visible');tooltip.hidden=false;tooltip.replaceChildren();
|
||
const available=closest&&Math.abs(closest.ts-t)<=Math.max(data.step*1.5,closest.live?20:900);
|
||
tooltip.append(el('strong','',when(available?closest.ts:t)));
|
||
if(available){series.forEach(s=>{const row=el('p'),value=s.value(closest);row.append(el('span','',s.name),el('span','',valid(value)?num(value,1)+' '+unit:'未采集'));tooltip.append(row);});}else tooltip.append(el('p','','该时段没有采样'));
|
||
tooltip.style.left=Math.max(3,Math.min(container.clientWidth-190,(event.clientX-bounds.left)+12))+'px';
|
||
});overlay.addEventListener('pointerleave',()=>{cursor.setAttribute('visibility','hidden');tooltip.hidden=true;});
|
||
}
|
||
function renderHistory(data){
|
||
historyData=data;
|
||
const devices=Object.keys(data.devices).sort();const palette=[colors.blue,colors.amber,colors.purple,colors.green];
|
||
const gpuSeries=key=>devices.map((uuid,i)=>({name:'GPU '+String(i+1).padStart(2,'0'),color:palette[i%palette.length],value:p=>p.gpus?.[uuid]?.[key]}));
|
||
chart('chart-system','legend-system',[{name:'CPU',color:colors.green,value:p=>p.cpu},{name:'内存',color:colors.purple,value:p=>p.memory}],data,'%',100);
|
||
chart('chart-gpu','legend-gpu',gpuSeries('utilization'),data,'%',100);
|
||
chart('chart-temp','legend-temp',[{name:'CPU 最高温度',color:colors.green,value:p=>p.cpu_temp},...gpuSeries('temperature_c')],data,'°C',100);
|
||
chart('chart-power','legend-power',gpuSeries('power_w'),data,'W',Math.max(250,...data.points.flatMap(p=>Object.values(p.gpus||{}).map(g=>g.power_w||0)))*1.08);
|
||
$('history-caption').textContent=`${when(data.start)} — ${when(data.end)} · 曲线为时段均值,移入可查看数值`;
|
||
}
|
||
async function loadHistory(){const seq=++historySequence;try{const data=await api('/api/history?range='+selectedRange);if(seq===historySequence)renderHistory(data);}catch(error){if(seq===historySequence)$('history-caption').textContent='曲线暂时无法读取,请稍后重试。';}}
|
||
function addTime(container,label,value){const cell=el('div');cell.append(el('span','',label),el('strong','',value));container.append(cell);}
|
||
function renderEvents(data,preservePosition=true){
|
||
eventData=data;const holder=$('event-list');
|
||
const opened=new Set([...holder.querySelectorAll('details[open]')].map(x=>x.dataset.id));
|
||
const visible=preservePosition?[...holder.querySelectorAll('.event-row')].find(x=>x.getBoundingClientRect().bottom>70&&x.getBoundingClientRect().top<innerHeight):null;
|
||
const anchor=visible?{id:visible.dataset.id,top:visible.getBoundingClientRect().top}:null;
|
||
holder.replaceChildren();
|
||
if($('category').options.length===1){data.categories.forEach(item=>{const option=el('option','',item.name);option.value=item.id;$('category').append(option);});}
|
||
$('event-count').textContent=data.view==='raw'?`共 ${data.total} 条技术记录`:`合并后 ${data.total} 段问题记录 · 来自 ${data.raw_total} 条技术记录,不是故障次数`;
|
||
if(!data.items.length){const empty=el('div','empty-events');empty.append(el('strong','','这个范围内没有匹配记录'),el('span','','可以扩大日期范围,或选择“全部问题”。'));holder.append(empty);}
|
||
data.items.forEach(item=>{
|
||
const details=el('details','event-row');details.dataset.id=item.id;details.open=opened.has(item.id);
|
||
const summary=el('summary'),severity=item.severity==='ERROR'?'error':item.severity==='INFO'?'info':'';
|
||
const main=el('div','event-main');main.append(el('strong','',item.title),el('p','',item.component_label||'这台电脑'));
|
||
const time=el('div','event-time',when(item.last_ts));time.append(el('small','',item.time_note?'最近一次 · 时间有估算':'最近一次发生'));
|
||
const historical=item.state==='history'||item.state==='related';const badge=item.severity==='ERROR'?(historical?'曾有故障':'需处理'):item.severity==='WARN'?(historical?'曾需关注':'需关注'):'记录';
|
||
summary.append(el('span','event-level '+severity,badge),main,el('span','event-state '+item.state+' '+(item.severity==='WARN'?'warning':''),item.state_label),time,el('span','event-chevron','›'));
|
||
const detail=el('div','event-detail');
|
||
[['这是什么问题',item.meaning],['现在还能不能用',item.impact]].forEach(([label,text])=>{const answer=el('div','plain-answer');answer.append(el('strong','',label),el('p','',text));detail.append(answer);});
|
||
const action=el('div','maintenance');action.append(el('strong','','你现在需要做什么'),el('p','maintenance-priority',item.urgency));const steps=el('ol');(item.next_steps||[item.action]).forEach(text=>steps.append(el('li','',text)));action.append(steps);detail.append(action);
|
||
const times=el('div','time-grid friendly-times');addTime(times,item.time_note?'这段问题开始(估算)':'这段问题从何时开始',when(item.first_ts));addTime(times,'最近一次发生',when(item.last_ts));addTime(times,'监控最近读到记录',when(item.observed_at));addTime(times,'当前状态检查于',when(item.current_checked_at));detail.append(times);
|
||
detail.append(el('p','record-summary',`已合并 ${item.record_count||1} 条技术记录,共 ${item.count} 行日志。${item.signal_reports?'其中有 '+item.signal_reports+' 条报错报告。':''}这些数字不是设备损坏次数。`));
|
||
if(item.time_note)detail.append(el('div','time-warning',item.time_note));
|
||
const technical=el('details','technical-details');technical.dataset.id=item.id+'-technical';technical.open=opened.has(technical.dataset.id);technical.append(el('summary','','查看原始技术日志(维护人员用)'));
|
||
const evidence=el('div','technical-body'),rawTimes=el('div','time-grid');
|
||
if(item.raw.first_monotonic_us)addTime(rawTimes,'原始批次:该次启动后经过',num(Number(item.raw.first_monotonic_us)/1e6,3)+' 秒 — '+num(Number(item.raw.last_monotonic_us)/1e6,3)+' 秒');
|
||
if(item.raw.boot_id)addTime(rawTimes,'启动 ID',item.raw.boot_id);
|
||
if(item.raw.first_at)addTime(rawTimes,'原始批次首次时间(未改写)',when(item.raw.first_at));
|
||
if(item.raw.last_at)addTime(rawTimes,'原始批次末次时间(未改写)',when(item.raw.last_at));
|
||
if(item.devices?.length)addTime(rawTimes,'涉及的硬件地址',item.devices.join(' / '));
|
||
evidence.append(rawTimes);(item.raw_examples||[item.raw]).forEach(row=>evidence.append(el('pre','raw-log',row.detail||JSON.stringify(row,null,2))));technical.append(evidence);detail.append(technical);
|
||
details.append(summary,detail);holder.append(details);
|
||
});
|
||
$('page-label').textContent=`第 ${data.page+1} 页 · 每页最多 30 条`;$('previous-page').disabled=data.page===0;$('next-page').disabled=!data.more;
|
||
if(anchor&&!navigationLock){const replacement=[...holder.querySelectorAll('.event-row')].find(x=>x.dataset.id===anchor.id);if(replacement){const delta=replacement.getBoundingClientRect().top-anchor.top;if(Math.abs(delta)>1)scrollPage(scrollYValue()+delta,false);}}
|
||
queueNavigation();
|
||
}
|
||
async function loadEvents(force=false){if(eventsBusy&&!force)return;if(force)eventController?.abort();const seq=++eventSequence;eventsBusy=true;const controller=new AbortController();eventController=controller;const timer=setTimeout(()=>controller.abort(),10000);try{const params=new URLSearchParams(eventParams);params.set('page',eventPage);const response=await fetch('/api/events?'+params.toString(),{signal:controller.signal,cache:'no-store'});if(!response.ok)throw new Error('HTTP '+response.status);const data=await response.json();if(seq===eventSequence)renderEvents(data,!force);}catch(error){if(seq===eventSequence&&error.name!=='AbortError')$('event-count').textContent='暂时无法读取记录,请点击“查询日志”重试。';}finally{clearTimeout(timer);if(seq===eventSequence)eventsBusy=false;}}
|
||
function applyEventFilters(){if($('date-from').value&&$('date-to').value&&$('date-from').value>$('date-to').value){toast('开始日期不能晚于结束日期。');return;}eventParams=new URLSearchParams({days:'7'});[['view','event-view'],['category','category'],['severity','severity'],['from','date-from'],['to','date-to'],['q','event-search']].forEach(([key,id])=>{if($(id).value)eventParams.set(key,$(id).value);});eventPage=0;$('event-count').textContent='正在按所选条件查询…';loadEvents(true);}
|
||
$('event-filter').addEventListener('submit',event=>{event.preventDefault();applyEventFilters();});
|
||
['event-view','category','severity','date-from','date-to'].forEach(id=>$(id).addEventListener('change',applyEventFilters));let searchTimer;$('event-search').addEventListener('input',()=>{clearTimeout(searchTimer);searchTimer=setTimeout(applyEventFilters,400);});
|
||
$('previous-page').addEventListener('click',()=>{if(!eventsBusy&&eventPage>0){eventPage--;loadEvents(true);}});$('next-page').addEventListener('click',()=>{if(!eventsBusy&&eventData?.more){eventPage++;loadEvents(true);}});
|
||
$('export-events').addEventListener('click',()=>{const params=new URLSearchParams(eventParams);params.set('page',eventPage);window.location.assign('/api/events.csv?'+params.toString());});
|
||
$('ranges').addEventListener('click',event=>{const button=event.target.closest('button[data-range]');if(!button)return;selectedRange=button.dataset.range;document.querySelectorAll('#ranges button').forEach(x=>x.classList.toggle('selected',x===button));$('history-caption').textContent='正在加载所选范围…';loadHistory();});
|
||
$('fullscreen').addEventListener('click',async()=>{try{if(document.fullscreenElement)await document.exitFullscreen();else await document.body.requestFullscreen();}catch(error){toast('此浏览器不支持网页全屏,可按 F11。');}});
|
||
const sections=[{id:'overview',name:'设备总览'},{id:'trends',name:'性能趋势'},{id:'events',name:'问题与维护'},{id:'tasks',name:'定时任务'},{id:'guide',name:'使用说明'}];
|
||
let navigationLock=null,navigationTimer=null,navigationFrame=null,currentSection='overview',userHasScrolled=false;
|
||
function scrollRoot(){return document.fullscreenElement===document.body?document.body:document.scrollingElement;}
|
||
function scrollYValue(){return scrollRoot()?.scrollTop??window.scrollY;}
|
||
function scrollPage(top,smooth=true){const options={top,behavior:smooth&&!matchMedia('(prefers-reduced-motion: reduce)').matches?'smooth':'instant'};if(document.fullscreenElement===document.body)document.body.scrollTo(options);else window.scrollTo(options);}
|
||
function captureSectionAnchor(){if(navigationLock||scrollYValue()<200||currentSection==='overview')return null;const target=$(currentSection);return target?{id:currentSection,top:target.getBoundingClientRect().top}:null;}
|
||
function restoreSectionAnchor(anchor){if(!anchor||navigationLock)return;const target=$(anchor.id);if(target){const delta=target.getBoundingClientRect().top-anchor.top;if(Math.abs(delta)>1)scrollPage(scrollYValue()+delta,false);}}
|
||
function markSection(id,updateHash=true){const index=Math.max(0,sections.findIndex(x=>x.id===id));currentSection=id;document.querySelectorAll('[data-section-link]').forEach(link=>{const active=link.dataset.sectionLink===id;link.classList.toggle('active',active);if(active)link.setAttribute('aria-current','location');else link.removeAttribute('aria-current');});$('current-section').textContent=sections[index].name;$('next-section').textContent=index<sections.length-1?'下一项:'+sections[index+1].name+' ↓':'↑ 回到设备总览';if(updateHash&&location.hash!=='#'+id)history.replaceState(null,'','#'+id);}
|
||
function synchronizeNavigation(){navigationFrame=null;const root=scrollRoot();if(!root)return;const y=scrollYValue(),total=Math.max(1,root.scrollHeight-root.clientHeight);$('reading-progress').style.width=Math.min(100,y/total*100)+'%';$('back-to-top').hidden=y<500;if(document.querySelector('.task-dialog[open]')){if(currentSection!=='tasks')markSection('tasks');return;}if(navigationLock)return;let active='overview';sections.forEach(section=>{if($(section.id).getBoundingClientRect().top<=105)active=section.id;});if(y>0&&y>=total-4)active='guide';if(active!==currentSection)markSection(active);}
|
||
function queueNavigation(){if(!navigationFrame)navigationFrame=requestAnimationFrame(synchronizeNavigation);}
|
||
function navigateSection(id,smooth=true){if(!sections.some(s=>s.id===id))return;userHasScrolled=true;clearTimeout(navigationTimer);navigationLock=id;markSection(id);scrollPage(id==='overview'?0:Math.max(0,$(id).getBoundingClientRect().top+scrollYValue()-76),smooth);navigationTimer=setTimeout(()=>{navigationLock=null;queueNavigation();},smooth?1300:50);}
|
||
document.querySelectorAll('[data-section-link]').forEach(link=>link.addEventListener('click',event=>{event.preventDefault();navigateSection(link.dataset.sectionLink);}));
|
||
$('back-to-top').addEventListener('click',()=>navigateSection('overview'));$('next-section').addEventListener('click',()=>{const index=sections.findIndex(s=>s.id===currentSection);navigateSection(sections[index+1]?.id||'overview');});
|
||
window.addEventListener('scroll',()=>{queueNavigation();if(navigationLock){clearTimeout(navigationTimer);navigationTimer=setTimeout(()=>{navigationLock=null;queueNavigation();},180);}},{passive:true,capture:true});
|
||
function manualScrollIntent(){userHasScrolled=true;if(navigationLock){scrollPage(scrollYValue(),false);clearTimeout(navigationTimer);navigationLock=null;}queueNavigation();}
|
||
['wheel','touchstart'].forEach(name=>window.addEventListener(name,manualScrollIntent,{passive:true}));
|
||
window.addEventListener('keydown',event=>{if(['PageDown','PageUp','ArrowDown','ArrowUp','Home','End',' '].includes(event.key))manualScrollIntent();});
|
||
window.addEventListener('hashchange',()=>navigateSection(location.hash.slice(1),false));window.addEventListener('resize',queueNavigation);document.addEventListener('fullscreenchange',queueNavigation);
|
||
window.addEventListener('online',()=>{loadStatus();loadHistory();loadEvents();});
|
||
Promise.allSettled([loadStatus(),loadHistory(),loadEvents()]).then(()=>{if(!userHasScrolled&§ions.some(s=>s.id===initialSection))navigateSection(initialSection,false);else queueNavigation();});
|
||
setInterval(loadStatus,10000);setInterval(()=>{if(!document.hidden){loadHistory();loadEvents();}},30000);
|
||
document.addEventListener('visibilitychange',()=>{if(!document.hidden){loadStatus();loadHistory();loadEvents();}});
|