mirror of
https://github.com/natankeddem/bale.git
synced 2026-05-03 14:12:54 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fada159fa0 | |||
| e09f68424e | |||
| e5941a87ad | |||
| 0425cdd110 | |||
| c3ee280cd8 | |||
| 141c34d9b4 | |||
| 2813cf050e | |||
| 458cf05780 | |||
| 483043bd4e | |||
| ce898250dd | |||
| 5cba893282 | |||
| db4f340898 | |||
| f58b03a86b | |||
| 61f297aa0b | |||
| 425b607e8c | |||
| c01989210b | |||
| 0148b23310 | |||
| 0221780a19 | |||
| 9ea536193b | |||
| fcd8362464 | |||
| 36ee1f94cd | |||
| 07ce7e0bae |
@@ -6,12 +6,12 @@ name: Docker
|
||||
# documentation.
|
||||
|
||||
on:
|
||||
# schedule:
|
||||
# - cron: '20 13 * * *'
|
||||
# schedule:
|
||||
# - cron: '20 13 * * *'
|
||||
push:
|
||||
branches: ["master"]
|
||||
branches: [ "master" ]
|
||||
# Publish semver tags as releases.
|
||||
tags: ["v*.*.*"]
|
||||
tags: [ 'v*.*.*' ]
|
||||
# pull_request:
|
||||
# branches: [ "master" ]
|
||||
|
||||
@@ -21,8 +21,10 @@ env:
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -39,19 +41,21 @@ jobs:
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
- name: Install cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1
|
||||
uses: sigstore/cosign-installer@v3.3.0
|
||||
with:
|
||||
cosign-release: "v2.1.1"
|
||||
cosign-release: 'v2.2.2' # optional
|
||||
|
||||
# Workaround: https://github.com/docker/build-push-action/issues/461
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf
|
||||
# Set up BuildKit Docker container builder to be able to build
|
||||
# multi-platform images and export cache
|
||||
# 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
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c
|
||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -61,7 +65,7 @@ jobs:
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
@@ -69,7 +73,7 @@ jobs:
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -92,4 +96,4 @@ jobs:
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.11.6-bookworm
|
||||
FROM python:3.12.2-bookworm
|
||||
|
||||
RUN echo "**** install runtime dependencies ****"
|
||||
RUN apt update
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
from nicegui import ui
|
||||
from nicegui import ui # type: ignore
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user