Skip to content

Add history exploration capabilities to Explorer UI#2407

Merged
thjaeckle merged 8 commits intoeclipse-ditto:masterfrom
beyonnex-io:feature/ui-history-exploration
Apr 16, 2026
Merged

Add history exploration capabilities to Explorer UI#2407
thjaeckle merged 8 commits intoeclipse-ditto:masterfrom
beyonnex-io:feature/ui-history-exploration

Conversation

@thjaeckle
Copy link
Copy Markdown
Member

@thjaeckle thjaeckle commented Apr 10, 2026

Summary

  • Time Travel Mode: Toggle in the Thing Details tab with revision slider and timestamp picker to inspect a Thing's state at any past point. All sections (Details, Attributes, Features) update automatically. Visual indicators (warning banner, amber border, badges) clearly signal historical viewing. Oldest available revision is probed via SSE.
  • Thing Updates (replaces "Incoming Thing Updates"): Dual-mode section with Live (existing SSE behavior) and Historical modes. Historical mode supports from/to range selection (by revision with sliders or by timestamp), server-side RQL filtering, and auto-stops when the stream is exhausted. "Show updates from here" button in Time Travel bridges to Historical Thing Updates.
  • Diff View: Side-by-side revision comparison using ace-diff with IntelliJ-style connector lines and character-level change highlighting. Revision slider pickers matching the Time Travel UX. Change overview minimap in the gutter for quick navigation. "Time Travel" button to use active time-travel revision as diff baseline.
  • Attributes Diff and Features Diff tabs: Scoped diff views showing only attributes or the selected feature, synchronized with the main Diff tab's revision controls. Activating any Diff tab activates all three.

thjaeckle and others added 3 commits April 10, 2026 14:15
Introduce history exploration capabilities to the Ditto Explorer UI,
leveraging Ditto's History API (since 3.2.0):

- Time Travel Mode: Toggle in the Thing Details tab with a revision
  slider and timestamp picker to inspect a Thing's state at any past
  point. All sections (Details, Attributes, Features) update
  automatically via the existing observer pattern. Visual indicators
  (warning banner, amber border, badges) clearly signal historical
  viewing. The oldest available revision is probed via SSE to show
  accurate slider bounds.

- Thing Updates (replaces "Incoming Thing Updates"): Dual-mode section
  with Live (existing SSE behavior) and Historical modes. Historical
  mode supports from/to range selection (by revision with sliders or
  by timestamp), server-side RQL filtering, and auto-stops when the
  stream is exhausted. "Show updates from here" button in Time Travel
  bridges to the Historical Thing Updates.

- API layer: New getHistoricalEventSource() for single-thing
  historical SSE streams. Core things.ts extended with
  refreshThingAtRevision/Timestamp and history mode flag.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a new "Diff" tab to the Thing detail panel using ace-diff for
IntelliJ-style side-by-side revision comparison with connector lines
and character-level change highlighting.

Features:
- Revision slider pickers (matching Time Travel/Thing Updates UX)
  with prev/next buttons, min/max labels, and oldest revision probing
- "Current" button to jump to latest revision on each side
- "Time Travel" button (left side) to use the active time-travel
  revision as diff baseline
- Change overview minimap in the gutter showing colored markers for
  all diff locations, clickable to scroll to each change
- Lazy initialization of ace-diff on first tab activation

Also includes UX polish across all revision pickers:
- Aligned stacked revision picker rows with fixed-width CSS classes
- Hidden native number input spinners (redundant with slider/buttons)
- Renamed "Most recent" to "Newest" to prevent button text wrapping
- Fixed ace.Editor type annotations in policies modules for
  compatibility with updated ace-builds types from ace-diff

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extend the revision diff capability to Attributes and Features sections:

- New reusable SubDiff component (subDiff.html/.ts): lightweight
  ace-diff panel added as a tab, receiving content from the main
  ThingsDiff module without its own revision controls.

- Attributes Diff tab (next to "Manage"): shows only the attributes
  portion of each revision side-by-side.

