155155 white-space : nowrap;
156156 font-variant-numeric : tabular-nums;
157157 }
158+ .usage-summary {
159+ color : var (--muted );
160+ cursor : pointer;
161+ user-select : none;
162+ white-space : nowrap;
163+ }
164+ .usage-summary : hover {
165+ color : var (--text );
166+ }
167+ .usage-details {
168+ min-width : 420px ;
169+ }
170+ .usage-details pre {
171+ margin : 8px 0 0 ;
172+ padding : 8px 10px ;
173+ border : 1px solid var (--border );
174+ border-radius : 8px ;
175+ background : rgba (255 , 255 , 255 , .03 );
176+ overflow : auto;
177+ max-height : 240px ;
178+ white-space : pre-wrap;
179+ word-break : break-word;
180+ }
181+
182+ .budget-cell { font-variant-numeric : tabular-nums; }
183+
184+ .budget-good { color : var (--good ); }
185+
186+ .budget-warn { color : var (--warn ); }
187+
188+ .budget-danger { color : var (--bad ); }
189+
158190 </ style >
159191</ head >
160192< body >
@@ -168,6 +200,51 @@ <h1>Sticky Router Dashboard</h1>
168200 </ div >
169201
170202 < div class ="grid ">
203+ < section class ="panel " id ="budget-panel ">
204+
205+ < div class ="panel-head ">
206+
207+ < h2 > Budget · < span id ="budget-month "> </ span > </ h2 >
208+
209+ < span class ="muted " id ="budget-snapshot-note "> </ span >
210+
211+ </ div >
212+
213+ < div class ="scroll ">
214+
215+ < table >
216+
217+ < thead >
218+
219+ < tr > < th > Used (USD)</ th > < th > Total (USD)</ th > < th > Used %</ th > < th > Pace (USD/day)</ th > < th > Month-end forecast (USD)</ th > </ tr >
220+
221+ </ thead >
222+
223+ < tbody >
224+
225+ < tr >
226+
227+ < td class ="budget-cell " id ="budget-used "> -</ td >
228+
229+ < td class ="budget-cell " id ="budget-total "> -</ td >
230+
231+ < td class ="budget-cell " id ="budget-pct "> -</ td >
232+
233+ < td class ="budget-cell " id ="budget-pace "> -</ td >
234+
235+ < td class ="budget-cell " id ="budget-forecast "> -</ td >
236+
237+ </ tr >
238+
239+ </ tbody >
240+
241+ </ table >
242+
243+ </ div >
244+
245+ </ section >
246+
247+
171248 < section class ="panel ">
172249 < h2 > Instances</ h2 >
173250 < div class ="stats ">
@@ -202,13 +279,13 @@ <h2>Active Bindings</h2>
202279
203280 < section class ="panel ">
204281 < div class ="panel-head ">
205- < h2 > Route History</ h2 >
282+ < h2 > Route History < span class =" muted " id =" history-total-usd " > (Total: $0.000000) </ span > </ h2 >
206283 < button id ="clear-history " class ="btn " type ="button "> Clear history</ button >
207284 </ div >
208285 < div class ="scroll ">
209286 < table >
210287 < thead >
211- < tr > < th > Time</ th > < th > Session</ th > < th > Agent</ th > < th > Model</ th > < th > Target</ th > < th > Reason</ th > </ tr >
288+ < tr > < th > Time</ th > < th > Session</ th > < th > Agent</ th > < th > Model</ th > < th > Target</ th > < th > Reason</ th > < th > Usage </ th > < /tr >
212289 </ thead >
213290 < tbody id ="history-body "> </ tbody >
214291 </ table >
@@ -218,7 +295,8 @@ <h2>Route History</h2>
218295 </ div >
219296
220297 < script >
221- const state = { history : [ ] }
298+ const state = { history : [ ] , totalNanoAiuSinceStart : 0 }
299+ const HISTORY_DISPLAY_LIMIT = 50
222300 const LAST_ACTIVE_REFRESH_INTERVAL_MS = 5000
223301 const byId = ( id ) => document . getElementById ( id )
224302 const cut = ( value , size = 24 ) => {
@@ -361,6 +439,100 @@ <h2>Route History</h2>
361439 } ) . join ( '' )
362440 }
363441
442+ function renderBudget ( instances ) {
443+
444+ const setText = ( id , val ) => { const el = byId ( id ) ; if ( el ) el . textContent = val }
445+
446+ const setClass = ( id , val ) => { const el = byId ( id ) ; if ( el ) el . className = val }
447+
448+ const reporting = ( instances || [ ] ) . filter ( ( instance ) => {
449+
450+ const usage = instance ?. headerSnapshot ?. premiumUsage
451+
452+ return usage && Number . isFinite ( Number ( usage . used ) ) && Number . isFinite ( Number ( usage . total ) )
453+
454+ } )
455+
456+ const total = reporting . length
457+
458+ const now = new Date ( )
459+
460+ const monthName = now . toLocaleString ( 'en-US' , { month : 'long' , year : 'numeric' } )
461+
462+ setText ( 'budget-month' , monthName )
463+
464+ setText ( 'budget-snapshot-note' , `${ total } /${ ( instances || [ ] ) . length } instances reporting` )
465+
466+ if ( total === 0 ) {
467+
468+ setText ( 'budget-used' , '-' )
469+
470+ setText ( 'budget-total' , '-' )
471+
472+ setText ( 'budget-pct' , '-' )
473+
474+ setText ( 'budget-pace' , '-' )
475+
476+ setText ( 'budget-forecast' , '-' )
477+
478+ setClass ( 'budget-forecast' , 'budget-cell' )
479+
480+ return
481+
482+ }
483+
484+ const totalUsedCredits = reporting . reduce ( ( sum , instance ) => sum + Number ( instance . headerSnapshot . premiumUsage . used ) , 0 )
485+
486+ const totalCapCredits = reporting . reduce ( ( sum , instance ) => sum + Number ( instance . headerSnapshot . premiumUsage . total ) , 0 )
487+
488+ const CREDIT_TO_USD = 0.01
489+
490+ const totalUsedUsd = totalUsedCredits * CREDIT_TO_USD
491+
492+ const totalCapUsd = totalCapCredits * CREDIT_TO_USD
493+
494+ const elapsed = now . getDate ( )
495+
496+ const daysInMonth = new Date ( now . getFullYear ( ) , now . getMonth ( ) + 1 , 0 ) . getDate ( )
497+
498+ const remaining = Math . max ( 0 , daysInMonth - elapsed )
499+
500+ const paceUsd = elapsed > 0 ? totalUsedUsd / elapsed : 0
501+
502+ const forecastUsd = paceUsd * daysInMonth
503+
504+ const pct = totalCapUsd > 0 ? ( totalUsedUsd / totalCapUsd ) * 100 : 0
505+
506+ const dailyBudgetUsd = remaining > 0 ? ( totalCapUsd - totalUsedUsd ) / remaining : 0
507+
508+ const ratio = dailyBudgetUsd > 0 ? paceUsd / dailyBudgetUsd : Infinity
509+
510+ let statusClass = 'budget-good'
511+
512+ let statusIcon = '✓'
513+
514+ if ( ratio > 1.1 ) { statusClass = 'budget-danger' ; statusIcon = '🔴' }
515+
516+ else if ( ratio > 0.9 ) { statusClass = 'budget-warn' ; statusIcon = '⚠' }
517+
518+ const fmtUsd = ( n ) => `$${ Number ( n ) . toLocaleString ( 'en-US' , { minimumFractionDigits : 2 , maximumFractionDigits : 2 } ) } `
519+
520+ setText ( 'budget-used' , fmtUsd ( totalUsedUsd ) )
521+
522+ setText ( 'budget-total' , fmtUsd ( totalCapUsd ) )
523+
524+ setText ( 'budget-pct' , `${ pct . toFixed ( 1 ) } %` )
525+
526+ setText ( 'budget-pace' , paceUsd > 0 ? fmtUsd ( paceUsd ) : '-' )
527+
528+ setText ( 'budget-forecast' , forecastUsd > 0 ? `${ fmtUsd ( forecastUsd ) } ${ statusIcon } ` : '-' )
529+
530+ setClass ( 'budget-forecast' , `budget-cell ${ statusClass } ` )
531+
532+ }
533+
534+
535+
364536 function renderBindings ( bindings ) {
365537 const entries = Object . entries ( bindings || { } )
366538 byId ( 'binding-count' ) . textContent = String ( entries . length )
@@ -377,6 +549,68 @@ <h2>Route History</h2>
377549 ` ) . join ( '' )
378550 }
379551
552+ function toPrettyJson ( value ) {
553+ return value == null ? 'null' : JSON . stringify ( value , null , 2 )
554+ }
555+
556+ function usageSummary ( item ) {
557+ const usage = item . usage
558+ const copilotUsage = item . copilotUsage
559+ if ( ! usage && ! copilotUsage ) return '-'
560+ const cached =
561+ usage ?. input_tokens_details ?. cached_tokens
562+ ?? usage ?. cache_read_input_tokens
563+ const nano = copilotUsage ?. total_nano_aiu
564+ const parts = [ ]
565+ const usd = Number . isFinite ( Number ( nano ) ) ? Number ( nano ) / 100000000000 : null
566+ const tokenDetails = Array . isArray ( copilotUsage ?. token_details ) ? copilotUsage . token_details : [ ]
567+ const typeOrder = [ "input" , "cache_read" , "cache_write" , "output" ]
568+ const typeLabel = {
569+ input : "in" ,
570+ cache_read : "cache" ,
571+ cache_write : "cache_write" ,
572+ output : "out" ,
573+ }
574+ const typeCosts = { }
575+ for ( const detail of tokenDetails ) {
576+ if ( ! detail || typeof detail !== "object" ) continue
577+ const tokenType = typeof detail . token_type === "string" ? detail . token_type : ""
578+ const tokenCount = Number ( detail . token_count )
579+ const batchSize = Number ( detail . batch_size )
580+ const costPerBatch = Number ( detail . cost_per_batch )
581+ if ( ! tokenType || ! Number . isFinite ( tokenCount ) || ! Number . isFinite ( batchSize ) || ! Number . isFinite ( costPerBatch ) || batchSize <= 0 ) continue
582+ const nanoCost = ( tokenCount / batchSize ) * costPerBatch
583+ if ( ! Number . isFinite ( nanoCost ) ) continue
584+ typeCosts [ tokenType ] = ( typeCosts [ tokenType ] || 0 ) + nanoCost
585+ }
586+ if ( cached !== undefined ) parts . push ( `cached=${ cached } ` )
587+ if ( usd !== null ) parts . push ( `$${ usd . toFixed ( 6 ) } ` )
588+ for ( const tokenType of typeOrder ) {
589+ const typeNano = typeCosts [ tokenType ]
590+ if ( ! Number . isFinite ( typeNano ) ) continue
591+ const typeUsd = typeNano / 100000000000
592+ parts . push ( `${ typeLabel [ tokenType ] || tokenType } =$${ typeUsd . toFixed ( 6 ) } ` )
593+ }
594+ return parts . length ? parts . join ( ' · ' ) : 'details'
595+ }
596+
597+ function usageCell ( item ) {
598+ if ( ! item . usage && ! item . copilotUsage ) return '-'
599+ return `
600+ <details class="usage-details">
601+ <summary class="usage-summary">${ usageSummary ( item ) } </summary>
602+ <pre>usage:\n${ escapeHtml ( toPrettyJson ( item . usage ) ) } \n\ncopilot_usage:\n${ escapeHtml ( toPrettyJson ( item . copilotUsage ) ) } </pre>
603+ </details>
604+ `
605+ }
606+
607+ function escapeHtml ( value ) {
608+ return String ( value )
609+ . replaceAll ( '&' , '&' )
610+ . replaceAll ( '<' , '<' )
611+ . replaceAll ( '>' , '>' )
612+ }
613+
380614 function historyRow ( item ) {
381615 return `
382616 <tr>
@@ -386,24 +620,40 @@ <h2>Route History</h2>
386620 <td>${ cut ( item . model , 28 ) } </td>
387621 <td><strong>${ item . port || '-' } </strong></td>
388622 <td>${ item . reason || '-' } </td>
623+ <td>${ usageCell ( item ) } </td>
389624 </tr>
390625 `
391626 }
392627
393628 function renderHistory ( history ) {
394- state . history = Array . isArray ( history ) ? [ ...history ] . sort ( ( a , b ) => ( b . ts || '' ) . localeCompare ( a . ts || '' ) ) . slice ( 0 , 50 ) : [ ]
629+ state . history = Array . isArray ( history ) ? [ ...history ] . sort ( ( a , b ) => ( b . ts || '' ) . localeCompare ( a . ts || '' ) ) . slice ( 0 , HISTORY_DISPLAY_LIMIT ) : [ ]
395630 byId ( 'history-count' ) . textContent = String ( state . history . length )
396631 const body = byId ( 'history-body' )
397632 if ( ! state . history . length ) {
398- body . innerHTML = '<tr><td colspan="6 " class="empty">No routes yet.</td></tr>'
633+ body . innerHTML = '<tr><td colspan="7 " class="empty">No routes yet.</td></tr>'
399634 return
400635 }
401636 body . innerHTML = state . history . map ( historyRow ) . join ( '' )
402637 }
403638
639+ function renderHistoryTotal ( totalNanoAiuSinceStart ) {
640+ const nano = Number ( totalNanoAiuSinceStart )
641+ const usd = Number . isFinite ( nano ) ? nano / 100000000000 : 0
642+ byId ( 'history-total-usd' ) . textContent = `(Total: $${ usd . toFixed ( 6 ) } )`
643+ }
644+
404645 function prependHistory ( item ) {
405646 state . history . unshift ( item )
406- state . history = state . history . slice ( 0 , 50 )
647+ state . history = state . history . slice ( 0 , HISTORY_DISPLAY_LIMIT )
648+ renderHistory ( state . history )
649+ }
650+
651+ function updateHistoryItem ( item ) {
652+ const historyId = item ?. historyId
653+ if ( ! historyId ) return
654+ const index = state . history . findIndex ( ( entry ) => entry . historyId === historyId )
655+ if ( index === - 1 ) return
656+ state . history [ index ] = item
407657 renderHistory ( state . history )
408658 }
409659
@@ -437,7 +687,11 @@ <h2>Route History</h2>
437687 const response = await fetch ( '/api/status' )
438688 const payload = await response . json ( )
439689 renderInstances ( payload . instances || [ ] )
690+ renderBudget ( payload . instances || [ ] )
691+
440692 renderBindings ( payload . sessionBindings || { } )
693+ state . totalNanoAiuSinceStart = Number ( payload . totalNanoAiuSinceStart || 0 )
694+ renderHistoryTotal ( state . totalNanoAiuSinceStart )
441695 if ( typeof payload . routeHistorySize === 'number' ) {
442696 byId ( 'history-count' ) . textContent = String ( payload . routeHistorySize )
443697 }
@@ -472,6 +726,14 @@ <h2>Route History</h2>
472726 events . addEventListener ( 'reset' , async ( ) => {
473727 await refreshAll ( )
474728 } )
729+ events . addEventListener ( 'history_update' , async ( event ) => {
730+ try {
731+ updateHistoryItem ( JSON . parse ( event . data ) )
732+ await loadStatus ( )
733+ } catch ( error ) {
734+ console . error ( 'failed to parse history_update payload' , error )
735+ }
736+ } )
475737 }
476738
477739 byId ( 'clear-bindings' ) . addEventListener ( 'click' , ( ) => clearData ( 'bindings' ) )
0 commit comments