diff --git a/.forgejo/workflows/pre-release.yaml b/.forgejo/workflows/pre-release.yaml new file mode 100644 index 0000000..140136c --- /dev/null +++ b/.forgejo/workflows/pre-release.yaml @@ -0,0 +1,180 @@ +name: Multi-Platform Release Build +on: + push: + tags: + - "v*.*.*-*" +jobs: + build: + runs-on: docker + strategy: + fail-fast: false + matrix: + include: + - target: native + cross: false + platform: linux/arm64 + + - target: x86_64-unknown-linux-gnu + cross: true + platform: linux/amd64 + deps: "build-essential gcc-x86-64-linux-gnu libc6-dev-amd64-cross pkg-config binutils-x86-64-linux-gnu" + env: + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: x86_64-linux-gnu-gcc + + - target: x86_64-pc-windows-gnu + cross: true + platform: linux/amd64 + deps: "gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 cmake nasm" + env: + CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc + CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc + + steps: + - name: Checkout code + uses: https://code.forgejo.org/actions/checkout@v4 + + - name: Setup Rust + uses: https://github.com/dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: Cache Rust dependencies + uses: https://code.forgejo.org/actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-${{ matrix.target }}- + ${{ runner.os }}-cargo- + + - name: Native Build Release + if: matrix.target == 'native' + run: | + NATIVE_TARGET=$(rustc -vV | grep 'host:' | cut -d' ' -f2) + echo "Building for native target: $NATIVE_TARGET" + cargo build --release + TARGET_BINARY="target/release/uptime-kuma-dashboard" + OUTPUT_NAME="uptime-kuma-dashboard-$NATIVE_TARGET" + mkdir -p release-artifacts + cp "$TARGET_BINARY" "release-artifacts/$OUTPUT_NAME" + strip "release-artifacts/$OUTPUT_NAME" 2>/dev/null || true + chmod +x "release-artifacts/$OUTPUT_NAME" + ls -lh "release-artifacts/$OUTPUT_NAME" + + - name: Get Rust version + id: rust-version + run: echo "version=$(rustc --version | cut -d' ' -f2)" >> $GITHUB_OUTPUT + + - name: Cross-Build Linux x86_64 Release + if: matrix.target == 'x86_64-unknown-linux-gnu' + uses: docker://rust:slim + with: + args: > + sh -c " + rustup target add ${{ matrix.target }} && + apt-get update && apt-get install -y ${{ matrix.deps }} && + mkdir -p ~/.cargo && + echo '[target.${{ matrix.target }}]\nlinker = \"x86_64-linux-gnu-gcc\"' > ~/.cargo/config.toml && + cargo build --release --target ${{ matrix.target }} && + mkdir -p release-artifacts && + cp target/${{ matrix.target }}/release/uptime-kuma-dashboard release-artifacts/uptime-kuma-dashboard-${{ matrix.target }} && + x86_64-linux-gnu-strip release-artifacts/uptime-kuma-dashboard-${{ matrix.target }} && + chmod +x release-artifacts/uptime-kuma-dashboard-${{ matrix.target }}" + env: ${{ matrix.env }} + options: --platform ${{ matrix.platform }} + + - name: Cross-Build Windows x86_64 Release + if: matrix.target == 'x86_64-pc-windows-gnu' + uses: docker://rust:slim + with: + args: > + sh -c " + rustup target add ${{ matrix.target }} && + apt-get update && apt-get install -y ${{ matrix.deps }} && + cargo build --release --target ${{ matrix.target }} && + mkdir -p release-artifacts && + cp target/${{ matrix.target }}/release/uptime-kuma-dashboard.exe release-artifacts/uptime-kuma-dashboard-${{ matrix.target }}.exe && + ls -lh release-artifacts/" + env: ${{ matrix.env }} + options: --platform ${{ matrix.platform }} + + - name: Upload artifacts + uses: https://data.forgejo.org/forgejo/upload-artifact@v4 + with: + name: binary-${{ matrix.target }} + path: release-artifacts/ + retention-days: 1 + + create-release: + runs-on: docker + needs: build + permissions: + contents: write + steps: + - name: Checkout code + uses: https://code.forgejo.org/actions/checkout@v4 + + - name: Download all artifacts + uses: https://data.forgejo.org/forgejo/download-artifact@v4 + with: + path: all-artifacts/ + + - name: Prepare release artifacts + run: | + mkdir -p release-artifacts + find all-artifacts -type f -name "uptime-kuma-dashboard-*" -exec cp {} release-artifacts/ \; + ls -lh release-artifacts/ + + - name: Generate checksums + run: | + cd release-artifacts + sha256sum uptime-kuma-dashboard-* > SHA256SUMS.txt + cat SHA256SUMS.txt + + - name: Create Release + uses: https://code.forgejo.org/actions/forgejo-release@v2 + with: + direction: upload + url: https://git.marcodearaujo.com + repo: marcodearaujo/uptime-kuma-dashboard + token: ${{ secrets.FORGEJO_TOKEN }} + release-dir: release-artifacts + title: "Pre-Release ${{ forgejo.ref_name }}" + prerelease: true + tag: ${{ forgejo.ref_name }} + release-notes: | + ## 🚀 Multi-Platform Release + Compiled on Forgejo Runner **${{ runner.arch }}** + + ### 📦 Available Binaries + This release includes binaries for the following platforms: + - **Linux x86_64** (Intel/AMD 64-bit) + - **Linux ARM64** (aarch64) - Raspberry Pi 4, ARM servers + - **Windows x86_64** (64-bit) + + ### 🚀 How to use + #### Linux/ARM: + # Download the appropriate binary + wget https://your-forgejo.com/releases/download/${{ forgejo.ref_name }}/uptime-kuma-dashboard- + # Make executable + chmod +x uptime-kuma-dashboard-* + # Run + ./uptime-kuma-dashboard-* --base-url https://your-kuma --slug your-slug + + #### Windows: + # Download uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe + # Run in PowerShell or CMD + .\uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe --base-url https://your-kuma --slug your-slug + + ### ✅ Verify Checksums + sha256sum -c SHA256SUMS.txt + + ### 🏗️ Build Information + - Rust Version: ${{ steps.rust-version.outputs.version }} + - Build Date: $(date +'%Y-%m-%d') + - Build Type: Release (optimized with LTO) diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml index 4b2ee51..e62f186 100644 --- a/.forgejo/workflows/release.yaml +++ b/.forgejo/workflows/release.yaml @@ -158,26 +158,20 @@ jobs: ### 🚀 How to use #### Linux/ARM: - ```bash - # Download the appropriate binary - wget https://your-forgejo.com/releases/download/${{ forgejo.ref_name }}/uptime-kuma-dashboard- - # Make executable - chmod +x uptime-kuma-dashboard-* - # Run - ./uptime-kuma-dashboard-* --base-url https://your-kuma --slug your-slug - ``` + # Download the appropriate binary + wget https://your-forgejo.com/releases/download/${{ forgejo.ref_name }}/uptime-kuma-dashboard- + # Make executable + chmod +x uptime-kuma-dashboard-* + # Run + ./uptime-kuma-dashboard-* --base-url https://your-kuma --slug your-slug #### Windows: - ```powershell - # Download uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe - # Run in PowerShell or CMD - .\uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe --base-url https://your-kuma --slug your-slug - ``` + # Download uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe + # Run in PowerShell or CMD + .\uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe --base-url https://your-kuma --slug your-slug ### ✅ Verify Checksums - ```bash - sha256sum -c SHA256SUMS.txt - ``` + sha256sum -c SHA256SUMS.txt ### 🏗️ Build Information - Rust Version: ${{ steps.rust-version.outputs.version }} diff --git a/Cargo.lock b/Cargo.lock index 4918cbe..62df022 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,9 +78,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "atomic" @@ -236,9 +236,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -249,9 +249,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.54" +version = "4.5.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" dependencies = [ "clap_builder", "clap_derive", @@ -259,9 +259,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.5.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" dependencies = [ "anstream", "anstyle", @@ -271,9 +271,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", @@ -1955,9 +1955,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "aws-lc-rs", "once_cell", @@ -2132,9 +2132,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.148" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", @@ -2668,7 +2668,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "uptime-kuma-dashboard" -version = "0.1.0" +version = "0.38.2" dependencies = [ "anyhow", "chrono", @@ -2689,9 +2689,9 @@ dependencies = [ [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", diff --git a/Cargo.toml b/Cargo.toml index ddcead8..acc8090 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,28 +1,28 @@ [package] name = "uptime-kuma-dashboard" -version = "0.1.0" +version = "0.38.2" edition = "2024" [dependencies] -clap = { version = "4.5.54", features = ["derive", "env"] } +clap = { version = "4.5.57", features = ["derive", "env"] } reqwest = { version = "0.13.1", default-features = false, features = [ "blocking", "json", "rustls", ] } -anyhow = "1.0" +anyhow = "1.0.101" fluent-templates = "0.13.2" unic-langid = "0.9.6" sys-locale = "0.3.2" serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0.148" +serde_json = "1.0.149" once_cell = "1.19" -url = "2.5.7" +url = "2.5.8" ratatui = "0.30.0" crossterm = "0.29.0" -chrono = "0.4.42" +chrono = "0.4.43" rayon = "1.11.0" -rustls = { version = "0.23.35", default-features = false, features = ["ring"] } +rustls = { version = "0.23.36", default-features = false, features = ["ring"] } [features] default = [] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..732499f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Marco De Araujo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bbfca7e --- /dev/null +++ b/README.md @@ -0,0 +1,145 @@ +# Uptime Kuma Terminal Dashboard + +![Terminal Dashboard Preview](https://img.shields.io/badge/Rust-1.76+-orange?style=flat-square&logo=rust) + +A terminal-based monitoring dashboard for Uptime Kuma that displays service status, uptime percentages, and historical performance data directly in your terminal. Built with Rust for performance and reliability. + +## Demo + + + +## Overview + +This personal project started as a way to monitor my home lab services without needing a web browser. It connects to your Uptime Kuma instance API and presents a clean, color-coded view of your services' status with real-time updates. The dashboard works great on servers, remote terminals, or anywhere you prefer a lightweight monitoring solution. + +Perfect for DevOps engineers, system administrators, or anyone who prefers terminal workflows over web interfaces for quick status checks. + +## Features + +- Real-time service monitoring with automatic refresh +- Color-coded status indicators (green for up, red for down) +- Historical uptime visualization with compact status history lines +- Responsive terminal UI that adapts to window size +- Internationalization support (English and Portuguese) +- Configurable refresh intervals +- Minimal resource usage (runs efficiently even on low-power devices) +- Keyboard navigation support (arrow keys, home/end) +- Mobile-friendly design for SSH connections + +## Tech Stack + +This project demonstrates proficiency with several modern technologies: + +- **Rust** (core language with async capabilities) +- **ratatui** (terminal UI framework) +- **reqwest** (HTTP client for API communication) +- **serde** (JSON serialization/deserialization) +- **clap** (command-line argument parsing) +- **fluent-templates** (internationalization framework) +- REST API integration patterns +- Error handling and logging best practices +- Cross-platform terminal development + +You can find the latest releases in the [Releases section](https://git.marcodearaujo.com/marcodearaujo/uptime-kuma-dashboard/releases). + +## Installation + +### Option 1: Download built Binary (Recommended) + +1. Go to the [Releases page](https://git.marcodearaujo.com/marcodearaujo/uptime-kuma-dashboard/releases) +2. Download the binary for your operating system: + - **Linux (x86_64)**: `uptime-kuma-dashboard-x86_64-unknown-linux-gnu` + - **Linux (ARM64)**: `uptime-kuma-dashboard-aarch64-unknown-linux-gnu` (Raspberry Pi, ARM servers) + - **Windows**: `uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe` +3. Verify the checksum (recommended for security): + ```bash + sha256sum -c SHA256SUMS.txt + ``` +4. Make it executable (Linux/ARM only): + ```bash + chmod +x uptime-kuma-dashboard-* + ``` +5. Run with your parameters: + ```bash + ./uptime-kuma-dashboard-* --base-url="http://your-kuma:3001/" --slug="your-slug" + ``` + +### Option 2: Build From Source + +### Prerequisites + +- Rust 1.74+ toolchain (install via [rustup](https://rustup.rs/)) +- Uptime Kuma instance with API access + +### Build from Source + +```bash +# Clone the repository +git clone https://git.marcodearaujo.com/marcodearaujo/uptime-kuma-dashboard.git +cd uptime-kuma-dashboard + +# Build the project +cargo build --release + +# Run the dashboard (replace with your Uptime Kuma URL and status page slug) +./target/release/uptime-kuma-dashboard \ + --base-url="http://your-uptime-kuma-instance:3001/" \ + --slug="your-status-page-slug" +``` + +## Usage + +The dashboard requires two parameters: + +- `--base-url`: Your Uptime Kuma instance URL (including port) +- `--slug`: Your status page slug identifier + +Example: + +```bash +./uptime-kuma-dashboard --base-url="http://192.168.1.100:3001/" --slug="home-services" +``` + +**Controls during runtime:** + +- `q` or `ESC`: Exit the dashboard +- `↑`/`↓` or `k`/`j`: Navigate through monitors +- `Home`/`End`: Jump to top/bottom of the list + +## Configuration + +You can set environment variables to avoid typing parameters each time: + +```bash +export UPTIME_KUMA_URL="http://your-uptime-kuma-instance:3001/" +export STATUS_PAGE_SLUG="your-status-page-slug" +``` + +## Development Notes + +This project follows Rust best practices including: + +- Comprehensive error handling with `anyhow` +- Internationalization-ready architecture +- Modular code organization +- Performance optimization for terminal rendering +- Cross-platform compatibility testing + +The architecture separates concerns into: + +- API client layer +- Data processing core +- UI rendering components +- Configuration management +- Internationalization system + +## License + +This project is available under the MIT License - see the [LICENSE](LICENSE) file for details. + +--- + +_Developed as a personal learning project to improve Rust skills and create a useful tool for my home lab monitoring. Feedback welcome!_ diff --git a/assets/demo.webm b/assets/demo.webm new file mode 100644 index 0000000..fb10b24 Binary files /dev/null and b/assets/demo.webm differ diff --git a/src/api/client.rs b/src/api/client.rs index eb3032f..6dc4448 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -1,9 +1,11 @@ +use std::time::Duration; + use anyhow::Result; use reqwest::blocking::Client; use crate::{ api::endpoints::UptimeKumaEndpoints, - data::{self, heartbeat::HeartbeatResponse, status_page::model::StatusPageResponse}, + data::{self, heartbeat::HeartbeatResponse, status_page::model::StatusPageResponse}, i18n::t, }; #[derive(Debug, Clone)] @@ -13,8 +15,9 @@ pub struct UptimeKumaClient { impl UptimeKumaClient { pub fn new() -> Self { + let client = Client::builder().connect_timeout(Duration::from_secs(10)).timeout(Duration::from_secs(30)).build().unwrap_or_else(|_| panic!("{}", t("http-build-error"))); Self { - client: Client::new(), + client, } } diff --git a/src/core/data.rs b/src/core/data.rs index bf43969..028515d 100644 --- a/src/core/data.rs +++ b/src/core/data.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::collections::HashMap; use crate::core::models::{UnifiedData, UnifiedGroupData, UnifiedMonitorData}; @@ -6,32 +7,45 @@ use crate::data::{ status_page::model::StatusPageResponse, }; -pub fn unify_data(status_page: &StatusPageResponse, heartbeat: &HeartbeatResponse) -> UnifiedData { +pub fn unify_data<'a>( + status_page: &'a StatusPageResponse, + heartbeat: &'a HeartbeatResponse, +) -> UnifiedData<'a> { let mut groups = Vec::with_capacity(status_page.public_group_list.len()); - let heartbeat_map: HashMap> = heartbeat - .monitors - .iter() - .map(|m| (m.monitor_id, m.heartbeats.clone())) - .collect(); - let uptime_map: HashMap<(u64, u32), UptimeData> = heartbeat - .uptime_data - .iter() - .map(|u| ((u.monitor_id, u.period_hours), u.clone())) - .collect(); + let mut heartbeat_map: HashMap = + HashMap::with_capacity(heartbeat.monitors.len()); + heartbeat_map.extend( + heartbeat + .monitors + .iter() + .map(|m| (m.monitor_id, &m.heartbeats[..])), + ); + + let mut uptime_map: HashMap<(u64, u32), &'a UptimeData> = + HashMap::with_capacity(heartbeat.uptime_data.len()); + uptime_map.extend( + heartbeat + .uptime_data + .iter() + .map(|u| ((u.monitor_id, u.period_hours), u)), + ); for group in &status_page.public_group_list { let mut monitors = Vec::with_capacity(group.monitor_list.len()); for monitor_info in &group.monitor_list { - let uptime_data = uptime_map.get(&(monitor_info.id, 24)).cloned(); - let heartbeats = heartbeat_map - .get(&monitor_info.id) - .cloned() - .unwrap_or_default(); + let uptime_data = uptime_map.get(&(monitor_info.id, 24)).copied(); + let heartbeats = heartbeat_map.get(&monitor_info.id).copied().unwrap_or(&[]); + + let name: Cow<'a, str> = if monitor_info.name.len() > 100 { + Cow::Owned(monitor_info.name.clone()) + } else { + Cow::Borrowed(monitor_info.name.as_str()) + }; monitors.push(UnifiedMonitorData { id: monitor_info.id, - name: monitor_info.name.clone(), + name, heartbeats, uptime_data, }); @@ -40,7 +54,7 @@ pub fn unify_data(status_page: &StatusPageResponse, heartbeat: &HeartbeatRespons monitors.sort_by_key(|m| m.id); groups.push(UnifiedGroupData { - group_info: group.clone(), + group_info: group, monitors, }); } @@ -48,8 +62,8 @@ pub fn unify_data(status_page: &StatusPageResponse, heartbeat: &HeartbeatRespons groups.sort_by_key(|g| g.group_info.weight); UnifiedData { - title: status_page.config.title.clone(), - description: status_page.config.description.clone(), + title: Cow::Borrowed(&status_page.config.title), + description: status_page.config.description.as_deref().map(Cow::Borrowed), groups, auto_refresh_interval: status_page.config.auto_refresh_interval, } diff --git a/src/core/models.rs b/src/core/models.rs index d3851a4..441f2dc 100644 --- a/src/core/models.rs +++ b/src/core/models.rs @@ -1,24 +1,26 @@ +use std::borrow::Cow; + use crate::data::heartbeat::model::{HeartbeatEntry, UptimeData}; use crate::data::status_page::model::{StatusPageGroup}; #[derive(Debug, Clone)] -pub struct UnifiedMonitorData { +pub struct UnifiedMonitorData<'a> { pub id: u64, - pub name: String, - pub heartbeats: Vec, - pub uptime_data: Option, + pub name: Cow<'a, str>, + pub heartbeats: &'a [HeartbeatEntry], + pub uptime_data: Option<&'a UptimeData>, } #[derive(Debug, Clone)] -pub struct UnifiedData { - pub title: String, - pub description: Option, +pub struct UnifiedData<'a> { + pub title: Cow<'a, str>, + pub description: Option>, pub auto_refresh_interval: u32, - pub groups: Vec, + pub groups: Vec>, } #[derive(Debug, Clone)] -pub struct UnifiedGroupData { - pub group_info: StatusPageGroup, - pub monitors: Vec, +pub struct UnifiedGroupData<'a> { + pub group_info: &'a StatusPageGroup, + pub monitors: Vec>, } diff --git a/src/data/heartbeat/model.rs b/src/data/heartbeat/model.rs index d4ed8cf..042ca91 100644 --- a/src/data/heartbeat/model.rs +++ b/src/data/heartbeat/model.rs @@ -55,7 +55,7 @@ impl HeartbeatResponse { for (monitor_id_str, heartbeats) in &self.heartbeat_list_raw { let monitor_id = monitor_id_str .parse::() - .with_context(|| format!("{}", t("invalid-monitor-id")))?; + .with_context(|| t("invalid-monitor-id").to_string())?; self.monitors.push(MonitorHeartbeats { monitor_id, @@ -77,11 +77,11 @@ impl HeartbeatResponse { let monitor_id = parts[0] .parse::() - .with_context(|| format!("{}", t("invalid-monitor-id")))?; + .with_context(|| t("invalid-monitor-id").to_string())?; let period_hours = parts[1] .parse::() - .with_context(|| format!("{}", t("invalid-period-hours")))?; + .with_context(|| t("invalid-period-hours").to_string())?; self.uptime_data.push(UptimeData { monitor_id, diff --git a/src/data/heartbeat/parser.rs b/src/data/heartbeat/parser.rs index 3733065..72e4bcd 100644 --- a/src/data/heartbeat/parser.rs +++ b/src/data/heartbeat/parser.rs @@ -3,8 +3,8 @@ use crate::i18n::t; use anyhow::{Context, Ok, Result}; pub fn parse_response(json_text: &str) -> Result { - let mut response: HeartbeatResponse = - serde_json::from_str(json_text).with_context(|| t("invalid-json-heartbeat"))?; + let mut response: HeartbeatResponse = serde_json::from_slice(json_text.as_bytes()) + .with_context(|| t("invalid-json-heartbeat"))?; response.process()?; Ok(response) diff --git a/src/data/status_page/parser.rs b/src/data/status_page/parser.rs index a765c58..b6b8c07 100644 --- a/src/data/status_page/parser.rs +++ b/src/data/status_page/parser.rs @@ -3,7 +3,7 @@ use crate::i18n::t; use anyhow::{Context, Ok, Result}; pub fn parse_response(json_text: &str) -> Result { - let response: StatusPageResponse = - serde_json::from_str(json_text).with_context(|| t("invalid-json-status-page"))?; + let response: StatusPageResponse = serde_json::from_slice(json_text.as_bytes()) + .with_context(|| t("invalid-json-status-page"))?; Ok(response) } diff --git a/src/i18n/translate.rs b/src/i18n/translate.rs index 660bebd..f9a01fc 100644 --- a/src/i18n/translate.rs +++ b/src/i18n/translate.rs @@ -34,7 +34,7 @@ pub fn t(key: &str) -> String { } } - let result = LOCALES.lookup(&*get_system_locale(), key); + let result = LOCALES.lookup(get_system_locale(), key); let mut cache_write = cache.write().unwrap(); @@ -51,5 +51,5 @@ pub fn t_with_args(key: &str, args: &HashMap<&'static str, String>) -> String { for (key, value) in args { map.insert(Cow::Borrowed(*key), FluentValue::from(value)); } - LOCALES.lookup_with_args(&get_system_locale(), key, &map) + LOCALES.lookup_with_args(get_system_locale(), key, &map) } diff --git a/src/ui/app.rs b/src/ui/app.rs index 18fa818..2e19131 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,12 +1,10 @@ use crate::api::{UptimeKumaClient, UptimeKumaEndpoints}; use crate::core; -use crate::data::{heartbeat::HeartbeatResponse, status_page::model::StatusPageResponse}; use crate::i18n::{t, t_with_args}; use crate::ui::{ components::{render_footer, render_header, render_monitor_list}, dashboard::model::DashboardViewState, }; -use anyhow::Result; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, execute, @@ -18,23 +16,18 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, - widgets::{Block, Borders, Padding, Paragraph}, + widgets::{Block, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation}, }; use std::{ collections::HashMap, io, - sync::mpsc, - thread, time::{Duration, Instant}, }; const INITIAL_INTERVAL: u32 = 300; - -enum FetchResult { - Heartbeat(Result), - StatusPage(Result), -} +const MAIN_LAYOUT_WITH_SCROLLBAR: [Constraint; 2] = [Constraint::Min(1), Constraint::Length(1)]; +const MAIN_LAYOUT_WITHOUT_SCROLLBAR: [Constraint; 1] = [Constraint::Min(1)]; pub struct App { state: DashboardViewState, @@ -47,6 +40,26 @@ pub struct App { } impl App { + pub fn new(endpoints: UptimeKumaEndpoints) -> io::Result { + let backend = CrosstermBackend::new(io::stdout()); + let mut terminal = Terminal::new(backend)?; + terminal.hide_cursor()?; + + let state = DashboardViewState::new(); + + let initial_interval = Duration::from_secs(INITIAL_INTERVAL as u64); + + Ok(Self { + state, + terminal, + should_quit: false, + last_update: Instant::now(), + update_interval: initial_interval, + endpoints, + client: UptimeKumaClient::new(), + }) + } + fn setup_terminal(&mut self) -> io::Result<()> { enable_raw_mode()?; execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; @@ -116,51 +129,101 @@ impl App { fn render(&mut self) { let _ = self.terminal.draw(|frame| { let area = frame.area(); + + const HEADER_HEIGHT: u16 = 3; + const FOOTER_HEIGHT: u16 = 3; + + let max_content_height = area.height.saturating_sub(HEADER_HEIGHT + FOOTER_HEIGHT); + let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) .constraints([ - Constraint::Length(3), - Constraint::Min(1), - Constraint::Length(3), + Constraint::Length(HEADER_HEIGHT), + Constraint::Length(max_content_height.max(1)), + Constraint::Length(FOOTER_HEIGHT), ]) .split(area); render_header(frame, chunks[0], &self.state); - if self.state.is_loading { - Self::render_loading(frame, chunks[1]); - } else if let Some(error) = &self.state.error_message { - Self::render_error(frame, chunks[1], error); - } else if self.state.groups.is_empty() || self.state.get_all_monitors().is_empty() { - Self::render_no_data(frame, chunks[1]); + let main_constraint = if self.state.show_vertical_scrollbar(chunks[1].height) { + &MAIN_LAYOUT_WITH_SCROLLBAR[..] } else { - render_monitor_list(frame, chunks[1], &self.state); + &MAIN_LAYOUT_WITHOUT_SCROLLBAR[..] + }; + + let main_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints(main_constraint) + .split(chunks[1]); + + if self.state.is_loading { + Self::render_loading(frame, main_chunks[0]); + } else if let Some(error) = &self.state.error_message { + Self::render_error(frame, main_chunks[0], error); + } else if self.state.groups.is_empty() || self.state.get_all_monitors().is_empty() { + Self::render_no_data(frame, main_chunks[0]); + } else { + Self::render_main(frame, main_chunks.to_vec(), &mut self.state); } let seconds_until_update = self .update_interval .checked_sub(self.last_update.elapsed()) - .map(|d| d.as_secs() as u64) + .map(|d| d.as_secs()) .unwrap_or(30); render_footer(frame, chunks[2], seconds_until_update); }); } + fn render_main(frame: &mut Frame, area: Vec, state: &mut DashboardViewState) { + render_monitor_list(frame, area[0], state); + + if !state.show_vertical_scrollbar(area[0].height) { + return; + } + + if area.len() <= 1 { + dbg!(area[0].height); + return; + } + + let max_scroll = state + .get_total_length() + .saturating_sub(area[0].height as usize) + .saturating_add(1); + + state.scroll_state = state + .scroll_state + .content_length(max_scroll.max(1)) + .viewport_content_length(area[0].height as usize); + + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + + frame.render_stateful_widget(scrollbar, area[1], &mut state.scroll_state); + } + fn handle_events(&mut self) -> io::Result<()> { let timeout = Duration::from_secs(1); - if event::poll(timeout)? { - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Release { - return Ok(()); - } + if event::poll(timeout)? + && let Event::Key(key) = event::read()? + { + if key.kind == KeyEventKind::Release { + return Ok(()); + } - match key.code { - KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, - _ => {} - } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, + KeyCode::Up | KeyCode::Char('k') => self.state.scroll_state.prev(), + KeyCode::Down | KeyCode::Char('j') => self.state.scroll_state.next(), + KeyCode::Home => self.state.scroll_state.first(), + KeyCode::End => self.state.scroll_state.last(), + _ => {} } } Ok(()) @@ -180,7 +243,6 @@ impl App { fn refresh_data(&mut self) -> io::Result<()> { self.state.is_loading = true; - self.render(); match self.fetch_and_update_data() { Ok(()) => { @@ -197,57 +259,11 @@ impl App { Ok(()) } - pub fn new(endpoints: UptimeKumaEndpoints) -> io::Result { - let backend = CrosstermBackend::new(io::stdout()); - let mut terminal = Terminal::new(backend)?; - terminal.hide_cursor()?; - - let state = DashboardViewState::new(); - - let initial_interval = Duration::from_secs(INITIAL_INTERVAL as u64); - - Ok(Self { - state, - terminal, - should_quit: false, - last_update: Instant::now(), - update_interval: initial_interval, - endpoints, - client: UptimeKumaClient::new(), - }) - } - fn fetch_and_update_data(&mut self) -> anyhow::Result<()> { - let (tx, rx) = mpsc::channel(); + let heartbeat_result = self.client.fetch_heartbeat(&self.endpoints)?; + let status_page_result = self.client.fetch_status_page(&self.endpoints)?; - let heartbeat_client = self.client.clone(); - let heartbeat_endpoints = self.endpoints.clone(); - let tx_clone = tx.clone(); - thread::spawn(move || { - let result = heartbeat_client.fetch_heartbeat(&heartbeat_endpoints); - tx.send(FetchResult::Heartbeat(result)).unwrap(); - }); - - let status_page_client = self.client.clone(); - let status_page_endpoints = self.endpoints.clone(); - - thread::spawn(move || { - let result = status_page_client.fetch_status_page(&status_page_endpoints); - tx_clone.send(FetchResult::StatusPage(result)).unwrap(); - }); - - let mut heartbeat_result = None; - let mut status_page_result = None; - - for _ in 0..2 { - match rx.recv()? { - FetchResult::Heartbeat(result) => heartbeat_result = Some(result?), - FetchResult::StatusPage(result) => status_page_result = Some(result?), - } - } - let heartbeat_data = heartbeat_result.unwrap(); - let status_page_data = status_page_result.unwrap(); - let unified_data = core::unify_data(&status_page_data, &heartbeat_data); + let unified_data = core::unify_data(&status_page_result, &heartbeat_result); self.state = DashboardViewState::from_unified_data(unified_data); Ok(()) } diff --git a/src/ui/components/footer.rs b/src/ui/components/footer.rs index 806002b..9a79434 100644 --- a/src/ui/components/footer.rs +++ b/src/ui/components/footer.rs @@ -9,7 +9,11 @@ use ratatui::{ use crate::i18n::t; use chrono::Local; -pub fn render_footer(frame: &mut Frame, area: Rect, seconds_until_update: u64) { +pub fn render_footer( + frame: &mut Frame, + area: Rect, + seconds_until_update: u64 +) { let now = Local::now(); let datatime_str = now.format("%Y-%m-%d %H:%M:%S").to_string(); let countdown_str = format!("↻ {}s", seconds_until_update); diff --git a/src/ui/components/monitor_list.rs b/src/ui/components/monitor_list.rs index e0b1c91..ae89733 100644 --- a/src/ui/components/monitor_list.rs +++ b/src/ui/components/monitor_list.rs @@ -1,10 +1,19 @@ -use std::cmp::min; - -use crate::i18n::t; -use crate::ui::dashboard::{ - MonitorStatus, MonitorViewState, - model::{DashboardViewState, GroupViewState}, +use std::{ + borrow::Cow, + cmp::min, + collections::{HashMap, hash_map::DefaultHasher}, + hash::{Hash, Hasher}, + sync::{OnceLock, RwLock}, }; + +use crate::{ + i18n::t, + ui::dashboard::{ + MonitorStatus, MonitorViewState, + model::{BORDER_LINES_VIEW, DashboardViewState, GroupViewState}, + }, +}; +use once_cell::sync::Lazy; use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, @@ -16,50 +25,72 @@ use ratatui::{ const STATUS_LINE_LENGTH: usize = 100; const MAX_NAME_LENGTH: usize = 30; -pub fn render_monitor_list(frame: &mut Frame, area: Rect, state: &DashboardViewState) { - let group_areas = layout_groups(area, &state.groups); +static UP_SPAN: Lazy> = + Lazy::new(|| Span::styled("■", Style::default().fg(Color::Green))); - for (i, (group, &group_area)) in state.groups.iter().zip(group_areas.iter()).enumerate() { - render_group(frame, group_area, group, i == 0); - } -} +static DOWN_SPAN: Lazy> = + Lazy::new(|| Span::styled("■", Style::default().fg(Color::Red))); -fn layout_groups(area: Rect, groups: &[GroupViewState]) -> Vec { - let total_lines: usize = groups.iter().map(|g| g.monitors.len() + 1).sum(); +static UNKNOWN_SPAN: Lazy> = + Lazy::new(|| Span::styled("■", Style::default().fg(Color::Yellow))); - if total_lines == 0 { - return Vec::new(); +static STATUS_LINE_CACHE: OnceLock>>> = OnceLock::new(); + +pub fn render_monitor_list(main_frame: &mut Frame, area: Rect, state: &mut DashboardViewState) { + let available_height = area.height as usize; + let max_scroll = state.get_total_length().saturating_sub(available_height); + + if state.scroll_state.get_position() > max_scroll { + state.scroll_state = state.scroll_state.position(max_scroll); } - let content_height = area.height.saturating_sub(2); - let line_height = content_height as usize / total_lines; + let scroll_pos = state.scroll_state.get_position(); + let mut current_y = area.y as usize; + let mut rendered_height = 0; + let mut lines_skipped = 0; + let half = state.get_total_length().saturating_div(2); - let mut current_y = area.y + 1; - let mut areas = Vec::with_capacity(groups.len()); + for group in state.groups.iter() { + let group_height = group.monitors.len() + BORDER_LINES_VIEW; - for group in groups { - let group_lines = group.monitors.len() + 2; - let group_height = (group_lines + line_height).max(1); - areas.push(Rect { + if lines_skipped + group_height <= scroll_pos { + lines_skipped += group_height; + continue; + } + + let visible_height = if lines_skipped < scroll_pos { + group_height - (scroll_pos - lines_skipped) + } else { + group_height + } + .min(available_height - rendered_height); + + let group_area = Rect { x: area.x, - y: current_y, + y: current_y as u16, width: area.width, - height: group_height as u16, - }); + height: visible_height as u16, + }; - current_y += group_height as u16; + render_group(main_frame, group_area, group, half > rendered_height); + + current_y += visible_height; + rendered_height += visible_height; + lines_skipped += group_height; } - - areas } -fn render_group(frame: &mut Frame, area: Rect, group: &GroupViewState, is_first: bool) { +fn render_group(frame: &mut Frame, area: Rect, group: &GroupViewState, is_first_half: bool) { let chunks = Layout::default() .direction(Direction::Vertical) .margin(0) .constraints([Constraint::Length(1), Constraint::Min(1)]) .split(area); + if chunks[0].height == 0 || chunks[1].height == 0 || group.monitors.is_empty() { + return; + } + let group_title = Line::from(vec![ Span::styled(format!("{} ", group.name), title_style()), Span::styled( @@ -69,11 +100,7 @@ fn render_group(frame: &mut Frame, area: Rect, group: &GroupViewState, is_first: ]); let title_block = Block::default() - .borders(if is_first { - Borders::TOP | Borders::LEFT | Borders::RIGHT - } else { - Borders::ALL - }) + .borders(Borders::ALL) .border_style(Style::default().fg(Color::Blue)) .title(group_title) .title_alignment(Alignment::Left); @@ -81,13 +108,24 @@ fn render_group(frame: &mut Frame, area: Rect, group: &GroupViewState, is_first: frame.render_widget(title_block, chunks[0]); if !group.monitors.is_empty() { - render_monitor_table(frame, chunks[1], &group.monitors); + render_monitor_table(frame, chunks[1], &group.monitors, is_first_half); } } -fn render_monitor_table(frame: &mut Frame, area: Rect, monitors: &Vec) { +fn render_monitor_table( + frame: &mut Frame, + area: Rect, + monitors: &[MonitorViewState], + is_first_half: bool, +) { let max_items = area.height as usize; let items_to_show = min(monitors.len(), max_items); + let index = if is_first_half { + monitors.len().saturating_sub(max_items.saturating_sub(2)) // 2 = Table header + botton + } else { + 0 + }; + let monitors = &monitors[index..index + items_to_show.min(monitors.len() - index)]; let header_cells = vec![ "".to_string(), @@ -99,11 +137,10 @@ fn render_monitor_table(frame: &mut Frame, area: Rect, monitors: &Vec = monitors - .iter() - .take(items_to_show) - .map(|monitor| create_monitor_item(monitor)) - .collect(); + let mut rows: Vec = Vec::with_capacity(items_to_show); + for monitor in monitors.iter().take(items_to_show) { + rows.push(create_monitor_item(monitor)); + } let widths = vec![ Constraint::Length(3), @@ -127,14 +164,6 @@ fn render_monitor_table(frame: &mut Frame, area: Rect, monitors: &Vec char { - match status { - MonitorStatus::Up => '■', - MonitorStatus::Down => '■', - MonitorStatus::Unknown => '■', - } -} - pub fn get_status_color(status: &MonitorStatus) -> Color { match status { MonitorStatus::Up => Color::Green, @@ -143,11 +172,14 @@ pub fn get_status_color(status: &MonitorStatus) -> Color { } } -pub fn get_status_emoji(status: &MonitorStatus) -> &str { +pub fn get_status_emoji(status: &MonitorStatus) -> Cow<'static, str> { + static UP_ICON: &str = "✅"; + static DOWN_ICON: &str = "❌"; + static UNKNOWN_ICON: &str = "❓"; match status { - MonitorStatus::Up => "✅", - MonitorStatus::Down => "❌", - MonitorStatus::Unknown => "❓", + MonitorStatus::Up => Cow::Borrowed(UP_ICON), + MonitorStatus::Down => Cow::Borrowed(DOWN_ICON), + MonitorStatus::Unknown => Cow::Borrowed(UNKNOWN_ICON), } } @@ -155,20 +187,24 @@ fn create_monitor_item(monitor: &MonitorViewState) -> Row<'_> { let status_icon = get_status_emoji(&monitor.status); let status_color = get_status_color(&monitor.status); - let display_name: String = if monitor.name.len() > MAX_NAME_LENGTH { - format!("{:.width$}...", &monitor.name, width = MAX_NAME_LENGTH - 3) + let display_name: Cow = if monitor.name.len() > MAX_NAME_LENGTH { + Cow::Owned(format!( + "{:.width$}...", + monitor.name, + width = MAX_NAME_LENGTH - 3 + )) } else { - monitor.name.clone() + Cow::Borrowed(&monitor.name) }; let response_text = format!("{:>7}ms", monitor.response_time); let uptime_text = format!("{:>7}%", monitor.uptime_24h); - let status_line_spans = create_status_line_spans(&monitor.status_history); + let status_line_spans = get_cached_status_line(&monitor.status_history); Row::new(vec![ get_formated_line(format!("{} ", status_icon), status_color, Modifier::empty()), - get_formated_line(display_name, Color::White, Modifier::empty()), + get_formated_line(display_name.to_string(), Color::White, Modifier::empty()), get_formated_line(response_text, Color::Cyan, Modifier::empty()), get_formated_line(uptime_text, Color::Magenta, Modifier::empty()), status_line_spans, @@ -184,22 +220,12 @@ fn get_formated_line(text: String, color: Color, modifier: Modifier) -> Line<'st )]) } -fn create_status_line_spans(status_history: &[MonitorStatus]) -> Line<'_> { - let recent_status: Vec<_> = status_history - .iter() - .rev() - .take(STATUS_LINE_LENGTH) - .collect(); - - let mut spans = Vec::with_capacity(recent_status.len()); - - for status in recent_status.iter().rev() { - let c = get_status_char(status); - let color = get_status_color(status); - - spans.push(Span::styled(c.to_string(), Style::default().fg(color))); +fn get_status_span(status: &MonitorStatus) -> &'static Span<'static> { + match status { + MonitorStatus::Up => &UP_SPAN, + MonitorStatus::Down => &DOWN_SPAN, + MonitorStatus::Unknown => &UNKNOWN_SPAN, } - Line::from(spans) } fn title_style() -> Style { @@ -207,3 +233,46 @@ fn title_style() -> Style { .fg(Color::Yellow) .add_modifier(Modifier::BOLD) } + +fn calculate_history_hash(status_history: &[MonitorStatus]) -> u64 { + let mut hasher = DefaultHasher::new(); + status_history + .iter() + .take(STATUS_LINE_LENGTH) + .for_each(|status| { + status.hash(&mut hasher); + }); + hasher.finish() +} + +fn get_cached_status_line(status_history: &[MonitorStatus]) -> Line<'static> { + let hash = calculate_history_hash(status_history); + let cache = STATUS_LINE_CACHE.get_or_init(|| RwLock::new(HashMap::new())); + { + let read = cache.read().unwrap(); + if let Some(line) = read.get(&hash) { + return line.clone(); + } + } + + let mut spans: Vec> = Vec::with_capacity(STATUS_LINE_LENGTH); + spans.extend( + status_history + .iter() + .take(STATUS_LINE_LENGTH) + .map(|status| get_status_span(status).clone()), + ); + + let new_line = Line::from(spans); + let mut write = cache.write().unwrap(); + if write.len() > 1000 { + let mut keys_to_remove: Vec = Vec::with_capacity(250); + keys_to_remove.extend(write.keys().take(250).copied()); + + for key in keys_to_remove { + write.remove(&key); + } + } + write.insert(hash, new_line.clone()); + new_line +} diff --git a/src/ui/dashboard/model.rs b/src/ui/dashboard/model.rs index 8f24c42..6425d2b 100644 --- a/src/ui/dashboard/model.rs +++ b/src/ui/dashboard/model.rs @@ -1,9 +1,12 @@ use crate::core::models::{UnifiedData, UnifiedGroupData}; use crate::data::heartbeat::model::HeartbeatEntry; use crate::i18n::t; +use ratatui::widgets::ScrollbarState; use rayon::prelude::*; +use std::borrow::Cow; +pub const BORDER_LINES_VIEW: usize = 3; -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum MonitorStatus { Up, Down, @@ -12,7 +15,7 @@ pub enum MonitorStatus { #[derive(Debug, Clone)] pub struct MonitorViewState { - pub name: String, + pub name: Cow<'static, str>, pub status: MonitorStatus, pub response_time: String, pub uptime_24h: String, @@ -33,6 +36,8 @@ pub struct DashboardViewState { pub is_loading: bool, pub error_message: Option, pub auto_refresh_interval: u32, + pub scroll_state: ScrollbarState, + total_length: usize, } impl DashboardViewState { @@ -44,6 +49,8 @@ impl DashboardViewState { is_loading: true, error_message: None, auto_refresh_interval: 300, + scroll_state: ScrollbarState::new(0), + total_length: 0, } } @@ -57,32 +64,43 @@ impl DashboardViewState { }); } + let total_length: usize = groups + .iter() + .map(|g| g.monitors.len() + BORDER_LINES_VIEW) + .sum(); + Self { - title: data.title, - descriptions: data.description, + title: data.title.into_owned(), + descriptions: data.description.map(|d| d.into_owned()), groups, is_loading: false, error_message: None, auto_refresh_interval: data.auto_refresh_interval.max(30), + scroll_state: ScrollbarState::new(total_length.saturating_sub(1)), + total_length, } } + pub fn get_total_length(&self) -> usize { + self.total_length + } + pub fn get_all_monitors(&self) -> Vec<&MonitorViewState> { self.groups.iter().flat_map(|g| g.monitors.iter()).collect() } + + pub fn show_vertical_scrollbar(&self, available_height: u16) -> bool { + self.total_length as u16 > available_height + } } fn get_status_history(heartbeats: &[HeartbeatEntry]) -> Vec { - let mut history = heartbeats - .iter() - .rev() - .take(100) - .map(|h| match h.status { - 0 => MonitorStatus::Down, - 1 => MonitorStatus::Up, - _ => MonitorStatus::Unknown, - }) - .collect::>(); + let mut history: Vec<_> = Vec::with_capacity(heartbeats.len()); + history.extend(heartbeats.iter().rev().take(100).map(|h| match h.status { + 0 => MonitorStatus::Down, + 1 => MonitorStatus::Up, + _ => MonitorStatus::Unknown, + })); while history.len() < 100 { history.push(MonitorStatus::Unknown); @@ -94,36 +112,42 @@ fn get_status_history(heartbeats: &[HeartbeatEntry]) -> Vec { fn add_monitor_view_state(group: UnifiedGroupData) -> Vec { let mut monitors = Vec::with_capacity(group.monitors.len()); - group.monitors.into_par_iter().map(|monitor| { + group + .monitors + .into_par_iter() + .map(|monitor| { + let status_history = get_status_history(monitor.heartbeats); - let status_history = get_status_history(&monitor.heartbeats); + let status = match monitor.heartbeats.last().map(|h| h.status) { + Some(1) => MonitorStatus::Up, + Some(0) => MonitorStatus::Down, + _ => MonitorStatus::Unknown, + }; - let status = match monitor.heartbeats.last().map(|h| h.status) { - Some(1) => MonitorStatus::Up, - Some(0) => MonitorStatus::Down, - _ => MonitorStatus::Unknown, - }; + let response_time = match monitor.heartbeats.last().and_then(|h| h.ping) { + Some(ms) => format!("{}", ms), + None => t("unknown"), + }; - let response_time = monitor - .heartbeats - .last() - .and_then(|h| h.ping) - .map(|ms| format!("{}", ms)) - .unwrap_or_else(|| t("unknown").to_string() + " "); + let uptime_24h = match monitor.uptime_data.map(|u| u.get_perc_formated()) { + Some(perc) => perc, + None => t("unknown"), + }; - let uptime_24h = monitor - .uptime_data - .map(|u| u.get_perc_formated()) - .unwrap_or_else(|| t("unknown").to_string()); + let name: Cow<'static, str> = match monitor.name { + Cow::Borrowed(borrowed) => Cow::Owned(borrowed.to_string()), + Cow::Owned(owned) => Cow::Owned(owned), + }; - MonitorViewState { - name: monitor.name, - status, - response_time, - uptime_24h, - status_history, - } - }).collect_into_vec(&mut monitors); + MonitorViewState { + name, + status, + response_time, + uptime_24h, + status_history, + } + }) + .collect_into_vec(&mut monitors); monitors.sort_by_key(|m| m.name.clone());