Compare commits

...

31 Commits

Author SHA1 Message Date
dc1a2e3a0f Add disk wear monitoring and fix storage overflow display
All checks were successful
Build and Release / build-and-release (push) Successful in 1m15s
- Add disk wear percentage collection from SMART data in backup script
- Add backup_disk_wear_percent metric to backup collector with thresholds
- Display wear percentage in backup widget disk section
- Fix storage section overflow handling to use consistent "X more below" logic
- Update maintenance mode to return pending status instead of unknown
2025-11-20 20:36:45 +01:00
5d6b8e6253 Treat pending status as OK for title bar color aggregation
All checks were successful
Build and Release / build-and-release (push) Successful in 1m12s
Apply same logic used for inactive status to pending status.
Pending services now contribute to OK count instead of being
ignored, preventing blue title bar during service transitions.
2025-11-20 18:09:59 +01:00
0cba083305 Remove pending status from title bar color aggregation
All checks were successful
Build and Release / build-and-release (push) Successful in 2m9s
Title bar now only shows Critical (red), Warning (yellow), and OK (green)
colors. Pending status is ignored in color calculation to prevent blue
title bar during service transitions.
2025-11-20 14:19:29 +01:00
a6be7a4788 Consolidate log viewing to use service-manage logs action
All checks were successful
Build and Release / build-and-release (push) Successful in 1m31s
Replace separate service_logs_cmd with service-manage logs action
to unify service management through single script interface.
Dashboard now calls 'service-manage logs <service>' which provides
intelligent log viewing based on service state and configuration.
2025-11-20 11:30:55 +01:00
2384f7f9b9 Unify log viewing with configurable script command
All checks were successful
Build and Release / build-and-release (push) Successful in 2m37s
Replace separate J/L keys with single L key that calls configurable
service_logs_cmd from dashboard config. Script handles both journalctl
and custom log files automatically based on service configuration.

Update status bar to show all available keybindings including
previously missing backup and terminal commands.
2025-11-20 11:00:38 +01:00
cd5ef65d3d Fix service selection for services with sub-services
All checks were successful
Build and Release / build-and-release (push) Successful in 2m35s
- Fix get_selected_service to always return parent service names
- Prevent selection of container sub-items when managing docker services
- Ensure service commands operate on correct systemd service names
- Simplify service selection logic to only consider parent services
- Update version to 0.1.92
2025-11-19 18:01:10 +01:00
7bf9ca6201 Fix SSH command quoting and remove duplicate user prompts
All checks were successful
Build and Release / build-and-release (push) Successful in 2m8s
- Fix rebuild and backup commands with proper inner command quoting
- Remove duplicate "Press any key to close..." from SSH commands since scripts handle it
- Clean up SSH terminal command to avoid redundant prompts
- Ensure consistent command execution patterns across all SSH operations
- Update version to 0.1.91
2025-11-19 16:08:03 +01:00
f587b42797 Implement unified SSH command management with dedicated scripts
All checks were successful
Build and Release / build-and-release (push) Successful in 1m11s
- Replace complex SSH command patterns with simple script calls
- Create service-manage script for start/stop operations with proper logging
- Create rebuild script equivalent to rebuild_git alias with user feedback
- Update dashboard to use unified command pattern: sudo service-manage, sudo rebuild
- Simplify backup to use service management: service-manage start borgbackup
- Configure sudoers with wildcards for Nix store path compatibility
- Remove cmtec references from script names for better genericity
- Update version to 0.1.90
2025-11-19 15:37:33 +01:00
7ae464e172 Wrap service commands in bash -c to ensure session persistence
All checks were successful
Build and Release / build-and-release (push) Successful in 1m10s
- Use bash -c to properly execute service start/stop command sequences
- Ensure SSH session stays alive for user input prompt
- Fix escaping issues with nested quotes in commands
- Update version to 0.1.89
2025-11-19 13:32:04 +01:00
980c9a20a2 Fix service start/stop popup auto-close issue
All checks were successful
Build and Release / build-and-release (push) Successful in 1m12s
- Move 'Press any key to close...' prompt inside SSH session
- Ensure tmux popup stays open until user manually closes
- Maintain consistent behavior with other SSH commands
- Update version to 0.1.88
2025-11-19 13:21:48 +01:00
448a38dede Fix service management command issues
All checks were successful
Build and Release / build-and-release (push) Successful in 2m7s
- Add sudo to pkill commands to resolve permission errors when killing journalctl processes
- Fix service stop command timing to show logs during shutdown process
- Add sleep delays to ensure log visibility before cleanup
- Update version to 0.1.87
2025-11-19 13:13:15 +01:00
f12e20b0f3 Standardize SSH command patterns with consistent user feedback
All checks were successful
Build and Release / build-and-release (push) Successful in 2m10s
- Apply uniform pattern to all SSH commands: informational text + command + exit prompt
- Remove exit prompt from logging commands (J/L keys) that run continuously with -f flag
- Simplify rebuild and backup commands to match service command pattern
- Update version to 0.1.86
2025-11-19 12:57:18 +01:00
564d1f37e7 Streamline service commands with auto-close functionality
All checks were successful
Build and Release / build-and-release (push) Successful in 2m8s
- Remove header text from start/stop commands for cleaner output
- Add automatic log termination when service reaches target state
- Start command auto-closes when service becomes active
- Stop command auto-closes when service becomes inactive
- Simplify SSH command structure by removing bash -c wrapper
- Version bump to 0.1.85
2025-11-19 12:30:36 +01:00
65bfb9f617 Add real-time logging to service stop command
All checks were successful
Build and Release / build-and-release (push) Successful in 2m9s
- Update stop command to use background systemctl with immediate log following
- Use same approach as start command for consistent real-time log viewing
- Version bump to 0.1.84
2025-11-19 11:59:18 +01:00
4f4ef6259b Fix service start log command escaping
All checks were successful
Build and Release / build-and-release (push) Successful in 2m6s
- Change --since="1 second ago" to --since='1 second ago'
- Fixes shell escaping issue preventing real-time logs
- Version bump to 0.1.83
2025-11-19 11:49:08 +01:00
505263cec6 Fix real-time service logs with background start approach
All checks were successful
Build and Release / build-and-release (push) Successful in 2m8s
- Update service start to use background systemctl start with immediate log following
- Implement `sudo systemctl start service & sudo journalctl -fu service --since="1 second ago"`
- Remove buffering issues that prevented real-time log streaming
- Version bump to 0.1.82
2025-11-19 11:21:49 +01:00
61dd686fb9 Fix real-time log streaming by simplifying service start command
All checks were successful
Build and Release / build-and-release (push) Successful in 1m34s
- Remove complex background process monitoring that was buffering output
- Use direct journalctl -fu command for immediate real-time log streaming
- Eliminate monitoring loop that was killing log stream when service became active
- User now controls log following duration with Ctrl+C
- Fixes buffering issues that prevented seeing ark server startup logs in real-time
2025-11-19 08:42:50 +01:00
c0f7a97a6f Remove all scrolling code and user-stopped tracking logic
All checks were successful
Build and Release / build-and-release (push) Successful in 2m36s
- Remove scroll offset fields from HostWidgets struct
- Replace scrolling with simple "X more below" indicators in all widgets
- Remove user-stopped service tracking from agent (now uses SSH control)
- Inactive services now consistently show Status::Inactive with empty circles
- Simplify widget render methods by removing scroll parameters
- Clean up unused imports and legacy scrolling infrastructure
- Fix journalctl command to use -fu for proper log following
2025-11-19 08:32:42 +01:00
9575077045 Fix Status::Inactive aggregation priority for green title bar
All checks were successful
Build and Release / build-and-release (push) Successful in 2m9s
- Move Status::Inactive to lowest priority in enum (before Ok)
- Status aggregation now prefers Ok over Inactive in mixed scenarios
- Title bar stays green when mixing active and inactive services
- Inactive services still show gray icons but don't affect overall status
- Ensures healthy systems with stopped services maintain green status
2025-11-18 18:17:25 +01:00
34a1f7b9dc Fix Status::Inactive ordering to prevent gray title bar
All checks were successful
Build and Release / build-and-release (push) Successful in 2m8s
- Reorder Status enum variants to fix aggregation priority
- Status::Inactive now has same priority as Status::Ok in aggregation
- Prevents inactive services from causing gray title bar
- Title bar stays green when system has only active and inactive services
- Only Unknown/Offline/Pending/Warning/Critical statuses affect title color
2025-11-18 18:03:50 +01:00
d11aa11f99 Add Status::Inactive for inactive services with empty circle display
All checks were successful
Build and Release / build-and-release (push) Successful in 1m12s
- Add new Status::Inactive variant to enum for better service state representation
- Agent now assigns Status::Inactive instead of Status::Warning for inactive services
- Dashboard displays inactive services with empty circle (○) icon in gray color
- User-stopped services still show as Status::Ok with green filled circle
- Inactive services treated as OK for host status aggregation
- Improves visual clarity between active (●), inactive (○), and warning (◐) states
2025-11-18 17:54:51 +01:00
0ca06d2507 Add smart service start with automatic log exit
All checks were successful
Build and Release / build-and-release (push) Successful in 1m8s
- Service start now follows logs in real-time until service becomes active
- Automatically stops log following when systemctl reports service as active
- Eliminates need for manual Ctrl+C to exit log stream
- Shows final service status after startup completes
- Background monitoring loop checks service state every second
2025-11-18 16:50:33 +01:00
6693f3a05f Remove transitional icons and improve service logs
All checks were successful
Build and Release / build-and-release (push) Successful in 1m31s
- Remove all transitional icon infrastructure (CommandType, pending transitions)
- Clean up ZMQ command system remnants after SSH migration
- Add real-time log streaming for service start operations
- Show final logs and status for service stop operations
- Fix compilation warnings by removing unused methods
- Simplify UI architecture with pure SSH-based service control
2025-11-18 16:40:14 +01:00
de252d27b9 Migrate service control from ZMQ to SSH with real-time progress
All checks were successful
Build and Release / build-and-release (push) Successful in 2m34s
Replace ZMQ-based service start/stop commands with SSH execution in tmux
popups. This provides better user feedback with real-time systemctl output
while eliminating blocking operations from the main message processing loop.

Changes:
- Service start/stop now use SSH with progress display
- Added backup functionality with 'B' key
- Preserved transitional icons (↑/↓) for immediate visual feedback
- Removed all ZMQ service control commands and handlers
- Updated configuration to include backup_alias setting
- All operations (rebuild, backup, services) now use consistent SSH interface

This ensures stable heartbeat processing while providing superior user
experience with live command output and service status feedback.
2025-11-18 16:02:15 +01:00
db0e41a7d3 Remove blocking CollectNow commands to fix heartbeat stability
All checks were successful
Build and Release / build-and-release (push) Successful in 1m9s
Eliminates automatic CollectNow command sending on host connection which
was blocking the main message processing loop for up to 5 seconds per
command. Since agents transmit cached data every 2 seconds anyway, the
CollectNow optimization provided minimal benefit while causing heartbeat
detection issues. Also removes unused send_command wrapper method.

