refactor: move Tauri window title to config and add mode toggle to header

This commit is contained in:
Syahdan 2025-12-29 20:16:20 +07:00
parent 0a4b289d70
commit d19a7bdaa0
11 changed files with 489 additions and 65 deletions

31
apps/web/Dockerfile Normal file
View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
fn main() {
#[cfg(feature = "desktop")]
tauri_build::build()
}

View file

@ -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<ProcessAlertsRequest>,
) -> Result<Json<Vec<ProcessedAlert>>, AppError> {
let raw_alerts: Vec<GrafanaAlert> =
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<ProcessedAlert>,
incidents: Vec<Incident>,
}
pub async fn calculate_kpis_handler(Json(req): Json<CalculateKpisRequest>) -> Json<KpiMetrics> {
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()
}
}

View file

@ -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();
}

View file

@ -1,3 +1,5 @@
#![cfg(feature = "desktop")]
use crate::{calculate_kpis, pair_alerts, AppError, GrafanaAlert, Incident, KpiMetrics, ProcessedAlert};
use std::fs;

View file

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

View file

@ -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();
}

View file

@ -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<ProcessedAlert[]> {
if (isTauri()) {
const { invoke } = await import("@tauri-apps/api/core");
return invoke<ProcessedAlert[]>("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<KpiMetrics> {
if (isTauri()) {
const { invoke } = await import("@tauri-apps/api/core");
return invoke<KpiMetrics>("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<string> {
if (isTauri()) {
return "ok";
}
const response = await fetch("/api/health");
return response.text();
}

View file

@ -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<string | null>(null);
const [searchQuery, setSearchQuery] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState<StatusFilter>("all");
const [sortBy, setSortBy] = React.useState<SortBy>("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<KpiMetrics>("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<HTMLInputElement>(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<HTMLInputElement>,
) => {
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<ProcessedAlert[]>(
"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 (
<div className="flex h-screen flex-col overflow-hidden">
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileInputChange}
className="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">
@ -272,6 +313,31 @@ function MonitorPage() {
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="sm" className="h-7 gap-1 px-2">
<ArrowDownWideNarrow className="size-3" />
<span className="text-xs">
{sortBy === "alertTime" ? "Newest" : "Longest"}
</span>
</Button>
}
/>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={sortBy}
onValueChange={(v) => setSortBy(v as SortBy)}
>
<DropdownMenuRadioItem value="alertTime">
Newest First
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="duration">
Longest Duration
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<div className="relative ml-auto flex-1 max-w-xs">
<Search className="absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground" />
<Input