Skip to content

Commit 4b97bed

Browse files
First iteration
One shot prompt using *Google Antigravity* with *Gemini 3 Pro (High)* model. Prompt: # ZFS Dashboard Create a tool that parses the output of zfs commands (zfs and zpool) and shows a dashboard with the most important information. The documentation for the tools can be found executing the following commands: * man zfs list * man zpool list * man zpool status * man zpool iostat ## Features ### Pool Selection * a tab to select the pool * the tab is only visible if the system has more than one pool * it should have the `all` option with aggregated information from all pools ### Pool Information * the pool information should be updated on application start, when a pool is selected and when F5 is pressed * the state of the pool * the state of each vdev * a list of datasets * datasets are show grouped by parents in a tree view * the tree view should be collapsible * if a dataset has more than 10 children, it should be collapsed by default * the thee view is scrollable * the tree view should be searchable * the tree view should be sorted by name * the tree view should show the following columns: * name * size/quota * used * available * compression * mountpoint * when a dataset is selected, it should show the following snapshot information: * name * size This information will be retrieved from the commands `zfs list` and `zpool status`. ### Realtime Monitoring * the monitoring should be updated every 5 seconds (configurable using command line arguments) and when F5 is pressed * size/used/available pool and vdev information * displayed as a progress bar, for the pool and each vdev. * IO and latency information * displayed as a graph, for the pool and each vdev This information should be retrieved from the commands `zpool iostat`. ## Arguments * -h/--help: show usage * -i/--interval (interval in seconds): change update interval in seconds * -p/--pool (pool name): only show this pool * -d/--dataset (dataset regex): a regex filter to dataset list ## Technology The application will be a TUI (Text User Interface) application. * in Python, use uv package manager * use [Textual](https://github.com/textualize/textual) for a beautiful TUI.
0 parents  commit 4b97bed

File tree

15 files changed

+1098
-0
lines changed

15 files changed

+1098
-0
lines changed

.gitignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Python-generated files
2+
__pycache__/
3+
*.py[oc]
4+
build/
5+
dist/
6+
wheels/
7+
*.egg-info
8+
9+
# Virtual environments
10+
.venv

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.13

README.md

Whitespace-only changes.

main.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
def main():
2+
print("Hello from zfs-dashboard-python!")
3+
4+
5+
if __name__ == "__main__":
6+
main()

pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[project]
2+
name = "zfs-dashboard-python"
3+
version = "0.1.0"
4+
description = "Add your description here"
5+
readme = "README.md"
6+
requires-python = ">=3.13"
7+
dependencies = [
8+
"textual>=6.6.0",
9+
]

src/zfs_dashboard/__init__.py

Whitespace-only changes.

src/zfs_dashboard/main.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import argparse
2+
import sys
3+
from .ui.app import ZfsDashboardApp
4+
5+
def main():
6+
parser = argparse.ArgumentParser(description="ZFS Dashboard TUI")
7+
parser.add_argument("-i", "--interval", type=int, default=5, help="Update interval in seconds")
8+
parser.add_argument("-p", "--pool", type=str, help="Only show this pool")
9+
# Dataset regex filter not fully implemented in UI yet, but we can add the arg
10+
parser.add_argument("-d", "--dataset", type=str, help="Regex filter for dataset list")
11+
12+
args = parser.parse_args()
13+
14+
app = ZfsDashboardApp(
15+
interval=args.interval,
16+
pool_filter=args.pool,
17+
dataset_filter=args.dataset
18+
)
19+
app.run()
20+
21+
if __name__ == "__main__":
22+
main()

src/zfs_dashboard/models.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from dataclasses import dataclass, field
2+
from typing import List, Optional
3+
4+
@dataclass
5+
class Snapshot:
6+
name: str
7+
used: str
8+
9+
@dataclass
10+
class Dataset:
11+
name: str
12+
used: str
13+
avail: str
14+
refer: str
15+
mountpoint: str
16+
compression: str = "off"
17+
children: List['Dataset'] = field(default_factory=list)
18+
snapshots: List[Snapshot] = field(default_factory=list)
19+
20+
@dataclass
21+
class Vdev:
22+
name: str
23+
state: str
24+
read: int = 0
25+
write: int = 0
26+
cksum: int = 0
27+
# IO Stats
28+
read_ops: int = 0
29+
write_ops: int = 0
30+
read_bytes: int = 0
31+
write_bytes: int = 0
32+
type: str = "disk" # mirror, raidz, disk, etc.
33+
size: str = ""
34+
alloc: str = ""
35+
free: str = ""
36+
frag: str = ""
37+
cap: str = ""
38+
39+
@dataclass
40+
class Pool:
41+
name: str
42+
state: str
43+
size: str
44+
alloc: str
45+
free: str
46+
frag: str
47+
cap: str
48+
health: str
49+
altroot: str = "-"
50+
# IO Stats
51+
read_ops: int = 0
52+
write_ops: int = 0
53+
read_bytes: int = 0
54+
write_bytes: int = 0
55+
vdevs: List[Vdev] = field(default_factory=list)
56+
datasets: List[Dataset] = field(default_factory=list)

