@@ -24,7 +24,7 @@ import {
2424} from '@/components/ui/select' ;
2525import { Alert , AlertDescription } from '@/components/ui/alert' ;
2626import { Button } from '@/components/ui/button' ;
27- import { Loader2 , AlertCircle , Layers , BarChart3 } from 'lucide-react' ;
27+ import { Loader2 , AlertCircle , Layers , BarChart3 , ArrowUpDown } from 'lucide-react' ;
2828
2929// Gradient ID mapping for each client
3030const DOWNLOAD_GRADIENT_IDS : Record < string , string > = {
@@ -190,17 +190,19 @@ export const BandwidthChart: React.FC<BandwidthChartProps> = ({
190190 timeRanges,
191191} ) => {
192192 const [ rawData , setRawData ] = useState < ChartDataPoint [ ] > ( [ ] ) ;
193- const [ data , setData ] = useState < ChartDataPoint [ ] > ( [ ] ) ;
194193 const [ isInitialLoad , setIsInitialLoad ] = useState ( true ) ;
195194 const [ error , setError ] = useState ( '' ) ;
196- const [ scalingRatio , setScalingRatio ] = useState ( 1 ) ;
197195 const [ visibleSeries , setVisibleSeries ] = useState < Record < string , boolean > > ( loadVisibleSeries ) ;
198196 const [ downloadClients , setDownloadClients ] = useState < DownloadClient [ ] > ( [ ] ) ;
199197 const [ snmpEnabled , setSnmpEnabled ] = useState < boolean > ( false ) ;
200198 const [ stackChart , setStackChart ] = useState < boolean > ( ( ) => {
201199 const saved = localStorage . getItem ( 'speedarr_chart_stacked' ) ;
202200 return saved !== null ? JSON . parse ( saved ) : false ;
203201 } ) ;
202+ const [ flipped , setFlipped ] = useState < boolean > ( ( ) => {
203+ const saved = localStorage . getItem ( 'speedarr_chart_flipped' ) ;
204+ return saved !== null ? JSON . parse ( saved ) : false ;
205+ } ) ;
204206 const [ clientOrder , setClientOrder ] = useState < string [ ] > ( ( ) => {
205207 try {
206208 const saved = localStorage . getItem ( 'speedarr_chart_client_order' ) ;
@@ -213,6 +215,9 @@ export const BandwidthChart: React.FC<BandwidthChartProps> = ({
213215 useEffect ( ( ) => {
214216 localStorage . setItem ( 'speedarr_chart_stacked' , JSON . stringify ( stackChart ) ) ;
215217 } , [ stackChart ] ) ;
218+ useEffect ( ( ) => {
219+ localStorage . setItem ( 'speedarr_chart_flipped' , JSON . stringify ( flipped ) ) ;
220+ } , [ flipped ] ) ;
216221 useEffect ( ( ) => {
217222 if ( clientOrder . length > 0 ) {
218223 localStorage . setItem ( 'speedarr_chart_client_order' , JSON . stringify ( clientOrder ) ) ;
@@ -453,85 +458,85 @@ export const BandwidthChart: React.FC<BandwidthChartProps> = ({
453458 if ( aggregatedData . length === 0 ) return { data : [ ] , ratio : 1 } ;
454459
455460 // Find max values for scaling - only include visible series
456- let maxDownload = 0 ;
457- let maxNegative = 0 ;
461+ // When flipped, uploads are positive (on top) and downloads are negated (below zero)
462+ let maxPositive = 0 ;
463+ let maxToNegate = 0 ;
458464
459465 aggregatedData . forEach ( ( point ) => {
460- // Only include visible download series in max calculation
466+ // Compute download totals from visible series
461467 let totalDownload = 0 ;
462468 if ( visibleSeries . qbittorrent_download ) totalDownload += point . qbittorrent_speed || 0 ;
463469 if ( visibleSeries . sabnzbd_download ) totalDownload += point . sabnzbd_speed || 0 ;
464470 if ( visibleSeries . nzbget_download ) totalDownload += point . nzbget_speed || 0 ;
465471 if ( visibleSeries . transmission_download ) totalDownload += point . transmission_speed || 0 ;
466472 if ( visibleSeries . deluge_download ) totalDownload += point . deluge_speed || 0 ;
467473
468- // Include SNMP download only if visible
469- if ( visibleSeries . snmp_download ) {
470- const snmpDownload = point . snmp_download_speed || 0 ;
471- maxDownload = Math . max ( maxDownload , totalDownload , snmpDownload ) ;
472- } else {
473- maxDownload = Math . max ( maxDownload , totalDownload ) ;
474- }
474+ const snmpDownloadVal = visibleSeries . snmp_download ? ( point . snmp_download_speed || 0 ) : 0 ;
475475
476- // Only include visible upload series in max calculation
476+ // Compute upload totals from visible series
477477 let totalUpload = 0 ;
478478 if ( visibleSeries . plex_streams ) totalUpload += point . stream_bandwidth || 0 ;
479479 if ( visibleSeries . qbittorrent_upload ) totalUpload += point . qbittorrent_upload_speed || 0 ;
480480 if ( visibleSeries . transmission_upload ) totalUpload += point . transmission_upload_speed || 0 ;
481481 if ( visibleSeries . deluge_upload ) totalUpload += point . deluge_upload_speed || 0 ;
482482
483- // Include SNMP upload only if visible
484- const snmpUpload = visibleSeries . snmp_upload ? ( point . snmp_upload_speed || 0 ) : 0 ;
483+ const snmpUploadVal = visibleSeries . snmp_upload ? ( point . snmp_upload_speed || 0 ) : 0 ;
485484
486485 // Include upload limits only if their respective limit lines are visible
487486 let maxUploadLimit = 0 ;
488487 if ( visibleSeries . qbittorrent_upload_limit_line ) maxUploadLimit = Math . max ( maxUploadLimit , point . qbittorrent_upload_limit || 0 ) ;
489488 if ( visibleSeries . transmission_upload_limit_line ) maxUploadLimit = Math . max ( maxUploadLimit , point . transmission_upload_limit || 0 ) ;
490489 if ( visibleSeries . deluge_upload_limit_line ) maxUploadLimit = Math . max ( maxUploadLimit , point . deluge_upload_limit || 0 ) ;
491490
492- maxNegative = Math . max ( maxNegative , totalUpload , snmpUpload , maxUploadLimit ) ;
491+ if ( flipped ) {
492+ // Uploads on top (positive), downloads negated
493+ maxPositive = Math . max ( maxPositive , totalUpload , snmpUploadVal , maxUploadLimit ) ;
494+ maxToNegate = Math . max ( maxToNegate , totalDownload , snmpDownloadVal ) ;
495+ } else {
496+ // Downloads on top (positive), uploads negated
497+ maxPositive = Math . max ( maxPositive , totalDownload , snmpDownloadVal ) ;
498+ maxToNegate = Math . max ( maxToNegate , totalUpload , snmpUploadVal , maxUploadLimit ) ;
499+ }
493500 } ) ;
494501
495502 // Calculate scaling ratio
496- const ratio = ( maxDownload > 0 && maxNegative > 0 ) ? maxDownload / maxNegative : 1 ;
503+ const ratio = ( maxPositive > 0 && maxToNegate > 0 ) ? maxPositive / maxToNegate : 1 ;
497504
498505 // Transform data and include limits as line data
506+ // When flipped, uploads stay positive and downloads get negated+scaled (and vice versa)
499507 const chartData = aggregatedData . map ( ( point ) => ( {
500508 ...point ,
501- // Download series (positive values, stacked)
502- qbittorrent_download : point . qbittorrent_speed || 0 ,
503- sabnzbd_download : point . sabnzbd_speed || 0 ,
504- nzbget_download : point . nzbget_speed || 0 ,
505- transmission_download : point . transmission_speed || 0 ,
506- deluge_download : point . deluge_speed || 0 ,
507- // Upload series (negative values, scaled)
508- plex_streams : - Math . abs ( point . stream_bandwidth || 0 ) * ratio ,
509- qbittorrent_upload : - Math . abs ( point . qbittorrent_upload_speed || 0 ) * ratio ,
510- transmission_upload : - Math . abs ( point . transmission_upload_speed || 0 ) * ratio ,
511- deluge_upload : - Math . abs ( point . deluge_upload_speed || 0 ) * ratio ,
509+ // Download series
510+ qbittorrent_download : flipped ? - Math . abs ( point . qbittorrent_speed || 0 ) * ratio : ( point . qbittorrent_speed || 0 ) ,
511+ sabnzbd_download : flipped ? - Math . abs ( point . sabnzbd_speed || 0 ) * ratio : ( point . sabnzbd_speed || 0 ) ,
512+ nzbget_download : flipped ? - Math . abs ( point . nzbget_speed || 0 ) * ratio : ( point . nzbget_speed || 0 ) ,
513+ transmission_download : flipped ? - Math . abs ( point . transmission_speed || 0 ) * ratio : ( point . transmission_speed || 0 ) ,
514+ deluge_download : flipped ? - Math . abs ( point . deluge_speed || 0 ) * ratio : ( point . deluge_speed || 0 ) ,
515+ // Upload series
516+ plex_streams : flipped ? Math . abs ( point . stream_bandwidth || 0 ) : - Math . abs ( point . stream_bandwidth || 0 ) * ratio ,
517+ qbittorrent_upload : flipped ? Math . abs ( point . qbittorrent_upload_speed || 0 ) : - Math . abs ( point . qbittorrent_upload_speed || 0 ) * ratio ,
518+ transmission_upload : flipped ? Math . abs ( point . transmission_upload_speed || 0 ) : - Math . abs ( point . transmission_upload_speed || 0 ) * ratio ,
519+ deluge_upload : flipped ? Math . abs ( point . deluge_upload_speed || 0 ) : - Math . abs ( point . deluge_upload_speed || 0 ) * ratio ,
512520 // Download limit lines
513- qbittorrent_download_limit_line : point . qbittorrent_download_limit || null ,
514- sabnzbd_download_limit_line : point . sabnzbd_download_limit || null ,
515- nzbget_download_limit_line : point . nzbget_download_limit || null ,
516- transmission_download_limit_line : point . transmission_download_limit || null ,
517- deluge_download_limit_line : point . deluge_download_limit || null ,
518- // Upload limit lines (negative and scaled)
519- qbittorrent_upload_limit_line : point . qbittorrent_upload_limit ? - Math . abs ( point . qbittorrent_upload_limit ) * ratio : null ,
520- transmission_upload_limit_line : point . transmission_upload_limit ? - Math . abs ( point . transmission_upload_limit ) * ratio : null ,
521- deluge_upload_limit_line : point . deluge_upload_limit ? - Math . abs ( point . deluge_upload_limit ) * ratio : null ,
522- // Add SNMP actual bandwidth - upload is negative and scaled like other uploads
523- snmp_download : point . snmp_download_speed ?? null ,
524- snmp_upload : point . snmp_upload_speed ? - Math . abs ( point . snmp_upload_speed ) * ratio : null ,
521+ qbittorrent_download_limit_line : flipped ? ( point . qbittorrent_download_limit ? - Math . abs ( point . qbittorrent_download_limit ) * ratio : null ) : ( point . qbittorrent_download_limit || null ) ,
522+ sabnzbd_download_limit_line : flipped ? ( point . sabnzbd_download_limit ? - Math . abs ( point . sabnzbd_download_limit ) * ratio : null ) : ( point . sabnzbd_download_limit || null ) ,
523+ nzbget_download_limit_line : flipped ? ( point . nzbget_download_limit ? - Math . abs ( point . nzbget_download_limit ) * ratio : null ) : ( point . nzbget_download_limit || null ) ,
524+ transmission_download_limit_line : flipped ? ( point . transmission_download_limit ? - Math . abs ( point . transmission_download_limit ) * ratio : null ) : ( point . transmission_download_limit || null ) ,
525+ deluge_download_limit_line : flipped ? ( point . deluge_download_limit ? - Math . abs ( point . deluge_download_limit ) * ratio : null ) : ( point . deluge_download_limit || null ) ,
526+ // Upload limit lines
527+ qbittorrent_upload_limit_line : flipped ? ( point . qbittorrent_upload_limit || null ) : ( point . qbittorrent_upload_limit ? - Math . abs ( point . qbittorrent_upload_limit ) * ratio : null ) ,
528+ transmission_upload_limit_line : flipped ? ( point . transmission_upload_limit || null ) : ( point . transmission_upload_limit ? - Math . abs ( point . transmission_upload_limit ) * ratio : null ) ,
529+ deluge_upload_limit_line : flipped ? ( point . deluge_upload_limit || null ) : ( point . deluge_upload_limit ? - Math . abs ( point . deluge_upload_limit ) * ratio : null ) ,
530+ // SNMP bandwidth
531+ snmp_download : flipped ? ( point . snmp_download_speed ? - Math . abs ( point . snmp_download_speed ) * ratio : null ) : ( point . snmp_download_speed ?? null ) ,
532+ snmp_upload : flipped ? ( point . snmp_upload_speed ?? null ) : ( point . snmp_upload_speed ? - Math . abs ( point . snmp_upload_speed ) * ratio : null ) ,
525533 } ) ) ;
526534
527535 return { data : chartData , ratio } ;
528- } , [ aggregatedData , visibleSeries , stackChart ] ) ;
536+ } , [ aggregatedData , visibleSeries , stackChart , flipped ] ) ;
529537
530- // Update state from memoized values
531- useEffect ( ( ) => {
532- setData ( transformedData . data ) ;
533- setScalingRatio ( transformedData . ratio ) ;
534- } , [ transformedData ] ) ;
538+ const data = transformedData . data ;
539+ const scalingRatio = transformedData . ratio ;
535540
536541 useEffect ( ( ) => {
537542 fetchData ( ) ;
@@ -656,6 +661,18 @@ export const BandwidthChart: React.FC<BandwidthChartProps> = ({
656661 { stackChart ? < Layers className = "h-4 w-4" aria-hidden = "true" /> : < BarChart3 className = "h-4 w-4" aria-hidden = "true" /> }
657662 { stackChart ? 'Stacked' : 'Overlapping' }
658663 </ Button >
664+ < Button
665+ variant = "outline"
666+ size = "sm"
667+ onClick = { ( ) => setFlipped ( ! flipped ) }
668+ className = "gap-2"
669+ title = { flipped ? 'Uploads on top — click to put downloads on top' : 'Downloads on top — click to put uploads on top' }
670+ aria-label = { flipped ? 'Currently showing uploads on top, click to flip' : 'Currently showing downloads on top, click to flip' }
671+ aria-pressed = { flipped }
672+ >
673+ < ArrowUpDown className = "h-4 w-4" aria-hidden = "true" />
674+ { flipped ? 'UL on Top' : 'DL on Top' }
675+ </ Button >
659676 </ div >
660677 </ div >
661678 </ CardHeader >
@@ -685,63 +702,63 @@ export const BandwidthChart: React.FC<BandwidthChartProps> = ({
685702 ) }
686703 < ResponsiveContainer width = "100%" height = { 700 } >
687704 < ComposedChart
705+ key = { `chart-${ flipped } ` }
688706 data = { data }
689707 margin = { { top : 10 , right : 10 , left : 10 , bottom : 20 } }
690- stackOffset = "sign"
691708 >
692- < defs >
709+ < defs key = { `chart-defs- ${ flipped } ` } >
693710 { /* Download gradients - only for enabled clients */ }
694711 { isClientEnabled ( 'qbittorrent' ) && (
695- < linearGradient id = "qbDownload" x1 = "0" y1 = "0" x2 = "0" y2 = "1" >
712+ < linearGradient id = "qbDownload" x1 = "0" y1 = { flipped ? "1" : "0" } x2 = "0" y2 = { flipped ? "0" : "1" } >
696713 < stop offset = "5%" stopColor = { qbitInfo . color } stopOpacity = { 0.8 } />
697714 < stop offset = "95%" stopColor = { qbitInfo . color } stopOpacity = { 0.3 } />
698715 </ linearGradient >
699716 ) }
700717 { isClientEnabled ( 'sabnzbd' ) && (
701- < linearGradient id = "sabDownload" x1 = "0" y1 = "0" x2 = "0" y2 = "1" >
718+ < linearGradient id = "sabDownload" x1 = "0" y1 = { flipped ? "1" : "0" } x2 = "0" y2 = { flipped ? "0" : "1" } >
702719 < stop offset = "5%" stopColor = { sabInfo . color } stopOpacity = { 0.8 } />
703720 < stop offset = "95%" stopColor = { sabInfo . color } stopOpacity = { 0.3 } />
704721 </ linearGradient >
705722 ) }
706723 { isClientEnabled ( 'nzbget' ) && (
707- < linearGradient id = "nzbgetDownload" x1 = "0" y1 = "0" x2 = "0" y2 = "1" >
724+ < linearGradient id = "nzbgetDownload" x1 = "0" y1 = { flipped ? "1" : "0" } x2 = "0" y2 = { flipped ? "0" : "1" } >
708725 < stop offset = "5%" stopColor = { nzbgetInfo . color } stopOpacity = { 0.8 } />
709726 < stop offset = "95%" stopColor = { nzbgetInfo . color } stopOpacity = { 0.3 } />
710727 </ linearGradient >
711728 ) }
712729 { isClientEnabled ( 'transmission' ) && (
713- < linearGradient id = "transmissionDownload" x1 = "0" y1 = "0" x2 = "0" y2 = "1" >
730+ < linearGradient id = "transmissionDownload" x1 = "0" y1 = { flipped ? "1" : "0" } x2 = "0" y2 = { flipped ? "0" : "1" } >
714731 < stop offset = "5%" stopColor = { transmissionInfo . color } stopOpacity = { 0.8 } />
715732 < stop offset = "95%" stopColor = { transmissionInfo . color } stopOpacity = { 0.3 } />
716733 </ linearGradient >
717734 ) }
718735 { isClientEnabled ( 'deluge' ) && (
719- < linearGradient id = "delugeDownload" x1 = "0" y1 = "0" x2 = "0" y2 = "1" >
736+ < linearGradient id = "delugeDownload" x1 = "0" y1 = { flipped ? "1" : "0" } x2 = "0" y2 = { flipped ? "0" : "1" } >
720737 < stop offset = "5%" stopColor = { delugeInfo . color } stopOpacity = { 0.8 } />
721738 < stop offset = "95%" stopColor = { delugeInfo . color } stopOpacity = { 0.3 } />
722739 </ linearGradient >
723740 ) }
724741 { /* Upload gradients - only for enabled clients that support upload */ }
725742 { isClientEnabled ( 'qbittorrent' ) && clientSupportsUpload ( 'qbittorrent' ) && (
726- < linearGradient id = "qbUpload" x1 = "0" y1 = "1" x2 = "0" y2 = "0" >
743+ < linearGradient id = "qbUpload" x1 = "0" y1 = { flipped ? "0" : "1" } x2 = "0" y2 = { flipped ? "1" : "0" } >
727744 < stop offset = "5%" stopColor = { qbitInfo . color } stopOpacity = { 0.8 } />
728745 < stop offset = "95%" stopColor = { qbitInfo . color } stopOpacity = { 0.3 } />
729746 </ linearGradient >
730747 ) }
731748 { isClientEnabled ( 'transmission' ) && clientSupportsUpload ( 'transmission' ) && (
732- < linearGradient id = "transmissionUpload" x1 = "0" y1 = "1" x2 = "0" y2 = "0" >
749+ < linearGradient id = "transmissionUpload" x1 = "0" y1 = { flipped ? "0" : "1" } x2 = "0" y2 = { flipped ? "1" : "0" } >
733750 < stop offset = "5%" stopColor = { transmissionInfo . color } stopOpacity = { 0.8 } />
734751 < stop offset = "95%" stopColor = { transmissionInfo . color } stopOpacity = { 0.3 } />
735752 </ linearGradient >
736753 ) }
737754 { isClientEnabled ( 'deluge' ) && clientSupportsUpload ( 'deluge' ) && (
738- < linearGradient id = "delugeUpload" x1 = "0" y1 = "1" x2 = "0" y2 = "0" >
755+ < linearGradient id = "delugeUpload" x1 = "0" y1 = { flipped ? "0" : "1" } x2 = "0" y2 = { flipped ? "1" : "0" } >
739756 < stop offset = "5%" stopColor = { delugeInfo . color } stopOpacity = { 0.8 } />
740757 < stop offset = "95%" stopColor = { delugeInfo . color } stopOpacity = { 0.3 } />
741758 </ linearGradient >
742759 ) }
743760 { /* Plex streams gradient - always shown */ }
744- < linearGradient id = "plexStreams" x1 = "0" y1 = "1" x2 = "0" y2 = "0" >
761+ < linearGradient id = "plexStreams" x1 = "0" y1 = { flipped ? "0" : "1" } x2 = "0" y2 = { flipped ? "1" : "0" } >
745762 < stop offset = "5%" stopColor = "#ff7300" stopOpacity = { 0.8 } />
746763 < stop offset = "95%" stopColor = "#ff7300" stopOpacity = { 0.3 } />
747764 </ linearGradient >
0 commit comments