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


##########
hudi-timeline-service/src/main/java/org/apache/hudi/timeline/service/TimelineService.java:
##########
@@ -230,17 +238,35 @@ private void createApp() {
     ScheduledExecutorScheduler scheduler = new 
ScheduledExecutorScheduler("TimelineService-JettyScheduler", true, 8);
     server.addBean(scheduler);
 
+    if (timelineServerConf.enableUi && 
TimelineService.class.getResource("/public/index.html") == null) {

Review Comment:
   This check runs inside createApp(), which startServiceOnPort retries 16x 
while swallowing every exception, so it never fails fast and the actionable 
message is lost behind a generic port IOException. Move it ahead of the retry 
loop, for example into startService.



##########
scripts/release/validate_bundle_ui_assets.sh:
##########
@@ -0,0 +1,154 @@
+#!/bin/bash
+
+#
+# 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.
+#
+
+#
+# Guards the "public/**" shade exclusions added for the Hudi Timeline UI 
(RFC-94).
+# The Timeline UI assets (vis-timeline, Bootstrap, renderjson) must ship ONLY 
in
+# hudi-timeline-server-bundle. The five engine bundles exclude "public/**" so 
that
+# the bundled-library LICENSE obligations stay scoped to the server bundle. 
RFC-94
+# calls this negative assertion load-bearing: without it the exclusion can 
regress
+# silently and an engine bundle would ship UI assets whose licenses are not 
carried
+# there.
+#
+# Usage: validate_bundle_ui_assets.sh [dir]
+#   dir  Optional directory to search for built bundle jars.
+#        Defaults to the repo "packaging" directory (scans 
packaging/*/target/).
+#
+# CI: invoked by the validate-bundles* jobs in .github/workflows/bot.yml right 
after the bundle build.
+#
+
+set -u
+
+# Resolve the search root.
+scriptDir="$(cd "$(dirname "$0")" && pwd)"
+repoRoot="$(cd "$scriptDir/../.." && pwd)"
+searchDir="${1:-$repoRoot/packaging}"
+
+if [ ! -d "$searchDir" ]; then
+  echo "Search directory does not exist: $searchDir [ERROR]"
+  exit 1
+fi
+
+# List the entries inside a jar. Prefer "jar", fall back to "unzip -l".
+listJar() {
+  local jar="$1"
+  if command -v jar >/dev/null 2>&1; then
+    jar tf "$jar"
+  elif command -v unzip >/dev/null 2>&1; then
+    unzip -l "$jar" | awk 'NR>3 {print $4}'
+  else
+    echo "__NO_TOOL__"
+  fi
+}
+
+# Find bundle jars matching a name pattern, skipping the unshaded 
original-*.jar
+# and the sources/javadoc/tests attachments.
+findBundleJars() {
+  local pattern="$1"
+  find "$searchDir" -type f -name "$pattern" \
+    ! -name 'original-*.jar' \
+    ! -name '*-sources.jar' \
+    ! -name '*-javadoc.jar' \
+    ! -name '*-tests.jar' 2>/dev/null
+}
+
+violations=0
+
+# --- Positive assertion: the server bundle MUST carry the UI assets ---
+serverPattern='hudi-timeline-server-bundle-*.jar'
+serverJars=$(findBundleJars "$serverPattern")
+if [ -z "$serverJars" ]; then
+  echo "WARNING: no $serverPattern found under $searchDir; the positive 
assertion did not run"
+else
+  while IFS= read -r jar; do
+    [ -z "$jar" ] && continue
+    entries=$(listJar "$jar")
+    if [ "$entries" = "__NO_TOOL__" ]; then
+      echo "Neither 'jar' nor 'unzip' is available to inspect jars [ERROR]"
+      exit 1
+    fi
+    hasIndex=1
+    hasLib=1
+    echo "$entries" | grep -q '^public/index.html$' || hasIndex=0
+    echo "$entries" | grep -q '^public/lib/' || hasLib=0
+    if [ "$hasIndex" -eq 0 ]; then
+      echo "MISSING public/index.html in $jar [ERROR]"
+      violations=$((violations + 1))
+    fi
+    if [ "$hasLib" -eq 0 ]; then
+      echo "MISSING public/lib/ entries in $jar [ERROR]"
+      violations=$((violations + 1))
+    fi
+    if [ "$hasIndex" -eq 1 ] && [ "$hasLib" -eq 1 ]; then
+      echo "OK: $jar carries the Timeline UI assets"
+    fi
+  done <<EOF
+$serverJars
+EOF
+fi
+
+# --- Negative assertion: engine bundles must NOT carry any UI assets ---
+# One pattern per bundle so it stays obvious which bundle maps to which 
expectation.
+# Spark and utilities jar names carry a scala suffix 
(hudi-spark3.5-bundle_2.12-*.jar,
+# hudi-utilities-bundle_2.12-*.jar), so their patterns must not assume 
"-bundle-".
+enginePatterns=(
+  'hudi-spark*-bundle*.jar'
+  'hudi-flink*-bundle-*.jar'
+  'hudi-utilities-bundle_*.jar'
+  'hudi-kafka-connect-bundle-*.jar'
+  'hudi-integ-test-bundle-*.jar'
+)
+
+for pattern in "${enginePatterns[@]}"; do
+  jars=$(findBundleJars "$pattern")
+  if [ -z "$jars" ]; then
+    echo "SKIP: no $pattern found under $searchDir (not built)"
+    continue
+  fi
+  while IFS= read -r jar; do
+    [ -z "$jar" ] && continue
+    entries=$(listJar "$jar")
+    if [ "$entries" = "__NO_TOOL__" ]; then
+      echo "Neither 'jar' nor 'unzip' is available to inspect jars [ERROR]"
+      exit 1
+    fi
+    offending=$(echo "$entries" | grep '^public/')

Review Comment:
   An empty jar listing leaves this grep empty, so a truncated or unreadable 
jar passes the "no public/" check vacuously. Assert the listing contains a 
known entry like META-INF/ before the OK verdict; follow-up.



##########
scripts/release/validate_bundle_ui_assets.sh:
##########
@@ -0,0 +1,154 @@
+#!/bin/bash
+
+#
+# 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.
+#
+
+#
+# Guards the "public/**" shade exclusions added for the Hudi Timeline UI 
(RFC-94).
+# The Timeline UI assets (vis-timeline, Bootstrap, renderjson) must ship ONLY 
in
+# hudi-timeline-server-bundle. The five engine bundles exclude "public/**" so 
that
+# the bundled-library LICENSE obligations stay scoped to the server bundle. 
RFC-94
+# calls this negative assertion load-bearing: without it the exclusion can 
regress
+# silently and an engine bundle would ship UI assets whose licenses are not 
carried
+# there.
+#
+# Usage: validate_bundle_ui_assets.sh [dir]
+#   dir  Optional directory to search for built bundle jars.
+#        Defaults to the repo "packaging" directory (scans 
packaging/*/target/).
+#
+# CI: invoked by the validate-bundles* jobs in .github/workflows/bot.yml right 
after the bundle build.
+#
+
+set -u
+
+# Resolve the search root.
+scriptDir="$(cd "$(dirname "$0")" && pwd)"
+repoRoot="$(cd "$scriptDir/../.." && pwd)"
+searchDir="${1:-$repoRoot/packaging}"
+
+if [ ! -d "$searchDir" ]; then
+  echo "Search directory does not exist: $searchDir [ERROR]"
+  exit 1
+fi
+
+# List the entries inside a jar. Prefer "jar", fall back to "unzip -l".
+listJar() {
+  local jar="$1"
+  if command -v jar >/dev/null 2>&1; then
+    jar tf "$jar"
+  elif command -v unzip >/dev/null 2>&1; then
+    unzip -l "$jar" | awk 'NR>3 {print $4}'
+  else
+    echo "__NO_TOOL__"
+  fi
+}
+
+# Find bundle jars matching a name pattern, skipping the unshaded 
original-*.jar
+# and the sources/javadoc/tests attachments.
+findBundleJars() {
+  local pattern="$1"
+  find "$searchDir" -type f -name "$pattern" \
+    ! -name 'original-*.jar' \
+    ! -name '*-sources.jar' \
+    ! -name '*-javadoc.jar' \
+    ! -name '*-tests.jar' 2>/dev/null
+}
+
+violations=0
+
+# --- Positive assertion: the server bundle MUST carry the UI assets ---
+serverPattern='hudi-timeline-server-bundle-*.jar'
+serverJars=$(findBundleJars "$serverPattern")
+if [ -z "$serverJars" ]; then
+  echo "WARNING: no $serverPattern found under $searchDir; the positive 
assertion did not run"
+else
+  while IFS= read -r jar; do
+    [ -z "$jar" ] && continue
+    entries=$(listJar "$jar")
+    if [ "$entries" = "__NO_TOOL__" ]; then
+      echo "Neither 'jar' nor 'unzip' is available to inspect jars [ERROR]"
+      exit 1
+    fi
+    hasIndex=1
+    hasLib=1
+    echo "$entries" | grep -q '^public/index.html$' || hasIndex=0
+    echo "$entries" | grep -q '^public/lib/' || hasLib=0
+    if [ "$hasIndex" -eq 0 ]; then
+      echo "MISSING public/index.html in $jar [ERROR]"
+      violations=$((violations + 1))
+    fi
+    if [ "$hasLib" -eq 0 ]; then
+      echo "MISSING public/lib/ entries in $jar [ERROR]"
+      violations=$((violations + 1))
+    fi
+    if [ "$hasIndex" -eq 1 ] && [ "$hasLib" -eq 1 ]; then
+      echo "OK: $jar carries the Timeline UI assets"
+    fi
+  done <<EOF
+$serverJars
+EOF
+fi
+
+# --- Negative assertion: engine bundles must NOT carry any UI assets ---
+# One pattern per bundle so it stays obvious which bundle maps to which 
expectation.
+# Spark and utilities jar names carry a scala suffix 
(hudi-spark3.5-bundle_2.12-*.jar,
+# hudi-utilities-bundle_2.12-*.jar), so their patterns must not assume 
"-bundle-".
+enginePatterns=(
+  'hudi-spark*-bundle*.jar'
+  'hudi-flink*-bundle-*.jar'
+  'hudi-utilities-bundle_*.jar'
+  'hudi-kafka-connect-bundle-*.jar'
+  'hudi-integ-test-bundle-*.jar'

Review Comment:
   hudi-integ-test-bundle is never built in the validate-bundles jobs that run 
this script, so this pattern always SKIPs and its public/ exclusion is never 
asserted. Run the script in the integration-tests job after its build, or drop 
this pattern.



##########
hudi-timeline-service/src/main/resources/public/js/timeline.js:
##########
@@ -0,0 +1,1262 @@
+/*
+ * 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) {
+        detailBody.innerHTML = '';
+        renderjson.set_show_to_level(1);
+        renderjson.set_icons('\u25B6', '\u25BC');
+        renderjson.set_sort_objects(true);
+        detailBody.appendChild(renderjson(json));

Review Comment:
   This fetch writes detailBody unconditionally, so fast selection changes can 
paint an older instant's JSON under the current header. Add the same 
getSelection()[0] !== item.id guard the nested clean-range fetch already uses; 
follow-up.



##########
hudi-timeline-service/src/test/java/org/apache/hudi/timeline/service/TestUiApi.java:
##########
@@ -0,0 +1,713 @@
+/*
+ * 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 {

Review Comment:
   The savepoint arm of getInstantDetails (the only one that reads the inflight 
directly, without requestedTwin) has no test. Add a completed-savepoint detail 
case; follow-up.



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