Skip to content

feat(web): add Dioxus web UI crate with streams browser - #4644

Open
billy-doyle wants to merge 2 commits into
apache:masterfrom
billy-doyle:web-ui/01-crate
Open

billy-doyle wants to merge 2 commits into
apache:masterfrom
billy-doyle:web-ui/01-crate

Conversation

@billy-doyle

Copy link
Copy Markdown

Which issue does this PR address?

Relates to #4633

Part 1 of 10. The plan and the full series are in the issue.

Rationale

Rust web ui, one toolchain. Starts with a crate that ships alongside svelte until final removal.

What changed?

The web UI is a SvelteKit app, so changing it needs a second toolchain and npm dependency tree next
to the Rust workspace. core/web_ui is a new Dioxus crate, built to WebAssembly with dx, covering
sign-in and sign-out, the dashboard shell, and browsing streams, topics, partitions and messages.
iggy-server still embeds the Svelte UI, so nothing changes for users until the switch-over PR.

Two behaviors differ from the Svelte UI on purpose. A 403 shows a permission message and keeps the
session, where the Svelte UI signed the user out, so a user without global read_streams can still
sign in. The message pager follows the offsets the server returns, so after retention it stops at
the oldest message left instead of repeating pages.

The UI keeps its own serde types for the HTTP JSON, because iggy_common pulls tokio with net and
does not build for wasm32-unknown-unknown. scripts/build-web-ui.sh builds the bundle into
core/web_ui/dist and clears dx's staging directory first, since dx copies every previously hashed
asset into the bundle.

Local Execution

  • Passed: cargo test -p iggy-web-ui (10 tests), the bundle build, and headless-Chrome runs of this
    PR's UI against a local iggy-server. For those runs the server's embed was pointed at the Dioxus
    bundle locally. That change is not part of this PR. The runs covered:
    • sign-in, a wrong password, browsing to messages, paging, deep links, not found, sign-out and the
      signed-out redirect
    • a user with no permissions, who sees the permission message and stays signed in
    • 40 KB payloads, which show short previews and load in full only when a row is opened
    • paging after the oldest segment was deleted, which stops at the oldest retained offset
    • a server restart, after which the next failed request signs the user out
      I confirmed that each of the last four checks fails when its handling is removed.
  • Pre-commit hooks ran: prek run --from-ref origin/master --to-ref HEAD, plus the pre-push
    cargo clippy --all-features --all-targets and cargo machete.

AI Usage

  1. Claude Code (Claude Opus 5.5).
  2. It wrote the implementation and the tests, from my direction and review.
  3. Checked locally, tests, etc
  4. yes

The web UI is moving from SvelteKit to Rust so it builds with the same
toolchain as the server and drops the npm dependency tree. This adds the
Dioxus crate alongside the existing UI without changing what the server
serves.

It covers sign-in and sign-out, the dashboard shell, and browsing
streams, topics, partitions and messages. The UI keeps its own serde
mirrors of the HTTP JSON because iggy_common pulls tokio with net and
does not build for wasm32-unknown-unknown.

The app lives in a library target with a thin binary on top. Cargo
ignores path dependencies that have no library target, so the license
manifest would otherwise see none of the crate's dependencies.

A 403 shows a permission message and keeps the session, where the Svelte
UI signed the user out. The message pager follows the offsets the server
returns, so it stops at the oldest retained message after retention.

The build script clears dx's staging directory first, because dx copies
every previously hashed asset into the bundle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Thanks for the PR. It is labeled S-waiting-on-review and queued for review.

If AI was used to create this PR, you must run the team-review skill before opening it.

Slash commands (own line, regular comment) move it around the queue:

  • /ready - back to S-waiting-on-review after addressing feedback
  • /author - flip to S-waiting-on-author while you finish changes
  • /request-review @user-or-team - request a reviewer
  • /pin - exempt the PR from the stale bot, /unpin to undo

See CONTRIBUTING.md for details.

@github-actions github-actions Bot added the S-waiting-on-review PR is waiting on a reviewer label Oct 25, 2026

@hubcio hubcio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

biggest issues: the messages pager can skip offsets and mixes stale topic stats with the poll reply, and session handling breaks when storage is blocked or across tabs. the rest is smaller stuff inline.

