feat(monitor): add alert monitoring page with KPI dashboard, alert list, and incident management

- Add Tauri plugins for file dialog and filesystem access
- Create Rust backend for processing Grafana alerts (pairing, KPI calculation)
- Add virtualized alert list with invalid toggle and incident attachment
- Add incident manager with create, attach, and detach functionality
- Add KPI dashboard showing coverage ratio, downtime, and invalid rate
- Add date-fns for date formatting
This commit is contained in:
Syahdan 2025-12-29 05:44:21 +07:00
parent ee3c0c156b
commit ea1c9105a7
15 changed files with 1757 additions and 19 deletions

View file

@ -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",

View file

@ -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",
]

View file

@ -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"

View file

@ -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": [{}]
}
]
}

View file

@ -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<Vec<ProcessedAlert>, AppError> {
let contents = fs::read_to_string(&path)?;
let raw_alerts: Vec<GrafanaAlert> = serde_json::from_str(&contents)?;
let processed: Vec<ProcessedAlert> = raw_alerts.into_iter().map(ProcessedAlert::from).collect();
Ok(processed)
}
#[tauri::command]
pub fn process_alerts_json(json_content: String) -> Result<Vec<ProcessedAlert>, AppError> {
let raw_alerts: Vec<GrafanaAlert> = serde_json::from_str(&json_content)?;
let processed: Vec<ProcessedAlert> = raw_alerts.into_iter().map(ProcessedAlert::from).collect();
Ok(processed)
}
#[tauri::command]
pub fn calculate_kpis_command(alerts: Vec<ProcessedAlert>, incidents: Vec<Incident>) -> KpiMetrics {
calculate_kpis(&alerts, &incidents)
}
#[tauri::command]
pub fn set_alert_invalid(
mut alerts: Vec<ProcessedAlert>,
alert_id: u64,
is_invalid: bool,
) -> Vec<ProcessedAlert> {
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<ProcessedAlert>,
mut incidents: Vec<Incident>,
alert_id: u64,
incident_id: String,
) -> (Vec<ProcessedAlert>, Vec<Incident>) {
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<ProcessedAlert>,
mut incidents: Vec<Incident>,
alert_id: u64,
) -> (Vec<ProcessedAlert>, Vec<Incident>) {
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)
}

View file

@ -1,6 +1,232 @@
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<String>,
#[serde(default)]
pub dashboard_uid: Option<String>,
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<AlertData>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlertData {
#[serde(default, deserialize_with = "deserialize_metric_values")]
pub values: HashMap<String, f64>,
}
/// Deserialize metric values that may contain "+Inf", "-Inf", "NaN" as strings
fn deserialize_metric_values<'de, D>(deserializer: D) -> Result<HashMap<String, f64>, 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<String, f64>;
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<M>(self, mut access: M) -> Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
let mut map = HashMap::new();
while let Some((key, value)) = access.next_entry::<String, serde_json::Value>()? {
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<String>,
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<String>,
pub values: HashMap<String, f64>,
}
impl From<GrafanaAlert> 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<u64>,
}
#[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()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
@ -11,6 +237,14 @@ pub fn run() {
}
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");
}

View file

@ -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 (
<div
style={style}
className={cn(
alertItemVariants({ status, invalid: alert.isInvalid }),
isSelected && "bg-muted border-l-primary",
)}
onClick={() => onSelect(alert)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onSelect(alert);
}
}}
>
<div
className="flex h-full items-start pt-0.5"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={alert.isInvalid}
onCheckedChange={(checked) =>
onToggleInvalid(alert.id, checked === true)
}
aria-label="Mark as invalid"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-semibold text-foreground">
{alert.alertName || `Unknown Alert #${alert.alertId}`}
</span>
<span className="flex shrink-0 items-center gap-1 text-[10px] font-mono text-muted-foreground">
{alert.durationMs > 0 && (
<span>{formatDuration(alert.durationMs)}</span>
)}
</span>
</div>
<div className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
<div className="flex items-center gap-2">
<span
className={cn(
"uppercase tracking-wider",
alert.isFiring
? "text-destructive font-bold"
: "text-green-600 dark:text-green-500",
)}
>
{alert.newState}
</span>
<span className="text-border">|</span>
<span className="font-mono">
{formatTimeRange(alert.time, alert.timeEnd)}
</span>
</div>
<div className="flex items-center gap-1.5">
{alert.attachedIncidentId && (
<div
className="flex items-center gap-0.5 text-primary"
title="Attached to incident"
>
<LinkIcon className="size-3" />
<span className="font-mono">{alert.attachedIncidentId}</span>
</div>
)}
{alert.isInvalid && (
<Activity className="size-3 text-muted-foreground/50" />
)}
</div>
</div>
<div className="truncate text-[10px] text-muted-foreground/70 font-mono">
{Object.entries(alert.values)
.map(
([k, v]) =>
`${k}=${Number.isFinite(v) ? v.toFixed(1) : String(v)}`,
)
.join(", ")}
</div>
</div>
{alert.isFiring && !alert.isInvalid && (
<div className="absolute right-0 top-0 h-full w-0.5 bg-destructive" />
)}
</div>
);
},
);
AlertListItem.displayName = "AlertListItem";