src/zfs_dashboard/ui/__init__.py

Whitespace-only changes.

src/zfs_dashboard/ui/app.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from textual.app import App
2+
from textual.binding import Binding
3+
4+
from ..zfs import get_system_status
5+
from .screens import DashboardScreen
6+
7+
class ZfsDashboardApp(App):
8+
CSS = """
9+
.left-pane {
10+
width: 50%;
11+
height: 100%;
12+
border-right: solid green;
13+
}
14+
.right-pane {
15+
width: 50%;
16+
height: 100%;
17+
}
18+
PoolOverview {
19+
height: auto;
20+
border-bottom: solid blue;
21+
padding: 1;
22+
}
23+
VdevList {
24+
height: 1fr;
25+
}
26+
DatasetTreeWidget {
27+
height: 60%;
28+
border-bottom: solid blue;
29+
}
30+
DatasetDetails {
31+
height: 40%;
32+
}
33+
.header {
34+
text-align: center;
35+
background: $accent;
36+
color: $text;
37+
width: 100%;
38+
}
39+
"""
40+
41+
BINDINGS = [
42+
Binding("f5", "refresh_data", "Refresh"),
43+
Binding("q", "quit", "Quit"),
44+
]
45+
46+
def __init__(self, interval: int = 5, pool_filter: str = None, dataset_filter: str = None):
47+
super().__init__()
48+
self.interval = interval
49+
self.pool_filter = pool_filter
50+
self.dataset_filter = dataset_filter
51+
self.pools = []
52+
53+
def on_mount(self):
54+
self.action_refresh_data()
55+
self.set_interval(self.interval, self.action_refresh_data)
56+
57+
def action_refresh_data(self):
58+
all_pools = get_system_status()
59+
60+
# Apply pool filter
61+
if self.pool_filter:
62+
self.pools = [p for p in all_pools if p.name == self.pool_filter]
63+
else:
64+
self.pools = all_pools
65+
66+
# Apply dataset filter (regex)
67+
# We need to filter datasets within pools.
68+
# Since datasets are a tree, if a child matches, we might need to keep parents.
69+
# Or just filter the list?
70+
# The requirement says "regex filter to dataset list".
71+
# I'll pass this filter to the UI widgets or filter here.
72+
# Filtering here is cleaner for the model.
73+
74+
if self.dataset_filter:
75+
import re
76+
try:
77+
pattern = re.compile(self.dataset_filter)
78+
for pool in self.pools:
79+
# Filter datasets. This is tricky with tree structure.
80+
# If we filter the flat list, we might break the tree if we rebuild it.
81+
# But `pool.datasets` currently contains ROOTS of the tree.
82+
# If I filter roots, I might lose children.
83+
# Actually `pool.datasets` in `models.py` are roots?
84+
# In `zfs.py`: `pool.datasets = [ds for ds in root_datasets if ds.name == pool.name]`
85+
# Yes, usually just the root dataset.
86+
87+
# So I need to traverse the tree and filter.
88+
# Or I can pass the filter to the widget.
89+
# Passing to widget is better because it can handle "match or has matching child" logic which I already implemented for search.
90+
pass
91+
except re.error:
92+
pass
93+
94+
# If screen is not mounted, mount it
95+
if not self.screen or not isinstance(self.screen, DashboardScreen):
96+
screen = DashboardScreen(self.pools)
97+
if self.dataset_filter:
98+
screen.dataset_filter = self.dataset_filter
99+
self.push_screen(screen)
100+
else:
101+
# Update existing screen
102+
screen = self.screen
103+
screen.pools = self.pools
104+
if self.dataset_filter:
105+
screen.dataset_filter = self.dataset_filter
106+
107+
if len(self.pools) > 1:
108+
screen.update_all_tab()
109+
for pool in self.pools:
110+
screen.update_pool_data(pool)

0 commit comments

Comments
 (0)