Compare commits
No commits in common. "main" and "v0.0.37-alpha1" have entirely different histories.
main
...
v0.0.37-al
17 changed files with 267 additions and 744 deletions
|
|
@ -1,179 +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: "Release ${{ forgejo.ref_name }}"
|
||||
tag: ${{ forgejo.ref_name }}
|
||||
release-notes: |
|
||||
## 🚀 Multi-Platform Release
|
||||
Compiled on Forgejo Runner **${{ runner.arch }}**
|
||||
|
||||
### 📦 Available Binaries
|
||||
This release includes binaries for the following platforms:
|
||||
- **Linux x86_64** (Intel/AMD 64-bit)
|
||||
- **Linux ARM64** (aarch64) - Raspberry Pi 4, ARM servers
|
||||
- **Windows x86_64** (64-bit)
|
||||
|
||||
### 🚀 How to use
|
||||
#### Linux/ARM:
|
||||
# Download the appropriate binary
|
||||
wget https://your-forgejo.com/releases/download/${{ forgejo.ref_name }}/uptime-kuma-dashboard-<your-target>
|
||||
# Make executable
|
||||
chmod +x uptime-kuma-dashboard-*
|
||||
# Run
|
||||
./uptime-kuma-dashboard-* --base-url https://your-kuma --slug your-slug
|
||||
|
||||
#### Windows:
|
||||
# Download uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe
|
||||
# Run in PowerShell or CMD
|
||||
.\uptime-kuma-dashboard-x86_64-pc-windows-gnu.exe --base-url https://your-kuma --slug your-slug
|
||||
|
||||
### ✅ Verify Checksums
|
||||
sha256sum -c SHA256SUMS.txt
|
||||
|
||||
### 🏗️ Build Information
|
||||
- Rust Version: ${{ steps.rust-version.outputs.version }}
|
||||
- Build Date: $(date +'%Y-%m-%d')
|
||||
- Build Type: Release (optimized with LTO)
|
||||
34
Cargo.lock
generated
34
Cargo.lock
generated
|
|
@ -78,9 +78,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.101"
|
||||
version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "atomic"
|
||||
|
|
@ -236,9 +236,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.43"
|
||||
version = "0.4.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
|
||||
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
|
|
@ -249,9 +249,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.57"
|
||||
version = "4.5.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a"
|
||||
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
|
|
@ -259,9 +259,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.57"
|
||||
version = "4.5.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238"
|
||||
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
|
|
@ -271,9 +271,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.55"
|
||||
version = "4.5.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
|
|
@ -1955,9 +1955,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.36"
|
||||
version = "0.23.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
|
|
@ -2132,9 +2132,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
version = "1.0.148"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
|
|
@ -2668,7 +2668,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
|||
|
||||
[[package]]
|
||||
name = "uptime-kuma-dashboard"
|
||||
version = "0.38.2"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2689,9 +2689,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
version = "2.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
|
|
|
|||
14
Cargo.toml
14
Cargo.toml
|
|
@ -1,28 +1,28 @@
|
|||
[package]
|
||||
name = "uptime-kuma-dashboard"
|
||||
version = "0.38.2"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.57", features = ["derive", "env"] }
|
||||
clap = { version = "4.5.54", features = ["derive", "env"] }
|
||||
reqwest = { version = "0.13.1", default-features = false, features = [
|
||||
"blocking",
|
||||
"json",
|
||||
"rustls",
|
||||
] }
|
||||
anyhow = "1.0.101"
|
||||
anyhow = "1.0"
|
||||
fluent-templates = "0.13.2"
|
||||
unic-langid = "0.9.6"
|
||||
sys-locale = "0.3.2"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
serde_json = "1.0.148"
|
||||
once_cell = "1.19"
|
||||
url = "2.5.8"
|
||||
url = "2.5.7"
|
||||
ratatui = "0.30.0"
|
||||
crossterm = "0.29.0"
|
||||
chrono = "0.4.43"
|
||||
chrono = "0.4.42"
|
||||
rayon = "1.11.0"
|
||||
rustls = { version = "0.23.36", default-features = false, features = ["ring"] }
|
||||
rustls = { version = "0.23.35", default-features = false, features = ["ring"] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
|
|
|||
21
LICENSE
21
LICENSE
|
|
@ -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
145
README.md
|
|
@ -1,145 +0,0 @@
|
|||
# Uptime Kuma Terminal Dashboard
|
||||
|
||||

|
||||
|
||||
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
BIN
assets/demo.webm
Binary file not shown.
|
|
@ -1,11 +1,9 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use reqwest::blocking::Client;
|
||||
|
||||
use crate::{
|
||||
api::endpoints::UptimeKumaEndpoints,
|
||||
data::{self, heartbeat::HeartbeatResponse, status_page::model::StatusPageResponse}, i18n::t,
|
||||
data::{self, heartbeat::HeartbeatResponse, status_page::model::StatusPageResponse},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -15,9 +13,8 @@ pub struct UptimeKumaClient {
|
|||
|
||||
impl UptimeKumaClient {
|
||||
pub fn new() -> Self {
|
||||
let client = Client::builder().connect_timeout(Duration::from_secs(10)).timeout(Duration::from_secs(30)).build().unwrap_or_else(|_| panic!("{}", t("http-build-error")));
|
||||
Self {
|
||||
client,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::core::models::{UnifiedData, UnifiedGroupData, UnifiedMonitorData};
|
||||
|
|
@ -7,45 +6,32 @@ use crate::data::{
|
|||
status_page::model::StatusPageResponse,
|
||||
};
|
||||
|
||||
pub fn unify_data<'a>(
|
||||
status_page: &'a StatusPageResponse,
|
||||
heartbeat: &'a HeartbeatResponse,
|
||||
) -> UnifiedData<'a> {
|
||||
pub fn unify_data(status_page: &StatusPageResponse, heartbeat: &HeartbeatResponse) -> UnifiedData {
|
||||
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]> =
|
||||
HashMap::with_capacity(heartbeat.monitors.len());
|
||||
heartbeat_map.extend(
|
||||
heartbeat
|
||||
.monitors
|
||||
.iter()
|
||||
.map(|m| (m.monitor_id, &m.heartbeats[..])),
|
||||
);
|
||||
|
||||
let mut uptime_map: HashMap<(u64, u32), &'a UptimeData> =
|
||||
HashMap::with_capacity(heartbeat.uptime_data.len());
|
||||
uptime_map.extend(
|
||||
heartbeat
|
||||
.uptime_data
|
||||
.iter()
|
||||
.map(|u| ((u.monitor_id, u.period_hours), u)),
|
||||
);
|
||||
let uptime_map: HashMap<(u64, u32), UptimeData> = heartbeat
|
||||
.uptime_data
|
||||
.iter()
|
||||
.map(|u| ((u.monitor_id, u.period_hours), u.clone()))
|
||||
.collect();
|
||||
|
||||
for group in &status_page.public_group_list {
|
||||
let mut monitors = Vec::with_capacity(group.monitor_list.len());
|
||||
for monitor_info in &group.monitor_list {
|
||||
let uptime_data = uptime_map.get(&(monitor_info.id, 24)).copied();
|
||||
let heartbeats = heartbeat_map.get(&monitor_info.id).copied().unwrap_or(&[]);
|
||||
|
||||
let name: Cow<'a, str> = if monitor_info.name.len() > 100 {
|
||||
Cow::Owned(monitor_info.name.clone())
|
||||
} else {
|
||||
Cow::Borrowed(monitor_info.name.as_str())
|
||||
};
|
||||
let uptime_data = uptime_map.get(&(monitor_info.id, 24)).cloned();
|
||||
let heartbeats = heartbeat_map
|
||||
.get(&monitor_info.id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
monitors.push(UnifiedMonitorData {
|
||||
id: monitor_info.id,
|
||||
name,
|
||||
name: monitor_info.name.clone(),
|
||||
heartbeats,
|
||||
uptime_data,
|
||||
});
|
||||
|
|
@ -54,7 +40,7 @@ pub fn unify_data<'a>(
|
|||
monitors.sort_by_key(|m| m.id);
|
||||
|
||||
groups.push(UnifiedGroupData {
|
||||
group_info: group,
|
||||
group_info: group.clone(),
|
||||
monitors,
|
||||
});
|
||||
}
|
||||
|
|
@ -62,8 +48,8 @@ pub fn unify_data<'a>(
|
|||
groups.sort_by_key(|g| g.group_info.weight);
|
||||
|
||||
UnifiedData {
|
||||
title: Cow::Borrowed(&status_page.config.title),
|
||||
description: status_page.config.description.as_deref().map(Cow::Borrowed),
|
||||
title: status_page.config.title.clone(),
|
||||
description: status_page.config.description.clone(),
|
||||
groups,
|
||||
auto_refresh_interval: status_page.config.auto_refresh_interval,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,24 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use crate::data::heartbeat::model::{HeartbeatEntry, UptimeData};
|
||||
use crate::data::status_page::model::{StatusPageGroup};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnifiedMonitorData<'a> {
|
||||
pub struct UnifiedMonitorData {
|
||||
pub id: u64,
|
||||
pub name: Cow<'a, str>,
|
||||
pub heartbeats: &'a [HeartbeatEntry],
|
||||
pub uptime_data: Option<&'a UptimeData>,
|
||||
pub name: String,
|
||||
pub heartbeats: Vec<HeartbeatEntry>,
|
||||
pub uptime_data: Option<UptimeData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnifiedData<'a> {
|
||||
pub title: Cow<'a, str>,
|
||||
pub description: Option<Cow<'a, str>>,
|
||||
pub struct UnifiedData {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub auto_refresh_interval: u32,
|
||||
pub groups: Vec<UnifiedGroupData<'a>>,
|
||||
pub groups: Vec<UnifiedGroupData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnifiedGroupData<'a> {
|
||||
pub group_info: &'a StatusPageGroup,
|
||||
pub monitors: Vec<UnifiedMonitorData<'a>>,
|
||||
pub struct UnifiedGroupData {
|
||||
pub group_info: StatusPageGroup,
|
||||
pub monitors: Vec<UnifiedMonitorData>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ impl HeartbeatResponse {
|
|||
for (monitor_id_str, heartbeats) in &self.heartbeat_list_raw {
|
||||
let monitor_id = monitor_id_str
|
||||
.parse::<u64>()
|
||||
.with_context(|| t("invalid-monitor-id").to_string())?;
|
||||
.with_context(|| format!("{}", t("invalid-monitor-id")))?;
|
||||
|
||||
self.monitors.push(MonitorHeartbeats {
|
||||
monitor_id,
|
||||
|
|
@ -77,11 +77,11 @@ impl HeartbeatResponse {
|
|||
|
||||
let monitor_id = parts[0]
|
||||
.parse::<u64>()
|
||||
.with_context(|| t("invalid-monitor-id").to_string())?;
|
||||
.with_context(|| format!("{}", t("invalid-monitor-id")))?;
|
||||
|
||||
let period_hours = parts[1]
|
||||
.parse::<u32>()
|
||||
.with_context(|| t("invalid-period-hours").to_string())?;
|
||||
.with_context(|| format!("{}", t("invalid-period-hours")))?;
|
||||
|
||||
self.uptime_data.push(UptimeData {
|
||||
monitor_id,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ use crate::i18n::t;
|
|||
use anyhow::{Context, Ok, Result};
|
||||
|
||||
pub fn parse_response(json_text: &str) -> Result<HeartbeatResponse> {
|
||||
let mut response: HeartbeatResponse = serde_json::from_slice(json_text.as_bytes())
|
||||
.with_context(|| t("invalid-json-heartbeat"))?;
|
||||
let mut response: HeartbeatResponse =
|
||||
serde_json::from_str(json_text).with_context(|| t("invalid-json-heartbeat"))?;
|
||||
|
||||
response.process()?;
|
||||
Ok(response)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use crate::i18n::t;
|
|||
use anyhow::{Context, Ok, Result};
|
||||
|
||||
pub fn parse_response(json_text: &str) -> Result<StatusPageResponse> {
|
||||
let response: StatusPageResponse = serde_json::from_slice(json_text.as_bytes())
|
||||
.with_context(|| t("invalid-json-status-page"))?;
|
||||
let response: StatusPageResponse =
|
||||
serde_json::from_str(json_text).with_context(|| t("invalid-json-status-page"))?;
|
||||
Ok(response)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ pub fn t(key: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
let result = LOCALES.lookup(get_system_locale(), key);
|
||||
let result = LOCALES.lookup(&*get_system_locale(), key);
|
||||
|
||||
let mut cache_write = cache.write().unwrap();
|
||||
|
||||
|
|
@ -51,5 +51,5 @@ pub fn t_with_args(key: &str, args: &HashMap<&'static str, String>) -> String {
|
|||
for (key, value) in args {
|
||||
map.insert(Cow::Borrowed(*key), FluentValue::from(value));
|
||||
}
|
||||
LOCALES.lookup_with_args(get_system_locale(), key, &map)
|
||||
LOCALES.lookup_with_args(&get_system_locale(), key, &map)
|
||||
}
|
||||
|
|
|
|||
172
src/ui/app.rs
172
src/ui/app.rs
|
|
@ -1,10 +1,12 @@
|
|||
use crate::api::{UptimeKumaClient, UptimeKumaEndpoints};
|
||||
use crate::core;
|
||||
use crate::data::{heartbeat::HeartbeatResponse, status_page::model::StatusPageResponse};
|
||||
use crate::i18n::{t, t_with_args};
|
||||
use crate::ui::{
|
||||
components::{render_footer, render_header, render_monitor_list},
|
||||
dashboard::model::DashboardViewState,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
|
||||
execute,
|
||||
|
|
@ -16,18 +18,23 @@ use ratatui::{
|
|||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation},
|
||||
widgets::{Block, Borders, Padding, Paragraph},
|
||||
};
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io,
|
||||
sync::mpsc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
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 {
|
||||
state: DashboardViewState,
|
||||
|
|
@ -40,26 +47,6 @@ pub struct 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<()> {
|
||||
enable_raw_mode()?;
|
||||
execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
|
||||
|
|
@ -129,101 +116,51 @@ impl App {
|
|||
fn render(&mut self) {
|
||||
let _ = self.terminal.draw(|frame| {
|
||||
let area = frame.area();
|
||||
|
||||
const HEADER_HEIGHT: u16 = 3;
|
||||
const FOOTER_HEIGHT: u16 = 3;
|
||||
|
||||
let max_content_height = area.height.saturating_sub(HEADER_HEIGHT + FOOTER_HEIGHT);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.margin(1)
|
||||
.constraints([
|
||||
Constraint::Length(HEADER_HEIGHT),
|
||||
Constraint::Length(max_content_height.max(1)),
|
||||
Constraint::Length(FOOTER_HEIGHT),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
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 {
|
||||
Self::render_loading(frame, main_chunks[0]);
|
||||
Self::render_loading(frame, chunks[1]);
|
||||
} 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() {
|
||||
Self::render_no_data(frame, main_chunks[0]);
|
||||
Self::render_no_data(frame, chunks[1]);
|
||||
} 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
|
||||
.update_interval
|
||||
.checked_sub(self.last_update.elapsed())
|
||||
.map(|d| d.as_secs())
|
||||
.map(|d| d.as_secs() as u64)
|
||||
.unwrap_or(30);
|
||||
|
||||
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<()> {
|
||||
let timeout = Duration::from_secs(1);
|
||||
|
||||
if event::poll(timeout)?
|
||||
&& let Event::Key(key) = event::read()?
|
||||
{
|
||||
if key.kind == KeyEventKind::Release {
|
||||
return Ok(());
|
||||
}
|
||||
if event::poll(timeout)? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind == KeyEventKind::Release {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
|
||||
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(),
|
||||
_ => {}
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -243,6 +180,7 @@ impl App {
|
|||
|
||||
fn refresh_data(&mut self) -> io::Result<()> {
|
||||
self.state.is_loading = true;
|
||||
self.render();
|
||||
|
||||
match self.fetch_and_update_data() {
|
||||
Ok(()) => {
|
||||
|
|
@ -259,11 +197,57 @@ impl App {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_and_update_data(&mut self) -> anyhow::Result<()> {
|
||||
let heartbeat_result = self.client.fetch_heartbeat(&self.endpoints)?;
|
||||
let status_page_result = self.client.fetch_status_page(&self.endpoints)?;
|
||||
pub fn new(endpoints: UptimeKumaEndpoints) -> io::Result<Self> {
|
||||
let backend = CrosstermBackend::new(io::stdout());
|
||||
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);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,7 @@ use ratatui::{
|
|||
use crate::i18n::t;
|
||||
use chrono::Local;
|
||||
|
||||
pub fn render_footer(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
seconds_until_update: u64
|
||||
) {
|
||||
pub fn render_footer(frame: &mut Frame, area: Rect, seconds_until_update: u64) {
|
||||
let now = Local::now();
|
||||
let datatime_str = now.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let countdown_str = format!("↻ {}s", seconds_until_update);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,10 @@
|
|||
use std::{
|
||||
borrow::Cow,
|
||||
cmp::min,
|
||||
collections::{HashMap, hash_map::DefaultHasher},
|
||||
hash::{Hash, Hasher},
|
||||
sync::{OnceLock, RwLock},
|
||||
};
|
||||
use std::cmp::min;
|
||||
|
||||
use crate::{
|
||||
i18n::t,
|
||||
ui::dashboard::{
|
||||
MonitorStatus, MonitorViewState,
|
||||
model::{BORDER_LINES_VIEW, DashboardViewState, GroupViewState},
|
||||
},
|
||||
use crate::i18n::t;
|
||||
use crate::ui::dashboard::{
|
||||
MonitorStatus, MonitorViewState,
|
||||
model::{DashboardViewState, GroupViewState},
|
||||
};
|
||||
use once_cell::sync::Lazy;
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
|
|
@ -25,72 +16,50 @@ use ratatui::{
|
|||
const STATUS_LINE_LENGTH: usize = 100;
|
||||
const MAX_NAME_LENGTH: usize = 30;
|
||||
|
||||
static UP_SPAN: Lazy<Span<'static>> =
|
||||
Lazy::new(|| Span::styled("■", Style::default().fg(Color::Green)));
|
||||
pub fn render_monitor_list(frame: &mut Frame, area: Rect, state: &DashboardViewState) {
|
||||
let group_areas = layout_groups(area, &state.groups);
|
||||
|
||||
static DOWN_SPAN: Lazy<Span<'static>> =
|
||||
Lazy::new(|| Span::styled("■", Style::default().fg(Color::Red)));
|
||||
|
||||
static UNKNOWN_SPAN: Lazy<Span<'static>> =
|
||||
Lazy::new(|| Span::styled("■", Style::default().fg(Color::Yellow)));
|
||||
|
||||
static STATUS_LINE_CACHE: OnceLock<RwLock<HashMap<u64, Line<'static>>>> = OnceLock::new();
|
||||
|
||||
pub fn render_monitor_list(main_frame: &mut Frame, area: Rect, state: &mut DashboardViewState) {
|
||||
let available_height = area.height as usize;
|
||||
let max_scroll = state.get_total_length().saturating_sub(available_height);
|
||||
|
||||
if state.scroll_state.get_position() > max_scroll {
|
||||
state.scroll_state = state.scroll_state.position(max_scroll);
|
||||
}
|
||||
|
||||
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;
|
||||
for (i, (group, &group_area)) in state.groups.iter().zip(group_areas.iter()).enumerate() {
|
||||
render_group(frame, group_area, group, i == 0);
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
.direction(Direction::Vertical)
|
||||
.margin(0)
|
||||
.constraints([Constraint::Length(1), Constraint::Min(1)])
|
||||
.split(area);
|
||||
|
||||
if chunks[0].height == 0 || chunks[1].height == 0 || group.monitors.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let group_title = Line::from(vec![
|
||||
Span::styled(format!("{} ", group.name), title_style()),
|
||||
Span::styled(
|
||||
|
|
@ -100,7 +69,11 @@ fn render_group(frame: &mut Frame, area: Rect, group: &GroupViewState, is_first_
|
|||
]);
|
||||
|
||||
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))
|
||||
.title(group_title)
|
||||
.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]);
|
||||
|
||||
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(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
monitors: &[MonitorViewState],
|
||||
is_first_half: bool,
|
||||
) {
|
||||
fn render_monitor_table(frame: &mut Frame, area: Rect, monitors: &Vec<MonitorViewState>) {
|
||||
let max_items = area.height as usize;
|
||||
let items_to_show = min(monitors.len(), max_items);
|
||||
let index = if is_first_half {
|
||||
monitors.len().saturating_sub(max_items.saturating_sub(2)) // 2 = Table header + botton
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let monitors = &monitors[index..index + items_to_show.min(monitors.len() - index)];
|
||||
|
||||
let header_cells = vec![
|
||||
"".to_string(),
|
||||
|
|
@ -137,10 +99,11 @@ fn render_monitor_table(
|
|||
|
||||
let header = Row::new(header_cells).style(title_style()).height(1);
|
||||
|
||||
let mut rows: Vec<Row> = Vec::with_capacity(items_to_show);
|
||||
for monitor in monitors.iter().take(items_to_show) {
|
||||
rows.push(create_monitor_item(monitor));
|
||||
}
|
||||
let rows: Vec<Row> = monitors
|
||||
.iter()
|
||||
.take(items_to_show)
|
||||
.map(|monitor| create_monitor_item(monitor))
|
||||
.collect();
|
||||
|
||||
let widths = vec![
|
||||
Constraint::Length(3),
|
||||
|
|
@ -164,6 +127,14 @@ fn render_monitor_table(
|
|||
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 {
|
||||
match status {
|
||||
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> {
|
||||
static UP_ICON: &str = "✅";
|
||||
static DOWN_ICON: &str = "❌";
|
||||
static UNKNOWN_ICON: &str = "❓";
|
||||
pub fn get_status_emoji(status: &MonitorStatus) -> &str {
|
||||
match status {
|
||||
MonitorStatus::Up => Cow::Borrowed(UP_ICON),
|
||||
MonitorStatus::Down => Cow::Borrowed(DOWN_ICON),
|
||||
MonitorStatus::Unknown => Cow::Borrowed(UNKNOWN_ICON),
|
||||
MonitorStatus::Up => "✅",
|
||||
MonitorStatus::Down => "❌",
|
||||
MonitorStatus::Unknown => "❓",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -187,24 +155,20 @@ fn create_monitor_item(monitor: &MonitorViewState) -> Row<'_> {
|
|||
let status_icon = get_status_emoji(&monitor.status);
|
||||
let status_color = get_status_color(&monitor.status);
|
||||
|
||||
let display_name: Cow<str> = if monitor.name.len() > MAX_NAME_LENGTH {
|
||||
Cow::Owned(format!(
|
||||
"{:.width$}...",
|
||||
monitor.name,
|
||||
width = MAX_NAME_LENGTH - 3
|
||||
))
|
||||
let display_name: String = if monitor.name.len() > MAX_NAME_LENGTH {
|
||||
format!("{:.width$}...", &monitor.name, width = MAX_NAME_LENGTH - 3)
|
||||
} else {
|
||||
Cow::Borrowed(&monitor.name)
|
||||
monitor.name.clone()
|
||||
};
|
||||
|
||||
let response_text = format!("{:>7}ms", monitor.response_time);
|
||||
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![
|
||||
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(uptime_text, Color::Magenta, Modifier::empty()),
|
||||
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> {
|
||||
match status {
|
||||
MonitorStatus::Up => &UP_SPAN,
|
||||
MonitorStatus::Down => &DOWN_SPAN,
|
||||
MonitorStatus::Unknown => &UNKNOWN_SPAN,
|
||||
fn create_status_line_spans(status_history: &[MonitorStatus]) -> Line<'_> {
|
||||
let recent_status: Vec<_> = status_history
|
||||
.iter()
|
||||
.rev()
|
||||
.take(STATUS_LINE_LENGTH)
|
||||
.collect();
|
||||
|
||||
let mut spans = Vec::with_capacity(recent_status.len());
|
||||
|
||||
for status in recent_status.iter().rev() {
|
||||
let c = get_status_char(status);
|
||||
let color = get_status_color(status);
|
||||
|
||||
spans.push(Span::styled(c.to_string(), Style::default().fg(color)));
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn title_style() -> Style {
|
||||
|
|
@ -233,46 +207,3 @@ fn title_style() -> Style {
|
|||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
fn calculate_history_hash(status_history: &[MonitorStatus]) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
status_history
|
||||
.iter()
|
||||
.take(STATUS_LINE_LENGTH)
|
||||
.for_each(|status| {
|
||||
status.hash(&mut hasher);
|
||||
});
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn get_cached_status_line(status_history: &[MonitorStatus]) -> Line<'static> {
|
||||
let hash = calculate_history_hash(status_history);
|
||||
let cache = STATUS_LINE_CACHE.get_or_init(|| RwLock::new(HashMap::new()));
|
||||
{
|
||||
let read = cache.read().unwrap();
|
||||
if let Some(line) = read.get(&hash) {
|
||||
return line.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let mut spans: Vec<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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
use crate::core::models::{UnifiedData, UnifiedGroupData};
|
||||
use crate::data::heartbeat::model::HeartbeatEntry;
|
||||
use crate::i18n::t;
|
||||
use ratatui::widgets::ScrollbarState;
|
||||
use rayon::prelude::*;
|
||||
use std::borrow::Cow;
|
||||
pub const BORDER_LINES_VIEW: usize = 3;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum MonitorStatus {
|
||||
Up,
|
||||
Down,
|
||||
|
|
@ -15,7 +12,7 @@ pub enum MonitorStatus {
|
|||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MonitorViewState {
|
||||
pub name: Cow<'static, str>,
|
||||
pub name: String,
|
||||
pub status: MonitorStatus,
|
||||
pub response_time: String,
|
||||
pub uptime_24h: String,
|
||||
|
|
@ -36,8 +33,6 @@ pub struct DashboardViewState {
|
|||
pub is_loading: bool,
|
||||
pub error_message: Option<String>,
|
||||
pub auto_refresh_interval: u32,
|
||||
pub scroll_state: ScrollbarState,
|
||||
total_length: usize,
|
||||
}
|
||||
|
||||
impl DashboardViewState {
|
||||
|
|
@ -49,8 +44,6 @@ impl DashboardViewState {
|
|||
is_loading: true,
|
||||
error_message: None,
|
||||
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 {
|
||||
title: data.title.into_owned(),
|
||||
descriptions: data.description.map(|d| d.into_owned()),
|
||||
title: data.title,
|
||||
descriptions: data.description,
|
||||
groups,
|
||||
is_loading: false,
|
||||
error_message: None,
|
||||
auto_refresh_interval: data.auto_refresh_interval.max(30),
|
||||
scroll_state: ScrollbarState::new(total_length.saturating_sub(1)),
|
||||
total_length,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_total_length(&self) -> usize {
|
||||
self.total_length
|
||||
}
|
||||
|
||||
pub fn get_all_monitors(&self) -> Vec<&MonitorViewState> {
|
||||
self.groups.iter().flat_map(|g| g.monitors.iter()).collect()
|
||||
}
|
||||
|
||||
pub fn show_vertical_scrollbar(&self, available_height: u16) -> bool {
|
||||
self.total_length as u16 > available_height
|
||||
}
|
||||
}
|
||||
|
||||
fn get_status_history(heartbeats: &[HeartbeatEntry]) -> Vec<MonitorStatus> {
|
||||
let mut history: Vec<_> = Vec::with_capacity(heartbeats.len());
|
||||
history.extend(heartbeats.iter().rev().take(100).map(|h| match h.status {
|
||||
0 => MonitorStatus::Down,
|
||||
1 => MonitorStatus::Up,
|
||||
_ => MonitorStatus::Unknown,
|
||||
}));
|
||||
let mut history = heartbeats
|
||||
.iter()
|
||||
.rev()
|
||||
.take(100)
|
||||
.map(|h| match h.status {
|
||||
0 => MonitorStatus::Down,
|
||||
1 => MonitorStatus::Up,
|
||||
_ => MonitorStatus::Unknown,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
while history.len() < 100 {
|
||||
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> {
|
||||
let mut monitors = Vec::with_capacity(group.monitors.len());
|
||||
group
|
||||
.monitors
|
||||
.into_par_iter()
|
||||
.map(|monitor| {
|
||||
let status_history = get_status_history(monitor.heartbeats);
|
||||
group.monitors.into_par_iter().map(|monitor| {
|
||||
|
||||
let status = match monitor.heartbeats.last().map(|h| h.status) {
|
||||
Some(1) => MonitorStatus::Up,
|
||||
Some(0) => MonitorStatus::Down,
|
||||
_ => MonitorStatus::Unknown,
|
||||
};
|
||||
let status_history = get_status_history(&monitor.heartbeats);
|
||||
|
||||
let response_time = match monitor.heartbeats.last().and_then(|h| h.ping) {
|
||||
Some(ms) => format!("{}", ms),
|
||||
None => t("unknown"),
|
||||
};
|
||||
let status = match monitor.heartbeats.last().map(|h| h.status) {
|
||||
Some(1) => MonitorStatus::Up,
|
||||
Some(0) => MonitorStatus::Down,
|
||||
_ => MonitorStatus::Unknown,
|
||||
};
|
||||
|
||||
let uptime_24h = match monitor.uptime_data.map(|u| u.get_perc_formated()) {
|
||||
Some(perc) => perc,
|
||||
None => t("unknown"),
|
||||
};
|
||||
let response_time = monitor
|
||||
.heartbeats
|
||||
.last()
|
||||
.and_then(|h| h.ping)
|
||||
.map(|ms| format!("{}", ms))
|
||||
.unwrap_or_else(|| t("unknown").to_string() + " ");
|
||||
|
||||
let name: Cow<'static, str> = match monitor.name {
|
||||
Cow::Borrowed(borrowed) => Cow::Owned(borrowed.to_string()),
|
||||
Cow::Owned(owned) => Cow::Owned(owned),
|
||||
};
|
||||
let uptime_24h = monitor
|
||||
.uptime_data
|
||||
.map(|u| u.get_perc_formated())
|
||||
.unwrap_or_else(|| t("unknown").to_string());
|
||||
|
||||
MonitorViewState {
|
||||
name,
|
||||
status,
|
||||
response_time,
|
||||
uptime_24h,
|
||||
status_history,
|
||||
}
|
||||
})
|
||||
.collect_into_vec(&mut monitors);
|
||||
MonitorViewState {
|
||||
name: monitor.name,
|
||||
status,
|
||||
response_time,
|
||||
uptime_24h,
|
||||
status_history,
|
||||
}
|
||||
}).collect_into_vec(&mut monitors);
|
||||
|
||||
monitors.sort_by_key(|m| m.name.clone());
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue