21 Commits

Author SHA1 Message Date
Natan Keddem e09f68424e improved host input sanitizing 2024-04-01 20:58:31 -04:00
Natan Keddem e5941a87ad updated python version 2024-04-01 20:58:11 -04:00
Natan Keddem 0425cdd110 improve startup and data handling 2024-03-31 20:23:12 -04:00
Natan Keddem c3ee280cd8 refactor cli and ssh 2024-03-31 20:22:57 -04:00
Natan Keddem 141c34d9b4 Merge pull request #1 from akanealw/patch-1
Update README.md
2024-03-28 20:09:02 -04:00
Natan Keddem 2813cf050e added section on file editing 2024-03-28 20:06:32 -04:00
akanealw 458cf05780 Update README.md 2024-03-28 12:29:31 -05:00
akanealw 483043bd4e Update README.md
Added manual installation instructions for Debian.
2024-03-28 12:28:18 -05:00
Natan Keddem ce898250dd updated requirements 2024-03-02 21:59:46 -05:00
Natan Keddem 5cba893282 Update README.md 2024-03-02 21:05:54 -05:00
Natan Keddem db4f340898 add default sort to tables 2023-11-24 19:21:53 -05:00
Natan Keddem f58b03a86b optimize result display 2023-11-24 19:21:06 -05:00
Natan Keddem 61f297aa0b improved job removal 2023-11-23 22:16:30 -05:00
Natan Keddem 425b607e8c cleanup 2023-11-23 22:16:03 -05:00
Natan Keddem c01989210b update to NiceGUI 1.4.3 2023-11-23 18:44:45 -05:00
Natan Keddem 0148b23310 refactored automation 2023-11-23 18:44:29 -05:00
Natan Keddem 0221780a19 optimized prop recall 2023-11-23 18:42:08 -05:00
Natan Keddem 9ea536193b cleanup for pylint 2023-11-23 17:54:27 -05:00
Natan Keddem fcd8362464 improved hold management and display 2023-11-21 19:05:14 -05:00
Natan Keddem 36ee1f94cd added arbitrary property entry 2023-11-20 21:57:10 -05:00
Natan Keddem 07ce7e0bae fixed edge case for ErrorAggregator 2023-11-20 21:56:20 -05:00
19 changed files with 586 additions and 311 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.11.6-bookworm
FROM python:3.12.2-bookworm
RUN echo "**** install runtime dependencies ****"
RUN apt update
+94 -2
View File
@@ -1,7 +1,23 @@
# bale: ZFS Snapshot Browser Based GUI
## Demo
https://github.com/natankeddem/bale/assets/44515217/53c2dc10-afbf-44a2-9546-545b06e7c565
## Host Creation
[bale_host_creation.webm](https://github.com/natankeddem/bale/assets/44515217/450afac1-ffa6-4f6f-80b4-1aeafce6a6d7)
## Manual Management Task Handling
[bale_manual_task.webm](https://github.com/natankeddem/bale/assets/44515217/d9728db9-6efa-45ed-8d07-2d925a9249b9)
## Automatic Management Task Handling
[bale_auto_task.webm](https://github.com/natankeddem/bale/assets/44515217/ab648c45-e567-4557-88f9-c11b2b412cef)
## Downloading Files From Snapshots
[bale_file_download.webm](https://github.com/natankeddem/bale/assets/44515217/7db08302-8a8b-47d4-879c-ba310f8628e4)
## Simple Automations
[bale_simple_automation.webm](https://github.com/natankeddem/bale/assets/44515217/0cd6a7da-ff11-4786-88ef-6a644ed431ff)
## ZFS-Autobackup Automations
[bale_zab_automation.webm](https://github.com/natankeddem/bale/assets/44515217/7816ae9c-695c-47f1-9d68-f0075bb8e567)
## ⚠️ **_WARNING_**
@@ -43,6 +59,82 @@ https://github.com/natankeddem/bale/assets/44515217/53c2dc10-afbf-44a2-9546-545b
ansible-playbook -i inv.yml pve-install.yml
```
### Manual install on Debian
1. Install required packages.
```bash
sudo apt update
sudo apt install -y git python3-pip python3-venv sshpass
```
2. Clone the repository.
```bash
git clone https://github.com/natankeddem/bale.git
```
3. Move to bale directory.
```bash
cd bale
```
4. Create python virtual environment.
```bash
python3 -m venv ./venv
```
5. Activate virtual environment.
```bash
source ./venv/bin/activate
```
6. Install pip3 packages from requirements.txt
```bash
pip3 install -r requirements.txt
```
7. Exit the virtual environment.
```bash
deactivate
```
8. Edit `resources/bale.service` with the actual path you are utilizing on your system.
```bash
nano resources/bale.service
```
9. Change paths in `resources/bale.service` if needed then copy to service directory and activate.
```bash
sudo cp resources/bale.service /etc/systemd/system
sudo systemctl enable bale.service
```
10. Start the service and check status.
```bash
sudo systemctl start bale.service
sudo systemctl status bale.service
```
#### Troubleshooting
If you get an error like this: `bale.service: Failed to locate executable /root/bale/venv/bin/python: No such file or directory`, modify the path in your `/etc/systemd/system/bale.service` file.
```bash
sudo nano /etc/systemd/system/bale.service
```
```bash
sudo systemctl daemon-reload
sudo systemctl start bale.service
sudo systemctl status bale.service
```
### Access GUI
Access bale by navigating to `http://host:8080`.
+4 -1
View File
@@ -34,6 +34,9 @@
"-l",
"180"
],
"editor.suggest.showStatusBar": true
"editor.suggest.showStatusBar": true,
"pylint.args": [
"\"pylint.args\": [\"--disable=C0115\", \"--disable=C0116\", \"--disable=C0301\",\"--max-line-length=180\"]"
]
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import asyncio
from nicegui import ui
from nicegui import ui # type: ignore
from bale import elements as el
import bale.logo as logo
from bale.tabs import Tab
+15 -10
View File
@@ -1,4 +1,5 @@
from nicegui import ui
from typing import Optional
from nicegui import ui # type: ignore
from bale import elements as el
from bale.tabs import Tab
from bale.interfaces import ssh
@@ -66,7 +67,7 @@ class Drawer(object):
)
self._table.tailwind.width("full")
self._table.visible = False
for name in ssh.get_hosts("data"):
for name in ssh.get_hosts():
self._add_host_to_table(name)
chevron = ui.button(icon="chevron_left", color=None, on_click=toggle_drawer).props("padding=0px")
chevron.classes("absolute")
@@ -87,7 +88,7 @@ class Drawer(object):
save = None
async def send_key():
s = ssh.Ssh("data", host=host_input.value, hostname=hostname_input.value, username=username_input.value, password=password_input.value)
s = ssh.Ssh(host_input.value, hostname=hostname_input.value, username=username_input.value, password=password_input.value)
result = await s.send_key()
if result.stdout.strip() != "":
el.notify(result.stdout.strip(), multi_line=True, type="positive")
@@ -97,8 +98,12 @@ class Drawer(object):
with ui.dialog() as host_dialog, el.Card():
with el.DBody(height="[560px]", width="[360px]"):
with el.WColumn():
host_input = el.DInput(label="Host", value=" ")
hostname_input = el.DInput(label="Hostname", value=" ")
all_hosts = list(ssh.get_hosts())
if name != "":
if name in all_hosts:
all_hosts.remove(name)
host_input = el.VInput(label="Host", value=" ", invalid_characters="""'`"$\\;&<>|(){} """, invalid_values=all_hosts, max_length=20)
hostname_input = el.VInput(label="Hostname", value=" ", invalid_characters="""!@#$%^&*'`"\\/:;<>|(){}=+[],? """)
username_input = el.DInput(label="Username", value=" ")
save_em = el.ErrorAggregator(host_input, hostname_input, username_input)
with el.Card() as c:
@@ -110,12 +115,12 @@ class Drawer(object):
c.tailwind.width("full")
with ui.scroll_area() as s:
s.tailwind.height("[160px]")
public_key = await ssh.get_public_key("data")
public_key = await ssh.get_public_key()
ui.label(public_key).classes("text-secondary break-all")
el.DButton("SAVE", on_click=lambda: host_dialog.submit("save")).bind_enabled_from(save_em, "no_errors")
host_input.value = name
if name != "":
s = ssh.Ssh(path="data", host=name)
s = ssh.Ssh(name)
hostname_input.value = s.hostname
username_input.value = s.username
@@ -125,11 +130,11 @@ class Drawer(object):
default = Tab(spinner=None).common.get("default", "")
if default == name:
Tab(spinner=None).common["default"] = ""
ssh.Ssh(path="data", host=name).remove()
ssh.Ssh(name).remove()
for row in self._table.rows:
if name == row["name"]:
self._table.remove_rows(row)
ssh.Ssh(path="data", host=host_input.value, hostname=hostname_input.value, username=username_input.value)
ssh.Ssh(host_input.value, hostname=hostname_input.value, username=username_input.value)
self._add_host_to_table(host_input.value)
def _modify_host(self, mode):
@@ -162,7 +167,7 @@ class Drawer(object):
if self._selection_mode == "remove":
if len(e.selection) > 0:
for row in e.selection:
ssh.Ssh(path="data", host=row["name"]).remove()
ssh.Ssh(row["name"]).remove()
self._table.remove_rows(row)
self._modify_host(None)
+58 -9
View File
@@ -1,11 +1,11 @@
from typing import Any, Callable, Dict, List, Literal, Optional, Union
from nicegui import ui, app, Tailwind
from nicegui.elements.spinner import SpinnerTypes
from nicegui.elements.tabs import Tab
from nicegui.tailwind_types.height import Height
from nicegui.tailwind_types.width import Width
from nicegui.elements.mixins.validation_element import ValidationElement
from nicegui.events import GenericEventArguments, handle_event
from nicegui import ui, app, Tailwind # type: ignore
from nicegui.elements.spinner import SpinnerTypes # type: ignore
from nicegui.elements.tabs import Tab # type: ignore
from nicegui.tailwind_types.height import Height # type: ignore
from nicegui.tailwind_types.width import Width # type: ignore
from nicegui.elements.mixins.validation_element import ValidationElement # type: ignore
from nicegui.events import GenericEventArguments, handle_event # type: ignore
from bale.interfaces import cli
import logging
@@ -71,8 +71,11 @@ class ErrorAggregator:
@property
def no_errors(self) -> bool:
validators = all(validation(element.value) for element in self.elements for validation in element.validation.values())
return self.enable and validators
if len(self.elements) > 0:
validators = all(validation(element.value) for element in self.elements for validation in element.validation.values())
return self.enable and validators
else:
return True
class WColumn(ui.column):
@@ -128,6 +131,52 @@ class DInput(ui.input):
self.value = ""
class VInput(ui.input):
def __init__(
self,
label: str | None = None,
*,
placeholder: str | None = None,
value: str = " ",
password: bool = False,
password_toggle_button: bool = False,
on_change: Callable[..., Any] | None = None,
autocomplete: List[str] | None = None,
invalid_characters: str = "",
invalid_values: List[str] = [],
max_length: int = 64,
check: Callable[..., Any] | None = None,
) -> None:
def checks(value: str) -> bool:
if value is None or value == "" or len(value) > max_length:
return False
for invalid_character in invalid_characters:
if invalid_character in value:
return False
for invalid_value in invalid_values:
if invalid_value == value:
return False
if check is not None:
check_status = check(value)
if check_status is not None:
return check_status
return True
super().__init__(
label,
placeholder=placeholder,
value=value,
password=password,
password_toggle_button=password_toggle_button,
on_change=on_change,
autocomplete=autocomplete,
validation={"": lambda value: checks(value)},
)
self.tailwind.width("full")
if value == " ":
self.value = ""
class FInput(ui.input):
def __init__(
self,
+24 -5
View File
@@ -4,7 +4,7 @@ from asyncio.subprocess import Process, PIPE
import contextlib
import shlex
from datetime import datetime
from nicegui import ui
from nicegui import ui # type: ignore
from bale.result import Result
import logging
@@ -116,30 +116,49 @@ class Cli:
self._terminate.clear()
self._busy = False
return Result(
command=command, return_code=process.returncode, stdout_lines=self.stdout.copy(), stderr_lines=self.stderr.copy(), terminated=terminated, truncated=self._truncated
command=command,
return_code=process.returncode,
stdout_lines=self.stdout.copy(),
stderr_lines=self.stderr.copy(),
terminated=terminated,
truncated=self._truncated,
)
async def shell(self, command: str) -> Result:
async def shell(self, command: str, max_output_lines: int = 0) -> Result:
self._busy = True
try:
process = await asyncio.create_subprocess_shell(command, stdout=PIPE, stderr=PIPE)
if process is not None and process.stdout is not None and process.stderr is not None:
self.clear_buffers()
self.stdout.clear()
self.stderr.clear()
self._terminate.clear()
self._truncated = False
terminated = False
now = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
self.prefix_line = f"<{now}> {command}\n"
for terminal in self._stdout_terminals:
terminal.call_terminal_method("write", "\n" + self.prefix_line)
await asyncio.gather(
self._controller(process=process, max_output_lines=max_output_lines),
self._read_stdout(stream=process.stdout),
self._read_stderr(stream=process.stderr),
)
if self._terminate.is_set():
terminated = True
await process.wait()
except Exception as e:
raise e
finally:
self._terminate.clear()
self._busy = False
return Result(command=command, return_code=process.returncode, stdout_lines=self.stdout.copy(), stderr_lines=self.stderr.copy(), terminated=False)
return Result(
command=command,
return_code=process.returncode,
stdout_lines=self.stdout.copy(),
stderr_lines=self.stderr.copy(),
terminated=terminated,
truncated=self._truncated,
)
def clear_buffers(self):
self.prefix_line = ""
+40 -22
View File
@@ -1,12 +1,10 @@
from typing import Dict, Union
from typing import Dict, Optional, Union
import os
import asyncio
from pathlib import Path
from bale.result import Result
from bale.interfaces.cli import Cli
from bale.interfaces import cli
def get_hosts(path):
def get_hosts(path: str = "data"):
path = f"{Path(path).resolve()}/config"
hosts = []
try:
@@ -20,32 +18,42 @@ def get_hosts(path):
return []
async def get_public_key(path: str) -> str:
async def get_public_key(path: str = "data") -> str:
path = Path(path).resolve()
if "id_rsa.pub" not in os.listdir(path) or "id_rsa" not in os.listdir(path):
await Cli().shell(f"""ssh-keygen -t rsa -N "" -f {path}/id_rsa""")
await cli.Cli().shell(f"""ssh-keygen -t rsa -N "" -f {path}/id_rsa""")
with open(f"{path}/id_rsa.pub", "r", encoding="utf-8") as reader:
return reader.read()
class Ssh(Cli):
def __init__(self, path: str, host: str, hostname: str = "", username: str = "", password: Union[str, None] = None, seperator: bytes = b"\n") -> None:
class Ssh(cli.Cli):
def __init__(
self,
host: str,
hostname: str = "",
username: str = "",
password: Optional[str] = None,
options: Optional[Dict[str, str]] = None,
path: str = "data",
seperator: bytes = b"\n",
) -> None:
super().__init__(seperator=seperator)
self._raw_path: str = path
self._path: Path = Path(path).resolve()
self.host: str = host
self.host: str = host.replace(" ", "")
self.password: Union[str, None] = password
self.use_key: bool = False
if password is None:
self.use_key = True
self.options: Optional[Dict[str, str]] = options
self.key_path: str = f"{self._path}/id_rsa"
self._base_cmd: str = ""
self._full_cmd: str = ""
self._base_command: str = ""
self._full_command: str = ""
self._config_path: str = f"{self._path}/config"
self._config: Dict[str, Dict[str, str]] = {}
self.read_config()
self.hostname: str = hostname or self._config.get(host, {}).get("HostName", "")
self.username: str = username or self._config.get(host, {}).get("User", "")
self.hostname: str = hostname or self._config.get(host.replace(" ", ""), {}).get("HostName", "")
self.username: str = username or self._config.get(host.replace(" ", ""), {}).get("User", "")
self.set_config()
def read_config(self) -> None:
@@ -57,7 +65,7 @@ class Ssh(Cli):
if line == "" or line.startswith("#"):
continue
if line.startswith("Host "):
current_host = line.split(" ")[1].strip()
current_host = line.split(" ", 1)[1].strip().replace('"', "")
self._config[current_host] = {}
else:
key, value = line.split(" ", 1)
@@ -76,30 +84,40 @@ class Ssh(Cli):
def set_config(self) -> None:
self._config[self.host] = {
"IdentityFile": self.key_path,
"PasswordAuthentication": "no",
"StrictHostKeychecking": "no",
"IdentitiesOnly": "yes",
}
self._config[self.host]["PasswordAuthentication"] = "no" if self.password is None else "yes"
if self.hostname != "":
self._config[self.host]["HostName"] = self.hostname
if self.username != "":
self._config[self.host]["User"] = self.username
if self.options is not None:
self._config[self.host].update(self.options)
self.write_config()
def remove(self) -> None:
del self._config[self.host]
self.write_config()
async def execute(self, command: str, max_output_lines: int = 0) -> Result:
self._base_cmd = f"{'' if self.use_key else f'sshpass -p {self.password} '} ssh -F {self._config_path} {self.host}"
self._full_cmd = f"{self._base_cmd} {command}"
return await super().execute(self._full_cmd, max_output_lines)
async def execute(self, command: str, max_output_lines: int = 0) -> cli.Result:
self._full_command = f"{self.base_command} {command}"
return await super().execute(self._full_command, max_output_lines)
async def send_key(self) -> Result:
async def shell(self, command: str, max_output_lines: int = 0) -> cli.Result:
self._full_command = f"{self.base_command} {command}"
return await super().shell(self._full_command, max_output_lines)
async def send_key(self) -> cli.Result:
await get_public_key(self._raw_path)
cmd = f"sshpass -p {self.password} " f"ssh-copy-id -o IdentitiesOnly=yes -i {self.key_path} " f"-o StrictHostKeychecking=no {self.username}@{self.hostname}"
return await super().execute(cmd)
return await super().shell(cmd)
@property
def config_path(self):
return self._config_path
@property
def base_command(self):
self._base_command = f'{"" if self.use_key else f"sshpass -p {self.password} "} ssh -F {self._config_path} {self.host}'
return self._base_command
+1 -1
View File
@@ -3,7 +3,7 @@ from pathlib import Path
import stat
from datetime import datetime
import uuid
from nicegui import app, background_tasks, events, ui
from nicegui import app, background_tasks, events, ui # type: ignore
from fastapi.responses import StreamingResponse
import asyncssh
from bale import elements as el
+25 -11
View File
@@ -1,4 +1,4 @@
from typing import Any, Dict, Union
from typing import Any, Dict, Optional, Union
import re
from datetime import datetime
from dataclasses import dataclass
@@ -115,16 +115,15 @@ class Zfs:
return result
async def filesystems_with_prop(self, prop: str) -> Result:
result = await self.execute(f"zfs get -Hp -t filesystem,volume {prop}")
filesystems = []
result = await self.execute(f"zfs get -Hp -t filesystem,volume {prop}")
for line in result.stdout_lines:
matches = re.match("^(?P<name>[^\t]+)\t(?P<property>[^\t]+)\t(?P<value>[^\t]+)\t(?P<source>[^\n]+)", line)
if matches is not None:
md = matches.groupdict()
if md["property"] == prop and md["source"] == "local":
filesystems.append(md["name"])
result = Result(data=filesystems, cached=False)
return result
return Result(data=filesystems, cached=False)
async def holds_for_snapshot(self, snapshot: Union[str, None] = None) -> Result:
query = "holds_for_snapshot"
@@ -137,7 +136,7 @@ class Zfs:
with_holds.append(_name)
with_holds = " ".join(with_holds)
else:
with_holds = [snapshot]
with_holds = snapshot
if len(with_holds) > 0:
result = await self.execute(f"zfs holds -H -r {with_holds}", notify=False)
tags: Dict[str, list[str]] = {}
@@ -149,11 +148,16 @@ class Zfs:
if s not in tags:
tags[s] = []
tags[s].append(md["tag"])
self._last_data[query] = tags
if snapshot in self._last_data[query]:
result.data = self._last_data[query][snapshot]
if query not in self._last_data:
self._last_data[query] = {}
self._last_data[query].update(tags)
if snapshot is None:
result.data = self._last_data[query]
else:
result.data = []
if snapshot in self._last_data[query]:
result.data = self._last_data[query][snapshot]
else:
result.data = []
else:
return Result(data=[])
else:
@@ -227,6 +231,7 @@ class Zfs:
md["creation_date"] = datetime.fromtimestamp(md["creation"]).strftime("%Y/%m/%d")
md["creation_time"] = datetime.fromtimestamp(md["creation"]).strftime("%H:%M")
md["used"] = format_bytes(md["used_bytes"])
md["userrefs"] = int(md["userrefs"])
snapshot = f"{md['filesystem']}@{md['name']}"
snapshots[snapshot] = md
self._last_data[query] = snapshots
@@ -238,8 +243,17 @@ class Zfs:
class Ssh(ssh.Ssh, Zfs):
def __init__(self, path: str, host: str, hostname: str = "", username: str = "", password: Union[str, None] = None) -> None:
super().__init__(path, host, hostname, username, password)
def __init__(
self,
host: str,
hostname: str = "",
username: str = "",
password: Optional[str] = None,
options: Optional[Dict[str, str]] = None,
path: str = "data",
seperator: bytes = b"\n",
) -> None:
super().__init__(host, hostname, username, password, options, path, seperator)
Zfs.__init__(self)
def notify(self, command: str):
+1 -1
View File
@@ -1,4 +1,4 @@
from nicegui import ui
from nicegui import ui # type: ignore
import logging
logger = logging.getLogger(__name__)
+1 -1
View File
@@ -1,5 +1,5 @@
import asyncio
from nicegui import app, Client, ui
from nicegui import app, Client, ui # type: ignore
from bale import elements as el
from bale.drawer import Drawer
from bale.content import Content
+20 -16
View File
@@ -5,22 +5,23 @@ from pathlib import Path
from functools import cache
from datetime import datetime
import time
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore
@dataclass(kw_only=True)
class Automation:
id: str
app: str
hosts: List[str]
host: str
command: str
schedule_mode: str
triggers: Dict[str, str]
options: Union[Dict[str, Any], None] = None
id: str = ""
name: str = ""
app: str = "remote"
hosts: List[str] = field(default_factory=list)
host: str = ""
command: str = ""
schedule_mode: str = ""
triggers: Dict[str, str] = field(default_factory=dict)
options: Dict[str, Any] = field(default_factory=dict)
pipe_success: bool = False
pipe_error: bool = False
timestamp: float = field(default_factory=time.time)
pipe_success: bool
pipe_error: bool
def to_dict(self) -> Dict[str, Any]:
return self.__dict__
@@ -29,11 +30,14 @@ class Automation:
@dataclass(kw_only=True)
class Zfs_Autobackup(Automation):
app: str = "zfs_autobackup"
execute_mode: str = "local"
target_host: str
target_path: str
target_paths: List[str]
filesystems: Dict[str, Union[str, List[str], Dict[str, str]]]
prop: str = "autobackup:{name}"
target_host: str = ""
target_path: str = ""
target_paths: List[str] = field(default_factory=list)
parentchildren: List[str] = field(default_factory=list)
parent: List[str] = field(default_factory=list)
children: List[str] = field(default_factory=list)
exclude: List[str] = field(default_factory=list)
class _Scheduler:
+10 -14
View File
@@ -6,7 +6,7 @@ from datetime import datetime
import time
import json
import httpx
from nicegui import app, ui
from nicegui import app, ui # type: ignore
from bale.interfaces.zfs import Ssh
from bale import elements as el
from bale.result import Result
@@ -83,7 +83,7 @@ class Tab:
@classmethod
def register_connection(cls, host: str) -> None:
cls._zfs[host] = Ssh(path="data", host=host)
cls._zfs[host] = Ssh(host)
async def _display_result(self, result: Result) -> None:
with ui.dialog() as dialog, el.Card():
@@ -91,20 +91,16 @@ class Tab:
with el.WColumn():
with el.Card() as card:
card.tailwind.width("full")
with el.WColumn():
ui.label(f"#> {result.command}").classes("text-secondary")
with el.WRow() as row:
row.tailwind.justify_content("around")
with ui.column() as col:
col.tailwind.max_width("lg")
ui.label(f"Host Name: {result.name}").classes("text-secondary")
ui.label(f"Command: {result.command}").classes("text-secondary")
timestamp = await ui.run_javascript(
f"new Date({result.timestamp} * 1000).toLocaleString(undefined, {{dateStyle: 'short', timeStyle: 'short', hour12: 'false'}});"
)
ui.label(f"Timestamp: {timestamp}").classes("text-secondary")
with ui.column() as col:
col.tailwind.max_width("lg")
ui.label(f"Task has failed: {result.failed}").classes("text-secondary")
ui.label(f"Data is cached: {result.cached}").classes("text-secondary")
ui.label(f"Host: {result.name}").classes("text-secondary")
timestamp = await ui.run_javascript(
f"new Date({result.timestamp} * 1000).toLocaleString(undefined, {{dateStyle: 'short', timeStyle: 'short', hour12: 'false'}});"
)
ui.label(f"Timestamp: {timestamp}").classes("text-secondary")
ui.label(f"Return Code: {result.return_code}").classes("text-secondary")
with el.Card() as card:
with el.WColumn():
terminal = cli.Terminal(options={"rows": 18, "cols": 120, "convertEol": True})
+183 -179
View File
@@ -1,14 +1,17 @@
from typing import Any, Dict, List, Union
from typing import Any, Callable, Dict, List, Union
import asyncio
from datetime import datetime
import json
import string
from apscheduler.triggers.combining import AndTrigger
from apscheduler.triggers.combining import OrTrigger
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.job import Job # type: ignore
from apscheduler.triggers.combining import AndTrigger # type: ignore
from apscheduler.triggers.combining import OrTrigger # type: ignore
from apscheduler.triggers.cron import CronTrigger # type: ignore
from apscheduler.triggers.interval import IntervalTrigger # type: ignore
from cron_validator import CronValidator # type: ignore
from cron_descriptor import get_description # type: ignore
from nicegui import ui, Tailwind, events # type: ignore
from . import SelectionConfirm, Tab
from nicegui import ui, Tailwind, events
from bale import elements as el
from bale.result import Result
from bale.interfaces import cli
@@ -16,8 +19,7 @@ from bale.interfaces import ssh
from bale.interfaces import zfs
from bale.apps import zab
from bale import scheduler
from cron_validator import CronValidator
from cron_descriptor import get_description
import logging
@@ -26,61 +28,74 @@ logger = logging.getLogger(__name__)
job_handlers: Dict[str, Union[cli.Cli, ssh.Ssh]] = {}
def automation(raw: Union[str, Job]) -> Union[scheduler.Automation, scheduler.Zfs_Autobackup, None]:
json_data = json.dumps({})
if isinstance(raw, str):
json_data = raw
elif isinstance(raw, Job):
if "data" in raw.kwargs:
json_data = raw.kwargs["data"]
else:
return None
raw_data = json.loads(json_data)
if raw_data["app"] == "zfs_autobackup":
return scheduler.Zfs_Autobackup(**raw_data)
else:
return scheduler.Automation(**raw_data)
def populate_job_handler(app: str, job_id: str, host: str):
tab = Tab(host=None, spinner=None)
if job_id not in job_handlers:
if app == "remote":
job_handlers[job_id] = ssh.Ssh("data", host=host)
job_handlers[job_id] = ssh.Ssh(host)
else:
job_handlers[job_id] = cli.Cli()
return job_handlers[job_id]
class CommandTemplate(string.Template):
class AutomationTemplate(string.Template):
delimiter = ""
async def automation_job(**kwargs) -> None:
if "data" in kwargs:
jd = json.loads(kwargs["data"])
command = CommandTemplate(jd["command"])
auto = automation(kwargs["data"])
if auto is not None:
command = AutomationTemplate(auto.command)
tab = Tab(host=None, spinner=None)
if jd["app"] == "zfs_autobackup":
d = scheduler.Zfs_Autobackup(**jd)
populate_job_handler(app=d.app, job_id=d.id, host=d.host)
if job_handlers[d.id].is_busy is False:
result = await job_handlers[d.id].execute(command.safe_substitute(host=d.host))
result.name = d.host
if auto.app == "zfs_autobackup":
populate_job_handler(app=auto.app, job_id=auto.id, host=auto.host)
if job_handlers[auto.id].is_busy is False:
result = await job_handlers[auto.id].execute(command.safe_substitute(name=auto.name, host=auto.host))
result.name = auto.host
result.status = "success" if result.return_code == 0 else "error"
if d.pipe_success is True and result.status == "success":
if auto.pipe_success is True and result.status == "success":
tab.pipe_result(result=result)
if d.pipe_error is True and result.status != "success":
if auto.pipe_error is True and result.status != "success":
tab.pipe_result(result=result)
tab.add_history(result=result)
else:
logger.warning("Job Skipped!")
elif jd["app"] == "remote":
d = scheduler.Automation(**jd)
populate_job_handler(app=d.app, job_id=d.id, host=d.host)
if job_handlers[d.id].is_busy is False:
result = await job_handlers[d.id].execute(command.safe_substitute(host=d.host))
result.name = d.host
if d.pipe_success is True and result.status == "success":
elif auto.app == "remote":
populate_job_handler(app=auto.app, job_id=auto.id, host=auto.host)
if job_handlers[auto.id].is_busy is False:
result = await job_handlers[auto.id].execute(command.safe_substitute(name=auto.name, host=auto.host))
result.name = auto.host
if auto.pipe_success is True and result.status == "success":
tab.pipe_result(result=result)
if d.pipe_error is True and result.status != "success":
if auto.pipe_error is True and result.status != "success":
tab.pipe_result(result=result)
tab.add_history(result=result)
else:
logger.warning("Job Skipped!")
elif jd["app"] == "local":
d = scheduler.Automation(**jd)
populate_job_handler(app=d.app, job_id=d.id, host=d.host)
if job_handlers[d.id].is_busy is False:
result = await job_handlers[d.id].execute(command.safe_substitute(host=d.host))
result.name = d.host
if d.pipe_success is True and result.status == "success":
elif auto.app == "local":
populate_job_handler(app=auto.app, job_id=auto.id, host=auto.host)
if job_handlers[auto.id].is_busy is False:
result = await job_handlers[auto.id].execute(command.safe_substitute(name=auto.name, host=auto.host))
result.name = auto.host
if auto.pipe_success is True and result.status == "success":
tab.pipe_result(result=result)
if d.pipe_error is True and result.status != "success":
if auto.pipe_error is True and result.status != "success":
tab.pipe_result(result=result)
tab.add_history(result=result)
else:
@@ -96,10 +111,10 @@ class Automation(Tab):
self.picked_options: Dict[str, str] = {}
self.triggers: Dict[str, str] = {}
self.picked_triggers: Dict[str, str] = {}
self.job_data: Dict[str, str] = {}
self.auto: Union[scheduler.Automation, scheduler.Zfs_Autobackup]
self.job_names: List[str] = []
self.default_options: Dict[str, str] = {}
self.build_command: str = ""
self.build_command: Callable
self.target_host: el.DSelect
self.target_paths: List[str] = [""]
self.target_path: el.DSelect
@@ -122,6 +137,11 @@ class Automation(Tab):
self.triggers_scroll: ui.scroll_area
self.trigger_controls: Dict[str, str] = {}
self.hosts: el.DSelect
self.prop: el.DInput
self.parentchildren: el.DSelect
self.parent: el.DSelect
self.children: el.DSelect
self.exclude: el.DSelect
super().__init__(spinner, host)
def _build(self) -> None:
@@ -134,7 +154,6 @@ class Automation(Tab):
el.SmButton("Create", on_click=self._create_automation)
el.SmButton("Remove", on_click=self._remove_automation)
el.SmButton("Edit", on_click=self._edit_automation)
# el.SmButton("Duplicate", on_click=self._duplicate_automation)
el.SmButton("Run Now", on_click=self._run_automation)
with ui.row().classes("items-center"):
el.SmButton(text="Refresh", on_click=self._update_automations)
@@ -170,6 +189,7 @@ class Automation(Tab):
var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});;
return date;
}""",
"sort": "asc",
},
{
"headerName": "Status",
@@ -194,11 +214,10 @@ class Automation(Tab):
job_id = f"{job_data.args['data']['name']}@{self.host}"
for job in self.scheduler.scheduler.get_jobs():
if job.id == job_id:
if "data" in job.kwargs:
jd = json.loads(job.kwargs["data"])
populate_job_handler(app=jd["app"], job_id=job.id, host=self.host)
break
auto = automation(job)
if auto is not None and auto.id == job_id:
populate_job_handler(app=auto.app, job_id=auto.id, host=self.host)
break
async def run():
for job in self.scheduler.scheduler.get_jobs():
@@ -238,17 +257,9 @@ class Automation(Tab):
next_run = job.next_run_time.timestamp()
else:
next_run = "NA"
if "data" in job.kwargs:
jd = json.loads(job.kwargs["data"])
if self.host == jd["host"]:
self._automations.append(
{
"name": job.id.split("@")[0],
"command": jd["command"],
"next_run": next_run,
"status": "",
}
)
auto = automation(job)
if auto is not None and auto.host == self.host:
self._automations.append({"name": auto.name, "command": auto.command, "next_run": next_run, "status": ""})
self._grid.update()
async def _remove_automation(self) -> None:
@@ -258,8 +269,15 @@ class Automation(Tab):
rows = await self._grid.get_selected_rows()
for row in rows:
for job in self.scheduler.scheduler.get_jobs():
j = job.id.split("@")[0]
if j == row["name"]:
auto = automation(job)
if auto is not None and auto.name == row["name"]:
if job.id in job_handlers:
del job_handlers[job.id]
if isinstance(auto, scheduler.Zfs_Autobackup):
for host in auto.hosts:
command = AutomationTemplate(auto.prop)
prop = command.safe_substitute(name=auto.name, host=host)
await self._remove_prop_from_all_fs(host=host, prop=prop)
self.scheduler.scheduler.remove_job(job.id)
self._automations.remove(row)
self._grid.update()
@@ -277,29 +295,6 @@ class Automation(Tab):
job.modify(next_run_time=datetime.now())
self._set_selection()
async def _duplicate_automation(self) -> None:
rows = await self._grid.get_selected_rows()
if len(rows) == 1:
with ui.dialog() as dialog, el.Card():
with el.DBody():
with el.WColumn():
host = el.DSelect(self._zfs_hosts, value=self.host, label="Host", with_input=True)
with el.WRow():
el.DButton("Duplicate", on_click=lambda: dialog.submit("duplicate"))
result = await dialog
if result == "confirm":
for job in self.scheduler.scheduler.get_jobs():
if job.id == rows[0]["name"]:
self.scheduler.scheduler.add_job(
automation_job,
trigger=build_triggers(),
kwargs={"data": json.dumps(auto.to_dict())},
id=self.auto_name.value.lower(),
coalesce=True,
max_instances=1,
replace_existing=True,
)
async def _edit_automation(self) -> None:
self._set_selection(mode="single")
result = await SelectionConfirm(container=self._confirm, label=">EDIT<")
@@ -308,26 +303,17 @@ class Automation(Tab):
await self._create_automation(rows[0]["name"])
self._set_selection()
async def _add_prop_to_fs(
self,
host: str,
prop: str,
value: str,
module: str = "autobackup",
filesystems: Union[List[str], None] = None,
) -> None:
async def _add_prop_to_fs(self, host: str, prop: str, value: str, filesystems: Union[List[str], None] = None) -> None:
if filesystems is not None:
full_prop = f"{module}:{prop}"
for fs in filesystems:
result = await self._zfs[host].add_filesystem_prop(filesystem=fs, prop=full_prop, value=value)
result = await self._zfs[host].add_filesystem_prop(filesystem=fs, prop=prop, value=value)
self.add_history(result=result)
async def _remove_prop_from_all_fs(self, host: str, prop: str, module: str = "autobackup") -> None:
full_prop = f"{module}:{prop}"
filesystems_with_prop_result = await self._zfs[host].filesystems_with_prop(full_prop)
async def _remove_prop_from_all_fs(self, host: str, prop: str) -> None:
filesystems_with_prop_result = await self._zfs[host].filesystems_with_prop(prop)
filesystems_with_prop = list(filesystems_with_prop_result.data)
for fs in filesystems_with_prop:
result = await self._zfs[host].remove_filesystem_prop(filesystem=fs, prop=full_prop)
result = await self._zfs[host].remove_filesystem_prop(filesystem=fs, prop=prop)
self.add_history(result=result)
async def _create_automation(self, name: str = "") -> None:
@@ -336,16 +322,16 @@ class Automation(Tab):
self.picked_options = {}
self.triggers = {}
self.picked_triggers = {}
self.job_data = {}
jobs = self.scheduler.scheduler.get_jobs()
self.job_names = []
self.auto = scheduler.Automation(host=self.host, hosts=[self.host])
job = None
for job in jobs:
j = job.id.split("@")[0]
self.job_names.append(j)
if name == j:
job = self.scheduler.scheduler.get_job(job.id)
self.job_data.update(json.loads(job.kwargs["data"]))
auto = automation(job)
if auto is not None:
self.job_names.append(auto.name)
if auto.name == name:
self.auto = auto
def validate_name(n: str):
if len(n) > 0 and n.islower() and "@" not in n and (n not in self.job_names or name != ""):
@@ -388,18 +374,15 @@ class Automation(Tab):
def option_changed(e):
self.current_help.text = self.options[e.value]["description"]
async def zab_controls() -> None:
async def zab_controls(auto: scheduler.Zfs_Autobackup) -> None:
filesystems = await self.zfs.filesystems
if isinstance(self.job_data.get("filesystems", {}), dict):
self.fs = self.job_data.get(
"filesystems",
{"all": {}, "values": {}, "parent": [], "children": [], "parentchildren": [], "exclude": []},
)
else:
self.fs = {"all": {}, "values": {}, "parent": [], "children": [], "parentchildren": [], "exclude": []}
if not self.fs["all"]:
for fs in filesystems.data:
self.fs["all"][fs] = ""
parent: List[str] = []
children: List[str] = []
parentchildren: List[str] = []
exclude: List[str] = []
all_fs: Dict[str, str] = {}
for fs in filesystems.data:
all_fs[fs] = ""
async def target_host_selected() -> None:
if self.target_host.value != "":
@@ -421,36 +404,37 @@ class Automation(Tab):
self.target_path.update()
self.target_path.value = ""
async def target_path_selected() -> None:
self.build_command()
def build_command() -> None:
try:
prop_suffix = self.prop.value.split(":")[1]
except IndexError:
prop_suffix = ""
base = ""
for key, value in self.picked_options.items():
base = base + f" --{key}{f' {value}' if value != '' else ''}"
target_path = f"{f' {self.target_path.value}' if self.target_path.value != '' else ''}"
base = base + f" {self.auto_name.value.lower()}" + target_path
base = base + f" {prop_suffix}" + target_path
self.command.value = base
def all_fs_to_lists():
self.fs["parentchildren"].clear()
self.fs["parent"].clear()
self.fs["children"].clear()
self.fs["exclude"].clear()
for fs, v in self.fs["all"].items():
parentchildren.clear()
parent.clear()
children.clear()
exclude.clear()
for fs, v in all_fs.items():
if v == "":
self.fs["parentchildren"].append(fs)
self.fs["parent"].append(fs)
self.fs["children"].append(fs)
self.fs["exclude"].append(fs)
parentchildren.append(fs)
parent.append(fs)
children.append(fs)
exclude.append(fs)
elif v == "true":
self.fs["parentchildren"].append(fs)
parentchildren.append(fs)
elif v == "parent":
self.fs["parent"].append(fs)
parent.append(fs)
elif v == "child":
self.fs["children"].append(fs)
children.append(fs)
elif v == "false":
self.fs["exclude"].append(fs)
exclude.append(fs)
def cull_fs_list(e: events.GenericEventArguments, value: str = "false") -> None:
if e.sender != self.parentchildren:
@@ -461,11 +445,11 @@ class Automation(Tab):
self.children.disable()
if e.sender != self.exclude:
self.exclude.disable()
for fs, v in self.fs["all"].items():
for fs, v in all_fs.items():
if v == value:
self.fs["all"][fs] = ""
all_fs[fs] = ""
for fs in e.sender.value:
self.fs["all"][fs] = value
all_fs[fs] = value
all_fs_to_lists()
self.parentchildren.enable()
self.parent.enable()
@@ -476,6 +460,17 @@ class Automation(Tab):
self.children.update()
self.exclude.update()
def validate_prop(value):
parts = value.split(":")
for part in parts:
if part.find(" ") != -1:
return False
if len(part) < 1:
return False
if len(parts) != 2:
return False
return True
if name == "":
self.default_options = {
"verbose": "",
@@ -484,7 +479,7 @@ class Automation(Tab):
"ssh-config": self.zfs.config_path,
}
else:
self.default_options = self.job_data["options"]
self.default_options = auto.options
self.options = zab.options
self.build_command = build_command
filesystems = await self.zfs.filesystems
@@ -496,35 +491,37 @@ class Automation(Tab):
row.tailwind.width("[860px]").justify_content("center")
with ui.column() as col:
col.tailwind.height("full").width("[420px]")
self.prop = el.DInput(label="Property", value=auto.prop, on_change=build_command, validation=validate_prop)
self.app_em.append(self.prop)
self.target_host = el.DSelect(target_host, label="Target Host", on_change=target_host_selected)
self.target_paths = [""]
self.target_path = el.DSelect(self.target_paths, value="", label="Target Path", new_value_mode="add-unique", on_change=target_path_selected)
self.hosts = el.DSelect(source_hosts, label="Source Host(s)", multiple=True, with_input=True)
self.target_path = el.DSelect(self.target_paths, value="", label="Target Path", new_value_mode="add-unique", on_change=build_command)
self.hosts = el.DSelect(source_hosts, label="Source Host(s)", value=auto.hosts, multiple=True, with_input=True)
all_fs_to_lists()
with ui.scroll_area().classes("col"):
self.parentchildren = el.DSelect(
self.fs["parentchildren"],
parentchildren,
label="Source Parent And Children",
with_input=True,
multiple=True,
on_change=lambda e: cull_fs_list(e, "true"),
)
self.parent = el.DSelect(
self.fs["parent"],
parent,
label="Source Parent Only",
with_input=True,
multiple=True,
on_change=lambda e: cull_fs_list(e, "parent"),
)
self.children = el.DSelect(
self.fs["children"],
children,
label="Source Children Only",
with_input=True,
multiple=True,
on_change=lambda e: cull_fs_list(e, "child"),
)
self.exclude = el.DSelect(
self.fs["exclude"],
exclude,
label="Exclude",
with_input=True,
multiple=True,
@@ -533,9 +530,14 @@ class Automation(Tab):
with ui.column() as col:
col.tailwind.height("full").width("[420px]")
options_controls()
self.parentchildren.value = auto.parentchildren
self.parent.value = auto.parent
self.children.value = auto.children
self.exclude.value = auto.exclude
self.previous_prop = auto.prop
if name != "":
self.target_host.value = self.job_data.get("target_host", "")
target_path = self.job_data.get("target_path", "")
self.target_host.value = auto.target_host
target_path = auto.target_path
tries = 0
while target_path not in self.target_path.options and tries < 20:
await asyncio.sleep(0.1)
@@ -543,11 +545,6 @@ class Automation(Tab):
if target_path not in self.target_paths:
self.target_paths.append(target_path)
self.target_path.value = target_path
self.parentchildren.value = self.fs["values"].get("parentchildren", None)
self.parent.value = self.fs["values"].get("parent", None)
self.children.value = self.fs["values"].get("children", None)
self.exclude.value = self.fs["values"].get("exclude", None)
self.hosts.value = self.job_data.get("hosts", [self.host])
else:
self.hosts.value = [self.host]
@@ -651,7 +648,7 @@ class Automation(Tab):
if name == "":
self.default_triggers = {"id": {"type": "Cron", "value": ""}}
else:
self.default_triggers = self.job_data["triggers"]
self.default_triggers = self.auto.triggers
with ui.row() as row:
row.tailwind(tw_rows)
self.current_trigger = el.FSelect(["Cron", "Interval"], value="Cron", label="Trigger", with_input=True)
@@ -675,7 +672,10 @@ class Automation(Tab):
if self.app.value is not None:
with options_col:
if self.app.value == "zfs_autobackup":
await zab_controls()
if isinstance(self.auto, scheduler.Zfs_Autobackup):
await zab_controls(self.auto)
else:
await zab_controls(scheduler.Zfs_Autobackup(host=self.host, hosts=[self.host]))
if self.app.value == "local":
local_controls()
if self.app.value == "remote":
@@ -684,20 +684,15 @@ class Automation(Tab):
self.stepper.next()
def local_controls():
command_input = el.DInput("Command").bind_value_to(self.command, "value")
if name != "":
command_input.value = self.job_data["command"]
el.DInput("Command", value=self.auto.command).bind_value_to(self.command, "value")
def remote_controls():
command_input = el.DInput("Command").bind_value_to(self.command, "value")
self.hosts = el.DSelect(self._zfs_hosts, value=self.host, label="Hosts", with_input=True, multiple=True)
command_input = el.DInput("Command", value=self.auto.command).bind_value_to(self.command, "value")
self.hosts = el.DSelect(self._zfs_hosts, value=self.auto.hosts, label="Hosts", with_input=True, multiple=True)
self.save.bind_enabled_from(self.hosts, "value", backward=lambda x: len(x) > 0)
if name != "":
command_input.value = self.job_data["command"]
self.hosts.value = self.job_data["hosts"]
def string_to_interval(string: str):
interval = string.split(":", 4)
def to_interval(value: str):
interval = value.split(":", 4)
interval = interval + ["0"] * (5 - len(interval))
return IntervalTrigger(weeks=int(interval[0]), days=int(interval[1]), hours=int(interval[2]), minutes=int(interval[3]), seconds=int(interval[4]))
@@ -708,7 +703,7 @@ class Automation(Tab):
if "Cron" == value["type"]:
triggers.append(CronTrigger().from_crontab(value["value"]))
elif "Interval" == value["type"]:
triggers.append(string_to_interval(value["value"]))
triggers.append(to_interval(value["value"]))
return combine(triggers)
def validate_hosts(e):
@@ -728,11 +723,11 @@ class Automation(Tab):
col.tailwind.height("full").width("[420px]")
self.auto_name = el.DInput(label="Name", value=" ", validation=validate_name)
with el.WRow():
self.pipe_success = el.DCheckbox("Pipe Success", value=self.job_data.get("pipe_success", False))
self.pipe_error = el.DCheckbox("Pipe Error", value=self.job_data.get("pipe_error", False))
self.pipe_success = el.DCheckbox("Pipe Success", value=self.auto.pipe_success)
self.pipe_error = el.DCheckbox("Pipe Error", value=self.auto.pipe_error)
self.schedule_em = el.ErrorAggregator(self.auto_name)
if name != "":
self.app = el.DInput(label="Application", value=self.job_data["app"]).props("readonly")
self.app = el.DInput(label="Application", value=self.auto.app).props("readonly")
else:
self.app = el.DSelect(
["zfs_autobackup", "local", "remote"],
@@ -758,15 +753,16 @@ class Automation(Tab):
with el.WRow() as row:
row.tailwind.height("[40px]")
self.as_spinner = el.Spinner()
self.app_em = el.ErrorAggregator()
self.save = el.DButton("SAVE", on_click=lambda: automation_dialog.submit("save"))
self.save.bind_enabled_from(self.app_em, "no_errors")
el.Spinner(master=self.as_spinner)
self.auto_name.value = name
if name != "":
self.auto_name.props("readonly")
self.schedule_mode.value = self.job_data["schedule_mode"]
self.schedule_mode.value = self.auto.schedule_mode
result = await automation_dialog
if result == "save":
auto: Union[scheduler.Automation, scheduler.Zfs_Autobackup]
auto_name = self.auto_name.value.lower()
if hasattr(self, "hosts"):
hosts = self.hosts.value
@@ -774,23 +770,25 @@ class Automation(Tab):
hosts = [self.host]
if self.app.value == "zfs_autobackup":
for job in jobs:
j = job.id.split("@")[0]
if j == auto_name:
existing_auto = automation(job)
if existing_auto is not None and existing_auto.name == auto_name:
self.scheduler.scheduler.remove_job(job.id)
for host in hosts:
auto_id = f"{auto_name}@{host}"
await self._remove_prop_from_all_fs(host=host, prop=auto_name)
await self._add_prop_to_fs(host=host, prop=auto_name, value="true", filesystems=self.parentchildren.value)
await self._add_prop_to_fs(host=host, prop=auto_name, value="parent", filesystems=self.parent.value)
await self._add_prop_to_fs(host=host, prop=auto_name, value="child", filesystems=self.children.value)
await self._add_prop_to_fs(host=host, prop=auto_name, value="false", filesystems=self.exclude.value)
self.fs["values"] = {}
self.fs["values"]["parentchildren"] = self.parentchildren.value
self.fs["values"]["parent"] = self.parent.value
self.fs["values"]["children"] = self.children.value
self.fs["values"]["exclude"] = self.exclude.value
if self.previous_prop != "":
command = AutomationTemplate(self.previous_prop)
prop = command.safe_substitute(name=auto_name, host=host)
await self._remove_prop_from_all_fs(host=host, prop=prop)
command = AutomationTemplate(self.prop.value)
prop = command.safe_substitute(name=auto_name, host=host)
await self._remove_prop_from_all_fs(host=host, prop=prop)
await self._add_prop_to_fs(host=host, prop=prop, value="true", filesystems=self.parentchildren.value)
await self._add_prop_to_fs(host=host, prop=prop, value="parent", filesystems=self.parent.value)
await self._add_prop_to_fs(host=host, prop=prop, value="child", filesystems=self.children.value)
await self._add_prop_to_fs(host=host, prop=prop, value="false", filesystems=self.exclude.value)
auto = scheduler.Zfs_Autobackup(
id=auto_id,
name=auto_name,
hosts=hosts,
host=host,
command="python -m zfs_autobackup.ZfsAutobackup" + self.command.value,
@@ -800,9 +798,13 @@ class Automation(Tab):
target_host=self.target_host.value,
target_path=self.target_path.value,
target_paths=self.target_path.options,
filesystems=self.fs,
pipe_success=self.pipe_success.value,
pipe_error=self.pipe_error.value,
prop=self.prop.value,
parentchildren=self.parentchildren.value,
parent=self.parent.value,
children=self.children.value,
exclude=self.exclude.value,
)
self.scheduler.scheduler.add_job(
automation_job,
@@ -815,13 +817,14 @@ class Automation(Tab):
)
elif self.app.value == "remote":
for job in jobs:
j = job.id.split("@")[0]
if j == auto_name:
auto = automation(job)
if auto is not None and auto.name == auto_name:
self.scheduler.scheduler.remove_job(job.id)
for host in hosts:
auto_id = f"{auto_name}@{host}"
auto = scheduler.Automation(
id=auto_id,
name=auto_name,
app=self.app.value,
hosts=hosts,
host=host,
@@ -844,6 +847,7 @@ class Automation(Tab):
auto_id = f"{auto_name}@{self.host}"
auto = scheduler.Automation(
id=auto_id,
name=auto_name,
app=self.app.value,
hosts=hosts,
host=self.host,
+8 -2
View File
@@ -1,6 +1,6 @@
from datetime import datetime
import json
from nicegui import ui, events
from nicegui import ui, events # type: ignore
import httpx
from . import SelectionConfirm, Tab
from bale import elements as el
@@ -34,7 +34,12 @@ class History(Tab):
"rowSelection": "multiple",
"paginationAutoPageSize": True,
"pagination": True,
"defaultColDef": {"resizable": True, "sortable": True, "suppressMovable": True, "sortingOrder": ["asc", "desc"]},
"defaultColDef": {
"resizable": True,
"sortable": True,
"suppressMovable": True,
"sortingOrder": ["asc", "desc"],
},
"columnDefs": [
{
"headerName": "Host",
@@ -57,6 +62,7 @@ class History(Tab):
var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});;
return date;
}""",
"sort": "desc",
},
{
"headerName": "Status",
+30 -27
View File
@@ -1,6 +1,6 @@
import asyncio
from copy import deepcopy
from nicegui import background_tasks, ui
from nicegui import background_tasks, ui # type: ignore
from . import SelectionConfirm, Tab, Task
from bale.result import Result
from bale import elements as el
@@ -87,6 +87,7 @@ class Manage(Tab):
var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});;
return date;
}""",
"sort": "desc",
},
{"headerName": "Holds", "field": "userrefs", "filter": "agNumberColumnFilter", "maxWidth": 100},
],
@@ -259,32 +260,34 @@ class Manage(Tab):
if result == "confirm":
self._spinner.visible = True
rows = await self._grid.get_selected_rows()
for row in rows:
holds = await self.zfs.holds_for_snapshot(f"{row['filesystem']}@{row['name']}")
for tag in holds.data:
if tag not in all_tags:
all_tags.append(tag)
if len(all_tags) > 0:
tags.update()
self._spinner.visible = False
result = await dialog
if result == "release":
if len(tags.value) > 0:
for tag in tags.value:
for row in rows:
tasks = self._add_task(
"release",
zfs.SnapshotRelease(
name=f"{row['filesystem']}@{row['name']}",
tag=tag,
recursive=recursive.value,
).command,
hosts=zfs_hosts.value,
)
if self._auto.value is True:
for task in tasks:
await self._run_task(task=task, spinner=self._spinner)
await self.display_snapshots()
if len(rows) > 0:
for row in rows:
holds = await self.zfs.holds_for_snapshot(f"{row['filesystem']}@{row['name']}")
for tag in holds.data:
if tag not in all_tags:
all_tags.append(tag)
if len(all_tags) > 0:
tags.update()
self._spinner.visible = False
result = await dialog
if result == "release":
if len(tags.value) > 0:
for tag in tags.value:
for row in rows:
tasks = self._add_task(
"release",
zfs.SnapshotRelease(
name=f"{row['filesystem']}@{row['name']}",
tag=tag,
recursive=recursive.value,
).command,
hosts=zfs_hosts.value,
)
if self._auto.value is True:
for task in tasks:
await self._run_task(task=task, spinner=self._spinner)
await self.display_snapshots()
self._spinner.visible = False
self._set_selection()
def _update_task_status(self, timestamp, status, result=None):
+13 -3
View File
@@ -5,9 +5,18 @@ logger = logging.getLogger(__name__)
import os
if not os.path.exists("data"):
os.makedirs("data")
logger.warning("Could not find 'data' directory, verify bind mounts.")
if os.path.exists(".nicegui"):
logger.warning("Creating 'data' directory symlink.")
os.symlink(".nicegui", "data", target_is_directory=True)
else:
logger.warning("Creating 'data' directory, settings will not be persistent.")
os.makedirs("data")
else:
logger.warning("Found 'data' directory.")
os.environ.setdefault("NICEGUI_STORAGE_PATH", "data")
from nicegui import ui
from nicegui import app, ui # type: ignore
ui.card.default_style("max-width: none")
ui.card.default_props("flat bordered")
@@ -22,7 +31,8 @@ from bale import page, logo, scheduler
if __name__ in {"__main__", "__mp_main__"}:
app.on_startup(lambda: print(f"Starting bale, bound to the following addresses {', '.join(app.urls)}.", flush=True))
page.build()
s = scheduler.Scheduler()
ui.timer(0.1, s.start, once=True)
ui.run(title="bale", favicon=logo.favicon, dark=True, reload=False)
ui.run(title="bale", favicon=logo.favicon, dark=True, reload=False, show=False, show_welcome_message=False)
+57 -5
View File
@@ -1,8 +1,60 @@
aiofiles==23.2.1
aiohttp==3.9.3
aiosignal==1.3.1
annotated-types==0.6.0
anyio==4.3.0
APScheduler==3.10.4
SQLAlchemy==2.0.22
asyncssh==2.14.0
attrs==23.2.0
bidict==0.23.1
certifi==2024.2.2
cffi==1.16.0
click==8.1.7
colorama==0.4.6
cron-descriptor==1.4.0
cron-validator==1.0.8
nicegui==1.4.2
zfs-autobackup==3.2
netifaces==0.11.0
asyncssh==2.14.0
cryptography==42.0.5
docutils==0.19
fastapi==0.109.2
frozenlist==1.4.1
greenlet==3.0.3
h11==0.14.0
httpcore==1.0.4
httptools==0.6.1
httpx==0.27.0
idna==3.6
ifaddr==0.2.0
itsdangerous==2.1.2
Jinja2==3.1.3
markdown2==2.4.13
MarkupSafe==2.1.5
multidict==6.0.5
nicegui==1.4.17
orjson==3.9.15
pscript==0.7.7
pycparser==2.21
pydantic==2.6.3
pydantic_core==2.16.3
Pygments==2.17.2
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
python-engineio==4.9.0
python-multipart==0.0.9
python-socketio==5.11.1
pytz==2024.1
PyYAML==6.0.1
simple-websocket==1.0.0
six==1.16.0
sniffio==1.3.1
SQLAlchemy==2.0.22
starlette==0.36.3
typing_extensions==4.10.0
tzlocal==5.2
uvicorn==0.27.1
uvloop==0.19.0
vbuild==0.8.2
watchfiles==0.21.0
websockets==12.0
wsproto==1.2.0
yarl==1.9.4
zfs-autobackup==3.2