This should completely resolve intermittent host connection dropping.
2025-11-15 11:41:58 +01:00
ec460496d8 Remove blocking TCP connectivity tests for fast startup
All checks were successful
Build and Release / build-and-release (push) Successful in 1m10s
Eliminates test_tcp_connectivity function that was causing 5-10 second
startup delays. ZMQ connections are non-blocking and we rely entirely
on heartbeat mechanism for connectivity detection. This restores fast
dashboard startup time.
2025-11-15 11:09:49 +01:00
33e700529e Bump version to 0.1.71
All checks were successful
Build and Release / build-and-release (push) Successful in 1m30s
Version bump for release with fixed automated NixOS configuration
update workflow that uses the correct file path.
2025-11-15 10:25:08 +01:00
d644b7d40a Fix NixOS config path in automated release workflow
Update release.yml to use correct path hosts/services/cm-dashboard.nix
instead of hosts/common/cm-dashboard.nix. Also update documentation
in CLAUDE.md and README.md to reflect the correct file location.
2025-11-15 10:21:30 +01:00
f635ba9c75 Remove Tailscale and connection type complexity
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
Simplifies host connection configuration by removing tailscale_ip field,
connection_type preferences, and fallback retry logic. Now uses only the
ip field or hostname as fallback. Eliminates blocking TCP connectivity
tests that interfered with heartbeat processing.

This resolves intermittent host lost/found issues by removing the
connection retry timeouts that blocked the ZMQ message processing loop.
2025-11-15 10:04:47 +01:00
76b6e3373e Change auto connection type to prioritize local IP first
All checks were successful
Build and Release / build-and-release (push) Successful in 2m36s
Update the auto connection type logic to try local network connections
before falling back to Tailscale. This provides better performance by
using faster local connections when available while maintaining Tailscale
as a reliable fallback.

Changes:
- Auto connection priority: local → tailscale → hostname (was tailscale → local)
- Fallback retry order updated to match new priority
- Supports omitting IP field in config for hosts without static local IP
2025-11-13 12:52:46 +01:00
0a13cab897 Add detected IP display in dashboard Agent row
All checks were successful
Build and Release / build-and-release (push) Successful in 1m8s
Display the connection IP address that the dashboard is configured to use
for each host below the Agent version information. Shows which network
path (local/Tailscale) is being used for connections based on host
configuration.

Features:
- Display detected IP below Agent row in system widget
- Uses existing host configuration connection logic
- Shows actual IP being used for dashboard connections
2025-11-13 11:26:58 +01:00
21 changed files with 434 additions and 926 deletions

View File

@@ -113,13 +113,13 @@ jobs:
NIX_HASH="sha256-$(python3 -c "import base64, binascii; print(base64.b64encode(binascii.unhexlify('$NEW_HASH')).decode())")" NIX_HASH="sha256-$(python3 -c "import base64, binascii; print(base64.b64encode(binascii.unhexlify('$NEW_HASH')).decode())")"
# Update the NixOS configuration # Update the NixOS configuration
sed -i "s|version = \"v[^\"]*\"|version = \"$VERSION\"|" hosts/common/cm-dashboard.nix sed -i "s|version = \"v[^\"]*\"|version = \"$VERSION\"|" hosts/services/cm-dashboard.nix
sed -i "s|sha256 = \"sha256-[^\"]*\"|sha256 = \"$NIX_HASH\"|" hosts/common/cm-dashboard.nix sed -i "s|sha256 = \"sha256-[^\"]*\"|sha256 = \"$NIX_HASH\"|" hosts/services/cm-dashboard.nix
# Commit and push changes # Commit and push changes
git config user.name "Gitea Actions" git config user.name "Gitea Actions"
git config user.email "actions@gitea.cmtec.se" git config user.email "actions@gitea.cmtec.se"
git add hosts/common/cm-dashboard.nix git add hosts/services/cm-dashboard.nix
git commit -m "Auto-update cm-dashboard to $VERSION git commit -m "Auto-update cm-dashboard to $VERSION
- Update version to $VERSION with automated release - Update version to $VERSION with automated release

View File