- Features Diff tab (next to "Manage" and "WoT TD"): shows the
  currently selected feature from each revision. Updates when a
  different feature is selected.

- Tab synchronization: activating any of the three Diff tabs
  (main, Attributes, Features) programmatically activates the
  other two via Bootstrap Tab API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@thjaeckle thjaeckle added enhancement UI Issues related to the Ditto explorer UI labels Apr 10, 2026
@thjaeckle thjaeckle self-assigned this Apr 10, 2026
@thjaeckle thjaeckle added this to the 3.9.0 milestone Apr 10, 2026
@thjaeckle thjaeckle moved this to Waiting for Approval in Ditto Planning Apr 10, 2026
@thomas-fries-keen
Copy link
Copy Markdown

Sounds very impressive! I was not aware that this is even possible.

@thjaeckle
Copy link
Copy Markdown
Member Author

@thomas-fries-keen yes, this is possible already since Ditto 3.2.0 - and I often used it directly via API to find out when and why a device changed its state. That however is a quite difficult API to use and providing it via the UI is a much nicer experience :)

It also depends on the configuration how long to keep history how far back you can look back

@hu-ahmed hu-ahmed self-requested a review April 15, 2026 09:45
Copy link
Copy Markdown
Contributor

@hu-ahmed hu-ahmed left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — feature/ui-history-exploration

▎ Thanks for this — really nice feature. Time Travel, the Diff tabs, and the Historical Updates stream are a proper step up for the Explorer, and the module split is clean.

I ran the branch locally and went through the code. Findings below, grouped by severity.

Blockers

B1. Tab-sync cascade on Diff-tab activation

File: thingsDiff.ts:266-276

syncTabs resets its syncing guard synchronously, but Bootstrap's shown.bs.tab fires asynchronously after the CSS fade. When the async events arrive, syncing is already false, so the guard doesn't stop re-entry. Each
activated sibling tab calls back into syncTabs, producing a cascade.

Today the !link.classList.contains('active') check prevents it from becoming infinite — but that safety depends on an undocumented Bootstrap detail. A theme change, a data-bs-animation="false", or a Bootstrap upgrade turns
this into a runaway with no related code change on this branch.

B2. Timestamp time-travel returns 404 for any non-UTC user (reproduced)

Files: thingsHistory.ts:322-325, thingUpdates.ts:507-510

toDatetimeLocalValue returns isoString.substring(0, 19), dropping the trailing Z. The interprets the result as local time. On submit, new Date(value).toISOString() converts local → UTC, shifting
the timestamp by the browser's offset.

B3. No environment-change cleanup — SSE streams persist across env switch

Files: thingsHistory.ts, thingUpdates.ts, thingsDiff.ts

The codebase pattern for stream-owning modules is to subscribe to Environments.addChangeListener and close SSE on env switch — see thingsSSE.ts:32, searchFilter.ts:51, fields.ts:61, featureMessages.ts:42,
thingMessages.ts:38. None of the new modules subscribe.

Start a historical stream in env A, switch to env B: the old connection keeps delivering env A's events into a UI that thinks it's in env B, with env A's auth headers baked in. Data-integrity issue, arguably a small security
issue.

Majors

File: thingsDiff.ts:288-309

Promise.all on two fetchRevision calls with no request token. If the gateway is momentarily slow, a late response from an earlier slider position overwrites newer state. Slider labels say one pair, rendered JSON is another.
The 300 ms debounce reduces but doesn't eliminate the window.

File: thingUpdates.ts:406-408

One malformed frame kills the handler and leaves Stop visible with a dead stream. The file's own probeOldestRevision wraps its parse; this one doesn't. Same pattern exists pre-existing in thingsSSE.ts, but the new code
shouldn't replicate it.

File: thingUpdates.ts:361-404

from > to (or fromTs > toTs) is passed straight to the API. Confirmed with a live probe: backend returns HTTP 200 with zero events and closes in ~19 ms. User sees Fetch → Stop → Fetch flicker with an empty table and no
message.

File: thingUpdates.ts:495-505

