hardware-dashboard/static/app.js

178 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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&&sections.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();}});