Initial commit: zfsdu v0.1.0 - ZFS disk usage viewer with snapshot management
This commit is contained in:
commit
75d164859f
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Build artifacts
|
||||||
|
/target/
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.claude/
|
||||||
18
Cargo.toml
Normal file
18
Cargo.toml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "zfsdu"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "ncdu-like disk usage viewer for ZFS with snapshot management"
|
||||||
|
authors = ["Florian Schermer"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ratatui = "0.29"
|
||||||
|
crossterm = "0.28"
|
||||||
|
anyhow = "1.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
55
LICENSE
Normal file
55
LICENSE
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
1.1. "Contributor" means each individual or entity that creates or
|
||||||
|
contributes to the creation of Modifications.
|
||||||
|
|
||||||
|
1.2. "Covered Software" means (a) the Original Software, or (b)
|
||||||
|
Modifications, or (c) the combination of files containing Original
|
||||||
|
Software with files containing Modifications.
|
||||||
|
|
||||||
|
1.3. "Executable" means the Covered Software in any form other than
|
||||||
|
Source Code.
|
||||||
|
|
||||||
|
1.4. "Initial Developer" means the individual or entity that first
|
||||||
|
makes Original Software available under this License.
|
||||||
|
|
||||||
|
1.5. "License" means this document.
|
||||||
|
|
||||||
|
1.6. "Modifications" means the Source Code and Executable form of
|
||||||
|
any file that results from an addition to, deletion from or
|
||||||
|
modification of the contents of a file containing Original Software.
|
||||||
|
|
||||||
|
1.7. "Original Software" means the Source Code and Executable form
|
||||||
|
of computer software code that is originally released under this
|
||||||
|
License.
|
||||||
|
|
||||||
|
1.8. "Source Code" means the common form of computer software code
|
||||||
|
in which modifications are made.
|
||||||
|
|
||||||
|
1.9. "You" (or "Your") means an individual or a legal entity
|
||||||
|
exercising rights under this License.
|
||||||
|
|
||||||
|
2. License Grants.
|
||||||
|
|
||||||
|
The Initial Developer hereby grants You a world-wide, royalty-free,
|
||||||
|
non-exclusive license to use, reproduce, modify, display, perform,
|
||||||
|
sublicense and distribute the Original Software (or portions thereof),
|
||||||
|
with or without Modifications, and/or as part of a Larger Work.
|
||||||
|
|
||||||
|
3. Distribution Obligations.
|
||||||
|
|
||||||
|
Any Covered Software that You distribute in Executable form must also
|
||||||
|
be made available in Source Code form under the terms of this License.
|
||||||
|
|
||||||
|
4. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
COVERED SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
||||||
|
|
||||||
|
5. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT SHALL THE INITIAL DEVELOPER OR ANY CONTRIBUTOR BE LIABLE
|
||||||
|
FOR ANY DAMAGES WHATSOEVER.
|
||||||
|
|
||||||
|
This license is the same as used by OpenZFS.
|
||||||
110
README.md
Normal file
110
README.md
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
# zfsdu - ZFS Disk Usage
|
||||||
|
|
||||||
|
A ncdu-like TUI application for ZFS with snapshot management. Navigate your ZFS pools, datasets, and volumes in a tree view, and manage snapshots interactively.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Quick Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSL https://gitlab.datazone.de/kidev/zfsdu/-/raw/main/install-binary.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Tree View**: Hierarchical display of ZFS pools, datasets, and volumes
|
||||||
|
- **Expand/Collapse**: Navigate the tree structure interactively
|
||||||
|
- **Snapshot Management**: Mark and delete multiple snapshots at once
|
||||||
|
- **Size Visualization**: Bar graphs showing relative sizes
|
||||||
|
- **Search**: Find datasets or snapshots by name
|
||||||
|
- **Filter**: Filter the view to show only matching items
|
||||||
|
- **Bulk Selection**: Select/unselect all visible snapshots (like Midnight Commander)
|
||||||
|
- **Scrolling**: Handle large ZFS hierarchies with scrollbar indicator
|
||||||
|
- **MC-Style UI**: Classic Midnight Commander look and feel
|
||||||
|
|
||||||
|
## Screenshot
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Name Size │
|
||||||
|
│─────────────────────────────────────────────────────────────────────────│
|
||||||
|
│ ▾ rpool 500.0G █████ │
|
||||||
|
│ ├──▾ ROOT 120.0G ██ │
|
||||||
|
│ │ ├── pve-1 80.0G █ │
|
||||||
|
│ │ └──○ @autosnap_2024-01-15 12.0G │
|
||||||
|
│ └──▸ data 380.0G ████ │
|
||||||
|
│ ▸ tank 2.0T █████ │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 8 items │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
F1 Help F5 Refr F8 Del / Search f Filter e Expand c Collapse
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Binary (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSL https://gitlab.datazone.de/kidev/zfsdu/-/raw/main/install-binary.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Rust
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
source ~/.cargo/env
|
||||||
|
|
||||||
|
# Clone and build
|
||||||
|
git clone https://gitlab.datazone.de/kidev/zfsdu.git
|
||||||
|
cd zfsdu
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Install
|
||||||
|
sudo cp target/release/zfsdu /usr/local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zfsdu
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| **Navigation** | |
|
||||||
|
| ↑/k, ↓/j | Move up/down |
|
||||||
|
| Enter/→/l | Expand/collapse dataset |
|
||||||
|
| Home/End | First/last item |
|
||||||
|
| PgUp/PgDn | Page up/down |
|
||||||
|
| **Tree** | |
|
||||||
|
| e | Expand all |
|
||||||
|
| c | Collapse all |
|
||||||
|
| **Selection** | |
|
||||||
|
| Space | Mark/unmark snapshot |
|
||||||
|
| + | Select all visible snapshots |
|
||||||
|
| - | Unselect all visible snapshots |
|
||||||
|
| * | Invert selection |
|
||||||
|
| **Search & Filter** | |
|
||||||
|
| / | Search (n=next, N=prev) |
|
||||||
|
| f | Filter visible items |
|
||||||
|
| Esc | Clear filter |
|
||||||
|
| **Actions** | |
|
||||||
|
| d / F8 | Delete marked snapshots (2x to confirm) |
|
||||||
|
| r / F5 | Refresh |
|
||||||
|
| q / F10 | Quit |
|
||||||
|
| F1 | Help |
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Linux with ZFS installed
|
||||||
|
- Terminal with UTF-8 support
|
||||||
|
- Root privileges (for snapshot deletion)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
CDDL (Common Development and Distribution License) - same as OpenZFS.
|
||||||
|
|
||||||
|
See [LICENSE](LICENSE) for details.
|
||||||
52
install-binary.sh
Normal file
52
install-binary.sh
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# zfsdu - Quick Binary Installation
|
||||||
|
# Downloads and installs pre-built binary from GitLab
|
||||||
|
#
|
||||||
|
# No additional packages required - uses only standard glibc
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
GITLAB_URL="https://gitlab.datazone.de/kidev/zfsdu/-/raw/main/releases/zfsdu-linux-amd64"
|
||||||
|
INSTALL_PATH="/usr/local/bin/zfsdu"
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo -e "${GREEN}================================${NC}"
|
||||||
|
echo -e "${GREEN} zfsdu - Binary Installation ${NC}"
|
||||||
|
echo -e "${GREEN}================================${NC}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Check root
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo -e "${RED}Error: Please run as root${NC}"
|
||||||
|
echo "Usage: curl -sSL ... | sudo bash"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download and install
|
||||||
|
echo -e "${YELLOW}Downloading zfsdu...${NC}"
|
||||||
|
curl -sSL -o "$INSTALL_PATH" "$GITLAB_URL"
|
||||||
|
chmod +x "$INSTALL_PATH"
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
if [ -x "$INSTALL_PATH" ]; then
|
||||||
|
echo -e "${GREEN}Successfully installed to $INSTALL_PATH${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Installation failed${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check ZFS
|
||||||
|
if ! command -v zfs &> /dev/null; then
|
||||||
|
echo
|
||||||
|
echo -e "${YELLOW}Note: ZFS not found. Install with:${NC}"
|
||||||
|
echo " apt install zfsutils-linux"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo -e "${GREEN}Done! Run 'zfsdu' to start.${NC}"
|
||||||
59
install.sh
Normal file
59
install.sh
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# zfsdu Installation Script - Build from Source
|
||||||
|
# For Debian/Ubuntu/Proxmox
|
||||||
|
#
|
||||||
|
# Use install-binary.sh if you already have the binary
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
echo -e "${GREEN}================================${NC}"
|
||||||
|
echo -e "${GREEN} zfsdu - Build from Source ${NC}"
|
||||||
|
echo -e "${GREEN}================================${NC}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Check root
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo -e "${RED}Error: Please run as root${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Detect OS
|
||||||
|
if [ -f /etc/os-release ]; then
|
||||||
|
. /etc/os-release
|
||||||
|
OS=$ID
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Installing build dependencies...${NC}"
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y curl build-essential pkg-config
|
||||||
|
|
||||||
|
# Install Rust if needed
|
||||||
|
if ! command -v cargo &> /dev/null; then
|
||||||
|
echo -e "${YELLOW}Installing Rust...${NC}"
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
source "$HOME/.cargo/env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
|
||||||
|
# Build
|
||||||
|
echo -e "${YELLOW}Building zfsdu...${NC}"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Install
|
||||||
|
echo -e "${YELLOW}Installing...${NC}"
|
||||||
|
cp "$SCRIPT_DIR/target/release/zfsdu" /usr/local/bin/
|
||||||
|
chmod +x /usr/local/bin/zfsdu
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo -e "${GREEN}Done! Run 'zfsdu' to start.${NC}"
|
||||||
BIN
releases/zfsdu-linux-amd64
Normal file
BIN
releases/zfsdu-linux-amd64
Normal file
Binary file not shown.
564
src/app.rs
Normal file
564
src/app.rs
Normal file
@ -0,0 +1,564 @@
|
|||||||
|
use crate::zfs::{self, ZfsEntry, ZfsSnapshot};
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ListItem {
|
||||||
|
Dataset(ZfsEntry),
|
||||||
|
Snapshot(ZfsSnapshot),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListItem {
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
ListItem::Dataset(d) => d.short_name(),
|
||||||
|
ListItem::Snapshot(s) => s.short_name(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn full_name(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
ListItem::Dataset(d) => &d.full_name,
|
||||||
|
ListItem::Snapshot(s) => &s.full_name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn used(&self) -> u64 {
|
||||||
|
match self {
|
||||||
|
ListItem::Dataset(d) => d.used,
|
||||||
|
ListItem::Snapshot(s) => s.used,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_snapshot(&self) -> bool {
|
||||||
|
matches!(self, ListItem::Snapshot(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_dataset(&self) -> bool {
|
||||||
|
matches!(self, ListItem::Dataset(_))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TreeItem {
|
||||||
|
pub item: ListItem,
|
||||||
|
pub depth: usize,
|
||||||
|
pub is_last: bool,
|
||||||
|
pub parent_is_last: Vec<bool>,
|
||||||
|
pub visible: bool,
|
||||||
|
pub parent_path: String, // For tracking which parent this belongs to
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum InputMode {
|
||||||
|
Normal,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
pub root_entries: Vec<ZfsEntry>,
|
||||||
|
pub tree_items: Vec<TreeItem>,
|
||||||
|
pub selected: usize,
|
||||||
|
pub marked: Vec<String>,
|
||||||
|
pub expanded: HashSet<String>, // Names of expanded datasets
|
||||||
|
pub show_help: bool,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub confirm_delete: bool,
|
||||||
|
pub page_size: usize,
|
||||||
|
pub scroll_offset: usize,
|
||||||
|
// Search & Filter
|
||||||
|
pub input_mode: InputMode,
|
||||||
|
pub search_query: String,
|
||||||
|
pub filter_query: String,
|
||||||
|
pub search_matches: Vec<usize>,
|
||||||
|
pub current_match: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let root_entries = zfs::get_zfs_tree()?;
|
||||||
|
let expanded = HashSet::new();
|
||||||
|
let tree_items = Self::build_tree_view(&root_entries, 0, &[], &expanded, "");
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
root_entries,
|
||||||
|
tree_items,
|
||||||
|
selected: 0,
|
||||||
|
marked: Vec::new(),
|
||||||
|
expanded,
|
||||||
|
show_help: false,
|
||||||
|
message: None,
|
||||||
|
confirm_delete: false,
|
||||||
|
page_size: 20,
|
||||||
|
scroll_offset: 0,
|
||||||
|
input_mode: InputMode::Normal,
|
||||||
|
search_query: String::new(),
|
||||||
|
filter_query: String::new(),
|
||||||
|
search_matches: Vec::new(),
|
||||||
|
current_match: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_tree_view(
|
||||||
|
entries: &[ZfsEntry],
|
||||||
|
depth: usize,
|
||||||
|
parent_is_last: &[bool],
|
||||||
|
expanded: &HashSet<String>,
|
||||||
|
parent_path: &str,
|
||||||
|
) -> Vec<TreeItem> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let len = entries.len();
|
||||||
|
|
||||||
|
for (i, entry) in entries.iter().enumerate() {
|
||||||
|
let is_last_entry = i == len - 1;
|
||||||
|
let has_children = !entry.children.is_empty() || !entry.snapshots.is_empty();
|
||||||
|
let is_expanded = expanded.contains(&entry.full_name);
|
||||||
|
|
||||||
|
items.push(TreeItem {
|
||||||
|
item: ListItem::Dataset(entry.clone()),
|
||||||
|
depth,
|
||||||
|
is_last: is_last_entry && (!is_expanded || !has_children),
|
||||||
|
parent_is_last: parent_is_last.to_vec(),
|
||||||
|
visible: true,
|
||||||
|
parent_path: parent_path.to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only show children if expanded
|
||||||
|
if is_expanded && has_children {
|
||||||
|
let mut child_parent_is_last = parent_is_last.to_vec();
|
||||||
|
child_parent_is_last.push(is_last_entry);
|
||||||
|
|
||||||
|
// Add child datasets
|
||||||
|
let child_items = Self::build_tree_view(
|
||||||
|
&entry.children,
|
||||||
|
depth + 1,
|
||||||
|
&child_parent_is_last,
|
||||||
|
expanded,
|
||||||
|
&entry.full_name,
|
||||||
|
);
|
||||||
|
items.extend(child_items);
|
||||||
|
|
||||||
|
// Add snapshots
|
||||||
|
let snap_len = entry.snapshots.len();
|
||||||
|
let children_empty = entry.children.is_empty();
|
||||||
|
for (j, snap) in entry.snapshots.iter().enumerate() {
|
||||||
|
items.push(TreeItem {
|
||||||
|
item: ListItem::Snapshot(snap.clone()),
|
||||||
|
depth: depth + 1,
|
||||||
|
is_last: j == snap_len - 1 && children_empty,
|
||||||
|
parent_is_last: child_parent_is_last.clone(),
|
||||||
|
visible: true,
|
||||||
|
parent_path: entry.full_name.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rebuild_tree(&mut self) {
|
||||||
|
self.tree_items = Self::build_tree_view(&self.root_entries, 0, &[], &self.expanded, "");
|
||||||
|
self.apply_filter();
|
||||||
|
self.ensure_valid_selection();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh(&mut self) -> Result<()> {
|
||||||
|
self.root_entries = zfs::get_zfs_tree()?;
|
||||||
|
self.rebuild_tree();
|
||||||
|
self.message = Some("Refreshed".to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_valid_selection(&mut self) {
|
||||||
|
let count = self.visible_count();
|
||||||
|
if count == 0 {
|
||||||
|
self.selected = 0;
|
||||||
|
} else if self.selected >= count {
|
||||||
|
self.selected = count - 1;
|
||||||
|
}
|
||||||
|
self.adjust_scroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn visible_items(&self) -> Vec<(usize, &TreeItem)> {
|
||||||
|
self.tree_items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, ti)| ti.visible)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn visible_count(&self) -> usize {
|
||||||
|
self.tree_items.iter().filter(|ti| ti.visible).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nth_visible(&self, n: usize) -> Option<usize> {
|
||||||
|
self.tree_items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, ti)| ti.visible)
|
||||||
|
.nth(n)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visible_index_of(&self, real_index: usize) -> Option<usize> {
|
||||||
|
self.tree_items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, ti)| ti.visible)
|
||||||
|
.position(|(i, _)| i == real_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adjust_scroll(&mut self) {
|
||||||
|
// Keep selected item visible
|
||||||
|
if self.selected < self.scroll_offset {
|
||||||
|
self.scroll_offset = self.selected;
|
||||||
|
} else if self.selected >= self.scroll_offset + self.page_size {
|
||||||
|
self.scroll_offset = self.selected - self.page_size + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&mut self) {
|
||||||
|
let count = self.visible_count();
|
||||||
|
if count > 0 && self.selected < count - 1 {
|
||||||
|
self.selected += 1;
|
||||||
|
self.adjust_scroll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn previous(&mut self) {
|
||||||
|
if self.selected > 0 {
|
||||||
|
self.selected -= 1;
|
||||||
|
self.adjust_scroll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn first(&mut self) {
|
||||||
|
self.selected = 0;
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn last(&mut self) {
|
||||||
|
let count = self.visible_count();
|
||||||
|
if count > 0 {
|
||||||
|
self.selected = count - 1;
|
||||||
|
self.adjust_scroll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn page_up(&mut self) {
|
||||||
|
self.selected = self.selected.saturating_sub(self.page_size);
|
||||||
|
self.adjust_scroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn page_down(&mut self) {
|
||||||
|
let count = self.visible_count();
|
||||||
|
if count > 0 {
|
||||||
|
self.selected = (self.selected + self.page_size).min(count - 1);
|
||||||
|
self.adjust_scroll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_expand(&mut self) {
|
||||||
|
if let Some(real_idx) = self.nth_visible(self.selected) {
|
||||||
|
if let Some(tree_item) = self.tree_items.get(real_idx) {
|
||||||
|
if let ListItem::Dataset(entry) = &tree_item.item {
|
||||||
|
let name = entry.full_name.clone();
|
||||||
|
if self.expanded.contains(&name) {
|
||||||
|
self.expanded.remove(&name);
|
||||||
|
} else {
|
||||||
|
self.expanded.insert(name);
|
||||||
|
}
|
||||||
|
self.rebuild_tree();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_expanded(&self, name: &str) -> bool {
|
||||||
|
self.expanded.contains(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_mark(&mut self) {
|
||||||
|
if let Some(real_idx) = self.nth_visible(self.selected) {
|
||||||
|
if let Some(tree_item) = self.tree_items.get(real_idx) {
|
||||||
|
if tree_item.item.is_snapshot() {
|
||||||
|
let name = tree_item.item.full_name().to_string();
|
||||||
|
if let Some(pos) = self.marked.iter().position(|x| x == &name) {
|
||||||
|
self.marked.remove(pos);
|
||||||
|
} else {
|
||||||
|
self.marked.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_marked(&self, name: &str) -> bool {
|
||||||
|
self.marked.contains(&name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_marked(&mut self) -> Result<()> {
|
||||||
|
if self.marked.is_empty() {
|
||||||
|
self.message = Some("No snapshots marked".to_string());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.confirm_delete {
|
||||||
|
self.confirm_delete = true;
|
||||||
|
self.message = Some(format!(
|
||||||
|
"Delete {} snapshot(s)? Press 'd' again to confirm",
|
||||||
|
self.marked.len()
|
||||||
|
));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut deleted = 0;
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
for name in &self.marked.clone() {
|
||||||
|
match zfs::delete_snapshot(name) {
|
||||||
|
Ok(_) => deleted += 1,
|
||||||
|
Err(e) => errors.push(format!("{}: {}", name, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.marked.clear();
|
||||||
|
self.confirm_delete = false;
|
||||||
|
|
||||||
|
if errors.is_empty() {
|
||||||
|
self.message = Some(format!("Deleted {} snapshot(s)", deleted));
|
||||||
|
} else {
|
||||||
|
self.message = Some(format!(
|
||||||
|
"Deleted {}, {} errors",
|
||||||
|
deleted,
|
||||||
|
errors.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.refresh()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_help(&mut self) {
|
||||||
|
self.show_help = !self.show_help;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total_marked_size(&self) -> u64 {
|
||||||
|
self.tree_items
|
||||||
|
.iter()
|
||||||
|
.filter(|ti| self.is_marked(ti.item.full_name()))
|
||||||
|
.map(|ti| ti.item.used())
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn max_size(&self) -> u64 {
|
||||||
|
self.tree_items
|
||||||
|
.iter()
|
||||||
|
.filter(|ti| ti.visible)
|
||||||
|
.map(|ti| ti.item.used())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search & Filter functions
|
||||||
|
pub fn start_search(&mut self) {
|
||||||
|
self.input_mode = InputMode::Search;
|
||||||
|
self.search_query.clear();
|
||||||
|
self.search_matches.clear();
|
||||||
|
self.current_match = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_filter(&mut self) {
|
||||||
|
self.input_mode = InputMode::Filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel_input(&mut self) {
|
||||||
|
self.input_mode = InputMode::Normal;
|
||||||
|
if self.input_mode == InputMode::Search {
|
||||||
|
self.search_query.clear();
|
||||||
|
self.search_matches.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn confirm_input(&mut self) {
|
||||||
|
match self.input_mode {
|
||||||
|
InputMode::Search => {
|
||||||
|
self.execute_search();
|
||||||
|
self.input_mode = InputMode::Normal;
|
||||||
|
}
|
||||||
|
InputMode::Filter => {
|
||||||
|
self.apply_filter();
|
||||||
|
self.input_mode = InputMode::Normal;
|
||||||
|
}
|
||||||
|
InputMode::Normal => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn input_char(&mut self, c: char) {
|
||||||
|
match self.input_mode {
|
||||||
|
InputMode::Search => self.search_query.push(c),
|
||||||
|
InputMode::Filter => self.filter_query.push(c),
|
||||||
|
InputMode::Normal => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn input_backspace(&mut self) {
|
||||||
|
match self.input_mode {
|
||||||
|
InputMode::Search => { self.search_query.pop(); }
|
||||||
|
InputMode::Filter => { self.filter_query.pop(); }
|
||||||
|
InputMode::Normal => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_search(&mut self) {
|
||||||
|
self.search_matches.clear();
|
||||||
|
|
||||||
|
if self.search_query.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = self.search_query.to_lowercase();
|
||||||
|
|
||||||
|
for (i, tree_item) in self.tree_items.iter().enumerate() {
|
||||||
|
if tree_item.visible {
|
||||||
|
let name = tree_item.item.full_name().to_lowercase();
|
||||||
|
if name.contains(&query) {
|
||||||
|
self.search_matches.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.current_match = 0;
|
||||||
|
self.jump_to_current_match();
|
||||||
|
|
||||||
|
let count = self.search_matches.len();
|
||||||
|
self.message = Some(format!("Found {} match(es)", count));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_match(&mut self) {
|
||||||
|
if self.search_matches.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.current_match = (self.current_match + 1) % self.search_matches.len();
|
||||||
|
self.jump_to_current_match();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_match(&mut self) {
|
||||||
|
if self.search_matches.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.current_match == 0 {
|
||||||
|
self.current_match = self.search_matches.len() - 1;
|
||||||
|
} else {
|
||||||
|
self.current_match -= 1;
|
||||||
|
}
|
||||||
|
self.jump_to_current_match();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jump_to_current_match(&mut self) {
|
||||||
|
if let Some(&real_idx) = self.search_matches.get(self.current_match) {
|
||||||
|
if let Some(visible_idx) = self.visible_index_of(real_idx) {
|
||||||
|
self.selected = visible_idx;
|
||||||
|
self.adjust_scroll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_filter(&mut self) {
|
||||||
|
if self.filter_query.is_empty() {
|
||||||
|
for ti in &mut self.tree_items {
|
||||||
|
ti.visible = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let query = self.filter_query.to_lowercase();
|
||||||
|
|
||||||
|
for ti in &mut self.tree_items {
|
||||||
|
let name = ti.item.full_name().to_lowercase();
|
||||||
|
ti.visible = name.contains(&query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.ensure_valid_selection();
|
||||||
|
|
||||||
|
let visible = self.visible_count();
|
||||||
|
let total = self.tree_items.len();
|
||||||
|
if !self.filter_query.is_empty() {
|
||||||
|
self.message = Some(format!("Filter: {} of {} shown", visible, total));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_filter(&mut self) {
|
||||||
|
self.filter_query.clear();
|
||||||
|
self.apply_filter();
|
||||||
|
self.message = Some("Filter cleared".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_search_match(&self, real_index: usize) -> bool {
|
||||||
|
self.search_matches.contains(&real_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select/Unselect Group (like MC)
|
||||||
|
pub fn select_group(&mut self) {
|
||||||
|
let mut count = 0;
|
||||||
|
for ti in &self.tree_items {
|
||||||
|
if ti.visible && ti.item.is_snapshot() {
|
||||||
|
let name = ti.item.full_name().to_string();
|
||||||
|
if !self.marked.contains(&name) {
|
||||||
|
self.marked.push(name);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.message = Some(format!("Selected {} snapshot(s)", count));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unselect_group(&mut self) {
|
||||||
|
let visible_names: Vec<String> = self.tree_items
|
||||||
|
.iter()
|
||||||
|
.filter(|ti| ti.visible && ti.item.is_snapshot())
|
||||||
|
.map(|ti| ti.item.full_name().to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let before = self.marked.len();
|
||||||
|
self.marked.retain(|name| !visible_names.contains(name));
|
||||||
|
let removed = before - self.marked.len();
|
||||||
|
self.message = Some(format!("Unselected {} snapshot(s)", removed));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invert_selection(&mut self) {
|
||||||
|
for ti in &self.tree_items {
|
||||||
|
if ti.visible && ti.item.is_snapshot() {
|
||||||
|
let name = ti.item.full_name().to_string();
|
||||||
|
if let Some(pos) = self.marked.iter().position(|x| x == &name) {
|
||||||
|
self.marked.remove(pos);
|
||||||
|
} else {
|
||||||
|
self.marked.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.message = Some(format!("{} snapshot(s) marked", self.marked.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand all datasets
|
||||||
|
pub fn expand_all(&mut self) {
|
||||||
|
for ti in &self.tree_items {
|
||||||
|
if let ListItem::Dataset(d) = &ti.item {
|
||||||
|
if d.has_children() {
|
||||||
|
self.expanded.insert(d.full_name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.rebuild_tree();
|
||||||
|
self.message = Some("Expanded all".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse all datasets
|
||||||
|
pub fn collapse_all(&mut self) {
|
||||||
|
self.expanded.clear();
|
||||||
|
self.rebuild_tree();
|
||||||
|
self.message = Some("Collapsed all".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/main.rs
Normal file
104
src/main.rs
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
mod app;
|
||||||
|
mod ui;
|
||||||
|
mod zfs;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use app::{App, InputMode};
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let mut app = App::new()?;
|
||||||
|
let res = run_app(&mut terminal, &mut app);
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
if let Err(err) = res {
|
||||||
|
eprintln!("Error: {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
terminal.draw(|f| ui::draw(f, app))?;
|
||||||
|
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
// Handle input mode
|
||||||
|
if app.input_mode != InputMode::Normal {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => app.cancel_input(),
|
||||||
|
KeyCode::Enter => app.confirm_input(),
|
||||||
|
KeyCode::Backspace => app.input_backspace(),
|
||||||
|
KeyCode::Char(c) => app.input_char(c),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quit on q or Ctrl+C
|
||||||
|
if key.code == KeyCode::Char('q') || key.code == KeyCode::F(10) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
// Navigation
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => app.previous(),
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => app.next(),
|
||||||
|
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => app.toggle_expand(),
|
||||||
|
KeyCode::Backspace | KeyCode::Left | KeyCode::Char('h') => app.toggle_expand(), // Same as Enter for tree
|
||||||
|
KeyCode::Home => app.first(),
|
||||||
|
KeyCode::End => app.last(),
|
||||||
|
KeyCode::PageUp => app.page_up(),
|
||||||
|
KeyCode::PageDown => app.page_down(),
|
||||||
|
|
||||||
|
// Expand/Collapse all
|
||||||
|
KeyCode::Char('e') => app.expand_all(),
|
||||||
|
KeyCode::Char('c') => app.collapse_all(),
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
KeyCode::Char(' ') => app.toggle_mark(),
|
||||||
|
KeyCode::Char('+') => app.select_group(),
|
||||||
|
KeyCode::Char('-') => app.unselect_group(),
|
||||||
|
KeyCode::Char('*') => app.invert_selection(),
|
||||||
|
|
||||||
|
// Search & Filter
|
||||||
|
KeyCode::Char('/') => app.start_search(),
|
||||||
|
KeyCode::Char('f') => app.start_filter(),
|
||||||
|
KeyCode::Char('n') => app.next_match(),
|
||||||
|
KeyCode::Char('N') => app.prev_match(),
|
||||||
|
KeyCode::Esc => app.clear_filter(),
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
KeyCode::Char('d') | KeyCode::F(8) => app.delete_marked()?,
|
||||||
|
KeyCode::Char('r') | KeyCode::F(5) => app.refresh()?,
|
||||||
|
|
||||||
|
// Help
|
||||||
|
KeyCode::F(1) => app.toggle_help(),
|
||||||
|
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
435
src/ui.rs
Normal file
435
src/ui.rs
Normal file
@ -0,0 +1,435 @@
|
|||||||
|
use crate::app::{App, InputMode, ListItem, TreeItem};
|
||||||
|
use crate::zfs::format_size;
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Clear, List, ListItem as RatatuiListItem, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
// MC-style colors
|
||||||
|
const BG_COLOR: Color = Color::Blue;
|
||||||
|
const FG_COLOR: Color = Color::White;
|
||||||
|
const HEADER_COLOR: Color = Color::Yellow;
|
||||||
|
const SELECTED_BG: Color = Color::Cyan;
|
||||||
|
const SELECTED_FG: Color = Color::Black;
|
||||||
|
const MARKED_COLOR: Color = Color::Yellow;
|
||||||
|
const SNAPSHOT_COLOR: Color = Color::LightCyan;
|
||||||
|
const SIZE_COLOR: Color = Color::LightGreen;
|
||||||
|
const TREE_COLOR: Color = Color::DarkGray;
|
||||||
|
const SEARCH_MATCH_COLOR: Color = Color::LightRed;
|
||||||
|
|
||||||
|
pub fn draw(f: &mut Frame, app: &mut App) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(1), // Title
|
||||||
|
Constraint::Min(10), // Main content
|
||||||
|
Constraint::Length(1), // Status bar
|
||||||
|
Constraint::Length(1), // Function keys / Input
|
||||||
|
])
|
||||||
|
.split(f.area());
|
||||||
|
|
||||||
|
draw_title(f, chunks[0], app);
|
||||||
|
draw_main(f, chunks[1], app);
|
||||||
|
draw_status(f, chunks[2], app);
|
||||||
|
|
||||||
|
if app.input_mode != InputMode::Normal {
|
||||||
|
draw_input(f, chunks[3], app);
|
||||||
|
} else {
|
||||||
|
draw_function_keys(f, chunks[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.show_help {
|
||||||
|
draw_help(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let mut title = " zfsdu ".to_string();
|
||||||
|
|
||||||
|
if !app.filter_query.is_empty() {
|
||||||
|
title.push_str(&format!("[Filter: {}] ", app.filter_query));
|
||||||
|
}
|
||||||
|
|
||||||
|
let title_bar = Paragraph::new(title).style(
|
||||||
|
Style::default()
|
||||||
|
.bg(BG_COLOR)
|
||||||
|
.fg(HEADER_COLOR)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
f.render_widget(title_bar, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_main(f: &mut Frame, area: Rect, app: &mut App) {
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(FG_COLOR).bg(BG_COLOR))
|
||||||
|
.style(Style::default().bg(BG_COLOR));
|
||||||
|
|
||||||
|
let inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
|
||||||
|
// Header - dynamic width
|
||||||
|
let header_area = Rect {
|
||||||
|
x: inner.x,
|
||||||
|
y: inner.y,
|
||||||
|
width: inner.width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
let header_name_width = (inner.width as usize).saturating_sub(22); // 10 size + 10 bar area + 2 padding
|
||||||
|
let header = Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!(" {:<width$}", "Name", width = header_name_width),
|
||||||
|
Style::default()
|
||||||
|
.fg(HEADER_COLOR)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!("{:>10}", "Size"),
|
||||||
|
Style::default()
|
||||||
|
.fg(HEADER_COLOR)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(header).style(Style::default().bg(BG_COLOR)),
|
||||||
|
header_area,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
let sep_area = Rect {
|
||||||
|
x: inner.x,
|
||||||
|
y: inner.y + 1,
|
||||||
|
width: inner.width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
let separator = "─".repeat(inner.width as usize);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(separator).style(Style::default().fg(FG_COLOR).bg(BG_COLOR)),
|
||||||
|
sep_area,
|
||||||
|
);
|
||||||
|
|
||||||
|
// List area
|
||||||
|
let list_area = Rect {
|
||||||
|
x: inner.x,
|
||||||
|
y: inner.y + 2,
|
||||||
|
width: inner.width,
|
||||||
|
height: inner.height.saturating_sub(2),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update page_size based on visible area
|
||||||
|
app.page_size = list_area.height as usize;
|
||||||
|
|
||||||
|
// Get visible items with scrolling
|
||||||
|
let visible_items: Vec<(usize, &TreeItem)> = app.visible_items();
|
||||||
|
let total_visible = visible_items.len();
|
||||||
|
|
||||||
|
// Apply scroll offset
|
||||||
|
let display_items: Vec<(usize, usize, &TreeItem)> = visible_items
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.skip(app.scroll_offset)
|
||||||
|
.take(list_area.height as usize)
|
||||||
|
.map(|(visible_idx, (real_idx, ti))| (visible_idx, real_idx, ti))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let available_width = list_area.width as usize;
|
||||||
|
let items: Vec<RatatuiListItem> = display_items
|
||||||
|
.iter()
|
||||||
|
.map(|(visible_idx, real_idx, tree_item)| {
|
||||||
|
render_tree_item(app, *visible_idx, *real_idx, tree_item, available_width)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let list = List::new(items).style(Style::default().bg(BG_COLOR));
|
||||||
|
f.render_widget(list, list_area);
|
||||||
|
|
||||||
|
// Draw scrollbar indicator if needed
|
||||||
|
if total_visible > list_area.height as usize {
|
||||||
|
draw_scrollbar(f, list_area, app.scroll_offset, total_visible, list_area.height as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_tree_item(app: &App, visible_index: usize, real_index: usize, tree_item: &TreeItem, available_width: usize) -> RatatuiListItem<'static> {
|
||||||
|
let is_selected = visible_index == app.selected;
|
||||||
|
let is_marked = app.is_marked(tree_item.item.full_name());
|
||||||
|
let is_search_match = app.is_search_match(real_index);
|
||||||
|
|
||||||
|
// Build tree prefix (│, ├──, └──)
|
||||||
|
let mut tree_prefix = String::new();
|
||||||
|
|
||||||
|
for &parent_last in &tree_item.parent_is_last {
|
||||||
|
if parent_last {
|
||||||
|
tree_prefix.push_str(" ");
|
||||||
|
} else {
|
||||||
|
tree_prefix.push_str(" │ ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tree_item.depth > 0 {
|
||||||
|
if tree_item.is_last {
|
||||||
|
tree_prefix.push_str(" └──");
|
||||||
|
} else {
|
||||||
|
tree_prefix.push_str(" ├──");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item prefix (icon)
|
||||||
|
let (icon, name_color) = match &tree_item.item {
|
||||||
|
ListItem::Dataset(d) => {
|
||||||
|
let has_children = d.has_children();
|
||||||
|
let is_expanded = app.is_expanded(&d.full_name);
|
||||||
|
let icon = if has_children {
|
||||||
|
if is_expanded { "▾ " } else { "▸ " }
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
};
|
||||||
|
(icon, FG_COLOR)
|
||||||
|
}
|
||||||
|
ListItem::Snapshot(_) => {
|
||||||
|
let icon = if is_marked { "● " } else { "○ " };
|
||||||
|
(icon, SNAPSHOT_COLOR)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate available width for name dynamically
|
||||||
|
// Layout: prefix + icon(2) + name + size(10) + space(1) + bar(8) + scrollbar(2)
|
||||||
|
let prefix_len = tree_prefix.chars().count();
|
||||||
|
let fixed_width = prefix_len + 2 + 10 + 1 + 8 + 2; // prefix + icon + size + space + bar + scrollbar
|
||||||
|
let max_name_len = available_width.saturating_sub(fixed_width).max(20);
|
||||||
|
let name = truncate_str(tree_item.item.name(), max_name_len);
|
||||||
|
let name_padded = format!("{:<width$}", name, width = max_name_len);
|
||||||
|
|
||||||
|
let size = format!("{:>10}", format_size(tree_item.item.used()));
|
||||||
|
let bar = create_size_bar(tree_item.item.used(), app.max_size());
|
||||||
|
|
||||||
|
// Determine styles
|
||||||
|
let tree_style = Style::default().fg(TREE_COLOR).bg(BG_COLOR);
|
||||||
|
|
||||||
|
let mut item_style = if is_marked {
|
||||||
|
Style::default()
|
||||||
|
.fg(MARKED_COLOR)
|
||||||
|
.bg(BG_COLOR)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else if is_search_match {
|
||||||
|
Style::default()
|
||||||
|
.fg(SEARCH_MATCH_COLOR)
|
||||||
|
.bg(BG_COLOR)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(name_color).bg(BG_COLOR)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut size_style = Style::default().fg(SIZE_COLOR).bg(BG_COLOR);
|
||||||
|
|
||||||
|
if is_selected {
|
||||||
|
item_style = item_style.bg(SELECTED_BG).fg(SELECTED_FG);
|
||||||
|
size_style = size_style.bg(SELECTED_BG).fg(SELECTED_FG);
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled(tree_prefix, if is_selected { item_style } else { tree_style }),
|
||||||
|
Span::styled(icon.to_string(), item_style),
|
||||||
|
Span::styled(name_padded, item_style),
|
||||||
|
Span::styled(size, size_style),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(bar, size_style),
|
||||||
|
]);
|
||||||
|
|
||||||
|
RatatuiListItem::new(line).style(Style::default().bg(BG_COLOR))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_scrollbar(f: &mut Frame, area: Rect, offset: usize, total: usize, visible: usize) {
|
||||||
|
if total == 0 || visible >= total {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scrollbar_height = area.height as usize;
|
||||||
|
let thumb_height = ((visible as f64 / total as f64) * scrollbar_height as f64).max(1.0) as usize;
|
||||||
|
let thumb_pos = ((offset as f64 / (total - visible) as f64) * (scrollbar_height - thumb_height) as f64) as usize;
|
||||||
|
|
||||||
|
for i in 0..scrollbar_height {
|
||||||
|
let char = if i >= thumb_pos && i < thumb_pos + thumb_height {
|
||||||
|
"█"
|
||||||
|
} else {
|
||||||
|
"░"
|
||||||
|
};
|
||||||
|
let scroll_area = Rect {
|
||||||
|
x: area.x + area.width - 1,
|
||||||
|
y: area.y + i as u16,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(char).style(Style::default().fg(Color::DarkGray).bg(BG_COLOR)),
|
||||||
|
scroll_area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let marked_info = if app.marked.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} marked ({})",
|
||||||
|
app.marked.len(),
|
||||||
|
format_size(app.total_marked_size())
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = app.message.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
let status = if !msg.is_empty() {
|
||||||
|
if marked_info.is_empty() {
|
||||||
|
format!(" {}", msg)
|
||||||
|
} else {
|
||||||
|
format!(" {} │ {}", marked_info, msg)
|
||||||
|
}
|
||||||
|
} else if !marked_info.is_empty() {
|
||||||
|
format!(" {}", marked_info)
|
||||||
|
} else {
|
||||||
|
format!(" {} items", app.visible_count())
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_bar = Paragraph::new(status).style(
|
||||||
|
Style::default().bg(Color::Cyan).fg(Color::Black),
|
||||||
|
);
|
||||||
|
f.render_widget(status_bar, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_input(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let (label, query) = match app.input_mode {
|
||||||
|
InputMode::Search => ("Search: ", &app.search_query),
|
||||||
|
InputMode::Filter => ("Filter: ", &app.filter_query),
|
||||||
|
InputMode::Normal => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let input_line = format!("{}{}_", label, query);
|
||||||
|
let input = Paragraph::new(input_line).style(
|
||||||
|
Style::default().bg(Color::Black).fg(Color::White),
|
||||||
|
);
|
||||||
|
f.render_widget(input, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_function_keys(f: &mut Frame, area: Rect) {
|
||||||
|
let keys = vec![
|
||||||
|
("F1", "Help"),
|
||||||
|
("F5", "Refr"),
|
||||||
|
("F8", "Del"),
|
||||||
|
("/", "Search"),
|
||||||
|
("f", "Filter"),
|
||||||
|
("e", "Expand"),
|
||||||
|
("c", "Collapse"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let spans: Vec<Span> = keys
|
||||||
|
.iter()
|
||||||
|
.flat_map(|(key, desc)| {
|
||||||
|
vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{}", key),
|
||||||
|
Style::default().bg(Color::Black).fg(Color::White),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!("{} ", desc),
|
||||||
|
Style::default().bg(Color::Cyan).fg(Color::Black),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let line = Line::from(spans);
|
||||||
|
let keys_bar = Paragraph::new(line).style(Style::default().bg(Color::Cyan));
|
||||||
|
f.render_widget(keys_bar, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_help(f: &mut Frame) {
|
||||||
|
let area = centered_rect(65, 85, f.area());
|
||||||
|
|
||||||
|
f.render_widget(Clear, area);
|
||||||
|
|
||||||
|
let help_text = vec![
|
||||||
|
"",
|
||||||
|
" Navigation:",
|
||||||
|
" ↑/k, ↓/j Move up/down",
|
||||||
|
" Enter/→/l Expand/collapse dataset",
|
||||||
|
" Home/End First/last item",
|
||||||
|
" PgUp/PgDn Page up/down",
|
||||||
|
"",
|
||||||
|
" Tree:",
|
||||||
|
" e Expand all",
|
||||||
|
" c Collapse all",
|
||||||
|
"",
|
||||||
|
" Selection:",
|
||||||
|
" Space Mark/unmark snapshot",
|
||||||
|
" + Select all visible snapshots",
|
||||||
|
" - Unselect all visible snapshots",
|
||||||
|
" * Invert selection",
|
||||||
|
"",
|
||||||
|
" Search & Filter:",
|
||||||
|
" / Search (n=next, N=prev)",
|
||||||
|
" f Filter visible items",
|
||||||
|
" Esc Clear filter",
|
||||||
|
"",
|
||||||
|
" Actions:",
|
||||||
|
" d / F8 Delete marked snapshots",
|
||||||
|
" r / F5 Refresh",
|
||||||
|
" q / F10 Quit",
|
||||||
|
"",
|
||||||
|
" Press any key to close",
|
||||||
|
];
|
||||||
|
|
||||||
|
let help = Paragraph::new(help_text.join("\n"))
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.title(" Help ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Yellow)),
|
||||||
|
)
|
||||||
|
.style(Style::default().bg(BG_COLOR).fg(FG_COLOR));
|
||||||
|
|
||||||
|
f.render_widget(help, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||||
|
let popup_layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage((100 - percent_y) / 2),
|
||||||
|
Constraint::Percentage(percent_y),
|
||||||
|
Constraint::Percentage((100 - percent_y) / 2),
|
||||||
|
])
|
||||||
|
.split(r);
|
||||||
|
|
||||||
|
Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage((100 - percent_x) / 2),
|
||||||
|
Constraint::Percentage(percent_x),
|
||||||
|
Constraint::Percentage((100 - percent_x) / 2),
|
||||||
|
])
|
||||||
|
.split(popup_layout[1])[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_str(s: &str, max_len: usize) -> String {
|
||||||
|
if s.chars().count() <= max_len {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
let truncated: String = s.chars().take(max_len.saturating_sub(1)).collect();
|
||||||
|
format!("{}…", truncated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_size_bar(size: u64, max_size: u64) -> String {
|
||||||
|
let bar_width = 8;
|
||||||
|
if max_size == 0 {
|
||||||
|
return " ".repeat(bar_width);
|
||||||
|
}
|
||||||
|
|
||||||
|
let filled = ((size as f64 / max_size as f64) * bar_width as f64).round() as usize;
|
||||||
|
let filled = filled.min(bar_width);
|
||||||
|
|
||||||
|
format!("{}{}", "█".repeat(filled), " ".repeat(bar_width - filled))
|
||||||
|
}
|
||||||
262
src/zfs.rs
Normal file
262
src/zfs.rs
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ZfsEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub full_name: String,
|
||||||
|
pub entry_type: ZfsType,
|
||||||
|
pub used: u64,
|
||||||
|
pub referenced: u64,
|
||||||
|
pub available: u64,
|
||||||
|
pub creation: String,
|
||||||
|
pub children: Vec<ZfsEntry>,
|
||||||
|
pub snapshots: Vec<ZfsSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ZfsSnapshot {
|
||||||
|
pub name: String,
|
||||||
|
pub full_name: String,
|
||||||
|
pub used: u64,
|
||||||
|
pub referenced: u64,
|
||||||
|
pub creation: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum ZfsType {
|
||||||
|
Pool,
|
||||||
|
Filesystem,
|
||||||
|
Volume,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZfsEntry {
|
||||||
|
pub fn short_name(&self) -> &str {
|
||||||
|
self.name.rsplit('/').next().unwrap_or(&self.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_children(&self) -> bool {
|
||||||
|
!self.children.is_empty() || !self.snapshots.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZfsSnapshot {
|
||||||
|
pub fn short_name(&self) -> &str {
|
||||||
|
self.name.rsplit('@').next().unwrap_or(&self.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_zfs_tree() -> Result<Vec<ZfsEntry>> {
|
||||||
|
// Get all datasets
|
||||||
|
let output = Command::new("zfs")
|
||||||
|
.args([
|
||||||
|
"list",
|
||||||
|
"-H",
|
||||||
|
"-o",
|
||||||
|
"name,type,used,referenced,available,creation",
|
||||||
|
"-p", // parseable (bytes)
|
||||||
|
"-t",
|
||||||
|
"filesystem,volume",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.context("Failed to run zfs list")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"zfs list failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let datasets = parse_zfs_list(&String::from_utf8_lossy(&output.stdout))?;
|
||||||
|
|
||||||
|
// Get all snapshots
|
||||||
|
let snap_output = Command::new("zfs")
|
||||||
|
.args([
|
||||||
|
"list",
|
||||||
|
"-H",
|
||||||
|
"-o",
|
||||||
|
"name,used,referenced,creation",
|
||||||
|
"-p",
|
||||||
|
"-t",
|
||||||
|
"snapshot",
|
||||||
|
"-s",
|
||||||
|
"used", // sort by used (ascending)
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.context("Failed to run zfs list snapshots")?;
|
||||||
|
|
||||||
|
let snapshots = if snap_output.status.success() {
|
||||||
|
parse_snapshots(&String::from_utf8_lossy(&snap_output.stdout))?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build tree structure
|
||||||
|
let tree = build_tree(datasets, snapshots);
|
||||||
|
|
||||||
|
Ok(tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_zfs_list(output: &str) -> Result<Vec<(String, ZfsType, u64, u64, u64, String)>> {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
|
for line in output.lines() {
|
||||||
|
let parts: Vec<&str> = line.split('\t').collect();
|
||||||
|
if parts.len() >= 6 {
|
||||||
|
let name = parts[0].to_string();
|
||||||
|
let entry_type = match parts[1] {
|
||||||
|
"filesystem" => ZfsType::Filesystem,
|
||||||
|
"volume" => ZfsType::Volume,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let used = parts[2].parse().unwrap_or(0);
|
||||||
|
let referenced = parts[3].parse().unwrap_or(0);
|
||||||
|
let available = parts[4].parse().unwrap_or(0);
|
||||||
|
let creation = parts[5].to_string();
|
||||||
|
|
||||||
|
entries.push((name, entry_type, used, referenced, available, creation));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_snapshots(output: &str) -> Result<Vec<ZfsSnapshot>> {
|
||||||
|
let mut snapshots = Vec::new();
|
||||||
|
|
||||||
|
for line in output.lines() {
|
||||||
|
let parts: Vec<&str> = line.split('\t').collect();
|
||||||
|
if parts.len() >= 4 {
|
||||||
|
let full_name = parts[0].to_string();
|
||||||
|
let name = full_name.clone();
|
||||||
|
let used = parts[1].parse().unwrap_or(0);
|
||||||
|
let referenced = parts[2].parse().unwrap_or(0);
|
||||||
|
let creation = parts[3].to_string();
|
||||||
|
|
||||||
|
snapshots.push(ZfsSnapshot {
|
||||||
|
name,
|
||||||
|
full_name,
|
||||||
|
used,
|
||||||
|
referenced,
|
||||||
|
creation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort descending by used (largest first)
|
||||||
|
snapshots.sort_by(|a, b| b.used.cmp(&a.used));
|
||||||
|
|
||||||
|
Ok(snapshots)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_tree(
|
||||||
|
datasets: Vec<(String, ZfsType, u64, u64, u64, String)>,
|
||||||
|
snapshots: Vec<ZfsSnapshot>,
|
||||||
|
) -> Vec<ZfsEntry> {
|
||||||
|
let mut root_entries: Vec<ZfsEntry> = Vec::new();
|
||||||
|
|
||||||
|
// First, create all entries
|
||||||
|
let mut all_entries: Vec<ZfsEntry> = datasets
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, entry_type, used, referenced, available, creation)| {
|
||||||
|
// Find snapshots for this dataset
|
||||||
|
let dataset_snaps: Vec<ZfsSnapshot> = snapshots
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.full_name.starts_with(&format!("{}@", name)))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
ZfsEntry {
|
||||||
|
name: name.clone(),
|
||||||
|
full_name: name,
|
||||||
|
entry_type,
|
||||||
|
used,
|
||||||
|
referenced,
|
||||||
|
available,
|
||||||
|
creation,
|
||||||
|
children: Vec::new(),
|
||||||
|
snapshots: dataset_snaps,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort by used (largest first)
|
||||||
|
all_entries.sort_by(|a, b| b.used.cmp(&a.used));
|
||||||
|
|
||||||
|
// Build hierarchy
|
||||||
|
// Root entries have no '/' or are pool roots
|
||||||
|
for entry in all_entries {
|
||||||
|
if !entry.name.contains('/') {
|
||||||
|
root_entries.push(entry);
|
||||||
|
} else {
|
||||||
|
// Find parent and add as child
|
||||||
|
let parent_name = entry.name.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
|
||||||
|
if let Some(parent) = find_entry_mut(&mut root_entries, parent_name) {
|
||||||
|
parent.children.push(entry);
|
||||||
|
} else {
|
||||||
|
// Orphan entry, add to root
|
||||||
|
root_entries.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort children by used
|
||||||
|
sort_children(&mut root_entries);
|
||||||
|
|
||||||
|
root_entries
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_entry_mut<'a>(entries: &'a mut [ZfsEntry], name: &str) -> Option<&'a mut ZfsEntry> {
|
||||||
|
for entry in entries.iter_mut() {
|
||||||
|
if entry.name == name {
|
||||||
|
return Some(entry);
|
||||||
|
}
|
||||||
|
if let Some(found) = find_entry_mut(&mut entry.children, name) {
|
||||||
|
return Some(found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sort_children(entries: &mut [ZfsEntry]) {
|
||||||
|
for entry in entries.iter_mut() {
|
||||||
|
entry.children.sort_by(|a, b| b.used.cmp(&a.used));
|
||||||
|
sort_children(&mut entry.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_snapshot(name: &str) -> Result<()> {
|
||||||
|
let output = Command::new("zfs")
|
||||||
|
.args(["destroy", name])
|
||||||
|
.output()
|
||||||
|
.context("Failed to run zfs destroy")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"zfs destroy failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_size(bytes: u64) -> String {
|
||||||
|
const KB: u64 = 1024;
|
||||||
|
const MB: u64 = KB * 1024;
|
||||||
|
const GB: u64 = MB * 1024;
|
||||||
|
const TB: u64 = GB * 1024;
|
||||||
|
|
||||||
|
if bytes >= TB {
|
||||||
|
format!("{:.1}T", bytes as f64 / TB as f64)
|
||||||
|
} else if bytes >= GB {
|
||||||
|
format!("{:.1}G", bytes as f64 / GB as f64)
|
||||||
|
} else if bytes >= MB {
|
||||||
|
format!("{:.1}M", bytes as f64 / MB as f64)
|
||||||
|
} else if bytes >= KB {
|
||||||
|
format!("{:.1}K", bytes as f64 / KB as f64)
|
||||||
|
} else {
|
||||||
|
format!("{}B", bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user