wombatu-kun commented on code in PR #13147: URL: https://github.com/apache/hudi/pull/13147#discussion_r3584698585
########## hudi-timeline-service/src/main/resources/public/js/timeline.js: ########## @@ -0,0 +1,1243 @@ +/* + * 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)); + + // 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) { + if (prevJson && prevJson.earliestCommitToRetain) { + var rangeStart = parseHudiTimestamp(prevJson.earliestCommitToRetain); + var rangeEnd = parseHudiTimestamp(json.earliestCommitToRetain); + allItems.add({ Review Comment: The clean-range background is removed synchronously in onSelect but re-added inside a nested async fetch, so selecting another instant or pressing Escape before that fetch resolves re-renders the previous instant's range against the new selection. Capture the current selection id before the fetch and add the range only if it still matches when the promise resolves; low-impact follow-up. ########## scripts/release/validate_bundle_ui_assets.sh: ########## @@ -0,0 +1,152 @@ +#!/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 Review Comment: This guard script is invoked by nothing - not validate_staged_release.sh, not bot.yml - so the load-bearing negative assertion it describes never runs and the "public/**" shade exclusions can regress silently. Wire it into scripts/release/validate_staged_release.sh next to validate_source_copyright.sh (and/or a bot.yml step) so it actually executes against built bundle jars. ########## 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( + "Instant not found in active timeline: " + requestedTime + " " + action + " " + parsedState)); + + try { + Object result; + switch (instant.getAction()) { + case HoodieTimeline.COMMIT_ACTION: + case HoodieTimeline.DELTA_COMMIT_ACTION: + result = readCommitMetadata(serde, activeTimeline, instant); + break; + case HoodieTimeline.CLEAN_ACTION: + result = instant.isCompleted() + ? readAs(serde, activeTimeline, instant, HoodieCleanMetadata.class) + : readAs(serde, activeTimeline, requestedTwin(metaClient, instant), HoodieCleanerPlan.class); + break; + case HoodieTimeline.ROLLBACK_ACTION: + result = instant.isCompleted() + ? readAs(serde, activeTimeline, instant, HoodieRollbackMetadata.class) + : readAs(serde, activeTimeline, requestedTwin(metaClient, instant), HoodieRollbackPlan.class); + break; + case HoodieTimeline.RESTORE_ACTION: + result = instant.isCompleted() + ? readAs(serde, activeTimeline, instant, HoodieRestoreMetadata.class) + : readAs(serde, activeTimeline, requestedTwin(metaClient, instant), HoodieRestorePlan.class); + break; + case HoodieTimeline.SAVEPOINT_ACTION: + // Savepoint has no requested state (inflight then completed); its inflight file is empty and + // now deserializes to an empty instance, so always read the instant itself. + result = readAs(serde, activeTimeline, instant, HoodieSavepointMetadata.class); + break; + case HoodieTimeline.COMPACTION_ACTION: + case HoodieTimeline.LOG_COMPACTION_ACTION: + result = instant.isCompleted() + ? readCommitMetadata(serde, activeTimeline, instant) + : readAs(serde, activeTimeline, requestedTwin(metaClient, instant), HoodieCompactionPlan.class); + break; + case HoodieTimeline.REPLACE_COMMIT_ACTION: + case HoodieTimeline.CLUSTERING_ACTION: + // A completed replacecommit/clustering file is avro HoodieReplaceCommitMetadata on disk; + // reading it as avro HoodieCommitMetadata fails avro record-name resolution. Read the POJO + // HoodieReplaceCommitMetadata: the serde deserializes the avro record and converts it to POJO. + result = instant.isCompleted() + ? readAs(serde, activeTimeline, instant, HoodieReplaceCommitMetadata.class) + : readAs(serde, activeTimeline, requestedTwin(metaClient, instant), HoodieRequestedReplaceMetadata.class); + break; + case HoodieTimeline.INDEXING_ACTION: + // A completed indexing instant stores avro HoodieIndexCommitMetadata, not HoodieCommitMetadata. + result = instant.isCompleted() + ? readAs(serde, activeTimeline, instant, HoodieIndexCommitMetadata.class) + : readAs(serde, activeTimeline, requestedTwin(metaClient, instant), HoodieIndexPlan.class); + break; + default: + throw new BadRequestResponse("Unsupported action: " + action); + } + + // Avro-generated objects (SpecificRecordBase) cannot be serialized by + // RequestHandler's ObjectMapper+AfterburnerModule due to module access + // restrictions on Avro's internal Schema classes. Convert to plain Maps + // using JsonUtils which accesses fields directly, bypassing getSchema(). + if (result instanceof SpecificRecordBase) { + return JsonUtils.getObjectMapper().convertValue(result, Map.class); + } + return result; + } catch (BadRequestResponse | NotFoundResponse e) { + throw e; + } catch (Exception e) { + log.warn("Failed to read instant details for basePath={}, requestedTime={}, action={}, state={}", + basePath, requestedTime, action, state, e); + throw new HoodieException("Failed to read instant details", e); + } + } + + public Map<String, Object> getTableConfig(String basePath) { + HoodieTableMetaClient metaClient = createMetaClient(basePath); + TreeMap<String, String> sorted = new TreeMap<>(); + metaClient.getTableConfig().getProps().forEach((k, v) -> sorted.put(k.toString(), v.toString())); + Map<String, Object> result = new HashMap<>(); + result.put("properties", sorted); + return result; + } + + public Map<String, Object> getSchemaHistory(String basePath, int limit) { + HoodieTableMetaClient metaClient = createMetaClient(basePath); + CommitMetadataSerDe serde = metaClient.getCommitMetadataSerDe(); + + Map<String, Object> result = new HashMap<>(); + + // Non-throwing accessor: a table with no commits yields null rather than a 500. + result.put("currentSchema", + new TableSchemaResolver(metaClient) + .getTableSchemaIfPresent(metaClient.getTableConfig().populateMetaFields()) + .map(schema -> schema.toString()) + .orElse(null)); + + HoodieTimeline commitsTimeline = metaClient.getActiveTimeline() + .getCommitsTimeline().filterCompletedInstants(); + List<HoodieInstant> instants = commitsTimeline.getInstants(); + + // Scan only the most recent N instants for performance. + int startIdx = Math.max(0, instants.size() - limit); + List<HoodieInstant> scanned = instants.subList(startIdx, instants.size()); + + List<Map<String, String>> history = new ArrayList<>(); + String previousSchema = null; + + for (HoodieInstant instant : scanned) { + try { + // Mirror TimelineUtils.getCommitMetadata: a completed replacecommit/clustering file is avro + // HoodieReplaceCommitMetadata, not HoodieCommitMetadata. Read it as the POJO (which extends + // HoodieCommitMetadata) or a schema change delivered by insert_overwrite is silently skipped. + HoodieCommitMetadata commitMetadata = + (instant.getAction().equals(HoodieTimeline.REPLACE_COMMIT_ACTION) + || instant.getAction().equals(HoodieTimeline.CLUSTERING_ACTION)) + ? readAs(serde, commitsTimeline, instant, HoodieReplaceCommitMetadata.class) + : readCommitMetadata(serde, commitsTimeline, instant); + String schemaStr = commitMetadata.getMetadata(HoodieCommitMetadata.SCHEMA_KEY); + if (schemaStr != null && !schemaStr.isEmpty() && !schemaStr.equals(previousSchema)) { + Map<String, String> entry = new LinkedHashMap<>(); + entry.put("type", previousSchema == null ? "baseline" : "change"); Review Comment: When the scan window is truncated (limit smaller than the commit count) the first scanned instant is labeled type="baseline" though it is only the oldest in the window, not the table's original schema. Consider deriving the label from window.truncated so a mid-history schema is not presented as the baseline; minor follow-up. ########## hudi-timeline-service/src/main/resources/public/js/timeline.js: ########## @@ -0,0 +1,1243 @@ +/* + * 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)); + + // 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) { + 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); + 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; + document.getElementById('configLoading').classList.add('d-none'); + renderTableConfig(data); + }) + .catch(function (err) { + document.getElementById('configLoading').classList.add('d-none'); + document.getElementById('configError').classList.remove('d-none'); + document.getElementById('configErrorMessage').textContent = 'Failed to load table config: ' + err.message; + }); + } + + function renderTableConfig(data) { + var tbody = document.getElementById('configTableBody'); + tbody.innerHTML = ''; + var props = data.properties || {}; + Object.keys(props).forEach(function (key) { + var tr = document.createElement('tr'); + var tdKey = document.createElement('td'); + tdKey.textContent = key; + var tdVal = document.createElement('td'); + tdVal.textContent = props[key]; + tr.appendChild(tdKey); + tr.appendChild(tdVal); + tbody.appendChild(tr); + }); + document.getElementById('configContent').classList.remove('d-none'); + } + + // Config filter + document.getElementById('configFilter').addEventListener('input', function () { + var query = this.value.toLowerCase(); + var rows = document.getElementById('configTableBody').querySelectorAll('tr'); + rows.forEach(function (row) { + var text = row.textContent.toLowerCase(); + row.classList.toggle('d-none', query && text.indexOf(query) === -1); + }); + }); + + function loadSchemaHistory() { + if (!currentTablePath) return; + if (schemaHistoryData) { + renderSchemaHistory(schemaHistoryData); + return; + } + + document.getElementById('schemaLoading').classList.remove('d-none'); + document.getElementById('schemaContent').classList.add('d-none'); + document.getElementById('schemaError').classList.add('d-none'); + + fetch(API_BASE + '/table/schema/history?basepath=' + encodeURIComponent(currentTablePath)) + .then(function (res) { + if (!res.ok) return httpError(res); + return res.json(); + }) + .then(function (data) { + schemaHistoryData = data; + document.getElementById('schemaLoading').classList.add('d-none'); + renderSchemaHistory(data); + }) + .catch(function (err) { + document.getElementById('schemaLoading').classList.add('d-none'); + document.getElementById('schemaError').classList.remove('d-none'); + document.getElementById('schemaErrorMessage').textContent = 'Failed to load schema history: ' + err.message; + }); + } + + function renderSchemaHistory(data) { + // Current schema tree + var treeContainer = document.getElementById('currentSchemaTree'); + treeContainer.innerHTML = ''; + if (data.currentSchema) { + try { + var schemaObj = JSON.parse(data.currentSchema); + renderjson.set_show_to_level(2); + renderjson.set_icons('\u25B6', '\u25BC'); + renderjson.set_sort_objects(false); + treeContainer.appendChild(renderjson(schemaObj)); + + // Fields summary table + var fieldsBody = document.getElementById('currentFieldsBody'); + fieldsBody.innerHTML = ''; + var fields = schemaObj.fields || []; + fields.forEach(function (field) { + var tr = document.createElement('tr'); + var tdName = document.createElement('td'); + tdName.className = 'font-monospace'; + tdName.textContent = field.name; + var tdType = document.createElement('td'); + tdType.textContent = formatAvroType(field.type); + var tdNullable = document.createElement('td'); + tdNullable.textContent = isNullable(field.type) ? 'Yes' : 'No'; + tr.appendChild(tdName); + tr.appendChild(tdType); + tr.appendChild(tdNullable); + fieldsBody.appendChild(tr); + }); + } catch (e) { + treeContainer.textContent = data.currentSchema; + } + } else { + treeContainer.innerHTML = '<p class="text-muted">No schema available</p>'; + document.getElementById('currentFieldsBody').innerHTML = ''; + } + + // Schema change history + var historyList = document.getElementById('schemaHistoryList'); + historyList.innerHTML = ''; + + // Always render the commit-metadata history first: the window truncation + // notice and the baseline/change typing are mandatory UI-visible signals + // (RFC-94), so they must appear even when the richer internal schema history + // from the .schema directory is also available. + renderCommitHistory(data, historyList); + + // When present, render the internal schema evolution (.schema directory) as + // an additional, clearly-labelled section below the commit-schema history. + if (data.internalSchemaHistory) { + renderInternalSchemaHistory(data, historyList); + } + + document.getElementById('schemaContent').classList.remove('d-none'); + } + + // Renders the commit-metadata schema history into historyList: an optional + // truncation notice followed by one card per recorded schema change (newest + // first), each tagged with its baseline/change type badge. Shows an empty-state + // message when the server reported no recorded changes. + function renderCommitHistory(data, historyList) { + var history = data.history || []; + + if (history.length === 0) { + historyList.innerHTML = '<div class="alert alert-info">No schema changes found</div>'; + return; + } + + // Truncation notice: when the scanned window is truncated, completed commits + // exist older than the oldest scanned instant, so the baseline entry is the + // window edge, not necessarily the table's first schema. + if (data.window && data.window.truncated) { + var truncNotice = document.createElement('div'); + truncNotice.className = 'alert alert-warning py-2 small'; + var oldestScanned = data.window.oldestInstantScanned || 'unknown'; + truncNotice.textContent = 'History truncated: older completed commits exist beyond the scanned window' + + ' (oldest scanned: ' + oldestScanned + '). The baseline entry is the window edge,' + + ' not necessarily the first table schema.'; + historyList.appendChild(truncNotice); + } + + // Build diff cards in reverse chronological order + for (var i = history.length - 1; i >= 0; i--) { + var entry = history[i]; + var card = document.createElement('div'); + card.className = 'card mb-2'; + + var header = document.createElement('div'); + header.className = 'card-header d-flex align-items-center gap-2'; + + var tsCode = document.createElement('code'); + tsCode.textContent = entry.instant; + + var actionBadge = document.createElement('span'); + actionBadge.className = 'badge bg-primary'; + actionBadge.textContent = entry.action; + + header.appendChild(tsCode); + header.appendChild(actionBadge); + + // Entry type badge: baseline (oldest recorded / window edge) vs later change. + if (entry.type === 'baseline') { + var baselineBadge = document.createElement('span'); + baselineBadge.className = 'badge bg-secondary'; + baselineBadge.textContent = 'baseline'; + header.appendChild(baselineBadge); + } else if (entry.type === 'change') { + var changeBadge = document.createElement('span'); + changeBadge.className = 'badge bg-light text-dark border'; + changeBadge.textContent = 'change'; + header.appendChild(changeBadge); + } + + // Completion time alongside the instant, when available. + if (entry.completionTime) { + var completionSpan = document.createElement('span'); + completionSpan.className = 'text-muted small'; + completionSpan.textContent = 'completed: ' + entry.completionTime; + header.appendChild(completionSpan); + } + + // Compute diff with previous schema if available + if (i > 0) { + var diff = diffSchemas(history[i - 1].schema, entry.schema); + var summaryParts = []; + if (diff.added.length > 0) summaryParts.push(diff.added.length + ' added'); + if (diff.removed.length > 0) summaryParts.push(diff.removed.length + ' removed'); + if (diff.changed.length > 0) summaryParts.push(diff.changed.length + ' changed'); + + if (summaryParts.length > 0) { + var summarySpan = document.createElement('span'); + summarySpan.className = 'text-muted small ms-auto'; + summarySpan.textContent = summaryParts.join(', '); + header.appendChild(summarySpan); + } + + // Collapsible diff detail + var collapseId = 'schemaDiff' + i; + var toggleBtn = document.createElement('button'); + toggleBtn.className = 'btn btn-sm btn-outline-secondary ms-2'; + toggleBtn.setAttribute('data-bs-toggle', 'collapse'); + toggleBtn.setAttribute('data-bs-target', '#' + collapseId); + toggleBtn.textContent = 'Details'; + header.appendChild(toggleBtn); + + var collapseDiv = document.createElement('div'); + collapseDiv.className = 'collapse'; + collapseDiv.id = collapseId; + var collapseBody = document.createElement('div'); + collapseBody.className = 'card-body'; + collapseBody.appendChild(renderDiffDetail(diff)); + collapseDiv.appendChild(collapseBody); + + card.appendChild(header); + card.appendChild(collapseDiv); + } else { + // Oldest entry: no previous schema to diff against. Fall back to an + // "Initial schema" marker only when the server did not tag it baseline. + if (entry.type !== 'baseline') { + var initialBadge = document.createElement('span'); + initialBadge.className = 'badge bg-secondary ms-auto'; + initialBadge.textContent = 'Initial schema'; + header.appendChild(initialBadge); + } + card.appendChild(header); + } + + historyList.appendChild(card); + } + } + + // Renders the internal schema evolution recorded in the .schema directory as a + // labelled section appended to historyList: one card per version_id (newest + // first) with field-level diffs between consecutive versions. Uses its own + // collapse-id namespace so its Details toggles never collide with the + // commit-history cards. Skipped silently when the payload cannot be parsed. + function renderInternalSchemaHistory(data, historyList) { + var internalData; + try { + internalData = JSON.parse(data.internalSchemaHistory); + } catch (e) { + return; + } + var schemas = (internalData.schemas || []).slice(); + // Sort by version_id ascending (oldest first) + schemas.sort(function (a, b) { return a.version_id - b.version_id; }); + + var heading = document.createElement('h6'); + heading.className = 'mt-4 mb-2'; + heading.textContent = 'Internal schema history (.schema)'; + historyList.appendChild(heading); + + if (schemas.length === 0) { + var emptyEl = document.createElement('div'); + emptyEl.className = 'alert alert-info'; + emptyEl.textContent = 'No schema changes found'; + historyList.appendChild(emptyEl); + return; + } + + // Build diff cards in reverse chronological order (newest first) + for (var i = schemas.length - 1; i >= 0; i--) { + var schema = schemas[i]; + var card = document.createElement('div'); + card.className = 'card mb-2'; + + var header = document.createElement('div'); + header.className = 'card-header d-flex align-items-center gap-2'; + + var tsCode = document.createElement('code'); + tsCode.textContent = schema.version_id === 0 ? 'Initial' : String(schema.version_id); + + header.appendChild(tsCode); + + if (i > 0) { + var diff = diffInternalSchemas(schemas[i - 1], schema); + var summaryParts = []; + if (diff.added.length > 0) summaryParts.push(diff.added.length + ' added'); + if (diff.removed.length > 0) summaryParts.push(diff.removed.length + ' removed'); + if (diff.changed.length > 0) summaryParts.push(diff.changed.length + ' changed'); + + if (summaryParts.length > 0) { + var summarySpan = document.createElement('span'); + summarySpan.className = 'text-muted small ms-auto'; + summarySpan.textContent = summaryParts.join(', '); + header.appendChild(summarySpan); + } + + var collapseId = 'internalSchemaDiff' + i; + var toggleBtn = document.createElement('button'); + toggleBtn.className = 'btn btn-sm btn-outline-secondary ms-2'; + toggleBtn.setAttribute('data-bs-toggle', 'collapse'); + toggleBtn.setAttribute('data-bs-target', '#' + collapseId); + toggleBtn.textContent = 'Details'; + header.appendChild(toggleBtn); + + var collapseDiv = document.createElement('div'); + collapseDiv.className = 'collapse'; + collapseDiv.id = collapseId; + var collapseBody = document.createElement('div'); + collapseBody.className = 'card-body'; + collapseBody.appendChild(renderDiffDetail(diff)); + collapseDiv.appendChild(collapseBody); + + card.appendChild(header); + card.appendChild(collapseDiv); + } else { + var initialBadge = document.createElement('span'); + initialBadge.className = 'badge bg-secondary ms-auto'; + initialBadge.textContent = 'Initial schema'; + header.appendChild(initialBadge); + card.appendChild(header); + } + + historyList.appendChild(card); + } + } + + function formatAvroType(type) { + if (typeof type === 'string') return type; + if (Array.isArray(type)) { + return type.map(formatAvroType).join(' | '); + } + if (type && type.type) { + if (type.type === 'array') return 'array<' + formatAvroType(type.items) + '>'; + if (type.type === 'map') return 'map<' + formatAvroType(type.values) + '>'; + return type.type; + } + return JSON.stringify(type); + } + + function isNullable(type) { + if (Array.isArray(type)) { + return type.indexOf('null') !== -1; + } + return type === 'null'; + } + + function diffSchemas(olderSchemaStr, newerSchemaStr) { + var result = { added: [], removed: [], changed: [] }; + try { + var older = JSON.parse(olderSchemaStr); + var newer = JSON.parse(newerSchemaStr); + var olderFields = (older.fields || []); + var newerFields = (newer.fields || []); + + var olderMap = {}; + olderFields.forEach(function (f) { olderMap[f.name] = f; }); + var newerMap = {}; + newerFields.forEach(function (f) { newerMap[f.name] = f; }); + + // Added fields + newerFields.forEach(function (f) { + if (!olderMap[f.name]) { + result.added.push(f); + } + }); + + // Removed fields + olderFields.forEach(function (f) { + if (!newerMap[f.name]) { + result.removed.push(f); + } + }); + + // Changed fields + newerFields.forEach(function (f) { + if (olderMap[f.name]) { + var oldType = JSON.stringify(olderMap[f.name].type); + var newType = JSON.stringify(f.type); + if (oldType !== newType) { + result.changed.push({ + name: f.name, + oldType: formatAvroType(olderMap[f.name].type), + newType: formatAvroType(f.type) + }); + } + } + }); + } catch (e) { + // If schemas can't be parsed, return empty diff + } + return result; + } + + function diffInternalSchemas(olderSchema, newerSchema) { + var result = { added: [], removed: [], changed: [] }; + var olderFields = olderSchema.fields || []; + var newerFields = newerSchema.fields || []; + + var olderMap = {}; + olderFields.forEach(function (f) { olderMap[f.name] = f; }); + var newerMap = {}; + newerFields.forEach(function (f) { newerMap[f.name] = f; }); + + newerFields.forEach(function (f) { + if (!olderMap[f.name]) { + result.added.push({ name: f.name, type: f.type }); + } + }); + + olderFields.forEach(function (f) { + if (!newerMap[f.name]) { + result.removed.push({ name: f.name, type: f.type }); + } + }); + + newerFields.forEach(function (f) { + if (olderMap[f.name] && olderMap[f.name].type !== f.type) { + result.changed.push({ + name: f.name, + oldType: String(olderMap[f.name].type), + newType: String(f.type) + }); + } + }); + + return result; + } + + function renderDiffDetail(diff) { + var container = document.createElement('div'); + + if (diff.added.length > 0) { + diff.added.forEach(function (f) { + var div = document.createElement('div'); + div.className = 'schema-diff-added mb-1 py-1'; + div.innerHTML = '<strong>+ ' + escapeHtml(f.name) + '</strong>: ' + escapeHtml(formatAvroType(f.type)); + container.appendChild(div); + }); + } + + if (diff.removed.length > 0) { + diff.removed.forEach(function (f) { + var div = document.createElement('div'); + div.className = 'schema-diff-removed mb-1 py-1'; + div.innerHTML = '<strong>- ' + escapeHtml(f.name) + '</strong>: ' + escapeHtml(formatAvroType(f.type)); + container.appendChild(div); + }); + } + + if (diff.changed.length > 0) { + diff.changed.forEach(function (c) { + var div = document.createElement('div'); + div.className = 'schema-diff-changed mb-1 py-1'; + div.innerHTML = '<strong>~ ' + escapeHtml(c.name) + '</strong>: ' + + escapeHtml(c.oldType) + ' → ' + escapeHtml(c.newType); + container.appendChild(div); + }); + } + + if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) { + container.innerHTML = '<span class="text-muted">No field-level changes detected</span>'; + } + + return container; + } + + function escapeHtml(str) { + var div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + + // ===== URL state management ===== + + function getActiveTabKey() { + var activeTab = mainTabsEl.querySelector('.nav-link.active'); + if (!activeTab) return null; + var target = activeTab.getAttribute('data-bs-target'); + if (target === '#tabConfig') return 'config'; + if (target === '#tabSchema') return 'schema'; + return null; // timeline is default, no param needed + } + + function updateUrlState() { + if (!currentTablePath) return; + var url = '?path=' + encodeURIComponent(currentTablePath); + var tabKey = getActiveTabKey(); + if (tabKey) { + url += '&tab=' + tabKey; + } + history.replaceState(null, '', url); + } + + // ===== Keyboard shortcuts ===== + + // Help modal toggle via '?' key + document.addEventListener('keydown', function (e) { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + if (e.key === '?') { + var modalEl = document.getElementById('helpModal'); + var modal = bootstrap.Modal.getOrCreateInstance(modalEl); + modal.toggle(); + } + }); + + // Tab switching via 1/2/3 keys and arrow navigation + document.addEventListener('keydown', function (e) { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + + // Tab switching + if (e.key === '1' || e.key === '2' || e.key === '3') { + if (!stateLoaded.classList.contains('d-none')) { + var tabIds = ['tab-timeline', 'tab-config', 'tab-schema']; + var idx = parseInt(e.key, 10) - 1; + var tabEl = document.getElementById(tabIds[idx]); + if (tabEl) { + bootstrap.Tab.getOrCreateInstance(tabEl).show(); + } + } + return; + } + + if (!timeline || !filteredView) return; + + if (e.key === 'Escape') { + timeline.setSelection([]); + detailCard.classList.add('d-none'); + allItems.remove(cleanRangeItemId); + return; + } + + if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; + e.preventDefault(); + + var visibleItems = filteredView.get({ + filter: function (item) { return item.type !== 'background'; } + }); + visibleItems.sort(function (a, b) { return new Date(a.start) - new Date(b.start); }); + + if (visibleItems.length === 0) return; + + var selected = timeline.getSelection(); + var currentIndex = -1; + if (selected.length > 0) { + for (var i = 0; i < visibleItems.length; i++) { + if (visibleItems[i].id === selected[0]) { + currentIndex = i; + break; + } + } + } + + var nextIndex; + if (e.key === 'ArrowRight') { + nextIndex = currentIndex < visibleItems.length - 1 ? currentIndex + 1 : 0; + } else { + nextIndex = currentIndex > 0 ? currentIndex - 1 : visibleItems.length - 1; + } + + var nextItem = visibleItems[nextIndex]; + timeline.setSelection([nextItem.id]); + timeline.focus(nextItem.id, { animation: { duration: 300, easingFunction: 'easeInOutQuad' } }); + + // Trigger detail panel update + if (currentTablePath) { + onSelect({ items: [nextItem.id] }, currentTablePath); + } + }); + + // Parses a Hudi timestamp (17-digit yyyyMMddHHmmssSSS or legacy 14-digit + // yyyyMMddHHmmss) to a readable date string. + function parseHudiTimestamp(ts) { + if (!ts) return ts; + var pattern = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{3})$/; + var match = ts.match(pattern); + if (match) { + return match[1] + '-' + match[2] + '-' + match[3] Review Comment: parseHudiTimestamp captures the milliseconds group in its 17-digit regex but omits it from the returned string, so item.start/item.end lose sub-second precision and computeStats' "if (diff > 0)" check silently drops every same-second commit from the average duration and collapses its range bar to zero width. Append the captured milliseconds group to the returned string so sub-second instants keep their real duration. ########## hudi-timeline-service/src/main/resources/public/js/timeline.js: ########## @@ -0,0 +1,1243 @@ +/* + * 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)); + + // 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) { + 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); + 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; + document.getElementById('configLoading').classList.add('d-none'); + renderTableConfig(data); + }) + .catch(function (err) { + document.getElementById('configLoading').classList.add('d-none'); + document.getElementById('configError').classList.remove('d-none'); + document.getElementById('configErrorMessage').textContent = 'Failed to load table config: ' + err.message; + }); + } + + function renderTableConfig(data) { + var tbody = document.getElementById('configTableBody'); + tbody.innerHTML = ''; + var props = data.properties || {}; + Object.keys(props).forEach(function (key) { + var tr = document.createElement('tr'); + var tdKey = document.createElement('td'); + tdKey.textContent = key; + var tdVal = document.createElement('td'); + tdVal.textContent = props[key]; + tr.appendChild(tdKey); + tr.appendChild(tdVal); + tbody.appendChild(tr); + }); + document.getElementById('configContent').classList.remove('d-none'); + } + + // Config filter + document.getElementById('configFilter').addEventListener('input', function () { + var query = this.value.toLowerCase(); + var rows = document.getElementById('configTableBody').querySelectorAll('tr'); + rows.forEach(function (row) { + var text = row.textContent.toLowerCase(); + row.classList.toggle('d-none', query && text.indexOf(query) === -1); + }); + }); + + function loadSchemaHistory() { + if (!currentTablePath) return; + if (schemaHistoryData) { + renderSchemaHistory(schemaHistoryData); + return; + } + + document.getElementById('schemaLoading').classList.remove('d-none'); + document.getElementById('schemaContent').classList.add('d-none'); + document.getElementById('schemaError').classList.add('d-none'); + + fetch(API_BASE + '/table/schema/history?basepath=' + encodeURIComponent(currentTablePath)) + .then(function (res) { + if (!res.ok) return httpError(res); + return res.json(); + }) + .then(function (data) { + schemaHistoryData = data; + document.getElementById('schemaLoading').classList.add('d-none'); + renderSchemaHistory(data); + }) + .catch(function (err) { + document.getElementById('schemaLoading').classList.add('d-none'); + document.getElementById('schemaError').classList.remove('d-none'); + document.getElementById('schemaErrorMessage').textContent = 'Failed to load schema history: ' + err.message; + }); + } + + function renderSchemaHistory(data) { + // Current schema tree + var treeContainer = document.getElementById('currentSchemaTree'); + treeContainer.innerHTML = ''; + if (data.currentSchema) { + try { + var schemaObj = JSON.parse(data.currentSchema); + renderjson.set_show_to_level(2); + renderjson.set_icons('\u25B6', '\u25BC'); + renderjson.set_sort_objects(false); + treeContainer.appendChild(renderjson(schemaObj)); + + // Fields summary table + var fieldsBody = document.getElementById('currentFieldsBody'); + fieldsBody.innerHTML = ''; + var fields = schemaObj.fields || []; + fields.forEach(function (field) { + var tr = document.createElement('tr'); + var tdName = document.createElement('td'); + tdName.className = 'font-monospace'; + tdName.textContent = field.name; + var tdType = document.createElement('td'); + tdType.textContent = formatAvroType(field.type); + var tdNullable = document.createElement('td'); + tdNullable.textContent = isNullable(field.type) ? 'Yes' : 'No'; + tr.appendChild(tdName); + tr.appendChild(tdType); + tr.appendChild(tdNullable); + fieldsBody.appendChild(tr); + }); + } catch (e) { + treeContainer.textContent = data.currentSchema; + } + } else { + treeContainer.innerHTML = '<p class="text-muted">No schema available</p>'; + document.getElementById('currentFieldsBody').innerHTML = ''; + } + + // Schema change history + var historyList = document.getElementById('schemaHistoryList'); + historyList.innerHTML = ''; + + // Always render the commit-metadata history first: the window truncation + // notice and the baseline/change typing are mandatory UI-visible signals + // (RFC-94), so they must appear even when the richer internal schema history + // from the .schema directory is also available. + renderCommitHistory(data, historyList); + + // When present, render the internal schema evolution (.schema directory) as + // an additional, clearly-labelled section below the commit-schema history. + if (data.internalSchemaHistory) { + renderInternalSchemaHistory(data, historyList); + } + + document.getElementById('schemaContent').classList.remove('d-none'); + } + + // Renders the commit-metadata schema history into historyList: an optional + // truncation notice followed by one card per recorded schema change (newest + // first), each tagged with its baseline/change type badge. Shows an empty-state + // message when the server reported no recorded changes. + function renderCommitHistory(data, historyList) { + var history = data.history || []; + + if (history.length === 0) { + historyList.innerHTML = '<div class="alert alert-info">No schema changes found</div>'; + return; + } + + // Truncation notice: when the scanned window is truncated, completed commits + // exist older than the oldest scanned instant, so the baseline entry is the + // window edge, not necessarily the table's first schema. + if (data.window && data.window.truncated) { + var truncNotice = document.createElement('div'); + truncNotice.className = 'alert alert-warning py-2 small'; + var oldestScanned = data.window.oldestInstantScanned || 'unknown'; + truncNotice.textContent = 'History truncated: older completed commits exist beyond the scanned window' + + ' (oldest scanned: ' + oldestScanned + '). The baseline entry is the window edge,' + + ' not necessarily the first table schema.'; + historyList.appendChild(truncNotice); + } + + // Build diff cards in reverse chronological order + for (var i = history.length - 1; i >= 0; i--) { + var entry = history[i]; + var card = document.createElement('div'); + card.className = 'card mb-2'; + + var header = document.createElement('div'); + header.className = 'card-header d-flex align-items-center gap-2'; + + var tsCode = document.createElement('code'); + tsCode.textContent = entry.instant; + + var actionBadge = document.createElement('span'); + actionBadge.className = 'badge bg-primary'; + actionBadge.textContent = entry.action; + + header.appendChild(tsCode); + header.appendChild(actionBadge); + + // Entry type badge: baseline (oldest recorded / window edge) vs later change. + if (entry.type === 'baseline') { + var baselineBadge = document.createElement('span'); + baselineBadge.className = 'badge bg-secondary'; + baselineBadge.textContent = 'baseline'; + header.appendChild(baselineBadge); + } else if (entry.type === 'change') { + var changeBadge = document.createElement('span'); + changeBadge.className = 'badge bg-light text-dark border'; + changeBadge.textContent = 'change'; + header.appendChild(changeBadge); + } + + // Completion time alongside the instant, when available. + if (entry.completionTime) { + var completionSpan = document.createElement('span'); + completionSpan.className = 'text-muted small'; + completionSpan.textContent = 'completed: ' + entry.completionTime; + header.appendChild(completionSpan); + } + + // Compute diff with previous schema if available + if (i > 0) { + var diff = diffSchemas(history[i - 1].schema, entry.schema); + var summaryParts = []; + if (diff.added.length > 0) summaryParts.push(diff.added.length + ' added'); + if (diff.removed.length > 0) summaryParts.push(diff.removed.length + ' removed'); + if (diff.changed.length > 0) summaryParts.push(diff.changed.length + ' changed'); + + if (summaryParts.length > 0) { + var summarySpan = document.createElement('span'); + summarySpan.className = 'text-muted small ms-auto'; + summarySpan.textContent = summaryParts.join(', '); + header.appendChild(summarySpan); + } + + // Collapsible diff detail + var collapseId = 'schemaDiff' + i; + var toggleBtn = document.createElement('button'); + toggleBtn.className = 'btn btn-sm btn-outline-secondary ms-2'; + toggleBtn.setAttribute('data-bs-toggle', 'collapse'); + toggleBtn.setAttribute('data-bs-target', '#' + collapseId); + toggleBtn.textContent = 'Details'; + header.appendChild(toggleBtn); + + var collapseDiv = document.createElement('div'); + collapseDiv.className = 'collapse'; + collapseDiv.id = collapseId; + var collapseBody = document.createElement('div'); + collapseBody.className = 'card-body'; + collapseBody.appendChild(renderDiffDetail(diff)); + collapseDiv.appendChild(collapseBody); + + card.appendChild(header); + card.appendChild(collapseDiv); + } else { + // Oldest entry: no previous schema to diff against. Fall back to an + // "Initial schema" marker only when the server did not tag it baseline. + if (entry.type !== 'baseline') { + var initialBadge = document.createElement('span'); + initialBadge.className = 'badge bg-secondary ms-auto'; + initialBadge.textContent = 'Initial schema'; + header.appendChild(initialBadge); + } + card.appendChild(header); + } + + historyList.appendChild(card); + } + } + + // Renders the internal schema evolution recorded in the .schema directory as a + // labelled section appended to historyList: one card per version_id (newest + // first) with field-level diffs between consecutive versions. Uses its own + // collapse-id namespace so its Details toggles never collide with the + // commit-history cards. Skipped silently when the payload cannot be parsed. + function renderInternalSchemaHistory(data, historyList) { + var internalData; + try { + internalData = JSON.parse(data.internalSchemaHistory); + } catch (e) { + return; + } + var schemas = (internalData.schemas || []).slice(); + // Sort by version_id ascending (oldest first) + schemas.sort(function (a, b) { return a.version_id - b.version_id; }); + + var heading = document.createElement('h6'); + heading.className = 'mt-4 mb-2'; + heading.textContent = 'Internal schema history (.schema)'; + historyList.appendChild(heading); + + if (schemas.length === 0) { + var emptyEl = document.createElement('div'); + emptyEl.className = 'alert alert-info'; + emptyEl.textContent = 'No schema changes found'; + historyList.appendChild(emptyEl); + return; + } + + // Build diff cards in reverse chronological order (newest first) + for (var i = schemas.length - 1; i >= 0; i--) { + var schema = schemas[i]; + var card = document.createElement('div'); + card.className = 'card mb-2'; + + var header = document.createElement('div'); + header.className = 'card-header d-flex align-items-center gap-2'; + + var tsCode = document.createElement('code'); + tsCode.textContent = schema.version_id === 0 ? 'Initial' : String(schema.version_id); + + header.appendChild(tsCode); + + if (i > 0) { + var diff = diffInternalSchemas(schemas[i - 1], schema); + var summaryParts = []; + if (diff.added.length > 0) summaryParts.push(diff.added.length + ' added'); + if (diff.removed.length > 0) summaryParts.push(diff.removed.length + ' removed'); + if (diff.changed.length > 0) summaryParts.push(diff.changed.length + ' changed'); + + if (summaryParts.length > 0) { + var summarySpan = document.createElement('span'); + summarySpan.className = 'text-muted small ms-auto'; + summarySpan.textContent = summaryParts.join(', '); + header.appendChild(summarySpan); + } + + var collapseId = 'internalSchemaDiff' + i; + var toggleBtn = document.createElement('button'); + toggleBtn.className = 'btn btn-sm btn-outline-secondary ms-2'; + toggleBtn.setAttribute('data-bs-toggle', 'collapse'); + toggleBtn.setAttribute('data-bs-target', '#' + collapseId); + toggleBtn.textContent = 'Details'; + header.appendChild(toggleBtn); + + var collapseDiv = document.createElement('div'); + collapseDiv.className = 'collapse'; + collapseDiv.id = collapseId; + var collapseBody = document.createElement('div'); + collapseBody.className = 'card-body'; + collapseBody.appendChild(renderDiffDetail(diff)); + collapseDiv.appendChild(collapseBody); + + card.appendChild(header); + card.appendChild(collapseDiv); + } else { + var initialBadge = document.createElement('span'); + initialBadge.className = 'badge bg-secondary ms-auto'; + initialBadge.textContent = 'Initial schema'; + header.appendChild(initialBadge); + card.appendChild(header); + } + + historyList.appendChild(card); + } + } + + function formatAvroType(type) { + if (typeof type === 'string') return type; + if (Array.isArray(type)) { + return type.map(formatAvroType).join(' | '); + } + if (type && type.type) { + if (type.type === 'array') return 'array<' + formatAvroType(type.items) + '>'; + if (type.type === 'map') return 'map<' + formatAvroType(type.values) + '>'; + return type.type; + } + return JSON.stringify(type); + } + + function isNullable(type) { + if (Array.isArray(type)) { + return type.indexOf('null') !== -1; + } + return type === 'null'; + } + + function diffSchemas(olderSchemaStr, newerSchemaStr) { + var result = { added: [], removed: [], changed: [] }; + try { + var older = JSON.parse(olderSchemaStr); + var newer = JSON.parse(newerSchemaStr); + var olderFields = (older.fields || []); + var newerFields = (newer.fields || []); + + var olderMap = {}; + olderFields.forEach(function (f) { olderMap[f.name] = f; }); + var newerMap = {}; + newerFields.forEach(function (f) { newerMap[f.name] = f; }); + + // Added fields + newerFields.forEach(function (f) { + if (!olderMap[f.name]) { + result.added.push(f); + } + }); + + // Removed fields + olderFields.forEach(function (f) { + if (!newerMap[f.name]) { + result.removed.push(f); + } + }); + + // Changed fields + newerFields.forEach(function (f) { + if (olderMap[f.name]) { + var oldType = JSON.stringify(olderMap[f.name].type); + var newType = JSON.stringify(f.type); + if (oldType !== newType) { + result.changed.push({ + name: f.name, + oldType: formatAvroType(olderMap[f.name].type), + newType: formatAvroType(f.type) + }); + } + } + }); + } catch (e) { + // If schemas can't be parsed, return empty diff + } + return result; + } + + function diffInternalSchemas(olderSchema, newerSchema) { + var result = { added: [], removed: [], changed: [] }; + var olderFields = olderSchema.fields || []; + var newerFields = newerSchema.fields || []; + + var olderMap = {}; + olderFields.forEach(function (f) { olderMap[f.name] = f; }); + var newerMap = {}; + newerFields.forEach(function (f) { newerMap[f.name] = f; }); + + newerFields.forEach(function (f) { + if (!olderMap[f.name]) { + result.added.push({ name: f.name, type: f.type }); + } + }); + + olderFields.forEach(function (f) { + if (!newerMap[f.name]) { + result.removed.push({ name: f.name, type: f.type }); + } + }); + + newerFields.forEach(function (f) { + if (olderMap[f.name] && olderMap[f.name].type !== f.type) { Review Comment: diffInternalSchemas compares field types with "!==" and renders them with String(), so any complex-typed field (record, array, map) is always reported as changed and shown as "[object Object] -> [object Object]", unlike diffSchemas which compares via JSON.stringify and formats via formatAvroType. Compare and render these types the same way diffSchemas does; follow-up, not a blocker. -- 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]
