Compare commits

..

32 commits

Author SHA1 Message Date
150d83be42 Fix typo 2026-02-10 11:55:01 -04:00
0ad49775fc Adding demo 2026-02-10 11:53:04 -04:00
cc54502a12 chore: Release uptime-kuma-dashboard version 0.38.2
All checks were successful
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 7m57s
Multi-Platform Release Build / build (true, build-essential gcc-x86-64-linux-gnu libc6-dev-amd64-cross pkg-config binutils-x86-64-linux-gnu, map[CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER:x86_64-linux-gnu-gcc], linux/amd64, x86_64-unknown-linux-gnu) (push) Successful in 16m31s
Multi-Platform Release Build / build (true, gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 cmake nasm, map[CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER:x86_64-w64-mingw32-gcc CC_x86_64_pc_windows_gnu:x86_64-w64-mingw32-gcc], linux/amd64, x86_64-pc-windows-gnu) (push) Successful in 18m1s
Multi-Platform Release Build / create-release (push) Successful in 45s
2026-02-07 14:34:40 -04:00
80bf20cda1 Fix typo 2026-02-07 14:34:04 -04:00
88138341ae chore: Release uptime-kuma-dashboard version 0.38.1
All checks were successful
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 15m1s
Multi-Platform Release Build / build (true, gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 cmake nasm, map[CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER:x86_64-w64-mingw32-gcc CC_x86_64_pc_windows_gnu:x86_64-w64-mingw32-gcc], linux/amd64, x86_64-pc-windows-gnu) (push) Successful in 22m50s
Multi-Platform Release Build / build (true, build-essential gcc-x86-64-linux-gnu libc6-dev-amd64-cross pkg-config binutils-x86-64-linux-gnu, map[CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER:x86_64-linux-gnu-gcc], linux/amd64, x86_64-unknown-linux-gnu) (push) Successful in 19m9s
Multi-Platform Release Build / create-release (push) Successful in 1m9s
2026-02-05 15:43:41 -04:00
436eac2600 Fix heartbeats order 2026-02-05 15:43:15 -04:00
c39fed294b chore: Release uptime-kuma-dashboard version 0.38.0
All checks were successful
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 13m7s
Multi-Platform Release Build / build (true, build-essential gcc-x86-64-linux-gnu libc6-dev-amd64-cross pkg-config binutils-x86-64-linux-gnu, map[CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER:x86_64-linux-gnu-gcc], linux/amd64, x86_64-unknown-linux-gnu) (push) Successful in 19m5s
Multi-Platform Release Build / build (true, gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 cmake nasm, map[CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER:x86_64-w64-mingw32-gcc CC_x86_64_pc_windows_gnu:x86_64-w64-mingw32-gcc], linux/amd64, x86_64-pc-windows-gnu) (push) Successful in 24m33s
Multi-Platform Release Build / create-release (push) Successful in 48s
2026-01-22 16:35:09 -04:00
441687f67d Adding LICENSE 2026-01-22 16:25:48 -04:00
e7e4509016 Update README.md 2026-01-22 12:32:10 -04:00
c5f272b70e Added README and production release workflow 2026-01-22 09:46:38 -04:00
17662107b0 Refactoring 2026-01-21 12:34:50 -04:00
d8b4843415 optimizations 2026-01-20 16:54:36 -04:00
99d4b94e5c optimizations 2026-01-20 16:18:54 -04:00
64f5115d54 Line optimizations 2026-01-19 16:04:45 -04:00
975c4b1eaf Span optimizations 2026-01-19 13:34:37 -04:00
d63c701867 Update dependencies 2026-01-19 10:31:18 -04:00
c48a37d56a Clippy suggestions 2026-01-19 06:28:25 -04:00
00cb8f7a9b Refactor 2026-01-18 15:43:40 -04:00
70a70af859 Refactor cache 2026-01-15 08:23:20 -04:00
512f597a0f Fix 2026-01-12 16:38:06 -04:00
389f2b886b Small optimizations 2026-01-12 14:37:52 -04:00
3d69f530d4 String Optizations 2026-01-12 12:37:28 -04:00
3929a4a277 String optimizations 2026-01-12 11:49:37 -04:00
6109785e63 Small fixes
All checks were successful
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 13m20s
Multi-Platform Release Build / build (true, build-essential gcc-x86-64-linux-gnu libc6-dev-amd64-cross pkg-config binutils-x86-64-linux-gnu, map[CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER:x86_64-linux-gnu-gcc], linux/amd64, x86_64-unknown-linux-gnu) (push) Successful in 10m58s
Multi-Platform Release Build / build (true, gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 cmake nasm, map[CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER:x86_64-w64-mingw32-gcc CC_x86_64_pc_windows_gnu:x86_64-w64-mingw32-gcc], linux/amd64, x86_64-pc-windows-gnu) (push) Successful in 13m31s
Multi-Platform Release Build / create-release (push) Successful in 1m1s
2026-01-12 04:03:12 -04:00
60e4fa3b9d Fixing scrollbar view content 2026-01-11 14:06:12 -04:00
000e31a14f Working scrollbar 2026-01-10 11:19:19 -04:00
df1d1dddc6 Scrollbar initial values 2026-01-09 11:56:35 -04:00
4e17ef1e09 Small fix 2026-01-08 14:30:22 -04:00
74464f6a48 Partial fix where height is lower than total monitors, but there is no
area for scrollbar (area.len() == 1)
2026-01-08 13:33:37 -04:00
142f76684b Show vertical scrollbar only if height is lower than total monitors 2026-01-08 09:25:26 -04:00
d4e3ce6e27 Update Cargo.toml 2026-01-07 10:33:16 -04:00
fba0d17403 Testing workflow
All checks were successful
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 6m15s
Multi-Platform Release Build / build (true, build-essential gcc-x86-64-linux-gnu libc6-dev-amd64-cross pkg-config binutils-x86-64-linux-gnu, map[CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER:x86_64-linux-gnu-gcc], linux/amd64, x86_64-unknown-linux-gnu) (push) Successful in 8m2s
Multi-Platform Release Build / build (true, gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 cmake nasm, map[CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER:x86_64-w64-mingw32-gcc CC_x86_64_pc_windows_gnu:x86_64-w64-mingw32-gcc], linux/amd64, x86_64-pc-windows-gnu) (push) Successful in 11m3s
Multi-Platform Release Build / create-release (push) Successful in 41s
2026-01-05 16:40:35 -04:00
18 changed files with 748 additions and 277 deletions