Comment thread core/web_ui/src/pages/messages.rs Outdated
button {
class: "secondary",
disabled: !has_newer,
onclick: move |_| requested_offset.set(Some(first_offset + page_size)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

warning: the newer button jumps to first_offset + page_size, so when the server returns a short page, the offsets after it never show up. start the next page at shown_last_offset + 1 instead.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed. Newer now starts at shown_last_offset + 1.

Comment thread core/web_ui/src/pages/messages.rs Outdated
.iter()
.find(|partition| partition.id == partition_id)
.ok_or(ApiError::NotFound)?;
let last_offset = (partition.messages_count > 0).then_some(partition.current_offset);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

warning: last_offset comes from topic stats read before the poll, so it can disagree with the page - the range runs past the end, newer stays disabled, or it says "no messages". take it from PolledMessages::current_offset instead.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed. The range and the Newer button now use current_offset from the poll reply. Topic stats are only used to pick where the newest page starts. An offset past the end now shows "no messages from offset N" with a Latest button.

Comment thread core/web_ui/src/components.rs Outdated
// error to a 401 without remounting.
use_effect(use_reactive!(|unauthorized| {
if unauthorized {
Session::clear();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

warning: any 401 calls Session::clear(), which deletes the shared iggy_session key, so a stale tab wipes a newer login that another tab just saved. only delete it when the stored token is the one that failed.

also at auth.rs:50.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed. Session::clear() is gone. Session::forget(&self) only removes the stored session if it still holds the token that failed. The 401 path and sign-out both use it.

Comment thread core/web_ui/src/auth.rs Outdated
impl Session {
/// Restores the stored session, dropping it if the token has expired.
pub fn restore() -> Option<Self> {
let session: Self = LocalStorage::get(SESSION_KEY).ok()?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

warning: when the browser blocks site storage, LocalStorage::raw() throws through expect_throw, so Session::restore kills the first render and the app never shows up. get window.local_storage() yourself and treat an error or None as no session.

also at lines 47, 51.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed. Storage goes through web_sys::window().local_storage(), and an error or None counts as no session. Same for persist and forget. Dropped gloo-storage.

Comment thread core/web_ui/src/pages/messages.rs Outdated
}
tbody {
for message in page.messages.iter().rev() {
MessageRow { key: "{message.header.offset}", message: message.clone() }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

warning: the loop clones each Message into row props, so every payload sits in memory twice and gets cloned again on re-render. keep Vec<Rc<Message>> in MessagesPage, pass Rc<Message> to the row, and derive Eq on Message so memo compares pointers first.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. The page holds Vec<Rc<IggyMessage>>, rows take Rc<IggyMessage>, and the message types derive Eq.

Comment thread core/web_ui/src/api.rs Outdated
IdentityInfo, LoginRequest, PolledMessages, Stream, StreamDetails, TopicDetails, UserDetails,
};

/// Empty in the embedded build so requests go to the server that served the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: the crate has no README, so the dx workflow and IGGY_WEB_UI_API_URL are only documented in this comment. add a short README covering scripts/build-web-ui.sh, dx serve and IGGY_WEB_UI_API_URL.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added core/web_ui/README.md. It covers the build script, dx serve and IGGY_WEB_UI_API_URL.

Comment thread core/web_ui/src/models.rs Outdated

#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct PolledMessages {
pub partition_id: u32,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

simplification: nothing reads PolledMessages::{partition_id, count}, MessageHeader::{checksum, origin_timestamp, user_headers_length}, OptionValue::{kind, explicit} or UserDetails::id, and serde skips unknown fields anyway. drop them, but keep PolledMessages::current_offset for the pager fix.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Dropped them. Kept PolledMessages::current_offset for the pager.

Comment thread core/web_ui/src/auth.rs Outdated
}
}

impl TryFrom<IdentityInfo> for Session {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

simplification: issue_identity always sets the token and HttpJwtConfig can't turn JWT off, so MissingToken and this TryFrom guard a reply that never happens. make IdentityInfo::access_token a plain TokenInfo and convert with From.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. access_token is a plain TokenInfo and Session uses From<IdentityInfo>.

Comment thread core/web_ui/src/format.rs Outdated
)
}

fn text_or_hex(bytes: Vec<u8>) -> String {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

simplification: text_or_hex is a one-line private helper with a single caller. inline String::from_utf8(bytes).unwrap_or_else(|error| hex(error.as_bytes())) into payload.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Inlined.

Comment thread core/web_ui/src/pages/layout.rs Outdated
use crate::auth::Session;
use crate::components::{api_client, use_session};

const LOGO: Asset = asset!("/assets/iggy-apache-darkbg.svg");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

simplification: LOGO is declared here and again in sign_in.rs:26 for the same SVG. declare it once in lib.rs next to STYLESHEET and FAVICON.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Moved it to lib.rs next to STYLESHEET and FAVICON.

@github-actions github-actions Bot added S-waiting-on-author PR is waiting on author response and removed S-waiting-on-review PR is waiting on a reviewer labels Oct 26, 2026
@codecov

codecov Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 48.71795% with 300 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.70%. Comparing base (6ce2f92) to head (108e2d1).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
core/web_ui/src/api.rs 0.00% 98 Missing ⚠️
core/web_ui/src/pages/messages.rs 0.00% 57 Missing ⚠️
core/web_ui/src/auth.rs 0.00% 41 Missing ⚠️
core/web_ui/src/pages/sign_in.rs 0.00% 25 Missing ⚠️
core/web_ui/src/components.rs 0.00% 19 Missing ⚠️
core/web_ui/src/pages/layout.rs 0.00% 19 Missing ⚠️
core/web_ui/src/pages/streams.rs 0.00% 17 Missing ⚠️
core/web_ui/src/lib.rs 0.00% 8 Missing ⚠️
core/web_ui/src/pages/topic.rs 0.00% 7 Missing ⚠️
core/web_ui/src/format.rs 96.37% 4 Missing and 1 partial ⚠️
... and 2 more
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #4644      +/-   ##
============================================
- Coverage     90.77%   90.70%   -0.08%     
+ Complexity     2989     2987       -2     
============================================
  Files          1459     1472      +13     
  Lines        348165   348747     +582     
  Branches     292747   293331     +584     
============================================
+ Hits         316042   316323     +281     
- Misses        25485    25784     +299     
- Partials       6638     6640       +2     
Components Coverage Δ
Rust Core 92.00% <48.71%> (-0.09%) ⬇️
Java SDK 77.79% <ø> (-0.01%) ⬇️
C# SDK 83.57% <ø> (-0.17%) ⬇️
Python SDK 85.69% <ø> (ø)
PHP SDK 90.67% <ø> (ø)
Node SDK 96.44% <ø> (ø)
Go SDK 70.19% <ø> (+0.22%) ⬆️
C++ SDK 85.51% <ø> (ø)
Files with missing lines Coverage Δ
core/web_ui/src/models.rs 100.00% <100.00%> (ø)
core/web_ui/src/pages/not_found.rs 0.00% <0.00%> (ø)
core/web_ui/src/main.rs 0.00% <0.00%> (ø)
core/web_ui/src/format.rs 96.37% <96.37%> (ø)
core/web_ui/src/pages/topic.rs 0.00% <0.00%> (ø)
core/web_ui/src/lib.rs 0.00% <0.00%> (ø)
core/web_ui/src/pages/streams.rs 0.00% <0.00%> (ø)
core/web_ui/src/components.rs 0.00% <0.00%> (ø)
core/web_ui/src/pages/layout.rs 0.00% <0.00%> (ø)
core/web_ui/src/pages/sign_in.rs 0.00% <0.00%> (ø)
... and 3 more

... and 31 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The messages pager computed its next page and range from topic stats
read before the poll, so a short page skipped offsets and the range
could disagree with the rows shown. It now pages from the offsets the
poll returned, takes the newest offset from the poll reply, keeps the
page offset in the URL so reloads and shared links land on it, and
ignores pager clicks while a poll is in flight.

Session storage went through gloo-storage, which throws when the
browser blocks site storage and took down the first render. It now
reads localStorage through web-sys and treats any failure as no
session. A 401 or sign-out only removes the stored session when it
holds the token that failed, so a stale tab no longer wipes a login
another tab just saved.

Responses are decoded from bytes instead of one JS string, which
browsers cap below a page of large messages. Messages are shared with
rows through Rc instead of cloned.

The model mirrors now carry the server's type names and only the
fields the UI reads, and host-only tests decode serialized iggy_common
types into each mirror so a renamed server field fails in CI. The
build script clears dx's staging directory under the real cargo target
directory, and a crate README covers the build, dx serve and
IGGY_WEB_UI_API_URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@billy-doyle

Copy link
Copy Markdown
Author

/ready

@github-actions github-actions Bot added S-waiting-on-review PR is waiting on a reviewer and removed S-waiting-on-author PR is waiting on author response labels Oct 26, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

S-waiting-on-review PR is waiting on a reviewer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants