@@ -11,16 +11,10 @@ async function api(name, payload = {}) {
1111 return json . data ;
1212}
1313
14- function createButton ( project , selectedProject ) {
15- const button = document . createElement ( "button" ) ;
16- button . className = project === selectedProject ? "active" : "" ;
17- button . textContent = project ;
18- button . addEventListener ( "click" , ( ) => {
19- const params = new URLSearchParams ( window . location . search ) ;
20- params . set ( "project" , project ) ;
21- window . location . search = params . toString ( ) ;
22- } ) ;
23- return button ;
14+ function updateProjectParam ( project ) {
15+ const params = new URLSearchParams ( window . location . search ) ;
16+ params . set ( "project" , project ) ;
17+ window . location . search = params . toString ( ) ;
2418}
2519
2620function renderList ( element , values , emptyLabel ) {
@@ -32,7 +26,6 @@ function renderList(element, values, emptyLabel) {
3226 element . appendChild ( item ) ;
3327 return ;
3428 }
35-
3629 for ( const value of values ) {
3730 const item = document . createElement ( "div" ) ;
3831 item . className = "item" ;
@@ -41,48 +34,189 @@ function renderList(element, values, emptyLabel) {
4134 }
4235}
4336
44- export async function mountTheme ( { title, projectsEl, runsEl, metricsEl } ) {
37+ function renderOptions ( selectEl , options , selectedValue , labelForOption ) {
38+ selectEl . innerHTML = "" ;
39+ for ( const option of options ) {
40+ const el = document . createElement ( "option" ) ;
41+ el . value = option . value ;
42+ el . textContent = labelForOption ( option ) ;
43+ if ( option . value === selectedValue ) {
44+ el . selected = true ;
45+ }
46+ selectEl . appendChild ( el ) ;
47+ }
48+ }
49+
50+ function isFiniteNumber ( value ) {
51+ return typeof value === "number" && Number . isFinite ( value ) ;
52+ }
53+
54+ function getChartableRows ( rows ) {
55+ return rows . filter ( ( row ) => isFiniteNumber ( row . value ) ) ;
56+ }
57+
58+ function isChartableSeries ( rows ) {
59+ return getChartableRows ( rows ) . length >= 2 ;
60+ }
61+
62+ function formatValue ( value ) {
63+ if ( ! isFiniteNumber ( value ) ) return String ( value ) ;
64+ if ( Math . abs ( value ) >= 1000 || Math . abs ( value ) < 0.01 ) return value . toExponential ( 2 ) ;
65+ return value . toFixed ( 3 ) ;
66+ }
67+
68+ function buildPath ( points ) {
69+ return points . map ( ( [ x , y ] , index ) => `${ index === 0 ? "M" : "L" } ${ x } ${ y } ` ) . join ( " " ) ;
70+ }
71+
72+ function buildAreaPath ( points , height ) {
73+ if ( ! points . length ) return "" ;
74+ const line = buildPath ( points ) ;
75+ const [ lastX ] = points [ points . length - 1 ] ;
76+ const [ firstX ] = points [ 0 ] ;
77+ return `${ line } L ${ lastX } ${ height } L ${ firstX } ${ height } Z` ;
78+ }
79+
80+ function renderMetricCard ( metric , rows ) {
81+ const chartableRows = getChartableRows ( rows ) ;
82+ const width = 360 ;
83+ const height = 140 ;
84+ const padding = 10 ;
85+ const values = chartableRows . map ( ( row ) => row . value ) ;
86+ const min = Math . min ( ...values ) ;
87+ const max = Math . max ( ...values ) ;
88+ const span = max - min || 1 ;
89+ const points = chartableRows . map ( ( row , index ) => {
90+ const x = padding + ( index / Math . max ( chartableRows . length - 1 , 1 ) ) * ( width - padding * 2 ) ;
91+ const y = height - padding - ( ( row . value - min ) / span ) * ( height - padding * 2 ) ;
92+ return [ x , y ] ;
93+ } ) ;
94+ const latest = chartableRows [ chartableRows . length - 1 ] ;
95+ const first = chartableRows [ 0 ] ;
96+ const skippedPoints = rows . length - chartableRows . length ;
97+
98+ const card = document . createElement ( "article" ) ;
99+ card . className = "metric-card" ;
100+ card . innerHTML = `
101+ <h3>${ metric } </h3>
102+ <div class="metric-meta">Latest ${ formatValue ( latest . value ) } | ${ chartableRows . length } plotted${ skippedPoints ? ` | ${ skippedPoints } skipped` : "" } </div>
103+ <div class="chart-frame">
104+ <svg viewBox="0 0 ${ width } ${ height } " role="img" aria-label="${ metric } line chart">
105+ <line class="chart-axis" x1="${ padding } " y1="${ height - padding } " x2="${ width - padding } " y2="${ height - padding } "></line>
106+ <path class="chart-fill" d="${ buildAreaPath ( points , height - padding ) } "></path>
107+ <path class="chart-line" d="${ buildPath ( points ) } "></path>
108+ <circle class="chart-point" cx="${ points [ points . length - 1 ] [ 0 ] } " cy="${ points [ points . length - 1 ] [ 1 ] } " r="4"></circle>
109+ </svg>
110+ </div>
111+ <div class="metric-value">${ formatValue ( first . value ) } -> ${ formatValue ( latest . value ) } </div>
112+ ` ;
113+ return card ;
114+ }
115+
116+ async function renderMetrics ( element , project , run ) {
117+ element . innerHTML = "" ;
118+ const metrics = await api ( "get_metrics_for_run" , { project, run : run . name , run_id : run . id } ) ;
119+ if ( ! metrics . length ) {
120+ renderList ( element , [ ] , "No metrics yet" ) ;
121+ return 0 ;
122+ }
123+
124+ const rowsByMetric = await Promise . all (
125+ metrics . slice ( 0 , 12 ) . map ( async ( metric ) => ( {
126+ metric,
127+ rows : await api ( "get_metric_values" , {
128+ project,
129+ run : run . name ,
130+ run_id : run . id ,
131+ metric_name : metric ,
132+ } ) ,
133+ } ) ) ,
134+ ) ;
135+
136+ const chartable = rowsByMetric . filter ( ( entry ) => isChartableSeries ( entry . rows ) ) ;
137+ const other = rowsByMetric . filter ( ( entry ) => ! isChartableSeries ( entry . rows ) ) . map ( ( entry ) => entry . metric ) ;
138+
139+ if ( chartable . length ) {
140+ const grid = document . createElement ( "div" ) ;
141+ grid . className = "metric-grid" ;
142+ for ( const entry of chartable . slice ( 0 , 6 ) ) grid . appendChild ( renderMetricCard ( entry . metric , entry . rows ) ) ;
143+ element . appendChild ( grid ) ;
144+ } else {
145+ renderList ( element , [ ] , "No numeric metrics available for charting" ) ;
146+ }
147+
148+ if ( other . length ) {
149+ const extras = document . createElement ( "section" ) ;
150+ extras . className = "metric-extras" ;
151+ extras . innerHTML = "<h3>Other Logged Items</h3>" ;
152+ for ( const metric of other ) {
153+ const pill = document . createElement ( "span" ) ;
154+ pill . className = "metric-pill" ;
155+ pill . textContent = metric ;
156+ extras . appendChild ( pill ) ;
157+ }
158+ element . appendChild ( extras ) ;
159+ }
160+
161+ return metrics . length ;
162+ }
163+
164+ export async function mountTheme ( {
165+ title,
166+ projectSelect,
167+ runSelect,
168+ metricsEl,
169+ metricsSubtitle,
170+ projectSummary,
171+ runsCount,
172+ metricsCount,
173+ selectedRunName,
174+ } ) {
45175 try {
46176 const projects = await api ( "get_all_projects" ) ;
47177 const params = new URLSearchParams ( window . location . search ) ;
48178 const selectedProject =
49- params . get ( "project" ) && projects . includes ( params . get ( "project" ) )
50- ? params . get ( "project" )
51- : projects [ 0 ] ;
52-
53- title . textContent = selectedProject || "No projects" ;
54- projectsEl . innerHTML = "" ;
55- for ( const project of projects ) {
56- projectsEl . appendChild ( createButton ( project , selectedProject ) ) ;
57- }
179+ params . get ( "project" ) && projects . includes ( params . get ( "project" ) ) ? params . get ( "project" ) : projects [ 0 ] ;
180+
181+ title . textContent = selectedProject || "No project" ;
182+ projectSummary . textContent = selectedProject ? "Punchy charts. Minimal chrome." : "Choose a project and run." ;
183+
184+ renderOptions ( projectSelect , projects . map ( ( project ) => ( { value : project } ) ) , selectedProject , ( option ) => option . value ) ;
185+ projectSelect . onchange = ( ) => updateProjectParam ( projectSelect . value ) ;
58186
59187 if ( ! selectedProject ) {
60- renderList ( runsEl , [ ] , "No runs yet" ) ;
61188 renderList ( metricsEl , [ ] , "No metrics yet" ) ;
189+ selectedRunName . textContent = "No run" ;
62190 return ;
63191 }
64192
65193 const runs = await api ( "get_runs_for_project" , { project : selectedProject } ) ;
66- renderList (
67- runsEl ,
68- runs . slice ( 0 , 8 ) . map ( ( run ) => run . name || "Unnamed run" ) ,
69- "No runs yet" ,
70- ) ;
71-
72- const firstRun = runs [ 0 ] ?. name ;
73- if ( ! firstRun ) {
194+ runsCount . textContent = String ( runs . length ) ;
195+ if ( ! runs . length ) {
196+ runSelect . innerHTML = "" ;
197+ metricsCount . textContent = "0" ;
198+ selectedRunName . textContent = "No run" ;
74199 renderList ( metricsEl , [ ] , "No metrics yet" ) ;
75200 return ;
76201 }
77202
78- const metrics = await api ( "get_metrics_for_run" , {
79- project : selectedProject ,
80- run : firstRun ,
81- } ) ;
82- renderList ( metricsEl , metrics . slice ( 0 , 12 ) , "No metrics yet" ) ;
203+ const paramsRunId = params . get ( "run_id" ) ;
204+ let selectedRun = runs . find ( ( run ) => run . id === paramsRunId ) || runs [ 0 ] ;
205+ renderOptions ( runSelect , runs . map ( ( run ) => ( { value : run . id , name : run . name || "Unnamed run" } ) ) , selectedRun . id , ( option ) => option . name ) ;
206+
207+ const updateSelectedRun = async ( runId ) => {
208+ selectedRun = runs . find ( ( run ) => run . id === runId ) || runs [ 0 ] ;
209+ runSelect . value = selectedRun . id ;
210+ selectedRunName . textContent = selectedRun . name || "Unnamed run" ;
211+ metricsSubtitle . textContent = `Live plots for ${ selectedRun . name || "the selected run" } .` ;
212+ metricsCount . textContent = String ( await renderMetrics ( metricsEl , selectedProject , selectedRun ) ) ;
213+ } ;
214+
215+ runSelect . onchange = ( ) => updateSelectedRun ( runSelect . value ) ;
216+ await updateSelectedRun ( selectedRun . id ) ;
83217 } catch ( error ) {
84218 title . textContent = "Error" ;
85- renderList ( runsEl , [ error . message ] , "Error" ) ;
86- renderList ( metricsEl , [ ] , "Error" ) ;
219+ selectedRunName . textContent = "Error" ;
220+ renderList ( metricsEl , [ ] , error . message ) ;
87221 }
88222}
0 commit comments