wombatu-kun commented on code in PR #13147:
URL: https://github.com/apache/hudi/pull/13147#discussion_r3964120586


##########
hudi-timeline-service/src/main/java/org/apache/hudi/timeline/service/handlers/TimelineHandler.java:
##########
@@ -46,4 +135,185 @@ public List<InstantDTO> getLastInstant(String basePath) {
   public TimelineDTO getTimeline(String basePath) {
     return 
TimelineDTO.fromTimeline(viewManager.getFileSystemView(basePath).getTimeline());
   }
+
+  public UiTimelineDTO getUiTimeline(String basePath) {
+    // The active timeline is used, not the file-system-view write timeline: 
the latter drops
+    // clean/rollback/savepoint/restore/indexing actions and all 
requested/inflight states.
+    return 
UiTimelineDTO.fromTimeline(createMetaClient(basePath).getActiveTimeline());
+  }
+
+  public Object getInstantDetails(String basePath, String requestedTime, 
String action, String state) {
+    HoodieInstant.State parsedState;
+    try {
+      parsedState = HoodieInstant.State.valueOf(state);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestResponse("Invalid instant state: " + state);
+    }
+
+    if 
(!Arrays.asList(HoodieTimeline.VALID_ACTIONS_IN_TIMELINE).contains(action)) {
+      throw new BadRequestResponse("Invalid instant action: " + action);
+    }
+
+    HoodieTableMetaClient metaClient = createMetaClient(basePath);
+    HoodieTimeline activeTimeline = metaClient.getActiveTimeline();
+    CommitMetadataSerDe serde = metaClient.getCommitMetadataSerDe();
+
+    // Resolve the instant against the timeline rather than constructing it 
from request params:
+    // an attacker-controlled instant would otherwise flow into a StoragePath 
whose URI.normalize
+    // collapses ".." segments, enabling path traversal.
+    HoodieInstant instant = activeTimeline.getInstantsAsStream()
+        .filter(i -> i.requestedTime().equals(requestedTime)
+            && i.getAction().equals(action)
+            && i.getState() == parsedState)
+        .findFirst()
+        .orElseThrow(() -> new NotFoundResponse(

Review Comment:
   Every 404 from `getInstantDetails` - a stale tab, or an instant that 
completes between the timeline listing and the click - falls into 
`ViewHandler`'s else branch and is logged as ERROR with a full stack trace. Add 
`NotFoundResponse` to the `warn` branch alongside `BadRequestResponse`.



##########
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]);

Review Comment:
   `focusOnInstant` calls `setSelection` without `onSelect`, so after a search 
the detail card still shows the previously selected instant's id, badges and 
JSON while the timeline highlights a different one. Add the `onSelect({ items: 
[match.id] }, currentTablePath)` call that the arrow-key navigation handler 
already makes.



##########
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:
   `setItems` does not refit the viewport and vis-timeline only auto-fits once, 
so loading a second table whose instants fall outside the previous window 
leaves the canvas blank while the instant count and stats still update. Add a 
`timeline.fit({ animation: false })` after `setItems`.



##########
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:
   A config fetch issued for the previous table can resolve after the submit 
handler clears `tableConfigData` and repopulate it, so opening the Table Config 
tab then renders the old table's properties under the new path; 
`loadSchemaHistory` has the same shape. Tag each load with a generation counter 
bumped in the submit handler and drop responses from an older generation.



##########
hudi-timeline-service/src/test/java/org/apache/hudi/timeline/service/TestUiApi.java:
##########
@@ -0,0 +1,732 @@
+/*
+ * 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.
+ */
+
+package org.apache.hudi.timeline.service;
+
+import org.apache.hudi.avro.model.HoodieActionInstant;
+import org.apache.hudi.avro.model.HoodieCleanerPlan;
+import org.apache.hudi.avro.model.HoodieIndexCommitMetadata;
+import org.apache.hudi.avro.model.HoodieIndexPartitionInfo;
+import org.apache.hudi.common.config.HoodieCommonConfig;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.engine.HoodieLocalEngineContext;
+import org.apache.hudi.common.model.HoodieCommitMetadata;
+import org.apache.hudi.common.model.HoodieReplaceCommitMetadata;
+import org.apache.hudi.common.model.WriteOperationType;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import 
org.apache.hudi.common.table.timeline.versioning.clean.CleanPlanV2MigrationHandler;
+import org.apache.hudi.common.table.view.FileSystemViewManager;
+import org.apache.hudi.common.table.view.FileSystemViewStorageConfig;
+import org.apache.hudi.common.table.view.FileSystemViewStorageType;
+import org.apache.hudi.common.testutils.HoodieCommonTestHarness;
+import org.apache.hudi.common.testutils.HoodieTestTable;
+import org.apache.hudi.common.testutils.HoodieTestUtils;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.hadoop.conf.Configuration;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+
+import static 
org.apache.hudi.common.table.view.RemoteHoodieTableFileSystemView.BASEPATH_PARAM;
+import static 
org.apache.hudi.common.table.view.RemoteHoodieTableFileSystemView.LAST_INSTANT_URL;
+import static 
org.apache.hudi.common.testutils.FileCreateUtils.createInflightCompaction;
+import static 
org.apache.hudi.common.testutils.FileCreateUtils.createRequestedCleanFile;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for the Timeline UI API (routes under {@code /ui} and {@code 
/ui/api}), gated behind
+ * {@code TimelineService.Config.enableUi}. Exercises the JSON contract 
consumed by the browser
+ * UI, the path-traversal defense on the instant-details route, and the 
enable-ui flag gating.
+ */
+class TestUiApi extends HoodieCommonTestHarness {
+
+  private static final ObjectMapper MAPPER = new ObjectMapper();
+
+  private static final String UI_TIMELINE_URL = 
"/ui/api/timeline/instants/all";
+  private static final String UI_INSTANT_URL = "/ui/api/timeline/instant";
+  private static final String UI_CONFIG_URL = "/ui/api/table/config";
+  private static final String UI_SCHEMA_URL = "/ui/api/table/schema/history";
+  private static final String UI_PAGE_URL = "/ui";
+  private static final String UI_STATIC_JS_URL = "/ui/static/js/timeline.js";
+
+  private static final String INSTANT_PARAM = "instant";
+  private static final String INSTANT_ACTION_PARAM = "instantaction";
+  private static final String INSTANT_STATE_PARAM = "instantstate";
+  private static final String LIMIT_PARAM = "limit";
+
+  // A minimal but valid Avro schema, so TableSchemaResolver can parse it when 
computing currentSchema.
+  private static final String SCHEMA_A =
+      
"{\"type\":\"record\",\"name\":\"R\",\"fields\":[{\"name\":\"id\",\"type\":\"long\"}]}";
+  private static final String SCHEMA_B =
+      
"{\"type\":\"record\",\"name\":\"R\",\"fields\":[{\"name\":\"id\",\"type\":\"long\"},"
+          + 
"{\"name\":\"name\",\"type\":[\"null\",\"string\"],\"default\":null}]}";
+  private static final String SCHEMA_C =
+      
"{\"type\":\"record\",\"name\":\"R\",\"fields\":[{\"name\":\"id\",\"type\":\"long\"},"
+          + 
"{\"name\":\"name\",\"type\":[\"null\",\"string\"],\"default\":null},"
+          + 
"{\"name\":\"amount\",\"type\":[\"null\",\"double\"],\"default\":null}]}";
+
+  private Configuration configuration;
+  private TimelineService server;
+  private int port;
+
+  @BeforeEach
+  void setUp() throws Exception {
+    configuration = new Configuration();
+    server = startServer(true);
+    port = server.getServerPort();
+    awaitUiReady(port);
+  }
+
+  // Guards against a brief startup window where the freshly bound server can 
404 a route.
+  private void awaitUiReady(int targetPort) throws InterruptedException {
+    long deadline = System.currentTimeMillis() + 10_000;
+    while (System.currentTimeMillis() < deadline) {
+      try {
+        if (httpGet(targetPort, UI_PAGE_URL, Collections.emptyMap()).code == 
200) {
+          return;
+        }
+      } catch (IOException ignored) {
+        // server not yet accepting connections
+      }
+      Thread.sleep(50);
+    }
+    throw new IllegalStateException("UI server did not become ready on port " 
+ targetPort);
+  }
+
+  @AfterEach
+  void tearDown() {
+    if (server != null) {
+      server.close();
+    }
+  }
+
+  private TimelineService startServer(boolean enableUi) throws IOException {
+    FileSystemViewStorageConfig sConf =
+        
FileSystemViewStorageConfig.newBuilder().withStorageType(FileSystemViewStorageType.SPILLABLE_DISK).build();
+    HoodieMetadataConfig metadataConfig = 
HoodieMetadataConfig.newBuilder().build();
+    HoodieCommonConfig commonConfig = HoodieCommonConfig.newBuilder().build();
+    HoodieLocalEngineContext ctx = new HoodieLocalEngineContext(new 
HadoopStorageConfiguration(configuration));
+    TimelineService svc = TimelineServiceTestHarness.newBuilder().build(
+        configuration,
+        
TimelineService.Config.builder().serverPort(0).enableUi(enableUi).build(),
+        FileSystemViewManager.createViewManager(ctx, metadataConfig, sConf, 
commonConfig));
+    svc.startService();
+    return svc;
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Test-table helpers
+  // 
---------------------------------------------------------------------------
+
+  private HoodieTableMetaClient initTable(String name) throws IOException {
+    return 
HoodieTestUtils.init(tempDir.resolve(name).toAbsolutePath().toString());
+  }
+
+  private HoodieCommitMetadata commitMetadata(HoodieTableMetaClient mc, String 
basePath, String ts,
+                                              Map<String, String> extra) 
throws IOException {
+    return getCommitMetadata(mc, basePath, "par", ts, 1, extra).get();
+  }
+
+  private HoodieCommitMetadata commitMetadataWithSchema(HoodieTableMetaClient 
mc, String basePath, String ts,
+                                                        String schema) throws 
IOException {
+    Map<String, String> extra = new HashMap<>();
+    extra.put(HoodieCommitMetadata.SCHEMA_KEY, schema);
+    return commitMetadata(mc, basePath, ts, extra);
+  }
+
+  // Writes an EMPTY instant file (any requested/inflight extension) straight 
into the timeline
+  // directory for actions with no HoodieTestTable helper. The instants/all 
route only lists instants,
+  // and the production inflight files of plan-carrying actions are empty by 
design.
+  private void createEmptyInstantFile(HoodieTableMetaClient mc, String ts, 
String extension)
+      throws IOException {
+    Path timelineDir = Paths.get(mc.getTimelinePath().toUri().getPath());
+    Files.createDirectories(timelineDir);
+    Files.createFile(timelineDir.resolve(ts + extension));
+  }
+
+  // 
---------------------------------------------------------------------------
+  // HTTP helpers
+  // 
---------------------------------------------------------------------------
+
+  private static final class Http {
+    final int code;
+    final String body;
+    final String contentType;
+
+    Http(int code, String body, String contentType) {
+      this.code = code;
+      this.body = body;
+      this.contentType = contentType;
+    }
+  }
+
+  private Http httpGet(int targetPort, String path, Map<String, String> 
params) throws IOException {
+    StringBuilder url = new 
StringBuilder("http://localhost:";).append(targetPort).append(path);
+    boolean first = true;
+    for (Map.Entry<String, String> e : params.entrySet()) {
+      url.append(first ? '?' : '&');
+      url.append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8))
+          .append('=')
+          .append(URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8));
+      first = false;
+    }
+    HttpURLConnection conn = (HttpURLConnection) new 
URL(url.toString()).openConnection();
+    conn.setRequestMethod("GET");
+    // Avoid reusing a keep-alive connection to a previously closed server on 
a recycled port.
+    conn.setRequestProperty("Connection", "close");
+    int code = conn.getResponseCode();
+    String contentType = conn.getContentType();
+    InputStream is = code >= 400 ? conn.getErrorStream() : 
conn.getInputStream();
+    String body = is == null ? "" : new String(is.readAllBytes(), 
StandardCharsets.UTF_8);
+    conn.disconnect();
+    return new Http(code, body, contentType);
+  }
+
+  private Map<String, String> params(String... kv) {
+    Map<String, String> m = new LinkedHashMap<>();
+    for (int i = 0; i < kv.length; i += 2) {
+      m.put(kv[i], kv[i + 1]);
+    }
+    return m;
+  }
+
+  private JsonNode getJsonOk(String path, Map<String, String> params) throws 
IOException {
+    Http r = httpGet(port, path, params);
+    assertEquals(200, r.code, r.body);
+    return MAPPER.readTree(r.body);
+  }
+
+  private JsonNode findInstant(JsonNode root, String requestTs) {
+    for (JsonNode n : root.get("instants")) {
+      if (requestTs.equals(n.get("requestTs").asText())) {
+        return n;
+      }
+    }
+    return null;
+  }
+
+  private static boolean isJsonNull(JsonNode node) {
+    return node == null || node.isNull();
+  }
+
+  // 
---------------------------------------------------------------------------
+  // 1. getUiTimeline mapping and ordering
+  // 
---------------------------------------------------------------------------
+
+  @Test
+  void testUiTimelineMappingAndOrder() throws Exception {
+    HoodieTableMetaClient mc = initTable("mapping");
+    String base = mc.getBasePath().toString();
+    String commitTs = "20240101000001";
+    String compactionTs = "20240101000002";
+    String cleanTs = "20240101000003";
+    String logCompactionTs = "20240101000004";
+    String clusteringTs = "20240101000005";
+
+    HoodieTestTable table = HoodieTestTable.of(mc);
+    table.addCommit(commitTs, Option.of(commitMetadata(mc, base, commitTs, 
Collections.emptyMap())));
+    // A pending (requested) compaction: not yet completed.
+    table.addRequestedCompaction(compactionTs);
+    // A clean: a non-foldable action whose comparableAction equals its action.
+    table.addClean(cleanTs);
+    // Two more pending instants that pin the remaining comparable-action folds
+    // (logcompaction -> deltacommit, clustering -> replacecommit). No 
HoodieTestTable helper writes a
+    // requested logcompaction and the clustering helper needs Avro metadata, 
so write empty requested
+    // instant files directly; the instants/all route never reads their 
content.
+    createEmptyInstantFile(mc, logCompactionTs, 
HoodieTimeline.REQUESTED_LOG_COMPACTION_EXTENSION);
+    createEmptyInstantFile(mc, clusteringTs, 
HoodieTimeline.REQUESTED_CLUSTERING_COMMIT_EXTENSION);
+
+    JsonNode root = getJsonOk(UI_TIMELINE_URL, params(BASEPATH_PARAM, base));
+    JsonNode instants = root.get("instants");
+    assertNotNull(instants, root.toString());
+
+    // Completed plain commit: comparableAction == action == commit, 
completionTs populated.
+    JsonNode commit = findInstant(root, commitTs);
+    assertNotNull(commit, root.toString());
+    assertEquals("commit", commit.get("action").asText());
+    assertEquals("commit", commit.get("comparableAction").asText());
+    assertEquals("COMPLETED", commit.get("state").asText());
+    assertFalse(isJsonNull(commit.get("completionTs")), "completed commit must 
carry a completionTs");
+
+    // Pending compaction: action=compaction, comparableAction folds to 
commit, completionTs null.
+    JsonNode compaction = findInstant(root, compactionTs);
+    assertNotNull(compaction, root.toString());
+    assertEquals("compaction", compaction.get("action").asText());
+    assertEquals("commit", compaction.get("comparableAction").asText());
+    assertEquals("REQUESTED", compaction.get("state").asText());
+    assertTrue(isJsonNull(compaction.get("completionTs")), "pending compaction 
must have null completionTs");
+
+    // Clean: non-foldable, comparableAction == action.
+    JsonNode clean = findInstant(root, cleanTs);
+    assertNotNull(clean, root.toString());
+    assertEquals("clean", clean.get("action").asText());
+    assertEquals("clean", clean.get("comparableAction").asText());
+
+    // Pending logcompaction: action=logcompaction, comparableAction folds to 
deltacommit, completionTs null.
+    JsonNode logCompaction = findInstant(root, logCompactionTs);
+    assertNotNull(logCompaction, root.toString());
+    assertEquals("logcompaction", logCompaction.get("action").asText());
+    assertEquals("deltacommit", 
logCompaction.get("comparableAction").asText());
+    assertEquals("REQUESTED", logCompaction.get("state").asText());
+    assertTrue(isJsonNull(logCompaction.get("completionTs")), "pending 
logcompaction must have null completionTs");
+
+    // Pending clustering: action=clustering, comparableAction folds to 
replacecommit, completionTs null.
+    JsonNode clustering = findInstant(root, clusteringTs);
+    assertNotNull(clustering, root.toString());
+    assertEquals("clustering", clustering.get("action").asText());
+    assertEquals("replacecommit", clustering.get("comparableAction").asText());
+    assertEquals("REQUESTED", clustering.get("state").asText());
+    assertTrue(isJsonNull(clustering.get("completionTs")), "pending clustering 
must have null completionTs");
+
+    // Instants returned in timeline (request-time ascending) order.
+    String previous = "";
+    for (JsonNode n : instants) {
+      String ts = n.get("requestTs").asText();
+      assertTrue(ts.compareTo(previous) >= 0, "instants not in ascending 
request-time order: " + root);
+      previous = ts;
+    }
+  }
+
+  @Test
+  void testUiTimelineCompletedCompactionFoldsToCommit() throws Exception {
+    HoodieTableMetaClient mc = initTable("completed-compaction");
+    String base = mc.getBasePath().toString();
+    String ts = "20240101000010";
+    HoodieCommitMetadata meta = commitMetadata(mc, base, ts, 
Collections.emptyMap());
+    // Writes .compaction.requested, .compaction.inflight and a completed 
.commit for the same instant.
+    HoodieTestTable.of(mc).addCompaction(ts, meta);
+
+    JsonNode root = getJsonOk(UI_TIMELINE_URL, params(BASEPATH_PARAM, base));
+
+    int matches = 0;
+    for (JsonNode n : root.get("instants")) {
+      if (ts.equals(n.get("requestTs").asText())) {
+        matches++;
+        // The active-timeline layout filter folds the (requested, inflight, 
completed) triple; the
+        // completed file is a .commit, so a completed compaction surfaces as 
action=commit.
+        assertEquals("commit", n.get("action").asText(), "completed compaction 
must surface as commit");
+        assertEquals("COMPLETED", n.get("state").asText());
+        assertFalse(isJsonNull(n.get("completionTs")));
+      }
+      assertFalse("compaction".equals(n.get("action").asText()) && 
ts.equals(n.get("requestTs").asText()),
+          "completed compaction must never surface as action=compaction");
+    }
+    assertEquals(1, matches, "completed compaction must surface exactly once: 
" + root);
+  }
+
+  @Test
+  void testUiTimelineEmptyTableReturnsEmptyList() throws Exception {
+    HoodieTableMetaClient mc = initTable("empty-timeline");
+    String base = mc.getBasePath().toString();
+
+    JsonNode root = getJsonOk(UI_TIMELINE_URL, params(BASEPATH_PARAM, base));
+    JsonNode instants = root.get("instants");
+    assertNotNull(instants, root.toString());
+    assertTrue(instants.isArray(), root.toString());
+    assertEquals(0, instants.size(), root.toString());
+  }
+
+  // 
---------------------------------------------------------------------------
+  // 2. getInstantDetails
+  // 
---------------------------------------------------------------------------
+
+  @Test
+  void testGetInstantDetailsCommitRoundTrip() throws Exception {
+    HoodieTableMetaClient mc = initTable("instant-commit");
+    String base = mc.getBasePath().toString();
+    String ts = "20240101000021";
+    Map<String, String> extra = new HashMap<>();
+    extra.put("myUiTestKey", "myUiTestValue");
+    HoodieTestTable.of(mc).addCommit(ts, Option.of(commitMetadata(mc, base, 
ts, extra)));
+
+    JsonNode root = getJsonOk(UI_INSTANT_URL,
+        params(BASEPATH_PARAM, base, INSTANT_PARAM, ts, INSTANT_ACTION_PARAM, 
"commit", INSTANT_STATE_PARAM, "COMPLETED"));
+    assertEquals("myUiTestValue", 
root.get("extraMetadata").get("myUiTestKey").asText(), root.toString());
+  }
+
+  @Test
+  void testGetInstantDetailsCompactionPlan() throws Exception {
+    HoodieTableMetaClient mc = initTable("instant-compaction-plan");
+    String base = mc.getBasePath().toString();
+    String ts = "20240101000022";
+    HoodieTestTable.of(mc).addRequestedCompaction(ts);
+
+    JsonNode root = getJsonOk(UI_INSTANT_URL,
+        params(BASEPATH_PARAM, base, INSTANT_PARAM, ts, INSTANT_ACTION_PARAM, 
"compaction", INSTANT_STATE_PARAM, "REQUESTED"));
+    // HoodieCompactionPlan is converted to a Map; the requested plan carries 
operations for its file slices.
+    assertTrue(root.has("operations"), "compaction plan must expose 
operations: " + root);
+    assertTrue(root.get("operations").isArray());
+  }
+
+  @Test
+  void testGetInstantDetailsCleanPlan() throws Exception {
+    HoodieTableMetaClient mc = initTable("instant-clean-plan");
+    String base = mc.getBasePath().toString();
+    String ts = "20240101000024";
+    // A REQUESTED-only clean carrying a HoodieCleanerPlan built the way 
HoodieTestTable.addClean does,
+    // but with a recognizable policy string. Requested-only is deliberate: 
the active timeline keeps only
+    // the highest state per instant, so writing inflight/completed files too 
would surface the completed
+    // HoodieCleanMetadata instead of the plan.
+    HoodieCleanerPlan cleanerPlan = new HoodieCleanerPlan(new 
HoodieActionInstant("", "", ""), "",
+        "KEEP_LATEST_COMMITS", new HashMap<>(), 
CleanPlanV2MigrationHandler.VERSION, new HashMap<>(),
+        new ArrayList<>(), Collections.emptyMap());
+    createRequestedCleanFile(mc, ts, cleanerPlan);
+
+    JsonNode root = getJsonOk(UI_INSTANT_URL,
+        params(BASEPATH_PARAM, base, INSTANT_PARAM, ts, INSTANT_ACTION_PARAM, 
"clean", INSTANT_STATE_PARAM, "REQUESTED"));
+    // The server converts the Avro HoodieCleanerPlan to a Map, so field names 
are the Avro field names.
+    assertEquals("KEEP_LATEST_COMMITS", root.get("policy").asText(), 
root.toString());
+    assertTrue(root.has("version"), "clean plan must expose version: " + root);
+  }
+
+  @Test
+  void testGetInstantDetailsCompletedReplaceCommit() throws Exception {
+    HoodieTableMetaClient mc = initTable("instant-replacecommit");
+    String base = mc.getBasePath().toString();
+    String ts = "20240101000025";
+    // A completed replacecommit is avro HoodieReplaceCommitMetadata on disk; 
reading it as plain
+    // HoodieCommitMetadata previously 500ed on the avro record-name mismatch. 
The POJO carries
+    // partitionToReplaceFileIds, surfaced directly (POJO, so no avro->Map 
conversion).
+    HoodieReplaceCommitMetadata completeReplaceMetadata = new 
HoodieReplaceCommitMetadata();
+    
completeReplaceMetadata.setOperationType(WriteOperationType.INSERT_OVERWRITE);
+    completeReplaceMetadata.addReplaceFileId("par", "file-1");
+    HoodieTestTable.of(mc).addReplaceCommit(ts, Option.empty(), 
Option.empty(), completeReplaceMetadata);
+
+    // A completed replacecommit surfaces in the folded active timeline as 
action=replacecommit
+    // (unlike compaction, which completes as commit).
+    JsonNode root = getJsonOk(UI_INSTANT_URL, params(BASEPATH_PARAM, base, 
INSTANT_PARAM, ts,
+        INSTANT_ACTION_PARAM, "replacecommit", INSTANT_STATE_PARAM, 
"COMPLETED"));
+    JsonNode replaced = root.get("partitionToReplaceFileIds");
+    assertNotNull(replaced, root.toString());
+    assertEquals("file-1", replaced.get("par").get(0).asText(), 
root.toString());
+  }
+
+  @Test
+  void testGetInstantDetailsCompletedIndexing() throws Exception {
+    HoodieTableMetaClient mc = initTable("instant-indexing");
+    String base = mc.getBasePath().toString();
+    String ts = "20240101000026";
+    // No HoodieTestTable helper writes an indexing instant. Lay down the 
empty requested+inflight
+    // pending files, then complete it exactly as RunIndexActionExecutor does. 
A completed indexing
+    // instant stores avro HoodieIndexCommitMetadata, not 
HoodieCommitMetadata; reading it as the
+    // latter previously 500ed.
+    createEmptyInstantFile(mc, ts, 
HoodieTimeline.REQUESTED_INDEX_COMMIT_EXTENSION);
+    createEmptyInstantFile(mc, ts, 
HoodieTimeline.INFLIGHT_INDEX_COMMIT_EXTENSION);
+    HoodieIndexPartitionInfo partitionInfo =
+        new HoodieIndexPartitionInfo(1, "column_stats", ts, 
Collections.emptyMap());
+    HoodieIndexCommitMetadata indexCommitMetadata = 
HoodieIndexCommitMetadata.newBuilder()
+        
.setVersion(1).setIndexPartitionInfos(Collections.singletonList(partitionInfo)).build();
+    // saveAsComplete checks the inflight file straight against storage; 
reloading the active timeline
+    // makes it observe the just-written pending files.
+    mc.reloadActiveTimeline().saveAsComplete(false,
+        mc.createNewInstant(HoodieInstant.State.INFLIGHT, 
HoodieTimeline.INDEXING_ACTION, ts),
+        Option.of(indexCommitMetadata));
+
+    JsonNode root = getJsonOk(UI_INSTANT_URL, params(BASEPATH_PARAM, base, 
INSTANT_PARAM, ts,
+        INSTANT_ACTION_PARAM, "indexing", INSTANT_STATE_PARAM, "COMPLETED"));
+    // The avro metadata is converted to a Map with avro field names.
+    JsonNode infos = root.get("indexPartitionInfos");
+    assertNotNull(infos, root.toString());
+    assertTrue(infos.isArray() && infos.size() >= 1, root.toString());
+    assertEquals("column_stats", 
infos.get(0).get("metadataPartitionPath").asText(), root.toString());
+  }
+
+  @Test
+  void testGetInstantDetailsInflightCompactionReturnsPlan() throws Exception {
+    HoodieTableMetaClient mc = initTable("instant-inflight-compaction");
+    String base = mc.getBasePath().toString();
+    String ts = "20240101000027";
+    // addRequestedCompaction writes a real HoodieCompactionPlan into the 
requested file;
+    // createInflightCompaction writes the production-like EMPTY inflight 
file. This pins the
+    // requested-twin read: the inflight compaction file is empty by design, 
so reading the plan from
+    // the inflight instant previously 500ed - it must be read from the 
requested twin.
+    HoodieTestTable.of(mc).addRequestedCompaction(ts);
+    createInflightCompaction(mc, ts);
+
+    JsonNode root = getJsonOk(UI_INSTANT_URL, params(BASEPATH_PARAM, base, 
INSTANT_PARAM, ts,
+        INSTANT_ACTION_PARAM, "compaction", INSTANT_STATE_PARAM, "INFLIGHT"));
+    assertTrue(root.has("operations"), "inflight compaction must expose its 
plan operations: " + root);
+    assertTrue(root.get("operations").isArray());
+  }
+
+  @Test
+  void testGetInstantDetailsCompletedSavepoint() throws Exception {
+    HoodieTableMetaClient mc = initTable("instant-savepoint");
+    String base = mc.getBasePath().toString();
+    String ts = "20240101000028";
+    // Savepoint is the only arm that reads the instant itself without a 
requested twin (savepoint has
+    // no requested state). This pins the completed read: avro 
HoodieSavepointMetadata surfaced as a Map.
+    HoodieTestTable testTable = HoodieTestTable.of(mc);
+    testTable.addSavepointCommit(ts, Option.of("20240101000029"),
+        testTable.getSavepointMetadata(ts, Collections.singletonMap("par", 
Collections.singletonList("file-1"))));
+
+    JsonNode root = getJsonOk(UI_INSTANT_URL, params(BASEPATH_PARAM, base, 
INSTANT_PARAM, ts,
+        INSTANT_ACTION_PARAM, "savepoint", INSTANT_STATE_PARAM, "COMPLETED"));
+    // The avro metadata is converted to a Map with avro field names.
+    assertEquals("test", root.get("savepointedBy").asText(), root.toString());
+    assertEquals("file-1", 
root.get("partitionMetadata").get("par").get("savepointDataFile").get(0).asText(),
+        root.toString());
+  }
+
+  @Test
+  void testGetInstantDetailsMalformedStateOrActionReturns400() throws 
Exception {
+    HoodieTableMetaClient mc = initTable("instant-bad-state");
+    String base = mc.getBasePath().toString();
+    String ts = "20240101000023";
+    HoodieTestTable.of(mc).addCommit(ts, Option.of(commitMetadata(mc, base, 
ts, Collections.emptyMap())));
+
+    // A malformed state is rejected before the timeline lookup.
+    Http badState = httpGet(port, UI_INSTANT_URL,
+        params(BASEPATH_PARAM, base, INSTANT_PARAM, ts, INSTANT_ACTION_PARAM, 
"commit", INSTANT_STATE_PARAM, "NOT_A_STATE"));
+    assertEquals(400, badState.code, badState.body);
+
+    // A valid state but an action outside VALID_ACTIONS_IN_TIMELINE is 
likewise a 400, not a 404.
+    Http badAction = httpGet(port, UI_INSTANT_URL,
+        params(BASEPATH_PARAM, base, INSTANT_PARAM, ts, INSTANT_ACTION_PARAM, 
"NOT_AN_ACTION", INSTANT_STATE_PARAM, "COMPLETED"));
+    assertEquals(400, badAction.code, badAction.body);
+  }
+
+  @Test
+  void testGetInstantDetailsUnknownTimestampReturns404() throws Exception {
+    HoodieTableMetaClient mc = initTable("instant-unknown");
+    String base = mc.getBasePath().toString();
+    HoodieTestTable.of(mc).addCommit("20240101000030",
+        Option.of(commitMetadata(mc, base, "20240101000030", 
Collections.emptyMap())));
+
+    // A well-formed timestamp that is not present in the timeline.
+    Http r = httpGet(port, UI_INSTANT_URL,
+        params(BASEPATH_PARAM, base, INSTANT_PARAM, "20991231235959999", 
INSTANT_ACTION_PARAM, "commit",
+            INSTANT_STATE_PARAM, "COMPLETED"));
+    assertEquals(404, r.code, r.body);
+  }
+
+  /**
+   * Load-bearing: pins the path-traversal defense. The probe is pinned to 
REQUESTED state (a
+   * COMPLETED probe would resolve to a real instant even against a vulnerable 
handler). A naive
+   * handler that built {@code <timelinePath>/<instant>.<action>.<state>} and 
opened it would leak
+   * the planted marker file located outside the table. The current handler 
resolves the (instant,
+   * action, state) triple against the active timeline instead, so the 
traversal is not found (404)
+   * and the marker never appears in the response.
+   */
+  @Test
+  void testGetInstantDetailsPathTraversalReturns404() throws Exception {
+    HoodieTableMetaClient mc = initTable("instant-traversal");
+    String base = mc.getBasePath().toString();
+    HoodieTestTable.of(mc).addCommit("20240101000040",
+        Option.of(commitMetadata(mc, base, "20240101000040", 
Collections.emptyMap())));
+
+    Path timelineDir = Paths.get(mc.getTimelinePath().toUri().getPath());
+    Path outsideDir = Files.createTempDirectory("hudi-ui-traversal");
+    String marker = "HUDI_UI_TRAVERSAL_SECRET_" + UUID.randomUUID();
+    // Plant the marker under several plausible names a vulnerable handler 
might have built.
+    Files.write(outsideDir.resolve("leak.commit.requested"), 
marker.getBytes(StandardCharsets.UTF_8));
+    Files.write(outsideDir.resolve("leak.commit.REQUESTED"), 
marker.getBytes(StandardCharsets.UTF_8));
+    Files.write(outsideDir.resolve("leak"), 
marker.getBytes(StandardCharsets.UTF_8));
+
+    // Traversal instant that, once ".commit.requested" is appended and 
normalized, points at the marker.
+    String traversalInstant = 
timelineDir.relativize(outsideDir.resolve("leak")).toString();
+
+    Http r = httpGet(port, UI_INSTANT_URL,
+        params(BASEPATH_PARAM, base, INSTANT_PARAM, traversalInstant, 
INSTANT_ACTION_PARAM, "commit",
+            INSTANT_STATE_PARAM, "REQUESTED"));
+    assertEquals(404, r.code, r.body);
+    assertFalse(r.body.contains(marker), "path traversal leaked planted file 
content: " + r.body);
+  }
+
+  // 
---------------------------------------------------------------------------
+  // 3. Schema history
+  // 
---------------------------------------------------------------------------
+
+  @Test
+  void testSchemaHistoryEmptyTable() throws Exception {
+    HoodieTableMetaClient mc = initTable("schema-empty");
+    String base = mc.getBasePath().toString();
+
+    JsonNode root = getJsonOk(UI_SCHEMA_URL, params(BASEPATH_PARAM, base));
+    assertTrue(isJsonNull(root.get("currentSchema")), root.toString());

Review Comment:
   `currentSchema` is asserted only through `isJsonNull`, which also passes 
when the key is absent, and no test exercises a table that has a schema, so 
`getSchemaHistory`'s `TableSchemaResolver` call could be removed with the suite 
still green. Add a positive assertion on it in 
`testSchemaHistoryBaselineAndChange`.



-- 
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]

Reply via email to