Compare commits

...

53 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
a8467a3cf1 Testing workflow
All checks were successful
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 6m7s
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 9m15s
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 10m35s
Multi-Platform Release Build / create-release (push) Successful in 41s
2026-01-05 15:53:04 -04:00
78d0e553af Testing workflow
All checks were successful
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 5m43s
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 8m34s
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 10m50s
Multi-Platform Release Build / create-release (push) Successful in 40s
2026-01-05 14:15:51 -04:00
89bf217afd Update type 2026-01-05 13:52:50 -04:00
349e0cf3ff Refactoring 2026-01-05 13:51:55 -04:00
4eb4c64398 Testing workflow
All checks were successful
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 5m28s
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 8m31s
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 10m14s
Multi-Platform Release Build / create-release (push) Successful in 39s
2026-01-05 11:50:50 -04:00
12e3a30325 Testing workflow
Some checks failed
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 5m31s
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 16m40s
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 11m21s
Multi-Platform Release Build / create-release (push) Failing after 57s
2026-01-05 11:05:06 -04:00
97b16624b6 Testing workflow
Some checks failed
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 6m18s
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 19m54s
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 17m50s
Multi-Platform Release Build / create-release (push) Failing after 2m2s
2026-01-04 16:41:18 -04:00
4347996e2b Testing workflow
Some checks failed
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 13m4s
Multi-Platform Release Build / build (true, build-essential gcc-x86-64-linux-gnu libc6-dev-amd64-cross pkg-config, map[CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER:x86_64-linux-gnu-gcc], linux/amd64, x86_64-unknown-linux-gnu) (push) Failing after 14m6s
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 19m24s
Multi-Platform Release Build / create-release (push) Has been skipped
2026-01-04 15:31:47 -04:00
d80bc544dc Testing workflow
Some checks failed
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 5m34s
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) Failing after 7m35s
Multi-Platform Release Build / build (true, gcc-x86-64-linux-gnu libc6-dev-amd64-cross, map[CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER:x86_64-linux-gnu-gcc], linux/amd64, x86_64-unknown-linux-gnu) (push) Failing after 7m23s
Multi-Platform Release Build / create-release (push) Has been skipped
2026-01-04 11:35:44 -04:00
60a1a87d04 Testing workflow
Some checks failed
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 8m58s
Multi-Platform Release Build / build (true, build-essential gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64, 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) Failing after 8m48s
Multi-Platform Release Build / build (true, linux/amd64, x86_64-unknown-linux-gnu) (push) Failing after 15m55s
Multi-Platform Release Build / create-release (push) Has been skipped
2026-01-04 07:57:15 -04:00
2a6b233909 Testing workflow
Some checks failed
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 9m42s
Multi-Platform Release Build / build (true, build-essential gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64, 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) Failing after 11m23s
Multi-Platform Release Build / build (true, linux/amd64, x86_64-unknown-linux-gnu) (push) Failing after 14m58s
Multi-Platform Release Build / create-release (push) Has been skipped
2026-01-03 14:15:35 -04:00
d3ff4d3916 Update Cargo.toml 2026-01-03 13:50:01 -04:00
5a77198344 Fix translation error 2026-01-03 08:45:13 -04:00
6e94081db2 Testing workflow
Some checks failed
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 5m22s
Multi-Platform Release Build / build (true, build-essential gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64, 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 13m0s
Multi-Platform Release Build / build (true, linux/amd64, x86_64-unknown-linux-gnu) (push) Failing after 8m11s
Multi-Platform Release Build / create-release (push) Has been skipped
2026-01-02 14:31:05 -04:00
3c95626cf9 Testing workflow
Some checks failed
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 5m17s
Multi-Platform Release Build / build (true, build-essential gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64, 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 12m30s
Multi-Platform Release Build / build (true, linux/amd64, x86_64-unknown-linux-gnu) (push) Failing after 2m33s
Multi-Platform Release Build / create-release (push) Has been skipped
2026-01-02 11:45:30 -04:00
7bb7a5a6b1 Testing workflow
Some checks failed
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 6m2s
Multi-Platform Release Build / build (true, build-essential gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64, 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 12m26s
Multi-Platform Release Build / build (true, build-essential libssl-dev pkg-config gcc-x86_64-linux-gnu, map[OPENSSL_INCLUDE_DIR:/usr/include/openssl OPENSSL_LIB_DIR:/usr/lib/x86_64-linux-gnu OPENSSL_STATIC:1 PKG_CONFIG_ALLOW_CROSS:1 PKG_CONFIG_ALL_STATIC:1], linux/amd64, x86_64-unkn… (push) Failing after 5m50s
Multi-Platform Release Build / create-release (push) Has been skipped
2026-01-02 10:12:51 -04:00
a31c7a4a3e Testing workflow
Some checks failed
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 5m32s
Multi-Platform Release Build / build (true, build-essential gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64, 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 12m31s
Multi-Platform Release Build / build (true, build-essential libssl-dev pkg-config, map[OPENSSL_DIR:/usr PKG_CONFIG_ALLOW_CROSS:1 PKG_CONFIG_PATH:/usr/lib/pkgconfig:/usr/share/pkgconfig], linux/amd64, x86_64-unknown-linux-gnu) (push) Failing after 5m59s
Multi-Platform Release Build / create-release (push) Has been skipped
2026-01-02 09:04:30 -04:00
845c49f664 Testing workflow
Some checks failed
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 5m35s
Multi-Platform Release Build / build (true, build-essential gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64, 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 17m27s
Multi-Platform Release Build / build (true, build-essential libssl-dev pkg-config, map[OPENSSL_DIR:/usr PKG_CONFIG_ALLOW_CROSS:1 PKG_CONFIG_PATH:/usr/lib/pkgconfig:/usr/share/pkgconfig], linux/amd64, x86_64-unknown-linux-gnu) (push) Failing after 4m29s
Multi-Platform Release Build / create-release (push) Has been skipped
2026-01-02 07:16:37 -04:00
e5938b9014 Testing workflow
Some checks failed
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 10m13s
Multi-Platform Release Build / build (true, build-essential gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64, 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) Failing after 3m58s
Multi-Platform Release Build / build (true, build-essential libssl-dev pkg-config, map[OPENSSL_DIR:/usr PKG_CONFIG_ALLOW_CROSS:1 PKG_CONFIG_PATH:/usr/lib/pkgconfig:/usr/share/pkgconfig], linux/amd64, x86_64-unknown-linux-gnu) (push) Failing after 9m48s
Multi-Platform Release Build / create-release (push) Has been skipped
2026-01-02 06:47:40 -04:00
ca1442851c Testing workflow
Some checks failed
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 5m22s
Multi-Platform Release Build / build (true, linux/amd64, x86_64-pc-windows-gnu) (push) Failing after 4m1s
Multi-Platform Release Build / build (true, linux/amd64, x86_64-unknown-linux-gnu) (push) Failing after 5m30s
Multi-Platform Release Build / create-release (push) Has been skipped
2026-01-01 19:17:53 -04:00
415032ecd1 Testing workflow
Some checks failed
Multi-Platform Release Build / build (false, linux/arm64, native) (push) Successful in 4m58s
Multi-Platform Release Build / build (true, linux/amd64, x86_64-pc-windows-gnu) (push) Failing after 2m36s
Multi-Platform Release Build / build (true, linux/amd64, x86_64-unknown-linux-gnu) (push) Failing after 5m41s
Multi-Platform Release Build / create-release (push) Has been skipped
2026-01-01 17:47:56 -04:00
22 changed files with 1271 additions and 600 deletions

View file

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

View file

@ -17,10 +17,17 @@ jobs:
- target: x86_64-unknown-linux-gnu - target: x86_64-unknown-linux-gnu
cross: true cross: true
platform: linux/amd64 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 - target: x86_64-pc-windows-gnu
cross: true cross: true
platform: linux/amd64 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: steps:
- name: Checkout code - name: Checkout code
@ -31,10 +38,6 @@ jobs:
with: with:
toolchain: stable toolchain: stable
- name: Add Rust target
if: matrix.target != 'native' && !matrix.container
run: rustup target add ${{ matrix.target }}
- name: Cache Rust dependencies - name: Cache Rust dependencies
uses: https://code.forgejo.org/actions/cache@v4 uses: https://code.forgejo.org/actions/cache@v4
with: with:
@ -63,39 +66,42 @@ jobs:
chmod +x "release-artifacts/$OUTPUT_NAME" chmod +x "release-artifacts/$OUTPUT_NAME"
ls -lh "release-artifacts/$OUTPUT_NAME" ls -lh "release-artifacts/$OUTPUT_NAME"
- name: Cross-Build Release (Linux x86_64) - name: Get Rust version
if: matrix.target == 'x86_64-unknown-linux-gnu' id: rust-version
uses: docker://rust:alpine run: echo "version=$(rustc --version | cut -d' ' -f2)" >> $GITHUB_OUTPUT
with:
args: >
sh -c "
apk add --no-cache build-base musl-dev gcc g++ openssl-dev pkg-config &&
rustup target add x86_64-unknown-linux-gnu &&
cargo build --release --target x86_64-unknown-linux-gnu &&
mkdir -p release-artifacts &&
cp target/x86_64-unknown-linux-gnu/release/uptime-kuma-dashboard release-artifacts/uptime-kuma-dashboard-x86_64-unknown-linux-gnu &&
strip release-artifacts/uptime-kuma-dashboard-x86_64-unknown-linux-gnu &&
chmod +x release-artifacts/uptime-kuma-dashboard-x86_64-unknown-linux-gnu &&
ls -lh release-artifacts/"
env:
CARGO_TARGET_DIR: target
options: --platform linux/amd64
- name: Cross-Build Release (Windows x86_64) - name: Cross-Build Linux x86_64 Release
if: matrix.target == 'x86_64-pc-windows-gnu' if: matrix.target == 'x86_64-unknown-linux-gnu'
uses: docker://rust:alpine uses: docker://rust:slim
with: with:
args: > args: >
sh -c " sh -c "
apk add --no-cache build-base mingw-w64-gcc mingw-w64-headers mingw-w64-crt mingw-w64-winpthreads && rustup target add ${{ matrix.target }} &&
rustup target add x86_64-pc-windows-gnu && apt-get update && apt-get install -y ${{ matrix.deps }} &&
cargo build --release --target x86_64-pc-windows-gnu && 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 && mkdir -p release-artifacts &&
cp target/x86_64-pc-windows-gnu/release/uptime-kuma-dashboard.exe release-artifacts/uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe && 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/" ls -lh release-artifacts/"
env: env: ${{ matrix.env }}
CARGO_TARGET_DIR: target options: --platform ${{ matrix.platform }}
options: --platform linux/amd64
- name: Upload artifacts - name: Upload artifacts
uses: https://data.forgejo.org/forgejo/upload-artifact@v4 uses: https://data.forgejo.org/forgejo/upload-artifact@v4
@ -132,42 +138,42 @@ jobs:
- name: Create Release - name: Create Release
uses: https://code.forgejo.org/actions/forgejo-release@v2 uses: https://code.forgejo.org/actions/forgejo-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
direction: upload direction: upload
url: https://git.marcodearaujo.com
repo: marcodearaujo/uptime-kuma-dashboard
token: ${{ secrets.FORGEJO_TOKEN }}
release-dir: release-artifacts release-dir: release-artifacts
title: "Release ${{ github.ref_name }}" title: "Release ${{ forgejo.ref_name }}"
tag: ${{ github.ref_name }} tag: ${{ forgejo.ref_name }}
release-notes:: | release-notes: |
## 🚀 Multi-Platform Release ## 🚀 Multi-Platform Release
Compiled on runner **${{ env.RUNNER_ARCH }}** Compiled on Forgejo Runner **${{ runner.arch }}**
### 📦 Available Binaries ### 📦 Available Binaries
This release includes binaries for the following platforms: This release includes binaries for the following platforms:
- **Linux x86_64** (Intel/AMD 64-bit) - **Linux x86_64** (Intel/AMD 64-bit)
- **Linux ARM64** (aarch64) - Raspberry Pi 4, ARM servers - **Linux ARM64** (aarch64) - Raspberry Pi 4, ARM servers
- **Windows x86_64** (64-bit) - **Windows x86_64** (64-bit)
### 🚀 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
- Runner Architecture: ${{ env.RUNNER_ARCH }} - Rust Version: ${{ steps.rust-version.outputs.version }}
- Rust Version: 1.84.0 - Build Date: $(date +'%Y-%m-%d')
- Build Type: Release (optimized) - Build Type: Release (optimized with LTO)

34
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,34 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Debug executable 'uptime-kuma-dashboard'",
"type": "lldb",
"request": "launch",
"cargo": {
"args": [
"run",
"--bin=uptime-kuma-dashboard"
]
},
"args": [
"--base-url=http://192.168.69.10:3001/",
"--slug=formgen",
]
},
{
"name": "Debug unit tests in executable 'uptime-kuma-dashboard'",
"type": "lldb",
"request": "launch",
"cargo": {
"args": [
"test",
"--bin=uptime-kuma-dashboard"
]
}
}
]
}

657
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,23 +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", features = ["derive", "env"] } clap = { version = "4.5.57", features = ["derive", "env"] }
reqwest = { version = "0.12.28", features = ["blocking", "json"] } reqwest = { version = "0.13.1", default-features = false, features = [
anyhow = "1.0" "blocking",
"json",
"rustls",
] }
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.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,29 +15,37 @@ 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,
} }
} }
pub fn fetch_heartbeat(&self, endpoints: &UptimeKumaEndpoints) -> Result<HeartbeatResponse> { pub fn fetch_heartbeat(&self, endpoints: &UptimeKumaEndpoints) -> Result<HeartbeatResponse> {
let response = self.client.get(endpoints.heartbeat_url()).send()?; self.fetch_and_parse(endpoints.heartbeat_url(), data::heartbeat::parse_response)
if response.status().is_success() {
let json_text = response.text()?;
data::heartbeat::parse_response(&json_text)
} else {
return Err(anyhow::anyhow!(response.status()));
}
} }
pub fn fetch_status_page(&self, endpoints: &UptimeKumaEndpoints) -> Result<StatusPageResponse> { pub fn fetch_status_page(&self, endpoints: &UptimeKumaEndpoints) -> Result<StatusPageResponse> {
let response = self.client.get(endpoints.status_page_url()).send()?; self.fetch_and_parse(
endpoints.status_page_url(),
data::status_page::parse_response,
)
}
fn fetch_and_parse<T, F>(&self, url: String, parser: F) -> Result<T>
where
F: FnOnce(&str) -> Result<T>,
{
let response = self.client.get(url.clone()).send()?;
if response.status().is_success() { if response.status().is_success() {
let json_text = response.text()?; let json_text = response.text()?;
data::status_page::parse_response(&json_text) parser(&json_text)
} else { } else {
return Err(anyhow::anyhow!(response.status())); Err(anyhow::anyhow!(
"URL: {}, Error: {}",
url,
response.status()
))
} }
} }
} }