@@ -49,8 +49,12 @@ hostname2 = [
### Navigation ### Navigation
- **Tab**: Switch between hosts - **Tab**: Switch between hosts
- **↑↓ or j/k**: Select services - **↑↓ or j/k**: Select services
- **s**: Start selected service (UserStart)
- **S**: Stop selected service (UserStop)
- **J**: Show service logs (journalctl) - **J**: Show service logs (journalctl)
- **L**: Show custom log files - **L**: Show custom log files
- **R**: Rebuild current host
- **B**: Run backup on current host
- **q**: Quit dashboard - **q**: Quit dashboard
## Core Architecture Principles ## Core Architecture Principles
@@ -115,7 +119,7 @@ This automatically:
- Uploads binaries via Gitea API - Uploads binaries via Gitea API
### NixOS Configuration Updates ### NixOS Configuration Updates
Edit `~/projects/nixosbox/hosts/common/cm-dashboard.nix`: Edit `~/projects/nixosbox/hosts/services/cm-dashboard.nix`:
```nix ```nix
version = "v0.1.X"; version = "v0.1.X";

230
Cargo.lock generated
View File

@@ -17,9 +17,9 @@ dependencies = [
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.3" version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@@ -71,22 +71,22 @@ dependencies = [
[[package]] [[package]]
name = "anstyle-query" name = "anstyle-query"
version = "1.1.4" version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
name = "anstyle-wincon" name = "anstyle-wincon"
version = "3.0.10" version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"once_cell_polyfill", "once_cell_polyfill",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -95,6 +95,15 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "ar_archive_writer"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c269894b6fe5e9d7ada0cf69b5bf847ff35bc25fc271f08e1d080fce80339a"
dependencies = [
"object",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -144,9 +153,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.10.1" version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]] [[package]]
name = "cassowary" name = "cassowary"
@@ -156,9 +165,9 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.41" version = "1.2.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -230,9 +239,9 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.49" version = "4.5.52"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4512b90fa68d3a9932cea5184017c5d200f5921df706d45e853537dea51508f" checksum = "aa8120877db0e5c011242f96806ce3c94e0737ab8108532a76a3300a01db2ab8"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@@ -240,9 +249,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.49" version = "4.5.52"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0025e98baa12e766c67ba13ff4695a887a1eba19569aad00a472546795bd6730" checksum = "02576b399397b659c26064fbc92a75fede9d18ffd5f80ca1cd74ddab167016e1"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@@ -270,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]] [[package]]
name = "cm-dashboard" name = "cm-dashboard"
version = "0.1.66" version = "0.1.96"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -292,7 +301,7 @@ dependencies = [
[[package]] [[package]]
name = "cm-dashboard-agent" name = "cm-dashboard-agent"
version = "0.1.66" version = "0.1.96"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -315,7 +324,7 @@ dependencies = [
[[package]] [[package]]
name = "cm-dashboard-shared" name = "cm-dashboard-shared"
version = "0.1.66" version = "0.1.96"
dependencies = [ dependencies = [
"chrono", "chrono",
"serde", "serde",
@@ -503,9 +512,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.4" version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
[[package]] [[package]]
name = "fnv" name = "fnv"
@@ -768,9 +777,9 @@ dependencies = [
[[package]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.0.0" version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"potential_utf", "potential_utf",
@@ -781,9 +790,9 @@ dependencies = [
[[package]] [[package]]
name = "icu_locale_core" name = "icu_locale_core"
version = "2.0.0" version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"litemap", "litemap",
@@ -794,11 +803,10 @@ dependencies = [
[[package]] [[package]]
name = "icu_normalizer" name = "icu_normalizer"
version = "2.0.0" version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
dependencies = [ dependencies = [
"displaydoc",
"icu_collections", "icu_collections",
"icu_normalizer_data", "icu_normalizer_data",
"icu_properties", "icu_properties",
@@ -809,42 +817,38 @@ dependencies = [
[[package]] [[package]]
name = "icu_normalizer_data" name = "icu_normalizer_data"
version = "2.0.0" version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
[[package]] [[package]]
name = "icu_properties" name = "icu_properties"
version = "2.0.1" version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99"
dependencies = [ dependencies = [
"displaydoc",
"icu_collections", "icu_collections",
"icu_locale_core", "icu_locale_core",
"icu_properties_data", "icu_properties_data",
"icu_provider", "icu_provider",
"potential_utf",
"zerotrie", "zerotrie",
"zerovec", "zerovec",
] ]
[[package]] [[package]]
name = "icu_properties_data" name = "icu_properties_data"
version = "2.0.1" version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899"
[[package]] [[package]]
name = "icu_provider" name = "icu_provider"
version = "2.0.0" version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"icu_locale_core", "icu_locale_core",
"stable_deref_trait",
"tinystr",
"writeable", "writeable",
"yoke", "yoke",
"zerofrom", "zerofrom",
@@ -885,9 +889,12 @@ dependencies = [
[[package]] [[package]]
name = "indoc" name = "indoc"
version = "2.0.6" version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
@@ -897,9 +904,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.1" version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itertools" name = "itertools"
@@ -928,9 +935,9 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.81" version = "0.3.82"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"wasm-bindgen", "wasm-bindgen",
@@ -988,9 +995,9 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.0" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
@@ -1104,6 +1111,15 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "object"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
@@ -1112,15 +1128,15 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]] [[package]]
name = "once_cell_polyfill" name = "once_cell_polyfill"
version = "1.70.1" version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.74" version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"cfg-if", "cfg-if",
@@ -1150,9 +1166,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.110" version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
@@ -1262,36 +1278,37 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.3" version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
dependencies = [ dependencies = [
"zerovec", "zerovec",
] ]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.101" version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "psm" name = "psm"
version = "0.1.27" version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e66fcd288453b748497d8fb18bccc83a16b0518e3906d4b8df0a8d42d93dbb1c" checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01"
dependencies = [ dependencies = [
"ar_archive_writer",
"cc", "cc",
] ]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.41" version = "1.0.42"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@@ -1611,9 +1628,9 @@ dependencies = [
[[package]] [[package]]
name = "signal-hook-mio" name = "signal-hook-mio"
version = "0.2.4" version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [ dependencies = [
"libc", "libc",
"mio 0.8.11", "mio 0.8.11",
@@ -1716,9 +1733,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.107" version = "2.0.110"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a26dbd934e5451d21ef060c018dae56fc073894c5a7896f882928a76e6d081b" checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1826,9 +1843,9 @@ dependencies = [
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.1" version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"zerovec", "zerovec",
@@ -1874,9 +1891,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.16" version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
dependencies = [ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
@@ -2001,9 +2018,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.19" version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
@@ -2055,9 +2072,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.0" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]] [[package]]
name = "version_check" name = "version_check"
@@ -2107,9 +2124,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.104" version = "0.2.105"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -2118,25 +2135,11 @@ dependencies = [
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
dependencies = [
"bumpalo",
"log",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.54" version = "0.4.55"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys", "js-sys",
@@ -2147,9 +2150,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.104" version = "0.2.105"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -2157,31 +2160,31 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.104" version = "0.2.105"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc"
dependencies = [ dependencies = [
"bumpalo",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.104" version = "0.2.105"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.81" version = "0.3.82"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -2535,17 +2538,16 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.1" version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.0" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
dependencies = [ dependencies = [
"serde",
"stable_deref_trait", "stable_deref_trait",
"yoke-derive", "yoke-derive",
"zerofrom", "zerofrom",
@@ -2553,9 +2555,9 @@ dependencies = [
[[package]] [[package]]
name = "yoke-derive" name = "yoke-derive"
version = "0.8.0" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -2616,9 +2618,9 @@ dependencies = [
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.2" version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"yoke", "yoke",
@@ -2627,9 +2629,9 @@ dependencies = [
[[package]] [[package]]
name = "zerovec" name = "zerovec"
version = "0.11.4" version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
dependencies = [ dependencies = [
"yoke", "yoke",
"zerofrom", "zerofrom",
@@ -2638,9 +2640,9 @@ dependencies = [
[[package]] [[package]]
name = "zerovec-derive" name = "zerovec-derive"
version = "0.11.1" version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

View File

@@ -88,7 +88,9 @@ cm-dashboard • ● cmbox ● srv01 ● srv02 ● steambox
- **s**: Start selected service (UserStart) - **s**: Start selected service (UserStart)
- **S**: Stop selected service (UserStop) - **S**: Stop selected service (UserStop)
- **J**: Show service logs (journalctl in tmux popup) - **J**: Show service logs (journalctl in tmux popup)
- **L**: Show custom log files (tail -f custom paths in tmux popup)
- **R**: Rebuild current host - **R**: Rebuild current host
- **B**: Run backup on current host
- **q**: Quit - **q**: Quit
### Status Indicators ### Status Indicators
@@ -173,9 +175,10 @@ subscriber_ports = [6130]
[hosts] [hosts]
predefined_hosts = ["cmbox", "srv01", "srv02"] predefined_hosts = ["cmbox", "srv01", "srv02"]
[ui] [ssh]
ssh_user = "cm" rebuild_user = "cm"
rebuild_alias = "nixos-rebuild-cmtec" rebuild_alias = "nixos-rebuild-cmtec"
backup_alias = "cm-backup-run"
``` ```
## Technical Implementation ## Technical Implementation
@@ -329,7 +332,7 @@ This triggers automated:
- Tarball upload to Gitea - Tarball upload to Gitea
### NixOS Integration ### NixOS Integration
Update `~/projects/nixosbox/hosts/common/cm-dashboard.nix`: Update `~/projects/nixosbox/hosts/services/cm-dashboard.nix`:
```nix ```nix
version = "v0.1.43"; version = "v0.1.43";

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cm-dashboard-agent" name = "cm-dashboard-agent"
version = "0.1.67" version = "0.1.97"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@@ -4,7 +4,7 @@ use std::time::Duration;
use tokio::time::interval; use tokio::time::interval;
use tracing::{debug, error, info}; use tracing::{debug, error, info};
use crate::communication::{AgentCommand, ServiceAction, ZmqHandler}; use crate::communication::{AgentCommand, ZmqHandler};
use crate::config::AgentConfig; use crate::config::AgentConfig;
use crate::metrics::MetricCollectionManager; use crate::metrics::MetricCollectionManager;
use crate::notifications::NotificationManager; use crate::notifications::NotificationManager;
@@ -315,75 +315,10 @@ impl Agent {
info!("Processing Ping command - agent is alive"); info!("Processing Ping command - agent is alive");
// Could send a response back via ZMQ if needed // Could send a response back via ZMQ if needed
} }
AgentCommand::ServiceControl { service_name, action } => {
info!("Processing ServiceControl command: {} {:?}", service_name, action);
if let Err(e) = self.handle_service_control(&service_name, &action).await {
error!("Failed to execute service control: {}", e);
}
}
} }
Ok(()) Ok(())
} }
/// Handle systemd service control commands
async fn handle_service_control(&mut self, service_name: &str, action: &ServiceAction) -> Result<()> {
let (action_str, is_user_action) = match action {
ServiceAction::Start => ("start", false),
ServiceAction::Stop => ("stop", false),
ServiceAction::Status => ("status", false),
ServiceAction::UserStart => ("start", true),
ServiceAction::UserStop => ("stop", true),
};
info!("Executing systemctl {} {} (user action: {})", action_str, service_name, is_user_action);
// Handle user-stopped service tracking before systemctl execution (stop only)
match action {
ServiceAction::UserStop => {
info!("Marking service '{}' as user-stopped", service_name);
if let Err(e) = self.service_tracker.mark_user_stopped(service_name) {
error!("Failed to mark service as user-stopped: {}", e);
} else {
// Sync to global tracker
UserStoppedServiceTracker::update_global(&self.service_tracker);
}
}
_ => {}
}
let output = tokio::process::Command::new("sudo")
.arg("systemctl")
.arg(action_str)
.arg(format!("{}.service", service_name))
.output()
.await?;
if output.status.success() {
info!("Service {} {} completed successfully", service_name, action_str);
if !output.stdout.is_empty() {
debug!("stdout: {}", String::from_utf8_lossy(&output.stdout));
}
// Note: User-stopped flag will be cleared by systemd collector
// when service actually reaches 'active' state, not here
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
error!("Service {} {} failed: {}", service_name, action_str, stderr);
return Err(anyhow::anyhow!("systemctl {} {} failed: {}", action_str, service_name, stderr));
}
// Force refresh metrics after service control to update service status
if matches!(action, ServiceAction::Start | ServiceAction::Stop | ServiceAction::UserStart | ServiceAction::UserStop) {
info!("Triggering immediate metric refresh after service control");
if let Err(e) = self.collect_metrics_only().await {
error!("Failed to refresh metrics after service control: {}", e);
} else {
info!("Service status refreshed immediately after {} {}", action_str, service_name);
}
}
Ok(())
}
/// Check metrics for user-stopped services that are now active and clear their flags /// Check metrics for user-stopped services that are now active and clear their flags
fn clear_user_stopped_flags_for_active_services(&mut self, metrics: &[Metric]) { fn clear_user_stopped_flags_for_active_services(&mut self, metrics: &[Metric]) {

View File

@@ -25,6 +25,25 @@ impl BackupCollector {
} }
async fn read_backup_status(&self) -> Result<Option<BackupStatusToml>, CollectorError> { async fn read_backup_status(&self) -> Result<Option<BackupStatusToml>, CollectorError> {
// Check if we're in maintenance mode
if std::fs::metadata("/tmp/cm-maintenance").is_ok() {
// Return special maintenance mode status
let maintenance_status = BackupStatusToml {
backup_name: "maintenance".to_string(),
start_time: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
current_time: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
duration_seconds: 0,
status: "pending".to_string(),
last_updated: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
disk_space: None,
disk_product_name: None,
disk_serial_number: None,
disk_wear_percent: None,
services: HashMap::new(),
};
return Ok(Some(maintenance_status));
}
// Check if backup status file exists // Check if backup status file exists
if !std::path::Path::new(&self.backup_status_file).exists() { if !std::path::Path::new(&self.backup_status_file).exists() {
return Ok(None); // File doesn't exist, but this is not an error return Ok(None); // File doesn't exist, but this is not an error
@@ -79,7 +98,9 @@ impl BackupCollector {
} }
} }
"failed" => Status::Critical, "failed" => Status::Critical,
"warning" => Status::Warning, // Backup completed with warnings
"running" => Status::Ok, // Currently running is OK "running" => Status::Ok, // Currently running is OK
"pending" => Status::Pending, // Maintenance mode or backup starting
_ => Status::Unknown, _ => Status::Unknown,
} }
} }
@@ -136,6 +157,7 @@ impl Collector for BackupCollector {
name: "backup_overall_status".to_string(), name: "backup_overall_status".to_string(),
value: MetricValue::String(match overall_status { value: MetricValue::String(match overall_status {
Status::Ok => "ok".to_string(), Status::Ok => "ok".to_string(),
Status::Inactive => "inactive".to_string(),
Status::Pending => "pending".to_string(), Status::Pending => "pending".to_string(),
Status::Warning => "warning".to_string(), Status::Warning => "warning".to_string(),
Status::Critical => "critical".to_string(), Status::Critical => "critical".to_string(),
@@ -199,6 +221,7 @@ impl Collector for BackupCollector {
name: format!("backup_service_{}_status", service_name), name: format!("backup_service_{}_status", service_name),
value: MetricValue::String(match service_status { value: MetricValue::String(match service_status {
Status::Ok => "ok".to_string(), Status::Ok => "ok".to_string(),
Status::Inactive => "inactive".to_string(),
Status::Pending => "pending".to_string(), Status::Pending => "pending".to_string(),
Status::Warning => "warning".to_string(), Status::Warning => "warning".to_string(),
Status::Critical => "critical".to_string(), Status::Critical => "critical".to_string(),
@@ -377,6 +400,25 @@ impl Collector for BackupCollector {
}); });
} }
if let Some(wear_percent) = backup_status.disk_wear_percent {
let wear_status = if wear_percent >= 90.0 {
Status::Critical
} else if wear_percent >= 75.0 {
Status::Warning
} else {
Status::Ok
};
metrics.push(Metric {
name: "backup_disk_wear_percent".to_string(),
value: MetricValue::Float(wear_percent),
status: wear_status,
timestamp,
description: Some("Backup disk wear percentage from SMART data".to_string()),
unit: Some("percent".to_string()),
});
}
// Count services by status // Count services by status
let mut status_counts = HashMap::new(); let mut status_counts = HashMap::new();
for service in backup_status.services.values() { for service in backup_status.services.values() {
@@ -410,6 +452,7 @@ pub struct BackupStatusToml {
pub disk_space: Option<DiskSpace>, pub disk_space: Option<DiskSpace>,
pub disk_product_name: Option<String>, pub disk_product_name: Option<String>,
pub disk_serial_number: Option<String>, pub disk_serial_number: Option<String>,
pub disk_wear_percent: Option<f32>,
pub services: HashMap<String, ServiceStatus>, pub services: HashMap<String, ServiceStatus>,
} }

View File

@@ -8,7 +8,6 @@ use tracing::debug;
use super::{Collector, CollectorError}; use super::{Collector, CollectorError};
use crate::config::SystemdConfig; use crate::config::SystemdConfig;
use crate::service_tracker::UserStoppedServiceTracker;
/// Systemd collector for monitoring systemd services /// Systemd collector for monitoring systemd services
pub struct SystemdCollector { pub struct SystemdCollector {
@@ -357,33 +356,15 @@ impl SystemdCollector {
/// Calculate service status, taking user-stopped services into account /// Calculate service status, taking user-stopped services into account
fn calculate_service_status(&self, service_name: &str, active_status: &str) -> Status { fn calculate_service_status(&self, service_name: &str, active_status: &str) -> Status {
match active_status.to_lowercase().as_str() { match active_status.to_lowercase().as_str() {
"active" => { "active" => Status::Ok,
// If service is now active and was marked as user-stopped, clear the flag
if UserStoppedServiceTracker::is_service_user_stopped(service_name) {
debug!("Service '{}' is now active - clearing user-stopped flag", service_name);
// Note: We can't directly clear here because this is a read-only context
// The agent will need to handle this differently
}
Status::Ok
},
"inactive" | "dead" => { "inactive" | "dead" => {
// Check if this service was stopped by user action debug!("Service '{}' is inactive - treating as Inactive status", service_name);
if UserStoppedServiceTracker::is_service_user_stopped(service_name) { Status::Inactive
debug!("Service '{}' is inactive but marked as user-stopped - treating as OK", service_name);
Status::Ok
} else {
Status::Warning
}
}, },
"failed" | "error" => Status::Critical, "failed" | "error" => Status::Critical,
"activating" | "deactivating" | "reloading" | "start" | "stop" | "restart" => { "activating" | "deactivating" | "reloading" | "start" | "stop" | "restart" => {
// For user-stopped services that are transitioning, keep them as OK during transition debug!("Service '{}' is transitioning - treating as Pending", service_name);
if UserStoppedServiceTracker::is_service_user_stopped(service_name) { Status::Pending
debug!("Service '{}' is transitioning but was user-stopped - treating as OK", service_name);
Status::Ok
} else {
Status::Pending
}
}, },
_ => Status::Unknown, _ => Status::Unknown,
} }

View File

@@ -98,19 +98,4 @@ pub enum AgentCommand {
ToggleCollector { name: String, enabled: bool }, ToggleCollector { name: String, enabled: bool },
/// Request status/health check /// Request status/health check
Ping, Ping,
/// Control systemd service
ServiceControl {
service_name: String,
action: ServiceAction,
},
}
/// Service control actions
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub enum ServiceAction {
Start,
Stop,
Status,
UserStart, // User-initiated start (clears user-stopped flag)
UserStop, // User-initiated stop (marks as user-stopped)
} }

View File

@@ -90,14 +90,6 @@ impl UserStoppedServiceTracker {
tracker tracker
} }
/// Mark a service as user-stopped
pub fn mark_user_stopped(&mut self, service_name: &str) -> Result<()> {
info!("Marking service '{}' as user-stopped", service_name);
self.user_stopped_services.insert(service_name.to_string());
self.save_to_storage()?;
debug!("Service '{}' marked as user-stopped and saved to storage", service_name);
Ok(())
}
/// Clear user-stopped flag for a service (when user starts it) /// Clear user-stopped flag for a service (when user starts it)
pub fn clear_user_stopped(&mut self, service_name: &str) -> Result<()> { pub fn clear_user_stopped(&mut self, service_name: &str) -> Result<()> {

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cm-dashboard" name = "cm-dashboard"
version = "0.1.67" version = "0.1.97"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@@ -9,14 +9,13 @@ use std::io;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use crate::communication::{AgentCommand, ServiceAction, ZmqCommandSender, ZmqConsumer}; use crate::communication::{ZmqConsumer};
use crate::config::DashboardConfig; use crate::config::DashboardConfig;
use crate::metrics::MetricStore; use crate::metrics::MetricStore;
use crate::ui::{TuiApp, UiCommand}; use crate::ui::TuiApp;
pub struct Dashboard { pub struct Dashboard {
zmq_consumer: ZmqConsumer, zmq_consumer: ZmqConsumer,
zmq_command_sender: ZmqCommandSender,
metric_store: MetricStore, metric_store: MetricStore,
tui_app: Option<TuiApp>, tui_app: Option<TuiApp>,
terminal: Option<Terminal<CrosstermBackend<io::Stdout>>>, terminal: Option<Terminal<CrosstermBackend<io::Stdout>>>,
@@ -58,14 +57,6 @@ impl Dashboard {
} }
}; };
// Initialize ZMQ command sender
let zmq_command_sender = match ZmqCommandSender::new(&config.zmq) {
Ok(sender) => sender,
Err(e) => {
error!("Failed to initialize ZMQ command sender: {}", e);
return Err(e);
}
};
// Try to connect to hosts but don't fail if none are available // Try to connect to hosts but don't fail if none are available
match zmq_consumer.connect_to_predefined_hosts(&config.hosts).await { match zmq_consumer.connect_to_predefined_hosts(&config.hosts).await {
@@ -124,7 +115,6 @@ impl Dashboard {
Ok(Self { Ok(Self {
zmq_consumer, zmq_consumer,
zmq_command_sender,
metric_store, metric_store,
tui_app, tui_app,
terminal, terminal,
@@ -134,12 +124,6 @@ impl Dashboard {
}) })
} }
/// Send a command to a specific agent
pub async fn send_command(&mut self, hostname: &str, command: AgentCommand) -> Result<()> {
self.zmq_command_sender
.send_command(hostname, command)
.await
}
pub async fn run(&mut self) -> Result<()> { pub async fn run(&mut self) -> Result<()> {
info!("Starting dashboard main loop"); info!("Starting dashboard main loop");
@@ -157,16 +141,10 @@ impl Dashboard {
match event::read() { match event::read() {
Ok(event) => { Ok(event) => {
if let Some(ref mut tui_app) = self.tui_app { if let Some(ref mut tui_app) = self.tui_app {
// Handle input and check for commands // Handle input
match tui_app.handle_input(event) { match tui_app.handle_input(event) {
Ok(Some(command)) => { Ok(_) => {
// Execute the command // Check if we should quit
if let Err(e) = self.execute_ui_command(command).await {
error!("Failed to execute UI command: {}", e);
}
}
Ok(None) => {
// No command, check if we should quit
if tui_app.should_quit() { if tui_app.should_quit() {
info!("Quit requested, exiting dashboard"); info!("Quit requested, exiting dashboard");
break; break;
@@ -212,34 +190,18 @@ impl Dashboard {
metric_message.metrics.len() metric_message.metrics.len()
); );
// Check if this is the first time we've seen this host // Track first contact with host (no command needed - agent sends data every 2s)
let is_new_host = !self let is_new_host = !self
.initial_commands_sent .initial_commands_sent
.contains(&metric_message.hostname); .contains(&metric_message.hostname);
if is_new_host { if is_new_host {
info!( info!(
"First contact with host {}, sending initial CollectNow command", "First contact with host {} - data will update automatically",
metric_message.hostname metric_message.hostname
); );
self.initial_commands_sent
// Send CollectNow command for immediate refresh .insert(metric_message.hostname.clone());
if let Err(e) = self
.send_command(&metric_message.hostname, AgentCommand::CollectNow)
.await
{
error!(
"Failed to send initial CollectNow command to {}: {}",
metric_message.hostname, e
);
} else {
info!(
"✓ Sent initial CollectNow command to {}",
metric_message.hostname
);
self.initial_commands_sent
.insert(metric_message.hostname.clone());
}
} }
// Update metric store // Update metric store
@@ -309,33 +271,6 @@ impl Dashboard {
Ok(()) Ok(())
} }
/// Execute a UI command by sending it to the appropriate agent
async fn execute_ui_command(&self, command: UiCommand) -> Result<()> {
match command {
UiCommand::ServiceStart { hostname, service_name } => {
info!("Sending user start command for service {} on {}", service_name, hostname);
let agent_command = AgentCommand::ServiceControl {
service_name: service_name.clone(),
action: ServiceAction::UserStart,
};
self.zmq_command_sender.send_command(&hostname, agent_command).await?;
}
UiCommand::ServiceStop { hostname, service_name } => {
info!("Sending user stop command for service {} on {}", service_name, hostname);
let agent_command = AgentCommand::ServiceControl {
service_name: service_name.clone(),
action: ServiceAction::UserStop,
};
self.zmq_command_sender.send_command(&hostname, agent_command).await?;
}
UiCommand::TriggerBackup { hostname } => {
info!("Trigger backup requested for {}", hostname);
// TODO: Implement backup trigger command
info!("Backup trigger not yet implemented");
}
}
Ok(())
}
} }

View File

@@ -5,40 +5,6 @@ use zmq::{Context, Socket, SocketType};
use crate::config::ZmqConfig; use crate::config::ZmqConfig;
/// Commands that can be sent to agents
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub enum AgentCommand {
/// Request immediate metric collection
CollectNow,
/// Change collection interval
SetInterval { seconds: u64 },
/// Enable/disable a collector
ToggleCollector { name: String, enabled: bool },
/// Request status/health check
Ping,
/// Control systemd service
ServiceControl {
service_name: String,
action: ServiceAction,
},
/// Rebuild NixOS system
SystemRebuild {
git_url: String,
git_branch: String,
working_dir: String,
api_key_file: Option<String>,
},
}
/// Service control actions
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub enum ServiceAction {
Start,
Stop,
Status,
UserStart, // User-initiated start (clears user-stopped flag)
UserStop, // User-initiated stop (marks as user-stopped)
}
/// ZMQ consumer for receiving metrics from agents /// ZMQ consumer for receiving metrics from agents
pub struct ZmqConsumer { pub struct ZmqConsumer {
@@ -84,6 +50,7 @@ impl ZmqConsumer {
} }
} }
/// Connect to predefined hosts using their configuration /// Connect to predefined hosts using their configuration
pub async fn connect_to_predefined_hosts(&mut self, hosts: &std::collections::HashMap<String, crate::config::HostDetails>) -> Result<()> { pub async fn connect_to_predefined_hosts(&mut self, hosts: &std::collections::HashMap<String, crate::config::HostDetails>) -> Result<()> {
let default_port = self.config.subscriber_ports[0]; let default_port = self.config.subscriber_ports[0];
@@ -104,27 +71,13 @@ impl ZmqConsumer {
Ok(()) Ok(())
} }
/// Connect to a host using its configuration details with fallback support /// Connect to a host using its configuration details
pub async fn connect_to_host_with_details(&mut self, hostname: &str, host_details: &crate::config::HostDetails, port: u16) -> Result<()> { pub async fn connect_to_host_with_details(&mut self, hostname: &str, host_details: &crate::config::HostDetails, port: u16) -> Result<()> {
// Get primary connection IP // Get primary connection IP only - no fallbacks
let primary_ip = host_details.get_connection_ip(hostname); let primary_ip = host_details.get_connection_ip(hostname);
// Try primary connection // Connect directly without fallback attempts
if let Ok(()) = self.connect_to_host(&primary_ip, port).await { self.connect_to_host(&primary_ip, port).await
info!("Connected to {} via primary address: {}", hostname, primary_ip);
return Ok(());
}
// Try fallback IPs if primary fails
let fallbacks = host_details.get_fallback_ips(hostname);
for fallback_ip in fallbacks {
if let Ok(()) = self.connect_to_host(&fallback_ip, port).await {
info!("Connected to {} via fallback address: {}", hostname, fallback_ip);
return Ok(());
}
}
Err(anyhow::anyhow!("Failed to connect to {} using all available addresses", hostname))
} }
/// Receive command output from any connected agent (non-blocking) /// Receive command output from any connected agent (non-blocking)
@@ -215,42 +168,3 @@ impl ZmqConsumer {
} }
} }
/// ZMQ command sender for sending commands to agents
pub struct ZmqCommandSender {
context: Context,
}
impl ZmqCommandSender {
pub fn new(_config: &ZmqConfig) -> Result<Self> {
let context = Context::new();
info!("ZMQ command sender initialized");
Ok(Self { context })
}
/// Send a command to a specific agent
pub async fn send_command(&self, hostname: &str, command: AgentCommand) -> Result<()> {
// Create a new PUSH socket for this command (ZMQ best practice)
let socket = self.context.socket(SocketType::PUSH)?;
// Set socket options
socket.set_linger(1000)?; // Wait up to 1 second on close
socket.set_sndtimeo(5000)?; // 5 second send timeout
// Connect to agent's command port (6131)
let address = format!("tcp://{}:6131", hostname);
socket.connect(&address)?;
// Serialize command
let serialized = serde_json::to_vec(&command)?;
// Send command
socket.send(&serialized, 0)?;
info!("Sent command {:?} to agent at {}", command, hostname);
// Socket will be automatically closed when dropped
Ok(())
}
}

View File

@@ -31,75 +31,15 @@ pub struct HostDetails {
pub mac_address: Option<String>, pub mac_address: Option<String>,
/// Primary IP address (local network) /// Primary IP address (local network)
pub ip: Option<String>, pub ip: Option<String>,
/// Tailscale network IP address
pub tailscale_ip: Option<String>,
/// Preferred connection type: "local", "tailscale", or "auto" (fallback)
#[serde(default = "default_connection_type")]
pub connection_type: String,
} }
fn default_connection_type() -> String {
"auto".to_string()
}
impl HostDetails { impl HostDetails {
/// Get the preferred IP address for connection based on connection_type /// Get the IP address for connection (uses ip field or hostname as fallback)
pub fn get_connection_ip(&self, hostname: &str) -> String { pub fn get_connection_ip(&self, hostname: &str) -> String {
match self.connection_type.as_str() { self.ip.as_ref().unwrap_or(&hostname.to_string()).clone()
"tailscale" => {
if let Some(ref ts_ip) = self.tailscale_ip {
ts_ip.clone()
} else {
// Fallback to local IP or hostname
self.ip.as_ref().unwrap_or(&hostname.to_string()).clone()
}
}
"local" => {
if let Some(ref local_ip) = self.ip {
local_ip.clone()
} else {
hostname.to_string()
}
}
"auto" | _ => {
// Try tailscale first, then local, then hostname
if let Some(ref ts_ip) = self.tailscale_ip {
ts_ip.clone()
} else if let Some(ref local_ip) = self.ip {
local_ip.clone()
} else {
hostname.to_string()
}
}
}
} }
/// Get fallback IP addresses for connection retry
pub fn get_fallback_ips(&self, hostname: &str) -> Vec<String> {
let mut fallbacks = Vec::new();
// Add all available IPs except the primary one
let primary = self.get_connection_ip(hostname);
if let Some(ref ts_ip) = self.tailscale_ip {
if ts_ip != &primary {
fallbacks.push(ts_ip.clone());
}
}
if let Some(ref local_ip) = self.ip {
if local_ip != &primary {
fallbacks.push(local_ip.clone());
}
}
// Always include hostname as final fallback if not already primary
if hostname != primary {
fallbacks.push(hostname.to_string());
}
fallbacks
}
} }
/// System configuration /// System configuration
@@ -111,11 +51,12 @@ pub struct SystemConfig {
pub nixos_config_api_key_file: Option<String>, pub nixos_config_api_key_file: Option<String>,
} }
/// SSH configuration for rebuild operations /// SSH configuration for rebuild and backup operations
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshConfig { pub struct SshConfig {
pub rebuild_user: String, pub rebuild_user: String,
pub rebuild_alias: String, pub rebuild_cmd: String,
pub service_manage_cmd: String,
} }
/// Service log file configuration per host /// Service log file configuration per host

View File

@@ -16,26 +16,12 @@ pub mod widgets;
use crate::config::DashboardConfig; use crate::config::DashboardConfig;
use crate::metrics::MetricStore; use crate::metrics::MetricStore;
use cm_dashboard_shared::{Metric, Status}; use cm_dashboard_shared::Status;
use theme::{Components, Layout as ThemeLayout, Theme, Typography}; use theme::{Components, Layout as ThemeLayout, Theme, Typography};
use widgets::{BackupWidget, ServicesWidget, SystemWidget, Widget}; use widgets::{BackupWidget, ServicesWidget, SystemWidget, Widget};
/// Commands that can be triggered from the UI
#[derive(Debug, Clone)]
pub enum UiCommand {
ServiceStart { hostname: String, service_name: String },
ServiceStop { hostname: String, service_name: String },
TriggerBackup { hostname: String },
}
/// Types of commands for status tracking
#[derive(Debug, Clone)]
pub enum CommandType {
ServiceStart,
ServiceStop,
BackupTrigger,
}
/// Panel types for focus management /// Panel types for focus management
@@ -48,14 +34,8 @@ pub struct HostWidgets {
pub services_widget: ServicesWidget, pub services_widget: ServicesWidget,
/// Backup widget state /// Backup widget state
pub backup_widget: BackupWidget, pub backup_widget: BackupWidget,
/// Scroll offsets for each panel
pub system_scroll_offset: usize,
pub services_scroll_offset: usize,
pub backup_scroll_offset: usize,
/// Last update time for this host /// Last update time for this host
pub last_update: Option<Instant>, pub last_update: Option<Instant>,
/// Pending service transitions for immediate visual feedback
pub pending_service_transitions: HashMap<String, (CommandType, String, Instant)>, // service_name -> (command_type, original_status, start_time)
} }
impl HostWidgets { impl HostWidgets {
@@ -64,11 +44,7 @@ impl HostWidgets {
system_widget: SystemWidget::new(), system_widget: SystemWidget::new(),
services_widget: ServicesWidget::new(), services_widget: ServicesWidget::new(),
backup_widget: BackupWidget::new(), backup_widget: BackupWidget::new(),
system_scroll_offset: 0,
services_scroll_offset: 0,
backup_scroll_offset: 0,
last_update: None, last_update: None,
pending_service_transitions: HashMap::new(),
} }
} }
} }
@@ -161,8 +137,6 @@ impl TuiApp {
} }
} }
// Clear completed transitions first
self.clear_completed_transitions(&hostname, &service_metrics);
// Now get host widgets and update them // Now get host widgets and update them
let host_widgets = self.get_or_create_host_widgets(&hostname); let host_widgets = self.get_or_create_host_widgets(&hostname);
@@ -198,14 +172,6 @@ impl TuiApp {
} }
} }
// Keep hosts that have pending transitions even if they're offline
for (hostname, host_widgets) in &self.host_widgets {
if !host_widgets.pending_service_transitions.is_empty() {
if !all_hosts.contains(hostname) {
all_hosts.push(hostname.clone());
}
}
}
all_hosts.sort(); all_hosts.sort();
self.available_hosts = all_hosts; self.available_hosts = all_hosts;
@@ -236,7 +202,7 @@ impl TuiApp {
} }
/// Handle keyboard input /// Handle keyboard input
pub fn handle_input(&mut self, event: Event) -> Result<Option<UiCommand>> { pub fn handle_input(&mut self, event: Event) -> Result<()> {
if let Event::Key(key) = event { if let Event::Key(key) = event {
match key.code { match key.code {
KeyCode::Char('q') => { KeyCode::Char('q') => {
@@ -254,12 +220,12 @@ impl TuiApp {
let connection_ip = self.get_connection_ip(&hostname); let connection_ip = self.get_connection_ip(&hostname);
// Create command that shows logo, rebuilds, and waits for user input // Create command that shows logo, rebuilds, and waits for user input
let logo_and_rebuild = format!( let logo_and_rebuild = format!(
"bash -c 'cat << \"EOF\"\nNixOS System Rebuild\nTarget: {} ({})\n\nEOF\nssh -tt {}@{} \"bash -ic {}\"\necho\necho \"========================================\"\necho \"Rebuild completed. Press any key to close...\"\necho \"========================================\"\nread -n 1 -s\nexit'", "echo 'Rebuilding system: {} ({})' && ssh -tt {}@{} \"bash -ic '{}'\"",
hostname, hostname,
connection_ip, connection_ip,
self.config.ssh.rebuild_user, self.config.ssh.rebuild_user,
connection_ip, connection_ip,
self.config.ssh.rebuild_alias self.config.ssh.rebuild_cmd
); );
std::process::Command::new("tmux") std::process::Command::new("tmux")
@@ -272,30 +238,41 @@ impl TuiApp {
.ok(); // Ignore errors, tmux will handle them .ok(); // Ignore errors, tmux will handle them
} }
} }
KeyCode::Char('s') => { KeyCode::Char('B') => {
// Service start command // Backup command - works on any panel for current host
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) { if let Some(hostname) = self.current_host.clone() {
if self.start_command(&hostname, CommandType::ServiceStart, service_name.clone()) {
return Ok(Some(UiCommand::ServiceStart { hostname, service_name }));
}
}
}
KeyCode::Char('S') => {
// Service stop command
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
if self.start_command(&hostname, CommandType::ServiceStop, service_name.clone()) {
return Ok(Some(UiCommand::ServiceStop { hostname, service_name }));
}
}
}
KeyCode::Char('J') => {
// Show service logs via journalctl in tmux split window
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
let connection_ip = self.get_connection_ip(&hostname); let connection_ip = self.get_connection_ip(&hostname);
let journalctl_command = format!( // Create command that shows logo, runs backup, and waits for user input
"bash -c \"ssh -tt {}@{} 'sudo journalctl -u {}.service -f --no-pager -n 50'; exit\"", let logo_and_backup = format!(
"echo 'Running backup: {} ({})' && ssh -tt {}@{} \"bash -ic '{}'\"",
hostname,
connection_ip,
self.config.ssh.rebuild_user, self.config.ssh.rebuild_user,
connection_ip, connection_ip,
format!("{} start borgbackup", self.config.ssh.service_manage_cmd)
);
std::process::Command::new("tmux")
.arg("split-window")
.arg("-v")
.arg("-p")
.arg("30")
.arg(&logo_and_backup)
.spawn()
.ok(); // Ignore errors, tmux will handle them
}
}
KeyCode::Char('s') => {
// Service start command via SSH with progress display
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
let connection_ip = self.get_connection_ip(&hostname);
let service_start_command = format!(
"echo 'Starting service: {} on {}' && ssh -tt {}@{} \"bash -ic '{} start {}'\"",
service_name,
hostname,
self.config.ssh.rebuild_user,
connection_ip,
self.config.ssh.service_manage_cmd,
service_name service_name
); );
@@ -304,42 +281,55 @@ impl TuiApp {
.arg("-v") .arg("-v")
.arg("-p") .arg("-p")
.arg("30") .arg("30")
.arg(&journalctl_command) .arg(&service_start_command)
.spawn()
.ok(); // Ignore errors, tmux will handle them
}
}
KeyCode::Char('S') => {
// Service stop command via SSH with progress display
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
let connection_ip = self.get_connection_ip(&hostname);
let service_stop_command = format!(
"echo 'Stopping service: {} on {}' && ssh -tt {}@{} \"bash -ic '{} stop {}'\"",
service_name,
hostname,
self.config.ssh.rebuild_user,
connection_ip,
self.config.ssh.service_manage_cmd,
service_name
);
std::process::Command::new("tmux")
.arg("split-window")
.arg("-v")
.arg("-p")
.arg("30")
.arg(&service_stop_command)
.spawn() .spawn()
.ok(); // Ignore errors, tmux will handle them .ok(); // Ignore errors, tmux will handle them
} }
} }
KeyCode::Char('L') => { KeyCode::Char('L') => {
// Show custom service log file in tmux split window // Show service logs via service-manage script in tmux split window
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) { if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
// Check if this service has a custom log file configured let connection_ip = self.get_connection_ip(&hostname);
if let Some(host_logs) = self.config.service_logs.get(&hostname) { let logs_command = format!(
if let Some(log_config) = host_logs.iter().find(|config| config.service_name == service_name) { "ssh -tt {}@{} '{} logs {}'",
let connection_ip = self.get_connection_ip(&hostname); self.config.ssh.rebuild_user,
let tail_command = format!( connection_ip,
"bash -c \"ssh -tt {}@{} 'sudo tail -n 50 -f {}'; exit\"", self.config.ssh.service_manage_cmd,
self.config.ssh.rebuild_user, service_name
connection_ip, );
log_config.log_file_path
); std::process::Command::new("tmux")
.arg("split-window")
std::process::Command::new("tmux") .arg("-v")
.arg("split-window") .arg("-p")
.arg("-v") .arg("30")
.arg("-p") .arg(&logs_command)
.arg("30") .spawn()
.arg(&tail_command) .ok(); // Ignore errors, tmux will handle them
.spawn()
.ok(); // Ignore errors, tmux will handle them
}
}
}
}
KeyCode::Char('b') => {
// Trigger backup
if let Some(hostname) = self.current_host.clone() {
self.start_command(&hostname, CommandType::BackupTrigger, hostname.clone());
return Ok(Some(UiCommand::TriggerBackup { hostname }));
} }
} }
KeyCode::Char('w') => { KeyCode::Char('w') => {
@@ -374,7 +364,8 @@ impl TuiApp {
if let Some(hostname) = self.current_host.clone() { if let Some(hostname) = self.current_host.clone() {
let connection_ip = self.get_connection_ip(&hostname); let connection_ip = self.get_connection_ip(&hostname);
let ssh_command = format!( let ssh_command = format!(
"ssh -tt {}@{}", "echo 'Opening SSH terminal to: {}' && ssh -tt {}@{}",
hostname,
self.config.ssh.rebuild_user, self.config.ssh.rebuild_user,
connection_ip connection_ip
); );
@@ -414,7 +405,7 @@ impl TuiApp {
_ => {} _ => {}
} }
} }
Ok(None) Ok(())
} }
/// Navigate between hosts /// Navigate between hosts
@@ -468,86 +459,8 @@ impl TuiApp {
self.should_quit self.should_quit
} }
/// Get current service status for state-aware command validation
fn get_current_service_status(&self, hostname: &str, service_name: &str) -> Option<String> {
if let Some(host_widgets) = self.host_widgets.get(hostname) {
return host_widgets.services_widget.get_service_status(service_name);
}
None
}
/// Start command execution with immediate visual feedback
pub fn start_command(&mut self, hostname: &str, command_type: CommandType, target: String) -> bool {
// Get current service status to validate command
let current_status = self.get_current_service_status(hostname, &target);
// Validate if command makes sense for current state
let should_execute = match (&command_type, current_status.as_deref()) {
(CommandType::ServiceStart, Some("inactive") | Some("failed") | Some("dead")) => true,
(CommandType::ServiceStop, Some("active")) => true,
(CommandType::ServiceStart, Some("active")) => {
// Already running - don't execute
false
},
(CommandType::ServiceStop, Some("inactive") | Some("failed") | Some("dead")) => {
// Already stopped - don't execute
false
},
(_, None) => {
// Unknown service state - allow command to proceed
true
},
_ => true, // Default: allow other combinations
};
// ALWAYS store the pending transition for immediate visual feedback, even if we don't execute
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) {
host_widgets.pending_service_transitions.insert(
target.clone(),
(command_type, current_status.unwrap_or_else(|| "unknown".to_string()), Instant::now())
);
}
should_execute
}
/// Clear pending transitions when real status updates arrive or timeout
fn clear_completed_transitions(&mut self, hostname: &str, service_metrics: &[&Metric]) {
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) {
let mut completed_services = Vec::new();
// Check each pending transition to see if real status has changed
for (service_name, (command_type, original_status, _start_time)) in &host_widgets.pending_service_transitions {
// Look for status metric for this service
for metric in service_metrics {
if metric.name == format!("service_{}_status", service_name) {
let new_status = metric.value.as_string();
// Check if status has changed from original (command completed)
if &new_status != original_status {
// Verify it changed in the expected direction
let expected_change = match command_type {
CommandType::ServiceStart => &new_status == "active",
CommandType::ServiceStop => &new_status != "active",
_ => false,
};
if expected_change {
completed_services.push(service_name.clone());
}
}
break;
}
}
}
// Remove completed transitions
for service_name in completed_services {
host_widgets.pending_service_transitions.remove(&service_name);
}
}
}
@@ -635,14 +548,10 @@ impl TuiApp {
// Render services widget for current host // Render services widget for current host
if let Some(hostname) = self.current_host.clone() { if let Some(hostname) = self.current_host.clone() {
let is_focused = true; // Always show service selection let is_focused = true; // Always show service selection
let (scroll_offset, pending_transitions) = {
let host_widgets = self.get_or_create_host_widgets(&hostname);
(host_widgets.services_scroll_offset, host_widgets.pending_service_transitions.clone())
};
let host_widgets = self.get_or_create_host_widgets(&hostname); let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets host_widgets
.services_widget .services_widget
.render_with_transitions(frame, content_chunks[1], is_focused, scroll_offset, &pending_transitions); // Services takes full right side .render(frame, content_chunks[1], is_focused); // Services takes full right side
} }
// Render statusbar at the bottom // Render statusbar at the bottom
@@ -760,27 +669,25 @@ impl TuiApp {
// Fallback to old aggregation logic with proper Pending handling // Fallback to old aggregation logic with proper Pending handling
let mut has_critical = false; let mut has_critical = false;
let mut has_warning = false; let mut has_warning = false;
let mut has_pending = false;
let mut ok_count = 0; let mut ok_count = 0;
for metric in &metrics { for metric in &metrics {
match metric.status { match metric.status {
Status::Critical => has_critical = true, Status::Critical => has_critical = true,
Status::Warning => has_warning = true, Status::Warning => has_warning = true,
Status::Pending => has_pending = true, Status::Pending => ok_count += 1, // Treat pending as OK for aggregation
Status::Ok => ok_count += 1, Status::Ok => ok_count += 1,
Status::Unknown => {}, // Ignore unknown for aggregation Status::Inactive => ok_count += 1, // Treat inactive as OK for aggregation
Status::Unknown => ok_count += 1, // Treat unknown as OK for aggregation
Status::Offline => {}, // Ignore offline for aggregation Status::Offline => {}, // Ignore offline for aggregation
} }
} }
// Priority order: Critical > Warning > Pending > Ok > Unknown // Priority order: Critical > Warning > Ok > Unknown (no Pending)
if has_critical { if has_critical {
Status::Critical Status::Critical
} else if has_warning { } else if has_warning {
Status::Warning Status::Warning
} else if has_pending {
Status::Pending
} else if ok_count > 0 { } else if ok_count > 0 {
Status::Ok Status::Ok
} else { } else {
@@ -808,9 +715,10 @@ impl TuiApp {
shortcuts.push("Tab: Host".to_string()); shortcuts.push("Tab: Host".to_string());
shortcuts.push("↑↓/jk: Select".to_string()); shortcuts.push("↑↓/jk: Select".to_string());
shortcuts.push("r: Rebuild".to_string()); shortcuts.push("r: Rebuild".to_string());
shortcuts.push("B: Backup".to_string());
shortcuts.push("s/S: Start/Stop".to_string()); shortcuts.push("s/S: Start/Stop".to_string());
shortcuts.push("J: Logs".to_string()); shortcuts.push("L: Logs".to_string());
shortcuts.push("L: Custom".to_string()); shortcuts.push("t: Terminal".to_string());
shortcuts.push("w: Wake".to_string()); shortcuts.push("w: Wake".to_string());
// Always show quit // Always show quit
@@ -825,12 +733,10 @@ impl TuiApp {
frame.render_widget(system_block, area); frame.render_widget(system_block, area);
// Get current host widgets, create if none exist // Get current host widgets, create if none exist
if let Some(hostname) = self.current_host.clone() { if let Some(hostname) = self.current_host.clone() {
let scroll_offset = { // Clone the config to avoid borrowing issues
let host_widgets = self.get_or_create_host_widgets(&hostname); let config = self.config.clone();
host_widgets.system_scroll_offset
};
let host_widgets = self.get_or_create_host_widgets(&hostname); let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets.system_widget.render_with_scroll(frame, inner_area, scroll_offset, &hostname); host_widgets.system_widget.render(frame, inner_area, &hostname, Some(&config));
} }
} }
@@ -841,12 +747,8 @@ impl TuiApp {
// Get current host widgets for backup widget // Get current host widgets for backup widget
if let Some(hostname) = self.current_host.clone() { if let Some(hostname) = self.current_host.clone() {
let scroll_offset = {
let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets.backup_scroll_offset
};
let host_widgets = self.get_or_create_host_widgets(&hostname); let host_widgets = self.get_or_create_host_widgets(&hostname);
host_widgets.backup_widget.render_with_scroll(frame, inner_area, scroll_offset); host_widgets.backup_widget.render(frame, inner_area);
} }
} }

View File

@@ -143,6 +143,7 @@ impl Theme {
pub fn status_color(status: Status) -> Color { pub fn status_color(status: Status) -> Color {
match status { match status {
Status::Ok => Self::success(), Status::Ok => Self::success(),
Status::Inactive => Self::muted_text(), // Gray for inactive services in service list
Status::Pending => Self::highlight(), // Blue for pending Status::Pending => Self::highlight(), // Blue for pending
Status::Warning => Self::warning(), Status::Warning => Self::warning(),
Status::Critical => Self::error(), Status::Critical => Self::error(),
@@ -243,6 +244,7 @@ impl StatusIcons {
pub fn get_icon(status: Status) -> &'static str { pub fn get_icon(status: Status) -> &'static str {
match status { match status {
Status::Ok => "", Status::Ok => "",
Status::Inactive => "", // Empty circle for inactive services
Status::Pending => "", // Hollow circle for pending Status::Pending => "", // Hollow circle for pending
Status::Warning => "", Status::Warning => "",
Status::Critical => "!", Status::Critical => "!",
@@ -256,6 +258,7 @@ impl StatusIcons {
let icon = Self::get_icon(status); let icon = Self::get_icon(status);
let status_color = match status { let status_color = match status {
Status::Ok => Theme::success(), // Green Status::Ok => Theme::success(), // Green
Status::Inactive => Theme::muted_text(), // Gray for inactive services
Status::Pending => Theme::highlight(), // Blue Status::Pending => Theme::highlight(), // Blue
Status::Warning => Theme::warning(), // Yellow Status::Warning => Theme::warning(), // Yellow
Status::Critical => Theme::error(), // Red Status::Critical => Theme::error(), // Red

View File

@@ -30,6 +30,8 @@ pub struct BackupWidget {
backup_disk_product_name: Option<String>, backup_disk_product_name: Option<String>,
/// Backup disk serial number from SMART data /// Backup disk serial number from SMART data
backup_disk_serial_number: Option<String>, backup_disk_serial_number: Option<String>,
/// Backup disk wear percentage from SMART data
backup_disk_wear_percent: Option<f32>,
/// Backup disk filesystem label /// Backup disk filesystem label
backup_disk_filesystem_label: Option<String>, backup_disk_filesystem_label: Option<String>,
/// Number of completed services /// Number of completed services
@@ -65,6 +67,7 @@ impl BackupWidget {
backup_disk_used_gb: None, backup_disk_used_gb: None,
backup_disk_product_name: None, backup_disk_product_name: None,
backup_disk_serial_number: None, backup_disk_serial_number: None,
backup_disk_wear_percent: None,
backup_disk_filesystem_label: None, backup_disk_filesystem_label: None,
services_completed_count: None, services_completed_count: None,
services_failed_count: None, services_failed_count: None,
@@ -197,6 +200,9 @@ impl Widget for BackupWidget {
"backup_disk_serial_number" => { "backup_disk_serial_number" => {
self.backup_disk_serial_number = Some(metric.value.as_string()); self.backup_disk_serial_number = Some(metric.value.as_string());
} }
"backup_disk_wear_percent" => {
self.backup_disk_wear_percent = metric.value.as_f32();
}
"backup_disk_filesystem_label" => { "backup_disk_filesystem_label" => {
self.backup_disk_filesystem_label = Some(metric.value.as_string()); self.backup_disk_filesystem_label = Some(metric.value.as_string());
} }
@@ -285,8 +291,8 @@ impl Widget for BackupWidget {
} }
impl BackupWidget { impl BackupWidget {
/// Render with scroll offset support /// Render backup widget
pub fn render_with_scroll(&mut self, frame: &mut Frame, area: Rect, scroll_offset: usize) { pub fn render(&mut self, frame: &mut Frame, area: Rect) {
let mut lines = Vec::new(); let mut lines = Vec::new();
// Latest backup section // Latest backup section
@@ -328,21 +334,31 @@ impl BackupWidget {
); );
lines.push(ratatui::text::Line::from(disk_spans)); lines.push(ratatui::text::Line::from(disk_spans));
// Serial number as sub-item // Collect sub-items to determine tree structure
let mut sub_items = Vec::new();
if let Some(serial) = &self.backup_disk_serial_number { if let Some(serial) = &self.backup_disk_serial_number {
lines.push(ratatui::text::Line::from(vec![ sub_items.push(format!("S/N: {}", serial));
ratatui::text::Span::styled(" ├─ ", Typography::tree()),
ratatui::text::Span::styled(format!("S/N: {}", serial), Typography::secondary())
]));
} }
// Usage as sub-item if let Some(wear) = self.backup_disk_wear_percent {
sub_items.push(format!("Wear: {:.0}%", wear));
}
if let (Some(used), Some(total)) = (self.backup_disk_used_gb, self.backup_disk_total_gb) { if let (Some(used), Some(total)) = (self.backup_disk_used_gb, self.backup_disk_total_gb) {
let used_str = Self::format_size_with_proper_units(used); let used_str = Self::format_size_with_proper_units(used);
let total_str = Self::format_size_with_proper_units(total); let total_str = Self::format_size_with_proper_units(total);
sub_items.push(format!("Usage: {}/{}", used_str, total_str));
}
// Render sub-items with proper tree structure
let num_items = sub_items.len();
for (i, item) in sub_items.into_iter().enumerate() {
let is_last = i == num_items - 1;
let tree_char = if is_last { " └─ " } else { " ├─ " };
lines.push(ratatui::text::Line::from(vec![ lines.push(ratatui::text::Line::from(vec![
ratatui::text::Span::styled(" └─ ", Typography::tree()), ratatui::text::Span::styled(tree_char, Typography::tree()),
ratatui::text::Span::styled(format!("Usage: {}/{}", used_str, total_str), Typography::secondary()) ratatui::text::Span::styled(item, Typography::secondary())
])); ]));
} }
} }
@@ -366,42 +382,20 @@ impl BackupWidget {
let total_lines = lines.len(); let total_lines = lines.len();
let available_height = area.height as usize; let available_height = area.height as usize;
// Calculate scroll boundaries // Show only what fits, with "X more below" if needed
let max_scroll = if total_lines > available_height { if total_lines > available_height {
total_lines - available_height let lines_for_content = available_height.saturating_sub(1); // Reserve one line for "more below"
} else {
total_lines.saturating_sub(1)
};
let effective_scroll = scroll_offset.min(max_scroll);
// Apply scrolling if needed
if scroll_offset > 0 || total_lines > available_height {
let mut visible_lines: Vec<_> = lines let mut visible_lines: Vec<_> = lines
.into_iter() .into_iter()
.skip(effective_scroll) .take(lines_for_content)
.take(available_height)
.collect(); .collect();
// Add scroll indicator if there are hidden lines let hidden_below = total_lines.saturating_sub(lines_for_content);
if total_lines > available_height { if hidden_below > 0 {
let hidden_above = effective_scroll; let more_line = ratatui::text::Line::from(vec![
let hidden_below = total_lines.saturating_sub(effective_scroll + available_height); ratatui::text::Span::styled(format!("... {} more below", hidden_below), Typography::muted())
]);
if (hidden_above > 0 || hidden_below > 0) && !visible_lines.is_empty() { visible_lines.push(more_line);
let scroll_text = if hidden_above > 0 && hidden_below > 0 {
format!("... {} above, {} below", hidden_above, hidden_below)
} else if hidden_above > 0 {
format!("... {} more above", hidden_above)
} else {
format!("... {} more below", hidden_below)
};
// Replace last line with scroll indicator
visible_lines.pop();
visible_lines.push(ratatui::text::Line::from(vec![
ratatui::text::Span::styled(scroll_text, Typography::muted())
]));
}
} }
let paragraph = Paragraph::new(ratatui::text::Text::from(visible_lines)); let paragraph = Paragraph::new(ratatui::text::Text::from(visible_lines));

View File

@@ -9,7 +9,6 @@ use tracing::debug;
use super::Widget; use super::Widget;
use crate::ui::theme::{Components, StatusIcons, Theme, Typography}; use crate::ui::theme::{Components, StatusIcons, Theme, Typography};
use crate::ui::CommandType;
use ratatui::style::Style; use ratatui::style::Style;
/// Services widget displaying hierarchical systemd service statuses /// Services widget displaying hierarchical systemd service statuses
@@ -125,41 +124,14 @@ impl ServicesWidget {
) )
} }
/// Get status icon for service, considering pending transitions for visual feedback
fn get_service_icon_and_status(&self, service_name: &str, info: &ServiceInfo, pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>) -> (String, String, ratatui::prelude::Color) {
// Check if this service has a pending transition
if let Some((command_type, _original_status, _start_time)) = pending_transitions.get(service_name) {
// Show transitional icons for pending commands
let (icon, status_text) = match command_type {
CommandType::ServiceStart => ("", "starting"),
CommandType::ServiceStop => ("", "stopping"),
_ => return (StatusIcons::get_icon(info.widget_status).to_string(), info.status.clone(), Theme::status_color(info.widget_status)), // Not a service command
};
return (icon.to_string(), status_text.to_string(), Theme::highlight());
}
// Normal status display
let icon = StatusIcons::get_icon(info.widget_status);
let status_color = match info.widget_status {
Status::Ok => Theme::success(),
Status::Pending => Theme::highlight(),
Status::Warning => Theme::warning(),
Status::Critical => Theme::error(),
Status::Unknown => Theme::muted_text(),
Status::Offline => Theme::muted_text(),
};
(icon.to_string(), info.status.clone(), status_color)
}
/// Create spans for sub-service with icon next to name, considering pending transitions /// Create spans for sub-service with icon next to name
fn create_sub_service_spans_with_transitions( fn create_sub_service_spans(
&self, &self,
name: &str, name: &str,
info: &ServiceInfo, info: &ServiceInfo,
is_last: bool, is_last: bool,
pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>,
) -> Vec<ratatui::text::Span<'static>> { ) -> Vec<ratatui::text::Span<'static>> {
// Truncate long sub-service names to fit layout (accounting for indentation) // Truncate long sub-service names to fit layout (accounting for indentation)
let short_name = if name.len() > 18 { let short_name = if name.len() > 18 {
@@ -168,19 +140,28 @@ impl ServicesWidget {
name.to_string() name.to_string()
}; };
// Get status icon and text, considering pending transitions // Get status icon and text
let (icon, mut status_str, status_color) = self.get_service_icon_and_status(name, info, pending_transitions); let icon = StatusIcons::get_icon(info.widget_status);
let status_color = match info.widget_status {
Status::Ok => Theme::success(),
Status::Inactive => Theme::muted_text(),
Status::Pending => Theme::highlight(),
Status::Warning => Theme::warning(),
Status::Critical => Theme::error(),
Status::Unknown => Theme::muted_text(),
Status::Offline => Theme::muted_text(),
};
// For sub-services, prefer latency if available (unless transition is pending) // For sub-services, prefer latency if available
if !pending_transitions.contains_key(name) { let status_str = if let Some(latency) = info.latency_ms {
if let Some(latency) = info.latency_ms { if latency < 0.0 {
status_str = if latency < 0.0 { "timeout".to_string()
"timeout".to_string() } else {
} else { format!("{:.0}ms", latency)
format!("{:.0}ms", latency)
};
} }
} } else {
info.status.clone()
};
let tree_symbol = if is_last { "└─" } else { "├─" }; let tree_symbol = if is_last { "└─" } else { "├─" };
vec![ vec![
@@ -228,36 +209,13 @@ impl ServicesWidget {
} }
/// Get currently selected service name (for actions) /// Get currently selected service name (for actions)
/// Only returns parent service names since only parent services can be selected
pub fn get_selected_service(&self) -> Option<String> { pub fn get_selected_service(&self) -> Option<String> {
// Build the same display list to find the selected service // Only parent services can be selected, so just get the parent service at selected_index
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>, String)> = Vec::new();
let mut parent_services: Vec<_> = self.parent_services.iter().collect(); let mut parent_services: Vec<_> = self.parent_services.iter().collect();
parent_services.sort_by(|(a, _), (b, _)| a.cmp(b)); parent_services.sort_by(|(a, _), (b, _)| a.cmp(b));
for (parent_name, parent_info) in parent_services {
let parent_line = self.format_parent_service_line(parent_name, parent_info);
display_lines.push((parent_line, parent_info.widget_status, false, None, parent_name.clone()));
if let Some(sub_list) = self.sub_services.get(parent_name) {
let mut sorted_subs = sub_list.clone();
sorted_subs.sort_by(|(a, _), (b, _)| a.cmp(b));
for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() {
let is_last_sub = i == sorted_subs.len() - 1;
let full_sub_name = format!("{}_{}", parent_name, sub_name);
display_lines.push((
sub_name.clone(),
sub_info.widget_status,
true,
Some((sub_info.clone(), is_last_sub)),
full_sub_name,
));
}
}
}
display_lines.get(self.selected_index).map(|(_, _, _, _, raw_name)| raw_name.clone()) parent_services.get(self.selected_index).map(|(name, _)| name.to_string())
} }
/// Get total count of selectable services (parent services only, not sub-services) /// Get total count of selectable services (parent services only, not sub-services)
@@ -266,25 +224,6 @@ impl ServicesWidget {
self.parent_services.len() self.parent_services.len()
} }
/// Get current status of a specific service by name
pub fn get_service_status(&self, service_name: &str) -> Option<String> {
// Check if it's a parent service
if let Some(parent_info) = self.parent_services.get(service_name) {
return Some(parent_info.status.clone());
}
// Check sub-services (format: parent_sub)
for (parent_name, sub_list) in &self.sub_services {
for (sub_name, sub_info) in sub_list {
let full_sub_name = format!("{}_{}", parent_name, sub_name);
if full_sub_name == service_name {
return Some(sub_info.status.clone());
}
}
}
None
}
/// Calculate which parent service index corresponds to a display line index /// Calculate which parent service index corresponds to a display line index
fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize { fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize {
@@ -439,8 +378,8 @@ impl Widget for ServicesWidget {
impl ServicesWidget { impl ServicesWidget {
/// Render with focus, scroll, and pending transitions for visual feedback /// Render with focus
pub fn render_with_transitions(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>) { pub fn render(&mut self, frame: &mut Frame, area: Rect, is_focused: bool) {
let services_block = Components::widget_block("services"); let services_block = Components::widget_block("services");
let inner_area = services_block.inner(area); let inner_area = services_block.inner(area);
frame.render_widget(services_block, area); frame.render_widget(services_block, area);
@@ -465,14 +404,14 @@ impl ServicesWidget {
return; return;
} }
// Use the existing render logic but with pending transitions // Render the services list
self.render_services_with_transitions(frame, content_chunks[1], is_focused, scroll_offset, pending_transitions); self.render_services(frame, content_chunks[1], is_focused);
} }
/// Render services list with pending transitions awareness /// Render services list
fn render_services_with_transitions(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>) { fn render_services(&mut self, frame: &mut Frame, area: Rect, is_focused: bool) {
// Build hierarchical service list for display - include raw service name for pending transition lookups // Build hierarchical service list for display
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>, String)> = Vec::new(); // Added raw service name let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>)> = Vec::new();
// Sort parent services alphabetically for consistent order // Sort parent services alphabetically for consistent order
let mut parent_services: Vec<_> = self.parent_services.iter().collect(); let mut parent_services: Vec<_> = self.parent_services.iter().collect();
@@ -481,7 +420,7 @@ impl ServicesWidget {
for (parent_name, parent_info) in parent_services { for (parent_name, parent_info) in parent_services {
// Add parent service line // Add parent service line
let parent_line = self.format_parent_service_line(parent_name, parent_info); let parent_line = self.format_parent_service_line(parent_name, parent_info);
display_lines.push((parent_line, parent_info.widget_status, false, None, parent_name.clone())); // Include raw name display_lines.push((parent_line, parent_info.widget_status, false, None));
// Add sub-services for this parent (if any) // Add sub-services for this parent (if any)
if let Some(sub_list) = self.sub_services.get(parent_name) { if let Some(sub_list) = self.sub_services.get(parent_name) {
@@ -491,49 +430,48 @@ impl ServicesWidget {
for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() { for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() {
let is_last_sub = i == sorted_subs.len() - 1; let is_last_sub = i == sorted_subs.len() - 1;
let full_sub_name = format!("{}_{}", parent_name, sub_name);
// Store sub-service info for custom span rendering // Store sub-service info for custom span rendering
display_lines.push(( display_lines.push((
sub_name.clone(), sub_name.clone(),
sub_info.widget_status, sub_info.widget_status,
true, true,
Some((sub_info.clone(), is_last_sub)), Some((sub_info.clone(), is_last_sub)),
full_sub_name, // Raw service name for pending transition lookup
)); // true = sub-service, with is_last info )); // true = sub-service, with is_last info
} }
} }
} }
// Apply scroll offset and render visible lines (same as existing logic) // Show only what fits, with "X more below" if needed
let available_lines = area.height as usize; let available_lines = area.height as usize;
let total_lines = display_lines.len(); let total_lines = display_lines.len();
// Calculate scroll boundaries // Reserve one line for "X more below" if needed
let max_scroll = if total_lines > available_lines { let lines_for_content = if total_lines > available_lines {
total_lines - available_lines available_lines.saturating_sub(1)
} else { } else {
total_lines.saturating_sub(1) available_lines
}; };
let effective_scroll = scroll_offset.min(max_scroll);
// Get visible lines after scrolling
let visible_lines: Vec<_> = display_lines let visible_lines: Vec<_> = display_lines
.iter() .iter()
.skip(effective_scroll) .take(lines_for_content)
.take(available_lines)
.collect(); .collect();
let hidden_below = total_lines.saturating_sub(lines_for_content);
let lines_to_show = visible_lines.len(); let lines_to_show = visible_lines.len();
if lines_to_show > 0 { if lines_to_show > 0 {
// Add space for "X more below" message if needed
let total_chunks_needed = if hidden_below > 0 { lines_to_show + 1 } else { lines_to_show };
let service_chunks = Layout::default() let service_chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints(vec![Constraint::Length(1); lines_to_show]) .constraints(vec![Constraint::Length(1); total_chunks_needed])
.split(area); .split(area);
for (i, (line_text, line_status, is_sub, sub_info, raw_service_name)) in visible_lines.iter().enumerate() for (i, (line_text, line_status, is_sub, sub_info)) in visible_lines.iter().enumerate()
{ {
let actual_index = effective_scroll + i; // Real index in the full list let actual_index = i; // Simple index since we're not scrolling
// Only parent services can be selected - calculate parent service index // Only parent services can be selected - calculate parent service index
let is_selected = if !*is_sub { let is_selected = if !*is_sub {
@@ -545,41 +483,16 @@ impl ServicesWidget {
}; };
let mut spans = if *is_sub && sub_info.is_some() { let mut spans = if *is_sub && sub_info.is_some() {
// Use custom sub-service span creation WITH pending transitions // Use custom sub-service span creation
let (service_info, is_last) = sub_info.as_ref().unwrap(); let (service_info, is_last) = sub_info.as_ref().unwrap();
self.create_sub_service_spans_with_transitions(line_text, service_info, *is_last, pending_transitions) self.create_sub_service_spans(line_text, service_info, *is_last)
} else { } else {
// Parent services - check if this parent service has a pending transition using RAW service name // Parent services - use normal status spans
if pending_transitions.contains_key(raw_service_name) { StatusIcons::create_status_spans(*line_status, line_text)
// Create spans with transitional status
let (icon, status_text, _) = self.get_service_icon_and_status(raw_service_name, &ServiceInfo {
status: "".to_string(),
memory_mb: None,
disk_gb: None,
latency_ms: None,
widget_status: *line_status
}, pending_transitions);
// Use blue for transitional icons when not selected, background color when selected
let icon_color = if is_selected && !*is_sub && is_focused {
Theme::background() // Dark background color for visibility against blue selection
} else {
Theme::highlight() // Blue for normal case
};
vec![
ratatui::text::Span::styled(format!("{} ", icon), Style::default().fg(icon_color)),
ratatui::text::Span::styled(line_text.clone(), Style::default().fg(Theme::primary_text())),
ratatui::text::Span::styled(format!(" {}", status_text), Style::default().fg(icon_color)),
]
} else {
StatusIcons::create_status_spans(*line_status, line_text)
}
}; };
// Apply selection highlighting to parent services only, making icons background color when selected // Apply selection highlighting to parent services only
// Only show selection when Services panel is focused // Only show selection when Services panel is focused
// Show selection highlighting even when transitional icons are present
if is_selected && !*is_sub && is_focused { if is_selected && !*is_sub && is_focused {
for (i, span) in spans.iter_mut().enumerate() { for (i, span) in spans.iter_mut().enumerate() {
if i == 0 { if i == 0 {
@@ -600,33 +513,12 @@ impl ServicesWidget {
frame.render_widget(service_para, service_chunks[i]); frame.render_widget(service_para, service_chunks[i]);
} }
}
// Show scroll indicator if there are more services than we can display (same as existing)
if total_lines > available_lines {
let hidden_above = effective_scroll;
let hidden_below = total_lines.saturating_sub(effective_scroll + available_lines);
if hidden_above > 0 || hidden_below > 0 { // Show "X more below" message if content was truncated
let scroll_text = if hidden_above > 0 && hidden_below > 0 { if hidden_below > 0 {
format!("... {} above, {} below", hidden_above, hidden_below) let more_text = format!("... {} more below", hidden_below);
} else if hidden_above > 0 { let more_para = Paragraph::new(more_text).style(Typography::muted());
format!("... {} more above", hidden_above) frame.render_widget(more_para, service_chunks[lines_to_show]);
} else {
format!("... {} more below", hidden_below)
};
if available_lines > 0 && lines_to_show > 0 {
let last_line_area = Rect {
x: area.x,
y: area.y + (lines_to_show - 1) as u16,
width: area.width,
height: 1,
};
let scroll_para = Paragraph::new(scroll_text).style(Typography::muted());
frame.render_widget(scroll_para, last_line_area);
}
} }
} }
} }

View File

@@ -438,8 +438,8 @@ impl Widget for SystemWidget {
} }
impl SystemWidget { impl SystemWidget {
/// Render with scroll offset support /// Render system widget
pub fn render_with_scroll(&mut self, frame: &mut Frame, area: Rect, scroll_offset: usize, hostname: &str) { pub fn render(&mut self, frame: &mut Frame, area: Rect, hostname: &str, config: Option<&crate::config::DashboardConfig>) {
let mut lines = Vec::new(); let mut lines = Vec::new();
// NixOS section // NixOS section
@@ -457,6 +457,16 @@ impl SystemWidget {
Span::styled(format!("Agent: {}", agent_version_text), Typography::secondary()) Span::styled(format!("Agent: {}", agent_version_text), Typography::secondary())
])); ]));
// Display detected connection IP
if let Some(config) = config {
if let Some(host_details) = config.hosts.get(hostname) {
let detected_ip = host_details.get_connection_ip(hostname);
lines.push(Line::from(vec![
Span::styled(format!("IP: {}", detected_ip), Typography::secondary())
]));
}
}
// CPU section // CPU section
lines.push(Line::from(vec![ lines.push(Line::from(vec![
@@ -503,69 +513,30 @@ impl SystemWidget {
Span::styled("Storage:", Typography::widget_title()) Span::styled("Storage:", Typography::widget_title())
])); ]));
// Storage items with overflow handling // Storage items - let main overflow logic handle truncation
let storage_lines = self.render_storage(); let storage_lines = self.render_storage();
let remaining_space = area.height.saturating_sub(lines.len() as u16); lines.extend(storage_lines);
if storage_lines.len() <= remaining_space as usize {
// All storage lines fit
lines.extend(storage_lines);
} else if remaining_space >= 2 {
// Show what we can and add overflow indicator
let lines_to_show = (remaining_space - 1) as usize; // Reserve 1 line for overflow
lines.extend(storage_lines.iter().take(lines_to_show).cloned());
// Count hidden pools
let mut hidden_pools = 0;
let mut current_pool = String::new();
for (i, line) in storage_lines.iter().enumerate() {
if i >= lines_to_show {
// Check if this line represents a new pool (no indentation)
if let Some(first_span) = line.spans.first() {
let text = first_span.content.as_ref();
if !text.starts_with(" ") && text.contains(':') {
let pool_name = text.split(':').next().unwrap_or("").trim();
if pool_name != current_pool {
hidden_pools += 1;
current_pool = pool_name.to_string();
}
}
}
}
}
if hidden_pools > 0 {
let overflow_text = format!(
"... and {} more pool{}",
hidden_pools,
if hidden_pools == 1 { "" } else { "s" }
);
lines.push(Line::from(vec![
Span::styled(overflow_text, Typography::muted())
]));
}
}
// Apply scroll offset // Apply scroll offset
let total_lines = lines.len(); let total_lines = lines.len();
let available_height = area.height as usize; let available_height = area.height as usize;
// Always apply scrolling if scroll_offset > 0, even if content fits // Show only what fits, with "X more below" if needed
if scroll_offset > 0 || total_lines > available_height { if total_lines > available_height {
let max_scroll = if total_lines > available_height { let lines_for_content = available_height.saturating_sub(1); // Reserve one line for "more below"
total_lines - available_height let mut visible_lines: Vec<Line> = lines
} else {
total_lines.saturating_sub(1)
};
let effective_scroll = scroll_offset.min(max_scroll);
// Take only the visible portion after scrolling
let visible_lines: Vec<Line> = lines
.into_iter() .into_iter()
.skip(effective_scroll) .take(lines_for_content)
.take(available_height)
.collect(); .collect();
let hidden_below = total_lines.saturating_sub(lines_for_content);
if hidden_below > 0 {
let more_line = Line::from(vec![
Span::styled(format!("... {} more below", hidden_below), Typography::muted())
]);
visible_lines.push(more_line);
}
let paragraph = Paragraph::new(Text::from(visible_lines)); let paragraph = Paragraph::new(Text::from(visible_lines));
frame.render_widget(paragraph, area); frame.render_widget(paragraph, area);
} else { } else {

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cm-dashboard-shared" name = "cm-dashboard-shared"
version = "0.1.67" version = "0.1.97"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@@ -82,12 +82,13 @@ impl MetricValue {
/// Health status for metrics /// Health status for metrics
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum Status { pub enum Status {
Ok, Inactive, // Lowest priority - treated as good
Ok, // Second lowest - also good
Unknown,
Offline,
Pending, Pending,
Warning, Warning,
Critical, Critical,
Unknown,
Offline,
} }
impl Status { impl Status {
@@ -181,6 +182,16 @@ impl HysteresisThresholds {
Status::Ok Status::Ok
} }
} }
Status::Inactive => {
// Inactive services use normal thresholds like first measurement
if value >= self.critical_high {
Status::Critical
} else if value >= self.warning_high {
Status::Warning
} else {
Status::Ok
}
}
Status::Pending => { Status::Pending => {
// Service transitioning, use normal thresholds like first measurement // Service transitioning, use normal thresholds like first measurement
if value >= self.critical_high { if value >= self.critical_high {