diff --git a/apps/web/package.json b/apps/web/package.json index 2b7c43a..ce2ac01 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,8 +21,13 @@ "@tanstack/react-form": "^1.12.3", "@tanstack/react-query": "^5.90.12", "@tanstack/react-router": "^1.141.1", + "@tanstack/react-virtual": "^3.13.13", + "@tauri-apps/api": "^2.9.1", + "@tauri-apps/plugin-dialog": "^2.4.2", + "@tauri-apps/plugin-fs": "^2.4.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "dotenv": "catalog:", "lucide-react": "^0.473.0", "next-themes": "^0.4.6", diff --git a/apps/web/src-tauri/Cargo.lock b/apps/web/src-tauri/Cargo.lock index d3607c6..5bff865 100644 --- a/apps/web/src-tauri/Cargo.lock +++ b/apps/web/src-tauri/Cargo.lock @@ -84,7 +84,10 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-fs", "tauri-plugin-log", + "thiserror 1.0.69", ] [[package]] @@ -93,6 +96,61 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ashpd" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle", + "serde", + "serde_repr", + "tokio", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "atk" version = "0.18.2" @@ -428,6 +486,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -666,6 +733,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.10.0", + "block2", + "libc", "objc2", ] @@ -680,6 +749,15 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading", +] + [[package]] name = "dlopen2" version = "0.8.2" @@ -703,6 +781,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -759,6 +843,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -786,6 +897,43 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fdeflate" version = "0.3.7" @@ -920,6 +1068,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -1741,6 +1902,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" @@ -1901,6 +2068,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2177,6 +2357,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2202,6 +2392,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2391,7 +2587,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.12.1", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -2527,6 +2723,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -2582,6 +2787,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2602,6 +2817,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -2620,6 +2845,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -2757,6 +2991,31 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rkyv" version = "0.7.45" @@ -2811,6 +3070,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2883,6 +3155,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -3110,6 +3388,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -3210,6 +3498,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.9" @@ -3501,6 +3795,46 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.17", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47df422695255ecbe7bac7012440eddaeefd026656171eac9559f5243d3230d9" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.17", + "toml 0.9.10+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-log" version = "2.7.1" @@ -3624,6 +3958,19 @@ dependencies = [ "toml 0.9.10+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.4.3" @@ -3743,7 +4090,9 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", + "tracing", "windows-sys 0.61.2", ] @@ -3908,9 +4257,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3960,6 +4321,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -4216,6 +4588,66 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +dependencies = [ + "bitflags 2.10.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +dependencies = [ + "proc-macro2", + "quick-xml 0.37.5", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.83" @@ -4869,6 +5301,62 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "nix", + "ordered-stream", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.14", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.111", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow 0.7.14", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.31" @@ -4948,3 +5436,44 @@ name = "zmij" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5858cd3a46fff31e77adea2935e357e3a2538d870741617bfb7c943e218fee6" + +[[package]] +name = "zvariant" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 0.7.14", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.111", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.111", + "winnow 0.7.14", +] diff --git a/apps/web/src-tauri/Cargo.toml b/apps/web/src-tauri/Cargo.toml index 23b83e3..944dcf6 100644 --- a/apps/web/src-tauri/Cargo.toml +++ b/apps/web/src-tauri/Cargo.toml @@ -23,3 +23,6 @@ serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.9.5", features = [] } tauri-plugin-log = "2" +tauri-plugin-fs = "2" +tauri-plugin-dialog = "2" +thiserror = "1" diff --git a/apps/web/src-tauri/capabilities/default.json b/apps/web/src-tauri/capabilities/default.json index 8e906f7..140f2e5 100644 --- a/apps/web/src-tauri/capabilities/default.json +++ b/apps/web/src-tauri/capabilities/default.json @@ -3,5 +3,21 @@ "identifier": "default", "description": "enables the default permissions", "windows": ["main"], - "permissions": ["core:default"] + "permissions": [ + "core:default", + "fs:default", + "dialog:default", + { + "identifier": "fs:allow-read-text-file", + "allow": [{ "path": "**/*" }] + }, + { + "identifier": "fs:allow-exists", + "allow": [{ "path": "**/*" }] + }, + { + "identifier": "dialog:allow-open", + "allow": [{}] + } + ] } diff --git a/apps/web/src-tauri/src/commands.rs b/apps/web/src-tauri/src/commands.rs new file mode 100644 index 0000000..ae8af7e --- /dev/null +++ b/apps/web/src-tauri/src/commands.rs @@ -0,0 +1,76 @@ +use crate::{calculate_kpis, AppError, GrafanaAlert, Incident, KpiMetrics, ProcessedAlert}; +use std::fs; + +#[tauri::command] +pub async fn load_alerts_from_file(path: String) -> Result, AppError> { + let contents = fs::read_to_string(&path)?; + let raw_alerts: Vec = serde_json::from_str(&contents)?; + let processed: Vec = raw_alerts.into_iter().map(ProcessedAlert::from).collect(); + Ok(processed) +} + +#[tauri::command] +pub fn process_alerts_json(json_content: String) -> Result, AppError> { + let raw_alerts: Vec = serde_json::from_str(&json_content)?; + let processed: Vec = raw_alerts.into_iter().map(ProcessedAlert::from).collect(); + Ok(processed) +} + +#[tauri::command] +pub fn calculate_kpis_command(alerts: Vec, incidents: Vec) -> KpiMetrics { + calculate_kpis(&alerts, &incidents) +} + +#[tauri::command] +pub fn set_alert_invalid( + mut alerts: Vec, + alert_id: u64, + is_invalid: bool, +) -> Vec { + if let Some(alert) = alerts.iter_mut().find(|a| a.id == alert_id) { + alert.is_invalid = is_invalid; + } + alerts +} + +#[tauri::command] +pub fn attach_alert_to_incident( + mut alerts: Vec, + mut incidents: Vec, + alert_id: u64, + incident_id: String, +) -> (Vec, Vec) { + if let Some(alert) = alerts.iter_mut().find(|a| a.id == alert_id) { + alert.attached_incident_id = Some(incident_id.clone()); + } + if let Some(incident) = incidents.iter_mut().find(|i| i.id == incident_id) { + if !incident.attached_alert_ids.contains(&alert_id) { + incident.attached_alert_ids.push(alert_id); + } + } + (alerts, incidents) +} + +#[tauri::command] +pub fn detach_alert_from_incident( + mut alerts: Vec, + mut incidents: Vec, + alert_id: u64, +) -> (Vec, Vec) { + let incident_id = alerts + .iter() + .find(|a| a.id == alert_id) + .and_then(|a| a.attached_incident_id.clone()); + + if let Some(alert) = alerts.iter_mut().find(|a| a.id == alert_id) { + alert.attached_incident_id = None; + } + + if let Some(id) = incident_id { + if let Some(incident) = incidents.iter_mut().find(|i| i.id == id) { + incident.attached_alert_ids.retain(|&aid| aid != alert_id); + } + } + + (alerts, incidents) +} diff --git a/apps/web/src-tauri/src/lib.rs b/apps/web/src-tauri/src/lib.rs index 9c3118c..7ffd32b 100644 --- a/apps/web/src-tauri/src/lib.rs +++ b/apps/web/src-tauri/src/lib.rs @@ -1,16 +1,250 @@ +mod commands; + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("JSON parse error: {0}")] + Json(#[from] serde_json::Error), + #[error("Not found: {0}")] + NotFound(String), +} + +impl Serialize for AppError { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GrafanaAlert { + pub id: u64, + pub alert_id: u32, + #[serde(default)] + pub alert_name: Option, + #[serde(default)] + pub dashboard_uid: Option, + pub new_state: String, + pub prev_state: String, + pub created: u64, + pub updated: u64, + pub time: u64, + pub time_end: u64, + pub text: String, + #[serde(default)] + pub data: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlertData { + #[serde(default, deserialize_with = "deserialize_metric_values")] + pub values: HashMap, +} + +/// Deserialize metric values that may contain "+Inf", "-Inf", "NaN" as strings +fn deserialize_metric_values<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de::{MapAccess, Visitor}; + use std::fmt; + + struct MetricValuesVisitor; + + impl<'de> Visitor<'de> for MetricValuesVisitor { + type Value = HashMap; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a map of string to number or special float strings") + } + + fn visit_map(self, mut access: M) -> Result + where + M: MapAccess<'de>, + { + let mut map = HashMap::new(); + + while let Some((key, value)) = access.next_entry::()? { + let num = match value { + serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0), + serde_json::Value::String(s) => match s.as_str() { + "+Inf" | "Inf" | "infinity" | "-Inf" | "-infinity" | "NaN" | "nan" => 0.0, + other => other.parse().unwrap_or(0.0), + }, + _ => 0.0, + }; + map.insert(key, num); + } + + Ok(map) + } + } + + deserializer.deserialize_map(MetricValuesVisitor) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessedAlert { + pub id: u64, + pub alert_id: u32, + #[serde(default)] + pub alert_name: Option, + pub new_state: String, + pub prev_state: String, + pub time: u64, + pub time_end: u64, + pub text: String, + pub duration_ms: u64, + pub is_firing: bool, + pub is_invalid: bool, + pub attached_incident_id: Option, + pub values: HashMap, +} + +impl From for ProcessedAlert { + fn from(alert: GrafanaAlert) -> Self { + let duration_ms = if alert.time_end > alert.time { + alert.time_end - alert.time + } else { + 0 + }; + + let is_firing = alert.new_state == "Alerting" || alert.new_state == "Firing"; + + ProcessedAlert { + id: alert.id, + alert_id: alert.alert_id, + alert_name: alert.alert_name, + new_state: alert.new_state, + prev_state: alert.prev_state, + time: alert.time, + time_end: alert.time_end, + text: alert.text, + duration_ms, + is_firing, + is_invalid: false, + attached_incident_id: None, + values: alert.data.map(|d| d.values).unwrap_or_default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Incident { + pub id: String, + pub title: String, + pub description: String, + pub start_time: u64, + pub end_time: u64, + pub attached_alert_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KpiMetrics { + pub error_coverage_ratio: f64, + pub total_incidents: usize, + pub covered_incidents: usize, + pub overall_downtime_ms: u64, + pub overall_downtime_formatted: String, + pub invalid_alert_ratio: f64, + pub total_firing_alerts: usize, + pub invalid_alerts: usize, +} + +pub fn calculate_kpis(alerts: &[ProcessedAlert], incidents: &[Incident]) -> KpiMetrics { + let total_incidents = incidents.len(); + let covered_incidents = incidents + .iter() + .filter(|incident| !incident.attached_alert_ids.is_empty()) + .count(); + let error_coverage_ratio = if total_incidents > 0 { + (covered_incidents as f64 / total_incidents as f64) * 100.0 + } else { + 0.0 + }; + + let overall_downtime_ms: u64 = alerts + .iter() + .filter(|a| a.is_firing && !a.is_invalid) + .map(|a| a.duration_ms) + .sum(); + + let overall_downtime_formatted = format_duration(overall_downtime_ms); + + let total_firing_alerts = alerts.iter().filter(|a| a.is_firing).count(); + let invalid_alerts = alerts + .iter() + .filter(|a| a.is_firing && a.is_invalid) + .count(); + let invalid_alert_ratio = if total_firing_alerts > 0 { + (invalid_alerts as f64 / total_firing_alerts as f64) * 100.0 + } else { + 0.0 + }; + + KpiMetrics { + error_coverage_ratio, + total_incidents, + covered_incidents, + overall_downtime_ms, + overall_downtime_formatted, + invalid_alert_ratio, + total_firing_alerts, + invalid_alerts, + } +} + +fn format_duration(ms: u64) -> String { + let seconds = ms / 1000; + let minutes = seconds / 60; + let hours = minutes / 60; + let days = hours / 24; + + if days > 0 { + format!("{}d {}h {}m", days, hours % 24, minutes % 60) + } else if hours > 0 { + format!("{}h {}m {}s", hours, minutes % 60, seconds % 60) + } else if minutes > 0 { + format!("{}m {}s", minutes, seconds % 60) + } else { + format!("{}s", seconds) + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() - .setup(|app| { - if cfg!(debug_assertions) { - app.handle().plugin( - tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) - .build(), - )?; - } - Ok(()) - }) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + tauri::Builder::default() + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_dialog::init()) + .setup(|app| { + if cfg!(debug_assertions) { + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .build(), + )?; + } + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + commands::load_alerts_from_file, + commands::process_alerts_json, + commands::calculate_kpis_command, + commands::set_alert_invalid, + commands::attach_alert_to_incident, + commands::detach_alert_from_incident, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); } diff --git a/apps/web/src/components/monitor/alert-list-item.tsx b/apps/web/src/components/monitor/alert-list-item.tsx new file mode 100644 index 0000000..b69370c --- /dev/null +++ b/apps/web/src/components/monitor/alert-list-item.tsx @@ -0,0 +1,159 @@ +import { cva } from "class-variance-authority"; +import { format } from "date-fns"; +import { Activity, Link as LinkIcon } from "lucide-react"; +import * as React from "react"; + +import { Checkbox } from "@/components/ui/checkbox"; +import type { ProcessedAlert } from "@/lib/types/alerts"; +import { cn } from "@/lib/utils"; + +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d ${hours % 24}h`; + if (hours > 0) return `${hours}h ${minutes % 60}m`; + if (minutes > 0) return `${minutes}m`; + return `${seconds}s`; +} + +function formatTimeRange(start: number, end: number): string { + return `${format(start, "HH:mm:ss")} - ${format(end, "HH:mm:ss")}`; +} + +const alertItemVariants = cva( + "group relative flex w-full items-center gap-3 border-b border-border p-3 transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", + { + variants: { + status: { + firing: + "bg-destructive/5 hover:bg-destructive/10 border-l-2 border-l-destructive", + resolved: "border-l-2 border-l-transparent", + pending: "border-l-2 border-l-yellow-500/50", + }, + invalid: { + true: "opacity-60 grayscale bg-muted/30", + false: "", + }, + }, + defaultVariants: { + status: "resolved", + invalid: false, + }, + }, +); + +interface AlertListItemProps { + alert: ProcessedAlert; + isSelected?: boolean; + onToggleInvalid: (id: number, isInvalid: boolean) => void; + onSelect: (alert: ProcessedAlert) => void; + style?: React.CSSProperties; +} + +export const AlertListItem = React.memo( + ({ + alert, + isSelected, + onToggleInvalid, + onSelect, + style, + }: AlertListItemProps) => { + const status = alert.isFiring ? "firing" : "resolved"; + + return ( +
onSelect(alert)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + onSelect(alert); + } + }} + > +
e.stopPropagation()} + > + + onToggleInvalid(alert.id, checked === true) + } + aria-label="Mark as invalid" + /> +
+ +
+
+ + {alert.alertName || `Unknown Alert #${alert.alertId}`} + + + {alert.durationMs > 0 && ( + {formatDuration(alert.durationMs)} + )} + +
+ +
+
+ + {alert.newState} + + | + + {formatTimeRange(alert.time, alert.timeEnd)} + +
+ +
+ {alert.attachedIncidentId && ( +
+ + {alert.attachedIncidentId} +
+ )} + {alert.isInvalid && ( + + )} +
+
+ +
+ {Object.entries(alert.values) + .map( + ([k, v]) => + `${k}=${Number.isFinite(v) ? v.toFixed(1) : String(v)}`, + ) + .join(", ")} +
+
+ + {alert.isFiring && !alert.isInvalid && ( +
+ )} +
+ ); + }, +); + +AlertListItem.displayName = "AlertListItem"; diff --git a/apps/web/src/components/monitor/alert-list.tsx b/apps/web/src/components/monitor/alert-list.tsx new file mode 100644 index 0000000..6ea93b0 --- /dev/null +++ b/apps/web/src/components/monitor/alert-list.tsx @@ -0,0 +1,74 @@ +import * as React from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { cn } from "@/lib/utils"; +import type { ProcessedAlert } from "@/lib/types/alerts"; +import { AlertListItem } from "./alert-list-item"; + +interface AlertListProps { + alerts: ProcessedAlert[]; + onToggleInvalid: (alertId: number, isInvalid: boolean) => void; + onSelectAlert: (alert: ProcessedAlert) => void; + selectedAlertId?: number; + className?: string; +} + +export function AlertList({ + alerts, + onToggleInvalid, + onSelectAlert, + selectedAlertId, + className, +}: AlertListProps) { + const parentRef = React.useRef(null); + + const getItemKey = React.useCallback( + (index: number) => alerts[index]?.id ?? index, + [alerts], + ); + + const virtualizer = useVirtualizer({ + count: alerts.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 80, + overscan: 5, + getItemKey, + }); + + return ( +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const alert = alerts[virtualItem.index]; + if (!alert) return null; + + return ( + + ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/monitor/incident-manager.tsx b/apps/web/src/components/monitor/incident-manager.tsx new file mode 100644 index 0000000..ab8ae36 --- /dev/null +++ b/apps/web/src/components/monitor/incident-manager.tsx @@ -0,0 +1,224 @@ +import * as React from "react"; +import { format } from "date-fns"; +import { Plus, Link, Link2Off } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import type { Incident, ProcessedAlert } from "@/lib/types/alerts"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +interface IncidentManagerProps { + incidents: Incident[]; + selectedAlert: ProcessedAlert | null; + onAddIncident: (incident: Omit) => void; + onAttachAlert: (alertId: number, incidentId: string) => void; + onDetachAlert: (alertId: number) => void; +} + +export function IncidentManager({ + incidents, + selectedAlert, + onAddIncident, + onAttachAlert, + onDetachAlert, +}: IncidentManagerProps) { + const [title, setTitle] = React.useState(""); + const [description, setDescription] = React.useState(""); + const [startTime, setStartTime] = React.useState(""); + const [endTime, setEndTime] = React.useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!title || !startTime || !endTime) return; + + const newIncident: Omit = { + id: crypto.randomUUID(), + title, + description, + startTime: new Date(startTime).getTime(), + endTime: new Date(endTime).getTime(), + }; + + onAddIncident(newIncident); + setTitle(""); + setDescription(""); + setStartTime(""); + setEndTime(""); + }; + + return ( +
+ + + Create Incident + + Log a confirmed Redis incident to track downtime. + + + +
+
+ + setTitle(e.target.value)} + placeholder="e.g., Redis Master OOM" + required + /> +
+
+ + setDescription(e.target.value)} + placeholder="Brief details about the incident" + /> +
+
+
+ + setStartTime(e.target.value)} + required + /> +
+
+ + setEndTime(e.target.value)} + required + /> +
+
+ +
+
+
+ +
+
+

+ Active Incidents ({incidents.length}) +

+ + {incidents.length === 0 ? ( +
+ No incidents recorded +
+ ) : ( +
+ {incidents.map((incident) => { + const isCurrentAlertAttached = + selectedAlert?.attachedIncidentId === incident.id; + + return ( + + +
+
+ + {incident.title} + + + ID: {incident.id.slice(0, 8)} + +
+ {selectedAlert && ( + + )} +
+
+ +

+ {incident.description || "No description provided."} +

+
+
+ START + {format(incident.startTime, "yyyy-MM-dd HH:mm")} +
+
+ END + {format(incident.endTime, "yyyy-MM-dd HH:mm")} +
+
+
+ +
+
0 + ? "bg-primary" + : "bg-muted-foreground/30", + )} + /> + {incident.attachedAlertIds.length} alerts attached +
+ + + ); + })} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/monitor/kpi-dashboard.tsx b/apps/web/src/components/monitor/kpi-dashboard.tsx new file mode 100644 index 0000000..e12d709 --- /dev/null +++ b/apps/web/src/components/monitor/kpi-dashboard.tsx @@ -0,0 +1,117 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import type { KpiMetrics } from "@/lib/types/alerts"; + +interface KpiDashboardProps { + kpis: KpiMetrics | null; + isLoading?: boolean; +} + +export function KpiDashboard({ kpis, isLoading = false }: KpiDashboardProps) { + if (isLoading) { + return ( +
+ + + + Error Coverage + + + + + + + + + + + Total Downtime + + + + + + + + + + Invalid Alerts + + + + + + + +
+ ); + } + + if (!kpis) { + return ( + + + No KPI data available + + + ); + } + + const coverageColor = + kpis.errorCoverageRatio > 80 + ? "text-green-500" + : kpis.errorCoverageRatio > 50 + ? "text-yellow-500" + : "text-destructive"; + + const invalidColor = + kpis.invalidAlertRatio < 10 + ? "text-green-500" + : kpis.invalidAlertRatio < 25 + ? "text-yellow-500" + : "text-destructive"; + + return ( +
+ + + Error Coverage + + +
+ {kpis.errorCoverageRatio.toFixed(1)}% +
+

+ {kpis.coveredIncidents} of {kpis.totalIncidents} incidents +

+
+
+ + + + Total Downtime + + +
+ {kpis.overallDowntimeFormatted} +
+
+
+ + + + Invalid Alerts + + +
+ {kpis.invalidAlertRatio.toFixed(1)}% +
+

+ {kpis.invalidAlerts} of {kpis.totalFiringAlerts} alerts +

+
+
+
+ ); +} diff --git a/apps/web/src/lib/types/alerts.ts b/apps/web/src/lib/types/alerts.ts new file mode 100644 index 0000000..03820f4 --- /dev/null +++ b/apps/web/src/lib/types/alerts.ts @@ -0,0 +1,43 @@ +export interface ProcessedAlert { + id: number; + alertId: number; + alertName?: string; + newState: string; + prevState: string; + time: number; + timeEnd: number; + text: string; + durationMs: number; + isFiring: boolean; + isInvalid: boolean; + attachedIncidentId: string | null; + values: Record; +} + +export interface Incident { + id: string; + title: string; + description: string; + startTime: number; + endTime: number; + attachedAlertIds: number[]; +} + +export interface KpiMetrics { + errorCoverageRatio: number; + totalIncidents: number; + coveredIncidents: number; + overallDowntimeMs: number; + overallDowntimeFormatted: string; + invalidAlertRatio: number; + totalFiringAlerts: number; + invalidAlerts: number; +} + +export interface MonitorState { + alerts: ProcessedAlert[]; + incidents: Incident[]; + kpis: KpiMetrics | null; + isLoading: boolean; + error: string | null; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index d204c26..194f6ce 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,8 +9,14 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as MonitorRouteImport } from './routes/monitor' import { Route as IndexRouteImport } from './routes/index' +const MonitorRoute = MonitorRouteImport.update({ + id: '/monitor', + path: '/monitor', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -19,28 +25,39 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/monitor': typeof MonitorRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/monitor': typeof MonitorRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/monitor': typeof MonitorRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' + fullPaths: '/' | '/monitor' fileRoutesByTo: FileRoutesByTo - to: '/' - id: '__root__' | '/' + to: '/' | '/monitor' + id: '__root__' | '/' | '/monitor' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + MonitorRoute: typeof MonitorRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/monitor': { + id: '/monitor' + path: '/monitor' + fullPath: '/monitor' + preLoaderRoute: typeof MonitorRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -53,6 +70,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + MonitorRoute: MonitorRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 4449f24..b737b65 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, Link } from "@tanstack/react-router"; export const Route = createFileRoute("/")({ component: HomeComponent, @@ -29,6 +29,9 @@ function HomeComponent() {

API Status

+ + Go to Monitor Page +
); } diff --git a/apps/web/src/routes/monitor.tsx b/apps/web/src/routes/monitor.tsx new file mode 100644 index 0000000..e155e59 --- /dev/null +++ b/apps/web/src/routes/monitor.tsx @@ -0,0 +1,220 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { invoke } from "@tauri-apps/api/core"; +import { open } from "@tauri-apps/plugin-dialog"; +import { readTextFile } from "@tauri-apps/plugin-fs"; +import { FileUp } from "lucide-react"; +import * as React from "react"; + +import { AlertList } from "@/components/monitor/alert-list"; +import { IncidentManager } from "@/components/monitor/incident-manager"; +import { KpiDashboard } from "@/components/monitor/kpi-dashboard"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { Incident, KpiMetrics, ProcessedAlert } from "@/lib/types/alerts"; + +export const Route = createFileRoute("/monitor")({ + component: MonitorPage, +}); + +function MonitorPage() { + const [alerts, setAlerts] = React.useState([]); + const [incidents, setIncidents] = React.useState([]); + const [kpis, setKpis] = React.useState(null); + const [selectedAlert, setSelectedAlert] = + React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + const recalculateKpis = React.useCallback( + async (currentAlerts: ProcessedAlert[], currentIncidents: Incident[]) => { + try { + const newKpis = await invoke("calculate_kpis_command", { + alerts: currentAlerts, + incidents: currentIncidents, + }); + setKpis(newKpis); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, + [], + ); + + const handleLoadFile = async () => { + try { + setIsLoading(true); + setError(null); + + const filePath = await open({ + multiple: false, + filters: [{ name: "JSON", extensions: ["json"] }], + }); + + if (!filePath) { + setIsLoading(false); + return; + } + + const content = await readTextFile(filePath); + + const processedAlerts = await invoke( + "process_alerts_json", + { jsonContent: content }, + ); + + setAlerts(processedAlerts); + setSelectedAlert(null); + await recalculateKpis(processedAlerts, incidents); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsLoading(false); + } + }; + + const handleToggleInvalid = async (alertId: number, isInvalid: boolean) => { + try { + const updatedAlerts = await invoke( + "set_alert_invalid", + { + alerts, + alertId, + isInvalid, + }, + ); + setAlerts(updatedAlerts); + await recalculateKpis(updatedAlerts, incidents); + + if (selectedAlert?.id === alertId) { + const updated = updatedAlerts.find((a) => a.id === alertId); + if (updated) setSelectedAlert(updated); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleSelectAlert = (alert: ProcessedAlert) => { + setSelectedAlert(alert); + }; + + const handleAddIncident = (incident: Omit) => { + const newIncident: Incident = { + ...incident, + attachedAlertIds: [], + }; + const newIncidents = [...incidents, newIncident]; + setIncidents(newIncidents); + recalculateKpis(alerts, newIncidents); + }; + + const handleAttachAlert = async (alertId: number, incidentId: string) => { + try { + const [updatedAlerts, updatedIncidents] = await invoke< + [ProcessedAlert[], Incident[]] + >("attach_alert_to_incident", { + alerts, + incidents, + alertId, + incidentId, + }); + + setAlerts(updatedAlerts); + setIncidents(updatedIncidents); + await recalculateKpis(updatedAlerts, updatedIncidents); + + if (selectedAlert?.id === alertId) { + const updated = updatedAlerts.find((a) => a.id === alertId); + if (updated) setSelectedAlert(updated); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleDetachAlert = async (alertId: number) => { + try { + const [updatedAlerts, updatedIncidents] = await invoke< + [ProcessedAlert[], Incident[]] + >("detach_alert_from_incident", { + alerts, + incidents, + alertId, + }); + + setAlerts(updatedAlerts); + setIncidents(updatedIncidents); + await recalculateKpis(updatedAlerts, updatedIncidents); + + if (selectedAlert?.id === alertId) { + const updated = updatedAlerts.find((a) => a.id === alertId); + if (updated) setSelectedAlert(updated); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + return ( +
+
+

Alert Monitor

+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+ +
+
+
+ + Alerts ({alerts.length}) + +
+ {alerts.length === 0 ? ( +
+ + + No Alerts Loaded + + + Click "Load Alerts JSON" to import alert data from a Grafana + Alerts API export. + + +
+ ) : ( + + )} +
+ +
+ +
+
+
+ ); +} diff --git a/bun.lock b/bun.lock index 32a674c..3b23200 100644 --- a/bun.lock +++ b/bun.lock @@ -28,8 +28,13 @@ "@tanstack/react-form": "^1.12.3", "@tanstack/react-query": "^5.90.12", "@tanstack/react-router": "^1.141.1", + "@tanstack/react-virtual": "^3.13.13", + "@tauri-apps/api": "^2.9.1", + "@tauri-apps/plugin-dialog": "^2.4.2", + "@tauri-apps/plugin-fs": "^2.4.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "dotenv": "catalog:", "lucide-react": "^0.473.0", "next-themes": "^0.4.6", @@ -407,6 +412,8 @@ "@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.13", "", { "dependencies": { "@tanstack/virtual-core": "3.13.13" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4o6oPMDvQv+9gMi8rE6gWmsOjtUZUYIJHv7EB+GblyYdi8U6OqLl8rhHWIUZSL1dUU2dPwTdTgybCKf9EjIrQg=="], + "@tanstack/router-core": ["@tanstack/router-core@1.144.0", "", { "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.1", "seroval-plugins": "^1.4.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-6oVERtK9XDHCP4XojgHsdHO56ZSj11YaWjF5g/zw39LhyA6Lx+/X86AEIHO4y0BUrMQaJfcjdAQMVSAs6Vjtdg=="], "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.144.0", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.144.0", "csstype": "^3.0.10", "solid-js": ">=1.9.5" }, "optionalPeers": ["csstype"] }, "sha512-rbpQn1aHUtcfY3U3SyJqOZRqDu0a2uPK+TE2CH50HieJApmCuNKj5RsjVQYHgwiFFvR0w0LUmueTnl2X2hiWTg=="], @@ -419,8 +426,12 @@ "@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.13", "", {}, "sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA=="], + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.141.0", "", {}, "sha512-CJrWtr6L9TVzEImm9S7dQINx+xJcYP/aDkIi6gnaWtIgbZs1pnzsE0yJc2noqXZ+yAOqLx3TBGpBEs9tS0P9/A=="], + "@tauri-apps/api": ["@tauri-apps/api@2.9.1", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="], + "@tauri-apps/cli": ["@tauri-apps/cli@2.9.6", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.9.6", "@tauri-apps/cli-darwin-x64": "2.9.6", "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6", "@tauri-apps/cli-linux-arm64-gnu": "2.9.6", "@tauri-apps/cli-linux-arm64-musl": "2.9.6", "@tauri-apps/cli-linux-riscv64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-musl": "2.9.6", "@tauri-apps/cli-win32-arm64-msvc": "2.9.6", "@tauri-apps/cli-win32-ia32-msvc": "2.9.6", "@tauri-apps/cli-win32-x64-msvc": "2.9.6" }, "bin": { "tauri": "tauri.js" } }, "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw=="], "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ=="], @@ -445,6 +456,10 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="], + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ=="], + + "@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.4.4", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-MTorXxIRmOnOPT1jZ3w96vjSuScER38ryXY88vl5F0uiKdnvTKKTtaEjTEo8uPbl4e3gnUtfsDVwC7h77GQLvQ=="], + "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -561,6 +576,8 @@ "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="],