View file

@ -16,10 +16,28 @@ impl UptimeKumaEndpoints {
} }
pub fn heartbeat_url(&self) -> String { pub fn heartbeat_url(&self) -> String {
format!("{}api/status-page/heartbeat/{}", self.base_url, self.slug) let mut url = self.get_url();
url.path_segments_mut()
.unwrap()
.push("heartbeat")
.push(&self.slug);
url.to_string()
} }
pub fn status_page_url(&self) -> String { pub fn status_page_url(&self) -> String {
format!("{}api/status-page/{}", self.base_url, self.slug) let mut url = self.get_url();
url.path_segments_mut()
.unwrap()
.push(&self.slug);
url.to_string()
}
fn get_url(&self) -> Url {
let mut url = self.base_url.clone();
url.path_segments_mut()
.unwrap()
.push("api")
.push("status-page");
url
} }
} }

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

@ -13,7 +13,7 @@ pub struct HeartbeatEntry {
#[serde(skip)] #[serde(skip)]
pub msg: String, pub msg: String,
#[serde(default)] #[serde(default)]
pub ping: Option<u64>, pub ping: Option<f32>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@ -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();
@ -48,12 +48,8 @@ pub fn t(key: &str) -> String {
pub fn t_with_args(key: &str, args: &HashMap<&'static str, String>) -> String { pub fn t_with_args(key: &str, args: &HashMap<&'static str, String>) -> String {
let mut map = HashMap::new(); let mut map = HashMap::new();
let args_map: &HashMap<Cow<'static, _>, FluentValue<'_>>;
args_map = {
for (key, value) in args { for (key, value) in args {
map.insert(Cow::Borrowed(*key), FluentValue::from(value.clone())); map.insert(Cow::Borrowed(*key), FluentValue::from(value));
} }
&map LOCALES.lookup_with_args(get_system_locale(), key, &map)
};
LOCALES.lookup_with_args(&get_system_locale(), key, args_map)
} }

View file

@ -6,9 +6,9 @@ Response = Response
response = response response = response
invalid-json-status-page = ❌ Error parssing status page JSON invalid-json-status-page = ❌ Error parssing status page JSON
invalid-json-heartbeat = ❌ Error parssing heartbeat JSON invalid-json-heartbeat = ❌ Error parssing heartbeat JSON
invalid-uptime-key-format = Invalid format for uptime key. Expected format "monitorID_period". Received key: {key} invalid-uptime-key-format = Invalid format for uptime key. Expected format "monitorID_period". Received key: {$key}
invalid-monitor-id = Invalid monitor ID: {id} invalid-monitor-id = Invalid monitor ID: {$id}
invalid-period-hours = Invalid period in hours: {hours} invalid-period-hours = Invalid period in hours: {$hours}
loading = Loading... loading = Loading...
monitors = Monitors monitors = Monitors
unknown = Unknown unknown = Unknown
@ -18,11 +18,11 @@ status = Status
error = Error error = Error
dashboard-header = Status Dashboard dashboard-header = Status Dashboard
never = Never never = Never
auto-update-failed = Automatic update failed: {error} auto-update-failed = Automatic update failed: {$error}
update-fail = Failed to update data: {error} update-fail = Failed to update data: {$error}
now = Now now = Now
uptime = Uptime uptime = Uptime
history = History history = History
auto-update-enabled = Auto-update enabled ({interval} min) auto-update-enabled = Auto-update enabled ({$interval} min)
update-failed = Update failed: {error} update-failed = Update failed: {$error}
key-to-exit = Press 'q' to exit key-to-exit = Press 'q' to exit

View file

@ -5,10 +5,10 @@ missing_url = ❌ URL não fornecida. Use --url ou a variável de ambiente UPTIM
Response = Resposta Response = Resposta
response = resposta response = resposta
invalid-json-status-page = ❌ Falha ao parsear JSON do status page invalid-json-status-page = ❌ Falha ao parsear JSON do status page
invalid-json-heartbeat = Falha ao parsear JSON do heartbeat invalid-json-heartbeat = Falha ao parsear JSON do heartbeat
invalid-uptime-key-format = Formato inválido na chave de uptime. Chave esperada no formato "monitorID_periodo". Chave recebida: {key} invalid-uptime-key-format = Formato inválido na chave de uptime. Chave esperada no formato "monitorID_periodo". Chave recebida: {$key}
invalid-monitor-id = ID de monitor inválido: {id} invalid-monitor-id = ID de monitor inválido: {$id}
invalid-period-hours = Período em horas inválido: {hours} invalid-period-hours = Período em horas inválido: {$hours}
loading = Carregando... loading = Carregando...
monitors = Monitors monitors = Monitors
monitor = Monitor monitor = Monitor
@ -23,6 +23,6 @@ auto-update-failed = Falha na atualização automática
update-fail = Falha ao atualizar dados update-fail = Falha ao atualizar dados
now = Agora now = Agora
history = Historico history = Historico
auto-update-enabled = Auto-atualização ativada ({interval} min) auto-update-enabled = Auto-atualização ativada ({$interval} min)
update-failed = Falha na atualização: {error} update-failed = Falha na atualização: {$error}
key-to-exit = Pressione 'q' para sair key-to-exit = Pressione 'q' para sair

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)));
}
}
fn layout_groups(area: Rect, groups: &[GroupViewState]) -> Vec<Rect> { static UNKNOWN_SPAN: Lazy<Span<'static>> =
let total_lines: usize = groups.iter().map(|g| g.monitors.len() + 1).sum(); Lazy::new(|| Span::styled("", Style::default().fg(Color::Yellow)));
if total_lines == 0 { static STATUS_LINE_CACHE: OnceLock<RwLock<HashMap<u64, Line<'static>>>> = OnceLock::new();
return Vec::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 scroll_pos = state.scroll_state.get_position();
let line_height = content_height as usize / total_lines; 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; for group in state.groups.iter() {
let mut areas = Vec::with_capacity(groups.len()); let group_height = group.monitors.len() + BORDER_LINES_VIEW;
for group in groups { if lines_skipped + group_height <= scroll_pos {
let group_lines = group.monitors.len() + 2; lines_skipped += group_height;
let group_height = (group_lines + line_height).max(1); continue;
areas.push(Rect { }
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, 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: bool) { fn render_group(frame: &mut Frame, area: Rect, group: &GroupViewState, is_first_half: 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());