diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..9b35746 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,31 @@ +FROM node:22-alpine AS frontend-builder +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@latest --activate + +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile + +COPY . . +RUN pnpm run build + +FROM rust:1.87-alpine AS backend-builder +RUN apk add --no-cache musl-dev +WORKDIR /app + +COPY src-tauri/Cargo.toml src-tauri/Cargo.lock ./ +COPY src-tauri/src ./src +COPY src-tauri/build.rs ./ + +RUN cargo build --release --bin addmon-server --features server --no-default-features + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates + +WORKDIR /app +COPY --from=backend-builder /app/target/release/addmon-server /app/addmon-server +COPY --from=frontend-builder /app/dist /app/dist + +ENV PORT=3000 +EXPOSE 3000 + +CMD ["/app/addmon-server"] diff --git a/apps/web/src-tauri/Cargo.lock b/apps/web/src-tauri/Cargo.lock index 5bff865..7c3e398 100644 --- a/apps/web/src-tauri/Cargo.lock +++ b/apps/web/src-tauri/Cargo.lock @@ -2,6 +2,26 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "addmon" +version = "0.1.0" +dependencies = [ + "axum", + "log", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-axum", + "tauri-plugin-dialog", + "tauri-plugin-fs", + "tauri-plugin-log", + "thiserror 1.0.69", + "tokio", + "tower", + "tower-http", +] + [[package]] name = "adler2" version = "2.0.1" @@ -75,21 +95,6 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -[[package]] -name = "app" -version = "0.1.0" -dependencies = [ - "log", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-dialog", - "tauri-plugin-fs", - "tauri-plugin-log", - "thiserror 1.0.69", -] - [[package]] name = "arrayvec" version = "0.7.6" @@ -186,6 +191,58 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -1499,12 +1556,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -1518,6 +1587,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1969,6 +2039,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.7.6" @@ -1990,6 +2066,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3267,6 +3353,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -3795,6 +3892,25 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-axum" +version = "0.7.1" +source = "git+https://github.com/mcitem/tauri-plugin-axum#0ea5c02790d500c20352113984bf6584429ac3c0" +dependencies = [ + "axum", + "bytes", + "futures-util", + "http-body-util", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "tokio", + "tower", + "tower-http", +] + [[package]] name = "tauri-plugin-dialog" version = "2.4.2" @@ -4089,13 +4205,26 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", + "tokio-macros", "tracing", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "tokio-util" version = "0.7.17" @@ -4218,6 +4347,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -4228,14 +4358,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -4256,6 +4396,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4373,6 +4514,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.22" diff --git a/apps/web/src-tauri/Cargo.toml b/apps/web/src-tauri/Cargo.toml index b01e963..5395ad5 100644 --- a/apps/web/src-tauri/Cargo.toml +++ b/apps/web/src-tauri/Cargo.toml @@ -1,28 +1,56 @@ [package] -name = "app" +name = "addmon" version = "0.1.0" -description = "A Tauri App" +description = "Alert Monitoring App" authors = ["you"] license = "" repository = "" edition = "2021" rust-version = "1.77.2" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +default-run = "addmon-desktop" [lib] -name = "app_lib" +name = "addmon_lib" crate-type = ["staticlib", "cdylib", "rlib"] +[[bin]] +name = "addmon-desktop" +path = "src/main.rs" +required-features = ["desktop"] + +[[bin]] +name = "addmon-server" +path = "src/bin/server.rs" +required-features = ["server"] + +[features] +default = ["desktop"] +desktop = [ + "tauri", + "tauri-build", + "tauri-plugin-log", + "tauri-plugin-fs", + "tauri-plugin-dialog", + "tauri-plugin-axum", +] +server = ["tokio/full", "tower-http"] + [build-dependencies] -tauri-build = { version = "2.5.3", features = [] } +tauri-build = { version = "2.5.3", features = [], optional = true } [dependencies] serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" -tauri = { version = "2.9.5", features = ["macos-private-api"] } -tauri-plugin-log = "2" -tauri-plugin-fs = "2" -tauri-plugin-dialog = "2" thiserror = "1" +axum = "0.8" +tower = "0.5" + +tauri = { version = "2.9.5", features = ["macos-private-api"], optional = true } +tauri-plugin-log = { version = "2", optional = true } +tauri-plugin-fs = { version = "2", optional = true } +tauri-plugin-dialog = { version = "2", optional = true } +tauri-plugin-axum = { git = "https://github.com/mcitem/tauri-plugin-axum", optional = true } + +tokio = { version = "1", features = ["rt-multi-thread", "macros"], default-features = false } +tower-http = { version = "0.6", features = ["fs", "cors"], optional = true } diff --git a/apps/web/src-tauri/build.rs b/apps/web/src-tauri/build.rs index 795b9b7..f57383e 100644 --- a/apps/web/src-tauri/build.rs +++ b/apps/web/src-tauri/build.rs @@ -1,3 +1,4 @@ fn main() { + #[cfg(feature = "desktop")] tauri_build::build() } diff --git a/apps/web/src-tauri/src/api.rs b/apps/web/src-tauri/src/api.rs new file mode 100644 index 0000000..67f51c5 --- /dev/null +++ b/apps/web/src-tauri/src/api.rs @@ -0,0 +1,59 @@ +use axum::{ + extract::DefaultBodyLimit, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; + +use crate::{calculate_kpis, pair_alerts, GrafanaAlert, Incident, KpiMetrics, ProcessedAlert}; + +#[derive(Deserialize)] +pub struct ProcessAlertsRequest { + json_content: String, +} + +pub async fn process_alerts_handler( + Json(req): Json, +) -> Result>, AppError> { + let raw_alerts: Vec = + serde_json::from_str(&req.json_content).map_err(|e| AppError::Json(e.to_string()))?; + Ok(Json(pair_alerts(raw_alerts))) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CalculateKpisRequest { + alerts: Vec, + incidents: Vec, +} + +pub async fn calculate_kpis_handler(Json(req): Json) -> Json { + Json(calculate_kpis(&req.alerts, &req.incidents)) +} + +pub async fn health_handler() -> &'static str { + "ok" +} + +pub fn create_router() -> Router { + Router::new() + .route("/api/health", get(health_handler)) + .route("/api/process-alerts", post(process_alerts_handler)) + .route("/api/calculate-kpis", post(calculate_kpis_handler)) + .layer(DefaultBodyLimit::max(50 * 1024 * 1024)) +} + +pub enum AppError { + Json(String), +} + +impl IntoResponse for AppError { + fn into_response(self) -> axum::response::Response { + let (status, message) = match self { + AppError::Json(msg) => (StatusCode::BAD_REQUEST, msg), + }; + (status, Json(serde_json::json!({ "error": message }))).into_response() + } +} diff --git a/apps/web/src-tauri/src/bin/server.rs b/apps/web/src-tauri/src/bin/server.rs new file mode 100644 index 0000000..9f8782b --- /dev/null +++ b/apps/web/src-tauri/src/bin/server.rs @@ -0,0 +1,22 @@ +use addmon_lib::create_router; +use std::net::SocketAddr; +use tower_http::services::{ServeDir, ServeFile}; + +#[tokio::main] +async fn main() { + let port: u16 = std::env::var("PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(3000); + + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + + let api = create_router(); + let static_files = ServeDir::new("dist").not_found_service(ServeFile::new("dist/index.html")); + let app = api.fallback_service(static_files); + + println!("Server listening on http://{}", addr); + + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/apps/web/src-tauri/src/commands.rs b/apps/web/src-tauri/src/commands.rs index 12a4c02..ea933eb 100644 --- a/apps/web/src-tauri/src/commands.rs +++ b/apps/web/src-tauri/src/commands.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "desktop")] + use crate::{calculate_kpis, pair_alerts, AppError, GrafanaAlert, Incident, KpiMetrics, ProcessedAlert}; use std::fs; diff --git a/apps/web/src-tauri/src/lib.rs b/apps/web/src-tauri/src/lib.rs index b5f2604..2141e6d 100644 --- a/apps/web/src-tauri/src/lib.rs +++ b/apps/web/src-tauri/src/lib.rs @@ -1,3 +1,5 @@ +mod api; +#[cfg(feature = "desktop")] mod commands; use serde::{Deserialize, Serialize}; @@ -49,7 +51,6 @@ pub struct AlertData { 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>, @@ -280,9 +281,15 @@ fn format_duration(ms: u64) -> String { } } +pub use api::create_router; + +#[cfg(feature = "desktop")] #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let router = create_router(); + tauri::Builder::default() + .plugin(tauri_plugin_axum::init(router)) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) .setup(|app| { diff --git a/apps/web/src-tauri/src/main.rs b/apps/web/src-tauri/src/main.rs index ad5fe83..885cf28 100644 --- a/apps/web/src-tauri/src/main.rs +++ b/apps/web/src-tauri/src/main.rs @@ -1,6 +1,5 @@ -// Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - app_lib::run(); + addmon_lib::run(); } diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts new file mode 100644 index 0000000..fa33b89 --- /dev/null +++ b/apps/web/src/lib/api/client.ts @@ -0,0 +1,62 @@ +import type { Incident, KpiMetrics, ProcessedAlert } from "@/lib/types/alerts"; + +const isTauri = (): boolean => + typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; + +export async function processAlerts( + jsonContent: string, +): Promise { + if (isTauri()) { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("process_alerts_json", { jsonContent }); + } + + const response = await fetch("/api/process-alerts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ json_content: jsonContent }), + }); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ error: response.statusText })); + throw new Error(error.error ?? "Request failed"); + } + + return response.json(); +} + +export async function calculateKpis( + alerts: ProcessedAlert[], + incidents: Incident[], +): Promise { + if (isTauri()) { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("calculate_kpis_command", { alerts, incidents }); + } + + const response = await fetch("/api/calculate-kpis", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ alerts, incidents }), + }); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ error: response.statusText })); + throw new Error(error.error ?? "Request failed"); + } + + return response.json(); +} + +export async function healthCheck(): Promise { + if (isTauri()) { + return "ok"; + } + + const response = await fetch("/api/health"); + return response.text(); +} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index a3234f9..9aeaaa3 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,8 +1,5 @@ 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, Filter, Search } from "lucide-react"; +import { ArrowDownWideNarrow, FileUp, Filter, Search } from "lucide-react"; import * as React from "react"; import { AlertList } from "@/components/monitor/alert-list"; @@ -19,9 +16,11 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; +import { calculateKpis, processAlerts } from "@/lib/api/client"; import type { Incident, KpiMetrics, ProcessedAlert } from "@/lib/types/alerts"; type StatusFilter = "all" | "resolved" | "unresolved"; +type SortBy = "alertTime" | "duration"; export const Route = createFileRoute("/")({ component: MonitorPage, @@ -37,9 +36,10 @@ function MonitorPage() { const [error, setError] = React.useState(null); const [searchQuery, setSearchQuery] = React.useState(""); const [statusFilter, setStatusFilter] = React.useState("all"); + const [sortBy, setSortBy] = React.useState("alertTime"); const filteredAlerts = React.useMemo(() => { - return alerts.filter((alert) => { + const filtered = alerts.filter((alert) => { if (statusFilter === "resolved" && !alert.isResolved) return false; if (statusFilter === "unresolved" && alert.isResolved) return false; @@ -54,7 +54,14 @@ function MonitorPage() { return false; }); - }, [alerts, searchQuery, statusFilter]); + + return filtered.sort((a, b) => { + if (sortBy === "duration") { + return b.durationMs - a.durationMs; + } + return b.alertTime - a.alertTime; + }); + }, [alerts, searchQuery, statusFilter, sortBy]); const selectAllState = React.useMemo(() => { if (filteredAlerts.length === 0) return "none" as const; @@ -67,10 +74,7 @@ function MonitorPage() { const recalculateKpis = React.useCallback( async (currentAlerts: ProcessedAlert[], currentIncidents: Incident[]) => { try { - const newKpis = await invoke("calculate_kpis_command", { - alerts: currentAlerts, - incidents: currentIncidents, - }); + const newKpis = await calculateKpis(currentAlerts, currentIncidents); setKpis(newKpis); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -79,35 +83,65 @@ function MonitorPage() { [], ); + const isTauri = "__TAURI_INTERNALS__" in window; + const fileInputRef = React.useRef(null); + const handleLoadFile = async () => { + if (isTauri) { + try { + setIsLoading(true); + setError(null); + + const { open } = await import("@tauri-apps/plugin-dialog"); + const { readTextFile } = await import("@tauri-apps/plugin-fs"); + + const filePath = await open({ + multiple: false, + filters: [{ name: "JSON", extensions: ["json"] }], + }); + + if (!filePath) { + setIsLoading(false); + return; + } + + const content = await readTextFile(filePath); + const newAlerts = await processAlerts(content); + + setAlerts(newAlerts); + setSelectedAlert(null); + await recalculateKpis(newAlerts, incidents); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsLoading(false); + } + } else { + fileInputRef.current?.click(); + } + }; + + const handleFileInputChange = async ( + e: React.ChangeEvent, + ) => { + const file = e.target.files?.[0]; + if (!file) return; + try { setIsLoading(true); setError(null); - const filePath = await open({ - multiple: false, - filters: [{ name: "JSON", extensions: ["json"] }], - }); + const content = await file.text(); + const newAlerts = await processAlerts(content); - if (!filePath) { - setIsLoading(false); - return; - } - - const content = await readTextFile(filePath); - - const processedAlerts = await invoke( - "process_alerts_json", - { jsonContent: content }, - ); - - setAlerts(processedAlerts); + setAlerts(newAlerts); setSelectedAlert(null); - await recalculateKpis(processedAlerts, incidents); + await recalculateKpis(newAlerts, incidents); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setIsLoading(false); + e.target.value = ""; } }; @@ -184,15 +218,15 @@ function MonitorPage() { ); const updatedIncidents = incidentId ? incidents.map((i) => - i.id === incidentId - ? { - ...i, - attachedAlertIds: i.attachedAlertIds.filter( - (aid) => aid !== alertId, - ), - } - : i, - ) + i.id === incidentId + ? { + ...i, + attachedAlertIds: i.attachedAlertIds.filter( + (aid) => aid !== alertId, + ), + } + : i, + ) : incidents; setAlerts(updatedAlerts); @@ -208,6 +242,13 @@ function MonitorPage() { return (
+

Alert Monitor

+ } + /> + + setSortBy(v as SortBy)} + > + + Newest First + + + Longest Duration + + + +