View file

@ -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<HTMLDivElement>(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 (
<div
ref={parentRef}
className={cn("h-full w-full overflow-y-auto contain-strict", className)}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const alert = alerts[virtualItem.index];
if (!alert) return null;
return (
<AlertListItem
key={virtualItem.key}
alert={alert}
isSelected={selectedAlertId === alert.id}
onToggleInvalid={onToggleInvalid}
onSelect={onSelectAlert}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
/>
);
})}
</div>
</div>
);
}

View file

@ -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<Incident, "attachedAlertIds">) => 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<Incident, "attachedAlertIds"> = {
id: crypto.randomUUID(),
title,
description,
startTime: new Date(startTime).getTime(),
endTime: new Date(endTime).getTime(),
};
onAddIncident(newIncident);
setTitle("");
setDescription("");
setStartTime("");
setEndTime("");
};
return (
<div className="flex flex-col gap-4 h-full">
<Card className="shrink-0 rounded-none border-x-0 border-t-0 border-b">
<CardHeader className="pb-3">
<CardTitle>Create Incident</CardTitle>
<CardDescription>
Log a confirmed Redis incident to track downtime.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Redis Master OOM"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief details about the incident"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="startTime">Start Time</Label>
<Input
id="startTime"
type="datetime-local"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="endTime">End Time</Label>
<Input
id="endTime"
type="datetime-local"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
required
/>
</div>
</div>
<Button type="submit" className="w-full mt-2" size="sm">
<Plus className="mr-2 size-4" />
Create Incident
</Button>
</form>
</CardContent>
</Card>
<div className="flex-1 overflow-auto px-1">
<div className="space-y-4 pb-4">
<h3 className="text-xs font-medium text-muted-foreground px-4 uppercase tracking-wider">
Active Incidents ({incidents.length})
</h3>
{incidents.length === 0 ? (
<div className="px-4 py-8 text-center text-muted-foreground text-xs border border-dashed mx-4">
No incidents recorded
</div>
) : (
<div className="grid gap-3 px-1">
{incidents.map((incident) => {
const isCurrentAlertAttached =
selectedAlert?.attachedIncidentId === incident.id;
return (
<Card
key={incident.id}
className={cn(
"relative transition-colors",
isCurrentAlertAttached &&
"border-primary/50 bg-primary/5",
)}
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<div className="grid gap-1">
<CardTitle className="leading-tight">
{incident.title}
</CardTitle>
<CardDescription className="font-mono text-[10px]">
ID: {incident.id.slice(0, 8)}
</CardDescription>
</div>
{selectedAlert && (
<Button
variant={
isCurrentAlertAttached
? "destructive"
: "secondary"
}
size="xs"
onClick={() =>
isCurrentAlertAttached
? onDetachAlert(selectedAlert.id)
: onAttachAlert(selectedAlert.id, incident.id)
}
className={cn(
"shrink-0",
isCurrentAlertAttached &&
"bg-destructive/10 text-destructive hover:bg-destructive/20",
)}
>
{isCurrentAlertAttached ? (
<>
<Link2Off className="mr-1 size-3" />
Detach Alert
</>
) : (
<>
<Link className="mr-1 size-3" />
Attach Alert
</>
)}
</Button>
)}
</div>
</CardHeader>
<CardContent className="pb-2 grid gap-1.5">
<p className="text-xs text-muted-foreground">
{incident.description || "No description provided."}
</p>
<div className="grid grid-cols-2 gap-2 text-[10px] text-muted-foreground font-mono mt-1 pt-2 border-t border-border/50">
<div>
<span className="opacity-50 block mb-0.5">START</span>
{format(incident.startTime, "yyyy-MM-dd HH:mm")}
</div>
<div>
<span className="opacity-50 block mb-0.5">END</span>
{format(incident.endTime, "yyyy-MM-dd HH:mm")}
</div>
</div>
</CardContent>
<CardFooter className="pt-0 pb-3 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1.5 w-full">
<div
className={cn(
"size-1.5 rounded-full",
incident.attachedAlertIds.length > 0
? "bg-primary"
: "bg-muted-foreground/30",
)}
/>
{incident.attachedAlertIds.length} alerts attached
</div>
</CardFooter>
</Card>
);
})}
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -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 (
<div className="grid gap-4 md:grid-cols-3">
<Card className="rounded-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Error Coverage
</CardTitle>
</CardHeader>
<CardContent>
<Skeleton className="h-7 w-20 rounded-none" />
<Skeleton className="mt-1 h-4 w-32 rounded-none" />
</CardContent>
</Card>
<Card className="rounded-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Downtime
</CardTitle>
</CardHeader>
<CardContent>
<Skeleton className="h-7 w-20 rounded-none" />
</CardContent>
</Card>
<Card className="rounded-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Invalid Alerts
</CardTitle>
</CardHeader>
<CardContent>
<Skeleton className="h-7 w-20 rounded-none" />
<Skeleton className="mt-1 h-4 w-32 rounded-none" />
</CardContent>
</Card>
</div>
);
}
if (!kpis) {
return (
<Card className="rounded-none">
<CardContent className="pt-6 text-center text-xs text-muted-foreground">
No KPI data available
</CardContent>
</Card>
);
}
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 (
<div className="grid gap-4 md:grid-cols-3">
<Card className="rounded-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Error Coverage</CardTitle>
</CardHeader>
<CardContent>
<div className={cn("text-2xl font-bold", coverageColor)}>
{kpis.errorCoverageRatio.toFixed(1)}%
</div>
<p className="text-xs text-muted-foreground">
{kpis.coveredIncidents} of {kpis.totalIncidents} incidents
</p>
</CardContent>
</Card>
<Card className="rounded-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Downtime</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{kpis.overallDowntimeFormatted}
</div>
</CardContent>
</Card>
<Card className="rounded-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Invalid Alerts</CardTitle>
</CardHeader>
<CardContent>
<div className={cn("text-2xl font-bold", invalidColor)}>
{kpis.invalidAlertRatio.toFixed(1)}%
</div>
<p className="text-xs text-muted-foreground">
{kpis.invalidAlerts} of {kpis.totalFiringAlerts} alerts
</p>
</CardContent>
</Card>
</div>
);
}

View file

@ -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<string, number>;
}
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;
}