.min and .max are set on every other slider and input. Only .min on this one. User can type any number into the From box. Looks like a copy-paste miss from an otherwise symmetric pair.

Files: thingsHistory.ts:142-186, thingsDiff.ts:219-247, thingUpdates.ts:459-493

The probe does self-close on first message, so this isn't a classic leak. The real issue is that thingsHistory.ts:155 has no thingId === currentThingId guard, unlike the other two (thingsDiff.ts:229, thingUpdates.ts:470). If
the user switches Thing before the probe's first message arrives, the stale probe's result silently overwrites the new Thing's slider range with the old Thing's oldest revision.

Files: policiesJSON.ts:33, policiesResources.ts:38, policiesSubjects.ts:36

The diff from commit 3612c6d:

let policyEditor: ace.Editor;
let policyEditor;
Three annotations deleted — implicit any on the editor variables, losing all downstream type safety. Commit message attributes this to ace-builds type conflicts pulled in by ace-diff. The right fix is to import the correct
type, not to erase types across unrelated files:

import { Ace } from 'ace-builds';
let policyEditor: Ace.Editor;

Same root cause as ace as any in thingsDiff.ts:335 and subDiff.ts:85.

File: main.scss:326-330

.input-group input[type="number"] {
flex: 0 0 80px !important;
width: 80px !important;
padding-right: 6px !important;
-moz-appearance: textfield !important;
}

This applies to every Bootstrap number input app-wide. At least five templates use input[type="number"], including featureMessages.html and thingMessages.html, which are unrelated to this feature. !important makes it
unoverridable. Scope the selectors to the history/diff containers.

File: thingsDiff.ts:91

The "Time Travel" button reads getElementById('historyRevisionSlider') from the Time Travel module. Rename the ID in thingsHistory.html and this button silently no-ops with no signal. Expose an accessor from thingsHistory.ts
(e.g. getCurrentTimeTravelRevision(): number | null) to make the dependency explicit.

File: thingUpdates.ts:85, 426

Every event is pushed to two arrays and rendered as a DOM row. A Thing with thousands of revisions will OOM or hang the browser. Needs a cap with a "limit reached — narrow your range" notice, or table virtualization.

File: thingUpdates.ts:156-157

selectedRow = event.target.parentNode.rowIndex - 1;

Assumes the click landed directly on a . Clicking a nested or yields undefined → NaN-indexed row, silent no-op. Use (event.target as HTMLElement).closest('tr')?.rowIndex.

File: things.ts:92-93, 103

.catch((err) => console.error(...)). API.callDittoREST does show a toast for non-OK responses, so the error isn't truly silent — but the UI banner still reads "Time travel to revision X" while the data shown is from the
previous successful fetch. Needs at least a banner/badge reset on error.

File: main.ts:98-100

Things.addChangeListener(thingsDiff.onThingChanged);
Things.addHistoryModeChangeListener(thingsDiff.onHistoryModeChanged);
await thingsDiff.ready();

The listeners are registered before ready() populates dom.diffLeft*. The window is narrow — no Thing is selected yet at this point in startup — but the ordering violates the module's initialization contract. One-line swap.

File: thingUpdates.ts:438-443

Any SSE hiccup — transient network blip, proxy restart — permanently stops the stream. Fetch silently reappears. A toast or status message would help the user understand why data stopped flowing.

Minors

