mirror of
https://github.com/natankeddem/bale.git
synced 2026-05-03 06:02:54 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fada159fa0 | |||
| e09f68424e | |||
| e5941a87ad | |||
| 0425cdd110 | |||
| c3ee280cd8 | |||
| 141c34d9b4 | |||
| 2813cf050e | |||
| 458cf05780 | |||
| 483043bd4e | |||
| ce898250dd | |||
| 5cba893282 | |||
| db4f340898 | |||
| f58b03a86b | |||
| 61f297aa0b | |||
| 425b607e8c | |||
| c01989210b | |||
| 0148b23310 | |||
| 0221780a19 | |||
| 9ea536193b | |||
| fcd8362464 |
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}"
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
Reference in New Issue
Block a user