View file

@ -159,26 +159,20 @@ jobs:
### 🚀 How to use ### 🚀 How to use
#### Linux/ARM: #### Linux/ARM:
```bash
# Download the appropriate binary # Download the appropriate binary
wget https://your-forgejo.com/releases/download/${{ forgejo.ref_name }}/uptime-kuma-dashboard-<your-target> wget https://your-forgejo.com/releases/download/${{ forgejo.ref_name }}/uptime-kuma-dashboard-<your-target>
# Make executable # Make executable
chmod +x uptime-kuma-dashboard-* chmod +x uptime-kuma-dashboard-*
# Run # Run
./uptime-kuma-dashboard-* --base-url https://your-kuma --slug your-slug ./uptime-kuma-dashboard-* --base-url https://your-kuma --slug your-slug
```
#### Windows: #### Windows:
```powershell
# Download uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe # Download uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe
# Run in PowerShell or CMD # Run in PowerShell or CMD
.\uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe --base-url https://your-kuma --slug your-slug .\uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe --base-url https://your-kuma --slug your-slug
```
### ✅ Verify Checksums ### ✅ Verify Checksums
```bash
sha256sum -c SHA256SUMS.txt sha256sum -c SHA256SUMS.txt
```
### 🏗️ Build Information ### 🏗️ Build Information
- Rust Version: ${{ steps.rust-version.outputs.version }} - Rust Version: ${{ steps.rust-version.outputs.version }}

View file

@ -0,0 +1,179 @@
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: "Release ${{ forgejo.ref_name }}"
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-<your-target>
# 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)

34
Cargo.lock generated
View file