thingUpdates.ts:252 — onThingChanged no-ops when the same Thing is re-selected; if revisions advanced, sliders don't refresh.
thingsDiff.ts, subDiff.ts — no destroy() for the AceDiff instance; recreating it leaks DOM.
thingsDiff.ts:366-429 — renderChangeOverview rebuilds the SVG and re-binds listeners on every render.
thingsDiff.ts:296-309 — Promise.all → a single missing revision blanks the diff; allSettled would handle partial failures.
things.ts:24 — export let historyModeActive is a mutable global. Make it a getter.
thingUpdates.ts:342-345 — classList.add('show') bypasses bootstrap.Collapse API, desyncs the collapse state.
Triplicated probeOldestRevision with subtle divergences; extract a shared helper.
Duplicated toDatetimeLocalValue (and wrong — fix both together).
Duplicated HISTORY_FIELDS constant.
thingsDiff.ts:318-319 — JSON.parse(JSON.stringify(thing)) deep clone; structuredClone is standard now.
package.json:31 — caret pin on ace-diff (^4.0.0); given it already forced type erosion (M6), prefer exact pin.
thingsHistory.ts:224 — sibling.parentElement can be null.
thingUpdates.ts:85-86 — messages / filteredMessages inferred any[].
thingUpdates.ts:282-283 — return type [Term?] is a one-element tuple misused as array; should be Term[].
thingsDiff.ts:335, subDiff.ts:85 — ace as any cast; same root cause as M6.
Inconsistent debounce: 200 ms in Time Travel, 300 ms in Diff.

Fix all blockers, major, and minor issues from hu-ahmed's review:

Blockers:
- B1: Make tab-sync guard async-safe via setTimeout(0) to survive
  Bootstrap's async shown.bs.tab events
- B2: Fix toDatetimeLocalValue to properly convert UTC to local
  timezone, preventing 404s for non-UTC users
- B3: Add Environments.addChangeListener to thingsHistory and
  thingUpdates to close SSE streams on environment switch

Majors:
- M1: Add fetch token to prevent stale diff responses from
  overwriting newer state
- M2: Wrap JSON.parse in onHistoricalMessage in try/catch
- M3: Validate from <= to range before opening SSE stream
- M4: Add missing .max constraint on historicalFromRevision input
- M5: Add thingId guard in thingsHistory probe callback
- M6: Restore Ace.Editor type annotations in policy files
- M7: Scope number input CSS to history/diff containers
- M9: Add MAX_MESSAGES cap (5000) with user notification
- M10: Use closest('tr') for robust row click handling
- M11: Return promises from refreshThingAtRevision/Timestamp,
  update banner on error
- M12: Call thingsDiff.ready() before registering listeners
- M13: Show toast on unexpected historical stream error

Minors:
- Extract shared probeOldestRevision helper to things.ts
- Extract shared toDatetimeLocalValue to utils.ts
- Deduplicate HISTORY_FIELDS constant (export from things.ts)
- Use structuredClone instead of JSON roundtrip
- Make historyModeActive a getter (isHistoryModeActive)
- Fix Term[] return type (was mistyped [Term?] tuple)
- Use Bootstrap Collapse API instead of classList manipulation
- Add AceDiff destroy() support, reuse SVG in minimap
- Pin ace-diff version exactly (4.0.0)
- Add null safety for sibling.parentElement
- Type messages/filteredMessages as any[]
- Cast ace as proper type instead of any
- Remove unused Time Travel button from Diff view
- Fix minimap click scroll bounce (disable animation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@thjaeckle
Copy link
Copy Markdown
Member Author

@hu-ahmed addressed your findings - thanks a lot for finding so much :)

If you could have another look?

thjaeckle and others added 3 commits April 16, 2026 09:02
When deselecting a thing, the Diff tab showed unformatted residual
content and the Attributes/Features Diff sub-tabs retained stale data.
Now destroyDiff() clears the container DOM, onThingChanged(null) resets
all state variables and destroys sub-diffs, and SubDiff.destroy() clears
its container and pending content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a checkbox to the Thing Updates section that optionally includes
_context/headers in SSE events. In Live mode, toggling it reconnects
the SSE stream immediately. In Historical mode, only the useful
_context/headers/historical-headers field is requested.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds "Last 10" / "Last 100" buttons when in By Revision mode and
"Last 1h" / "Last 24h" buttons when in By Timestamp mode. Each button
sets the from/to range and immediately triggers a fetch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@thjaeckle thjaeckle merged commit f93a45b into eclipse-ditto:master Apr 16, 2026
3 checks passed
@github-project-automation github-project-automation bot moved this from Waiting for Approval to Done in Ditto Planning Apr 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement UI Issues related to the Ditto explorer UI

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants