Compare commits

..

No commits in common. "main" and "v0.0.10" have entirely different histories.

22 changed files with 650 additions and 1301 deletions

View file

@ -1,180 +0,0 @@
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

@ -12,22 +12,18 @@ jobs:
include: include:
- target: native - target: native
cross: false cross: false
platform: linux/arm64
- target: x86_64-unknown-linux-gnu - target: x86_64-unknown-linux-gnu
cross: true cross: true
platform: linux/amd64 linker: x86_64-linux-gnu-gcc
deps: "build-essential gcc-x86-64-linux-gnu libc6-dev-amd64-cross pkg-config binutils-x86-64-linux-gnu" - target: aarch64-unknown-linux-gnu
env: cross: true
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: x86_64-linux-gnu-gcc linker: aarch64-linux-gnu-gcc
- target: armv7-unknown-linux-gnueabihf
cross: true
linker: arm-linux-gnueabihf-gcc
- target: x86_64-pc-windows-gnu - target: x86_64-pc-windows-gnu
cross: true cross: true
platform: linux/amd64 linker: x86_64-w64-mingw32-gcc
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
@ -38,6 +34,24 @@ jobs:
with: with:
toolchain: stable toolchain: stable
- name: Add Rust target
if: matrix.target != 'native'
run: rustup target add ${{ matrix.target }}
- name: Install cross-compilation tools
if: matrix.cross
run: |
sudo apt-get update
sudo apt-get install -y \
gcc-arm-linux-gnueabihf \
gcc-aarch64-linux-gnu \
gcc-x86-64-linux-gnu \
g++-arm-linux-gnueabihf \
g++-aarch64-linux-gnu \
mingw-w64 \
libc6-dev-armhf-cross \
libc6-dev-arm64-cross
- 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:
@ -52,59 +66,58 @@ jobs:
${{ runner.os }}-cargo-${{ matrix.target }}- ${{ runner.os }}-cargo-${{ matrix.target }}-
${{ runner.os }}-cargo- ${{ runner.os }}-cargo-
- name: Native Build Release - name: Configure cross-compilation
if: matrix.target == 'native' if: matrix.cross && matrix.target != 'native'
run: | run: |
NATIVE_TARGET=$(rustc -vV | grep 'host:' | cut -d' ' -f2) mkdir -p .cargo
echo "Building for native target: $NATIVE_TARGET" cat >> .cargo/config.toml << EOF
cargo build --release [target.${{ matrix.target }}]
TARGET_BINARY="target/release/uptime-kuma-dashboard" linker = "${{ matrix.linker }}"
OUTPUT_NAME="uptime-kuma-dashboard-$NATIVE_TARGET" EOF
- name: Build release
run: |
if [ "${{ matrix.target }}" = "native" ]; then
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"
else
echo "Building for target: ${{ matrix.target }}"
cargo build --release --target ${{ matrix.target }}
if [[ "${{ matrix.target }}" == *"windows"* ]]; then
TARGET_BINARY="target/${{ matrix.target }}/release/uptime-kuma-dashboard.exe"
OUTPUT_NAME="uptime-kuma-dashboard-${{ matrix.target }}.exe"
else
TARGET_BINARY="target/${{ matrix.target }}/release/uptime-kuma-dashboard"
OUTPUT_NAME="uptime-kuma-dashboard-${{ matrix.target }}"
fi
fi
mkdir -p release-artifacts mkdir -p release-artifacts
if [ ! -f "$TARGET_BINARY" ]; then
echo "Error: Binary not found at $TARGET_BINARY"
exit 1
fi
cp "$TARGET_BINARY" "release-artifacts/$OUTPUT_NAME" cp "$TARGET_BINARY" "release-artifacts/$OUTPUT_NAME"
strip "release-artifacts/$OUTPUT_NAME" 2>/dev/null || true
chmod +x "release-artifacts/$OUTPUT_NAME" if [[ "${{ matrix.target }}" == *"linux"* ]] || [ "${{ matrix.target }}" = "native" ]; then
strip "release-artifacts/$OUTPUT_NAME" 2>/dev/null || echo "Strip failed, continuing..."
fi
if [[ "${{ matrix.target }}" != *"windows"* ]]; then
chmod +x "release-artifacts/$OUTPUT_NAME"
fi
ls -lh "release-artifacts/$OUTPUT_NAME" ls -lh "release-artifacts/$OUTPUT_NAME"
file "release-artifacts/$OUTPUT_NAME" || true
- 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 - name: Upload artifacts
uses: https://data.forgejo.org/forgejo/upload-artifact@v4 uses: https://code.forgejo.org/actions/upload-artifact@v6
with: with:
name: binary-${{ matrix.target }} name: binary-${{ matrix.target }}
path: release-artifacts/ path: release-artifacts/
@ -120,7 +133,7 @@ jobs:
uses: https://code.forgejo.org/actions/checkout@v4 uses: https://code.forgejo.org/actions/checkout@v4
- name: Download all artifacts - name: Download all artifacts
uses: https://data.forgejo.org/forgejo/download-artifact@v4 uses: https://code.forgejo.org/actions/download-artifact@v4
with: with:
path: all-artifacts/ path: all-artifacts/
@ -138,42 +151,43 @@ 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 ${{ forgejo.ref_name }}" title: "Release ${{ github.ref_name }}"
tag: ${{ forgejo.ref_name }} tag: ${{ github.ref_name }}
release-notes: | release-notes: |
## 🚀 Multi-Platform Release ## 🚀 Multi-Platform Release
Compiled on Forgejo Runner **${{ runner.arch }}** Compiled on runner **${{ env.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, Apple Silicon Linux, ARM servers
- **Linux ARMv7** (armhf) - Raspberry Pi 3 and earlier
- **Windows x86_64** (64-bit) - **Windows x86_64** (64-bit)
### 🚀 How to use ### 🚀 How to use
#### Linux/ARM: #### Linux/ARM:
# Download the appropriate binary ```bash
wget https://your-forgejo.com/releases/download/${{ forgejo.ref_name }}/uptime-kuma-dashboard-<your-target> # Download the appropriate binary
# Make executable wget https://your-forgejo.com/releases/download/${{ forgejo.ref_name }}/uptime-kuma-dashboard-<your-target>
chmod +x uptime-kuma-dashboard-* # Make executable
# Run chmod +x uptime-kuma-dashboard-*
./uptime-kuma-dashboard-* --base-url https://your-kuma --slug your-slug # Run
./uptime-kuma-dashboard-* --base-url https://your-kuma --slug your-slug
```
#### Windows: #### Windows:
# Download uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe ```powershell
# Run in PowerShell or CMD # Download uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe
.\uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe --base-url https://your-kuma --slug your-slug # Run in PowerShell or CMD
.\uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe --base-url https://your-kuma --slug your-slug
```
### ✅ Verify Checksums ### ✅ Verify Checksums
sha256sum -c SHA256SUMS.txt ```bash
sha256sum -c SHA256SUMS.txt
```
### 🏗️ Build Information ### 🏗️ Build Information
- Rust Version: ${{ steps.rust-version.outputs.version }} - Runner Architecture: ${{ env.RUNNER_ARCH }}
- Build Date: $(date +'%Y-%m-%d') - Rust Version: 1.84.0
- Build Type: Release (optimized with LTO) - Build Type: Release (optimized)

34
.vscode/launch.json vendored
View file

@ -1,34 +0,0 @@
{
// 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"
]
}
}
]
}

659
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,28 +1,23 @@
[package] [package]
name = "uptime-kuma-dashboard" name = "uptime-kuma-dashboard"
version = "0.38.2" version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
clap = { version = "4.5.57", features = ["derive", "env"] } clap = { version = "4.5", features = ["derive", "env"] }
reqwest = { version = "0.13.1", default-features = false, features = [ reqwest = { version = "0.12.28", features = ["blocking", "json"] }
"blocking", anyhow = "1.0"
"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.149" serde_json = "1.0.148"
once_cell = "1.19" once_cell = "1.19"
url = "2.5.8" url = "2.5.7"
ratatui = "0.30.0" ratatui = "0.30.0"
crossterm = "0.29.0" crossterm = "0.29.0"
chrono = "0.4.43" chrono = "0.4.42"
rayon = "1.11.0" rayon = "1.11.0"
rustls = { version = "0.23.36", default-features = false, features = ["ring"] }
[features] [features]
default = [] default = []
@ -38,7 +33,7 @@ lto = true
codegen-units = 1 codegen-units = 1
strip = true strip = true
panic = "abort" panic = "abort"
debug = false debug = false
[profile.release-min] [profile.release-min]
inherits = "release" inherits = "release"

21
LICENSE
View file

@ -1,21 +0,0 @@
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
View file

@ -1,145 +0,0 @@
# 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!_

Binary file not shown.

View file

@ -1,11 +1,9 @@
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}, i18n::t, data::{self, heartbeat::HeartbeatResponse, status_page::model::StatusPageResponse},
}; };
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -15,37 +13,29 @@ 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: Client::new(),
} }
} }
pub fn fetch_heartbeat(&self, endpoints: &UptimeKumaEndpoints) -> Result<HeartbeatResponse> { pub fn fetch_heartbeat(&self, endpoints: &UptimeKumaEndpoints) -> Result<HeartbeatResponse> {
self.fetch_and_parse(endpoints.heartbeat_url(), data::heartbeat::parse_response) let response = self.client.get(endpoints.heartbeat_url()).send()?;
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> {
self.fetch_and_parse( let response = self.client.get(endpoints.status_page_url()).send()?;
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()?;
parser(&json_text) data::status_page::parse_response(&json_text)
} else { } else {
Err(anyhow::anyhow!( return Err(anyhow::anyhow!(response.status()));
"URL: {}, Error: {}",
url,
response.status()
))
} }
} }
} }

View file

@ -16,28 +16,10 @@ impl UptimeKumaEndpoints {
} }
pub fn heartbeat_url(&self) -> String { pub fn heartbeat_url(&self) -> String {
let mut url = self.get_url(); format!("{}api/status-page/heartbeat/{}", self.base_url, self.slug)
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 {
let mut url = self.get_url(); format!("{}api/status-page/{}", self.base_url, self.slug)
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,4 +1,3 @@
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};
@ -7,45 +6,32 @@ use crate::data::{
status_page::model::StatusPageResponse, status_page::model::StatusPageResponse,
}; };
pub fn unify_data<'a>( pub fn unify_data(status_page: &StatusPageResponse, heartbeat: &HeartbeatResponse) -> UnifiedData {
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
.monitors
.iter()
.map(|m| (m.monitor_id, m.heartbeats.clone()))
.collect();
let mut heartbeat_map: HashMap<u64, &'a [HeartbeatEntry]> = let uptime_map: HashMap<(u64, u32), UptimeData> = heartbeat
HashMap::with_capacity(heartbeat.monitors.len()); .uptime_data
heartbeat_map.extend( .iter()
heartbeat .map(|u| ((u.monitor_id, u.period_hours), u.clone()))
.monitors .collect();
.iter()
.map(|m| (m.monitor_id, &m.heartbeats[..])),
);
let mut uptime_map: HashMap<(u64, u32), &'a UptimeData> =
HashMap::with_capacity(heartbeat.uptime_data.len());
uptime_map.extend(
heartbeat
.uptime_data
.iter()
.map(|u| ((u.monitor_id, u.period_hours), u)),
);
for group in &status_page.public_group_list { 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)).copied(); let uptime_data = uptime_map.get(&(monitor_info.id, 24)).cloned();
let heartbeats = heartbeat_map.get(&monitor_info.id).copied().unwrap_or(&[]); let heartbeats = heartbeat_map
.get(&monitor_info.id)
let name: Cow<'a, str> = if monitor_info.name.len() > 100 { .cloned()
Cow::Owned(monitor_info.name.clone()) .unwrap_or_default();
} else {
Cow::Borrowed(monitor_info.name.as_str())
};
monitors.push(UnifiedMonitorData { monitors.push(UnifiedMonitorData {
id: monitor_info.id, id: monitor_info.id,
name, name: monitor_info.name.clone(),
heartbeats, heartbeats,
uptime_data, uptime_data,
}); });
@ -54,7 +40,7 @@ pub fn unify_data<'a>(
monitors.sort_by_key(|m| m.id); monitors.sort_by_key(|m| m.id);
groups.push(UnifiedGroupData { groups.push(UnifiedGroupData {
group_info: group, group_info: group.clone(),
monitors, monitors,
}); });
} }
@ -62,8 +48,8 @@ pub fn unify_data<'a>(
groups.sort_by_key(|g| g.group_info.weight); groups.sort_by_key(|g| g.group_info.weight);
UnifiedData { UnifiedData {
title: Cow::Borrowed(&status_page.config.title), title: status_page.config.title.clone(),
description: status_page.config.description.as_deref().map(Cow::Borrowed), description: status_page.config.description.clone(),
groups, groups,
auto_refresh_interval: status_page.config.auto_refresh_interval, auto_refresh_interval: status_page.config.auto_refresh_interval,
} }

View file

@ -1,26 +1,24 @@
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<'a> { pub struct UnifiedMonitorData {
pub id: u64, pub id: u64,
pub name: Cow<'a, str>, pub name: String,
pub heartbeats: &'a [HeartbeatEntry], pub heartbeats: Vec<HeartbeatEntry>,
pub uptime_data: Option<&'a UptimeData>, pub uptime_data: Option<UptimeData>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct UnifiedData<'a> { pub struct UnifiedData {
pub title: Cow<'a, str>, pub title: String,
pub description: Option<Cow<'a, str>>, pub description: Option<String>,
pub auto_refresh_interval: u32, pub auto_refresh_interval: u32,
pub groups: Vec<UnifiedGroupData<'a>>, pub groups: Vec<UnifiedGroupData>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct UnifiedGroupData<'a> { pub struct UnifiedGroupData {
pub group_info: &'a StatusPageGroup, pub group_info: StatusPageGroup,
pub monitors: Vec<UnifiedMonitorData<'a>>, pub monitors: Vec<UnifiedMonitorData>,
} }

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<f32>, pub ping: Option<u64>,
} }
#[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(|| t("invalid-monitor-id").to_string())?; .with_context(|| format!("{}", t("invalid-monitor-id")))?;
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(|| t("invalid-monitor-id").to_string())?; .with_context(|| format!("{}", t("invalid-monitor-id")))?;
let period_hours = parts[1] let period_hours = parts[1]
.parse::<u32>() .parse::<u32>()
.with_context(|| t("invalid-period-hours").to_string())?; .with_context(|| format!("{}", t("invalid-period-hours")))?;
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 = serde_json::from_slice(json_text.as_bytes()) let mut response: HeartbeatResponse =
.with_context(|| t("invalid-json-heartbeat"))?; serde_json::from_str(json_text).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 = serde_json::from_slice(json_text.as_bytes()) let response: StatusPageResponse =
.with_context(|| t("invalid-json-status-page"))?; serde_json::from_str(json_text).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,8 +48,12 @@ 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();
for (key, value) in args { let args_map: &HashMap<Cow<'static, _>, FluentValue<'_>>;
map.insert(Cow::Borrowed(*key), FluentValue::from(value)); args_map = {
} for (key, value) in args {
LOCALES.lookup_with_args(get_system_locale(), key, &map) map.insert(Cow::Borrowed(*key), FluentValue::from(value.clone()));
}
&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,10 +1,12 @@
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,
@ -16,18 +18,23 @@ 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, Scrollbar, ScrollbarOrientation}, widgets::{Block, Borders, Padding, Paragraph},
}; };
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)];
const MAIN_LAYOUT_WITHOUT_SCROLLBAR: [Constraint; 1] = [Constraint::Min(1)]; enum FetchResult {
Heartbeat(Result<HeartbeatResponse>),
StatusPage(Result<StatusPageResponse>),
}
pub struct App { pub struct App {
state: DashboardViewState, state: DashboardViewState,
@ -40,26 +47,6 @@ 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)?;
@ -129,101 +116,51 @@ 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(HEADER_HEIGHT), Constraint::Length(3),
Constraint::Length(max_content_height.max(1)), Constraint::Min(1),
Constraint::Length(FOOTER_HEIGHT), Constraint::Length(3),
]) ])
.split(area); .split(area);
render_header(frame, chunks[0], &self.state); render_header(frame, chunks[0], &self.state);
let main_constraint = if self.state.show_vertical_scrollbar(chunks[1].height) {
&MAIN_LAYOUT_WITH_SCROLLBAR[..]
} else {
&MAIN_LAYOUT_WITHOUT_SCROLLBAR[..]
};
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(main_constraint)
.split(chunks[1]);
if self.state.is_loading { if self.state.is_loading {
Self::render_loading(frame, main_chunks[0]); Self::render_loading(frame, chunks[1]);
} else if let Some(error) = &self.state.error_message { } else if let Some(error) = &self.state.error_message {
Self::render_error(frame, main_chunks[0], error); Self::render_error(frame, chunks[1], error);
} else if self.state.groups.is_empty() || self.state.get_all_monitors().is_empty() { } else if self.state.groups.is_empty() || self.state.get_all_monitors().is_empty() {
Self::render_no_data(frame, main_chunks[0]); Self::render_no_data(frame, chunks[1]);
} else { } else {
Self::render_main(frame, main_chunks.to_vec(), &mut self.state); render_monitor_list(frame, chunks[1], &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()) .map(|d| d.as_secs() as u64)
.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)? {
&& let Event::Key(key) = event::read()? if 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(())
@ -243,6 +180,7 @@ 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(()) => {
@ -259,11 +197,57 @@ impl App {
Ok(()) Ok(())
} }
fn fetch_and_update_data(&mut self) -> anyhow::Result<()> { pub fn new(endpoints: UptimeKumaEndpoints) -> io::Result<Self> {
let heartbeat_result = self.client.fetch_heartbeat(&self.endpoints)?; let backend = CrosstermBackend::new(io::stdout());
let status_page_result = self.client.fetch_status_page(&self.endpoints)?; let mut terminal = Terminal::new(backend)?;
terminal.hide_cursor()?;
let unified_data = core::unify_data(&status_page_result, &heartbeat_result); let state = DashboardViewState::new();
let initial_interval = Duration::from_secs(INITIAL_INTERVAL as u64);
Ok(Self {
state,
terminal,
should_quit: false,
last_update: Instant::now(),
update_interval: initial_interval,
endpoints,
client: UptimeKumaClient::new(),
})
}
fn fetch_and_update_data(&mut self) -> anyhow::Result<()> {
let (tx, rx) = mpsc::channel();
let heartbeat_client = self.client.clone();
let heartbeat_endpoints = self.endpoints.clone();
let tx_clone = tx.clone();
thread::spawn(move || {
let result = heartbeat_client.fetch_heartbeat(&heartbeat_endpoints);
tx.send(FetchResult::Heartbeat(result)).unwrap();
});
let status_page_client = self.client.clone();
let status_page_endpoints = self.endpoints.clone();
thread::spawn(move || {
let result = status_page_client.fetch_status_page(&status_page_endpoints);
tx_clone.send(FetchResult::StatusPage(result)).unwrap();
});
let mut heartbeat_result = None;
let mut status_page_result = None;
for _ in 0..2 {
match rx.recv()? {
FetchResult::Heartbeat(result) => heartbeat_result = Some(result?),
FetchResult::StatusPage(result) => status_page_result = Some(result?),
}
}
let heartbeat_data = heartbeat_result.unwrap();
let status_page_data = status_page_result.unwrap();
let unified_data = core::unify_data(&status_page_data, &heartbeat_data);
self.state = DashboardViewState::from_unified_data(unified_data); self.state = DashboardViewState::from_unified_data(unified_data);
Ok(()) Ok(())
} }

View file

@ -9,11 +9,7 @@ use ratatui::{
use crate::i18n::t; use crate::i18n::t;
use chrono::Local; use chrono::Local;
pub fn render_footer( pub fn render_footer(frame: &mut Frame, area: Rect, seconds_until_update: u64) {
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,19 +1,10 @@
use std::{ use std::cmp::min;
borrow::Cow,
cmp::min,
collections::{HashMap, hash_map::DefaultHasher},
hash::{Hash, Hasher},
sync::{OnceLock, RwLock},
};
use crate::{ use crate::i18n::t;
i18n::t, use crate::ui::dashboard::{
ui::dashboard::{ MonitorStatus, MonitorViewState,
MonitorStatus, MonitorViewState, model::{DashboardViewState, GroupViewState},
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},
@ -25,72 +16,50 @@ 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;
static UP_SPAN: Lazy<Span<'static>> = pub fn render_monitor_list(frame: &mut Frame, area: Rect, state: &DashboardViewState) {
Lazy::new(|| Span::styled("", Style::default().fg(Color::Green))); let group_areas = layout_groups(area, &state.groups);
static DOWN_SPAN: Lazy<Span<'static>> = for (i, (group, &group_area)) in state.groups.iter().zip(group_areas.iter()).enumerate() {
Lazy::new(|| Span::styled("", Style::default().fg(Color::Red))); render_group(frame, group_area, group, i == 0);
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);
}
let scroll_pos = state.scroll_state.get_position();
let mut current_y = area.y as usize;
let mut rendered_height = 0;
let mut lines_skipped = 0;
let half = state.get_total_length().saturating_div(2);
for group in state.groups.iter() {
let group_height = group.monitors.len() + BORDER_LINES_VIEW;
if lines_skipped + group_height <= scroll_pos {
lines_skipped += group_height;
continue;
}
let visible_height = if lines_skipped < scroll_pos {
group_height - (scroll_pos - lines_skipped)
} else {
group_height
}
.min(available_height - rendered_height);
let group_area = Rect {
x: area.x,
y: current_y as u16,
width: area.width,
height: visible_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;
} }
} }
fn render_group(frame: &mut Frame, area: Rect, group: &GroupViewState, is_first_half: bool) { fn layout_groups(area: Rect, groups: &[GroupViewState]) -> Vec<Rect> {
let total_lines: usize = groups.iter().map(|g| g.monitors.len() + 1).sum();
if total_lines == 0 {
return Vec::new();
}
let content_height = area.height.saturating_sub(2);
let line_height = content_height as usize / total_lines;
let mut current_y = area.y + 1;
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,
y: current_y,
width: area.width,
height: group_height as u16,
});
current_y += group_height as u16;
}
areas
}
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(
@ -100,7 +69,11 @@ fn render_group(frame: &mut Frame, area: Rect, group: &GroupViewState, is_first_
]); ]);
let title_block = Block::default() let title_block = Block::default()
.borders(Borders::ALL) .borders(if is_first {
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);
@ -108,24 +81,13 @@ 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, is_first_half); render_monitor_table(frame, chunks[1], &group.monitors);
} }
} }
fn render_monitor_table( fn render_monitor_table(frame: &mut Frame, area: Rect, monitors: &Vec<MonitorViewState>) {
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(),
@ -137,10 +99,11 @@ fn render_monitor_table(
let header = Row::new(header_cells).style(title_style()).height(1); let header = Row::new(header_cells).style(title_style()).height(1);
let mut rows: Vec<Row> = Vec::with_capacity(items_to_show); let rows: Vec<Row> = monitors
for monitor in monitors.iter().take(items_to_show) { .iter()
rows.push(create_monitor_item(monitor)); .take(items_to_show)
} .map(|monitor| create_monitor_item(monitor))
.collect();
let widths = vec![ let widths = vec![
Constraint::Length(3), Constraint::Length(3),
@ -164,6 +127,14 @@ fn render_monitor_table(
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,
@ -172,14 +143,11 @@ pub fn get_status_color(status: &MonitorStatus) -> Color {
} }
} }
pub fn get_status_emoji(status: &MonitorStatus) -> Cow<'static, str> { pub fn get_status_emoji(status: &MonitorStatus) -> &str {
static UP_ICON: &str = "";
static DOWN_ICON: &str = "";
static UNKNOWN_ICON: &str = "";
match status { match status {
MonitorStatus::Up => Cow::Borrowed(UP_ICON), MonitorStatus::Up => "",
MonitorStatus::Down => Cow::Borrowed(DOWN_ICON), MonitorStatus::Down => "",
MonitorStatus::Unknown => Cow::Borrowed(UNKNOWN_ICON), MonitorStatus::Unknown => "",
} }
} }
@ -187,24 +155,20 @@ 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: Cow<str> = if monitor.name.len() > MAX_NAME_LENGTH { let display_name: String = if monitor.name.len() > MAX_NAME_LENGTH {
Cow::Owned(format!( format!("{:.width$}...", &monitor.name, width = MAX_NAME_LENGTH - 3)
"{:.width$}...",
monitor.name,
width = MAX_NAME_LENGTH - 3
))
} else { } else {
Cow::Borrowed(&monitor.name) monitor.name.clone()
}; };
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 = get_cached_status_line(&monitor.status_history); let status_line_spans = create_status_line_spans(&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.to_string(), Color::White, Modifier::empty()), get_formated_line(display_name, 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,
@ -220,12 +184,22 @@ fn get_formated_line(text: String, color: Color, modifier: Modifier) -> Line<'st
)]) )])
} }
fn get_status_span(status: &MonitorStatus) -> &'static Span<'static> { fn create_status_line_spans(status_history: &[MonitorStatus]) -> Line<'_> {
match status { let recent_status: Vec<_> = status_history
MonitorStatus::Up => &UP_SPAN, .iter()
MonitorStatus::Down => &DOWN_SPAN, .rev()
MonitorStatus::Unknown => &UNKNOWN_SPAN, .take(STATUS_LINE_LENGTH)
.collect();
let mut spans = Vec::with_capacity(recent_status.len());
for status in recent_status.iter().rev() {
let c = get_status_char(status);
let color = get_status_color(status);
spans.push(Span::styled(c.to_string(), Style::default().fg(color)));
} }
Line::from(spans)
} }
fn title_style() -> Style { fn title_style() -> Style {
@ -233,46 +207,3 @@ 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,12 +1,9 @@
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, Eq, Hash)] #[derive(Debug, Clone, PartialEq)]
pub enum MonitorStatus { pub enum MonitorStatus {
Up, Up,
Down, Down,
@ -15,7 +12,7 @@ pub enum MonitorStatus {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MonitorViewState { pub struct MonitorViewState {
pub name: Cow<'static, str>, pub name: String,
pub status: MonitorStatus, pub status: MonitorStatus,
pub response_time: String, pub response_time: String,
pub uptime_24h: String, pub uptime_24h: String,
@ -36,8 +33,6 @@ 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 {
@ -49,8 +44,6 @@ 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,
} }
} }
@ -64,43 +57,32 @@ impl DashboardViewState {
}); });
} }
let total_length: usize = groups
.iter()
.map(|g| g.monitors.len() + BORDER_LINES_VIEW)
.sum();
Self { Self {
title: data.title.into_owned(), title: data.title,
descriptions: data.description.map(|d| d.into_owned()), descriptions: data.description,
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: Vec<_> = Vec::with_capacity(heartbeats.len()); let mut history = heartbeats
history.extend(heartbeats.iter().rev().take(100).map(|h| match h.status { .iter()
0 => MonitorStatus::Down, .rev()
1 => MonitorStatus::Up, .take(100)
_ => MonitorStatus::Unknown, .map(|h| match h.status {
})); 0 => MonitorStatus::Down,
1 => MonitorStatus::Up,
_ => MonitorStatus::Unknown,
})
.collect::<Vec<_>>();
while history.len() < 100 { while history.len() < 100 {
history.push(MonitorStatus::Unknown); history.push(MonitorStatus::Unknown);
@ -112,42 +94,36 @@ 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 group.monitors.into_par_iter().map(|monitor| {
.monitors
.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_history = get_status_history(&monitor.heartbeats);
Some(1) => MonitorStatus::Up,
Some(0) => MonitorStatus::Down,
_ => MonitorStatus::Unknown,
};
let response_time = match monitor.heartbeats.last().and_then(|h| h.ping) { let status = match monitor.heartbeats.last().map(|h| h.status) {
Some(ms) => format!("{}", ms), Some(1) => MonitorStatus::Up,
None => t("unknown"), Some(0) => MonitorStatus::Down,
}; _ => MonitorStatus::Unknown,
};
let uptime_24h = match monitor.uptime_data.map(|u| u.get_perc_formated()) { let response_time = monitor
Some(perc) => perc, .heartbeats
None => t("unknown"), .last()
}; .and_then(|h| h.ping)
.map(|ms| format!("{}", ms))
.unwrap_or_else(|| t("unknown").to_string() + " ");
let name: Cow<'static, str> = match monitor.name { let uptime_24h = monitor
Cow::Borrowed(borrowed) => Cow::Owned(borrowed.to_string()), .uptime_data
Cow::Owned(owned) => Cow::Owned(owned), .map(|u| u.get_perc_formated())
}; .unwrap_or_else(|| t("unknown").to_string());
MonitorViewState { MonitorViewState {
name, name: monitor.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());