mirror of
https://github.com/KohakuBlueleaf/KohakuHub.git
synced 2026-03-11 17:34:08 -05:00
hover sync, NaN/inf handling, better layout
This commit is contained in:
2
src/kohaku-board-ui/src/components.d.ts
vendored
2
src/kohaku-board-ui/src/components.d.ts
vendored
@@ -14,6 +14,8 @@ declare module 'vue' {
|
||||
ConfigurableChartCard: typeof import('./components/ConfigurableChartCard.vue')['default']
|
||||
DataTable: typeof import('./components/DataTable.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
|
||||
@@ -126,10 +126,14 @@ const hasDataError = computed(() => {
|
||||
const xData = props.sparseData[config.xMetric];
|
||||
if (!xData || xData.length === 0) return true;
|
||||
|
||||
// Check if all y metrics are empty or missing
|
||||
// Check if all y metrics have no valid data (including NaN/inf as valid!)
|
||||
const allEmpty = config.yMetrics.every((yMetric) => {
|
||||
const yData = props.sparseData[yMetric];
|
||||
return !yData || yData.length === 0;
|
||||
if (!yData || yData.length === 0) return true;
|
||||
|
||||
// Check if there's at least ONE non-null value (including NaN/inf)
|
||||
const hasAnyData = yData.some((val) => val !== null);
|
||||
return !hasAnyData;
|
||||
});
|
||||
|
||||
return allEmpty;
|
||||
@@ -156,6 +160,9 @@ const processedChartData = computed(() => {
|
||||
const x = [];
|
||||
const y = [];
|
||||
let lastXValue = null;
|
||||
let nanCount = 0;
|
||||
let infCount = 0;
|
||||
let negInfCount = 0;
|
||||
|
||||
for (let i = 0; i < xData.length; i++) {
|
||||
let xVal = xData[i];
|
||||
@@ -171,12 +178,38 @@ const processedChartData = computed(() => {
|
||||
}
|
||||
|
||||
if (xVal !== null) lastXValue = xVal;
|
||||
|
||||
// Include NaN/inf values (they are not null!)
|
||||
// Only skip if yVal is actually null (missing data)
|
||||
if (yVal !== null && lastXValue !== null) {
|
||||
x.push(lastXValue);
|
||||
y.push(yVal);
|
||||
|
||||
// Count special values for logging
|
||||
if (isNaN(yVal)) {
|
||||
nanCount++;
|
||||
} else if (yVal === Infinity) {
|
||||
infCount++;
|
||||
} else if (yVal === -Infinity) {
|
||||
negInfCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const specialValuesLog = [];
|
||||
if (nanCount > 0) specialValuesLog.push(`NaN=${nanCount}`);
|
||||
if (infCount > 0) specialValuesLog.push(`+inf=${infCount}`);
|
||||
if (negInfCount > 0) specialValuesLog.push(`-inf=${negInfCount}`);
|
||||
|
||||
console.log(
|
||||
`[${props.cardId}] ${yMetric}: collected ${y.length} points` +
|
||||
(specialValuesLog.length > 0
|
||||
? ` (${specialValuesLog.join(", ")})`
|
||||
: ""),
|
||||
);
|
||||
|
||||
// Return series even if all values are NaN/inf (x/y might be empty but we still need the series)
|
||||
// The LinePlot component will handle special values
|
||||
return { name: yMetric, x, y };
|
||||
})
|
||||
.filter((d) => d !== null);
|
||||
|
||||
@@ -282,24 +282,95 @@ const processedData = computed(() => {
|
||||
const rate = config.downsampleRate;
|
||||
|
||||
return props.data.map((series) => {
|
||||
const smoothedY = applySmoothing(series.y, mode, value);
|
||||
const downsampledSmoothed = downsampleData(series.x, smoothedY, rate);
|
||||
console.log(
|
||||
`[LinePlot] Processing series: ${series.name}, data length: ${series.y.length}`,
|
||||
);
|
||||
|
||||
// Build line segments separated by NaN/inf breaks
|
||||
// If we have: [1, 2, NaN, 3, 4], we create 2 segments: [[1,2], [3,4]]
|
||||
const segments = []; // Each segment: {x: [], y: []}
|
||||
const nanX = [];
|
||||
const infX = [];
|
||||
const negInfX = [];
|
||||
|
||||
let currentSegmentX = [];
|
||||
let currentSegmentY = [];
|
||||
|
||||
for (let i = 0; i < series.y.length; i++) {
|
||||
const yVal = series.y[i];
|
||||
const xVal = series.x[i];
|
||||
|
||||
if (Number.isNaN(yVal)) {
|
||||
// NaN breaks the line - finish current segment
|
||||
if (currentSegmentX.length > 0) {
|
||||
segments.push({ x: currentSegmentX, y: currentSegmentY });
|
||||
currentSegmentX = [];
|
||||
currentSegmentY = [];
|
||||
}
|
||||
nanX.push(xVal);
|
||||
console.log(
|
||||
`[LinePlot] ${series.name}[${i}]: NaN at x=${xVal} - breaking line`,
|
||||
);
|
||||
} else if (yVal === Infinity) {
|
||||
// +inf breaks the line
|
||||
if (currentSegmentX.length > 0) {
|
||||
segments.push({ x: currentSegmentX, y: currentSegmentY });
|
||||
currentSegmentX = [];
|
||||
currentSegmentY = [];
|
||||
}
|
||||
infX.push(xVal);
|
||||
console.log(
|
||||
`[LinePlot] ${series.name}[${i}]: +inf at x=${xVal} - breaking line`,
|
||||
);
|
||||
} else if (yVal === -Infinity) {
|
||||
// -inf breaks the line
|
||||
if (currentSegmentX.length > 0) {
|
||||
segments.push({ x: currentSegmentX, y: currentSegmentY });
|
||||
currentSegmentX = [];
|
||||
currentSegmentY = [];
|
||||
}
|
||||
negInfX.push(xVal);
|
||||
console.log(
|
||||
`[LinePlot] ${series.name}[${i}]: -inf at x=${xVal} - breaking line`,
|
||||
);
|
||||
} else {
|
||||
// Normal finite value - add to current segment
|
||||
currentSegmentX.push(xVal);
|
||||
currentSegmentY.push(yVal);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last segment
|
||||
if (currentSegmentX.length > 0) {
|
||||
segments.push({ x: currentSegmentX, y: currentSegmentY });
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[LinePlot] ${series.name}: ${segments.length} segments, NaN=${nanX.length}, +inf=${infX.length}, -inf=${negInfX.length}`,
|
||||
);
|
||||
|
||||
// Process each segment separately (smoothing + downsampling)
|
||||
const processedSegments = segments.map((seg) => {
|
||||
const smoothedY = applySmoothing(seg.y, mode, value);
|
||||
return downsampleData(seg.x, smoothedY, rate);
|
||||
});
|
||||
|
||||
const result = {
|
||||
name: series.name,
|
||||
x: downsampledSmoothed.x,
|
||||
y: downsampledSmoothed.y,
|
||||
segments: processedSegments, // Multiple line segments
|
||||
original: null,
|
||||
originalY: null,
|
||||
originalSegments: null,
|
||||
// Store special value x-positions for marker rendering
|
||||
nanX,
|
||||
infX,
|
||||
negInfX,
|
||||
};
|
||||
|
||||
if (mode !== "disabled" && config.showOriginal) {
|
||||
const downsampledOriginal = downsampleData(series.x, series.y, rate);
|
||||
result.original = {
|
||||
x: downsampledOriginal.x,
|
||||
y: downsampledOriginal.y,
|
||||
};
|
||||
result.originalY = downsampledOriginal.y;
|
||||
const originalSegments = segments.map((seg) =>
|
||||
downsampleData(seg.x, seg.y, rate),
|
||||
);
|
||||
result.originalSegments = originalSegments;
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -341,9 +412,14 @@ function createPlot() {
|
||||
const traces = [];
|
||||
|
||||
data.forEach((series, index) => {
|
||||
console.log(
|
||||
`[LinePlot] createPlot - processing series ${series.name}: ${series.segments?.length || 0} segments, nanX=${series.nanX?.length || 0}, infX=${series.infX?.length || 0}, negInfX=${series.negInfX?.length || 0}`,
|
||||
);
|
||||
|
||||
const baseColor = plotlyColors[index % plotlyColors.length];
|
||||
|
||||
if (series.original) {
|
||||
// Render original (smoothed) segments if enabled
|
||||
if (series.originalSegments && series.originalSegments.length > 0) {
|
||||
// Convert to dimmer color (not opacity) - blend with gray
|
||||
let dimmerColor = baseColor;
|
||||
if (baseColor.startsWith("#")) {
|
||||
@@ -358,68 +434,155 @@ function createPlot() {
|
||||
dimmerColor = `rgb(${dimR}, ${dimG}, ${dimB})`;
|
||||
}
|
||||
|
||||
traces.push({
|
||||
type: "scattergl",
|
||||
mode: "lines",
|
||||
name: `${series.name} (original)`,
|
||||
x: series.original.x,
|
||||
y: series.original.y,
|
||||
line: {
|
||||
width: config.lineWidth * 0.5,
|
||||
color: dimmerColor,
|
||||
},
|
||||
showlegend: false,
|
||||
legendgroup: series.name,
|
||||
hoverinfo: "skip",
|
||||
// Add each original segment as a separate trace
|
||||
series.originalSegments.forEach((seg, segIdx) => {
|
||||
traces.push({
|
||||
type: "scattergl",
|
||||
mode: "lines",
|
||||
name: `${series.name} (original)`,
|
||||
x: seg.x,
|
||||
y: seg.y,
|
||||
line: {
|
||||
width: config.lineWidth * 0.5,
|
||||
color: dimmerColor,
|
||||
},
|
||||
showlegend: segIdx === 0, // Only first segment shows in legend
|
||||
legendgroup: series.name + "_original",
|
||||
hoverinfo: "skip",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const trace = {
|
||||
type: "scattergl",
|
||||
mode: config.showMarkers ? "lines+markers" : "lines",
|
||||
name: series.name,
|
||||
x: series.x,
|
||||
y: series.y,
|
||||
line: {
|
||||
width: config.lineWidth,
|
||||
color: baseColor,
|
||||
},
|
||||
marker: {
|
||||
size: 3,
|
||||
color: baseColor,
|
||||
},
|
||||
legendgroup: series.name,
|
||||
};
|
||||
// Render main line segments
|
||||
if (series.segments && series.segments.length > 0) {
|
||||
series.segments.forEach((seg, segIdx) => {
|
||||
const trace = {
|
||||
type: "scattergl",
|
||||
mode: config.showMarkers ? "lines+markers" : "lines",
|
||||
name: series.name,
|
||||
x: seg.x,
|
||||
y: seg.y,
|
||||
line: {
|
||||
width: config.lineWidth,
|
||||
color: baseColor,
|
||||
},
|
||||
marker: {
|
||||
size: 3,
|
||||
color: baseColor,
|
||||
},
|
||||
showlegend: segIdx === 0, // Only first segment shows in legend
|
||||
legendgroup: series.name,
|
||||
};
|
||||
|
||||
// Custom hover template for relative_walltime
|
||||
if (props.xaxis === "relative_walltime") {
|
||||
trace.customdata = series.x.map((xVal, idx) => ({
|
||||
duration: formatDuration(xVal),
|
||||
original: series.originalY ? series.originalY[idx] : null,
|
||||
}));
|
||||
if (series.originalY) {
|
||||
trace.hovertemplate =
|
||||
"<b>%{fullData.name}</b><br>" +
|
||||
"Time: %{customdata.duration}<br>" +
|
||||
"Value: %{y:.4f} (%{customdata.original:.4f})<extra></extra>";
|
||||
} else {
|
||||
trace.hovertemplate =
|
||||
"<b>%{fullData.name}</b><br>" +
|
||||
"Time: %{customdata.duration}<br>" +
|
||||
"Value: %{y:.4f}<extra></extra>";
|
||||
}
|
||||
} else if (series.originalY) {
|
||||
trace.customdata = series.originalY.map((origY, idx) => ({
|
||||
original: origY,
|
||||
smoothed: series.y[idx],
|
||||
}));
|
||||
trace.hovertemplate =
|
||||
"<b>%{fullData.name}</b>: %{y:.4f} (%{customdata.original:.4f})<extra></extra>";
|
||||
} else {
|
||||
trace.hovertemplate = "<b>%{fullData.name}</b>: %{y:.4f}<extra></extra>";
|
||||
// Custom hover template for relative_walltime
|
||||
if (props.xaxis === "relative_walltime") {
|
||||
trace.customdata = seg.x.map((xVal, idx) => ({
|
||||
duration: formatDuration(xVal),
|
||||
}));
|
||||
trace.hovertemplate =
|
||||
"<b>%{fullData.name}</b><br>" +
|
||||
"Time: %{customdata.duration}<br>" +
|
||||
"Value: %{y:.4f}<extra></extra>";
|
||||
} else {
|
||||
trace.hovertemplate =
|
||||
"<b>%{fullData.name}</b>: %{y:.4f}<extra></extra>";
|
||||
}
|
||||
|
||||
traces.push(trace);
|
||||
});
|
||||
}
|
||||
|
||||
traces.push(trace);
|
||||
// Add special marker traces for NaN/inf values
|
||||
// These render as dots at top/bottom without affecting axis range
|
||||
const hasSpecialValues =
|
||||
series.nanX.length > 0 ||
|
||||
series.infX.length > 0 ||
|
||||
series.negInfX.length > 0;
|
||||
|
||||
console.log(
|
||||
`[LinePlot] ${series.name} special values: NaN=${series.nanX.length}, +inf=${series.infX.length}, -inf=${series.negInfX.length}`,
|
||||
);
|
||||
|
||||
if (hasSpecialValues) {
|
||||
// NaN markers (circle at top of chart)
|
||||
if (series.nanX.length > 0) {
|
||||
console.log(
|
||||
`[LinePlot] Adding NaN markers for ${series.name}:`,
|
||||
series.nanX,
|
||||
);
|
||||
traces.push({
|
||||
type: "scattergl",
|
||||
mode: "markers",
|
||||
name: `${series.name} (NaN)`,
|
||||
x: series.nanX,
|
||||
y: series.nanX.map(() => 1), // Will be positioned at top via yaxis2
|
||||
marker: {
|
||||
symbol: "circle",
|
||||
size: 8,
|
||||
color: baseColor,
|
||||
line: { color: "white", width: 1.5 },
|
||||
},
|
||||
yaxis: "y2", // Use secondary y-axis fixed at [0, 1]
|
||||
showlegend: false,
|
||||
legendgroup: series.name,
|
||||
hoverinfo: "skip", // CRITICAL: Don't show in unified hover tooltip
|
||||
// Individual hover still works when directly hovering the marker
|
||||
text: series.nanX.map(() => `${series.name}: NaN`),
|
||||
});
|
||||
}
|
||||
|
||||
// +Infinity markers (triangle-up at top of chart)
|
||||
if (series.infX.length > 0) {
|
||||
console.log(
|
||||
`[LinePlot] Adding +inf markers for ${series.name}:`,
|
||||
series.infX,
|
||||
);
|
||||
traces.push({
|
||||
type: "scattergl",
|
||||
mode: "markers",
|
||||
name: `${series.name} (+inf)`,
|
||||
x: series.infX,
|
||||
y: series.infX.map(() => 0.95), // Slightly below top
|
||||
marker: {
|
||||
symbol: "triangle-up",
|
||||
size: 10,
|
||||
color: baseColor,
|
||||
line: { color: "white", width: 1.5 },
|
||||
},
|
||||
yaxis: "y2",
|
||||
showlegend: false,
|
||||
legendgroup: series.name,
|
||||
hoverinfo: "skip", // CRITICAL: Don't show in unified hover tooltip
|
||||
text: series.infX.map(() => `${series.name}: +inf`),
|
||||
});
|
||||
}
|
||||
|
||||
// -Infinity markers (triangle-down at bottom of chart)
|
||||
if (series.negInfX.length > 0) {
|
||||
console.log(
|
||||
`[LinePlot] Adding -inf markers for ${series.name}:`,
|
||||
series.negInfX,
|
||||
);
|
||||
traces.push({
|
||||
type: "scattergl",
|
||||
mode: "markers",
|
||||
name: `${series.name} (-inf)`,
|
||||
x: series.negInfX,
|
||||
y: series.negInfX.map(() => 0.05), // Slightly above bottom
|
||||
marker: {
|
||||
symbol: "triangle-down",
|
||||
size: 10,
|
||||
color: baseColor,
|
||||
line: { color: "white", width: 1.5 },
|
||||
},
|
||||
yaxis: "y2",
|
||||
showlegend: false,
|
||||
legendgroup: series.name,
|
||||
hoverinfo: "skip", // CRITICAL: Don't show in unified hover tooltip
|
||||
text: series.negInfX.map(() => `${series.name}: -inf`),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const xAxisConfig = config.xRange.auto
|
||||
@@ -457,6 +620,16 @@ function createPlot() {
|
||||
showline: false,
|
||||
...yAxisConfig,
|
||||
},
|
||||
// Secondary y-axis for NaN/inf markers (fixed [0,1] range, overlaid)
|
||||
yaxis2: {
|
||||
overlaying: "y",
|
||||
side: "right",
|
||||
showgrid: false,
|
||||
showticklabels: false,
|
||||
showline: false,
|
||||
range: [0, 1],
|
||||
fixedrange: true,
|
||||
},
|
||||
dragmode: "zoom",
|
||||
height: props.height,
|
||||
paper_bgcolor: "rgba(0,0,0,0)",
|
||||
|
||||
@@ -16,14 +16,21 @@
|
||||
KohakuBoard
|
||||
</h1>
|
||||
</router-link>
|
||||
<div class="flex items-center gap-1">
|
||||
<router-link
|
||||
to="/"
|
||||
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-blue-600 dark:hover:text-blue-400"
|
||||
<!-- Dynamic breadcrumb navigation -->
|
||||
<el-breadcrumb separator="/" class="text-sm">
|
||||
<el-breadcrumb-item :to="{ path: '/projects' }"
|
||||
>Projects</el-breadcrumb-item
|
||||
>
|
||||
Projects
|
||||
</router-link>
|
||||
</div>
|
||||
<el-breadcrumb-item
|
||||
v-if="currentProject"
|
||||
:to="{ path: `/projects/${currentProject}` }"
|
||||
>
|
||||
{{ currentProject }}
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item v-if="currentRun">
|
||||
{{ currentRun }}
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Auth UI (only in remote mode) -->
|
||||
@@ -99,8 +106,13 @@ import { ElMessage } from "element-plus";
|
||||
const { animationsEnabled, toggleAnimations } = useAnimationPreference();
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const systemInfo = ref(null);
|
||||
|
||||
// Extract current project and run from route for breadcrumb
|
||||
const currentProject = computed(() => route.params.project || null);
|
||||
const currentRun = computed(() => route.params.id || null);
|
||||
|
||||
const props = defineProps({
|
||||
darkMode: {
|
||||
type: Boolean,
|
||||
|
||||
@@ -296,7 +296,7 @@ export function useHoverSync() {
|
||||
const xRange = xaxis.range || [0, 1];
|
||||
const yRange = yaxis.range || [0, 1];
|
||||
|
||||
// Create vertical spike line shape at xValue
|
||||
// Create vertical spike line (x-axis spike only)
|
||||
const spikeShape = {
|
||||
type: "line",
|
||||
x0: xValue,
|
||||
@@ -327,7 +327,7 @@ export function useHoverSync() {
|
||||
});
|
||||
|
||||
console.log(
|
||||
`${LOG_PREFIX} Drew spike line at x=${xValue} for chart:`,
|
||||
`${LOG_PREFIX} Drew x-axis spike line at x=${xValue} for chart:`,
|
||||
chartInfo.id,
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -18,6 +18,7 @@ const isUpdating = ref(false);
|
||||
const showAddTabDialog = ref(false);
|
||||
const newTabName = ref("");
|
||||
const showGlobalSettings = ref(false);
|
||||
const isInitializing = ref(true); // Prevent watch triggers during init
|
||||
|
||||
// Pagination for WebGL context limit
|
||||
const currentPage = ref(0);
|
||||
@@ -51,11 +52,19 @@ const storageKey = computed(
|
||||
() => `experiment-layout-${route.params.project}-${route.params.id}`,
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
// Extracted initialization logic for reuse
|
||||
async function initializeExperiment() {
|
||||
try {
|
||||
isInitializing.value = true;
|
||||
|
||||
const projectName = route.params.project;
|
||||
const runId = route.params.id;
|
||||
|
||||
console.log(`[Init] Loading experiment: ${projectName}/${runId}`);
|
||||
|
||||
// Clear previous data
|
||||
metricDataCache.value = {};
|
||||
|
||||
// Fetch summary using runs API
|
||||
const summaryResponse = await fetch(
|
||||
`/api/projects/${projectName}/runs/${runId}/summary`,
|
||||
@@ -298,9 +307,18 @@ onMounted(async () => {
|
||||
|
||||
// Determine default x-axis (prefer global_step if it's used, otherwise step)
|
||||
await determineDefaultXAxis();
|
||||
|
||||
// Initialization complete - allow watch to fire on user tab changes
|
||||
isInitializing.value = false;
|
||||
console.log("[Init] Initialization complete");
|
||||
} catch (error) {
|
||||
console.error("Failed to load experiment:", error);
|
||||
isInitializing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeExperiment();
|
||||
});
|
||||
|
||||
async function determineDefaultXAxis() {
|
||||
@@ -313,9 +331,10 @@ async function determineDefaultXAxis() {
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
// New columnar format: {values: []}
|
||||
// Check if any global_step value is non-zero
|
||||
const hasNonZeroGlobalStep = result.data.some(
|
||||
(item) => item.value !== 0 && item.value !== null,
|
||||
const hasNonZeroGlobalStep = result.values.some(
|
||||
(value) => value !== 0 && value !== null,
|
||||
);
|
||||
|
||||
// Update all cards to use global_step if it's being used
|
||||
@@ -338,8 +357,14 @@ async function determineDefaultXAxis() {
|
||||
|
||||
async function fetchMetricsForTab() {
|
||||
try {
|
||||
console.log(
|
||||
`[fetchMetricsForTab] Starting fetch for tab: ${activeTab.value}`,
|
||||
);
|
||||
const tab = tabs.value.find((t) => t.name === activeTab.value);
|
||||
if (!tab) return;
|
||||
if (!tab) {
|
||||
console.warn(`[fetchMetricsForTab] Tab not found: ${activeTab.value}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Metrics that are computed on frontend (don't fetch from API)
|
||||
const computedMetrics = new Set(["walltime", "relative_walltime"]);
|
||||
@@ -368,35 +393,65 @@ async function fetchMetricsForTab() {
|
||||
for (const metric of neededMetrics) {
|
||||
if (!metricDataCache.value[metric]) {
|
||||
try {
|
||||
console.log(`[fetchMetricsForTab] Fetching metric: ${metric}`);
|
||||
// Don't URL-encode - FastAPI :path parameter handles it
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectName}/runs/${runId}/scalars/${metric}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch ${metric}: ${response.status}`);
|
||||
console.warn(
|
||||
`[fetchMetricsForTab] Failed to fetch ${metric}: ${response.status}`,
|
||||
);
|
||||
// Set empty array so card can still render (just shows "no data")
|
||||
metricDataCache.value[metric] = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log(
|
||||
`[fetchMetricsForTab] Fetched ${metric}: ${result.values?.length || 0} values`,
|
||||
);
|
||||
|
||||
// Convert step-value pairs to sparse array
|
||||
const sparseArray = new Array(
|
||||
result.data[result.data.length - 1]?.step + 1 || 0,
|
||||
).fill(null);
|
||||
for (const item of result.data) {
|
||||
sparseArray[item.step] = item.value;
|
||||
// New columnar format: {steps: [], global_steps: [], timestamps: [], values: []}
|
||||
// Convert to sparse array (indexed by step number)
|
||||
const maxStep = Math.max(...result.steps);
|
||||
const sparseArray = new Array(maxStep + 1).fill(null);
|
||||
|
||||
for (let i = 0; i < result.steps.length; i++) {
|
||||
const step = result.steps[i];
|
||||
let value = result.values[i];
|
||||
|
||||
// Convert special string markers back to numeric NaN/inf
|
||||
// Backend sends these as strings because JSON doesn't support Infinity
|
||||
if (value === "NaN") {
|
||||
value = NaN;
|
||||
} else if (value === "Infinity") {
|
||||
value = Infinity;
|
||||
} else if (value === "-Infinity") {
|
||||
value = -Infinity;
|
||||
}
|
||||
|
||||
sparseArray[step] = value;
|
||||
}
|
||||
|
||||
metricDataCache.value[metric] = sparseArray;
|
||||
console.log(
|
||||
`[fetchMetricsForTab] Stored ${metric} in cache: ${sparseArray.length} slots, ${sparseArray.filter((v) => v !== null).length} non-null values`,
|
||||
);
|
||||
|
||||
// Also store timestamp data if available
|
||||
if (result.data[0]?.timestamp) {
|
||||
// Also store timestamp data if available (as Unix seconds)
|
||||
if (result.timestamps && result.timestamps.length > 0) {
|
||||
const timestampArray = new Array(sparseArray.length).fill(null);
|
||||
for (const item of result.data) {
|
||||
timestampArray[item.step] = item.timestamp;
|
||||
for (let i = 0; i < result.steps.length; i++) {
|
||||
const step = result.steps[i];
|
||||
const unixSeconds = result.timestamps[i];
|
||||
if (unixSeconds !== null) {
|
||||
// Convert Unix seconds to ISO string for consistency
|
||||
timestampArray[step] = new Date(
|
||||
unixSeconds * 1000,
|
||||
).toISOString();
|
||||
}
|
||||
}
|
||||
metricDataCache.value[`${metric}_timestamp`] = timestampArray;
|
||||
}
|
||||
@@ -435,8 +490,13 @@ async function fetchMetricsForTab() {
|
||||
metricDataCache.value.walltime = walltime;
|
||||
metricDataCache.value.relative_walltime = relativeWalltime;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[fetchMetricsForTab] Completed. Cache keys:`,
|
||||
Object.keys(metricDataCache.value),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error in fetchMetricsForTab:", error);
|
||||
console.error("[fetchMetricsForTab] Error:", error);
|
||||
// Don't throw - allow page to render with partial data
|
||||
}
|
||||
}
|
||||
@@ -446,6 +506,7 @@ const sparseData = computed(() => {
|
||||
for (const [key, values] of Object.entries(metricDataCache.value)) {
|
||||
data[key] = values;
|
||||
}
|
||||
console.log(`[sparseData] Computed with ${Object.keys(data).length} metrics`);
|
||||
return data;
|
||||
});
|
||||
|
||||
@@ -509,11 +570,30 @@ const visibleCards = computed(() => {
|
||||
});
|
||||
|
||||
watch(activeTab, () => {
|
||||
// Don't fetch during initialization (prevents race condition)
|
||||
if (isInitializing.value) {
|
||||
console.log("[Watch] activeTab changed during init, skipping fetch");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Watch] activeTab changed to:", activeTab.value);
|
||||
currentPage.value = 0;
|
||||
fetchMetricsForTab();
|
||||
saveLayout();
|
||||
});
|
||||
|
||||
// Watch route params to reinitialize when navigating between runs
|
||||
watch(
|
||||
() => [route.params.project, route.params.id],
|
||||
([newProject, newId], [oldProject, oldId]) => {
|
||||
if (oldId && (newId !== oldId || newProject !== oldProject)) {
|
||||
console.log("[Watch] Route changed - reinitializing");
|
||||
currentPage.value = 0;
|
||||
initializeExperiment();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function saveLayout() {
|
||||
const layout = {
|
||||
tabs: tabs.value,
|
||||
@@ -833,54 +913,19 @@ function onDragEnd(evt) {
|
||||
|
||||
<template>
|
||||
<div class="container-main">
|
||||
<div class="mb-6">
|
||||
<!-- Desktop layout -->
|
||||
<div v-if="!isMobile" class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Run: {{ route.params.id }}</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Project: {{ route.params.project }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<router-link
|
||||
:to="`/projects/${route.params.project}`"
|
||||
class="px-3 py-1.5 bg-gray-600 hover:bg-gray-700 text-white rounded text-sm"
|
||||
>
|
||||
← Back
|
||||
</router-link>
|
||||
<el-button type="primary" size="small" @click="addCard"
|
||||
>Add Chart</el-button
|
||||
>
|
||||
<el-button size="small" @click="addTab">Add Tab</el-button>
|
||||
<el-button size="small" @click="isEditingTabs = !isEditingTabs">
|
||||
{{ isEditingTabs ? "Done Editing" : "Edit Tabs" }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile layout -->
|
||||
<div v-else>
|
||||
<h1 class="text-xl font-bold mb-1">Run: {{ route.params.id }}</h1>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Project: {{ route.params.project }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<router-link
|
||||
:to="`/projects/${route.params.project}`"
|
||||
class="px-3 py-1.5 bg-gray-600 hover:bg-gray-700 text-white rounded text-sm"
|
||||
>
|
||||
← Back
|
||||
</router-link>
|
||||
<el-button type="primary" size="small" @click="addCard" class="flex-1"
|
||||
>Add Chart</el-button
|
||||
>
|
||||
<el-button size="small" @click="addTab">Add Tab</el-button>
|
||||
<el-button size="small" @click="isEditingTabs = !isEditingTabs">
|
||||
{{ isEditingTabs ? "Done" : "Edit" }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Action buttons -->
|
||||
<div class="mb-6 flex items-center justify-end gap-2">
|
||||
<el-button type="primary" size="small" @click="addCard">
|
||||
<span v-if="!isMobile">Add Chart</span>
|
||||
<span v-else>Add</span>
|
||||
</el-button>
|
||||
<el-button size="small" @click="addTab">
|
||||
<span v-if="!isMobile">Add Tab</span>
|
||||
<span v-else>Tab</span>
|
||||
</el-button>
|
||||
<el-button size="small" @click="isEditingTabs = !isEditingTabs">
|
||||
{{ isEditingTabs ? "Done" : "Edit" }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab" type="card">
|
||||
|
||||
Reference in New Issue
Block a user