@ -78,9 +78,9 @@ dependencies = [
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.100" version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]] [[package]]
name = "atomic" name = "atomic"
@ -236,9 +236,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.42" version = "0.4.43"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
dependencies = [ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
@ -249,9 +249,9 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.54" version = "4.5.57"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -259,9 +259,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.54" version = "4.5.57"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@ -271,9 +271,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.5.49" version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
@ -1955,9 +1955,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.35" version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"once_cell", "once_cell",
@ -2132,9 +2132,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.148" version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@ -2668,7 +2668,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "uptime-kuma-dashboard" name = "uptime-kuma-dashboard"
version = "0.1.0" version = "0.38.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -2689,9 +2689,9 @@ dependencies = [
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.7" version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [ dependencies = [
"form_urlencoded", "form_urlencoded",
"idna", "idna",

View file

@ -1,28 +1,28 @@
[package] [package]
name = "uptime-kuma-dashboard" name = "uptime-kuma-dashboard"
version = "0.1.0" version = "0.38.2"
edition = "2024" edition = "2024"
[dependencies] [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 = [ reqwest = { version = "0.13.1", default-features = false, features = [
"blocking", "blocking",
"json", "json",
"rustls", "rustls",
] } ] }
anyhow = "1.0" anyhow = "1.0.101"
fluent-templates = "0.13.2" fluent-templates = "0.13.2"
unic-langid = "0.9.6" unic-langid = "0.9.6"
sys-locale = "0.3.2" sys-locale = "0.3.2"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.148" serde_json = "1.0.149"
once_cell = "1.19" once_cell = "1.19"
url = "2.5.7" url = "2.5.8"
ratatui = "0.30.0" ratatui = "0.30.0"
crossterm = "0.29.0" crossterm = "0.29.0"
chrono = "0.4.42" chrono = "0.4.43"
rayon = "1.11.0" 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] [features]
default = [] default = []

21
LICENSE Normal file
View file

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

145
README.md Normal file
View file

@ -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
<video width="800" controls>
<source src="assets/demo.webm" type="video/webm">
Your browser does not support the video tag.
</video>
## 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!_

BIN
assets/demo.webm Normal file

Binary file not shown.

View file

@ -1,9 +1,11 @@
use std::time::Duration;
use anyhow::Result; use anyhow::Result;
use reqwest::blocking::Client; use reqwest::blocking::Client;
use crate::{ use crate::{
api::endpoints::UptimeKumaEndpoints, api::endpoints::UptimeKumaEndpoints,
data::{self, heartbeat::HeartbeatResponse, status_page::model::StatusPageResponse}, data::{self, heartbeat::HeartbeatResponse, status_page::model::StatusPageResponse}, i18n::t,
}; };
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -13,8 +15,9 @@ pub struct UptimeKumaClient {
impl UptimeKumaClient { impl UptimeKumaClient {
pub fn new() -> Self { 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 { Self {
client: Client::new(), client,
} }
} }

View file

@ -1,3 +1,4 @@
use std::borrow::Cow;
use std::collections::HashMap; use std::collections::HashMap;
use crate::core::models::{UnifiedData, UnifiedGroupData, UnifiedMonitorData}; use crate::core::models::{UnifiedData, UnifiedGroupData, UnifiedMonitorData};
@ -6,32 +7,45 @@ use crate::data::{
status_page::model::StatusPageResponse, 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 mut groups = Vec::with_capacity(status_page.public_group_list.len());
let heartbeat_map: HashMap<u64, Vec<HeartbeatEntry>> = heartbeat
let mut heartbeat_map: HashMap<u64, &'a [HeartbeatEntry]> =
HashMap::with_capacity(heartbeat.monitors.len());
heartbeat_map.extend(
heartbeat
.monitors .monitors
.iter() .iter()
.map(|m| (m.monitor_id, m.heartbeats.clone())) .map(|m| (m.monitor_id, &m.heartbeats[..])),
.collect(); );
let uptime_map: HashMap<(u64, u32), UptimeData> = heartbeat let mut uptime_map: HashMap<(u64, u32), &'a UptimeData> =
HashMap::with_capacity(heartbeat.uptime_data.len());
uptime_map.extend(
heartbeat
.uptime_data .uptime_data
.iter() .iter()
.map(|u| ((u.monitor_id, u.period_hours), u.clone())) .map(|u| ((u.monitor_id, u.period_hours), u)),
.collect(); );
for group in &status_page.public_group_list { for group in &status_page.public_group_list {
let mut monitors = Vec::with_capacity(group.monitor_list.len()); let mut monitors = Vec::with_capacity(group.monitor_list.len());
for monitor_info in &group.monitor_list { for monitor_info in &group.monitor_list {
let uptime_data = uptime_map.get(&(monitor_info.id, 24)).cloned(); let uptime_data = uptime_map.get(&(monitor_info.id, 24)).copied();
let heartbeats = heartbeat_map let heartbeats = heartbeat_map.get(&monitor_info.id).copied().unwrap_or(&[]);
.get(&monitor_info.id)
.cloned() let name: Cow<'a, str> = if monitor_info.name.len() > 100 {
.unwrap_or_default(); Cow::Owned(monitor_info.name.clone())
} else {
Cow::Borrowed(monitor_info.name.as_str())
};
monitors.push(UnifiedMonitorData { monitors.push(UnifiedMonitorData {
id: monitor_info.id, id: monitor_info.id,
name: monitor_info.name.clone(), name,
heartbeats, heartbeats,
uptime_data, uptime_data,
}); });
@ -40,7 +54,7 @@ pub fn unify_data(status_page: &StatusPageResponse, heartbeat: &HeartbeatRespons
monitors.sort_by_key(|m| m.id); monitors.sort_by_key(|m| m.id);
groups.push(UnifiedGroupData { groups.push(UnifiedGroupData {
group_info: group.clone(), group_info: group,
monitors, monitors,
}); });
} }
@ -48,8 +62,8 @@ pub fn unify_data(status_page: &StatusPageResponse, heartbeat: &HeartbeatRespons
groups.sort_by_key(|g| g.group_info.weight); groups.sort_by_key(|g| g.group_info.weight);
UnifiedData { UnifiedData {
title: status_page.config.title.clone(), title: Cow::Borrowed(&status_page.config.title),
description: status_page.config.description.clone(), description: status_page.config.description.as_deref().map(Cow::Borrowed),
groups, groups,
auto_refresh_interval: status_page.config.auto_refresh_interval, auto_refresh_interval: status_page.config.auto_refresh_interval,
} }

View file

@ -1,24 +1,26 @@
use std::borrow::Cow;
use crate::data::heartbeat::model::{HeartbeatEntry, UptimeData}; use crate::data::heartbeat::model::{HeartbeatEntry, UptimeData};
use crate::data::status_page::model::{StatusPageGroup}; use crate::data::status_page::model::{StatusPageGroup};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct UnifiedMonitorData { pub struct UnifiedMonitorData<'a> {
pub id: u64, pub id: u64,
pub name: String, pub name: Cow<'a, str>,
pub heartbeats: Vec<HeartbeatEntry>, pub heartbeats: &'a [HeartbeatEntry],
pub uptime_data: Option<UptimeData>, pub uptime_data: Option<&'a UptimeData>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct UnifiedData { pub struct UnifiedData<'a> {
pub title: String, pub title: Cow<'a, str>,
pub description: Option<String>, pub description: Option<Cow<'a, str>>,
pub auto_refresh_interval: u32, pub auto_refresh_interval: u32,
pub groups: Vec<UnifiedGroupData>, pub groups: Vec<UnifiedGroupData<'a>>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct UnifiedGroupData { pub struct UnifiedGroupData<'a> {
pub group_info: StatusPageGroup, pub group_info: &'a StatusPageGroup,
pub monitors: Vec<UnifiedMonitorData>, pub monitors: Vec<UnifiedMonitorData<'a>>,
} }

View file

@ -55,7 +55,7 @@ impl HeartbeatResponse {
for (monitor_id_str, heartbeats) in &self.heartbeat_list_raw { for (monitor_id_str, heartbeats) in &self.heartbeat_list_raw {
let monitor_id = monitor_id_str let monitor_id = monitor_id_str
.parse::<u64>() .parse::<u64>()
.with_context(|| format!("{}", t("invalid-monitor-id")))?; .with_context(|| t("invalid-monitor-id").to_string())?;
self.monitors.push(MonitorHeartbeats { self.monitors.push(MonitorHeartbeats {
monitor_id, monitor_id,
@ -77,11 +77,11 @@ impl HeartbeatResponse {
let monitor_id = parts[0] let monitor_id = parts[0]
.parse::<u64>() .parse::<u64>()
.with_context(|| format!("{}", t("invalid-monitor-id")))?; .with_context(|| t("invalid-monitor-id").to_string())?;
let period_hours = parts[1] let period_hours = parts[1]
.parse::<u32>() .parse::<u32>()
.with_context(|| format!("{}", t("invalid-period-hours")))?; .with_context(|| t("invalid-period-hours").to_string())?;
self.uptime_data.push(UptimeData { self.uptime_data.push(UptimeData {
monitor_id, monitor_id,

View file

@ -3,8 +3,8 @@ use crate::i18n::t;
use anyhow::{Context, Ok, Result}; use anyhow::{Context, Ok, Result};
pub fn parse_response(json_text: &str) -> Result<HeartbeatResponse> { pub fn parse_response(json_text: &str) -> Result<HeartbeatResponse> {
let mut response: HeartbeatResponse = let mut response: HeartbeatResponse = serde_json::from_slice(json_text.as_bytes())
serde_json::from_str(json_text).with_context(|| t("invalid-json-heartbeat"))?; .with_context(|| t("invalid-json-heartbeat"))?;
response.process()?; response.process()?;
Ok(response) Ok(response)

View file

@ -3,7 +3,7 @@ use crate::i18n::t;
use anyhow::{Context, Ok, Result}; use anyhow::{Context, Ok, Result};
pub fn parse_response(json_text: &str) -> Result<StatusPageResponse> { pub fn parse_response(json_text: &str) -> Result<StatusPageResponse> {
let response: StatusPageResponse = let response: StatusPageResponse = serde_json::from_slice(json_text.as_bytes())
serde_json::from_str(json_text).with_context(|| t("invalid-json-status-page"))?; .with_context(|| t("invalid-json-status-page"))?;
Ok(response) Ok(response)
} }

View file

@ -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(); 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 { for (key, value) in args {
map.insert(Cow::Borrowed(*key), FluentValue::from(value)); 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)
} }

View file

@ -1,12 +1,10 @@
use crate::api::{UptimeKumaClient, UptimeKumaEndpoints}; use crate::api::{UptimeKumaClient, UptimeKumaEndpoints};
use crate::core; use crate::core;
use crate::data::{heartbeat::HeartbeatResponse, status_page::model::StatusPageResponse};
use crate::i18n::{t, t_with_args}; use crate::i18n::{t, t_with_args};
use crate::ui::{ use crate::ui::{
components::{render_footer, render_header, render_monitor_list}, components::{render_footer, render_header, render_monitor_list},
dashboard::model::DashboardViewState, dashboard::model::DashboardViewState,
}; };
use anyhow::Result;
use crossterm::{ use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute, execute,
@ -18,23 +16,18 @@ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span, Text}, text::{Line, Span, Text},
widgets::{Block, Borders, Padding, Paragraph}, widgets::{Block, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation},
}; };
use std::{ use std::{
collections::HashMap, collections::HashMap,
io, io,
sync::mpsc,
thread,
time::{Duration, Instant}, time::{Duration, Instant},
}; };
const INITIAL_INTERVAL: u32 = 300; const INITIAL_INTERVAL: u32 = 300;
const MAIN_LAYOUT_WITH_SCROLLBAR: [Constraint; 2] = [Constraint::Min(1), Constraint::Length(1)];
enum FetchResult { const MAIN_LAYOUT_WITHOUT_SCROLLBAR: [Constraint; 1] = [Constraint::Min(1)];
Heartbeat(Result<HeartbeatResponse>),
StatusPage(Result<StatusPageResponse>),
}
pub struct App { pub struct App {
state: DashboardViewState, state: DashboardViewState,
@ -47,6 +40,26 @@ pub struct App {
} }
impl App { impl App {
pub fn new(endpoints: UptimeKumaEndpoints) -> io::Result<Self> {
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<()> { fn setup_terminal(&mut self) -> io::Result<()> {
enable_raw_mode()?; enable_raw_mode()?;
execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
@ -116,53 +129,103 @@ impl App {
fn render(&mut self) { fn render(&mut self) {
let _ = self.terminal.draw(|frame| { let _ = self.terminal.draw(|frame| {
let area = frame.area(); 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() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.margin(1) .margin(1)
.constraints([ .constraints([
Constraint::Length(3), Constraint::Length(HEADER_HEIGHT),
Constraint::Min(1), Constraint::Length(max_content_height.max(1)),
Constraint::Length(3), Constraint::Length(FOOTER_HEIGHT),
]) ])
.split(area); .split(area);
render_header(frame, chunks[0], &self.state); render_header(frame, chunks[0], &self.state);
if self.state.is_loading { let main_constraint = if self.state.show_vertical_scrollbar(chunks[1].height) {
Self::render_loading(frame, chunks[1]); &MAIN_LAYOUT_WITH_SCROLLBAR[..]
} 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]);
} else { } 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 let seconds_until_update = self
.update_interval .update_interval
.checked_sub(self.last_update.elapsed()) .checked_sub(self.last_update.elapsed())
.map(|d| d.as_secs() as u64) .map(|d| d.as_secs())
.unwrap_or(30); .unwrap_or(30);
render_footer(frame, chunks[2], seconds_until_update); render_footer(frame, chunks[2], seconds_until_update);
}); });
} }
fn render_main(frame: &mut Frame, area: Vec<Rect>, 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<()> { fn handle_events(&mut self) -> io::Result<()> {
let timeout = Duration::from_secs(1); let timeout = Duration::from_secs(1);
if event::poll(timeout)? { if event::poll(timeout)?
if let Event::Key(key) = event::read()? { && let Event::Key(key) = event::read()?
{
if key.kind == KeyEventKind::Release { if key.kind == KeyEventKind::Release {
return Ok(()); return Ok(());
} }
match key.code { match key.code {
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, 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(()) Ok(())
} }
@ -180,7 +243,6 @@ impl App {
fn refresh_data(&mut self) -> io::Result<()> { fn refresh_data(&mut self) -> io::Result<()> {
self.state.is_loading = true; self.state.is_loading = true;
self.render();
match self.fetch_and_update_data() { match self.fetch_and_update_data() {
Ok(()) => { Ok(()) => {
@ -197,57 +259,11 @@ impl App {
Ok(()) Ok(())
} }
pub fn new(endpoints: UptimeKumaEndpoints) -> io::Result<Self> {
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<()> { 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 unified_data = core::unify_data(&status_page_result, &heartbeat_result);
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);
self.state = DashboardViewState::from_unified_data(unified_data); self.state = DashboardViewState::from_unified_data(unified_data);
Ok(()) Ok(())
} }

View file

@ -9,7 +9,11 @@ use ratatui::{
use crate::i18n::t; use crate::i18n::t;
use chrono::Local; 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 now = Local::now();
let datatime_str = now.format("%Y-%m-%d %H:%M:%S").to_string(); let datatime_str = now.format("%Y-%m-%d %H:%M:%S").to_string();
let countdown_str = format!("{}s", seconds_until_update); let countdown_str = format!("{}s", seconds_until_update);

View file

@ -1,10 +1,19 @@
use std::cmp::min; use std::{
borrow::Cow,
use crate::i18n::t; cmp::min,
use crate::ui::dashboard::{ collections::{HashMap, hash_map::DefaultHasher},
MonitorStatus, MonitorViewState, hash::{Hash, Hasher},
model::{DashboardViewState, GroupViewState}, sync::{OnceLock, RwLock},
}; };
use crate::{
i18n::t,
ui::dashboard::{
MonitorStatus, MonitorViewState,
model::{BORDER_LINES_VIEW, DashboardViewState, GroupViewState},
},
};
use once_cell::sync::Lazy;
use ratatui::{ use ratatui::{
Frame, Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
@ -16,50 +25,72 @@ use ratatui::{
const STATUS_LINE_LENGTH: usize = 100; const STATUS_LINE_LENGTH: usize = 100;
const MAX_NAME_LENGTH: usize = 30; const MAX_NAME_LENGTH: usize = 30;
pub fn render_monitor_list(frame: &mut Frame, area: Rect, state: &DashboardViewState) { static UP_SPAN: Lazy<Span<'static>> =
let group_areas = layout_groups(area, &state.groups); Lazy::new(|| Span::styled("", Style::default().fg(Color::Green)));
for (i, (group, &group_area)) in state.groups.iter().zip(group_areas.iter()).enumerate() { static DOWN_SPAN: Lazy<Span<'static>> =
render_group(frame, group_area, group, i == 0); Lazy::new(|| Span::styled("", Style::default().fg(Color::Red)));
}
static UNKNOWN_SPAN: Lazy<Span<'static>> =
Lazy::new(|| Span::styled("", Style::default().fg(Color::Yellow)));
static STATUS_LINE_CACHE: OnceLock<RwLock<HashMap<u64, Line<'static>>>> = 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);
} }
fn layout_groups(area: Rect, groups: &[GroupViewState]) -> Vec<Rect> { let scroll_pos = state.scroll_state.get_position();
let total_lines: usize = groups.iter().map(|g| g.monitors.len() + 1).sum(); 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);
if total_lines == 0 { for group in state.groups.iter() {
return Vec::new(); let group_height = group.monitors.len() + BORDER_LINES_VIEW;
if lines_skipped + group_height <= scroll_pos {
lines_skipped += group_height;
continue;
} }
let content_height = area.height.saturating_sub(2); let visible_height = if lines_skipped < scroll_pos {
let line_height = content_height as usize / total_lines; group_height - (scroll_pos - lines_skipped)
} else {
group_height
}
.min(available_height - rendered_height);
let mut current_y = area.y + 1; let group_area = Rect {
let mut areas = Vec::with_capacity(groups.len());
for group in groups {
let group_lines = group.monitors.len() + 2;
let group_height = (group_lines + line_height).max(1);
areas.push(Rect {
x: area.x, x: area.x,
y: current_y, y: current_y as u16,
width: area.width, 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_half: bool) {
}
fn render_group(frame: &mut Frame, area: Rect, group: &GroupViewState, is_first: bool) {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.margin(0) .margin(0)
.constraints([Constraint::Length(1), Constraint::Min(1)]) .constraints([Constraint::Length(1), Constraint::Min(1)])
.split(area); .split(area);
if chunks[0].height == 0 || chunks[1].height == 0 || group.monitors.is_empty() {
return;
}
let group_title = Line::from(vec![ let group_title = Line::from(vec![
Span::styled(format!("{} ", group.name), title_style()), Span::styled(format!("{} ", group.name), title_style()),
Span::styled( Span::styled(
@ -69,11 +100,7 @@ fn render_group(frame: &mut Frame, area: Rect, group: &GroupViewState, is_first:
]); ]);
let title_block = Block::default() let title_block = Block::default()
.borders(if is_first { .borders(Borders::ALL)
Borders::TOP | Borders::LEFT | Borders::RIGHT
} else {
Borders::ALL
})
.border_style(Style::default().fg(Color::Blue)) .border_style(Style::default().fg(Color::Blue))
.title(group_title) .title(group_title)
.title_alignment(Alignment::Left); .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]); frame.render_widget(title_block, chunks[0]);
if !group.monitors.is_empty() { 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<MonitorViewState>) { fn render_monitor_table(
frame: &mut Frame,
area: Rect,
monitors: &[MonitorViewState],
is_first_half: bool,
) {
let max_items = area.height as usize; let max_items = area.height as usize;
let items_to_show = min(monitors.len(), max_items); 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![ let header_cells = vec![
"".to_string(), "".to_string(),
@ -99,11 +137,10 @@ fn render_monitor_table(frame: &mut Frame, area: Rect, monitors: &Vec<MonitorVie
let header = Row::new(header_cells).style(title_style()).height(1); let header = Row::new(header_cells).style(title_style()).height(1);
let rows: Vec<Row> = monitors let mut rows: Vec<Row> = Vec::with_capacity(items_to_show);
.iter() for monitor in monitors.iter().take(items_to_show) {
.take(items_to_show) rows.push(create_monitor_item(monitor));
.map(|monitor| create_monitor_item(monitor)) }
.collect();
let widths = vec![ let widths = vec![
Constraint::Length(3), Constraint::Length(3),
@ -127,14 +164,6 @@ fn render_monitor_table(frame: &mut Frame, area: Rect, monitors: &Vec<MonitorVie
frame.render_widget(table, area); frame.render_widget(table, area);
} }
pub fn get_status_char(status: &MonitorStatus) -> char {
match status {
MonitorStatus::Up => '■',
MonitorStatus::Down => '■',
MonitorStatus::Unknown => '■',
}
}
pub fn get_status_color(status: &MonitorStatus) -> Color { pub fn get_status_color(status: &MonitorStatus) -> Color {
match status { match status {
MonitorStatus::Up => Color::Green, 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 { match status {
MonitorStatus::Up => "", MonitorStatus::Up => Cow::Borrowed(UP_ICON),
MonitorStatus::Down => "", MonitorStatus::Down => Cow::Borrowed(DOWN_ICON),
MonitorStatus::Unknown => "", 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_icon = get_status_emoji(&monitor.status);
let status_color = get_status_color(&monitor.status); let status_color = get_status_color(&monitor.status);
let display_name: String = if monitor.name.len() > MAX_NAME_LENGTH { let display_name: Cow<str> = if monitor.name.len() > MAX_NAME_LENGTH {
format!("{:.width$}...", &monitor.name, width = MAX_NAME_LENGTH - 3) Cow::Owned(format!(
"{:.width$}...",
monitor.name,
width = MAX_NAME_LENGTH - 3
))
} else { } else {
monitor.name.clone() Cow::Borrowed(&monitor.name)
}; };
let response_text = format!("{:>7}ms", monitor.response_time); let response_text = format!("{:>7}ms", monitor.response_time);
let uptime_text = format!("{:>7}%", monitor.uptime_24h); 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![ Row::new(vec![
get_formated_line(format!("{} ", status_icon), status_color, Modifier::empty()), 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(response_text, Color::Cyan, Modifier::empty()),
get_formated_line(uptime_text, Color::Magenta, Modifier::empty()), get_formated_line(uptime_text, Color::Magenta, Modifier::empty()),
status_line_spans, 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<'_> { fn get_status_span(status: &MonitorStatus) -> &'static Span<'static> {
let recent_status: Vec<_> = status_history match status {
.iter() MonitorStatus::Up => &UP_SPAN,
.rev() MonitorStatus::Down => &DOWN_SPAN,
.take(STATUS_LINE_LENGTH) MonitorStatus::Unknown => &UNKNOWN_SPAN,
.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)));
} }
Line::from(spans)
} }
fn title_style() -> Style { fn title_style() -> Style {
@ -207,3 +233,46 @@ fn title_style() -> Style {
.fg(Color::Yellow) .fg(Color::Yellow)
.add_modifier(Modifier::BOLD) .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<Span<'static>> = 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<u64> = 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
}

View file

@ -1,9 +1,12 @@
use crate::core::models::{UnifiedData, UnifiedGroupData}; use crate::core::models::{UnifiedData, UnifiedGroupData};
use crate::data::heartbeat::model::HeartbeatEntry; use crate::data::heartbeat::model::HeartbeatEntry;
use crate::i18n::t; use crate::i18n::t;
use ratatui::widgets::ScrollbarState;
use rayon::prelude::*; 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 { pub enum MonitorStatus {
Up, Up,
Down, Down,
@ -12,7 +15,7 @@ pub enum MonitorStatus {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MonitorViewState { pub struct MonitorViewState {
pub name: String, pub name: Cow<'static, str>,
pub status: MonitorStatus, pub status: MonitorStatus,
pub response_time: String, pub response_time: String,
pub uptime_24h: String, pub uptime_24h: String,
@ -33,6 +36,8 @@ pub struct DashboardViewState {
pub is_loading: bool, pub is_loading: bool,
pub error_message: Option<String>, pub error_message: Option<String>,
pub auto_refresh_interval: u32, pub auto_refresh_interval: u32,
pub scroll_state: ScrollbarState,
total_length: usize,
} }
impl DashboardViewState { impl DashboardViewState {
@ -44,6 +49,8 @@ impl DashboardViewState {
is_loading: true, is_loading: true,
error_message: None, error_message: None,
auto_refresh_interval: 300, 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 { Self {
title: data.title, title: data.title.into_owned(),
descriptions: data.description, descriptions: data.description.map(|d| d.into_owned()),
groups, groups,
is_loading: false, is_loading: false,
error_message: None, error_message: None,
auto_refresh_interval: data.auto_refresh_interval.max(30), 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> { pub fn get_all_monitors(&self) -> Vec<&MonitorViewState> {
self.groups.iter().flat_map(|g| g.monitors.iter()).collect() 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<MonitorStatus> { fn get_status_history(heartbeats: &[HeartbeatEntry]) -> Vec<MonitorStatus> {
let mut history = heartbeats let mut history: Vec<_> = Vec::with_capacity(heartbeats.len());
.iter() history.extend(heartbeats.iter().rev().take(100).map(|h| match h.status {
.rev()
.take(100)
.map(|h| match h.status {
0 => MonitorStatus::Down, 0 => MonitorStatus::Down,
1 => MonitorStatus::Up, 1 => MonitorStatus::Up,
_ => MonitorStatus::Unknown, _ => MonitorStatus::Unknown,
}) }));
.collect::<Vec<_>>();
while history.len() < 100 { while history.len() < 100 {
history.push(MonitorStatus::Unknown); history.push(MonitorStatus::Unknown);
@ -94,9 +112,11 @@ fn get_status_history(heartbeats: &[HeartbeatEntry]) -> Vec<MonitorStatus> {
fn add_monitor_view_state(group: UnifiedGroupData) -> Vec<MonitorViewState> { fn add_monitor_view_state(group: UnifiedGroupData) -> Vec<MonitorViewState> {
let mut monitors = Vec::with_capacity(group.monitors.len()); let mut monitors = Vec::with_capacity(group.monitors.len());
group.monitors.into_par_iter().map(|monitor| { group
.monitors
let status_history = get_status_history(&monitor.heartbeats); .into_par_iter()
.map(|monitor| {
let status_history = get_status_history(monitor.heartbeats);
let status = match monitor.heartbeats.last().map(|h| h.status) { let status = match monitor.heartbeats.last().map(|h| h.status) {
Some(1) => MonitorStatus::Up, Some(1) => MonitorStatus::Up,
@ -104,26 +124,30 @@ fn add_monitor_view_state(group: UnifiedGroupData) -> Vec<MonitorViewState> {
_ => MonitorStatus::Unknown, _ => MonitorStatus::Unknown,
}; };
let response_time = monitor let response_time = match monitor.heartbeats.last().and_then(|h| h.ping) {
.heartbeats Some(ms) => format!("{}", ms),
.last() None => t("unknown"),
.and_then(|h| h.ping) };
.map(|ms| format!("{}", ms))
.unwrap_or_else(|| t("unknown").to_string() + " ");
let uptime_24h = monitor let uptime_24h = match monitor.uptime_data.map(|u| u.get_perc_formated()) {
.uptime_data Some(perc) => perc,
.map(|u| u.get_perc_formated()) None => t("unknown"),
.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 { MonitorViewState {
name: monitor.name, name,
status, status,
response_time, response_time,
uptime_24h, uptime_24h,
status_history, status_history,
} }
}).collect_into_vec(&mut monitors); })
.collect_into_vec(&mut monitors);
monitors.sort_by_key(|m| m.name.clone()); monitors.sort_by_key(|m| m.name.clone());