From f6dc944560e766cba5a3b4e724e0ba65717c632f Mon Sep 17 00:00:00 2001 From: Billy Doyle <50842911+billy-doyle@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:03:11 -0400 Subject: [PATCH 1/2] feat(web): add Dioxus web UI crate with streams browser 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 --- .gitignore | 1 + Cargo.lock | 1343 +++++++++++++++++++-- Cargo.toml | 4 + core/web_ui/Cargo.toml | 41 + core/web_ui/Dioxus.toml | 25 + core/web_ui/assets/favicon.svg | 98 ++ core/web_ui/assets/iggy-apache-darkbg.svg | 112 ++ core/web_ui/assets/main.css | 317 +++++ core/web_ui/src/api.rs | 184 +++ core/web_ui/src/auth.rs | 77 ++ core/web_ui/src/components.rs | 86 ++ core/web_ui/src/format.rs | 238 ++++ core/web_ui/src/lib.rs | 70 ++ core/web_ui/src/main.rs | 20 + core/web_ui/src/models.rs | 143 +++ core/web_ui/src/pages/layout.rs | 76 ++ core/web_ui/src/pages/messages.rs | 174 +++ core/web_ui/src/pages/mod.rs | 30 + core/web_ui/src/pages/not_found.rs | 34 + core/web_ui/src/pages/sign_in.rs | 98 ++ core/web_ui/src/pages/streams.rs | 135 +++ core/web_ui/src/pages/topic.rs | 83 ++ scripts/build-web-ui.sh | 31 + 23 files changed, 3342 insertions(+), 78 deletions(-) create mode 100644 core/web_ui/Cargo.toml create mode 100644 core/web_ui/Dioxus.toml create mode 100644 core/web_ui/assets/favicon.svg create mode 100644 core/web_ui/assets/iggy-apache-darkbg.svg create mode 100644 core/web_ui/assets/main.css create mode 100644 core/web_ui/src/api.rs create mode 100644 core/web_ui/src/auth.rs create mode 100644 core/web_ui/src/components.rs create mode 100644 core/web_ui/src/format.rs create mode 100644 core/web_ui/src/lib.rs create mode 100644 core/web_ui/src/main.rs create mode 100644 core/web_ui/src/models.rs create mode 100644 core/web_ui/src/pages/layout.rs create mode 100644 core/web_ui/src/pages/messages.rs create mode 100644 core/web_ui/src/pages/mod.rs create mode 100644 core/web_ui/src/pages/not_found.rs create mode 100644 core/web_ui/src/pages/sign_in.rs create mode 100644 core/web_ui/src/pages/streams.rs create mode 100644 core/web_ui/src/pages/topic.rs create mode 100755 scripts/build-web-ui.sh diff --git a/.gitignore b/.gitignore index 2dfad02764..6d9af9d641 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ foreign/node/.npm __pycache__/ go.work core/bench/dashboard/frontend/dist +core/web_ui/dist core/integration/tests/connectors/postgres/cdc_restart_connectors/* !core/integration/tests/connectors/postgres/cdc_restart_connectors/config.toml LICENSE-binary diff --git a/Cargo.lock b/Cargo.lock index cefd525776..2e6503c658 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -185,7 +185,7 @@ dependencies = [ "bytes", "bytestring", "cfg-if", - "cookie", + "cookie 0.16.2", "derive_more", "encoding_rs", "foldhash", @@ -1178,7 +1178,7 @@ dependencies = [ "futures-core", "libc", "portable-atomic", - "rustc-hash", + "rustc-hash 2.1.3", "rustix 1.1.5", "tokio", "tokio-stream", @@ -1444,6 +1444,22 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "async-tungstenite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee88b4c88ac8c9ea446ad43498955750a4bbe64c4392f21ccfe5d952865e318f" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "tungstenite 0.27.0", +] + [[package]] name = "async-tungstenite" version = "0.34.1" @@ -2031,6 +2047,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -2114,6 +2131,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "base16" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" + [[package]] name = "base16ct" version = "0.2.0" @@ -2274,7 +2297,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.3", "shlex 1.3.0", "syn 2.0.119", ] @@ -2803,6 +2826,9 @@ name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] [[package]] name = "bytes-utils" @@ -2924,6 +2950,12 @@ dependencies = [ "shlex 2.0.1", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cexpr" version = "0.6.0" @@ -2933,6 +2965,17 @@ dependencies = [ "nom 7.1.3", ] +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -2988,6 +3031,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "charset" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" +dependencies = [ + "base64 0.22.1", + "encoding_rs", +] + [[package]] name = "chrono" version = "0.4.45" @@ -3012,6 +3065,33 @@ dependencies = [ "phf 0.12.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -3358,7 +3438,7 @@ dependencies = [ "futures-util", "libc", "quinn-proto", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "rustls-platform-verifier", "synchrony", @@ -3415,7 +3495,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0a9e5cbacce3ff2594cfd7321fd946d5f7afcf2dbc7537f6de7b42d798efb3a" dependencies = [ - "async-tungstenite", + "async-tungstenite 0.34.1", "compio-buf", "compio-io", "compio-log", @@ -3564,12 +3644,90 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "const-serialize" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7154afa56de2f290e3c82c2c6dc4f5b282b6870903f56ef3509aba95866edc" +dependencies = [ + "const-serialize-macro 0.7.2", +] + +[[package]] +name = "const-serialize" +version = "0.8.0-alpha.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e42cd5aabba86f128b3763da1fec1491c0f728ce99245062cd49b6f9e6d235b" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize-macro 0.8.0-alpha.3", + "serde", +] + +[[package]] +name = "const-serialize-macro" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f160aad86b4343e8d4e261fee9965c3005b2fd6bc117d172ab65948779e4acf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "const-serialize-macro" +version = "0.8.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d372d7ea4c50890f188c72a08782d5c98bc8c7cc9dc4343b61d8ba8e20c26b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "const-str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0664d2867b4a32697dfe655557f5c3b187e9b605b38612a748e5ec99811d160" + +[[package]] +name = "const_format" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3decb4d6965448659923544a634a0571ba44cede4e11ef15a5a317126840454" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941b570269a642506f08cb5b688c04bfa6cfd41a26f8bcdc49db8fd00c0ab659" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "constant_time_eq" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "content_disposition" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc14a88e1463ddd193906285abe5c360c7e8564e05ccc5d501755f7fbc9ca9c" +dependencies = [ + "charset", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -3623,12 +3781,41 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "cookie-factory" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie 0.18.2", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -4569,126 +4756,599 @@ dependencies = [ name = "derive-new" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" +checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive-syn-parse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive-where" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dioxus" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9320593eda4f01858f046698b42441603d2af5fcb62a6c1f9046a0c2eebd6d9b" +dependencies = [ + "dioxus-asset-resolver", + "dioxus-cli-config", + "dioxus-config-macro", + "dioxus-config-macros", + "dioxus-core", + "dioxus-core-macro", + "dioxus-devtools", + "dioxus-document", + "dioxus-fullstack", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-logger", + "dioxus-router", + "dioxus-signals", + "dioxus-stores", + "dioxus-web", + "manganis", + "subsecond", + "warnings", +] + +[[package]] +name = "dioxus-asset-resolver" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84235ff0e272f15d4537603cdd50df6d45d50e51bb96326dbc0bdfd01d825226" +dependencies = [ + "dioxus-cli-config", + "http 1.5.0", + "infer", + "jni 0.21.1", + "js-sys", + "ndk", + "ndk-context", + "ndk-sys", + "percent-encoding", + "thiserror 2.0.21", + "tokio", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "dioxus-cli-config" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322d10f47effebd85ee0662a2cb083ff920d40dd6295a4afd8cfa4f9bc2e05c6" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "dioxus-config-macro" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f3a906c8219e94fa48189a793c760a4e3193cbb7791f799e9d37633fe41161d" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "dioxus-config-macros" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20de015bc89c20f4ffc7b4f849943420308365fa66f991250b5c075261990a21" + +[[package]] +name = "dioxus-core" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b232228ada232c0adc151e41ebd9ef70cc04683db097fa0dc6015979fa01722" +dependencies = [ + "anyhow", + "const_format", + "dioxus-core-types", + "futures-channel", + "futures-util", + "generational-box", + "longest-increasing-subsequence", + "rustc-hash 2.1.3", + "rustversion", + "serde", + "slab", + "slotmap", + "subsecond", + "tracing", +] + +[[package]] +name = "dioxus-core-macro" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6256a68462222f0600f5fd7d9542cbd9f299bc19355d900f7827dd3fc9e09fdb" +dependencies = [ + "convert_case 0.8.0", + "dioxus-rsx", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dioxus-core-types" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96007ed6cbad951dfed82d83c581aabd95bc591f351f5666d454ddbe7845b324" + +[[package]] +name = "dioxus-devtools" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434cebb282b3f820a341582ebfd37dab907d5a25c3eeafd87802468afcd2a959" +dependencies = [ + "dioxus-cli-config", + "dioxus-core", + "dioxus-devtools-types", + "dioxus-signals", + "serde", + "serde_json", + "subsecond", + "thiserror 2.0.21", + "tracing", + "tungstenite 0.28.0", +] + +[[package]] +name = "dioxus-devtools-types" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfd6f3475e38c93be245a664a9ce11679a8f2b99832620705cf7837f42aa39ce" +dependencies = [ + "dioxus-core", + "serde", + "subsecond-types", +] + +[[package]] +name = "dioxus-document" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f6fadd5c6d886b48b762820abae1a08142a2159f8a1136c0466b4b4ed9f547e" +dependencies = [ + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-html", + "futures-channel", + "futures-util", + "generational-box", + "lazy-js-bundle", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "dioxus-fullstack" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a91d5acabd1470145bcf164cab9fd2edcf2e4fba0fa6fb0fd6dca23079fcc169" +dependencies = [ + "anyhow", + "async-stream", + "async-tungstenite 0.31.0", + "axum", + "axum-core", + "base64 0.22.1", + "bytes", + "ciborium", + "const-str", + "const_format", + "content_disposition", + "derive_more", + "dioxus-asset-resolver", + "dioxus-cli-config", + "dioxus-core", + "dioxus-fullstack-core", + "dioxus-fullstack-macro", + "dioxus-hooks", + "dioxus-html", + "dioxus-signals", + "form_urlencoded", + "futures", + "futures-channel", + "futures-util", + "gloo-net 0.6.0", + "headers", + "http 1.5.0", + "http-body", + "http-body-util", + "js-sys", + "mime", + "pin-project", + "reqwest 0.12.28", + "rustversion", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "serde_urlencoded", + "thiserror 2.0.21", + "tokio-util", + "tracing", + "tungstenite 0.27.0", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "dioxus-fullstack-core" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0011e9bca8da2ae6bf02ba9ef93ba6c5fa539e402de3aeb316ad86ca77c59079" +dependencies = [ + "anyhow", + "axum-core", + "base64 0.22.1", + "ciborium", + "dioxus-core", + "dioxus-document", + "dioxus-history", + "dioxus-hooks", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "http 1.5.0", + "inventory", + "parking_lot", + "serde", + "serde_json", + "thiserror 2.0.21", + "tokio", + "tracing", +] + +[[package]] +name = "dioxus-fullstack-macro" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "681f39665de36af7bb724b1cc0ca9c2b7c36a2b93febf1b2911716479eac3db0" +dependencies = [ + "const_format", + "convert_case 0.8.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "xxhash-rust", +] + +[[package]] +name = "dioxus-history" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2404b77d4441e694a5e93afb5c9a729efb26a3f6920f769cfe6cfedf5c7ac210" +dependencies = [ + "dioxus-core", + "tracing", +] + +[[package]] +name = "dioxus-hooks" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d3807a1bc039299cd37ad85f1c56ae7088210e8a38e3e877f01034d68c15fbb" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "dioxus-core", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "rustversion", + "slab", + "tracing", ] [[package]] -name = "derive-syn-parse" -version = "0.2.0" +name = "dioxus-html" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" +checksum = "40b9a85679c4dfd8699407a9ad5041a6a4fe6be0ee386353951cd03b57a12030" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "async-trait", + "bytes", + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-hooks", + "dioxus-html-internal-macro", + "enumset", + "euclid", + "futures-channel", + "futures-util", + "generational-box", + "keyboard-types", + "lazy-js-bundle", + "rustversion", + "tracing", ] [[package]] -name = "derive-where" -version = "1.7.0" +name = "dioxus-html-internal-macro" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" +checksum = "500282dad5b62e91f93e127cbf41c587bf1c457d52514a75f3ff2a55fd0ea9f6" dependencies = [ + "convert_case 0.8.0", "proc-macro2", "quote", - "syn 3.0.9", + "syn 2.0.119", ] [[package]] -name = "derive_builder" -version = "0.20.2" +name = "dioxus-interpreter-js" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +checksum = "faa22cc3431e7efdbae28069542a1763c977d5edbe4d4fbfb0b7667b9c9966e9" dependencies = [ - "derive_builder_macro", + "js-sys", + "lazy-js-bundle", + "rustc-hash 2.1.3", + "sledgehammer_bindgen", + "sledgehammer_utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] -name = "derive_builder_core" -version = "0.20.2" +name = "dioxus-logger" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +checksum = "0f36b2dac4df8c747431f77f2a6644dd033ea98e7f8ac80271ff47fd4ba8a548" dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.119", + "dioxus-cli-config", + "tracing", + "tracing-subscriber", + "tracing-wasm", ] [[package]] -name = "derive_builder_macro" -version = "0.20.2" +name = "dioxus-router" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +checksum = "e9652a5bcead34f687d7dbb12d5c8f6a7ecb342786273395ca0078e51b24320f" dependencies = [ - "derive_builder_core", - "syn 2.0.119", + "dioxus-cli-config", + "dioxus-core", + "dioxus-core-macro", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-router-macro", + "dioxus-signals", + "percent-encoding", + "rustversion", + "tracing", + "url", ] [[package]] -name = "derive_more" -version = "2.1.1" +name = "dioxus-router-macro" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +checksum = "56d20cf2173183946fdf8ab069b606a840a9d06e949050619355fc8cb97aafb6" dependencies = [ - "derive_more-impl", + "base16", + "digest 0.10.7", + "proc-macro2", + "quote", + "sha2 0.10.9", + "slab", + "syn 2.0.119", ] [[package]] -name = "derive_more-impl" -version = "2.1.1" +name = "dioxus-rsx" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +checksum = "aea662c9e13a91279d8acbc0d02a80430c7081704c20e8c791e4c4a151268596" dependencies = [ - "convert_case 0.10.0", "proc-macro2", + "proc-macro2-diagnostics", "quote", - "rustc_version", + "rustversion", "syn 2.0.119", - "unicode-xid", ] [[package]] -name = "des" -version = "0.8.1" +name = "dioxus-signals" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +checksum = "95d85e36fcaf7abdc986836801bf248776da7d5961f5fac65fdbf5ed491afb4f" dependencies = [ - "cipher 0.4.4", + "dioxus-core", + "futures-channel", + "futures-util", + "generational-box", + "parking_lot", + "rustc-hash 2.1.3", + "tracing", + "warnings", ] [[package]] -name = "difflib" -version = "0.4.0" +name = "dioxus-stores" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +checksum = "15eaa9f2ccc5ce962d056515e61345c846a54b13a2b880cf7e8ac93faa53a178" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "dioxus-stores-macro", + "generational-box", +] [[package]] -name = "digest" -version = "0.10.7" +name = "dioxus-stores-macro" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "a13bd8a693d7042544fa3ac79698a08783bbc698d1ed257e88c5d4059e9ac476" dependencies = [ - "block-buffer 0.10.4", - "const-oid 0.9.6", - "crypto-common 0.1.6", - "subtle", + "convert_case 0.8.0", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "digest" -version = "0.11.3" +name = "dioxus-web" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid 0.10.2", - "crypto-common 0.2.2", - "ctutils", +checksum = "481093233d789c5ffbdde66e7970e803b01a199a7ab0c0f8ef84c65d30b7384f" +dependencies = [ + "dioxus-cli-config", + "dioxus-core", + "dioxus-core-types", + "dioxus-devtools", + "dioxus-document", + "dioxus-history", + "dioxus-html", + "dioxus-interpreter-js", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "gloo-timers 0.3.0", + "js-sys", + "lazy-js-bundle", + "rustc-hash 2.1.3", + "send_wrapper", + "serde", + "serde-wasm-bindgen", + "serde_json", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", ] [[package]] @@ -4802,6 +5462,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -5694,6 +6363,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "generational-box" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b2dc9b873cd1a8adb80aa5dcef9a8205f781a8a313d5ffb867248cb3bcb764" +dependencies = [ + "parking_lot", + "tracing", +] + [[package]] name = "generator" version = "0.8.11" @@ -6044,6 +6723,27 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http 1.5.0", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "gloo-net" version = "0.7.0" @@ -6406,6 +7106,30 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "headers" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2700e3fe555c3310aa7286cac6167449f2c87e0eb58769c9208a1c58a1d106" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http 1.5.0", + "httpdate", + "mime", + "sha1 0.10.7", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.5.0", +] + [[package]] name = "heapless" version = "0.7.17" @@ -6459,7 +7183,7 @@ dependencies = [ "hickory-proto", "idna", "ipnet", - "jni", + "jni 0.22.4", "rand 0.10.3", "thiserror 2.0.21", "tinyvec", @@ -6477,7 +7201,7 @@ dependencies = [ "data-encoding", "idna", "ipnet", - "jni", + "jni 0.22.4", "once_cell", "prefix-trie", "rand 0.10.3", @@ -6500,7 +7224,7 @@ dependencies = [ "hickory-proto", "ipconfig", "ipnet", - "jni", + "jni 0.22.4", "moka", "ndk-context", "once_cell", @@ -7352,6 +8076,21 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "iggy-web-ui" +version = "0.1.0" +dependencies = [ + "base64 0.23.1", + "chrono", + "dioxus", + "gloo-net 0.7.0", + "gloo-storage 0.4.0", + "js-sys", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "iggy_binary_protocol" version = "0.11.1-edge.3" @@ -8039,6 +8778,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + [[package]] name = "inflections" version = "1.1.1" @@ -8321,6 +9069,22 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni" version = "0.22.4" @@ -8330,7 +9094,7 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys", + "jni-sys 0.4.1", "log", "simd_cesu8", "thiserror 2.0.21", @@ -8351,6 +9115,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + [[package]] name = "jni-sys" version = "0.4.1" @@ -8501,6 +9274,15 @@ dependencies = [ "cpufeatures 0.3.1", ] +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "keyring-core" version = "1.0.1" @@ -8510,6 +9292,21 @@ dependencies = [ "log", ] +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "kqueue" version = "1.2.1" @@ -8565,6 +9362,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "lazy-js-bundle" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51cdaa5abc885e2d606e6985281ace220778bde190be20aab1ec2346ed8bd1fa" + [[package]] name = "lazy-regex" version = "3.6.2" @@ -8823,6 +9626,12 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + [[package]] name = "local-channel" version = "0.1.5" @@ -8893,6 +9702,12 @@ dependencies = [ "logos-codegen", ] +[[package]] +name = "longest-increasing-subsequence" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" + [[package]] name = "loom" version = "0.7.2" @@ -8961,6 +9776,17 @@ dependencies = [ "twox-hash", ] +[[package]] +name = "macro-string" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "macro_magic" version = "0.5.1" @@ -9009,6 +9835,50 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "manganis" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ed2af894a724a0b6e521f9efe84f98cfdc74a4de6828efef77ffd3b1679fd52" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize 0.8.0-alpha.0", + "jni 0.21.1", + "manganis-core", + "manganis-macro", + "ndk-context", + "objc2", + "thiserror 2.0.21", +] + +[[package]] +name = "manganis-core" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e258c71b137bf48deaffb48b8a8a2f994af9e5c3ca201daa20d41e26b379da11" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize 0.8.0-alpha.0", + "dioxus-cli-config", + "dioxus-core-types", + "serde", + "winnow 0.7.15", +] + +[[package]] +name = "manganis-macro" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0abae8346bbf637e6d21ac3831863bd5a98657517f5cd2d69d651046b95e7ce9" +dependencies = [ + "dunce", + "macro-string", + "manganis-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "matchers" version = "0.2.0" @@ -9130,6 +10000,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memfd" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90" +dependencies = [ + "rustix 1.1.5", +] + [[package]] name = "memmap2" version = "0.9.11" @@ -9446,6 +10325,23 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.5.0", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -9458,12 +10354,36 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9252111cf132ba0929b6f8e030cac2a24b507f3a4d6db6fb2896f27b354c714b" +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + [[package]] name = "ndk-context" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -9734,6 +10654,28 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.3.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -11292,6 +12234,12 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "psm" version = "0.1.32" @@ -11302,6 +12250,16 @@ dependencies = [ "cc", ] +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "pulp" version = "0.22.3" @@ -11408,7 +12366,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "socket2", "thiserror 2.0.21", @@ -11431,7 +12389,7 @@ dependencies = [ "rand 0.10.3", "rand_pcg", "ring", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -11636,6 +12594,12 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "rayon" version = "1.12.0" @@ -11855,6 +12819,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "cookie 0.18.2", + "cookie_store", "futures-core", "futures-util", "h2 0.4.20", @@ -11866,6 +12832,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -12181,7 +13148,7 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "249896ab27ed630590971738264baa8f722f18965d2e387c706c40a3c2a572cc" dependencies = [ - "async-tungstenite", + "async-tungstenite 0.34.1", "rustls-native-certs", "rustls-pki-types", "rustls-webpki", @@ -12196,7 +13163,7 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3767dde22a804ea806107d0f4331e33cb794d1b8468b11c86fb0c1366f07054" dependencies = [ - "async-tungstenite", + "async-tungstenite 0.34.1", "bytes", "fixedbitset", "flume", @@ -12221,7 +13188,7 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfa6ddcc7a7dd5688f9bf78d8f81cb94f367bce56c055d8d94cf81ecb0518bf" dependencies = [ - "async-tungstenite", + "async-tungstenite 0.34.1", "bytes", "fixedbitset", "flume", @@ -12338,6 +13305,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -12468,7 +13441,7 @@ checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.22.4", "log", "once_cell", "rustls", @@ -12854,6 +13827,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_qs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.21", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -13344,12 +14328,42 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "sledgehammer_bindgen" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e83e178d176459c92bc129cfd0958afac3ced925471b889b3a75546cfc4133" +dependencies = [ + "sledgehammer_bindgen_macro", + "wasm-bindgen", +] + +[[package]] +name = "sledgehammer_bindgen_macro" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sledgehammer_utils" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae" +dependencies = [ + "rustc-hash 1.1.0", +] + [[package]] name = "slotmap" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" dependencies = [ + "serde", "version_check", ] @@ -13437,7 +14451,7 @@ dependencies = [ "data-encoding", "debugid", "if_chain", - "rustc-hash", + "rustc-hash 2.1.3", "serde", "serde_json", "unicode-id-start", @@ -13844,6 +14858,34 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "subsecond" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d350d5788fa94d560d92269266a50efc77b036bfca6675e420b64df7b3211f37" +dependencies = [ + "js-sys", + "libc", + "libloading", + "memfd", + "memmap2", + "serde", + "subsecond-types", + "thiserror 2.0.21", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "subsecond-types" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf32d66269b5fbb8558334e8d22b657b2f7af0dd2ef56c231541099a862e464" +dependencies = [ + "serde", +] + [[package]] name = "subtle" version = "2.6.1" @@ -14867,6 +15909,17 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + [[package]] name = "trait-variant" version = "0.1.3" @@ -14893,6 +15946,40 @@ dependencies = [ "core_maths", ] +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http 1.5.0", + "httparse", + "log", + "rand 0.9.5", + "sha1 0.10.7", + "thiserror 2.0.21", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http 1.5.0", + "httparse", + "log", + "rand 0.9.5", + "sha1 0.10.7", + "thiserror 2.0.21", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.29.0" @@ -15277,6 +16364,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8-width" version = "0.1.9" @@ -15498,6 +16591,28 @@ dependencies = [ "try-lock", ] +[[package]] +name = "warnings" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f68998838dab65727c9b30465595c6f7c953313559371ca8bf31759b3680ad" +dependencies = [ + "pin-project", + "tracing", + "warnings-macro", +] + +[[package]] +name = "warnings-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -15965,6 +17080,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -16001,6 +17125,21 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -16052,6 +17191,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -16064,6 +17209,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -16076,6 +17227,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -16100,6 +17257,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -16112,6 +17275,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -16124,6 +17293,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -16136,6 +17311,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -16308,6 +17489,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "xxhash-rust" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" + [[package]] name = "y4m" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index f9528ebf60..5e3d1e13b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,7 @@ members = [ "core/simulator", "core/system_stats", "core/tools", + "core/web_ui", "examples/rust", "gateways/kafka", "gateways/kafka/tools/kafka-tool", @@ -167,6 +168,7 @@ dashmap = "6.2.1" deltalake = { version = "1.1.1", features = ["azure", "gcs", "s3"] } derive_builder = "0.20.2" derive_more = { version = "2.1.1", features = ["full"] } +dioxus = { version = "0.7.10", features = ["router"] } dircpy = "0.3.21" dirs = "7.0.0" dlopen2 = "0.9.0" @@ -199,6 +201,8 @@ gcloud-googleapis = { version = "1.3.0", features = ["bigquery"] } getrandom = { version = "0.4", features = ["wasm_js"] } git2 = { version = "0.21.0", default-features = false, features = ["vendored-libgit2"] } gloo = "0.12" +gloo-net = { version = "0.7.0", default-features = false, features = ["http", "json"] } +gloo-storage = "0.4.0" governor = "0.10.4" harness_derive = { path = "core/harness_derive" } hash32 = "1.0.0" diff --git a/core/web_ui/Cargo.toml b/core/web_ui/Cargo.toml new file mode 100644 index 0000000000..fe871779a4 --- /dev/null +++ b/core/web_ui/Cargo.toml @@ -0,0 +1,41 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +[package] +name = "iggy-web-ui" +license = "Apache-2.0" +version = "0.1.0" +edition = "2024" +publish = false + +[features] +default = ["web"] +web = ["dioxus/web"] + +[dependencies] +base64 = { workspace = true } +chrono = { workspace = true } +dioxus = { workspace = true } +gloo-net = { workspace = true } +gloo-storage = { workspace = true } +js-sys = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[lints] +workspace = true diff --git a/core/web_ui/Dioxus.toml b/core/web_ui/Dioxus.toml new file mode 100644 index 0000000000..661b5bf335 --- /dev/null +++ b/core/web_ui/Dioxus.toml @@ -0,0 +1,25 @@ +# 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. + +[application] +name = "iggy-web-ui" +default_platform = "web" + +[web.app] +title = "Apache Iggy" +# The server embeds the bundle and serves it under /ui. +base_path = "ui" diff --git a/core/web_ui/assets/favicon.svg b/core/web_ui/assets/favicon.svg new file mode 100644 index 0000000000..7048812d45 --- /dev/null +++ b/core/web_ui/assets/favicon.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/web_ui/assets/iggy-apache-darkbg.svg b/core/web_ui/assets/iggy-apache-darkbg.svg new file mode 100644 index 0000000000..a03645fb3e --- /dev/null +++ b/core/web_ui/assets/iggy-apache-darkbg.svg @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/web_ui/assets/main.css b/core/web_ui/assets/main.css new file mode 100644 index 0000000000..552ba14f08 --- /dev/null +++ b/core/web_ui/assets/main.css @@ -0,0 +1,317 @@ +/* + * 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. + */ + +:root { + --bg: #1e1f22; + --surface: #2b2d31; + --surface-raised: #313338; + --border: #404249; + --text: #f2f3f5; + --text-muted: #949ba4; + --accent: #32ad84; + --accent-hover: #5abd9c; + --danger: #f23f43; + color-scheme: dark; + font-family: Inter, system-ui, -apple-system, 'Segoe UI', sans-serif; + font-size: 14px; + background: var(--bg); + color: var(--text); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + color: var(--accent-hover); + text-decoration: underline; +} + +h1 { + margin: 0; + font-size: 1.6rem; + font-weight: 600; +} + +h2 { + margin: 2rem 0 0.75rem; + font-size: 1.1rem; + font-weight: 600; +} + +button { + padding: 0.55rem 1rem; + border: none; + border-radius: 6px; + background: var(--accent); + color: #fff; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +button:hover:not(:disabled) { + background: var(--accent-hover); +} + +button:disabled { + opacity: 0.45; + cursor: default; +} + +button.secondary { + background: var(--surface-raised); + color: var(--text); + border: 1px solid var(--border); +} + +button.secondary:hover:not(:disabled) { + background: var(--border); +} + +input { + width: 100%; + margin-top: 0.35rem; + padding: 0.55rem 0.7rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--text); + font: inherit; +} + +input:focus { + outline: 2px solid var(--accent); + outline-offset: -1px; +} + +.muted { + color: var(--text-muted); +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85rem; +} + +.auth { + display: grid; + min-height: 100vh; + place-items: center; + padding: 1rem; +} + +.auth-card { + display: flex; + flex-direction: column; + gap: 1rem; + width: min(380px, 100%); + padding: 2rem; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); +} + +.auth-card label { + color: var(--text-muted); + font-size: 0.85rem; +} + +.auth-logo { + width: 70%; + margin: 0 auto 0.5rem; +} + +.form-error, +.error-box { + margin: 0; + color: var(--danger); +} + +.error-box { + padding: 0.75rem 1rem; + border: 1px solid var(--danger); + border-radius: 8px; +} + +.dashboard { + display: grid; + grid-template-columns: 220px 1fr; + min-height: 100vh; +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: 1.25rem 1rem; + border-right: 1px solid var(--border); + background: var(--surface); +} + +.sidebar nav { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sidebar-logo { + width: 100%; +} + +.sidebar-footer { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: auto; +} + +.nav-link { + padding: 0.5rem 0.75rem; + border-radius: 6px; + color: var(--text-muted); +} + +.nav-link:hover { + background: var(--surface-raised); + color: var(--text); + text-decoration: none; +} + +.nav-link.active { + background: var(--surface-raised); + color: var(--text); +} + +.content { + min-width: 0; + padding: 2rem 2.5rem; +} + +.page-header { + margin-bottom: 1.5rem; +} + +.breadcrumbs { + display: flex; + gap: 0.4rem; + min-height: 1.2rem; + margin-bottom: 0.4rem; + font-size: 0.85rem; +} + +.crumb-separator { + color: var(--text-muted); +} + +.stats { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 0.75rem; +} + +.stat { + display: flex; + flex-direction: column; + gap: 0.3rem; + padding: 0.85rem 1rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} + +.stat-label { + color: var(--text-muted); + font-size: 0.8rem; +} + +.stat-value { + font-size: 1.05rem; + font-weight: 600; +} + +table { + width: 100%; + border-collapse: collapse; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} + +th, +td { + padding: 0.6rem 0.85rem; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; +} + +th { + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 500; +} + +tbody tr:last-child td { + border-bottom: none; +} + +tbody tr:hover { + background: var(--surface-raised); +} + +.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.pager { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.pager .muted { + margin-right: auto; +} + +details summary { + cursor: pointer; +} + +details pre { + max-height: 320px; + margin: 0.5rem 0 0; + padding: 0.75rem; + overflow: auto; + border-radius: 6px; + background: var(--bg); + white-space: pre-wrap; + word-break: break-all; +} diff --git a/core/web_ui/src/api.rs b/core/web_ui/src/api.rs new file mode 100644 index 0000000000..4551b41c60 --- /dev/null +++ b/core/web_ui/src/api.rs @@ -0,0 +1,184 @@ +// 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. + +use gloo_net::http::{Request, RequestBuilder, Response}; +use serde::Deserialize; +use serde::de::DeserializeOwned; + +use crate::auth::Session; +use crate::models::{ + IdentityInfo, LoginRequest, PolledMessages, Stream, StreamDetails, TopicDetails, UserDetails, +}; + +/// Empty in the embedded build so requests go to the server that served the +/// UI. Set at build time to point `dx serve` at a separately running server. +const API_BASE_URL: &str = match option_env!("IGGY_WEB_UI_API_URL") { + Some(url) => url, + None => "", +}; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum ApiError { + /// The session is not valid any more. + #[error("session expired or not authorized")] + Unauthorized, + /// The account lacks a permission. The session stays valid. + #[error("you do not have permission to do this")] + Forbidden, + #[error("not found")] + NotFound, + /// The server rejected the request and said why. + #[error("{0}")] + Rejected(String), + #[error("server responded with {status}: {body}")] + Status { status: u16, body: String }, + #[error("request failed: {0}")] + Network(String), + #[error("unexpected response: {0}")] + Decode(String), +} + +/// Thin client for the server's HTTP API, scoped to one session. +#[derive(Debug, Clone, Default)] +pub struct ApiClient { + access_token: Option, +} + +impl ApiClient { + pub fn new(session: Option<&Session>) -> Self { + Self { + access_token: session.map(|session| session.access_token.clone()), + } + } + + pub async fn login(&self, credentials: &LoginRequest) -> Result { + let request = self + .builder(Request::post(&url("/users/login"))) + .json(credentials) + .map_err(|error| ApiError::Decode(error.to_string()))?; + let response = request + .send() + .await + .map_err(|error| ApiError::Network(error.to_string()))?; + decode(response).await + } + + pub async fn logout(&self) -> Result<(), ApiError> { + let response = self + .builder(Request::delete(&url("/users/logout"))) + .send() + .await + .map_err(|error| ApiError::Network(error.to_string()))?; + check_status(response).await.map(drop) + } + + pub async fn user(&self, user_id: u32) -> Result { + self.get(&format!("/users/{user_id}"), &[]).await + } + + pub async fn streams(&self) -> Result, ApiError> { + self.get("/streams", &[]).await + } + + pub async fn stream(&self, stream_id: u32) -> Result { + self.get(&format!("/streams/{stream_id}"), &[]).await + } + + pub async fn topic(&self, stream_id: u32, topic_id: u32) -> Result { + self.get(&format!("/streams/{stream_id}/topics/{topic_id}"), &[]) + .await + } + + /// Reads without committing, so browsing never moves a consumer offset. + pub async fn poll_messages( + &self, + stream_id: u32, + topic_id: u32, + partition_id: u32, + offset: u64, + count: u32, + ) -> Result { + let query = [ + ("kind", "offset".to_owned()), + ("value", offset.to_string()), + ("count", count.to_string()), + ("auto_commit", "false".to_owned()), + ("partition_id", partition_id.to_string()), + ]; + self.get( + &format!("/streams/{stream_id}/topics/{topic_id}/messages"), + &query, + ) + .await + } + + async fn get( + &self, + path: &str, + query: &[(&str, String)], + ) -> Result { + let builder = + Request::get(&url(path)).query(query.iter().map(|(key, value)| (*key, value.as_str()))); + let response = self + .builder(builder) + .send() + .await + .map_err(|error| ApiError::Network(error.to_string()))?; + decode(response).await + } + + fn builder(&self, builder: RequestBuilder) -> RequestBuilder { + match &self.access_token { + Some(token) => builder.header("Authorization", &format!("Bearer {token}")), + None => builder, + } + } +} + +fn url(path: &str) -> String { + format!("{API_BASE_URL}{path}") +} + +async fn decode(response: Response) -> Result { + check_status(response) + .await? + .json::() + .await + .map_err(|error| ApiError::Decode(error.to_string())) +} + +async fn check_status(response: Response) -> Result { + match response.status() { + 200..=299 => Ok(response), + 401 => Err(ApiError::Unauthorized), + 403 => Err(ApiError::Forbidden), + 404 => Err(ApiError::NotFound), + status => { + let body = response.text().await.unwrap_or_default(); + Err(match serde_json::from_str::(&body) { + Ok(error) => ApiError::Rejected(error.reason), + Err(_) => ApiError::Status { status, body }, + }) + } + } +} + +/// The JSON body the server attaches to rejected requests. +#[derive(Deserialize)] +struct ErrorBody { + reason: String, +} diff --git a/core/web_ui/src/auth.rs b/core/web_ui/src/auth.rs new file mode 100644 index 0000000000..47e01c1d9b --- /dev/null +++ b/core/web_ui/src/auth.rs @@ -0,0 +1,77 @@ +// 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. + +use gloo_storage::{LocalStorage, Storage}; +use serde::{Deserialize, Serialize}; + +use crate::models::IdentityInfo; + +const SESSION_KEY: &str = "iggy_session"; + +/// The signed-in user's token, persisted so a reload keeps the session. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Session { + pub user_id: u32, + pub access_token: String, + /// Unix seconds, as returned by `/users/login`. + pub expiry: u64, +} + +impl Session { + /// Restores the stored session, dropping it if the token has expired. + pub fn restore() -> Option { + let session: Self = LocalStorage::get(SESSION_KEY).ok()?; + if session.is_expired() { + Self::clear(); + return None; + } + Some(session) + } + + pub fn persist(&self) { + // A failed write only costs the user a sign-in after the next reload. + let _ = LocalStorage::set(SESSION_KEY, self); + } + + pub fn clear() { + LocalStorage::delete(SESSION_KEY); + } + + fn is_expired(&self) -> bool { + let now_secs = (js_sys::Date::now() / 1000.0) as u64; + self.expiry <= now_secs + } +} + +impl TryFrom for Session { + type Error = MissingToken; + + fn try_from(identity: IdentityInfo) -> Result { + let token = identity.access_token.ok_or(MissingToken)?; + Ok(Self { + user_id: identity.user_id, + access_token: token.token, + expiry: token.expiry, + }) + } +} + +/// The server answered a login without an access token, which happens when +/// JWT auth is disabled for HTTP. +#[derive(Debug, Clone, Copy, thiserror::Error)] +#[error("the server did not return an access token")] +pub struct MissingToken; diff --git a/core/web_ui/src/components.rs b/core/web_ui/src/components.rs new file mode 100644 index 0000000000..ef92ae7530 --- /dev/null +++ b/core/web_ui/src/components.rs @@ -0,0 +1,86 @@ +// 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. + +use dioxus::prelude::*; + +use crate::Route; +use crate::api::{ApiClient, ApiError}; +use crate::auth::Session; + +pub fn use_session() -> Signal> { + use_context() +} + +pub fn api_client(session: Signal>) -> ApiClient { + ApiClient::new(session.read().as_ref()) +} + +#[component] +pub fn PageHeader(title: String, children: Element) -> Element { + rsx! { + header { class: "page-header", + nav { class: "breadcrumbs", {children} } + h1 { "{title}" } + } + } +} + +#[component] +pub fn Crumb(to: Route, label: String) -> Element { + rsx! { + Link { to, "{label}" } + span { class: "crumb-separator", "/" } + } +} + +#[component] +pub fn Stat(label: String, value: String) -> Element { + rsx! { + div { class: "stat", + span { class: "stat-label", "{label}" } + span { class: "stat-value", "{value}" } + } + } +} + +#[component] +pub fn Loading() -> Element { + rsx! { + p { class: "muted", "Loading…" } + } +} + +/// Renders a failed request. A 401 signs the user out, and the dashboard +/// layout then redirects to sign-in. A 403 only shows the message. +#[component] +pub fn ErrorView(error: ApiError) -> Element { + let mut session = use_session(); + let unauthorized = error == ApiError::Unauthorized; + + // Reactive on the prop, because a mounted view can change from another + // error to a 401 without remounting. + use_effect(use_reactive!(|unauthorized| { + if unauthorized { + Session::clear(); + session.set(None); + } + })); + + rsx! { + div { class: "error-box", "{error}" } + } +} diff --git a/core/web_ui/src/format.rs b/core/web_ui/src/format.rs new file mode 100644 index 0000000000..f378f71654 --- /dev/null +++ b/core/web_ui/src/format.rs @@ -0,0 +1,238 @@ +// 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. + +use std::fmt::Write; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use chrono::DateTime; + +use crate::models::ResourceOptions; + +const MICROS_PER_SECOND: u64 = 1_000_000; + +/// Formats a server timestamp (Unix microseconds) in UTC. +pub fn timestamp(micros: u64) -> String { + format_micros(micros, "%Y-%m-%d %H:%M:%S UTC") +} + +/// Like [`timestamp`], keeping the microseconds that order messages. +pub fn timestamp_precise(micros: u64) -> String { + format_micros(micros, "%Y-%m-%d %H:%M:%S%.6f UTC") +} + +/// Renders the `message_expiry` option (microseconds, 0 or `u64::MAX` means +/// never) as a short duration. +pub fn message_expiry(options: &ResourceOptions) -> String { + let micros = options + .get("message_expiry") + .and_then(|option| option.value.parse::().ok()) + .unwrap_or(0); + if micros == 0 || micros == u64::MAX { + return "never".to_owned(); + } + duration(micros / MICROS_PER_SECOND) +} + +/// Shows the payload as text when it is UTF-8 and as hex otherwise. +pub fn payload(base64_payload: &str) -> String { + match STANDARD.decode(base64_payload) { + Ok(bytes) => text_or_hex(bytes), + Err(_) => base64_payload.to_owned(), + } +} + +/// The first `max_chars` characters of [`payload`], decoding only as much of +/// the payload as that needs, so a list of large messages stays cheap. +pub fn payload_preview(base64_payload: &str, max_chars: usize) -> String { + // A character is at most 4 bytes of UTF-8, and every 3 bytes take 4 + // base64 characters, so this prefix always holds `max_chars` characters. + let prefix_len = (max_chars * 4).div_ceil(3) * 4; + if base64_payload.len() <= prefix_len { + return truncate(&payload(base64_payload), max_chars); + } + let Ok(bytes) = STANDARD.decode(&base64_payload[..prefix_len]) else { + return truncate(base64_payload, max_chars); + }; + let preview = match String::from_utf8(bytes) { + Ok(text) => text, + // The cut can split the last character, which is still text. + Err(error) if error.utf8_error().error_len().is_none() => { + let valid = error.utf8_error().valid_up_to(); + let mut bytes = error.into_bytes(); + bytes.truncate(valid); + String::from_utf8(bytes).unwrap_or_default() + } + Err(error) => hex(error.as_bytes()), + }; + // The payload goes on past the prefix, so the preview always ends in "…". + match truncate(&preview, max_chars) { + cut if cut.ends_with('…') => cut, + whole => format!("{whole}…"), + } +} + +/// Message IDs are 128-bit and usually UUIDs, so they read best in that form. +pub fn message_id(id: u128) -> String { + let hex = format!("{id:032x}"); + format!( + "{}-{}-{}-{}-{}", + &hex[..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..] + ) +} + +fn text_or_hex(bytes: Vec) -> String { + String::from_utf8(bytes).unwrap_or_else(|error| hex(error.as_bytes())) +} + +fn hex(bytes: &[u8]) -> String { + let mut text = String::with_capacity(bytes.len() * 3); + for (index, byte) in bytes.iter().enumerate() { + if index > 0 { + text.push(' '); + } + // Writing into a String cannot fail. + let _ = write!(text, "{byte:02x}"); + } + text +} + +pub fn truncate(text: &str, max_chars: usize) -> String { + match text.char_indices().nth(max_chars) { + Some((cut, _)) => format!("{}…", &text[..cut]), + None => text.to_owned(), + } +} + +fn format_micros(micros: u64, pattern: &str) -> String { + i64::try_from(micros) + .ok() + .and_then(DateTime::from_timestamp_micros) + .map(|date| date.format(pattern).to_string()) + .unwrap_or_else(|| micros.to_string()) +} + +fn duration(total_seconds: u64) -> String { + const UNITS: [(u64, &str); 4] = [(86_400, "d"), (3_600, "h"), (60, "m"), (1, "s")]; + let mut remaining = total_seconds; + let parts: Vec = UNITS + .iter() + .filter_map(|&(unit_seconds, suffix)| { + let amount = remaining / unit_seconds; + remaining %= unit_seconds; + (amount > 0).then(|| format!("{amount}{suffix}")) + }) + .collect(); + if parts.is_empty() { + "0s".to_owned() + } else { + parts.join(" ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::OptionValue; + + fn expiry_options(micros: u64) -> ResourceOptions { + ResourceOptions::from([( + "message_expiry".to_owned(), + OptionValue { + value: micros.to_string(), + kind: "uint64".to_owned(), + explicit: true, + }, + )]) + } + + #[test] + fn given_zero_or_max_expiry_should_render_never() { + assert_eq!(message_expiry(&expiry_options(0)), "never"); + assert_eq!(message_expiry(&expiry_options(u64::MAX)), "never"); + assert_eq!(message_expiry(&ResourceOptions::new()), "never"); + } + + #[test] + fn given_expiry_in_micros_should_render_largest_units_first() { + let micros = (86_400 + 3_600 + 5) * MICROS_PER_SECOND; + assert_eq!(message_expiry(&expiry_options(micros)), "1d 1h 5s"); + } + + #[test] + fn given_utf8_payload_should_render_text() { + assert_eq!(payload(&STANDARD.encode("hello")), "hello"); + } + + #[test] + fn given_binary_payload_should_render_hex() { + assert_eq!(payload(&STANDARD.encode([0xff, 0x00, 0x10])), "ff 00 10"); + } + + #[test] + fn given_message_id_should_render_uuid_form() { + assert_eq!( + message_id(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef), + "01234567-89ab-cdef-0123-456789abcdef" + ); + assert_eq!(message_id(1), "00000000-0000-0000-0000-000000000001"); + } + + #[test] + fn given_long_payload_should_preview_from_a_prefix() { + let text = "zażółć gęślą jaźń ".repeat(20); + let preview = payload_preview(&STANDARD.encode(&text), 10); + assert_eq!(preview, "zażółć gęś…"); + + let binary: Vec = (0xf8..=0xff).cycle().take(4096).collect(); + let preview = payload_preview(&STANDARD.encode(&binary), 11); + assert_eq!(preview, "f8 f9 fa fb…"); + } + + #[test] + fn given_short_payload_should_preview_all_of_it() { + assert_eq!(payload_preview(&STANDARD.encode("hello"), 60), "hello"); + assert_eq!(payload_preview(&STANDARD.encode([0xff, 0x00]), 60), "ff 00"); + } + + #[test] + fn given_prefix_cut_inside_a_character_should_keep_it_as_text() { + // 60 two-byte characters make a prefix cut inside a character likely. + let text = "ł".repeat(400); + let preview = payload_preview(&STANDARD.encode(&text), 60); + assert_eq!(preview, format!("{}…", "ł".repeat(60))); + } + + #[test] + fn given_long_text_should_truncate_on_char_boundary() { + assert_eq!(truncate("zażółć", 3), "zaż…"); + assert_eq!(truncate("short", 10), "short"); + } + + #[test] + fn given_micros_should_format_utc() { + assert_eq!(timestamp(1_700_000_000_123_456), "2023-11-14 22:13:20 UTC"); + assert_eq!( + timestamp_precise(1_700_000_000_123_456), + "2023-11-14 22:13:20.123456 UTC" + ); + } +} diff --git a/core/web_ui/src/lib.rs b/core/web_ui/src/lib.rs new file mode 100644 index 0000000000..b9c0a66eee --- /dev/null +++ b/core/web_ui/src/lib.rs @@ -0,0 +1,70 @@ +// 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. + +mod api; +mod auth; +mod components; +mod format; +mod models; +mod pages; + +use dioxus::prelude::*; + +use crate::auth::Session; +use crate::pages::{DashboardLayout, Messages, NotFound, SignIn, StreamPage, Streams, TopicPage}; + +const STYLESHEET: Asset = asset!("/assets/main.css"); +const FAVICON: Asset = asset!("/assets/favicon.svg"); + +#[derive(Debug, Clone, PartialEq, Routable)] +#[rustfmt::skip] +pub enum Route { + #[redirect("/", || Route::SignIn {})] + #[route("/auth/sign-in")] + SignIn {}, + #[layout(DashboardLayout)] + #[redirect("/dashboard", || Route::Streams {})] + #[route("/dashboard/streams")] + Streams {}, + #[route("/dashboard/streams/:stream_id")] + StreamPage { stream_id: u32 }, + #[route("/dashboard/streams/:stream_id/topics/:topic_id")] + TopicPage { stream_id: u32, topic_id: u32 }, + #[route("/dashboard/streams/:stream_id/topics/:topic_id/partitions/:partition_id/messages")] + Messages { stream_id: u32, topic_id: u32, partition_id: u32 }, + #[end_layout] + #[route("/:..segments")] + NotFound { segments: Vec }, +} + +/// Starts the app in the browser. The binary is a thin wrapper so that tools +/// such as the license manifest, which only follow library targets, see the +/// crate's dependencies. +pub fn launch() { + dioxus::launch(App); +} + +#[component] +fn App() -> Element { + use_context_provider(|| Signal::new(Session::restore())); + + rsx! { + document::Link { rel: "icon", r#type: "image/svg+xml", href: FAVICON } + document::Stylesheet { href: STYLESHEET } + Router:: {} + } +} diff --git a/core/web_ui/src/main.rs b/core/web_ui/src/main.rs new file mode 100644 index 0000000000..832ad45028 --- /dev/null +++ b/core/web_ui/src/main.rs @@ -0,0 +1,20 @@ +// 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. + +fn main() { + iggy_web_ui::launch(); +} diff --git a/core/web_ui/src/models.rs b/core/web_ui/src/models.rs new file mode 100644 index 0000000000..afeb4189e1 --- /dev/null +++ b/core/web_ui/src/models.rs @@ -0,0 +1,143 @@ +// 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. + +//! Mirrors of the server's HTTP JSON shapes. +//! +//! `iggy_common` owns the real types but pulls tokio with `net`, which does +//! not build for `wasm32-unknown-unknown`, so the UI keeps its own copies of +//! the fields it renders. Unknown fields are ignored on purpose. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct IdentityInfo { + pub user_id: u32, + pub access_token: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TokenInfo { + pub token: String, + pub expiry: u64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct UserDetails { + pub id: u32, + pub username: String, +} + +/// One entry of a resource's `options` map, as written by +/// `iggy_common::resource_options_json`. +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct OptionValue { + pub value: String, + pub kind: String, + pub explicit: bool, +} + +pub type ResourceOptions = BTreeMap; + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct Stream { + pub id: u32, + pub created_at: u64, + pub name: String, + pub size: String, + pub messages_count: u64, + pub topics_count: u32, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct StreamDetails { + pub id: u32, + pub created_at: u64, + pub name: String, + pub size: String, + pub messages_count: u64, + pub topics_count: u32, + pub topics: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct Topic { + pub id: u32, + pub created_at: u64, + pub name: String, + pub size: String, + pub messages_count: u64, + pub partitions_count: u32, + #[serde(default)] + pub options: ResourceOptions, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct TopicDetails { + pub id: u32, + pub created_at: u64, + pub name: String, + pub size: String, + pub messages_count: u64, + pub partitions_count: u32, + pub partitions: Vec, + #[serde(default)] + pub options: ResourceOptions, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct Partition { + pub id: u32, + pub created_at: u64, + pub segments_count: u32, + pub current_offset: u64, + pub size: String, + pub messages_count: u64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct PolledMessages { + pub partition_id: u32, + pub current_offset: u64, + pub count: u32, + pub messages: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct Message { + pub header: MessageHeader, + /// Standard padded base64. + pub payload: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct MessageHeader { + pub checksum: u64, + pub id: u128, + pub offset: u64, + pub timestamp: u64, + pub origin_timestamp: u64, + pub user_headers_length: u32, + pub payload_length: u32, +} diff --git a/core/web_ui/src/pages/layout.rs b/core/web_ui/src/pages/layout.rs new file mode 100644 index 0000000000..72ab991882 --- /dev/null +++ b/core/web_ui/src/pages/layout.rs @@ -0,0 +1,76 @@ +// 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. + +use dioxus::prelude::*; + +use crate::Route; +use crate::auth::Session; +use crate::components::{api_client, use_session}; + +const LOGO: Asset = asset!("/assets/iggy-apache-darkbg.svg"); + +/// Shell for every signed-in page. Without a session it redirects to sign-in. +#[component] +pub fn DashboardLayout() -> Element { + let mut session = use_session(); + let navigator = navigator(); + + use_effect(move || { + if session.read().is_none() { + navigator.replace(Route::SignIn {}); + } + }); + + let user = use_resource(move || async move { + let user_id = session.read().as_ref()?.user_id; + api_client(session).user(user_id).await.ok() + }); + + let sign_out = move |_| async move { + // The token is dropped locally either way, so a failed revoke only + // leaves it valid on the server until it expires. + let _ = api_client(session).logout().await; + Session::clear(); + session.set(None); + }; + + if session.read().is_none() { + return rsx! {}; + } + + let username = user + .read() + .as_ref() + .and_then(|user| user.as_ref().map(|user| user.username.clone())) + .unwrap_or_default(); + + rsx! { + div { class: "dashboard", + aside { class: "sidebar", + img { class: "sidebar-logo", src: LOGO, alt: "Apache Iggy" } + nav { + Link { class: "nav-link", active_class: "active", to: Route::Streams {}, "Streams" } + } + div { class: "sidebar-footer", + span { class: "muted", "{username}" } + button { class: "secondary", onclick: sign_out, "Sign out" } + } + } + main { class: "content", Outlet:: {} } + } + } +} diff --git a/core/web_ui/src/pages/messages.rs b/core/web_ui/src/pages/messages.rs new file mode 100644 index 0000000000..c75850fc1c --- /dev/null +++ b/core/web_ui/src/pages/messages.rs @@ -0,0 +1,174 @@ +// 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. + +use dioxus::prelude::*; + +use crate::Route; +use crate::api::ApiError; +use crate::components::{Crumb, ErrorView, Loading, PageHeader, api_client, use_session}; +use crate::format; +use crate::models::Message; + +const PAGE_SIZE: u32 = 20; +const PAYLOAD_PREVIEW_CHARS: usize = 60; + +#[derive(Debug, Clone, PartialEq)] +struct MessagesPage { + topic_name: String, + /// Offset of the newest message, `None` when the partition is empty. + last_offset: Option, + /// Offset of the first message shown. Retention can make it later than + /// the offset that was asked for. + first_offset: u64, + /// The server answered with a later offset than requested, so nothing + /// older is left. + at_oldest: bool, + messages: Vec, +} + +#[component] +pub fn Messages(stream_id: u32, topic_id: u32, partition_id: u32) -> Element { + let session = use_session(); + // `None` follows the newest page, so reloading picks up new messages. + let mut requested_offset = use_signal(|| None::); + + let page = use_resource(use_reactive!( + |(stream_id, topic_id, partition_id)| async move { + let client = api_client(session); + let topic = client.topic(stream_id, topic_id).await?; + let partition = topic + .partitions + .iter() + .find(|partition| partition.id == partition_id) + .ok_or(ApiError::NotFound)?; + let last_offset = (partition.messages_count > 0).then_some(partition.current_offset); + let newest_page_start = last_offset + .map(|last| last.saturating_sub(u64::from(PAGE_SIZE) - 1)) + .unwrap_or(0); + let requested = requested_offset().unwrap_or(newest_page_start); + + let polled = client + .poll_messages(stream_id, topic_id, partition_id, requested, PAGE_SIZE) + .await?; + let first_offset = polled + .messages + .first() + .map_or(requested, |message| message.header.offset); + Ok::<_, ApiError>(MessagesPage { + topic_name: topic.name, + last_offset, + first_offset, + at_oldest: first_offset > requested, + messages: polled.messages, + }) + } + )); + + let header = rsx! { + PageHeader { title: format!("Partition {partition_id} messages"), + Crumb { to: Route::Streams {}, label: "Streams" } + Crumb { to: Route::StreamPage { stream_id }, label: format!("Stream {stream_id}") } + Crumb { to: Route::TopicPage { stream_id, topic_id }, label: format!("Topic {topic_id}") } + } + }; + + let body = match &*page.read() { + None => rsx! { Loading {} }, + Some(Err(error)) => rsx! { ErrorView { error: error.clone() } }, + Some(Ok(page)) if page.last_offset.is_none() => rsx! { + p { class: "muted", "Topic {page.topic_name} has no messages in this partition." } + }, + Some(Ok(page)) => { + let first_offset = page.first_offset; + let last_offset = page.last_offset.unwrap_or(0); + let page_size = u64::from(PAGE_SIZE); + let has_older = first_offset > 0 && !page.at_oldest; + let has_newer = first_offset + page_size <= last_offset; + let shown_last_offset = page + .messages + .last() + .map_or(first_offset, |message| message.header.offset); + rsx! { + div { class: "pager", + span { class: "muted", + "Topic {page.topic_name}, showing offsets {first_offset} to {shown_last_offset} of {last_offset}" + } + button { + class: "secondary", + disabled: !has_older, + onclick: move |_| requested_offset.set(Some(first_offset.saturating_sub(page_size))), + "Older" + } + button { + class: "secondary", + disabled: !has_newer, + onclick: move |_| requested_offset.set(Some(first_offset + page_size)), + "Newer" + } + button { class: "secondary", onclick: move |_| requested_offset.set(None), "Latest" } + } + table { + thead { + tr { + th { class: "num", "Offset" } + th { "Timestamp" } + th { "ID" } + th { class: "num", "Bytes" } + th { "Payload" } + } + } + tbody { + for message in page.messages.iter().rev() { + MessageRow { key: "{message.header.offset}", message: message.clone() } + } + } + } + } + } + }; + + rsx! { + {header} + {body} + } +} + +#[component] +fn MessageRow(message: Message) -> Element { + // The full payload can be up to 64 MB, so it is decoded only once the + // row is opened. + let mut open = use_signal(|| false); + let preview = format::payload_preview(&message.payload, PAYLOAD_PREVIEW_CHARS); + let header = &message.header; + + rsx! { + tr { + td { class: "num", "{header.offset}" } + td { {format::timestamp_precise(header.timestamp)} } + td { class: "mono", {format::message_id(header.id)} } + td { class: "num", "{header.payload_length}" } + td { + details { ontoggle: move |_| open.toggle(), + summary { class: "mono", "{preview}" } + if open() { + pre { {format::payload(&message.payload)} } + } + } + } + } + } +} diff --git a/core/web_ui/src/pages/mod.rs b/core/web_ui/src/pages/mod.rs new file mode 100644 index 0000000000..6fd17b98c4 --- /dev/null +++ b/core/web_ui/src/pages/mod.rs @@ -0,0 +1,30 @@ +// 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. + +mod layout; +mod messages; +mod not_found; +mod sign_in; +mod streams; +mod topic; + +pub use layout::DashboardLayout; +pub use messages::Messages; +pub use not_found::NotFound; +pub use sign_in::SignIn; +pub use streams::{StreamPage, Streams}; +pub use topic::TopicPage; diff --git a/core/web_ui/src/pages/not_found.rs b/core/web_ui/src/pages/not_found.rs new file mode 100644 index 0000000000..083d3eb6c8 --- /dev/null +++ b/core/web_ui/src/pages/not_found.rs @@ -0,0 +1,34 @@ +// 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. + +use dioxus::prelude::*; + +use crate::Route; + +#[component] +pub fn NotFound(segments: Vec) -> Element { + let path = segments.join("/"); + rsx! { + main { class: "auth", + div { class: "auth-card", + h1 { "Page not found" } + p { class: "muted", "/{path}" } + Link { to: Route::Streams {}, "Back to the dashboard" } + } + } + } +} diff --git a/core/web_ui/src/pages/sign_in.rs b/core/web_ui/src/pages/sign_in.rs new file mode 100644 index 0000000000..f7a4727f7f --- /dev/null +++ b/core/web_ui/src/pages/sign_in.rs @@ -0,0 +1,98 @@ +// 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. + +use dioxus::prelude::*; + +use crate::Route; +use crate::api::ApiClient; +use crate::auth::Session; +use crate::components::use_session; +use crate::models::LoginRequest; + +const LOGO: Asset = asset!("/assets/iggy-apache-darkbg.svg"); + +#[component] +pub fn SignIn() -> Element { + let mut session = use_session(); + let navigator = navigator(); + let mut username = use_signal(String::new); + let mut password = use_signal(String::new); + let mut error = use_signal(|| None::); + let mut submitting = use_signal(|| false); + + use_effect(move || { + if session.read().is_some() { + navigator.replace(Route::Streams {}); + } + }); + + let submit = move |event: FormEvent| async move { + event.prevent_default(); + submitting.set(true); + let credentials = LoginRequest { + username: username(), + password: password(), + }; + let result = ApiClient::default().login(&credentials).await; + submitting.set(false); + + let signed_in = match result { + Ok(identity) => Session::try_from(identity).map_err(|error| error.to_string()), + Err(error) => Err(error.to_string()), + }; + match signed_in { + Ok(new_session) => { + new_session.persist(); + session.set(Some(new_session)); + } + Err(message) => error.set(Some(message)), + } + }; + + rsx! { + main { class: "auth", + form { class: "auth-card", onsubmit: submit, + img { class: "auth-logo", src: LOGO, alt: "Apache Iggy" } + label { + "Username" + input { + name: "username", + autocomplete: "username", + required: true, + value: username, + oninput: move |event| username.set(event.value()), + } + } + label { + "Password" + input { + name: "password", + r#type: "password", + autocomplete: "current-password", + required: true, + value: password, + oninput: move |event| password.set(event.value()), + } + } + if let Some(message) = error() { + p { class: "form-error", "{message}" } + } + button { r#type: "submit", disabled: submitting(), "Sign in" } + } + } + } +} diff --git a/core/web_ui/src/pages/streams.rs b/core/web_ui/src/pages/streams.rs new file mode 100644 index 0000000000..37dc3df4ae --- /dev/null +++ b/core/web_ui/src/pages/streams.rs @@ -0,0 +1,135 @@ +// 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. + +use dioxus::prelude::*; + +use crate::Route; +use crate::api::ApiError; +use crate::components::{Crumb, ErrorView, Loading, PageHeader, Stat, api_client, use_session}; +use crate::format; + +#[component] +pub fn Streams() -> Element { + let session = use_session(); + let streams = use_resource(move || async move { + let mut streams = api_client(session).streams().await?; + streams.sort_by_key(|stream| stream.id); + Ok::<_, ApiError>(streams) + }); + + let body = match &*streams.read() { + None => rsx! { Loading {} }, + Some(Err(error)) => rsx! { ErrorView { error: error.clone() } }, + Some(Ok(streams)) if streams.is_empty() => rsx! { p { class: "muted", "No streams yet." } }, + Some(Ok(streams)) => rsx! { + table { + thead { + tr { + th { "ID" } + th { "Name" } + th { class: "num", "Topics" } + th { class: "num", "Messages" } + th { class: "num", "Size" } + th { "Created" } + } + } + tbody { + for stream in streams { + tr { key: "{stream.id}", + td { "{stream.id}" } + td { + Link { to: Route::StreamPage { stream_id: stream.id }, "{stream.name}" } + } + td { class: "num", "{stream.topics_count}" } + td { class: "num", "{stream.messages_count}" } + td { class: "num", "{stream.size}" } + td { {format::timestamp(stream.created_at)} } + } + } + } + } + }, + }; + + rsx! { + PageHeader { title: "Streams" } + {body} + } +} + +#[component] +pub fn StreamPage(stream_id: u32) -> Element { + let session = use_session(); + let stream = use_resource(use_reactive!(|(stream_id,)| async move { + api_client(session).stream(stream_id).await + })); + + let body = match &*stream.read() { + None => rsx! { Loading {} }, + Some(Err(error)) => rsx! { ErrorView { error: error.clone() } }, + Some(Ok(stream)) => rsx! { + PageHeader { title: stream.name.clone(), + Crumb { to: Route::Streams {}, label: "Streams" } + } + div { class: "stats", + Stat { label: "ID", value: stream.id.to_string() } + Stat { label: "Topics", value: stream.topics_count.to_string() } + Stat { label: "Messages", value: stream.messages_count.to_string() } + Stat { label: "Size", value: stream.size.clone() } + Stat { label: "Created", value: format::timestamp(stream.created_at) } + } + h2 { "Topics" } + if stream.topics.is_empty() { + p { class: "muted", "No topics in this stream." } + } else { + table { + thead { + tr { + th { "ID" } + th { "Name" } + th { class: "num", "Partitions" } + th { class: "num", "Messages" } + th { class: "num", "Size" } + th { "Message expiry" } + th { "Created" } + } + } + tbody { + for topic in &stream.topics { + tr { key: "{topic.id}", + td { "{topic.id}" } + td { + Link { + to: Route::TopicPage { stream_id, topic_id: topic.id }, + "{topic.name}" + } + } + td { class: "num", "{topic.partitions_count}" } + td { class: "num", "{topic.messages_count}" } + td { class: "num", "{topic.size}" } + td { {format::message_expiry(&topic.options)} } + td { {format::timestamp(topic.created_at)} } + } + } + } + } + } + }, + }; + + rsx! { {body} } +} diff --git a/core/web_ui/src/pages/topic.rs b/core/web_ui/src/pages/topic.rs new file mode 100644 index 0000000000..fb51194ec6 --- /dev/null +++ b/core/web_ui/src/pages/topic.rs @@ -0,0 +1,83 @@ +// 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. + +use dioxus::prelude::*; + +use crate::Route; +use crate::components::{Crumb, ErrorView, Loading, PageHeader, Stat, api_client, use_session}; +use crate::format; + +#[component] +pub fn TopicPage(stream_id: u32, topic_id: u32) -> Element { + let session = use_session(); + let topic = use_resource(use_reactive!(|(stream_id, topic_id)| async move { + api_client(session).topic(stream_id, topic_id).await + })); + + let body = match &*topic.read() { + None => rsx! { Loading {} }, + Some(Err(error)) => rsx! { ErrorView { error: error.clone() } }, + Some(Ok(topic)) => rsx! { + PageHeader { title: topic.name.clone(), + Crumb { to: Route::Streams {}, label: "Streams" } + Crumb { to: Route::StreamPage { stream_id }, label: format!("Stream {stream_id}") } + } + div { class: "stats", + Stat { label: "ID", value: topic.id.to_string() } + Stat { label: "Partitions", value: topic.partitions_count.to_string() } + Stat { label: "Messages", value: topic.messages_count.to_string() } + Stat { label: "Size", value: topic.size.clone() } + Stat { label: "Message expiry", value: format::message_expiry(&topic.options) } + Stat { label: "Created", value: format::timestamp(topic.created_at) } + } + h2 { "Partitions" } + table { + thead { + tr { + th { "ID" } + th { class: "num", "Current offset" } + th { class: "num", "Segments" } + th { class: "num", "Messages" } + th { class: "num", "Size" } + th { "Created" } + th {} + } + } + tbody { + for partition in &topic.partitions { + tr { key: "{partition.id}", + td { "{partition.id}" } + td { class: "num", "{partition.current_offset}" } + td { class: "num", "{partition.segments_count}" } + td { class: "num", "{partition.messages_count}" } + td { class: "num", "{partition.size}" } + td { {format::timestamp(partition.created_at)} } + td { + Link { + to: Route::Messages { stream_id, topic_id, partition_id: partition.id }, + "Browse messages" + } + } + } + } + } + } + }, + }; + + rsx! { {body} } +} diff --git a/scripts/build-web-ui.sh b/scripts/build-web-ui.sh new file mode 100755 index 0000000000..560de30910 --- /dev/null +++ b/scripts/build-web-ui.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env 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. + +set -Eeuo pipefail + +# Builds the Dioxus web UI into core/web_ui/dist, where iggy-server embeds it. + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CRATE_DIR="${REPO_ROOT}/core/web_ui" + +# dx keeps every previously hashed asset in its staging directory and copies +# all of them into the bundle, so stale builds would end up in the server binary. +rm -rf "${REPO_ROOT}/target/dx/iggy-web-ui" "${CRATE_DIR}/dist" + +cd "${CRATE_DIR}" +dx bundle --platform web --release --out-dir dist From 108e2d15c2435d922b08de5805c564d76c9282a3 Mon Sep 17 00:00:00 2001 From: Billy Doyle <50842911+billy-doyle@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:49:00 -0400 Subject: [PATCH 2/2] fix(web): harden web UI paging, sessions and model mirrors 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 --- Cargo.lock | 3 +- Cargo.toml | 1 - core/web_ui/Cargo.toml | 5 +- core/web_ui/Dioxus.toml | 3 +- core/web_ui/README.md | 51 +++++++ core/web_ui/src/api.rs | 22 +-- core/web_ui/src/auth.rs | 55 +++++--- core/web_ui/src/components.rs | 9 +- core/web_ui/src/format.rs | 14 +- core/web_ui/src/lib.rs | 5 +- core/web_ui/src/models.rs | 226 +++++++++++++++++++++++++++--- core/web_ui/src/pages/layout.rs | 15 +- core/web_ui/src/pages/messages.rs | 206 +++++++++++++++------------ core/web_ui/src/pages/sign_in.rs | 19 +-- core/web_ui/src/pages/topic.rs | 2 +- scripts/build-web-ui.sh | 17 ++- 16 files changed, 477 insertions(+), 176 deletions(-) create mode 100644 core/web_ui/README.md diff --git a/Cargo.lock b/Cargo.lock index 2e6503c658..264deab350 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8084,11 +8084,12 @@ dependencies = [ "chrono", "dioxus", "gloo-net 0.7.0", - "gloo-storage 0.4.0", + "iggy_common", "js-sys", "serde", "serde_json", "thiserror 2.0.21", + "web-sys", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5e3d1e13b9..da5c42b41e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -202,7 +202,6 @@ getrandom = { version = "0.4", features = ["wasm_js"] } git2 = { version = "0.21.0", default-features = false, features = ["vendored-libgit2"] } gloo = "0.12" gloo-net = { version = "0.7.0", default-features = false, features = ["http", "json"] } -gloo-storage = "0.4.0" governor = "0.10.4" harness_derive = { path = "core/harness_derive" } hash32 = "1.0.0" diff --git a/core/web_ui/Cargo.toml b/core/web_ui/Cargo.toml index fe871779a4..a50cc704c3 100644 --- a/core/web_ui/Cargo.toml +++ b/core/web_ui/Cargo.toml @@ -31,11 +31,14 @@ base64 = { workspace = true } chrono = { workspace = true } dioxus = { workspace = true } gloo-net = { workspace = true } -gloo-storage = { workspace = true } js-sys = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +web-sys = { workspace = true, features = ["Storage"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +iggy_common = { workspace = true } [lints] workspace = true diff --git a/core/web_ui/Dioxus.toml b/core/web_ui/Dioxus.toml index 661b5bf335..8954ead77f 100644 --- a/core/web_ui/Dioxus.toml +++ b/core/web_ui/Dioxus.toml @@ -21,5 +21,6 @@ default_platform = "web" [web.app] title = "Apache Iggy" -# The server embeds the bundle and serves it under /ui. +# The switch-over from the Svelte app will have the server embed the bundle +# and serve it under /ui. base_path = "ui" diff --git a/core/web_ui/README.md b/core/web_ui/README.md new file mode 100644 index 0000000000..c98d3f448f --- /dev/null +++ b/core/web_ui/README.md @@ -0,0 +1,51 @@ +# Apache Iggy Web UI + +A [Dioxus](https://dioxuslabs.com/) single-page app for browsing an Iggy +server over its HTTP API. It compiles to `wasm32-unknown-unknown` and is +built with the Dioxus CLI, `dx`. + +## Prerequisites + +```bash +rustup target add wasm32-unknown-unknown +cargo install dioxus-cli --version 0.7.10 --locked +``` + +Keep the `dx` version equal to the `dioxus` version in `Cargo.lock`. + +## Release bundle + +```bash +./scripts/build-web-ui.sh +``` + +The bundle lands in `core/web_ui/dist`, with every asset path under `/ui`. +The script clears dx's staging directory first, because dx copies every +previously hashed asset into the bundle. + +## Development + +Start a server, then serve the UI with hot reload from this directory: + +```bash +cargo run --bin iggy-server -- --with-default-root-credentials +cd core/web_ui +IGGY_WEB_UI_API_URL=http://localhost:3000 dx serve +``` + +Open and sign in as `iggy` / `iggy`. + +`IGGY_WEB_UI_API_URL` is read at build time and prefixes every API request. +Left unset, requests go to the origin that served the UI, which is what an +embedded build wants. The server's default CORS settings allow any origin, so +the dev server on another port can reach it. + +## Tests + +```bash +cargo test -p iggy-web-ui +``` + +The tests run on the host. Besides the formatting helpers, they serialize +`iggy_common` types and decode them into the UI's mirrors in `src/models.rs`, +so a renamed server field fails here instead of in the browser. diff --git a/core/web_ui/src/api.rs b/core/web_ui/src/api.rs index 4551b41c60..3c294f96b2 100644 --- a/core/web_ui/src/api.rs +++ b/core/web_ui/src/api.rs @@ -21,11 +21,12 @@ use serde::de::DeserializeOwned; use crate::auth::Session; use crate::models::{ - IdentityInfo, LoginRequest, PolledMessages, Stream, StreamDetails, TopicDetails, UserDetails, + IdentityInfo, LoginUser, PolledMessages, Stream, StreamDetails, TopicDetails, UserInfoDetails, }; -/// Empty in the embedded build so requests go to the server that served the -/// UI. Set at build time to point `dx serve` at a separately running server. +/// Empty by default so requests go to the server that served the UI. Set at +/// build time to point `dx serve` at a separately running server, see the +/// crate README. const API_BASE_URL: &str = match option_env!("IGGY_WEB_UI_API_URL") { Some(url) => url, None => "", @@ -65,11 +66,11 @@ impl ApiClient { } } - pub async fn login(&self, credentials: &LoginRequest) -> Result { + pub async fn login(&self, credentials: &LoginUser) -> Result { let request = self .builder(Request::post(&url("/users/login"))) .json(credentials) - .map_err(|error| ApiError::Decode(error.to_string()))?; + .map_err(|error| ApiError::Network(error.to_string()))?; let response = request .send() .await @@ -86,7 +87,7 @@ impl ApiClient { check_status(response).await.map(drop) } - pub async fn user(&self, user_id: u32) -> Result { + pub async fn user(&self, user_id: u32) -> Result { self.get(&format!("/users/{user_id}"), &[]).await } @@ -153,12 +154,15 @@ fn url(path: &str) -> String { format!("{API_BASE_URL}{path}") } +/// Reads the body as bytes, because `Response::json` goes through one JS +/// string, which browsers cap well below a page of large messages. async fn decode(response: Response) -> Result { - check_status(response) + let body = check_status(response) .await? - .json::() + .binary() .await - .map_err(|error| ApiError::Decode(error.to_string())) + .map_err(|error| ApiError::Network(error.to_string()))?; + serde_json::from_slice(&body).map_err(|error| ApiError::Decode(error.to_string())) } async fn check_status(response: Response) -> Result { diff --git a/core/web_ui/src/auth.rs b/core/web_ui/src/auth.rs index 47e01c1d9b..8bb64fb832 100644 --- a/core/web_ui/src/auth.rs +++ b/core/web_ui/src/auth.rs @@ -15,8 +15,8 @@ // specific language governing permissions and limitations // under the License. -use gloo_storage::{LocalStorage, Storage}; use serde::{Deserialize, Serialize}; +use web_sys::Storage; use crate::models::IdentityInfo; @@ -33,10 +33,13 @@ pub struct Session { impl Session { /// Restores the stored session, dropping it if the token has expired. + /// Blocked or unreadable storage counts as no session. pub fn restore() -> Option { - let session: Self = LocalStorage::get(SESSION_KEY).ok()?; + let storage = local_storage()?; + let stored = storage.get_item(SESSION_KEY).ok()??; + let session: Self = serde_json::from_str(&stored).ok()?; if session.is_expired() { - Self::clear(); + let _ = storage.remove_item(SESSION_KEY); return None; } Some(session) @@ -44,11 +47,26 @@ impl Session { pub fn persist(&self) { // A failed write only costs the user a sign-in after the next reload. - let _ = LocalStorage::set(SESSION_KEY, self); + let (Some(storage), Ok(json)) = (local_storage(), serde_json::to_string(self)) else { + return; + }; + let _ = storage.set_item(SESSION_KEY, &json); } - pub fn clear() { - LocalStorage::delete(SESSION_KEY); + /// Removes this session from storage. Tabs share the stored session, so + /// one that another tab saved after signing in again is left alone. + pub fn forget(&self) { + let Some(storage) = local_storage() else { + return; + }; + let stored = storage + .get_item(SESSION_KEY) + .ok() + .flatten() + .and_then(|stored| serde_json::from_str::(&stored).ok()); + if stored.is_none_or(|stored| stored.access_token == self.access_token) { + let _ = storage.remove_item(SESSION_KEY); + } } fn is_expired(&self) -> bool { @@ -57,21 +75,18 @@ impl Session { } } -impl TryFrom for Session { - type Error = MissingToken; - - fn try_from(identity: IdentityInfo) -> Result { - let token = identity.access_token.ok_or(MissingToken)?; - Ok(Self { +impl From for Session { + fn from(identity: IdentityInfo) -> Self { + Self { user_id: identity.user_id, - access_token: token.token, - expiry: token.expiry, - }) + access_token: identity.access_token.token, + expiry: identity.access_token.expiry, + } } } -/// The server answered a login without an access token, which happens when -/// JWT auth is disabled for HTTP. -#[derive(Debug, Clone, Copy, thiserror::Error)] -#[error("the server did not return an access token")] -pub struct MissingToken; +/// `None` when the browser blocks site storage, where reading +/// `window.localStorage` throws. +fn local_storage() -> Option { + web_sys::window()?.local_storage().ok().flatten() +} diff --git a/core/web_ui/src/components.rs b/core/web_ui/src/components.rs index ef92ae7530..e6f7a9dae5 100644 --- a/core/web_ui/src/components.rs +++ b/core/web_ui/src/components.rs @@ -74,9 +74,12 @@ pub fn ErrorView(error: ApiError) -> Element { // Reactive on the prop, because a mounted view can change from another // error to a 401 without remounting. use_effect(use_reactive!(|unauthorized| { - if unauthorized { - Session::clear(); - session.set(None); + if !unauthorized { + return; + } + let failed = session.write().take(); + if let Some(failed) = failed { + failed.forget(); } })); diff --git a/core/web_ui/src/format.rs b/core/web_ui/src/format.rs index f378f71654..f01ed40008 100644 --- a/core/web_ui/src/format.rs +++ b/core/web_ui/src/format.rs @@ -51,13 +51,15 @@ pub fn message_expiry(options: &ResourceOptions) -> String { /// Shows the payload as text when it is UTF-8 and as hex otherwise. pub fn payload(base64_payload: &str) -> String { match STANDARD.decode(base64_payload) { - Ok(bytes) => text_or_hex(bytes), + Ok(bytes) => String::from_utf8(bytes).unwrap_or_else(|error| hex(error.as_bytes())), Err(_) => base64_payload.to_owned(), } } -/// The first `max_chars` characters of [`payload`], decoding only as much of -/// the payload as that needs, so a list of large messages stays cheap. +/// Up to `max_chars` characters of the payload, decoding only as much of it +/// as that needs, so a list of large messages stays cheap. Text or hex is +/// picked from the decoded prefix alone, so a payload whose invalid UTF-8 +/// comes later previews as text while [`payload`] shows it as hex. pub fn payload_preview(base64_payload: &str, max_chars: usize) -> String { // A character is at most 4 bytes of UTF-8, and every 3 bytes take 4 // base64 characters, so this prefix always holds `max_chars` characters. @@ -99,10 +101,6 @@ pub fn message_id(id: u128) -> String { ) } -fn text_or_hex(bytes: Vec) -> String { - String::from_utf8(bytes).unwrap_or_else(|error| hex(error.as_bytes())) -} - fn hex(bytes: &[u8]) -> String { let mut text = String::with_capacity(bytes.len() * 3); for (index, byte) in bytes.iter().enumerate() { @@ -158,8 +156,6 @@ mod tests { "message_expiry".to_owned(), OptionValue { value: micros.to_string(), - kind: "uint64".to_owned(), - explicit: true, }, )]) } diff --git a/core/web_ui/src/lib.rs b/core/web_ui/src/lib.rs index b9c0a66eee..54f5957824 100644 --- a/core/web_ui/src/lib.rs +++ b/core/web_ui/src/lib.rs @@ -29,6 +29,7 @@ use crate::pages::{DashboardLayout, Messages, NotFound, SignIn, StreamPage, Stre const STYLESHEET: Asset = asset!("/assets/main.css"); const FAVICON: Asset = asset!("/assets/favicon.svg"); +const LOGO: Asset = asset!("/assets/iggy-apache-darkbg.svg"); #[derive(Debug, Clone, PartialEq, Routable)] #[rustfmt::skip] @@ -44,8 +45,8 @@ pub enum Route { StreamPage { stream_id: u32 }, #[route("/dashboard/streams/:stream_id/topics/:topic_id")] TopicPage { stream_id: u32, topic_id: u32 }, - #[route("/dashboard/streams/:stream_id/topics/:topic_id/partitions/:partition_id/messages")] - Messages { stream_id: u32, topic_id: u32, partition_id: u32 }, + #[route("/dashboard/streams/:stream_id/topics/:topic_id/partitions/:partition_id/messages?:offset")] + Messages { stream_id: u32, topic_id: u32, partition_id: u32, offset: Option }, #[end_layout] #[route("/:..segments")] NotFound { segments: Vec }, diff --git a/core/web_ui/src/models.rs b/core/web_ui/src/models.rs index afeb4189e1..e332a5125e 100644 --- a/core/web_ui/src/models.rs +++ b/core/web_ui/src/models.rs @@ -19,14 +19,16 @@ //! //! `iggy_common` owns the real types but pulls tokio with `net`, which does //! not build for `wasm32-unknown-unknown`, so the UI keeps its own copies of -//! the fields it renders. Unknown fields are ignored on purpose. +//! the fields it renders, under the server's type names. Unknown fields are +//! ignored on purpose. The tests below decode the server's serialized types +//! into each mirror, so a renamed field fails on the host. use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize)] -pub struct LoginRequest { +pub struct LoginUser { pub username: String, pub password: String, } @@ -34,7 +36,7 @@ pub struct LoginRequest { #[derive(Debug, Clone, Deserialize)] pub struct IdentityInfo { pub user_id: u32, - pub access_token: Option, + pub access_token: TokenInfo, } #[derive(Debug, Clone, Deserialize)] @@ -44,8 +46,7 @@ pub struct TokenInfo { } #[derive(Debug, Clone, PartialEq, Deserialize)] -pub struct UserDetails { - pub id: u32, +pub struct UserInfoDetails { pub username: String, } @@ -54,8 +55,6 @@ pub struct UserDetails { #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct OptionValue { pub value: String, - pub kind: String, - pub explicit: bool, } pub type ResourceOptions = BTreeMap; @@ -118,26 +117,217 @@ pub struct Partition { #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct PolledMessages { - pub partition_id: u32, + /// Offset of the newest message in the partition when the poll ran. pub current_offset: u64, - pub count: u32, - pub messages: Vec, + pub messages: Vec, } -#[derive(Debug, Clone, PartialEq, Deserialize)] -pub struct Message { - pub header: MessageHeader, +/// `Eq` lets `Rc` props compare by pointer before comparing +/// payloads. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct IggyMessage { + pub header: IggyMessageHeader, /// Standard padded base64. pub payload: String, } -#[derive(Debug, Clone, PartialEq, Deserialize)] -pub struct MessageHeader { - pub checksum: u64, +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct IggyMessageHeader { pub id: u128, pub offset: u64, pub timestamp: u64, - pub origin_timestamp: u64, - pub user_headers_length: u32, pub payload_length: u32, } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use base64::Engine; + use base64::engine::general_purpose::STANDARD; + use iggy_common::{ + HeaderKey, HeaderValue, IggyByteSize, IggyTimestamp, OptionKey, PartitionContext, + UserStatus, + }; + use serde::de::DeserializeOwned; + + use super::*; + + const CREATED_AT: u64 = 1_700_000_000_000_000; + const MESSAGE_EXPIRY_MICROS: u64 = 86_400_000_000; + + #[test] + fn given_server_login_reply_should_decode_identity() { + let server = iggy_common::IdentityInfo { + user_id: 7, + access_token: Some(iggy_common::TokenInfo { + token: "secret".to_owned(), + expiry: 1_800_000_000, + }), + }; + let mirror: IdentityInfo = decode(&server); + assert_eq!(mirror.user_id, 7); + assert_eq!(mirror.access_token.token, "secret"); + assert_eq!(mirror.access_token.expiry, 1_800_000_000); + } + + #[test] + fn given_ui_login_should_decode_as_server_login_user() { + let mirror = LoginUser { + username: "iggy".to_owned(), + password: "password".to_owned(), + }; + let server: iggy_common::login_user::LoginUser = decode(&mirror); + assert_eq!(server.username, "iggy"); + } + + #[test] + fn given_server_user_should_decode_user_info_details() { + let server = iggy_common::UserInfoDetails { + id: 1, + created_at: IggyTimestamp::from(CREATED_AT), + status: UserStatus::Active, + username: "iggy".to_owned(), + permissions: None, + options: Default::default(), + }; + let mirror: UserInfoDetails = decode(&server); + assert_eq!(mirror.username, "iggy"); + } + + #[test] + fn given_server_streams_should_decode_stream_list() { + let server = vec![iggy_common::Stream { + id: 1, + created_at: IggyTimestamp::from(CREATED_AT), + name: "orders".to_owned(), + size: IggyByteSize::from(2_048), + messages_count: 10, + topics_count: 2, + options: Default::default(), + }]; + let mirror: Vec = decode(&server); + assert_eq!( + mirror, + [Stream { + id: 1, + created_at: CREATED_AT, + name: "orders".to_owned(), + size: "2 KiB".to_owned(), + messages_count: 10, + topics_count: 2, + }] + ); + } + + #[test] + fn given_server_stream_details_should_decode_topics() { + let server = iggy_common::StreamDetails { + id: 1, + created_at: IggyTimestamp::from(CREATED_AT), + name: "orders".to_owned(), + size: IggyByteSize::from(2_048), + messages_count: 10, + topics_count: 1, + topics: vec![server_topic()], + options: Default::default(), + }; + let mirror: StreamDetails = decode(&server); + assert_eq!(mirror.name, "orders"); + assert_eq!(mirror.topics.len(), 1); + let topic = &mirror.topics[0]; + assert_eq!((topic.id, topic.partitions_count), (3, 1)); + assert_eq!( + topic.options["message_expiry"].value, + MESSAGE_EXPIRY_MICROS.to_string() + ); + } + + #[test] + fn given_server_topic_details_should_decode_partitions() { + let topic = server_topic(); + let server = iggy_common::TopicDetails { + id: topic.id, + created_at: topic.created_at, + name: topic.name, + size: topic.size, + messages_count: topic.messages_count, + partitions_count: topic.partitions_count, + partitions: vec![iggy_common::Partition { + id: 0, + created_at: IggyTimestamp::from(CREATED_AT), + segments_count: 1, + current_offset: 9, + size: IggyByteSize::from(1_024), + messages_count: 10, + context: PartitionContext::default(), + }], + options: topic.options, + }; + let mirror: TopicDetails = decode(&server); + assert_eq!( + mirror.partitions, + [Partition { + id: 0, + created_at: CREATED_AT, + segments_count: 1, + current_offset: 9, + size: "1 KiB".to_owned(), + messages_count: 10, + }] + ); + assert_eq!( + mirror.options["message_expiry"].value, + MESSAGE_EXPIRY_MICROS.to_string() + ); + } + + #[test] + fn given_server_polled_messages_should_decode_payload_and_header() { + let mut message = iggy_common::IggyMessage::builder() + .id(42) + .payload("hello".into()) + .build() + .expect("valid message"); + message.header.offset = 5; + message.header.timestamp = CREATED_AT; + let mut server = iggy_common::PolledMessages::empty(); + server.current_offset = 9; + server.count = 1; + server.messages = vec![message]; + + let mirror: PolledMessages = decode(&server); + assert_eq!(mirror.current_offset, 9); + assert_eq!( + mirror.messages, + [IggyMessage { + header: IggyMessageHeader { + id: 42, + offset: 5, + timestamp: CREATED_AT, + payload_length: 5, + }, + payload: STANDARD.encode("hello"), + }] + ); + } + + fn server_topic() -> iggy_common::Topic { + let options = iggy_common::ResourceOptions::from([( + HeaderKey::from(OptionKey::MessageExpiry), + iggy_common::OptionValue::explicit(HeaderValue::from(MESSAGE_EXPIRY_MICROS)), + )]); + iggy_common::Topic { + id: 3, + created_at: IggyTimestamp::from(CREATED_AT), + name: "events".to_owned(), + size: IggyByteSize::from(1_024), + messages_count: 10, + partitions_count: 1, + options, + } + } + + fn decode(value: &impl Serialize) -> T { + let json = serde_json::to_string(value).expect("serializable"); + serde_json::from_str(&json).expect("the mirror decodes the server's JSON") + } +} diff --git a/core/web_ui/src/pages/layout.rs b/core/web_ui/src/pages/layout.rs index 72ab991882..d91be9fc30 100644 --- a/core/web_ui/src/pages/layout.rs +++ b/core/web_ui/src/pages/layout.rs @@ -17,11 +17,8 @@ use dioxus::prelude::*; -use crate::Route; -use crate::auth::Session; use crate::components::{api_client, use_session}; - -const LOGO: Asset = asset!("/assets/iggy-apache-darkbg.svg"); +use crate::{LOGO, Route}; /// Shell for every signed-in page. Without a session it redirects to sign-in. #[component] @@ -41,11 +38,13 @@ pub fn DashboardLayout() -> Element { }); let sign_out = move |_| async move { - // The token is dropped locally either way, so a failed revoke only - // leaves it valid on the server until it expires. + // Logout does not revoke the bearer, so the token stays valid on the + // server until `expiry` whether or not this call succeeds. let _ = api_client(session).logout().await; - Session::clear(); - session.set(None); + let signed_out = session.write().take(); + if let Some(signed_out) = signed_out { + signed_out.forget(); + } }; if session.read().is_none() { diff --git a/core/web_ui/src/pages/messages.rs b/core/web_ui/src/pages/messages.rs index c75850fc1c..91ba50de4b 100644 --- a/core/web_ui/src/pages/messages.rs +++ b/core/web_ui/src/pages/messages.rs @@ -15,13 +15,15 @@ // specific language governing permissions and limitations // under the License. +use std::rc::Rc; + use dioxus::prelude::*; use crate::Route; use crate::api::ApiError; use crate::components::{Crumb, ErrorView, Loading, PageHeader, api_client, use_session}; use crate::format; -use crate::models::Message; +use crate::models::IggyMessage; const PAGE_SIZE: u32 = 20; const PAYLOAD_PREVIEW_CHARS: usize = 60; @@ -29,54 +31,75 @@ const PAYLOAD_PREVIEW_CHARS: usize = 60; #[derive(Debug, Clone, PartialEq)] struct MessagesPage { topic_name: String, - /// Offset of the newest message, `None` when the partition is empty. - last_offset: Option, - /// Offset of the first message shown. Retention can make it later than - /// the offset that was asked for. - first_offset: u64, - /// The server answered with a later offset than requested, so nothing - /// older is left. - at_oldest: bool, - messages: Vec, + /// Offset the page was polled from. Retention can make the first message + /// later than this. + requested_offset: u64, + /// Newest offset in the partition, taken from the poll reply so it always + /// agrees with `messages`. + current_offset: u64, + messages: Vec>, } +/// `offset` is the first offset of the page. `None` follows the newest page, +/// so reloading picks up new messages. #[component] -pub fn Messages(stream_id: u32, topic_id: u32, partition_id: u32) -> Element { +pub fn Messages(stream_id: u32, topic_id: u32, partition_id: u32, offset: Option) -> Element { let session = use_session(); - // `None` follows the newest page, so reloading picks up new messages. - let mut requested_offset = use_signal(|| None::); + let navigator = navigator(); + + let mut page = use_resource(use_reactive!(|( + stream_id, + topic_id, + partition_id, + offset, + )| async move { + let client = api_client(session); + let topic = client.topic(stream_id, topic_id).await?; + let partition = topic + .partitions + .iter() + .find(|partition| partition.id == partition_id) + .ok_or(ApiError::NotFound)?; + let requested_offset = offset.unwrap_or_else(|| { + partition + .current_offset + .saturating_sub(u64::from(PAGE_SIZE) - 1) + }); - let page = use_resource(use_reactive!( - |(stream_id, topic_id, partition_id)| async move { - let client = api_client(session); - let topic = client.topic(stream_id, topic_id).await?; - let partition = topic - .partitions - .iter() - .find(|partition| partition.id == partition_id) - .ok_or(ApiError::NotFound)?; - let last_offset = (partition.messages_count > 0).then_some(partition.current_offset); - let newest_page_start = last_offset - .map(|last| last.saturating_sub(u64::from(PAGE_SIZE) - 1)) - .unwrap_or(0); - let requested = requested_offset().unwrap_or(newest_page_start); + let polled = client + .poll_messages( + stream_id, + topic_id, + partition_id, + requested_offset, + PAGE_SIZE, + ) + .await?; + Ok::<_, ApiError>(MessagesPage { + topic_name: topic.name, + requested_offset, + current_offset: polled.current_offset, + messages: polled.messages.into_iter().map(Rc::new).collect(), + }) + })); - let polled = client - .poll_messages(stream_id, topic_id, partition_id, requested, PAGE_SIZE) - .await?; - let first_offset = polled - .messages - .first() - .map_or(requested, |message| message.header.offset); - Ok::<_, ApiError>(MessagesPage { - topic_name: topic.name, - last_offset, - first_offset, - at_oldest: first_offset > requested, - messages: polled.messages, - }) + let mut show = move |target: Option| { + // A second click would restart the poll in flight and fetch the same + // page again. + if page.pending() { + return; + } + if target == offset { + page.restart(); + } else { + navigator.push(Route::Messages { + stream_id, + topic_id, + partition_id, + offset: target, + }); } - )); + }; let header = rsx! { PageHeader { title: format!("Partition {partition_id} messages"), @@ -89,56 +112,65 @@ pub fn Messages(stream_id: u32, topic_id: u32, partition_id: u32) -> Element { let body = match &*page.read() { None => rsx! { Loading {} }, Some(Err(error)) => rsx! { ErrorView { error: error.clone() } }, - Some(Ok(page)) if page.last_offset.is_none() => rsx! { - p { class: "muted", "Topic {page.topic_name} has no messages in this partition." } - }, - Some(Ok(page)) => { - let first_offset = page.first_offset; - let last_offset = page.last_offset.unwrap_or(0); - let page_size = u64::from(PAGE_SIZE); - let has_older = first_offset > 0 && !page.at_oldest; - let has_newer = first_offset + page_size <= last_offset; - let shown_last_offset = page - .messages - .last() - .map_or(first_offset, |message| message.header.offset); - rsx! { - div { class: "pager", - span { class: "muted", - "Topic {page.topic_name}, showing offsets {first_offset} to {shown_last_offset} of {last_offset}" - } - button { - class: "secondary", - disabled: !has_older, - onclick: move |_| requested_offset.set(Some(first_offset.saturating_sub(page_size))), - "Older" - } - button { - class: "secondary", - disabled: !has_newer, - onclick: move |_| requested_offset.set(Some(first_offset + page_size)), - "Newer" - } - button { class: "secondary", onclick: move |_| requested_offset.set(None), "Latest" } - } - table { - thead { - tr { - th { class: "num", "Offset" } - th { "Timestamp" } - th { "ID" } - th { class: "num", "Bytes" } - th { "Payload" } + Some(Ok(loaded)) => match (loaded.messages.first(), loaded.messages.last()) { + (Some(first), Some(last)) => { + let first_offset = first.header.offset; + let shown_last_offset = last.header.offset; + let current_offset = loaded.current_offset; + // The server skipped ahead of the requested offset, so + // nothing older is left. + let at_oldest = first_offset > loaded.requested_offset; + let has_older = first_offset > 0 && !at_oldest; + let has_newer = shown_last_offset < current_offset; + rsx! { + div { class: "pager", + span { class: "muted", + "Topic {loaded.topic_name}, showing offsets {first_offset} to {shown_last_offset} of {current_offset}" + } + button { + class: "secondary", + disabled: !has_older, + onclick: move |_| show(Some(first_offset.saturating_sub(u64::from(PAGE_SIZE)))), + "Older" + } + button { + class: "secondary", + disabled: !has_newer, + onclick: move |_| show(Some(shown_last_offset + 1)), + "Newer" } + button { class: "secondary", onclick: move |_| show(None), "Latest" } } - tbody { - for message in page.messages.iter().rev() { - MessageRow { key: "{message.header.offset}", message: message.clone() } + table { + thead { + tr { + th { class: "num", "Offset" } + th { "Timestamp" } + th { "ID" } + th { class: "num", "Bytes" } + th { "Payload" } + } + } + tbody { + for message in loaded.messages.iter().rev() { + MessageRow { key: "{message.header.offset}", message: Rc::clone(message) } + } } } } } - } + _ if loaded.requested_offset == 0 => rsx! { + p { class: "muted", "Topic {loaded.topic_name} has no messages in this partition." } + }, + _ => rsx! { + div { class: "pager", + span { class: "muted", + "Topic {loaded.topic_name} has no messages from offset {loaded.requested_offset}." + } + button { class: "secondary", onclick: move |_| show(None), "Latest" } + } + }, + }, }; rsx! { @@ -148,7 +180,7 @@ pub fn Messages(stream_id: u32, topic_id: u32, partition_id: u32) -> Element { } #[component] -fn MessageRow(message: Message) -> Element { +fn MessageRow(message: Rc) -> Element { // The full payload can be up to 64 MB, so it is decoded only once the // row is opened. let mut open = use_signal(|| false); diff --git a/core/web_ui/src/pages/sign_in.rs b/core/web_ui/src/pages/sign_in.rs index f7a4727f7f..f46e41d89c 100644 --- a/core/web_ui/src/pages/sign_in.rs +++ b/core/web_ui/src/pages/sign_in.rs @@ -17,13 +17,11 @@ use dioxus::prelude::*; -use crate::Route; use crate::api::ApiClient; use crate::auth::Session; use crate::components::use_session; -use crate::models::LoginRequest; - -const LOGO: Asset = asset!("/assets/iggy-apache-darkbg.svg"); +use crate::models::LoginUser; +use crate::{LOGO, Route}; #[component] pub fn SignIn() -> Element { @@ -43,23 +41,20 @@ pub fn SignIn() -> Element { let submit = move |event: FormEvent| async move { event.prevent_default(); submitting.set(true); - let credentials = LoginRequest { + let credentials = LoginUser { username: username(), password: password(), }; let result = ApiClient::default().login(&credentials).await; submitting.set(false); - let signed_in = match result { - Ok(identity) => Session::try_from(identity).map_err(|error| error.to_string()), - Err(error) => Err(error.to_string()), - }; - match signed_in { - Ok(new_session) => { + match result { + Ok(identity) => { + let new_session = Session::from(identity); new_session.persist(); session.set(Some(new_session)); } - Err(message) => error.set(Some(message)), + Err(failure) => error.set(Some(failure.to_string())), } }; diff --git a/core/web_ui/src/pages/topic.rs b/core/web_ui/src/pages/topic.rs index fb51194ec6..428f90c4fd 100644 --- a/core/web_ui/src/pages/topic.rs +++ b/core/web_ui/src/pages/topic.rs @@ -68,7 +68,7 @@ pub fn TopicPage(stream_id: u32, topic_id: u32) -> Element { td { {format::timestamp(partition.created_at)} } td { Link { - to: Route::Messages { stream_id, topic_id, partition_id: partition.id }, + to: Route::Messages { stream_id, topic_id, partition_id: partition.id, offset: None }, "Browse messages" } } diff --git a/scripts/build-web-ui.sh b/scripts/build-web-ui.sh index 560de30910..17b71aa532 100755 --- a/scripts/build-web-ui.sh +++ b/scripts/build-web-ui.sh @@ -18,14 +18,25 @@ set -Eeuo pipefail -# Builds the Dioxus web UI into core/web_ui/dist, where iggy-server embeds it. +# Builds the Dioxus web UI into core/web_ui/dist. iggy-server does not embed +# this bundle yet, the switch-over from the Svelte app will make it do so. REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CRATE_DIR="${REPO_ROOT}/core/web_ui" +# dx stages under the cargo target directory, which CARGO_TARGET_DIR or a +# cargo config can move, so ask cargo the same way dx does. Plain bash parses +# the JSON so the script needs no jq. +metadata="$(cargo metadata --format-version 1 --no-deps --manifest-path "${CRATE_DIR}/Cargo.toml")" +if [[ ! "${metadata}" =~ \"target_directory\":\"([^\"]+)\" ]]; then + echo "cargo metadata did not report a target_directory" >&2 + exit 1 +fi +TARGET_DIR="${BASH_REMATCH[1]}" + # dx keeps every previously hashed asset in its staging directory and copies -# all of them into the bundle, so stale builds would end up in the server binary. -rm -rf "${REPO_ROOT}/target/dx/iggy-web-ui" "${CRATE_DIR}/dist" +# all of them into the bundle, so stale builds would end up in the bundle. +rm -rf "${TARGET_DIR}/dx/iggy-web-ui" "${CRATE_DIR}/dist" cd "${CRATE_DIR}" dx bundle --platform web --release --out-dir dist