Files
pyTorrent/pytorrent/static/js/charts.js
Mateusz Gruszczyński b94cd52b26 changes_1
2026-05-20 06:54:21 +02:00

2 lines
20 KiB
JavaScript

export const chartsSource = " function drawTraffic(down, up){\n // Note: Live traffic rendering is throttled to animation frames to keep frequent socket updates smooth.\n traffic.push({down:Number(down||0), up:Number(up||0)});\n if(traffic.length>90) traffic.shift();\n if(drawTraffic.raf) return;\n drawTraffic.raf=requestAnimationFrame(()=>{\n drawTraffic.raf=0;\n const c=$('trafficChart');\n if(!c) return;\n const rect=c.getBoundingClientRect();\n const dpr=window.devicePixelRatio||1;\n const cssW=Math.max(120, Math.floor(rect.width||c.width||300));\n const cssH=Math.max(32, Math.floor(rect.height||c.height||80));\n if(c.width!==Math.floor(cssW*dpr) || c.height!==Math.floor(cssH*dpr)){\n c.width=Math.floor(cssW*dpr);\n c.height=Math.floor(cssH*dpr);\n }\n const ctx=c.getContext('2d');\n ctx.setTransform(dpr,0,0,dpr,0,0);\n ctx.clearRect(0,0,cssW,cssH);\n const max=Math.max(1,...traffic.map(p=>Math.max(p.down,p.up)));\n const pad=3;\n const drawSeries=(key,color)=>{\n ctx.beginPath();\n traffic.forEach((p,i)=>{\n const x=pad+i*((cssW-pad*2)/Math.max(1,traffic.length-1));\n const y=cssH-pad-(Number(p[key]||0)/max)*(cssH-pad*2);\n i?ctx.lineTo(x,y):ctx.moveTo(x,y);\n });\n ctx.lineWidth=1.75;\n ctx.lineJoin='round';\n ctx.lineCap='round';\n ctx.strokeStyle=color;\n ctx.stroke();\n };\n ctx.fillStyle='rgba(148,163,184,.12)';\n ctx.fillRect(0,0,cssW,cssH);\n drawSeries('down','#38bdf8');\n drawSeries('up','#f59e0b');\n });\n }\n function drawSystemUsage(cpu,ram){\n const c=$('systemChart'); if(!c) return;\n const cpuVal=Math.max(0,Math.min(100,Number(cpu||0)));\n const ramVal=Math.max(0,Math.min(100,Number(ram||0)));\n systemUsage.push({cpu:cpuVal,ram:ramVal}); if(systemUsage.length>60) systemUsage.shift();\n const ctx=c.getContext('2d'), w=c.width, h=c.height; ctx.clearRect(0,0,w,h);\n ctx.fillStyle='rgba(148,163,184,.18)'; ctx.fillRect(0,0,w,h);\n ctx.beginPath(); systemUsage.forEach((p,i)=>{const x=i*(w/Math.max(1,systemUsage.length-1)), y=h-(p.cpu/100)*h; i?ctx.lineTo(x,y):ctx.moveTo(x,y);}); ctx.strokeStyle='#a78bfa'; ctx.stroke();\n ctx.beginPath(); systemUsage.forEach((p,i)=>{const x=i*(w/Math.max(1,systemUsage.length-1)), y=h-(p.ram/100)*h; i?ctx.lineTo(x,y):ctx.moveTo(x,y);}); ctx.strokeStyle='#22c55e'; ctx.stroke();\n c.title=`CPU ${cpuVal.toFixed(1)}% / RAM ${ramVal.toFixed(1)}%`;\n }\n async function refreshUserDiskUsage(force=false){\n // Note: Profile switches force a fresh no-store disk read and ignore older in-flight responses.\n const now=Date.now();\n if(userDiskFetchInFlight && !force) return;\n if(!force && now-lastUserDiskFetchAt<15000) return;\n const seq=++userDiskFetchSeq;\n userDiskFetchInFlight=true;\n try{\n const res=await fetch(`/api/system/disk?_=${Date.now()}`, {cache:'no-store'});\n const json=await res.json();\n if(seq!==userDiskFetchSeq) return;\n if(json.ok && json.disk){\n lastUserDiskFetchAt=Date.now();\n drawDiskUsage(json.disk);\n }\n }catch(_){\n }finally{\n if(seq===userDiskFetchSeq) userDiskFetchInFlight=false;\n }\n }\n\n function diskUsageTooltip(disk){\n // Note: The footer tooltip explains the active disk source and every monitored path.\n const mode=disk.mode==='aggregate'?'Aggregate':disk.mode==='selected'?'Selected path':'Default rTorrent path';\n const lines=[mode, `Used: ${disk.used_h||'-'} / ${disk.total_h||'-'}`, `Free: ${disk.free_h||'-'}`];\n if(disk.path && disk.path!=='aggregate') lines.unshift(`Path: ${disk.path}`);\n if(disk.fallback) lines.push(`Measured on: ${disk.source_path||'-'}`);\n const paths=Array.isArray(disk.paths)?disk.paths:[];\n if(paths.length){\n lines.push('', 'Monitored paths:');\n paths.forEach(p=>{\n const marker=(disk.mode==='selected' && p.path===disk.path) ? '*' : '+';\n const measured=p.fallback && p.source_path ? `, measured on ${p.source_path}` : '';\n const pct=Number(p.percent||0);\n const shownPct=Number.isFinite(pct)?pct.toFixed(pct%1?1:0):'0';\n const status=p.ok ? `${shownPct}% used, ${p.free_h||'-'} free${measured}` : `unavailable${p.error?`: ${p.error}`:''}`;\n lines.push(`${marker} ${p.path}: ${status}`);\n });\n }\n return lines.join('\\n');\n }\n\n function drawDiskUsage(disk){\n const box=$('diskStatus'), label=$('statDisk'), c=$('diskChart');\n if(!box||!label||!c)return;\n const ctx=c.getContext('2d'), w=c.width, h=c.height;\n ctx.clearRect(0,0,w,h);\n const ok=disk&&disk.ok;\n const pct=ok?Math.max(0,Math.min(100,Number(disk.percent||0))):0;\n label.textContent=ok?`${pct.toFixed(pct%1?1:0)}%`:'-';\n box.classList.toggle('disk-warn', !ok || pct>=90);\n box.title=ok?diskUsageTooltip(disk):`Disk usage unavailable${disk?.error?`\n${disk.error}`:''}`;\n ctx.fillStyle='rgba(148,163,184,.22)'; ctx.fillRect(0,5,w,14);\n ctx.fillStyle=pct>=90?'#ef4444':pct>=75?'#f59e0b':'#22c55e'; ctx.fillRect(0,5,Math.round(w*pct/100),14);\n ctx.strokeStyle='rgba(148,163,184,.55)'; ctx.strokeRect(.5,5.5,w-1,13);\n }\n let lastTrafficHistory = null;\n let lastTrafficHistoryRange = '7d';\n let trafficHistoryAbort = null;\n const trafficHistoryCache = new Map();\n\n async function loadTrafficHistory(range=\"7d\", force=false){\n const info=$('trafficHistoryInfo');\n const volume=$('trafficHistoryChart');\n const speed=$('trafficSpeedChart');\n if(!volume||!speed) return;\n lastTrafficHistoryRange=range;\n const cached=trafficHistoryCache.get(range);\n if(cached && !force){\n lastTrafficHistory=cached;\n drawTrafficHistory(cached);\n updateTrafficHistoryInfo(cached);\n refreshTrafficHistoryInBackground(range);\n return;\n }\n if(info) info.textContent='Loading...';\n await fetchTrafficHistory(range, true);\n }\n\n async function refreshTrafficHistoryInBackground(range){\n try{ await fetchTrafficHistory(range, false); }catch(_){ }\n }\n\n async function fetchTrafficHistory(range, showErrors){\n if(trafficHistoryAbort) trafficHistoryAbort.abort();\n trafficHistoryAbort = new AbortController();\n try{\n const res=await fetch(`/api/traffic/history?range=${encodeURIComponent(range)}`,{signal:trafficHistoryAbort.signal,cache:'no-store'});\n const j=await res.json();\n if(!j.ok) throw new Error(j.error||'Failed to load history');\n const history=j.history || {rows:[],range};\n trafficHistoryCache.set(range, history);\n if(range===lastTrafficHistoryRange){\n lastTrafficHistory=history;\n drawTrafficHistory(history);\n updateTrafficHistoryInfo(history);\n }\n }catch(e){\n if(e.name==='AbortError') return;\n if(showErrors){\n const info=$('trafficHistoryInfo');\n if(info) info.textContent=e.message;\n [$('trafficHistoryChart'),$('trafficSpeedChart')].forEach(c=>{ if(c) c.getContext('2d').clearRect(0,0,c.width,c.height); });\n }\n }finally{\n trafficHistoryAbort=null;\n }\n }\n\n function updateTrafficHistoryInfo(hist){\n const info=$('trafficHistoryInfo');\n if(!info) return;\n const rows=Array.isArray(hist.rows)?hist.rows:[];\n const bucket=hist.bucket||'bucket';\n info.textContent=rows.length ? `${rows.length} ${bucket} bucket(s), retention ${hist.retention_days||90} days.` : 'No retained samples yet. Data is stored every minute while pyTorrent is running.';\n }\n\n function setupChartCanvas(canvas){\n const rect=canvas.getBoundingClientRect();\n const dpr=window.devicePixelRatio||1;\n const cssW=Math.max(320, Math.floor(rect.width || canvas.parentElement?.clientWidth || 900));\n const cssH=Math.max(340, Math.floor(rect.height || 420));\n const pxW=Math.floor(cssW*dpr), pxH=Math.floor(cssH*dpr);\n if(canvas.width!==pxW || canvas.height!==pxH){\n canvas.width=pxW;\n canvas.height=pxH;\n }\n const ctx=canvas.getContext('2d');\n ctx.setTransform(dpr,0,0,dpr,0,0);\n return {ctx,w:cssW,h:cssH,dpr};\n }\n function fmtBytes(v){\n v=Number(v||0);\n const u=['B','KiB','MiB','GiB','TiB'];\n let i=0;\n while(v>=1024&&i<u.length-1){v/=1024;i++;}\n return `${v.toFixed(i?1:0)} ${u[i]}`;\n }\n function formatBucketLabel(value, range){\n const text=String(value||'');\n if(!text) return '';\n if(range==='15m'||range==='1h'||range==='3h') return text.slice(11,16);\n if(range==='6h'||range==='24h') return text.slice(11,16);\n return text.slice(5,10);\n }\n function tooltipBucketLabel(value, range){\n const text=String(value||'');\n if(!text) return '-';\n if(range==='15m'||range==='1h'||range==='3h') return text.replace('T',' ').slice(0,16);\n if(range==='6h'||range==='24h') return text.replace('T',' ').slice(0,13)+':00';\n return text.slice(0,10);\n }\n function downsampleRows(rows, limit=360){\n if(rows.length<=limit) return rows.map(r=>({...r}));\n const step=Math.ceil(rows.length/limit);\n const output=[];\n for(let i=0;i<rows.length;i+=step){\n const chunk=rows.slice(i,i+step);\n const sums=chunk.reduce((acc,r)=>{\n acc.downloaded+=Number(r.downloaded||0);\n acc.uploaded+=Number(r.uploaded||0);\n acc.downRate+=Number(r.avg_down_rate||0);\n acc.upRate+=Number(r.avg_up_rate||0);\n return acc;\n },{downloaded:0,uploaded:0,downRate:0,upRate:0});\n output.push({\n bucket: chunk[0]?.bucket || '',\n bucket_end: chunk[chunk.length-1]?.bucket || chunk[0]?.bucket || '',\n downloaded: sums.downloaded,\n uploaded: sums.uploaded,\n avg_down_rate: sums.downRate/chunk.length,\n avg_up_rate: sums.upRate/chunk.length,\n });\n }\n return output;\n }\n function cssColor(name, fallback){\n const value=getComputedStyle(document.documentElement).getPropertyValue(name).trim();\n return value || fallback;\n }\n function chartTheme(){\n const body=getComputedStyle(document.body);\n return {\n body: body.color || '#d7d7d7',\n muted: cssColor('--bs-secondary-color', 'rgba(160,160,160,.85)'),\n grid: 'rgba(var(--bs-border-color-rgb, 128,128,128), .45)',\n panel: cssColor('--bs-body-bg', '#202020'),\n surface: cssColor('--bs-secondary-bg', '#2b2b2b'),\n border: cssColor('--bs-border-color', 'rgba(128,128,128,.6)'),\n down: '#2f63c7',\n up: '#209638',\n downFill: 'rgba(47,99,199,.36)',\n upFill: 'rgba(32,150,56,.32)',\n };\n }\n function drawChartPanel(ctx,w,h,theme){\n ctx.clearRect(0,0,w,h);\n ctx.fillStyle=theme.surface;\n ctx.fillRect(0,0,w,h);\n ctx.strokeStyle=theme.border;\n ctx.lineWidth=1;\n ctx.strokeRect(.5,.5,w-1,h-1);\n }\n function chartLayout(w,h){ return {left:72,right:22,top:34,bottom:42,width:w-94,height:h-76}; }\n function drawGrid(ctx,layout,maxValue,theme,suffix=''){\n ctx.strokeStyle=theme.grid;\n ctx.fillStyle=theme.muted;\n ctx.font='11px system-ui';\n ctx.lineWidth=1;\n for(let i=0;i<=4;i++){\n const y=layout.top+(layout.height*i/4);\n const value=maxValue*(1-i/4);\n ctx.beginPath();\n ctx.moveTo(layout.left,y);\n ctx.lineTo(layout.left+layout.width,y);\n ctx.stroke();\n ctx.fillText(`${fmtBytes(value)}${suffix}`,8,y+4);\n }\n }\n function drawLegend(ctx,title,theme,labels){\n ctx.fillStyle=theme.body;\n ctx.font='600 12px system-ui';\n ctx.fillText(title,14,21);\n ctx.font='11px system-ui';\n const width=ctx.canvas.width/(window.devicePixelRatio||1);\n const x=Math.max(120,width-154);\n ctx.fillStyle=theme.down; ctx.fillRect(x,10,12,10);\n ctx.fillStyle=theme.body; ctx.fillText(labels.down,x+18,19);\n ctx.fillStyle=theme.up; ctx.fillRect(x,28,12,10);\n ctx.fillStyle=theme.body; ctx.fillText(labels.up,x+18,37);\n }\n function pickXAxisIndexes(rows, maxTicks=9){\n if(rows.length<=1) return rows.length?[0]:[];\n const count=Math.min(maxTicks, rows.length);\n const seen=new Set();\n for(let i=0;i<count;i++) seen.add(Math.round(i*(rows.length-1)/(count-1)));\n return [...seen].sort((a,b)=>a-b);\n }\n function drawXAxis(ctx,layout,rows,theme,range){\n if(!rows.length) return;\n const y=layout.top+layout.height+20;\n ctx.fillStyle=theme.muted;\n ctx.font='11px system-ui';\n ctx.strokeStyle=theme.grid;\n pickXAxisIndexes(rows).forEach((idx)=>{\n const x=layout.left+idx*(layout.width/Math.max(1,rows.length-1));\n const label=formatBucketLabel(rows[idx].bucket, range);\n const width=ctx.measureText(label).width;\n ctx.beginPath();\n ctx.moveTo(x,layout.top+layout.height);\n ctx.lineTo(x,layout.top+layout.height+4);\n ctx.stroke();\n ctx.fillText(label,Math.max(2,Math.min(x-width/2,layout.left+layout.width-width)),y);\n });\n }\n function drawRuTorrentLine(ctx,rows,layout,maxValue,key,color,fillColor){\n const points=rows.map((r,i)=>({\n x: layout.left+i*(layout.width/Math.max(1,rows.length-1)),\n y: layout.top+layout.height-(Number(r[key]||0)/maxValue)*layout.height,\n }));\n if(!points.length) return;\n ctx.save();\n ctx.beginPath();\n points.forEach((p,i)=>{ i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y); });\n ctx.lineTo(points[points.length-1].x, layout.top+layout.height);\n ctx.lineTo(points[0].x, layout.top+layout.height);\n ctx.closePath();\n ctx.fillStyle=fillColor||color;\n ctx.fill();\n ctx.beginPath();\n points.forEach((p,i)=>{ i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y); });\n ctx.lineWidth=2.4;\n ctx.lineJoin='round';\n ctx.lineCap='round';\n ctx.strokeStyle=color;\n ctx.stroke();\n ctx.restore();\n }\n function drawHoverMarker(ctx,rows,layout,idx,theme){\n if(idx<0||idx>=rows.length) return;\n const x=layout.left+idx*(layout.width/Math.max(1,rows.length-1));\n ctx.save();\n ctx.strokeStyle=theme.body;\n ctx.globalAlpha=.55;\n ctx.beginPath();\n ctx.moveTo(x,layout.top);\n ctx.lineTo(x,layout.top+layout.height);\n ctx.stroke();\n ctx.restore();\n }\n function drawEmptyChart(canvas,message){\n const {ctx,w,h}=setupChartCanvas(canvas);\n const theme=chartTheme();\n drawChartPanel(ctx,w,h,theme);\n ctx.fillStyle=theme.muted;\n ctx.font='13px system-ui';\n ctx.fillText(message,18,34);\n }\n function tooltipNode(){\n let node=document.querySelector('.traffic-chart-tooltip');\n if(node) return node;\n node=document.createElement('div');\n node.className='traffic-chart-tooltip d-none';\n document.body.appendChild(node);\n return node;\n }\n function hideTrafficTooltip(){ tooltipNode().classList.add('d-none'); }\n function showTrafficTooltip(canvas,event,row,kind,range){\n const node=tooltipNode();\n const title=tooltipBucketLabel(row.bucket, range);\n const end=row.bucket_end && row.bucket_end!==row.bucket ? ` - ${tooltipBucketLabel(row.bucket_end, range)}` : '';\n const body=kind==='speed'\n ? `<div>Download: <b>${fmtBytes(row.avg_down_rate)}/s</b></div><div>Upload: <b>${fmtBytes(row.avg_up_rate)}/s</b></div>`\n : `<div>Downloaded: <b>${fmtBytes(row.downloaded)}</b></div><div>Uploaded: <b>${fmtBytes(row.uploaded)}</b></div>`;\n node.innerHTML=`<div class=\"traffic-tooltip-title\">${esc(title+end)}</div>${body}`;\n node.classList.remove('d-none');\n const box=node.getBoundingClientRect();\n let left=event.clientX+14;\n let top=event.clientY+14;\n if(left+box.width>window.innerWidth-8) left=event.clientX-box.width-14;\n if(top+box.height>window.innerHeight-8) top=event.clientY-box.height-14;\n node.style.left=`${Math.max(8,left)}px`;\n node.style.top=`${Math.max(8,top)}px`;\n }\n function attachTrafficTooltip(canvas,rows,layout,kind,range){\n canvas._trafficTooltip={rows,layout,kind,range};\n if(canvas._trafficTooltipReady) return;\n canvas._trafficTooltipReady=true;\n canvas.addEventListener('mousemove',event=>{\n const data=canvas._trafficTooltip;\n if(!data||!data.rows.length) return;\n const rect=canvas.getBoundingClientRect();\n const x=event.clientX-rect.left;\n const inside=x>=data.layout.left && x<=data.layout.left+data.layout.width;\n if(!inside){ hideTrafficTooltip(); return; }\n const idx=Math.max(0,Math.min(data.rows.length-1,Math.round((x-data.layout.left)/data.layout.width*(data.rows.length-1))));\n canvas._trafficHoverIndex=idx;\n drawTrafficHistory(lastTrafficHistory);\n showTrafficTooltip(canvas,event,data.rows[idx],data.kind,data.range);\n });\n canvas.addEventListener('mouseleave',()=>{ canvas._trafficHoverIndex=-1; hideTrafficTooltip(); if(lastTrafficHistory) drawTrafficHistory(lastTrafficHistory); });\n }\n function drawTrafficHistory(hist){\n const sourceRows=Array.isArray(hist.rows)?hist.rows:[];\n const rows=downsampleRows(sourceRows);\n const range=hist.range||lastTrafficHistoryRange||'7d';\n const volume=$('trafficHistoryChart'), speed=$('trafficSpeedChart');\n if(!volume||!speed) return;\n if(!sourceRows.length){\n drawEmptyChart(volume,'No history yet. Samples appear after pyTorrent records traffic.');\n drawEmptyChart(speed,'No speed samples yet.');\n return;\n }\n const theme=chartTheme();\n\n let canvas=setupChartCanvas(volume);\n let {ctx,w,h}=canvas;\n let layout=chartLayout(w,h);\n let maxVol=Math.max(1,...rows.map(r=>Math.max(Number(r.downloaded||0),Number(r.uploaded||0))));\n drawChartPanel(ctx,w,h,theme);\n drawGrid(ctx,layout,maxVol,theme,'');\n drawRuTorrentLine(ctx,rows,layout,maxVol,'downloaded',theme.down,theme.downFill);\n drawRuTorrentLine(ctx,rows,layout,maxVol,'uploaded',theme.up,theme.upFill);\n drawHoverMarker(ctx,rows,layout,volume._trafficHoverIndex ?? -1,theme);\n drawLegend(ctx,'Transferred data',theme,{down:'Downloaded',up:'Uploaded'});\n drawXAxis(ctx,layout,rows,theme,range);\n attachTrafficTooltip(volume,rows,layout,'transfer',range);\n\n canvas=setupChartCanvas(speed);\n ({ctx,w,h}=canvas);\n layout=chartLayout(w,h);\n const maxSpeed=Math.max(1,...rows.map(r=>Math.max(Number(r.avg_down_rate||0),Number(r.avg_up_rate||0))));\n drawChartPanel(ctx,w,h,theme);\n drawGrid(ctx,layout,maxSpeed,theme,'/s');\n drawRuTorrentLine(ctx,rows,layout,maxSpeed,'avg_down_rate',theme.down,theme.downFill);\n drawRuTorrentLine(ctx,rows,layout,maxSpeed,'avg_up_rate',theme.up,theme.upFill);\n drawHoverMarker(ctx,rows,layout,speed._trafficHoverIndex ?? -1,theme);\n drawLegend(ctx,'Speed trend',theme,{down:'Download',up:'Upload'});\n drawXAxis(ctx,layout,rows,theme,range);\n attachTrafficTooltip(speed,rows,layout,'speed',range);\n }\n $('trafficModal')?.addEventListener(\"show.bs.modal\",()=>loadTrafficHistory(lastTrafficHistoryRange||\"7d\"));\n document.querySelectorAll('.traffic-history-tab').forEach(tab=>tab.addEventListener('shown.bs.tab',()=>{ if(lastTrafficHistory) requestAnimationFrame(()=>drawTrafficHistory(lastTrafficHistory)); }));\n document.querySelectorAll(\".traffic-range\").forEach(b=>b.addEventListener(\"click\",()=>{\n document.querySelectorAll(\".traffic-range\").forEach(x=>{x.classList.remove(\"btn-primary\");x.classList.add(\"btn-outline-secondary\");});\n b.classList.add(\"btn-primary\"); b.classList.remove(\"btn-outline-secondary\");\n loadTrafficHistory(b.dataset.range||\"7d\");\n }));\n window.addEventListener('resize',()=>{ if(document.body.classList.contains('modal-open') && lastTrafficHistory) requestAnimationFrame(()=>drawTrafficHistory(lastTrafficHistory)); });\n";