20 Commits

Author SHA1 Message Date
Natan Keddem fada159fa0 updated build action 2024-04-01 21:18:04 -04:00
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
20 changed files with 559 additions and 299 deletions
+16 -12
View File
@@ -6,12 +6,12 @@ name: Docker
# documentation. # documentation.
on: on:
# schedule: # schedule:
# - cron: '20 13 * * *' # - cron: '20 13 * * *'
push: push:
branches: ["master"] branches: [ "master" ]
# Publish semver tags as releases. # Publish semver tags as releases.
tags: ["v*.*.*"] tags: [ 'v*.*.*' ]
# pull_request: # pull_request:
# branches: [ "master" ] # branches: [ "master" ]
@@ -21,8 +21,10 @@ env:
# github.repository as <account>/<repo> # github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@@ -39,19 +41,21 @@ jobs:
# https://github.com/sigstore/cosign-installer # https://github.com/sigstore/cosign-installer
- name: Install cosign - name: Install cosign
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1 uses: sigstore/cosign-installer@v3.3.0
with: with:
cosign-release: "v2.1.1" cosign-release: 'v2.2.2' # optional
# Workaround: https://github.com/docker/build-push-action/issues/461 # Set up BuildKit Docker container builder to be able to build
- name: Setup Docker buildx # multi-platform images and export cache
uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf # https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
# Login against a Docker registry except on PR # Login against a Docker registry except on PR
# https://github.com/docker/login-action # https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }} - name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -61,7 +65,7 @@ jobs:
# https://github.com/docker/metadata-action # https://github.com/docker/metadata-action
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -69,7 +73,7 @@ jobs:
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
+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 echo "**** install runtime dependencies ****"
RUN apt update RUN apt update
+94 -2
View File
@@ -1,7 +1,23 @@
# bale: ZFS Snapshot Browser Based GUI # bale: ZFS Snapshot Browser Based GUI
## Demo ## Host Creation
https://github.com/natankeddem/bale/assets/44515217/53c2dc10-afbf-44a2-9546-545b06e7c565 [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_** ## ⚠️ **_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 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 GUI
Access bale by navigating to `http://host:8080`. Access bale by navigating to `http://host:8080`.
+4 -1
View File
@@ -34,6 +34,9 @@
"-l", "-l",
"180" "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 import asyncio
from nicegui import ui from nicegui import ui # type: ignore
from bale import elements as el from bale import elements as el
import bale.logo as logo import bale.logo as logo
from bale.tabs import Tab 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 import elements as el
from bale.tabs import Tab from bale.tabs import Tab
from bale.interfaces import ssh from bale.interfaces import ssh
@@ -66,7 +67,7 @@ class Drawer(object):
) )
self._table.tailwind.width("full") self._table.tailwind.width("full")
self._table.visible = False self._table.visible = False
for name in ssh.get_hosts("data"): for name in ssh.get_hosts():
self._add_host_to_table(name) self._add_host_to_table(name)
chevron = ui.button(icon="chevron_left", color=None, on_click=toggle_drawer).props("padding=0px") chevron = ui.button(icon="chevron_left", color=None, on_click=toggle_drawer).props("padding=0px")
chevron.classes("absolute") chevron.classes("absolute")
@@ -87,7 +88,7 @@ class Drawer(object):
save = None save = None
async def send_key(): 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() result = await s.send_key()
if result.stdout.strip() != "": if result.stdout.strip() != "":
el.notify(result.stdout.strip(), multi_line=True, type="positive") 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 ui.dialog() as host_dialog, el.Card():
with el.DBody(height="[560px]", width="[360px]"): with el.DBody(height="[560px]", width="[360px]"):
with el.WColumn(): with el.WColumn():
host_input = el.DInput(label="Host", value=" ") all_hosts = list(ssh.get_hosts())
hostname_input = el.DInput(label="Hostname", value=" ") 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=" ") username_input = el.DInput(label="Username", value=" ")
save_em = el.ErrorAggregator(host_input, hostname_input, username_input) save_em = el.ErrorAggregator(host_input, hostname_input, username_input)
with el.Card() as c: with el.Card() as c:
@@ -110,12 +115,12 @@ class Drawer(object):
c.tailwind.width("full") c.tailwind.width("full")
with ui.scroll_area() as s: with ui.scroll_area() as s:
s.tailwind.height("[160px]") 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") 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") el.DButton("SAVE", on_click=lambda: host_dialog.submit("save")).bind_enabled_from(save_em, "no_errors")
host_input.value = name host_input.value = name
if name != "": if name != "":
s = ssh.Ssh(path="data", host=name) s = ssh.Ssh(name)
hostname_input.value = s.hostname hostname_input.value = s.hostname
username_input.value = s.username username_input.value = s.username
@@ -125,11 +130,11 @@ class Drawer(object):
default = Tab(spinner=None).common.get("default", "") default = Tab(spinner=None).common.get("default", "")
if default == name: if default == name:
Tab(spinner=None).common["default"] = "" Tab(spinner=None).common["default"] = ""
ssh.Ssh(path="data", host=name).remove() ssh.Ssh(name).remove()
for row in self._table.rows: for row in self._table.rows:
if name == row["name"]: if name == row["name"]:
self._table.remove_rows(row) 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) self._add_host_to_table(host_input.value)
def _modify_host(self, mode): def _modify_host(self, mode):
@@ -162,7 +167,7 @@ class Drawer(object):
if self._selection_mode == "remove": if self._selection_mode == "remove":
if len(e.selection) > 0: if len(e.selection) > 0:
for row in e.selection: for row in e.selection:
ssh.Ssh(path="data", host=row["name"]).remove() ssh.Ssh(row["name"]).remove()
self._table.remove_rows(row) self._table.remove_rows(row)
self._modify_host(None) self._modify_host(None)
+53 -7
View File
@@ -1,11 +1,11 @@
from typing import Any, Callable, Dict, List, Literal, Optional, Union from typing import Any, Callable, Dict, List, Literal, Optional, Union
from nicegui import ui, app, Tailwind from nicegui import ui, app, Tailwind # type: ignore
from nicegui.elements.spinner import SpinnerTypes from nicegui.elements.spinner import SpinnerTypes # type: ignore
from nicegui.elements.tabs import Tab from nicegui.elements.tabs import Tab # type: ignore
from nicegui.tailwind_types.height import Height from nicegui.tailwind_types.height import Height # type: ignore
from nicegui.tailwind_types.width import Width from nicegui.tailwind_types.width import Width # type: ignore
from nicegui.elements.mixins.validation_element import ValidationElement from nicegui.elements.mixins.validation_element import ValidationElement # type: ignore
from nicegui.events import GenericEventArguments, handle_event from nicegui.events import GenericEventArguments, handle_event # type: ignore
from bale.interfaces import cli from bale.interfaces import cli
import logging import logging
@@ -131,6 +131,52 @@ class DInput(ui.input):
self.value = "" 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): class FInput(ui.input):
def __init__( def __init__(
self, self,
+24 -5
View File
@@ -4,7 +4,7 @@ from asyncio.subprocess import Process, PIPE
import contextlib import contextlib
import shlex import shlex
from datetime import datetime from datetime import datetime
from nicegui import ui from nicegui import ui # type: ignore
from bale.result import Result from bale.result import Result
import logging import logging
@@ -116,30 +116,49 @@ class Cli:
self._terminate.clear() self._terminate.clear()
self._busy = False self._busy = False
return Result( 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 self._busy = True
try: try:
process = await asyncio.create_subprocess_shell(command, stdout=PIPE, stderr=PIPE) 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: 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._terminate.clear()
self._truncated = False
terminated = False
now = datetime.now().strftime("%Y/%m/%d %H:%M:%S") now = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
self.prefix_line = f"<{now}> {command}\n" self.prefix_line = f"<{now}> {command}\n"
for terminal in self._stdout_terminals: for terminal in self._stdout_terminals:
terminal.call_terminal_method("write", "\n" + self.prefix_line) terminal.call_terminal_method("write", "\n" + self.prefix_line)
await asyncio.gather( await asyncio.gather(
self._controller(process=process, max_output_lines=max_output_lines),
self._read_stdout(stream=process.stdout), self._read_stdout(stream=process.stdout),
self._read_stderr(stream=process.stderr), self._read_stderr(stream=process.stderr),
) )
if self._terminate.is_set():
terminated = True
await process.wait() await process.wait()
except Exception as e: except Exception as e:
raise e raise e
finally: finally:
self._terminate.clear()
self._busy = False 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): def clear_buffers(self):
self.prefix_line = "" 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 os
import asyncio
from pathlib import Path from pathlib import Path
from bale.result import Result from bale.interfaces import cli
from bale.interfaces.cli import Cli
def get_hosts(path): def get_hosts(path: str = "data"):
path = f"{Path(path).resolve()}/config" path = f"{Path(path).resolve()}/config"
hosts = [] hosts = []
try: try:
@@ -20,32 +18,42 @@ def get_hosts(path):
return [] return []
async def get_public_key(path: str) -> str: async def get_public_key(path: str = "data") -> str:
path = Path(path).resolve() path = Path(path).resolve()
if "id_rsa.pub" not in os.listdir(path) or "id_rsa" not in os.listdir(path): 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: with open(f"{path}/id_rsa.pub", "r", encoding="utf-8") as reader:
return reader.read() return reader.read()
class Ssh(Cli): class Ssh(cli.Cli):
def __init__(self, path: str, host: str, hostname: str = "", username: str = "", password: Union[str, None] = None, seperator: bytes = b"\n") -> None: 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) super().__init__(seperator=seperator)
self._raw_path: str = path self._raw_path: str = path
self._path: Path = Path(path).resolve() self._path: Path = Path(path).resolve()
self.host: str = host self.host: str = host.replace(" ", "")
self.password: Union[str, None] = password self.password: Union[str, None] = password
self.use_key: bool = False self.use_key: bool = False
if password is None: if password is None:
self.use_key = True self.use_key = True
self.options: Optional[Dict[str, str]] = options
self.key_path: str = f"{self._path}/id_rsa" self.key_path: str = f"{self._path}/id_rsa"
self._base_cmd: str = "" self._base_command: str = ""
self._full_cmd: str = "" self._full_command: str = ""
self._config_path: str = f"{self._path}/config" self._config_path: str = f"{self._path}/config"
self._config: Dict[str, Dict[str, str]] = {} self._config: Dict[str, Dict[str, str]] = {}
self.read_config() self.read_config()
self.hostname: str = hostname or self._config.get(host, {}).get("HostName", "") self.hostname: str = hostname or self._config.get(host.replace(" ", ""), {}).get("HostName", "")
self.username: str = username or self._config.get(host, {}).get("User", "") self.username: str = username or self._config.get(host.replace(" ", ""), {}).get("User", "")
self.set_config() self.set_config()
def read_config(self) -> None: def read_config(self) -> None:
@@ -57,7 +65,7 @@ class Ssh(Cli):
if line == "" or line.startswith("#"): if line == "" or line.startswith("#"):
continue continue
if line.startswith("Host "): if line.startswith("Host "):
current_host = line.split(" ")[1].strip() current_host = line.split(" ", 1)[1].strip().replace('"', "")
self._config[current_host] = {} self._config[current_host] = {}
else: else:
key, value = line.split(" ", 1) key, value = line.split(" ", 1)
@@ -76,30 +84,40 @@ class Ssh(Cli):
def set_config(self) -> None: def set_config(self) -> None:
self._config[self.host] = { self._config[self.host] = {
"IdentityFile": self.key_path, "IdentityFile": self.key_path,
"PasswordAuthentication": "no",
"StrictHostKeychecking": "no", "StrictHostKeychecking": "no",
"IdentitiesOnly": "yes", "IdentitiesOnly": "yes",
} }
self._config[self.host]["PasswordAuthentication"] = "no" if self.password is None else "yes"
if self.hostname != "": if self.hostname != "":
self._config[self.host]["HostName"] = self.hostname self._config[self.host]["HostName"] = self.hostname
if self.username != "": if self.username != "":
self._config[self.host]["User"] = 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() self.write_config()
def remove(self) -> None: def remove(self) -> None:
del self._config[self.host] del self._config[self.host]
self.write_config() self.write_config()
async def execute(self, command: str, max_output_lines: int = 0) -> Result: async def execute(self, command: str, max_output_lines: int = 0) -> cli.Result:
self._base_cmd = f"{'' if self.use_key else f'sshpass -p {self.password} '} ssh -F {self._config_path} {self.host}" self._full_command = f"{self.base_command} {command}"
self._full_cmd = f"{self._base_cmd} {command}" return await super().execute(self._full_command, max_output_lines)
return await super().execute(self._full_cmd, 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) 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}" 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 @property
def config_path(self): def config_path(self):
return self._config_path 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 import stat
from datetime import datetime from datetime import datetime
import uuid 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 from fastapi.responses import StreamingResponse
import asyncssh import asyncssh
from bale import elements as el 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 import re
from datetime import datetime from datetime import datetime
from dataclasses import dataclass from dataclasses import dataclass
@@ -115,16 +115,15 @@ class Zfs:
return result return result
async def filesystems_with_prop(self, prop: str) -> Result: async def filesystems_with_prop(self, prop: str) -> Result:
result = await self.execute(f"zfs get -Hp -t filesystem,volume {prop}")
filesystems = [] filesystems = []
result = await self.execute(f"zfs get -Hp -t filesystem,volume {prop}")
for line in result.stdout_lines: 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) matches = re.match("^(?P<name>[^\t]+)\t(?P<property>[^\t]+)\t(?P<value>[^\t]+)\t(?P<source>[^\n]+)", line)
if matches is not None: if matches is not None:
md = matches.groupdict() md = matches.groupdict()
if md["property"] == prop and md["source"] == "local": if md["property"] == prop and md["source"] == "local":
filesystems.append(md["name"]) filesystems.append(md["name"])
result = Result(data=filesystems, cached=False) return Result(data=filesystems, cached=False)
return result
async def holds_for_snapshot(self, snapshot: Union[str, None] = None) -> Result: async def holds_for_snapshot(self, snapshot: Union[str, None] = None) -> Result:
query = "holds_for_snapshot" query = "holds_for_snapshot"
@@ -137,7 +136,7 @@ class Zfs:
with_holds.append(_name) with_holds.append(_name)
with_holds = " ".join(with_holds) with_holds = " ".join(with_holds)
else: else:
with_holds = [snapshot] with_holds = snapshot
if len(with_holds) > 0: if len(with_holds) > 0:
result = await self.execute(f"zfs holds -H -r {with_holds}", notify=False) result = await self.execute(f"zfs holds -H -r {with_holds}", notify=False)
tags: Dict[str, list[str]] = {} tags: Dict[str, list[str]] = {}
@@ -149,11 +148,16 @@ class Zfs:
if s not in tags: if s not in tags:
tags[s] = [] tags[s] = []
tags[s].append(md["tag"]) tags[s].append(md["tag"])
self._last_data[query] = tags if query not in self._last_data:
if snapshot in self._last_data[query]: self._last_data[query] = {}
result.data = self._last_data[query][snapshot] self._last_data[query].update(tags)
if snapshot is None:
result.data = self._last_data[query]
else: else:
result.data = [] if snapshot in self._last_data[query]:
result.data = self._last_data[query][snapshot]
else:
result.data = []
else: else:
return Result(data=[]) return Result(data=[])
else: else:
@@ -227,6 +231,7 @@ class Zfs:
md["creation_date"] = datetime.fromtimestamp(md["creation"]).strftime("%Y/%m/%d") md["creation_date"] = datetime.fromtimestamp(md["creation"]).strftime("%Y/%m/%d")
md["creation_time"] = datetime.fromtimestamp(md["creation"]).strftime("%H:%M") md["creation_time"] = datetime.fromtimestamp(md["creation"]).strftime("%H:%M")
md["used"] = format_bytes(md["used_bytes"]) md["used"] = format_bytes(md["used_bytes"])
md["userrefs"] = int(md["userrefs"])
snapshot = f"{md['filesystem']}@{md['name']}" snapshot = f"{md['filesystem']}@{md['name']}"
snapshots[snapshot] = md snapshots[snapshot] = md
self._last_data[query] = snapshots self._last_data[query] = snapshots
@@ -238,8 +243,17 @@ class Zfs:
class Ssh(ssh.Ssh, Zfs): class Ssh(ssh.Ssh, Zfs):
def __init__(self, path: str, host: str, hostname: str = "", username: str = "", password: Union[str, None] = None) -> None: def __init__(
super().__init__(path, host, hostname, username, password) 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) Zfs.__init__(self)
def notify(self, command: str): 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 import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+1 -1
View File
@@ -1,5 +1,5 @@
import asyncio import asyncio
from nicegui import app, Client, ui from nicegui import app, Client, ui # type: ignore
from bale import elements as el from bale import elements as el
from bale.drawer import Drawer from bale.drawer import Drawer
from bale.content import Content from bale.content import Content
+20 -18
View File
@@ -5,23 +5,23 @@ from pathlib import Path
from functools import cache from functools import cache
from datetime import datetime from datetime import datetime
import time import time
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore
@dataclass(kw_only=True) @dataclass(kw_only=True)
class Automation: class Automation:
id: str id: str = ""
name: str name: str = ""
app: str app: str = "remote"
hosts: List[str] hosts: List[str] = field(default_factory=list)
host: str host: str = ""
command: str command: str = ""
schedule_mode: str schedule_mode: str = ""
triggers: Dict[str, str] triggers: Dict[str, str] = field(default_factory=dict)
options: Union[Dict[str, Any], None] = None options: Dict[str, Any] = field(default_factory=dict)
pipe_success: bool = False
pipe_error: bool = False
timestamp: float = field(default_factory=time.time) timestamp: float = field(default_factory=time.time)
pipe_success: bool
pipe_error: bool
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
return self.__dict__ return self.__dict__
@@ -30,12 +30,14 @@ class Automation:
@dataclass(kw_only=True) @dataclass(kw_only=True)
class Zfs_Autobackup(Automation): class Zfs_Autobackup(Automation):
app: str = "zfs_autobackup" app: str = "zfs_autobackup"
execute_mode: str = "local" prop: str = "autobackup:{name}"
prop: str target_host: str = ""
target_host: str target_path: str = ""
target_path: str target_paths: List[str] = field(default_factory=list)
target_paths: List[str] parentchildren: List[str] = field(default_factory=list)
filesystems: Dict[str, Union[str, List[str], Dict[str, str]]] parent: List[str] = field(default_factory=list)
children: List[str] = field(default_factory=list)
exclude: List[str] = field(default_factory=list)
class _Scheduler: class _Scheduler:
+10 -14
View File
@@ -6,7 +6,7 @@ from datetime import datetime
import time import time
import json import json
import httpx import httpx
from nicegui import app, ui from nicegui import app, ui # type: ignore
from bale.interfaces.zfs import Ssh from bale.interfaces.zfs import Ssh
from bale import elements as el from bale import elements as el
from bale.result import Result from bale.result import Result
@@ -83,7 +83,7 @@ class Tab:
@classmethod @classmethod
def register_connection(cls, host: str) -> None: 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: async def _display_result(self, result: Result) -> None:
with ui.dialog() as dialog, el.Card(): with ui.dialog() as dialog, el.Card():
@@ -91,20 +91,16 @@ class Tab:
with el.WColumn(): with el.WColumn():
with el.Card() as card: with el.Card() as card:
card.tailwind.width("full") card.tailwind.width("full")
with el.WColumn():
ui.label(f"#> {result.command}").classes("text-secondary")
with el.WRow() as row: with el.WRow() as row:
row.tailwind.justify_content("around") row.tailwind.justify_content("around")
with ui.column() as col: ui.label(f"Host: {result.name}").classes("text-secondary")
col.tailwind.max_width("lg") timestamp = await ui.run_javascript(
ui.label(f"Host Name: {result.name}").classes("text-secondary") f"new Date({result.timestamp} * 1000).toLocaleString(undefined, {{dateStyle: 'short', timeStyle: 'short', hour12: 'false'}});"
ui.label(f"Command: {result.command}").classes("text-secondary") )
timestamp = await ui.run_javascript( ui.label(f"Timestamp: {timestamp}").classes("text-secondary")
f"new Date({result.timestamp} * 1000).toLocaleString(undefined, {{dateStyle: 'short', timeStyle: 'short', hour12: 'false'}});" ui.label(f"Return Code: {result.return_code}").classes("text-secondary")
)
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")
with el.Card() as card: with el.Card() as card:
with el.WColumn(): with el.WColumn():
terminal = cli.Terminal(options={"rows": 18, "cols": 120, "convertEol": True}) terminal = cli.Terminal(options={"rows": 18, "cols": 120, "convertEol": True})
+144 -154
View File
@@ -3,12 +3,15 @@ import asyncio
from datetime import datetime from datetime import datetime
import json import json
import string import string
from apscheduler.triggers.combining import AndTrigger from apscheduler.job import Job # type: ignore
from apscheduler.triggers.combining import OrTrigger from apscheduler.triggers.combining import AndTrigger # type: ignore
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.combining import OrTrigger # type: ignore
from apscheduler.triggers.interval import IntervalTrigger 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 . import SelectionConfirm, Tab
from nicegui import ui, Tailwind, events
from bale import elements as el from bale import elements as el
from bale.result import Result from bale.result import Result
from bale.interfaces import cli from bale.interfaces import cli
@@ -16,8 +19,7 @@ from bale.interfaces import ssh
from bale.interfaces import zfs from bale.interfaces import zfs
from bale.apps import zab from bale.apps import zab
from bale import scheduler from bale import scheduler
from cron_validator import CronValidator
from cron_descriptor import get_description
import logging import logging
@@ -26,11 +28,27 @@ logger = logging.getLogger(__name__)
job_handlers: Dict[str, Union[cli.Cli, ssh.Ssh]] = {} 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): def populate_job_handler(app: str, job_id: str, host: str):
tab = Tab(host=None, spinner=None) tab = Tab(host=None, spinner=None)
if job_id not in job_handlers: if job_id not in job_handlers:
if app == "remote": if app == "remote":
job_handlers[job_id] = ssh.Ssh("data", host=host) job_handlers[job_id] = ssh.Ssh(host)
else: else:
job_handlers[job_id] = cli.Cli() job_handlers[job_id] = cli.Cli()
return job_handlers[job_id] return job_handlers[job_id]
@@ -41,46 +59,43 @@ class AutomationTemplate(string.Template):
async def automation_job(**kwargs) -> None: async def automation_job(**kwargs) -> None:
if "data" in kwargs: auto = automation(kwargs["data"])
jd = json.loads(kwargs["data"]) if auto is not None:
command = AutomationTemplate(jd["command"]) command = AutomationTemplate(auto.command)
tab = Tab(host=None, spinner=None) tab = Tab(host=None, spinner=None)
if jd["app"] == "zfs_autobackup": if auto.app == "zfs_autobackup":
d = scheduler.Zfs_Autobackup(**jd) populate_job_handler(app=auto.app, job_id=auto.id, host=auto.host)
populate_job_handler(app=d.app, job_id=d.id, host=d.host) if job_handlers[auto.id].is_busy is False:
if job_handlers[d.id].is_busy is False: result = await job_handlers[auto.id].execute(command.safe_substitute(name=auto.name, host=auto.host))
result = await job_handlers[d.id].execute(command.safe_substitute(name=d.name, host=d.host)) result.name = auto.host
result.name = d.host
result.status = "success" if result.return_code == 0 else "error" 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) 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.pipe_result(result=result)
tab.add_history(result=result) tab.add_history(result=result)
else: else:
logger.warning("Job Skipped!") logger.warning("Job Skipped!")
elif jd["app"] == "remote": elif auto.app == "remote":
d = scheduler.Automation(**jd) populate_job_handler(app=auto.app, job_id=auto.id, host=auto.host)
populate_job_handler(app=d.app, job_id=d.id, host=d.host) if job_handlers[auto.id].is_busy is False:
if job_handlers[d.id].is_busy is False: result = await job_handlers[auto.id].execute(command.safe_substitute(name=auto.name, host=auto.host))
result = await job_handlers[d.id].execute(command.safe_substitute(name=d.name, host=d.host)) result.name = auto.host
result.name = d.host if auto.pipe_success is True and result.status == "success":
if d.pipe_success is True and result.status == "success":
tab.pipe_result(result=result) 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.pipe_result(result=result)
tab.add_history(result=result) tab.add_history(result=result)
else: else:
logger.warning("Job Skipped!") logger.warning("Job Skipped!")
elif jd["app"] == "local": elif auto.app == "local":
d = scheduler.Automation(**jd) populate_job_handler(app=auto.app, job_id=auto.id, host=auto.host)
populate_job_handler(app=d.app, job_id=d.id, host=d.host) if job_handlers[auto.id].is_busy is False:
if job_handlers[d.id].is_busy is False: result = await job_handlers[auto.id].execute(command.safe_substitute(name=auto.name, host=auto.host))
result = await job_handlers[d.id].execute(command.safe_substitute(name=d.name, host=d.host)) result.name = auto.host
result.name = d.host if auto.pipe_success is True and result.status == "success":
if d.pipe_success is True and result.status == "success":
tab.pipe_result(result=result) 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.pipe_result(result=result)
tab.add_history(result=result) tab.add_history(result=result)
else: else:
@@ -96,7 +111,7 @@ class Automation(Tab):
self.picked_options: Dict[str, str] = {} self.picked_options: Dict[str, str] = {}
self.triggers: Dict[str, str] = {} self.triggers: Dict[str, str] = {}
self.picked_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.job_names: List[str] = []
self.default_options: Dict[str, str] = {} self.default_options: Dict[str, str] = {}
self.build_command: Callable self.build_command: Callable
@@ -122,6 +137,11 @@ class Automation(Tab):
self.triggers_scroll: ui.scroll_area self.triggers_scroll: ui.scroll_area
self.trigger_controls: Dict[str, str] = {} self.trigger_controls: Dict[str, str] = {}
self.hosts: el.DSelect 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) super().__init__(spinner, host)
def _build(self) -> None: def _build(self) -> None:
@@ -134,7 +154,6 @@ class Automation(Tab):
el.SmButton("Create", on_click=self._create_automation) el.SmButton("Create", on_click=self._create_automation)
el.SmButton("Remove", on_click=self._remove_automation) el.SmButton("Remove", on_click=self._remove_automation)
el.SmButton("Edit", on_click=self._edit_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) el.SmButton("Run Now", on_click=self._run_automation)
with ui.row().classes("items-center"): with ui.row().classes("items-center"):
el.SmButton(text="Refresh", on_click=self._update_automations) 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});; var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});;
return date; return date;
}""", }""",
"sort": "asc",
}, },
{ {
"headerName": "Status", "headerName": "Status",
@@ -194,11 +214,10 @@ class Automation(Tab):
job_id = f"{job_data.args['data']['name']}@{self.host}" job_id = f"{job_data.args['data']['name']}@{self.host}"
for job in self.scheduler.scheduler.get_jobs(): for job in self.scheduler.scheduler.get_jobs():
if job.id == job_id: auto = automation(job)
if "data" in job.kwargs: if auto is not None and auto.id == job_id:
jd = json.loads(job.kwargs["data"]) populate_job_handler(app=auto.app, job_id=auto.id, host=self.host)
populate_job_handler(app=jd["app"], job_id=job.id, host=self.host) break
break
async def run(): async def run():
for job in self.scheduler.scheduler.get_jobs(): for job in self.scheduler.scheduler.get_jobs():
@@ -238,17 +257,9 @@ class Automation(Tab):
next_run = job.next_run_time.timestamp() next_run = job.next_run_time.timestamp()
else: else:
next_run = "NA" next_run = "NA"
if "data" in job.kwargs: auto = automation(job)
jd = json.loads(job.kwargs["data"]) if auto is not None and auto.host == self.host:
if self.host == jd["host"]: self._automations.append({"name": auto.name, "command": auto.command, "next_run": next_run, "status": ""})
self._automations.append(
{
"name": job.id.split("@")[0],
"command": jd["command"],
"next_run": next_run,
"status": "",
}
)
self._grid.update() self._grid.update()
async def _remove_automation(self) -> None: async def _remove_automation(self) -> None:
@@ -258,8 +269,15 @@ class Automation(Tab):
rows = await self._grid.get_selected_rows() rows = await self._grid.get_selected_rows()
for row in rows: for row in rows:
for job in self.scheduler.scheduler.get_jobs(): for job in self.scheduler.scheduler.get_jobs():
j = job.id.split("@")[0] auto = automation(job)
if j == row["name"]: 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.scheduler.scheduler.remove_job(job.id)
self._automations.remove(row) self._automations.remove(row)
self._grid.update() self._grid.update()
@@ -277,29 +295,6 @@ class Automation(Tab):
job.modify(next_run_time=datetime.now()) job.modify(next_run_time=datetime.now())
self._set_selection() 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: async def _edit_automation(self) -> None:
self._set_selection(mode="single") self._set_selection(mode="single")
result = await SelectionConfirm(container=self._confirm, label=">EDIT<") result = await SelectionConfirm(container=self._confirm, label=">EDIT<")
@@ -327,16 +322,16 @@ class Automation(Tab):
self.picked_options = {} self.picked_options = {}
self.triggers = {} self.triggers = {}
self.picked_triggers = {} self.picked_triggers = {}
self.job_data = {}
jobs = self.scheduler.scheduler.get_jobs() jobs = self.scheduler.scheduler.get_jobs()
self.job_names = [] self.job_names = []
self.auto = scheduler.Automation(host=self.host, hosts=[self.host])
job = None job = None
for job in jobs: for job in jobs:
j = job.id.split("@")[0] auto = automation(job)
self.job_names.append(j) if auto is not None:
if name == j: self.job_names.append(auto.name)
job = self.scheduler.scheduler.get_job(job.id) if auto.name == name:
self.job_data.update(json.loads(job.kwargs["data"])) self.auto = auto
def validate_name(n: str): 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 != ""): if len(n) > 0 and n.islower() and "@" not in n and (n not in self.job_names or name != ""):
@@ -379,18 +374,15 @@ class Automation(Tab):
def option_changed(e): def option_changed(e):
self.current_help.text = self.options[e.value]["description"] 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 filesystems = await self.zfs.filesystems
if isinstance(self.job_data.get("filesystems", {}), dict): parent: List[str] = []
self.fs = self.job_data.get( children: List[str] = []
"filesystems", parentchildren: List[str] = []
{"all": {}, "values": {}, "parent": [], "children": [], "parentchildren": [], "exclude": []}, exclude: List[str] = []
) all_fs: Dict[str, str] = {}
else: for fs in filesystems.data:
self.fs = {"all": {}, "values": {}, "parent": [], "children": [], "parentchildren": [], "exclude": []} all_fs[fs] = ""
if not self.fs["all"]:
for fs in filesystems.data:
self.fs["all"][fs] = ""
async def target_host_selected() -> None: async def target_host_selected() -> None:
if self.target_host.value != "": if self.target_host.value != "":
@@ -425,24 +417,24 @@ class Automation(Tab):
self.command.value = base self.command.value = base
def all_fs_to_lists(): def all_fs_to_lists():
self.fs["parentchildren"].clear() parentchildren.clear()
self.fs["parent"].clear() parent.clear()
self.fs["children"].clear() children.clear()
self.fs["exclude"].clear() exclude.clear()
for fs, v in self.fs["all"].items(): for fs, v in all_fs.items():
if v == "": if v == "":
self.fs["parentchildren"].append(fs) parentchildren.append(fs)
self.fs["parent"].append(fs) parent.append(fs)
self.fs["children"].append(fs) children.append(fs)
self.fs["exclude"].append(fs) exclude.append(fs)
elif v == "true": elif v == "true":
self.fs["parentchildren"].append(fs) parentchildren.append(fs)
elif v == "parent": elif v == "parent":
self.fs["parent"].append(fs) parent.append(fs)
elif v == "child": elif v == "child":
self.fs["children"].append(fs) children.append(fs)
elif v == "false": elif v == "false":
self.fs["exclude"].append(fs) exclude.append(fs)
def cull_fs_list(e: events.GenericEventArguments, value: str = "false") -> None: def cull_fs_list(e: events.GenericEventArguments, value: str = "false") -> None:
if e.sender != self.parentchildren: if e.sender != self.parentchildren:
@@ -453,11 +445,11 @@ class Automation(Tab):
self.children.disable() self.children.disable()
if e.sender != self.exclude: if e.sender != self.exclude:
self.exclude.disable() self.exclude.disable()
for fs, v in self.fs["all"].items(): for fs, v in all_fs.items():
if v == value: if v == value:
self.fs["all"][fs] = "" all_fs[fs] = ""
for fs in e.sender.value: for fs in e.sender.value:
self.fs["all"][fs] = value all_fs[fs] = value
all_fs_to_lists() all_fs_to_lists()
self.parentchildren.enable() self.parentchildren.enable()
self.parent.enable() self.parent.enable()
@@ -487,7 +479,7 @@ class Automation(Tab):
"ssh-config": self.zfs.config_path, "ssh-config": self.zfs.config_path,
} }
else: else:
self.default_options = self.job_data["options"] self.default_options = auto.options
self.options = zab.options self.options = zab.options
self.build_command = build_command self.build_command = build_command
filesystems = await self.zfs.filesystems filesystems = await self.zfs.filesystems
@@ -499,37 +491,37 @@ class Automation(Tab):
row.tailwind.width("[860px]").justify_content("center") row.tailwind.width("[860px]").justify_content("center")
with ui.column() as col: with ui.column() as col:
col.tailwind.height("full").width("[420px]") col.tailwind.height("full").width("[420px]")
self.prop = el.DInput(label="Property", value="autobackup:{name}", on_change=build_command, validation=validate_prop) self.prop = el.DInput(label="Property", value=auto.prop, on_change=build_command, validation=validate_prop)
self.app_em.append(self.prop) self.app_em.append(self.prop)
self.target_host = el.DSelect(target_host, label="Target Host", on_change=target_host_selected) self.target_host = el.DSelect(target_host, label="Target Host", on_change=target_host_selected)
self.target_paths = [""] self.target_paths = [""]
self.target_path = el.DSelect(self.target_paths, value="", label="Target Path", new_value_mode="add-unique", on_change=build_command) 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)", multiple=True, with_input=True) self.hosts = el.DSelect(source_hosts, label="Source Host(s)", value=auto.hosts, multiple=True, with_input=True)
all_fs_to_lists() all_fs_to_lists()
with ui.scroll_area().classes("col"): with ui.scroll_area().classes("col"):
self.parentchildren = el.DSelect( self.parentchildren = el.DSelect(
self.fs["parentchildren"], parentchildren,
label="Source Parent And Children", label="Source Parent And Children",
with_input=True, with_input=True,
multiple=True, multiple=True,
on_change=lambda e: cull_fs_list(e, "true"), on_change=lambda e: cull_fs_list(e, "true"),
) )
self.parent = el.DSelect( self.parent = el.DSelect(
self.fs["parent"], parent,
label="Source Parent Only", label="Source Parent Only",
with_input=True, with_input=True,
multiple=True, multiple=True,
on_change=lambda e: cull_fs_list(e, "parent"), on_change=lambda e: cull_fs_list(e, "parent"),
) )
self.children = el.DSelect( self.children = el.DSelect(
self.fs["children"], children,
label="Source Children Only", label="Source Children Only",
with_input=True, with_input=True,
multiple=True, multiple=True,
on_change=lambda e: cull_fs_list(e, "child"), on_change=lambda e: cull_fs_list(e, "child"),
) )
self.exclude = el.DSelect( self.exclude = el.DSelect(
self.fs["exclude"], exclude,
label="Exclude", label="Exclude",
with_input=True, with_input=True,
multiple=True, multiple=True,
@@ -538,10 +530,14 @@ class Automation(Tab):
with ui.column() as col: with ui.column() as col:
col.tailwind.height("full").width("[420px]") col.tailwind.height("full").width("[420px]")
options_controls() 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 != "": if name != "":
self.prop.value = self.job_data.get("prop", "autobackup:{name}") self.target_host.value = auto.target_host
self.target_host.value = self.job_data.get("target_host", "") target_path = auto.target_path
target_path = self.job_data.get("target_path", "")
tries = 0 tries = 0
while target_path not in self.target_path.options and tries < 20: while target_path not in self.target_path.options and tries < 20:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@@ -549,11 +545,6 @@ class Automation(Tab):
if target_path not in self.target_paths: if target_path not in self.target_paths:
self.target_paths.append(target_path) self.target_paths.append(target_path)
self.target_path.value = 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: else:
self.hosts.value = [self.host] self.hosts.value = [self.host]
@@ -657,7 +648,7 @@ class Automation(Tab):
if name == "": if name == "":
self.default_triggers = {"id": {"type": "Cron", "value": ""}} self.default_triggers = {"id": {"type": "Cron", "value": ""}}
else: else:
self.default_triggers = self.job_data["triggers"] self.default_triggers = self.auto.triggers
with ui.row() as row: with ui.row() as row:
row.tailwind(tw_rows) row.tailwind(tw_rows)
self.current_trigger = el.FSelect(["Cron", "Interval"], value="Cron", label="Trigger", with_input=True) self.current_trigger = el.FSelect(["Cron", "Interval"], value="Cron", label="Trigger", with_input=True)
@@ -681,7 +672,10 @@ class Automation(Tab):
if self.app.value is not None: if self.app.value is not None:
with options_col: with options_col:
if self.app.value == "zfs_autobackup": 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": if self.app.value == "local":
local_controls() local_controls()
if self.app.value == "remote": if self.app.value == "remote":
@@ -690,20 +684,15 @@ class Automation(Tab):
self.stepper.next() self.stepper.next()
def local_controls(): def local_controls():
command_input = el.DInput("Command").bind_value_to(self.command, "value") el.DInput("Command", value=self.auto.command).bind_value_to(self.command, "value")
if name != "":
command_input.value = self.job_data["command"]
def remote_controls(): def remote_controls():
command_input = el.DInput("Command").bind_value_to(self.command, "value") command_input = el.DInput("Command", value=self.auto.command).bind_value_to(self.command, "value")
self.hosts = el.DSelect(self._zfs_hosts, value=self.host, label="Hosts", with_input=True, multiple=True) 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) 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): def to_interval(value: str):
interval = string.split(":", 4) interval = value.split(":", 4)
interval = interval + ["0"] * (5 - len(interval)) 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])) return IntervalTrigger(weeks=int(interval[0]), days=int(interval[1]), hours=int(interval[2]), minutes=int(interval[3]), seconds=int(interval[4]))
@@ -714,7 +703,7 @@ class Automation(Tab):
if "Cron" == value["type"]: if "Cron" == value["type"]:
triggers.append(CronTrigger().from_crontab(value["value"])) triggers.append(CronTrigger().from_crontab(value["value"]))
elif "Interval" == value["type"]: elif "Interval" == value["type"]:
triggers.append(string_to_interval(value["value"])) triggers.append(to_interval(value["value"]))
return combine(triggers) return combine(triggers)
def validate_hosts(e): def validate_hosts(e):
@@ -734,11 +723,11 @@ class Automation(Tab):
col.tailwind.height("full").width("[420px]") col.tailwind.height("full").width("[420px]")
self.auto_name = el.DInput(label="Name", value=" ", validation=validate_name) self.auto_name = el.DInput(label="Name", value=" ", validation=validate_name)
with el.WRow(): with el.WRow():
self.pipe_success = el.DCheckbox("Pipe Success", value=self.job_data.get("pipe_success", False)) self.pipe_success = el.DCheckbox("Pipe Success", value=self.auto.pipe_success)
self.pipe_error = el.DCheckbox("Pipe Error", value=self.job_data.get("pipe_error", False)) self.pipe_error = el.DCheckbox("Pipe Error", value=self.auto.pipe_error)
self.schedule_em = el.ErrorAggregator(self.auto_name) self.schedule_em = el.ErrorAggregator(self.auto_name)
if 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: else:
self.app = el.DSelect( self.app = el.DSelect(
["zfs_autobackup", "local", "remote"], ["zfs_autobackup", "local", "remote"],
@@ -771,10 +760,9 @@ class Automation(Tab):
self.auto_name.value = name self.auto_name.value = name
if name != "": if name != "":
self.auto_name.props("readonly") 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 result = await automation_dialog
if result == "save": if result == "save":
auto: Union[scheduler.Automation, scheduler.Zfs_Autobackup]
auto_name = self.auto_name.value.lower() auto_name = self.auto_name.value.lower()
if hasattr(self, "hosts"): if hasattr(self, "hosts"):
hosts = self.hosts.value hosts = self.hosts.value
@@ -782,11 +770,15 @@ class Automation(Tab):
hosts = [self.host] hosts = [self.host]
if self.app.value == "zfs_autobackup": if self.app.value == "zfs_autobackup":
for job in jobs: for job in jobs:
j = job.id.split("@")[0] existing_auto = automation(job)
if j == auto_name: if existing_auto is not None and existing_auto.name == auto_name:
self.scheduler.scheduler.remove_job(job.id) self.scheduler.scheduler.remove_job(job.id)
for host in hosts: for host in hosts:
auto_id = f"{auto_name}@{host}" auto_id = f"{auto_name}@{host}"
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) command = AutomationTemplate(self.prop.value)
prop = command.safe_substitute(name=auto_name, host=host) prop = command.safe_substitute(name=auto_name, host=host)
await self._remove_prop_from_all_fs(host=host, prop=prop) await self._remove_prop_from_all_fs(host=host, prop=prop)
@@ -794,11 +786,6 @@ class Automation(Tab):
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="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="child", filesystems=self.children.value)
await self._add_prop_to_fs(host=host, prop=prop, value="false", filesystems=self.exclude.value) await self._add_prop_to_fs(host=host, prop=prop, 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
auto = scheduler.Zfs_Autobackup( auto = scheduler.Zfs_Autobackup(
id=auto_id, id=auto_id,
name=auto_name, name=auto_name,
@@ -811,10 +798,13 @@ class Automation(Tab):
target_host=self.target_host.value, target_host=self.target_host.value,
target_path=self.target_path.value, target_path=self.target_path.value,
target_paths=self.target_path.options, target_paths=self.target_path.options,
filesystems=self.fs,
pipe_success=self.pipe_success.value, pipe_success=self.pipe_success.value,
pipe_error=self.pipe_error.value, pipe_error=self.pipe_error.value,
prop=self.prop.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( self.scheduler.scheduler.add_job(
automation_job, automation_job,
@@ -827,8 +817,8 @@ class Automation(Tab):
) )
elif self.app.value == "remote": elif self.app.value == "remote":
for job in jobs: for job in jobs:
j = job.id.split("@")[0] auto = automation(job)
if j == auto_name: if auto is not None and auto.name == auto_name:
self.scheduler.scheduler.remove_job(job.id) self.scheduler.scheduler.remove_job(job.id)
for host in hosts: for host in hosts:
auto_id = f"{auto_name}@{host}" auto_id = f"{auto_name}@{host}"
+8 -2
View File
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
import json import json
from nicegui import ui, events from nicegui import ui, events # type: ignore
import httpx import httpx
from . import SelectionConfirm, Tab from . import SelectionConfirm, Tab
from bale import elements as el from bale import elements as el
@@ -34,7 +34,12 @@ class History(Tab):
"rowSelection": "multiple", "rowSelection": "multiple",
"paginationAutoPageSize": True, "paginationAutoPageSize": True,
"pagination": True, "pagination": True,
"defaultColDef": {"resizable": True, "sortable": True, "suppressMovable": True, "sortingOrder": ["asc", "desc"]}, "defaultColDef": {
"resizable": True,
"sortable": True,
"suppressMovable": True,
"sortingOrder": ["asc", "desc"],
},
"columnDefs": [ "columnDefs": [
{ {
"headerName": "Host", "headerName": "Host",
@@ -57,6 +62,7 @@ class History(Tab):
var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});; var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});;
return date; return date;
}""", }""",
"sort": "desc",
}, },
{ {
"headerName": "Status", "headerName": "Status",
+30 -27
View File
@@ -1,6 +1,6 @@
import asyncio import asyncio
from copy import deepcopy from copy import deepcopy
from nicegui import background_tasks, ui from nicegui import background_tasks, ui # type: ignore
from . import SelectionConfirm, Tab, Task from . import SelectionConfirm, Tab, Task
from bale.result import Result from bale.result import Result
from bale import elements as el 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});; var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});;
return date; return date;
}""", }""",
"sort": "desc",
}, },
{"headerName": "Holds", "field": "userrefs", "filter": "agNumberColumnFilter", "maxWidth": 100}, {"headerName": "Holds", "field": "userrefs", "filter": "agNumberColumnFilter", "maxWidth": 100},
], ],
@@ -259,32 +260,34 @@ class Manage(Tab):
if result == "confirm": if result == "confirm":
self._spinner.visible = True self._spinner.visible = True
rows = await self._grid.get_selected_rows() rows = await self._grid.get_selected_rows()
for row in rows: if len(rows) > 0:
holds = await self.zfs.holds_for_snapshot(f"{row['filesystem']}@{row['name']}") for row in rows:
for tag in holds.data: holds = await self.zfs.holds_for_snapshot(f"{row['filesystem']}@{row['name']}")
if tag not in all_tags: for tag in holds.data:
all_tags.append(tag) if tag not in all_tags:
if len(all_tags) > 0: all_tags.append(tag)
tags.update() if len(all_tags) > 0:
self._spinner.visible = False tags.update()
result = await dialog self._spinner.visible = False
if result == "release": result = await dialog
if len(tags.value) > 0: if result == "release":
for tag in tags.value: if len(tags.value) > 0:
for row in rows: for tag in tags.value:
tasks = self._add_task( for row in rows:
"release", tasks = self._add_task(
zfs.SnapshotRelease( "release",
name=f"{row['filesystem']}@{row['name']}", zfs.SnapshotRelease(
tag=tag, name=f"{row['filesystem']}@{row['name']}",
recursive=recursive.value, tag=tag,
).command, recursive=recursive.value,
hosts=zfs_hosts.value, ).command,
) hosts=zfs_hosts.value,
if self._auto.value is True: )
for task in tasks: if self._auto.value is True:
await self._run_task(task=task, spinner=self._spinner) for task in tasks:
await self.display_snapshots() await self._run_task(task=task, spinner=self._spinner)
await self.display_snapshots()
self._spinner.visible = False
self._set_selection() self._set_selection()
def _update_task_status(self, timestamp, status, result=None): def _update_task_status(self, timestamp, status, result=None):
+13 -3
View File
@@ -5,9 +5,18 @@ logger = logging.getLogger(__name__)
import os import os
if not os.path.exists("data"): 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") 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_style("max-width: none")
ui.card.default_props("flat bordered") ui.card.default_props("flat bordered")
@@ -22,7 +31,8 @@ from bale import page, logo, scheduler
if __name__ in {"__main__", "__mp_main__"}: 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() page.build()
s = scheduler.Scheduler() s = scheduler.Scheduler()
ui.timer(0.1, s.start, once=True) 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)
+56 -4
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 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-descriptor==1.4.0
cron-validator==1.0.8 cron-validator==1.0.8
nicegui==1.4.2 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 zfs-autobackup==3.2
netifaces==0.11.0
asyncssh==2.14.0