View file

@ -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)

View file

@ -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() {
<h2 className="mb-2 font-medium">API Status</h2>
</section>
</div>
<Link to="/monitor" className="text-blue-500 underline">
Go to Monitor Page
</Link>
</div>
);
}

View file

@ -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<ProcessedAlert[]>([]);
const [incidents, setIncidents] = React.useState<Incident[]>([]);
const [kpis, setKpis] = React.useState<KpiMetrics | null>(null);
const [selectedAlert, setSelectedAlert] =
React.useState<ProcessedAlert | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const recalculateKpis = React.useCallback(
async (currentAlerts: ProcessedAlert[], currentIncidents: Incident[]) => {
try {
const newKpis = await invoke<KpiMetrics>("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<ProcessedAlert[]>(
"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<ProcessedAlert[]>(
"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<Incident, "attachedAlertIds">) => {
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 (
<div className="flex h-screen flex-col overflow-hidden">
<header className="flex shrink-0 items-center justify-between border-b px-4 py-3">
<h1 className="text-sm font-semibold">Alert Monitor</h1>
<Button onClick={handleLoadFile} disabled={isLoading} size="sm">
<FileUp className="mr-2 size-4" />
{isLoading ? "Loading..." : "Load Alerts JSON"}
</Button>
</header>
{error && (
<div className="shrink-0 border-b border-destructive/50 bg-destructive/10 px-4 py-2 text-xs text-destructive">
{error}
</div>
)}
<div className="shrink-0 border-b p-4">
<KpiDashboard kpis={kpis} isLoading={isLoading} />
</div>
<div className="grid min-h-0 flex-1 grid-cols-[1fr_360px]">
<div className="flex min-h-0 flex-col border-r">
<div className="shrink-0 border-b px-4 py-2">
<span className="text-xs font-medium text-muted-foreground">
Alerts ({alerts.length})
</span>
</div>
{alerts.length === 0 ? (
<div className="flex flex-1 items-center justify-center">
<Card className="max-w-sm">
<CardHeader>
<CardTitle>No Alerts Loaded</CardTitle>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">
Click "Load Alerts JSON" to import alert data from a Grafana
Alerts API export.
</CardContent>
</Card>
</div>
) : (
<AlertList
alerts={alerts}
onToggleInvalid={handleToggleInvalid}
onSelectAlert={handleSelectAlert}
selectedAlertId={selectedAlert?.id}
className="flex-1"
/>
)}
</div>
<div className="min-h-0 overflow-hidden">
<IncidentManager
incidents={incidents}
selectedAlert={selectedAlert}
onAddIncident={handleAddIncident}
onAttachAlert={handleAttachAlert}
onDetachAlert={handleDetachAlert}
/>
</div>
</div>
</div>
);
}

View file

@ -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=="],