Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/poor-windows-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"trackio": patch
---

feat: Subdue empty dashboard tabs
38 changes: 38 additions & 0 deletions tests/unit/test_local_server_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,44 @@ def test_local_dashboard_upload_api_accepts_only_server_uploaded_paths(temp_dir)
app.close()


def test_get_tab_availability_reflects_data(temp_dir):
from trackio.server import get_tab_availability
from trackio.utils import MEDIA_DIR

project = "ta_srv"

empty = get_tab_availability(project)
assert empty == {
"metrics": False,
"system": False,
"traces": False,
"media": False,
"reports": False,
"files": False,
}

SQLiteStorage.log(project=project, run="r1", metrics={"loss": 0.25})
SQLiteStorage.bulk_alert(
project=project,
run="r1",
titles=["alert"],
texts=[None],
levels=["warn"],
steps=[None],
)
files_dir = MEDIA_DIR / project / "files"
files_dir.mkdir(parents=True, exist_ok=True)
(files_dir / "note.txt").write_text("hi")

result = get_tab_availability(project)
assert result["metrics"] is True
assert result["reports"] is True
assert result["files"] is True
assert result["media"] is False
assert result["system"] is False
assert result["traces"] is False


def test_local_dashboard_supports_mcp(temp_dir):
pytest.importorskip("mcp")
from mcp import ClientSession
Expand Down
98 changes: 97 additions & 1 deletion trackio/frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
getRunsForProject,
getRunConfigs,
getAlerts,
getTabAvailability,
getRunMutationStatus,
getSettings,
getReadOnlySource,
Expand Down Expand Up @@ -89,6 +90,29 @@
let spaceId = $state(null);
let availableSystemDevices = $state([]);
let selectedSystemDevices = $state([]);
let tabAvailability = $state({});
let tabAvailabilityRequestId = 0;
let lastTabAvailabilityRefreshAt = 0;
let shouldOpenFirstNonEmptyTab = false;
let openedFirstNonEmptyTab = false;
const TAB_AVAILABILITY_POLL_INTERVAL_MS = 15000;

const OPTIONAL_EMPTY_TABS = new Set([
"system",
"traces",
"media",
"reports",
"files",
]);
const AUTO_OPEN_TAB_ORDER = [
"metrics",
"system",
"traces",
"media",
"reports",
"runs",
"files",
];
let runConfigs = $state({});
let runConfigsProject = $state(null);

Expand All @@ -101,10 +125,16 @@
);

function handleNavigate(page) {
openedFirstNonEmptyTab = true;
currentPage = page;
navigateTo(page);
}

function isBareDashboardPath() {
const pathname = window.location.pathname.replace(/\/+$/, "") || "/";
return pathname === "/";
}

function lockedProjectName() {
return getQueryParam("project") || getQueryParam("selected_project");
}
Expand Down Expand Up @@ -188,6 +218,58 @@
}
}

function initialAvailability() {
return {
metrics: false,
system: false,
traces: false,
media: false,
reports: false,
runs: false,
files: false,
};
}

async function refreshTabAvailability({ force = false } = {}) {
const now = Date.now();
if (
!force &&
now - lastTabAvailabilityRefreshAt < TAB_AVAILABILITY_POLL_INTERVAL_MS
) {
return;
}
lastTabAvailabilityRefreshAt = now;
const requestId = ++tabAvailabilityRequestId;
if (!selectedProject) {
tabAvailability = initialAvailability();
return;
}

try {
const flags = await getTabAvailability(selectedProject);
if (requestId !== tabAvailabilityRequestId) return;
const availability = {
...initialAvailability(),
...flags,
runs: runs.length > 0,
};
tabAvailability = availability;

if (shouldOpenFirstNonEmptyTab && !openedFirstNonEmptyTab && isBareDashboardPath()) {
const first = AUTO_OPEN_TAB_ORDER.find((page) => availability[page]);
if (first && first !== currentPage) {
currentPage = first;
navigateTo(first);
}
openedFirstNonEmptyTab = true;
}
} catch (e) {
if (requestId !== tabAvailabilityRequestId) return;
console.error("Failed to load tab availability:", e);
tabAvailability = initialAvailability();
}
}

function startPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(async () => {
Expand All @@ -197,6 +279,7 @@
await refreshProjects();
await refreshRuns();
await refreshAlerts();
await refreshTabAvailability();
}, getAppPollIntervalMs());
}

Expand Down Expand Up @@ -285,6 +368,7 @@
showHeaders = false;
}

shouldOpenFirstNonEmptyTab = isBareDashboardPath();
currentPage = getPageFromPath();

window.addEventListener("popstate", () => {
Expand Down Expand Up @@ -326,6 +410,7 @@
await refreshRuns();

await refreshAlerts();
await refreshTabAvailability({ force: true });
} catch (e) {
console.error("Failed to load projects:", e);
} finally {
Expand Down Expand Up @@ -356,6 +441,12 @@
if (projectLocked) applyLockedProject();
});

$effect(() => {
selectedProject;
runs;
if (appBootstrapReady) refreshTabAvailability({ force: true });
});

let urlRunsFromQueryApplied = $state(false);

