voonhous commented on code in PR #13147:
URL: https://github.com/apache/hudi/pull/13147#discussion_r3964885004


##########
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:
   Fixed: `NotFoundResponse` now takes the WARN branch together with 
`BadRequestResponse`; the message is generic since it covers both.



##########
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:
   Fixed: `focusOnInstant` calls `onSelect` after `setSelection`.



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