279279 }
280280 .cb-snap-item { color : var (--text-dim ); }
281281 .cb-snap-pass { color : var (--green ); font-weight : 700 ; }
282+ .cb-snap-slow { color : var (--yellow ); font-weight : 700 ; }
282283 .cb-snap-fail { color : var (--red ); font-weight : 700 ; }
283284 .cb-complete-msg {
284285 font-family : var (--font-display );
@@ -768,7 +769,8 @@ <h1 class="live-page-title">
768769 </ div >
769770 < div class ="cb-snapshot-row ">
770771 < span class ="cb-snap-item "> Batch size: < strong id ="cbSnapshot "> —</ strong > </ span >
771- < span class ="cb-snap-item "> Passed: < strong class ="cb-snap-pass " id ="cbPassed "> 0</ strong > </ span >
772+ < span class ="cb-snap-item "> Passed SLA: < strong class ="cb-snap-pass " id ="cbPassed "> 0</ strong > </ span >
773+ < span class ="cb-snap-item "> Slow: < strong class ="cb-snap-slow " id ="cbSlow "> 0</ strong > </ span >
772774 < span class ="cb-snap-item "> Failed: < strong class ="cb-snap-fail " id ="cbFailed "> 0</ strong > </ span >
773775 </ div >
774776 < div class ="cb-complete-msg " id ="cbCompleteMsg "> </ div >
@@ -1040,11 +1042,11 @@ <h1 class="live-page-title">
10401042 testedAt : r2 . testedAt || null ,
10411043 } ) ;
10421044 }
1043- // Sync the current-batch counter to the seeded rows so the
1044- // Tested tile shows real progress on a cold refresh.
1045- _cb . tested = resultsArr . length ;
1046- _cb . passed = resultsArr . filter ( x => Number ( x . actualMbps ) >= 10 ) . length ;
1047- _cb . failed = _cb . tested - _cb . passed ;
1045+ // Sync the current-batch counter to the seeded rows so the Tested
1046+ // tile shows real progress on a cold refresh, then let cbRender derive
1047+ // the Passed SLA / Slow / Failed partition from the rows.
1048+ _cb . tested = resultsArr . length ;
1049+ cbRender ( ) ;
10481050 }
10491051 applyHeaderStatsFromState ( ) ;
10501052 renderLiveStats ( ) ;
@@ -1504,6 +1506,7 @@ <h1 class="live-page-title">
15041506 iteration : null ,
15051507 tested : 0 ,
15061508 passed : 0 ,
1509+ slow : 0 ,
15071510 failed : 0 ,
15081511 gapEndsAt : null ,
15091512 gapTimer : null ,
@@ -1518,7 +1521,7 @@ <h1 class="live-page-title">
15181521 const startRaw = data . startedAt || data . started_at ;
15191522 _cb . startedAt = startRaw ? new Date ( startRaw ) . getTime ( ) : Date . now ( ) ;
15201523 _cb . iteration = data . iteration ?? null ;
1521- _cb . tested = 0 ; _cb . passed = 0 ; _cb . failed = 0 ;
1524+ _cb . tested = 0 ; _cb . passed = 0 ; _cb . slow = 0 ; _cb . failed = 0 ;
15221525 _cb . gapEndsAt = null ;
15231526 if ( _cb . gapTimer ) { clearInterval ( _cb . gapTimer ) ; _cb . gapTimer = null ; }
15241527 const dot = _cbEl ( 'cbDot' ) ;
@@ -1528,21 +1531,19 @@ <h1 class="live-page-title">
15281531 cbRender ( ) ;
15291532 }
15301533
1531- function cbApplyNodeResult ( data ) {
1532- _cb . tested ++ ;
1533- const passed = data . passed != null
1534- ? ! ! data . passed
1535- : ( data . actual_mbps != null && Number ( data . actual_mbps ) >= 10 ) || ( data . actualMbps != null && Number ( data . actualMbps ) >= 10 ) ;
1536- if ( passed ) _cb . passed ++ ;
1537- else _cb . failed ++ ;
1534+ function cbApplyNodeResult ( ) {
1535+ // The row is already upserted into resultsArr by the caller; cbRender is the
1536+ // single source of truth — it recomputes Tested + the Passed SLA / Slow /
1537+ // Failed partition from resultsArr. Don't accumulate counts here (that path
1538+ // used a 2-bucket SLA-only definition that disagreed with cbRender).
15381539 cbRender ( ) ;
15391540 }
15401541
1541- function cbApplyBatchEnd ( data ) {
1542- data = data || { } ;
1543- if ( data . passed != null ) _cb . passed = data . passed ;
1544- if ( data . failed != null ) _cb . failed = data . failed ;
1545- _cb . tested = _cb . passed + _cb . failed ;
1542+ function cbApplyBatchEnd ( ) {
1543+ // Don't trust the server's batch:end passed/failed — they use a different
1544+ // definition ( passed = total testedNodes, failed = hard errors only) that
1545+ // conflicts with cbRender's resultsArr-derived partition. Just stop the
1546+ // pulse and let cbRender recompute from the authoritative rows.
15461547 const dot = _cbEl ( 'cbDot' ) ;
15471548 if ( dot ) dot . className = 'pulse-dot' ;
15481549 cbRender ( ) ;
@@ -1627,11 +1628,27 @@ <h1 class="live-page-title">
16271628 }
16281629 }
16291630
1630- // Pass/Fail derived from resultsArr (matches admin's recompute logic).
1631- const passed = arr . filter ( r => Number ( r . actualMbps ) >= 10 ) . length ;
1632- const failed = arr . filter ( r => ! r . skipped && r . errorCode !== 'TEST_RUN_SKIP' && ( r . error || r . errorCode || r . actualMbps == null ) ) . length ;
1631+ // Outcome buckets — mutually exclusive, derived from resultsArr (the single
1632+ // source of truth). They PARTITION the tested (non-skipped) nodes so that
1633+ // Passed SLA + Slow + Failed always reconciles. Priority: a hard error or no
1634+ // speed reading is Failed; otherwise ≥10 Mbps is Passed SLA; a connected
1635+ // sub-10 reading is Slow. Skipped / TEST RUN rows aren't outcomes — they're
1636+ // surfaced in the stat tiles, not here. (This replaced a 2-bucket scheme
1637+ // where slow-but-connected nodes fell through both filters, e.g. 7 tested
1638+ // showing 5 passed / 0 failed with the 2 slow nodes uncounted.)
1639+ let passed = 0 , slow = 0 , failed = 0 ;
1640+ for ( const r of arr ) {
1641+ if ( r . skipped || r . errorCode === 'TEST_RUN_SKIP' ) continue ;
1642+ const m = r . actualMbps != null ? Number ( r . actualMbps ) : null ;
1643+ if ( m == null || r . error || r . errorCode ) failed ++ ;
1644+ else if ( m >= 10 ) passed ++ ;
1645+ else slow ++ ;
1646+ }
1647+ _cb . passed = passed ; _cb . slow = slow ; _cb . failed = failed ;
16331648 const passEl = _cbEl ( 'cbPassed' ) ;
16341649 if ( passEl ) passEl . textContent = passed ;
1650+ const slowEl = _cbEl ( 'cbSlow' ) ;
1651+ if ( slowEl ) slowEl . textContent = slow ;
16351652 const failEl = _cbEl ( 'cbFailed' ) ;
16361653 if ( failEl ) failEl . textContent = failed ;
16371654
@@ -2158,11 +2175,11 @@ <h1 class="live-page-title">
21582175 testRun : err === 'TEST_RUN_SKIP' ,
21592176 testedAt : tested ,
21602177 } ) ;
2161- cbApplyNodeResult ( { passed : resultMbps != null && resultMbps >= 10 } ) ;
2178+ cbApplyNodeResult ( ) ;
21622179 break ;
21632180 }
21642181 case 'batch:end' : {
2165- cbApplyBatchEnd ( d ) ;
2182+ cbApplyBatchEnd ( ) ;
21662183 const next = d . next_in_ms ? Math . round ( d . next_in_ms / 1000 ) : null ;
21672184 const passed = d . passed != null ? d . passed : _cb . passed ;
21682185 const failed = d . failed != null ? d . failed : _cb . failed ;
@@ -2230,7 +2247,7 @@ <h1 class="live-page-title">
22302247 resultsArr . length = 0 ;
22312248 const tbody = document . getElementById ( 'resultsBody' ) ;
22322249 if ( tbody ) tbody . innerHTML = '' ;
2233- _cb . tested = 0 ; _cb . passed = 0 ; _cb . failed = 0 ;
2250+ _cb . tested = 0 ; _cb . passed = 0 ; _cb . slow = 0 ; _cb . failed = 0 ;
22342251 }
22352252 }
22362253 if ( Array . isArray ( d . results ) && d . results . length ) {
@@ -2254,11 +2271,10 @@ <h1 class="live-page-title">
22542271 testedAt : r . testedAt || null ,
22552272 } ) ;
22562273 }
2257- // Sync the current-batch counter to the replayed rows so the
2258- // Tested tile + cb panel reflect actual progress on first paint.
2259- _cb . tested = resultsArr . length ;
2260- _cb . passed = resultsArr . filter ( x => Number ( x . actualMbps ) >= 10 ) . length ;
2261- _cb . failed = _cb . tested - _cb . passed ;
2274+ // Sync the current-batch counter to the replayed rows so the Tested
2275+ // tile + cb panel reflect actual progress on first paint; cbRender
2276+ // (below) derives the Passed SLA / Slow / Failed partition.
2277+ _cb . tested = resultsArr . length ;
22622278 }
22632279 applyHeaderStatsFromState ( ) ;
22642280 renderLiveStats ( ) ;
0 commit comments