mirror of
https://github.com/natankeddem/bale.git
synced 2026-05-03 06:02:54 +00:00
Compare commits
27 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 | |||
| ffbc9b71c0 | |||
| f3ef97a342 | |||
| 94fba0b925 | |||
| 8572ad766b | |||
| 8a2922262e |
@@ -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
|
||||||
@@ -92,4 +96,4 @@ jobs:
|
|||||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||||
# This step uses the identity token to provision an ephemeral certificate
|
# This step uses the identity token to provision an ephemeral certificate
|
||||||
# against the sigstore community Fulcio instance.
|
# 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 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)
|
||||||
|
|
||||||
|
|||||||
+58
-9
@@ -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
|
||||||
|
|
||||||
@@ -71,8 +71,11 @@ class ErrorAggregator:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def no_errors(self) -> bool:
|
def no_errors(self) -> bool:
|
||||||
validators = all(validation(element.value) for element in self.elements for validation in element.validation.values())
|
if len(self.elements) > 0:
|
||||||
return self.enable and validators
|
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):
|
class WColumn(ui.column):
|
||||||
@@ -128,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,
|
||||||
|
|||||||
+34
-8
@@ -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
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ class Cli:
|
|||||||
self.stderr: List[str] = []
|
self.stderr: List[str] = []
|
||||||
self._terminate: asyncio.Event = asyncio.Event()
|
self._terminate: asyncio.Event = asyncio.Event()
|
||||||
self._busy: bool = False
|
self._busy: bool = False
|
||||||
|
self._truncated: bool = False
|
||||||
self.prefix_line: str = ""
|
self.prefix_line: str = ""
|
||||||
self._stdout_terminals: List[Terminal] = []
|
self._stdout_terminals: List[Terminal] = []
|
||||||
self._stderr_terminals: List[Terminal] = []
|
self._stderr_terminals: List[Terminal] = []
|
||||||
@@ -70,8 +71,11 @@ class Cli:
|
|||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
async def _controller(self, process: Process) -> None:
|
async def _controller(self, process: Process, max_output_lines) -> None:
|
||||||
while process.returncode is None:
|
while process.returncode is None:
|
||||||
|
if max_output_lines > 0 and len(self.stderr) + len(self.stdout) > max_output_lines:
|
||||||
|
self._truncated = True
|
||||||
|
process.terminate()
|
||||||
if self._terminate.is_set():
|
if self._terminate.is_set():
|
||||||
process.terminate()
|
process.terminate()
|
||||||
try:
|
try:
|
||||||
@@ -83,7 +87,7 @@ class Cli:
|
|||||||
def terminate(self) -> None:
|
def terminate(self) -> None:
|
||||||
self._terminate.set()
|
self._terminate.set()
|
||||||
|
|
||||||
async def execute(self, command: str) -> Result:
|
async def execute(self, command: str, max_output_lines: int = 0) -> Result:
|
||||||
self._busy = True
|
self._busy = True
|
||||||
c = shlex.split(command, posix=False)
|
c = shlex.split(command, posix=False)
|
||||||
try:
|
try:
|
||||||
@@ -92,13 +96,14 @@ class Cli:
|
|||||||
self.stdout.clear()
|
self.stdout.clear()
|
||||||
self.stderr.clear()
|
self.stderr.clear()
|
||||||
self._terminate.clear()
|
self._terminate.clear()
|
||||||
|
self._truncated = False
|
||||||
terminated = 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),
|
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),
|
||||||
)
|
)
|
||||||
@@ -110,29 +115,50 @@ class Cli:
|
|||||||
finally:
|
finally:
|
||||||
self._terminate.clear()
|
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=terminated)
|
return Result(
|
||||||
|
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) -> 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)
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
+21
-13
@@ -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, 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
|
||||||
@@ -87,7 +87,7 @@ class SshFileBrowse(ui.dialog):
|
|||||||
row.tailwind.height("[40px]")
|
row.tailwind.height("[40px]")
|
||||||
el.DButton("Download", on_click=self._start_download)
|
el.DButton("Download", on_click=self._start_download)
|
||||||
ui.button("Exit", on_click=lambda: self.submit("exit"))
|
ui.button("Exit", on_click=lambda: self.submit("exit"))
|
||||||
await self._update_grid()
|
await self._update_handler()
|
||||||
|
|
||||||
async def _connect(self) -> Tuple[asyncssh.SSHClientConnection, asyncssh.SFTPClient]:
|
async def _connect(self) -> Tuple[asyncssh.SSHClientConnection, asyncssh.SFTPClient]:
|
||||||
ssh = await asyncssh.connect(self._zfs.hostname, username=self._zfs.username, client_keys=[self._zfs.key_path])
|
ssh = await asyncssh.connect(self._zfs.hostname, username=self._zfs.username, client_keys=[self._zfs.key_path])
|
||||||
@@ -137,7 +137,7 @@ class SshFileBrowse(ui.dialog):
|
|||||||
"permissions": attributes.permissions,
|
"permissions": attributes.permissions,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _update_grid(self) -> None:
|
async def _update_handler(self) -> None:
|
||||||
self._grid.call_api_method("showLoadingOverlay")
|
self._grid.call_api_method("showLoadingOverlay")
|
||||||
if self._ssh is None or self._sftp is None:
|
if self._ssh is None or self._sftp is None:
|
||||||
self._ssh, self._sftp = await self._connect()
|
self._ssh, self._sftp = await self._connect()
|
||||||
@@ -165,7 +165,7 @@ class SshFileBrowse(ui.dialog):
|
|||||||
async def _handle_double_click(self, e: events.GenericEventArguments) -> None:
|
async def _handle_double_click(self, e: events.GenericEventArguments) -> None:
|
||||||
self.path = e.args["data"]["path"]
|
self.path = e.args["data"]["path"]
|
||||||
if e.args["data"]["type"] == "directory":
|
if e.args["data"]["type"] == "directory":
|
||||||
await self._update_grid()
|
await self._update_handler()
|
||||||
else:
|
else:
|
||||||
await self._start_download(e)
|
await self._start_download(e)
|
||||||
|
|
||||||
@@ -226,8 +226,10 @@ class SshFileFind(SshFileBrowse):
|
|||||||
with el.DBody(height="fit", width="[90vw]"):
|
with el.DBody(height="fit", width="[90vw]"):
|
||||||
with el.WColumn().classes("col"):
|
with el.WColumn().classes("col"):
|
||||||
filesystems = await self._zfs.filesystems
|
filesystems = await self._zfs.filesystems
|
||||||
self._filesystem = el.DSelect(list(filesystems.data.keys()), label="filesystem", with_input=True, on_change=self._update_grid)
|
self._filesystem = el.DSelect(list(filesystems.data.keys()), label="filesystem", with_input=True, on_change=self._update_handler)
|
||||||
self._pattern = el.DInput("Pattern", on_change=self._update_grid)
|
with el.WRow():
|
||||||
|
self._pattern = ui.input("Pattern").classes("col").on("keydown.enter", handler=self._update_handler)
|
||||||
|
el.LgButton(icon="search", on_click=self._update_handler)
|
||||||
self._grid = ui.aggrid(
|
self._grid = ui.aggrid(
|
||||||
{
|
{
|
||||||
"defaultColDef": {"flex": 1, "sortable": True, "suppressMovable": True, "sortingOrder": ["asc", "desc"]},
|
"defaultColDef": {"flex": 1, "sortable": True, "suppressMovable": True, "sortingOrder": ["asc", "desc"]},
|
||||||
@@ -238,7 +240,7 @@ class SshFileFind(SshFileBrowse):
|
|||||||
"headerName": "Modified",
|
"headerName": "Modified",
|
||||||
"field": "modified_timestamp",
|
"field": "modified_timestamp",
|
||||||
"filter": "agTextColumnFilter",
|
"filter": "agTextColumnFilter",
|
||||||
"maxWidth": 200,
|
"maxWidth": 125,
|
||||||
":cellRenderer": """(data) => {
|
":cellRenderer": """(data) => {
|
||||||
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;
|
||||||
@@ -264,15 +266,21 @@ class SshFileFind(SshFileBrowse):
|
|||||||
row.tailwind.height("[40px]")
|
row.tailwind.height("[40px]")
|
||||||
el.DButton("Download", on_click=self._start_download)
|
el.DButton("Download", on_click=self._start_download)
|
||||||
ui.button("Exit", on_click=lambda: self.submit("exit"))
|
ui.button("Exit", on_click=lambda: self.submit("exit"))
|
||||||
await self._update_grid()
|
self._grid.call_api_method("hideOverlay")
|
||||||
|
|
||||||
async def _update_grid(self) -> None:
|
async def _update_handler(self) -> None:
|
||||||
self._grid.call_api_method("showLoadingOverlay")
|
if len(self._pattern.value) > 0 and self._filesystem is not None:
|
||||||
if self._filesystem is not None:
|
self._grid.call_api_method("showLoadingOverlay")
|
||||||
|
self._filesystem.props("readonly")
|
||||||
|
self._pattern.props("readonly")
|
||||||
files = await self._zfs.find_files_in_snapshots(filesystem=self._filesystem.value, pattern=self._pattern.value)
|
files = await self._zfs.find_files_in_snapshots(filesystem=self._filesystem.value, pattern=self._pattern.value)
|
||||||
self._grid.options["rowData"] = files.data
|
self._grid.options["rowData"] = files.data
|
||||||
self._grid.update()
|
if files.truncated is True:
|
||||||
self._grid.call_api_method("hideOverlay")
|
el.notify("Too many files found, truncating list.", type="warning")
|
||||||
|
self._grid.update()
|
||||||
|
self._filesystem.props(remove="readonly")
|
||||||
|
self._pattern.props(remove="readonly")
|
||||||
|
self._grid.call_api_method("hideOverlay")
|
||||||
|
|
||||||
async def _handle_double_click(self, e: events.GenericEventArguments) -> None:
|
async def _handle_double_click(self, e: events.GenericEventArguments) -> None:
|
||||||
await self._start_download(e)
|
await self._start_download(e)
|
||||||
|
|||||||
+50
-35
@@ -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
|
||||||
@@ -81,7 +81,7 @@ class Zfs:
|
|||||||
command = command if len(command) < 160 else command[:160] + "..."
|
command = command if len(command) < 160 else command[:160] + "..."
|
||||||
el.notify(command)
|
el.notify(command)
|
||||||
|
|
||||||
async def execute(self, command: str, notify: bool = True) -> Result:
|
async def execute(self, command: str, max_output_lines: int = 0, notify: bool = True) -> Result:
|
||||||
if notify:
|
if notify:
|
||||||
self.notify(command)
|
self.notify(command)
|
||||||
return Result(command=command)
|
return Result(command=command)
|
||||||
@@ -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:
|
||||||
@@ -166,27 +170,28 @@ class Zfs:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
async def find_files_in_snapshots(self, filesystem: str, pattern: str) -> Result:
|
async def find_files_in_snapshots(self, filesystem: str, pattern: str) -> Result:
|
||||||
filesystems = await self.filesystems
|
try:
|
||||||
if filesystem in filesystems.data.keys():
|
filesystems = await self.filesystems
|
||||||
if "mountpoint" in filesystems.data[filesystem]:
|
command = f"find {filesystems.data[filesystem]['mountpoint']}/.zfs/snapshot -type f -name '{pattern}' -printf '%h\t%f\t%s\t%T@\n'"
|
||||||
command = f"find {filesystems.data[filesystem]['mountpoint']}/.zfs/snapshot -type f -name '{pattern}' -printf '%h\t%f\t%s\t%T@\n'"
|
result = await self.execute(command=command, notify=False, max_output_lines=1000)
|
||||||
result = await self.execute(command=command, notify=False)
|
files = []
|
||||||
files = []
|
for line in result.stdout_lines:
|
||||||
for line in result.stdout_lines:
|
matches = re.match(
|
||||||
matches = re.match(
|
"^(?P<location>[^\t]+)\t(?P<name>[^\t]+)\t(?P<bytes>[^\t]+)\t(?P<modified_timestamp>[^\n]+)",
|
||||||
"^(?P<location>[^\t]+)\t(?P<name>[^\t]+)\t(?P<bytes>[^\t]+)\t(?P<modified_timestamp>[^\n]+)",
|
line,
|
||||||
line,
|
)
|
||||||
)
|
if matches is not None:
|
||||||
if matches is not None:
|
md = matches.groupdict()
|
||||||
md = matches.groupdict()
|
md["path"] = f"{md['location']}/{md['name']}"
|
||||||
md["path"] = f"{md['location']}/{md['name']}"
|
md["bytes"] = int(md["bytes"])
|
||||||
md["bytes"] = int(md["bytes"])
|
md["size"] = format_bytes(md["bytes"])
|
||||||
md["size"] = format_bytes(md["bytes"])
|
md["modified_datetime"] = datetime.fromtimestamp(float(md["modified_timestamp"])).strftime("%Y/%m/%d %H:%M:%S")
|
||||||
md["modified_datetime"] = datetime.fromtimestamp(float(md["modified_timestamp"])).strftime("%Y/%m/%d %H:%M:%S")
|
md["modified_timestamp"] = float(md["modified_timestamp"])
|
||||||
md["modified_timestamp"] = float(md["modified_timestamp"])
|
files.append(md)
|
||||||
files.append(md)
|
result.data = files
|
||||||
result.data = files
|
return result
|
||||||
return result
|
except KeyError:
|
||||||
|
pass
|
||||||
return Result()
|
return Result()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -226,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
|
||||||
@@ -237,17 +243,26 @@ 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):
|
||||||
super().notify(f"<{self.host}> {command}")
|
super().notify(f"<{self.host}> {command}")
|
||||||
|
|
||||||
async def execute(self, command: str, notify: bool = True) -> Result:
|
async def execute(self, command: str, max_output_lines: int = 0, notify: bool = True) -> Result:
|
||||||
if notify:
|
if notify:
|
||||||
self.notify(command)
|
self.notify(command)
|
||||||
result = await super().execute(command)
|
result = await super().execute(command, max_output_lines)
|
||||||
if result.stderr != "":
|
if result.stderr != "":
|
||||||
el.notify(result.stderr, type="negative")
|
el.notify(result.stderr, type="negative")
|
||||||
result.name = self.host
|
result.name = self.host
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class Result:
|
|||||||
stdout_lines: List[str] = field(default_factory=list)
|
stdout_lines: List[str] = field(default_factory=list)
|
||||||
stderr_lines: List[str] = field(default_factory=list)
|
stderr_lines: List[str] = field(default_factory=list)
|
||||||
terminated: bool = False
|
terminated: bool = False
|
||||||
|
truncated: bool = False
|
||||||
data: Any = None
|
data: Any = None
|
||||||
trace: str = ""
|
trace: str = ""
|
||||||
cached: bool = False
|
cached: bool = False
|
||||||
|
|||||||
+20
-16
@@ -5,22 +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 = ""
|
||||||
app: str
|
name: str = ""
|
||||||
hosts: List[str]
|
app: str = "remote"
|
||||||
host: str
|
hosts: List[str] = field(default_factory=list)
|
||||||
command: str
|
host: str = ""
|
||||||
schedule_mode: str
|
command: str = ""
|
||||||
triggers: Dict[str, str]
|
schedule_mode: str = ""
|
||||||
options: Union[Dict[str, Any], None] = None
|
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)
|
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__
|
||||||
@@ -29,11 +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}"
|
||||||
target_host: str
|
target_host: str = ""
|
||||||
target_path: str
|
target_path: str = ""
|
||||||
target_paths: List[str]
|
target_paths: List[str] = field(default_factory=list)
|
||||||
filesystems: Dict[str, Union[str, List[str], Dict[str, str]]]
|
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:
|
class _Scheduler:
|
||||||
|
|||||||
+10
-12
@@ -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,18 +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")
|
)
|
||||||
ui.label(f"Date: {result.date}").classes("text-secondary")
|
ui.label(f"Timestamp: {timestamp}").classes("text-secondary")
|
||||||
with ui.column() as col:
|
ui.label(f"Return Code: {result.return_code}").classes("text-secondary")
|
||||||
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"Time: {result.time}").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})
|
||||||
|
|||||||
+195
-186
@@ -1,14 +1,17 @@
|
|||||||
from typing import Any, Dict, List, Union
|
from typing import Any, Callable, Dict, List, Union
|
||||||
import asyncio
|
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,61 +28,74 @@ 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]
|
||||||
|
|
||||||
|
|
||||||
class CommandTemplate(string.Template):
|
class AutomationTemplate(string.Template):
|
||||||
delimiter = ""
|
delimiter = ""
|
||||||
|
|
||||||
|
|
||||||
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 = CommandTemplate(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(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(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(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,10 +111,10 @@ 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: str = ""
|
self.build_command: Callable
|
||||||
self.target_host: el.DSelect
|
self.target_host: el.DSelect
|
||||||
self.target_paths: List[str] = [""]
|
self.target_paths: List[str] = [""]
|
||||||
self.target_path: el.DSelect
|
self.target_path: el.DSelect
|
||||||
@@ -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)
|
||||||
@@ -161,8 +180,17 @@ class Automation(Tab):
|
|||||||
"maxWidth": 150,
|
"maxWidth": 150,
|
||||||
},
|
},
|
||||||
{"headerName": "Command", "field": "command", "filter": "agTextColumnFilter"},
|
{"headerName": "Command", "field": "command", "filter": "agTextColumnFilter"},
|
||||||
{"headerName": "Next Date", "field": "next_run_date", "filter": "agDateColumnFilter", "maxWidth": 100},
|
{
|
||||||
{"headerName": "Next Time", "field": "next_run_time", "maxWidth": 100},
|
"headerName": "Next Run",
|
||||||
|
"field": "next_run",
|
||||||
|
"filter": "agTextColumnFilter",
|
||||||
|
"maxWidth": 125,
|
||||||
|
":cellRenderer": """(data) => {
|
||||||
|
var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});;
|
||||||
|
return date;
|
||||||
|
}""",
|
||||||
|
"sort": "asc",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"headerName": "Status",
|
"headerName": "Status",
|
||||||
"field": "status",
|
"field": "status",
|
||||||
@@ -186,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():
|
||||||
@@ -227,23 +254,12 @@ class Automation(Tab):
|
|||||||
self._automations.clear()
|
self._automations.clear()
|
||||||
for job in self.scheduler.scheduler.get_jobs():
|
for job in self.scheduler.scheduler.get_jobs():
|
||||||
if job.next_run_time is not None:
|
if job.next_run_time is not None:
|
||||||
next_run_date = job.next_run_time.strftime("%Y/%m/%d")
|
next_run = job.next_run_time.timestamp()
|
||||||
next_run_time = job.next_run_time.strftime("%H:%M")
|
|
||||||
else:
|
else:
|
||||||
next_run_date = "NA"
|
next_run = "NA"
|
||||||
next_run_time = "NA"
|
auto = automation(job)
|
||||||
if "data" in job.kwargs:
|
if auto is not None and auto.host == self.host:
|
||||||
jd = json.loads(job.kwargs["data"])
|
self._automations.append({"name": auto.name, "command": auto.command, "next_run": next_run, "status": ""})
|
||||||
if self.host == jd["host"]:
|
|
||||||
self._automations.append(
|
|
||||||
{
|
|
||||||
"name": job.id.split("@")[0],
|
|
||||||
"command": jd["command"],
|
|
||||||
"next_run_date": next_run_date,
|
|
||||||
"next_run_time": next_run_time,
|
|
||||||
"status": "",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self._grid.update()
|
self._grid.update()
|
||||||
|
|
||||||
async def _remove_automation(self) -> None:
|
async def _remove_automation(self) -> None:
|
||||||
@@ -253,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()
|
||||||
@@ -272,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<")
|
||||||
@@ -303,26 +303,17 @@ class Automation(Tab):
|
|||||||
await self._create_automation(rows[0]["name"])
|
await self._create_automation(rows[0]["name"])
|
||||||
self._set_selection()
|
self._set_selection()
|
||||||
|
|
||||||
async def _add_prop_to_fs(
|
async def _add_prop_to_fs(self, host: str, prop: str, value: str, filesystems: Union[List[str], None] = None) -> None:
|
||||||
self,
|
|
||||||
host: str,
|
|
||||||
prop: str,
|
|
||||||
value: str,
|
|
||||||
module: str = "autobackup",
|
|
||||||
filesystems: Union[List[str], None] = None,
|
|
||||||
) -> None:
|
|
||||||
if filesystems is not None:
|
if filesystems is not None:
|
||||||
full_prop = f"{module}:{prop}"
|
|
||||||
for fs in filesystems:
|
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)
|
self.add_history(result=result)
|
||||||
|
|
||||||
async def _remove_prop_from_all_fs(self, host: str, prop: str, module: str = "autobackup") -> None:
|
async def _remove_prop_from_all_fs(self, host: str, prop: str) -> None:
|
||||||
full_prop = f"{module}:{prop}"
|
filesystems_with_prop_result = await self._zfs[host].filesystems_with_prop(prop)
|
||||||
filesystems_with_prop_result = await self._zfs[host].filesystems_with_prop(full_prop)
|
|
||||||
filesystems_with_prop = list(filesystems_with_prop_result.data)
|
filesystems_with_prop = list(filesystems_with_prop_result.data)
|
||||||
for fs in filesystems_with_prop:
|
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)
|
self.add_history(result=result)
|
||||||
|
|
||||||
async def _create_automation(self, name: str = "") -> None:
|
async def _create_automation(self, name: str = "") -> None:
|
||||||
@@ -331,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 != ""):
|
||||||
@@ -383,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 != "":
|
||||||
@@ -416,36 +404,37 @@ class Automation(Tab):
|
|||||||
self.target_path.update()
|
self.target_path.update()
|
||||||
self.target_path.value = ""
|
self.target_path.value = ""
|
||||||
|
|
||||||
async def target_path_selected() -> None:
|
|
||||||
self.build_command()
|
|
||||||
|
|
||||||
def build_command() -> None:
|
def build_command() -> None:
|
||||||
|
try:
|
||||||
|
prop_suffix = self.prop.value.split(":")[1]
|
||||||
|
except IndexError:
|
||||||
|
prop_suffix = ""
|
||||||
base = ""
|
base = ""
|
||||||
for key, value in self.picked_options.items():
|
for key, value in self.picked_options.items():
|
||||||
base = base + f" --{key}{f' {value}' if value != '' else ''}"
|
base = base + f" --{key}{f' {value}' if value != '' else ''}"
|
||||||
target_path = f"{f' {self.target_path.value}' if self.target_path.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
|
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:
|
||||||
@@ -456,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()
|
||||||
@@ -471,6 +460,17 @@ class Automation(Tab):
|
|||||||
self.children.update()
|
self.children.update()
|
||||||
self.exclude.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 == "":
|
if name == "":
|
||||||
self.default_options = {
|
self.default_options = {
|
||||||
"verbose": "",
|
"verbose": "",
|
||||||
@@ -479,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
|
||||||
@@ -491,35 +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=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_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=target_path_selected)
|
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,
|
||||||
@@ -528,9 +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.target_host.value = self.job_data.get("target_host", "")
|
self.target_host.value = auto.target_host
|
||||||
target_path = self.job_data.get("target_path", "")
|
target_path = auto.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)
|
||||||
@@ -538,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]
|
||||||
|
|
||||||
@@ -646,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)
|
||||||
@@ -670,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":
|
||||||
@@ -679,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]))
|
||||||
|
|
||||||
@@ -703,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):
|
||||||
@@ -723,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"],
|
||||||
@@ -753,15 +753,16 @@ class Automation(Tab):
|
|||||||
with el.WRow() as row:
|
with el.WRow() as row:
|
||||||
row.tailwind.height("[40px]")
|
row.tailwind.height("[40px]")
|
||||||
self.as_spinner = el.Spinner()
|
self.as_spinner = el.Spinner()
|
||||||
|
self.app_em = el.ErrorAggregator()
|
||||||
self.save = el.DButton("SAVE", on_click=lambda: automation_dialog.submit("save"))
|
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)
|
el.Spinner(master=self.as_spinner)
|
||||||
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
|
||||||
@@ -769,23 +770,25 @@ 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}"
|
||||||
await self._remove_prop_from_all_fs(host=host, prop=auto_name)
|
if self.previous_prop != "":
|
||||||
await self._add_prop_to_fs(host=host, prop=auto_name, value="true", filesystems=self.parentchildren.value)
|
command = AutomationTemplate(self.previous_prop)
|
||||||
await self._add_prop_to_fs(host=host, prop=auto_name, value="parent", filesystems=self.parent.value)
|
prop = command.safe_substitute(name=auto_name, host=host)
|
||||||
await self._add_prop_to_fs(host=host, prop=auto_name, value="child", filesystems=self.children.value)
|
await self._remove_prop_from_all_fs(host=host, prop=prop)
|
||||||
await self._add_prop_to_fs(host=host, prop=auto_name, value="false", filesystems=self.exclude.value)
|
command = AutomationTemplate(self.prop.value)
|
||||||
self.fs["values"] = {}
|
prop = command.safe_substitute(name=auto_name, host=host)
|
||||||
self.fs["values"]["parentchildren"] = self.parentchildren.value
|
await self._remove_prop_from_all_fs(host=host, prop=prop)
|
||||||
self.fs["values"]["parent"] = self.parent.value
|
await self._add_prop_to_fs(host=host, prop=prop, value="true", filesystems=self.parentchildren.value)
|
||||||
self.fs["values"]["children"] = self.children.value
|
await self._add_prop_to_fs(host=host, prop=prop, value="parent", filesystems=self.parent.value)
|
||||||
self.fs["values"]["exclude"] = self.exclude.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(
|
auto = scheduler.Zfs_Autobackup(
|
||||||
id=auto_id,
|
id=auto_id,
|
||||||
|
name=auto_name,
|
||||||
hosts=hosts,
|
hosts=hosts,
|
||||||
host=host,
|
host=host,
|
||||||
command="python -m zfs_autobackup.ZfsAutobackup" + self.command.value,
|
command="python -m zfs_autobackup.ZfsAutobackup" + self.command.value,
|
||||||
@@ -795,9 +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,
|
||||||
|
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,
|
||||||
@@ -810,13 +817,14 @@ 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}"
|
||||||
auto = scheduler.Automation(
|
auto = scheduler.Automation(
|
||||||
id=auto_id,
|
id=auto_id,
|
||||||
|
name=auto_name,
|
||||||
app=self.app.value,
|
app=self.app.value,
|
||||||
hosts=hosts,
|
hosts=hosts,
|
||||||
host=host,
|
host=host,
|
||||||
@@ -839,6 +847,7 @@ class Automation(Tab):
|
|||||||
auto_id = f"{auto_name}@{self.host}"
|
auto_id = f"{auto_name}@{self.host}"
|
||||||
auto = scheduler.Automation(
|
auto = scheduler.Automation(
|
||||||
id=auto_id,
|
id=auto_id,
|
||||||
|
name=auto_name,
|
||||||
app=self.app.value,
|
app=self.app.value,
|
||||||
hosts=hosts,
|
hosts=hosts,
|
||||||
host=self.host,
|
host=self.host,
|
||||||
|
|||||||
+31
-6
@@ -1,7 +1,8 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
|
from nicegui import ui, events # type: ignore
|
||||||
|
import httpx
|
||||||
from . import SelectionConfirm, Tab
|
from . import SelectionConfirm, Tab
|
||||||
from nicegui import ui, 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 zfs
|
from bale.interfaces import zfs
|
||||||
@@ -33,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",
|
||||||
@@ -51,11 +57,12 @@ class History(Tab):
|
|||||||
"headerName": "Timestamp",
|
"headerName": "Timestamp",
|
||||||
"field": "timestamp",
|
"field": "timestamp",
|
||||||
"filter": "agTextColumnFilter",
|
"filter": "agTextColumnFilter",
|
||||||
"maxWidth": 200,
|
"maxWidth": 125,
|
||||||
":cellRenderer": """(data) => {
|
":cellRenderer": """(data) => {
|
||||||
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",
|
||||||
@@ -98,6 +105,20 @@ class History(Tab):
|
|||||||
http[status]["data"] = e.content["json"]["data"]
|
http[status]["data"] = e.content["json"]["data"]
|
||||||
http[status]["headers"] = e.content["json"]["headers"]
|
http[status]["headers"] = e.content["json"]["headers"]
|
||||||
|
|
||||||
|
def test(status):
|
||||||
|
try:
|
||||||
|
url = http[status]["url"]
|
||||||
|
data = self.process_pipe_data(result=Result(name=self.host, command="TEST COMMAND", status=status), data=http[status]["data"])
|
||||||
|
headers = http[status]["headers"]
|
||||||
|
post = httpx.post(url=url, json=data, headers=headers)
|
||||||
|
print(post.status_code)
|
||||||
|
if post.status_code == 200:
|
||||||
|
el.notify("Test successful!", type="positive")
|
||||||
|
else:
|
||||||
|
el.notify(f"Test failed with status code {post.status_code}!", type="negative")
|
||||||
|
except:
|
||||||
|
el.notify("Test failed!", type="negative")
|
||||||
|
|
||||||
def show_controls(status):
|
def show_controls(status):
|
||||||
if status not in http:
|
if status not in http:
|
||||||
http[status] = {}
|
http[status] = {}
|
||||||
@@ -115,7 +136,7 @@ class History(Tab):
|
|||||||
"topic": "mytopic",
|
"topic": "mytopic",
|
||||||
"tags": ["turtle"],
|
"tags": ["turtle"],
|
||||||
"title": "Successful Automation Run for {name}",
|
"title": "Successful Automation Run for {name}",
|
||||||
"message": "{stdout}",
|
"message": "{command}",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
editor.properties["content"]["json"]["headers"] = self.get_pipe_status("http", status).get("headers", {"Authorization": "Bearer tk_..."})
|
editor.properties["content"]["json"]["headers"] = self.get_pipe_status("http", status).get("headers", {"Authorization": "Bearer tk_..."})
|
||||||
@@ -134,11 +155,15 @@ class History(Tab):
|
|||||||
with ui.step("On Success"):
|
with ui.step("On Success"):
|
||||||
with el.WColumn().classes("col justify-start"):
|
with el.WColumn().classes("col justify-start"):
|
||||||
show_controls(status="success")
|
show_controls(status="success")
|
||||||
el.LgButton("NEXT", on_click=lambda _: stepper.next())
|
with el.WRow():
|
||||||
|
el.LgButton("TEST", on_click=lambda _: test(status="success"))
|
||||||
|
el.LgButton("NEXT", on_click=lambda _: stepper.next())
|
||||||
with ui.step("On Error"):
|
with ui.step("On Error"):
|
||||||
with el.WColumn().classes("col justify-start"):
|
with el.WColumn().classes("col justify-start"):
|
||||||
show_controls(status="error")
|
show_controls(status="error")
|
||||||
el.DButton("SAVE", on_click=lambda: host_dialog.submit("save"))
|
with el.WRow():
|
||||||
|
el.LgButton("TEST", on_click=lambda _: test(status="error"))
|
||||||
|
el.DButton("SAVE", on_click=lambda: host_dialog.submit("save"))
|
||||||
|
|
||||||
result = await host_dialog
|
result = await host_dialog
|
||||||
if result == "save":
|
if result == "save":
|
||||||
|
|||||||
+37
-31
@@ -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
|
||||||
@@ -82,11 +82,12 @@ class Manage(Tab):
|
|||||||
"headerName": "Created",
|
"headerName": "Created",
|
||||||
"field": "creation",
|
"field": "creation",
|
||||||
"filter": "agTextColumnFilter",
|
"filter": "agTextColumnFilter",
|
||||||
"maxWidth": 200,
|
"maxWidth": 125,
|
||||||
":cellRenderer": """(data) => {
|
":cellRenderer": """(data) => {
|
||||||
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},
|
||||||
],
|
],
|
||||||
@@ -111,9 +112,12 @@ class Manage(Tab):
|
|||||||
result = await SelectionConfirm(container=self._confirm, label=">BROWSE<")
|
result = await SelectionConfirm(container=self._confirm, label=">BROWSE<")
|
||||||
if result == "confirm":
|
if result == "confirm":
|
||||||
rows = await self._grid.get_selected_rows()
|
rows = await self._grid.get_selected_rows()
|
||||||
filesystems = await self.zfs.filesystems
|
try:
|
||||||
mount_path = filesystems.data[rows[0]["filesystem"]]["mountpoint"]
|
filesystems = await self.zfs.filesystems
|
||||||
await sshdl.SshFileBrowse(zfs=self.zfs, path=f"{mount_path}/.zfs/snapshot/{rows[0]['name']}")
|
mount_path = filesystems.data[rows[0]["filesystem"]]["mountpoint"]
|
||||||
|
await sshdl.SshFileBrowse(zfs=self.zfs, path=f"{mount_path}/.zfs/snapshot/{rows[0]['name']}")
|
||||||
|
except KeyError:
|
||||||
|
el.notify(f"Unable to browse {rows[0]['filesystem']}", type="warning")
|
||||||
self._set_selection()
|
self._set_selection()
|
||||||
|
|
||||||
async def _find(self) -> None:
|
async def _find(self) -> None:
|
||||||
@@ -256,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)
|
||||||
|
|||||||
+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
|
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
|
||||||
zfs-autobackup==3.2
|
docutils==0.19
|
||||||
netifaces==0.11.0
|
fastapi==0.109.2
|
||||||
asyncssh==2.14.0
|
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