|
15 | 15 | getAllProjects, |
16 | 16 | getRunsForProject, |
17 | 17 | getAlerts, |
| 18 | + getLogsBatch, |
| 19 | + getTraces, |
| 20 | + getSystemMetricsForRun, |
| 21 | + getProjectFiles, |
18 | 22 | getRunMutationStatus, |
19 | 23 | getSettings, |
20 | 24 | getReadOnlySource, |
|
88 | 92 | let spaceId = $state(null); |
89 | 93 | let availableSystemDevices = $state([]); |
90 | 94 | let selectedSystemDevices = $state([]); |
| 95 | + let tabAvailability = $state({}); |
| 96 | + let tabAvailabilityRequestId = 0; |
| 97 | + let lastTabAvailabilityRefreshAt = 0; |
| 98 | + let shouldOpenFirstNonEmptyTab = false; |
| 99 | + let openedFirstNonEmptyTab = false; |
| 100 | + const TAB_AVAILABILITY_POLL_INTERVAL_MS = 15000; |
| 101 | +
|
| 102 | + const OPTIONAL_EMPTY_TABS = new Set([ |
| 103 | + "system", |
| 104 | + "traces", |
| 105 | + "media", |
| 106 | + "reports", |
| 107 | + "files", |
| 108 | + ]); |
| 109 | + const AUTO_OPEN_TAB_ORDER = [ |
| 110 | + "metrics", |
| 111 | + "system", |
| 112 | + "traces", |
| 113 | + "media", |
| 114 | + "reports", |
| 115 | + "runs", |
| 116 | + "files", |
| 117 | + ]; |
| 118 | + const RESERVED_METRIC_KEYS = new Set([ |
| 119 | + "project", |
| 120 | + "run", |
| 121 | + "timestamp", |
| 122 | + "step", |
| 123 | + "time", |
| 124 | + "metrics", |
| 125 | + ]); |
91 | 126 |
|
92 | 127 | function runKey(run) { |
93 | 128 | return run?.id ?? run?.name; |
|
98 | 133 | ); |
99 | 134 |
|
100 | 135 | function handleNavigate(page) { |
| 136 | + openedFirstNonEmptyTab = true; |
101 | 137 | currentPage = page; |
102 | 138 | navigateTo(page); |
103 | 139 | } |
104 | 140 |
|
| 141 | + function isBareDashboardPath() { |
| 142 | + const pathname = window.location.pathname.replace(/\/+$/, "") || "/"; |
| 143 | + return pathname === "/"; |
| 144 | + } |
| 145 | +
|
105 | 146 | function lockedProjectName() { |
106 | 147 | return getQueryParam("project") || getQueryParam("selected_project"); |
107 | 148 | } |
|
171 | 212 | } |
172 | 213 | } |
173 | 214 |
|
| 215 | + function initialAvailability() { |
| 216 | + return { |
| 217 | + metrics: false, |
| 218 | + system: false, |
| 219 | + traces: false, |
| 220 | + media: false, |
| 221 | + reports: false, |
| 222 | + runs: false, |
| 223 | + files: false, |
| 224 | + }; |
| 225 | + } |
| 226 | +
|
| 227 | + function logHasScalarMetric(log) { |
| 228 | + return Object.entries(log || {}).some(([key, value]) => ( |
| 229 | + !RESERVED_METRIC_KEYS.has(key) && |
| 230 | + typeof value === "number" && |
| 231 | + Number.isFinite(value) |
| 232 | + )); |
| 233 | + } |
| 234 | +
|
| 235 | + function logHasMedia(log) { |
| 236 | + return Object.values(log || {}).some((value) => ( |
| 237 | + value && |
| 238 | + typeof value === "object" && |
| 239 | + ["trackio.image", "trackio.video", "trackio.audio", "trackio.table"].includes(value._type) |
| 240 | + )); |
| 241 | + } |
| 242 | +
|
| 243 | + function logHasMarkdownReport(log) { |
| 244 | + return Object.values(log || {}).some((value) => ( |
| 245 | + value && |
| 246 | + typeof value === "object" && |
| 247 | + value._type === "trackio.markdown" |
| 248 | + )); |
| 249 | + } |
| 250 | +
|
| 251 | + async function anyRunHasSystemMetrics(project, runRecords) { |
| 252 | + const results = await Promise.all( |
| 253 | + runRecords.map(async (run) => { |
| 254 | + try { |
| 255 | + const metrics = await getSystemMetricsForRun(project, run); |
| 256 | + return (metrics || []).length > 0; |
| 257 | + } catch { |
| 258 | + return false; |
| 259 | + } |
| 260 | + }), |
| 261 | + ); |
| 262 | + return results.some(Boolean); |
| 263 | + } |
| 264 | +
|
| 265 | + async function anyRunHasTraces(project, runRecords) { |
| 266 | + const results = await Promise.all( |
| 267 | + runRecords.map(async (run) => { |
| 268 | + try { |
| 269 | + const traces = await getTraces(project, run, { limit: 1 }); |
| 270 | + return (traces || []).length > 0; |
| 271 | + } catch { |
| 272 | + return false; |
| 273 | + } |
| 274 | + }), |
| 275 | + ); |
| 276 | + return results.some(Boolean); |
| 277 | + } |
| 278 | +
|
| 279 | + async function refreshTabAvailability({ force = false } = {}) { |
| 280 | + const now = Date.now(); |
| 281 | + if ( |
| 282 | + !force && |
| 283 | + now - lastTabAvailabilityRefreshAt < TAB_AVAILABILITY_POLL_INTERVAL_MS |
| 284 | + ) { |
| 285 | + return; |
| 286 | + } |
| 287 | + lastTabAvailabilityRefreshAt = now; |
| 288 | + const requestId = ++tabAvailabilityRequestId; |
| 289 | + if (!selectedProject) { |
| 290 | + tabAvailability = initialAvailability(); |
| 291 | + return; |
| 292 | + } |
| 293 | +
|
| 294 | + const availability = { |
| 295 | + ...initialAvailability(), |
| 296 | + runs: runs.length > 0, |
| 297 | + }; |
| 298 | + const runRecords = selectedRunRecords; |
| 299 | +
|
| 300 | + try { |
| 301 | + const [ |
| 302 | + logsBatch, |
| 303 | + projectAlerts, |
| 304 | + hasSystem, |
| 305 | + hasTraces, |
| 306 | + projectFiles, |
| 307 | + ] = await Promise.all([ |
| 308 | + runRecords.length ? getLogsBatch(selectedProject, runRecords) : [], |
| 309 | + getAlerts(selectedProject, null, null, null).catch(() => []), |
| 310 | + runRecords.length ? anyRunHasSystemMetrics(selectedProject, runRecords) : false, |
| 311 | + runRecords.length ? anyRunHasTraces(selectedProject, runRecords) : false, |
| 312 | + getProjectFiles(selectedProject).catch(() => []), |
| 313 | + ]); |
| 314 | + if (requestId !== tabAvailabilityRequestId) return; |
| 315 | +
|
| 316 | + const selectedRunNames = new Set(runRecords.map((run) => run.name)); |
| 317 | + const selectedAlerts = (projectAlerts || []).filter( |
| 318 | + (alert) => !alert.run || selectedRunNames.has(alert.run), |
| 319 | + ); |
| 320 | + for (const entry of logsBatch || []) { |
| 321 | + for (const log of entry.logs || []) { |
| 322 | + availability.metrics ||= logHasScalarMetric(log); |
| 323 | + availability.media ||= logHasMedia(log); |
| 324 | + availability.reports ||= logHasMarkdownReport(log); |
| 325 | + } |
| 326 | + } |
| 327 | + availability.system = hasSystem; |
| 328 | + availability.traces = hasTraces; |
| 329 | + availability.reports ||= selectedAlerts.length > 0; |
| 330 | + availability.files = (projectFiles || []).length > 0; |
| 331 | + tabAvailability = availability; |
| 332 | +
|
| 333 | + if (shouldOpenFirstNonEmptyTab && !openedFirstNonEmptyTab && isBareDashboardPath()) { |
| 334 | + const first = AUTO_OPEN_TAB_ORDER.find((page) => availability[page]); |
| 335 | + if (first && first !== currentPage) { |
| 336 | + currentPage = first; |
| 337 | + navigateTo(first); |
| 338 | + } |
| 339 | + openedFirstNonEmptyTab = true; |
| 340 | + } |
| 341 | + } catch (e) { |
| 342 | + if (requestId !== tabAvailabilityRequestId) return; |
| 343 | + console.error("Failed to load tab availability:", e); |
| 344 | + tabAvailability = availability; |
| 345 | + } |
| 346 | + } |
| 347 | +
|
174 | 348 | function startPolling() { |
175 | 349 | if (pollTimer) clearInterval(pollTimer); |
176 | 350 | pollTimer = setInterval(async () => { |
|
180 | 354 | await refreshProjects(); |
181 | 355 | await refreshRuns(); |
182 | 356 | await refreshAlerts(); |
| 357 | + await refreshTabAvailability(); |
183 | 358 | }, getAppPollIntervalMs()); |
184 | 359 | } |
185 | 360 |
|
|
268 | 443 | showHeaders = false; |
269 | 444 | } |
270 | 445 |
|
| 446 | + shouldOpenFirstNonEmptyTab = isBareDashboardPath(); |
271 | 447 | currentPage = getPageFromPath(); |
272 | 448 |
|
273 | 449 | window.addEventListener("popstate", () => { |
|
309 | 485 | await refreshRuns(); |
310 | 486 |
|
311 | 487 | await refreshAlerts(); |
| 488 | + await refreshTabAvailability({ force: true }); |
312 | 489 | } catch (e) { |
313 | 490 | console.error("Failed to load projects:", e); |
314 | 491 | } finally { |
|
339 | 516 | if (projectLocked) applyLockedProject(); |
340 | 517 | }); |
341 | 518 |
|
| 519 | + $effect(() => { |
| 520 | + selectedProject; |
| 521 | + selectedRuns; |
| 522 | + runs; |
| 523 | + if (appBootstrapReady) refreshTabAvailability({ force: true }); |
| 524 | + }); |
| 525 | +
|
342 | 526 | let urlRunsFromQueryApplied = $state(false); |
343 | 527 |
|
344 | 528 | $effect(() => { |
|
421 | 605 |
|
422 | 606 | <div class="main"> |
423 | 607 | {#if !navbarHidden} |
424 | | - <Navbar {currentPage} onNavigate={handleNavigate} /> |
| 608 | + <Navbar |
| 609 | + {currentPage} |
| 610 | + {tabAvailability} |
| 611 | + optionalEmptyTabs={OPTIONAL_EMPTY_TABS} |
| 612 | + onNavigate={handleNavigate} |
| 613 | + /> |
425 | 614 | {/if} |
426 | 615 |
|
427 | 616 | <div class="page-content"> |
|
0 commit comments