voonhous commented on code in PR #13147: URL: https://github.com/apache/hudi/pull/13147#discussion_r3964885776
########## hudi-timeline-service/src/main/resources/public/js/timeline.js: ########## @@ -0,0 +1,1266 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +(function () { + 'use strict'; + + // Base path for the Timeline UI REST endpoints (served by Javalin under /ui/api). + var API_BASE = '/ui/api'; + + // DOM references + var stateEmpty = document.getElementById('stateEmpty'); + var stateLoading = document.getElementById('stateLoading'); + var stateError = document.getElementById('stateError'); + var stateLoaded = document.getElementById('stateLoaded'); + var errorMessage = document.getElementById('errorMessage'); + var instantCount = document.getElementById('instantCount'); + var detailCard = document.getElementById('detailCard'); + var detailInstantId = document.getElementById('detailInstantId'); + var detailAction = document.getElementById('detailAction'); + var detailState = document.getElementById('detailState'); + var detailMeta = document.getElementById('detailMeta'); + var detailBody = document.getElementById('detailBody'); + + var timeline = null; + var cleanRangeItemId = 'clean-range-bg'; + var allItems = null; + var filteredView = null; + var activeStates = new Set(['COMPLETED', 'INFLIGHT', 'REQUESTED']); + var allLoadedActions = new Set(); + var activeActions = new Set(); + var currentTablePath = null; + + // Lazy-loaded data caches for tabs + var tableConfigData = null; + var schemaHistoryData = null; + + // Maps each comparable action to a group row: one row per comparable action. + // Pending compaction/logcompaction/clustering fold into the row of the action + // they complete as (compaction -> commit, logcompaction -> deltacommit, + // clustering -> replacecommit), mirroring Hudi's own timeline mapping. The + // comparableAction is computed server-side and sent on each instant; items + // keep their raw action for the visible label, colors and detail fetch. + var comparableActionToGroupId = { + 'commit': 0, + 'deltacommit': 1, + 'replacecommit': 2, + 'clean': 3, + 'rollback': 4, + 'savepoint': 5, + 'restore': 6, + 'indexing': 7 + }; + + var groups = Object.keys(comparableActionToGroupId).map(function (action) { + return { id: comparableActionToGroupId[action], content: action }; + }); + + // Preferred display order for the raw-action filter pills. Filtering is always + // by raw action (what the user sees on each item), even though several raw + // actions can share a single comparable-action group row. + var RAW_ACTION_ORDER = [ + 'commit', 'deltacommit', 'replacecommit', 'compaction', 'logcompaction', + 'clustering', 'clean', 'rollback', 'savepoint', 'restore', 'indexing' + ]; + + // State management + var STATES = {EMPTY: 'EMPTY', LOADING: 'LOADING', ERROR: 'ERROR', LOADED: 'LOADED'}; + var stateElements = { + EMPTY: stateEmpty, + LOADING: stateLoading, + ERROR: stateError, + LOADED: stateLoaded + }; + + function setState(state, data) { + Object.keys(stateElements).forEach(function (key) { + stateElements[key].classList.add('d-none'); + }); + stateElements[state].classList.remove('d-none'); + + if (state === STATES.ERROR && data) { + errorMessage.textContent = data; + } + + // Hide detail card when switching states + if (state !== STATES.LOADED) { + detailCard.classList.add('d-none'); + } + } + + // State badge color mapping + var stateBadgeClass = { + 'COMPLETED': 'bg-success', + 'INFLIGHT': 'bg-warning text-dark', + 'REQUESTED': 'bg-danger' + }; + + function displayInstantDetails(item) { + var html = '<p>Start: ' + escapeHtml(String(item.start)) + '</p>'; + if (item.end !== undefined) { + html += '<p>End: ' + escapeHtml(String(item.end)) + '</p>'; + var duration = localize(timeDiff(item.end, item.start)); + html += '<p>Duration: ' + escapeHtml(duration) + '</p>'; + } + html += '<p>Instant: ' + escapeHtml(item.content) + '</p>'; + return html; + } + + // Composes a descriptive Error from a non-OK fetch Response and returns a + // Promise that rejects with it. The server sends human-readable bodies on + // errors (e.g. "Not a valid Hudi table path: ..."), so prefer the body over + // the terse statusText; fall back to statusText when the body is empty. The + // body is capped at ~300 chars so DOM alerts stay readable. Every caller + // surfaces the message via textContent, so the server text is never treated + // as HTML. + function httpError(res) { + return res.text().then(function (body) { + var message = 'HTTP ' + res.status; + var detail = (body || '').trim(); + if (detail) { + if (detail.length > 300) { + detail = detail.slice(0, 300) + '...'; + } + message += ': ' + detail; + } else if (res.statusText) { + message += ': ' + res.statusText; + } + throw new Error(message); + }); + } + + var options = { + width: '100%', + height: '100%', + margin: { item: 10, axis: 5 }, + horizontalScroll: true, + zoomKey: 'shiftKey', + editable: false, + tooltip: { + delay: 0, + template: displayInstantDetails + } + }; + + // Navigation: Go to Now + document.getElementById('goToNowBtn').addEventListener('click', function () { + if (timeline) { + timeline.moveTo(new Date()); + } + }); + + // Navigation: Instant search + document.getElementById('instantSearchBtn').addEventListener('click', function () { + focusOnInstant(); + }); + document.getElementById('instantSearchInput').addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + focusOnInstant(); + } + }); + + function focusOnInstant() { + if (!timeline || !allItems) return; + var query = document.getElementById('instantSearchInput').value.trim(); + if (!query) return; + + var searchItems = allItems.get(); + var match = null; + for (var i = 0; i < searchItems.length; i++) { + if (searchItems[i].requestTs === query || searchItems[i].completionTs === query) { + match = searchItems[i]; + break; + } + } + + if (match) { + timeline.focus(match.id, { animation: { duration: 500, easingFunction: 'easeInOutQuad' } }); + timeline.setSelection([match.id]); + } else { + var parsed = parseHudiTimestamp(query); + if (parsed && parsed !== query) { + timeline.moveTo(new Date(parsed), { animation: { duration: 500, easingFunction: 'easeInOutQuad' } }); + } else { + var input = document.getElementById('instantSearchInput'); + input.classList.add('is-invalid'); + setTimeout(function () { input.classList.remove('is-invalid'); }, 1500); + } + } + } + + // Summary statistics computation + function computeStats() { + if (!filteredView) return; + + var items = filteredView.get({ + filter: function (item) { return item.type !== 'background'; } + }); + + var total = items.length; + document.getElementById('statTotal').textContent = total; + + var completed = 0, inflight = 0, requested = 0; + items.forEach(function (item) { + if (item.state === 'COMPLETED') completed++; + else if (item.state === 'INFLIGHT') inflight++; + else if (item.state === 'REQUESTED') requested++; + }); + + var badges = document.getElementById('statByState').children; + badges[0].textContent = completed; + badges[1].textContent = inflight; + badges[2].textContent = requested; + + // Time span + if (items.length > 0) { + var dates = items.map(function (i) { return new Date(i.start); }).sort(function (a, b) { return a - b; }); + var spanMs = dates[dates.length - 1] - dates[0]; + document.getElementById('statTimeSpan').textContent = localize({ + days: Math.floor(spanMs / (1000 * 60 * 60 * 24)), + hours: Math.floor(spanMs / (1000 * 60 * 60)) % 24, + minutes: Math.floor(spanMs / (1000 * 60)) % 60, + seconds: Math.floor(spanMs / 1000) % 60 + }); + } else { + document.getElementById('statTimeSpan').textContent = '\u2014'; + } + + // Avg commit duration + var commitDurations = []; + items.forEach(function (item) { + if ((item.action === 'commit' || item.action === 'deltacommit') + && item.state === 'COMPLETED' && item.end) { + var diff = new Date(item.end) - new Date(item.start); + if (diff > 0) commitDurations.push(diff); + } + }); + + if (commitDurations.length > 0) { + var avg = commitDurations.reduce(function (a, b) { return a + b; }, 0) / commitDurations.length; + document.getElementById('statAvgDuration').textContent = localize({ + days: Math.floor(avg / (1000 * 60 * 60 * 24)), + hours: Math.floor(avg / (1000 * 60 * 60)) % 24, + minutes: Math.floor(avg / (1000 * 60)) % 60, + seconds: Math.floor(avg / 1000) % 60 + }); + } else { + document.getElementById('statAvgDuration').textContent = '\u2014'; + } + + // Update instant count badge + instantCount.textContent = total + ' instants'; + } + + // Build action filter pills dynamically from the raw actions present in the + // loaded data. Pills are per raw action so a pending compaction can be toggled + // independently of the completed commits sharing its group row. + function buildActionFilters() { + var container = document.getElementById('actionFilters'); + container.innerHTML = ''; + var ordered = []; + RAW_ACTION_ORDER.forEach(function (action) { + if (allLoadedActions.has(action)) ordered.push(action); + }); + // Append any loaded actions not covered by the known order list. + allLoadedActions.forEach(function (action) { + if (ordered.indexOf(action) === -1) ordered.push(action); + }); + ordered.forEach(function (action) { + var btn = document.createElement('button'); + btn.className = 'btn btn-sm filter-pill active'; + btn.setAttribute('data-filter-action', action); + btn.textContent = action; + container.appendChild(btn); + }); + } + + // Reset state filter pill UI to all-active + function resetFilterPillUI() { + document.querySelectorAll('[data-filter-state]').forEach(function (btn) { + btn.classList.add('active'); + }); + } + + // Filter pill click handler (delegated) + document.getElementById('filterControls').addEventListener('click', function (e) { + var btn = e.target.closest('.filter-pill'); + if (!btn || !filteredView) return; + + var stateFilter = btn.getAttribute('data-filter-state'); + var actionFilter = btn.getAttribute('data-filter-action'); + + if (stateFilter) { + if (activeStates.has(stateFilter)) { + activeStates.delete(stateFilter); + btn.classList.remove('active'); + } else { + activeStates.add(stateFilter); + btn.classList.add('active'); + } + } + + if (actionFilter) { + if (activeActions.has(actionFilter)) { + activeActions.delete(actionFilter); + btn.classList.remove('active'); + } else { + activeActions.add(actionFilter); + btn.classList.add('active'); + } + } + + filteredView.refresh(); + computeStats(); + }); + + document.getElementById('timelineForm').addEventListener('submit', function (e) { + e.preventDefault(); + + setState(STATES.LOADING); + + var tablePath = document.getElementById('tablePath').value; + var timelineContainer = document.getElementById('timeline'); + + // Reset cached tab data on new table load + tableConfigData = null; + schemaHistoryData = null; + + // Switch to Timeline tab + var timelineTab = document.getElementById('tab-timeline'); + if (timelineTab) { + bootstrap.Tab.getOrCreateInstance(timelineTab).show(); + } + + fetch(API_BASE + '/timeline/instants/all?basepath=' + encodeURIComponent(tablePath)) + .then(function (res) { + if (!res.ok) { + return httpError(res); + } + return res.json(); + }) + .then(function (data) { + var instants = data.instants; + if (!instants || instants.length === 0) { + setState(STATES.ERROR, 'No instants found for this table.'); + return; + } + + setState(STATES.LOADED); + + var items = instants.map(function (instant, index) { + var requestTs = instant.requestTs; + var completionTs = instant.completionTs; + var action = instant.action; + var state = instant.state; + // Group by the server-provided comparable action (fall back to the raw + // action if absent, then to -1 for anything unknown). The item keeps its + // raw action below for the label, colors and detail fetch. + var comparableAction = instant.comparableAction || action; + var groupId = comparableActionToGroupId[comparableAction] !== undefined + ? comparableActionToGroupId[comparableAction] : -1; + + var effectiveRequestTs = /^0+$/.test(requestTs) ? completionTs : requestTs; + var requestTsFormatted = parseHudiTimestamp(effectiveRequestTs); + var completionTsFormatted = completionTs ? parseHudiTimestamp(completionTs) : null; + + var item = { + id: index + 1, + content: requestTs + '__' + action + '__' + state, + start: requestTsFormatted, + group: groupId, + className: 'state-' + state, + requestTs: requestTs, + completionTs: completionTs, + action: action, + state: state + }; + + // Completed instants with a completion time render as range bars + if (completionTsFormatted && state === 'COMPLETED') { + item.end = completionTsFormatted; + } + + return item; + }); + + // Set up DataSet, DataView, and filters + allItems = new vis.DataSet(items); + allLoadedActions = new Set(items.map(function (i) { return i.action; })); + activeActions = new Set(allLoadedActions); + activeStates = new Set(['COMPLETED', 'INFLIGHT', 'REQUESTED']); + resetFilterPillUI(); + buildActionFilters(); + + filteredView = new vis.DataView(allItems, { + filter: function (item) { + if (item.type === 'background') return true; + return activeStates.has(item.state) && activeActions.has(item.action); + } + }); + + if (timeline === null) { + timeline = new vis.Timeline(timelineContainer, filteredView, groups, options); + } else { + timeline.setItems(filteredView); Review Comment: Fixed: `timeline.fit({ animation: false })` after `setItems` on the reused instance. ########## hudi-timeline-service/src/main/resources/public/js/timeline.js: ########## @@ -0,0 +1,1266 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +(function () { + 'use strict'; + + // Base path for the Timeline UI REST endpoints (served by Javalin under /ui/api). + var API_BASE = '/ui/api'; + + // DOM references + var stateEmpty = document.getElementById('stateEmpty'); + var stateLoading = document.getElementById('stateLoading'); + var stateError = document.getElementById('stateError'); + var stateLoaded = document.getElementById('stateLoaded'); + var errorMessage = document.getElementById('errorMessage'); + var instantCount = document.getElementById('instantCount'); + var detailCard = document.getElementById('detailCard'); + var detailInstantId = document.getElementById('detailInstantId'); + var detailAction = document.getElementById('detailAction'); + var detailState = document.getElementById('detailState'); + var detailMeta = document.getElementById('detailMeta'); + var detailBody = document.getElementById('detailBody'); + + var timeline = null; + var cleanRangeItemId = 'clean-range-bg'; + var allItems = null; + var filteredView = null; + var activeStates = new Set(['COMPLETED', 'INFLIGHT', 'REQUESTED']); + var allLoadedActions = new Set(); + var activeActions = new Set(); + var currentTablePath = null; + + // Lazy-loaded data caches for tabs + var tableConfigData = null; + var schemaHistoryData = null; + + // Maps each comparable action to a group row: one row per comparable action. + // Pending compaction/logcompaction/clustering fold into the row of the action + // they complete as (compaction -> commit, logcompaction -> deltacommit, + // clustering -> replacecommit), mirroring Hudi's own timeline mapping. The + // comparableAction is computed server-side and sent on each instant; items + // keep their raw action for the visible label, colors and detail fetch. + var comparableActionToGroupId = { + 'commit': 0, + 'deltacommit': 1, + 'replacecommit': 2, + 'clean': 3, + 'rollback': 4, + 'savepoint': 5, + 'restore': 6, + 'indexing': 7 + }; + + var groups = Object.keys(comparableActionToGroupId).map(function (action) { + return { id: comparableActionToGroupId[action], content: action }; + }); + + // Preferred display order for the raw-action filter pills. Filtering is always + // by raw action (what the user sees on each item), even though several raw + // actions can share a single comparable-action group row. + var RAW_ACTION_ORDER = [ + 'commit', 'deltacommit', 'replacecommit', 'compaction', 'logcompaction', + 'clustering', 'clean', 'rollback', 'savepoint', 'restore', 'indexing' + ]; + + // State management + var STATES = {EMPTY: 'EMPTY', LOADING: 'LOADING', ERROR: 'ERROR', LOADED: 'LOADED'}; + var stateElements = { + EMPTY: stateEmpty, + LOADING: stateLoading, + ERROR: stateError, + LOADED: stateLoaded + }; + + function setState(state, data) { + Object.keys(stateElements).forEach(function (key) { + stateElements[key].classList.add('d-none'); + }); + stateElements[state].classList.remove('d-none'); + + if (state === STATES.ERROR && data) { + errorMessage.textContent = data; + } + + // Hide detail card when switching states + if (state !== STATES.LOADED) { + detailCard.classList.add('d-none'); + } + } + + // State badge color mapping + var stateBadgeClass = { + 'COMPLETED': 'bg-success', + 'INFLIGHT': 'bg-warning text-dark', + 'REQUESTED': 'bg-danger' + }; + + function displayInstantDetails(item) { + var html = '<p>Start: ' + escapeHtml(String(item.start)) + '</p>'; + if (item.end !== undefined) { + html += '<p>End: ' + escapeHtml(String(item.end)) + '</p>'; + var duration = localize(timeDiff(item.end, item.start)); + html += '<p>Duration: ' + escapeHtml(duration) + '</p>'; + } + html += '<p>Instant: ' + escapeHtml(item.content) + '</p>'; + return html; + } + + // Composes a descriptive Error from a non-OK fetch Response and returns a + // Promise that rejects with it. The server sends human-readable bodies on + // errors (e.g. "Not a valid Hudi table path: ..."), so prefer the body over + // the terse statusText; fall back to statusText when the body is empty. The + // body is capped at ~300 chars so DOM alerts stay readable. Every caller + // surfaces the message via textContent, so the server text is never treated + // as HTML. + function httpError(res) { + return res.text().then(function (body) { + var message = 'HTTP ' + res.status; + var detail = (body || '').trim(); + if (detail) { + if (detail.length > 300) { + detail = detail.slice(0, 300) + '...'; + } + message += ': ' + detail; + } else if (res.statusText) { + message += ': ' + res.statusText; + } + throw new Error(message); + }); + } + + var options = { + width: '100%', + height: '100%', + margin: { item: 10, axis: 5 }, + horizontalScroll: true, + zoomKey: 'shiftKey', + editable: false, + tooltip: { + delay: 0, + template: displayInstantDetails + } + }; + + // Navigation: Go to Now + document.getElementById('goToNowBtn').addEventListener('click', function () { + if (timeline) { + timeline.moveTo(new Date()); + } + }); + + // Navigation: Instant search + document.getElementById('instantSearchBtn').addEventListener('click', function () { + focusOnInstant(); + }); + document.getElementById('instantSearchInput').addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + focusOnInstant(); + } + }); + + function focusOnInstant() { + if (!timeline || !allItems) return; + var query = document.getElementById('instantSearchInput').value.trim(); + if (!query) return; + + var searchItems = allItems.get(); + var match = null; + for (var i = 0; i < searchItems.length; i++) { + if (searchItems[i].requestTs === query || searchItems[i].completionTs === query) { + match = searchItems[i]; + break; + } + } + + if (match) { + timeline.focus(match.id, { animation: { duration: 500, easingFunction: 'easeInOutQuad' } }); + timeline.setSelection([match.id]); + } else { + var parsed = parseHudiTimestamp(query); + if (parsed && parsed !== query) { + timeline.moveTo(new Date(parsed), { animation: { duration: 500, easingFunction: 'easeInOutQuad' } }); + } else { + var input = document.getElementById('instantSearchInput'); + input.classList.add('is-invalid'); + setTimeout(function () { input.classList.remove('is-invalid'); }, 1500); + } + } + } + + // Summary statistics computation + function computeStats() { + if (!filteredView) return; + + var items = filteredView.get({ + filter: function (item) { return item.type !== 'background'; } + }); + + var total = items.length; + document.getElementById('statTotal').textContent = total; + + var completed = 0, inflight = 0, requested = 0; + items.forEach(function (item) { + if (item.state === 'COMPLETED') completed++; + else if (item.state === 'INFLIGHT') inflight++; + else if (item.state === 'REQUESTED') requested++; + }); + + var badges = document.getElementById('statByState').children; + badges[0].textContent = completed; + badges[1].textContent = inflight; + badges[2].textContent = requested; + + // Time span + if (items.length > 0) { + var dates = items.map(function (i) { return new Date(i.start); }).sort(function (a, b) { return a - b; }); + var spanMs = dates[dates.length - 1] - dates[0]; + document.getElementById('statTimeSpan').textContent = localize({ + days: Math.floor(spanMs / (1000 * 60 * 60 * 24)), + hours: Math.floor(spanMs / (1000 * 60 * 60)) % 24, + minutes: Math.floor(spanMs / (1000 * 60)) % 60, + seconds: Math.floor(spanMs / 1000) % 60 + }); + } else { + document.getElementById('statTimeSpan').textContent = '\u2014'; + } + + // Avg commit duration + var commitDurations = []; + items.forEach(function (item) { + if ((item.action === 'commit' || item.action === 'deltacommit') + && item.state === 'COMPLETED' && item.end) { + var diff = new Date(item.end) - new Date(item.start); + if (diff > 0) commitDurations.push(diff); + } + }); + + if (commitDurations.length > 0) { + var avg = commitDurations.reduce(function (a, b) { return a + b; }, 0) / commitDurations.length; + document.getElementById('statAvgDuration').textContent = localize({ + days: Math.floor(avg / (1000 * 60 * 60 * 24)), + hours: Math.floor(avg / (1000 * 60 * 60)) % 24, + minutes: Math.floor(avg / (1000 * 60)) % 60, + seconds: Math.floor(avg / 1000) % 60 + }); + } else { + document.getElementById('statAvgDuration').textContent = '\u2014'; + } + + // Update instant count badge + instantCount.textContent = total + ' instants'; + } + + // Build action filter pills dynamically from the raw actions present in the + // loaded data. Pills are per raw action so a pending compaction can be toggled + // independently of the completed commits sharing its group row. + function buildActionFilters() { + var container = document.getElementById('actionFilters'); + container.innerHTML = ''; + var ordered = []; + RAW_ACTION_ORDER.forEach(function (action) { + if (allLoadedActions.has(action)) ordered.push(action); + }); + // Append any loaded actions not covered by the known order list. + allLoadedActions.forEach(function (action) { + if (ordered.indexOf(action) === -1) ordered.push(action); + }); + ordered.forEach(function (action) { + var btn = document.createElement('button'); + btn.className = 'btn btn-sm filter-pill active'; + btn.setAttribute('data-filter-action', action); + btn.textContent = action; + container.appendChild(btn); + }); + } + + // Reset state filter pill UI to all-active + function resetFilterPillUI() { + document.querySelectorAll('[data-filter-state]').forEach(function (btn) { + btn.classList.add('active'); + }); + } + + // Filter pill click handler (delegated) + document.getElementById('filterControls').addEventListener('click', function (e) { + var btn = e.target.closest('.filter-pill'); + if (!btn || !filteredView) return; + + var stateFilter = btn.getAttribute('data-filter-state'); + var actionFilter = btn.getAttribute('data-filter-action'); + + if (stateFilter) { + if (activeStates.has(stateFilter)) { + activeStates.delete(stateFilter); + btn.classList.remove('active'); + } else { + activeStates.add(stateFilter); + btn.classList.add('active'); + } + } + + if (actionFilter) { + if (activeActions.has(actionFilter)) { + activeActions.delete(actionFilter); + btn.classList.remove('active'); + } else { + activeActions.add(actionFilter); + btn.classList.add('active'); + } + } + + filteredView.refresh(); + computeStats(); + }); + + document.getElementById('timelineForm').addEventListener('submit', function (e) { + e.preventDefault(); + + setState(STATES.LOADING); + + var tablePath = document.getElementById('tablePath').value; + var timelineContainer = document.getElementById('timeline'); + + // Reset cached tab data on new table load + tableConfigData = null; + schemaHistoryData = null; + + // Switch to Timeline tab + var timelineTab = document.getElementById('tab-timeline'); + if (timelineTab) { + bootstrap.Tab.getOrCreateInstance(timelineTab).show(); + } + + fetch(API_BASE + '/timeline/instants/all?basepath=' + encodeURIComponent(tablePath)) + .then(function (res) { + if (!res.ok) { + return httpError(res); + } + return res.json(); + }) + .then(function (data) { + var instants = data.instants; + if (!instants || instants.length === 0) { + setState(STATES.ERROR, 'No instants found for this table.'); + return; + } + + setState(STATES.LOADED); + + var items = instants.map(function (instant, index) { + var requestTs = instant.requestTs; + var completionTs = instant.completionTs; + var action = instant.action; + var state = instant.state; + // Group by the server-provided comparable action (fall back to the raw + // action if absent, then to -1 for anything unknown). The item keeps its + // raw action below for the label, colors and detail fetch. + var comparableAction = instant.comparableAction || action; + var groupId = comparableActionToGroupId[comparableAction] !== undefined + ? comparableActionToGroupId[comparableAction] : -1; + + var effectiveRequestTs = /^0+$/.test(requestTs) ? completionTs : requestTs; + var requestTsFormatted = parseHudiTimestamp(effectiveRequestTs); + var completionTsFormatted = completionTs ? parseHudiTimestamp(completionTs) : null; + + var item = { + id: index + 1, + content: requestTs + '__' + action + '__' + state, + start: requestTsFormatted, + group: groupId, + className: 'state-' + state, + requestTs: requestTs, + completionTs: completionTs, + action: action, + state: state + }; + + // Completed instants with a completion time render as range bars + if (completionTsFormatted && state === 'COMPLETED') { + item.end = completionTsFormatted; + } + + return item; + }); + + // Set up DataSet, DataView, and filters + allItems = new vis.DataSet(items); + allLoadedActions = new Set(items.map(function (i) { return i.action; })); + activeActions = new Set(allLoadedActions); + activeStates = new Set(['COMPLETED', 'INFLIGHT', 'REQUESTED']); + resetFilterPillUI(); + buildActionFilters(); + + filteredView = new vis.DataView(allItems, { + filter: function (item) { + if (item.type === 'background') return true; + return activeStates.has(item.state) && activeActions.has(item.action); + } + }); + + if (timeline === null) { + timeline = new vis.Timeline(timelineContainer, filteredView, groups, options); + } else { + timeline.setItems(filteredView); + } + + computeStats(); + currentTablePath = tablePath; + + // Update URL with table path (preserve tab param) + updateUrlState(); + + timeline.off('select'); + timeline.on('select', function (props) { + onSelect(props, currentTablePath); + }); + }) + .catch(function (err) { + setState(STATES.ERROR, 'Failed to load timeline: ' + err.message); + console.error(err); + }); + }); + + function getCleanPolicy(cleanMetadataJson) { + var pm = cleanMetadataJson.partitionMetadata; + if (!pm) return null; + var keys = Object.keys(pm); + if (keys.length === 0) return null; + return pm[keys[0]].policy || null; + } + + function findPreviousCompletedClean(currentRequestTs) { + var cleans = allItems.get({ + filter: function (item) { + return item.action === 'clean' && item.state === 'COMPLETED' && item.requestTs < currentRequestTs; + } + }); + if (cleans.length === 0) return null; + cleans.sort(function (a, b) { return a.requestTs < b.requestTs ? 1 : -1; }); + return cleans[0]; + } + + function onSelect(props, tablePath) { + allItems.remove(cleanRangeItemId); + + if (props.items.length === 0) { + detailCard.classList.add('d-none'); + return; + } + + var item = allItems.get(props.items[0]); + + // Populate detail header + detailInstantId.textContent = item.requestTs; + detailAction.textContent = item.action; + detailState.textContent = item.state; + detailState.className = 'badge ' + (stateBadgeClass[item.state] || 'bg-secondary'); + + // Build metadata line + var metaParts = []; + var requestTsFormatted = parseHudiTimestamp(item.requestTs); + if (requestTsFormatted) { + metaParts.push('Request: ' + requestTsFormatted); + } + if (item.end) { + metaParts.push('Completed: ' + item.end); + var duration = localize(timeDiff(item.end, item.start)); + metaParts.push('Duration: ' + duration); + } + detailMeta.textContent = metaParts.join(' | '); + + // Show detail card with loading state + detailCard.classList.remove('d-none'); + detailBody.innerHTML = ''; + var loadingEl = document.createElement('div'); + loadingEl.className = 'text-center text-muted py-3'; + loadingEl.innerHTML = '<div class="spinner-border spinner-border-sm me-2" role="status"></div>Loading instant details...'; + detailBody.appendChild(loadingEl); + + var url = API_BASE + '/timeline/instant?basepath=' + encodeURIComponent(tablePath) + + '&instant=' + item.requestTs + + '&instantaction=' + item.action + + '&instantstate=' + item.state; + + fetch(url) + .then(function (res) { + if (!res.ok) { + return httpError(res); + } + return res.json(); + }) + .then(function (json) { + // Skip stale responses: the selection may have changed or been + // cleared while this fetch was in flight. + if (timeline.getSelection()[0] !== item.id) return; + detailBody.innerHTML = ''; + renderjson.set_show_to_level(1); + renderjson.set_icons('\u25B6', '\u25BC'); + renderjson.set_sort_objects(true); + detailBody.appendChild(renderjson(json)); + + // Show clean range only for KEEP_LATEST_COMMITS policy. + // The range spans from the previous clean's earliestCommitToRetain + // to this clean's earliestCommitToRetain, matching the incremental + // cleaning scan window in CleanPlanner. + var cleanPolicy = getCleanPolicy(json); + if (cleanPolicy === 'KEEP_LATEST_COMMITS' && json.earliestCommitToRetain) { + var prevClean = findPreviousCompletedClean(item.requestTs); + if (prevClean) { + var prevUrl = API_BASE + '/timeline/instant?basepath=' + encodeURIComponent(tablePath) + + '&instant=' + prevClean.requestTs + + '&instantaction=' + prevClean.action + + '&instantstate=' + prevClean.state; + fetch(prevUrl) + .then(function (res) { return res.ok ? res.json() : null; }) + .then(function (prevJson) { + // Skip stale responses: the selection may have changed or been + // cleared while this fetch was in flight. + if (timeline.getSelection()[0] !== item.id) return; + if (prevJson && prevJson.earliestCommitToRetain) { + var rangeStart = parseHudiTimestamp(prevJson.earliestCommitToRetain); + var rangeEnd = parseHudiTimestamp(json.earliestCommitToRetain); + allItems.add({ + id: cleanRangeItemId, + type: 'background', + content: 'Clean Range', + start: rangeStart, + end: rangeEnd, + className: 'clean-range-bg' + }); + detailMeta.textContent += ' | Clean range: ' + rangeStart + ' \u2192 ' + rangeEnd; + } + }) + .catch(function (err) { console.error('Failed to fetch previous clean metadata:', err); }); + } + } + }) + .catch(function (err) { + console.error(err); + if (timeline.getSelection()[0] !== item.id) return; + detailBody.innerHTML = ''; + var alertEl = document.createElement('div'); + alertEl.className = 'alert alert-danger mb-0'; + alertEl.textContent = 'Failed to fetch instant details: ' + err.message; + detailBody.appendChild(alertEl); + }); + } + + // ===== Tab handling ===== + + // Tab shown event: lazy-load data + var mainTabsEl = document.getElementById('mainTabs'); + mainTabsEl.addEventListener('shown.bs.tab', function (e) { + var targetId = e.target.getAttribute('data-bs-target'); + updateUrlState(); + + if (targetId === '#tabConfig') { + loadTableConfig(); + } else if (targetId === '#tabSchema') { + loadSchemaHistory(); + } + }); + + // Refresh controls: drop the cached tab data and re-pull the latest values. + var configRefreshBtn = document.getElementById('configRefreshBtn'); + if (configRefreshBtn) { + configRefreshBtn.addEventListener('click', function () { + tableConfigData = null; + loadTableConfig(); + }); + } + var schemaRefreshBtn = document.getElementById('schemaRefreshBtn'); + if (schemaRefreshBtn) { + schemaRefreshBtn.addEventListener('click', function () { + schemaHistoryData = null; + loadSchemaHistory(); + }); + } + + function loadTableConfig() { + if (!currentTablePath) return; + if (tableConfigData) { + renderTableConfig(tableConfigData); + return; + } + + document.getElementById('configLoading').classList.remove('d-none'); + document.getElementById('configContent').classList.add('d-none'); + document.getElementById('configError').classList.add('d-none'); + + fetch(API_BASE + '/table/config?basepath=' + encodeURIComponent(currentTablePath)) + .then(function (res) { + if (!res.ok) return httpError(res); + return res.json(); + }) + .then(function (data) { + tableConfigData = data; Review Comment: Fixed: `loadGeneration` is bumped in the submit handler; the config, schema-history and timeline fetches drop responses from an older generation. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