$effect(() => {
Expand Down Expand Up @@ -439,7 +530,12 @@

<div class="main">
{#if !navbarHidden}
<Navbar {currentPage} onNavigate={handleNavigate} />
<Navbar
{currentPage}
{tabAvailability}
optionalEmptyTabs={OPTIONAL_EMPTY_TABS}
onNavigate={handleNavigate}
/>
{/if}

<div class="page-content">
Expand Down
18 changes: 17 additions & 1 deletion trackio/frontend/src/components/Navbar.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
<script>
let { currentPage = "metrics", onNavigate } = $props();
let {
currentPage = "metrics",
tabAvailability = {},
optionalEmptyTabs = new Set(),
onNavigate,
} = $props();

const links = [
{ id: "metrics", label: "Metrics" },
Expand All @@ -14,6 +19,10 @@
function handleClick(id) {
onNavigate?.(id);
}

function isOptionalEmpty(id) {
return optionalEmptyTabs.has(id) && tabAvailability[id] === false;
}
</script>

<nav class="navbar">
Expand All @@ -23,7 +32,9 @@
<button
class="nav-link"
class:active={currentPage === link.id}
class:empty={isOptionalEmpty(link.id)}
onclick={() => handleClick(link.id)}
title={isOptionalEmpty(link.id) ? `${link.label} is empty for this project` : link.label}
>
{link.label}
</button>
Expand Down Expand Up @@ -75,8 +86,13 @@
transition: color 0.15s;
font-weight: 400;
}
.nav-link.empty:not(.active) {
color: var(--body-text-color-subdued, #9ca3af);
opacity: 0.48;
}
.nav-link:hover {
color: var(--body-text-color, #1f2937);
opacity: 1;
}
.nav-link.active {
color: var(--body-text-color, #1f2937);
Expand Down
5 changes: 5 additions & 0 deletions trackio/frontend/src/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ export async function getProjectFiles(project) {
return await callApi("/get_project_files", { project });
}

export async function getTabAvailability(project) {
if (await isStaticMode()) return staticApi.getTabAvailability(project);
return await callApi("/get_tab_availability", { project });
}

export async function getRunMutationStatus() {
if (await isStaticMode()) return staticApi.getRunMutationStatus();
return await callApi("/get_run_mutation_status", {});
Expand Down
69 changes: 69 additions & 0 deletions trackio/frontend/src/lib/staticApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,75 @@ export async function getSettings() {
};
}

let tabAvailabilityCache = null;

const MEDIA_TYPES = new Set([
"trackio.image",
"trackio.video",
"trackio.audio",
"trackio.table",
]);

const MARKDOWN_TYPES = new Set(["trackio.markdown"]);

function rowHasScalarMetric(row) {
for (const [key, value] of Object.entries(row)) {
if (STRUCTURAL_KEYS.has(key)) continue;
if (value === null || value === undefined) continue;
if (typeof value === "number" && Number.isFinite(value)) return true;
}
return false;
}

function rowHasTypedValue(row, types) {
for (const [key, value] of Object.entries(row)) {
if (STRUCTURAL_KEYS.has(key)) continue;
if (value == null) continue;
let parsed = value;
if (typeof parsed === "string" && parsed.startsWith("{") && parsed.includes("_type")) {
try {
parsed = JSON.parse(parsed);
} catch {
continue;
}
}
if (parsed && typeof parsed === "object" && types.has(parsed._type)) return true;
}
return false;
}

export async function getTabAvailability() {
if (tabAvailabilityCache) return tabAvailabilityCache;

const [metricsRaw, systemRaw, tracesRaw, files] = await Promise.all([
getMetricsData().catch(() => []),
getSystemData().catch(() => []),
getTracesData().catch(() => []),
getProjectFiles().catch(() => []),
]);

const metricsRows = (metricsRaw || []);
let metrics = false;
let media = false;
let reports = false;
for (const row of metricsRows) {
if (!metrics && rowHasScalarMetric(row)) metrics = true;
if (!media && rowHasTypedValue(row, MEDIA_TYPES)) media = true;
if (!reports && rowHasTypedValue(row, MARKDOWN_TYPES)) reports = true;
if (metrics && media && reports) break;
Comment on lines +496 to +500
}

tabAvailabilityCache = {
metrics,
media,
reports,
system: (systemRaw || []).length > 0,
traces: (tracesRaw || []).length > 0,
files: (files || []).length > 0,
};
return tabAvailabilityCache;
}

export async function getProjectFiles() {
if (fileListData) return fileListData;

Expand Down
23 changes: 23 additions & 0 deletions trackio/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,28 @@ def get_project_files(project: str) -> list[dict[str, Any]]:
return results


def _project_has_files(project: str) -> bool:
files_dir = utils.MEDIA_DIR / project / "files"
if not files_dir.exists():
return False
for entry in files_dir.rglob("*"):
if entry.is_file():
return True
return False


def get_tab_availability(project: str) -> dict[str, bool]:
flags = SQLiteStorage.get_tab_availability_flags(project)
return {
"metrics": flags["metrics"],
"system": flags["system"],
"traces": flags["traces"],
"media": flags["media"],
"reports": flags["reports"] or flags["alerts"],
"files": _project_has_files(project),
}


def delete_run(
request: Request,
project: str,
Expand Down Expand Up @@ -987,6 +1009,7 @@ def _api_registry() -> dict[str, Any]:
"query_project": query_project,
"get_settings": get_settings,
"get_project_files": get_project_files,
"get_tab_availability": get_tab_availability,
"delete_run": delete_run,
"rename_run": rename_run,
"force_sync": force_sync,
Expand Down
Loading
Loading