15 Commits

Author SHA1 Message Date
Natan Keddem fada159fa0 updated build action 2024-04-01 21:18:04 -04:00
Natan Keddem e09f68424e improved host input sanitizing 2024-04-01 20:58:31 -04:00
Natan Keddem e5941a87ad updated python version 2024-04-01 20:58:11 -04:00
Natan Keddem 0425cdd110 improve startup and data handling 2024-03-31 20:23:12 -04:00
Natan Keddem c3ee280cd8 refactor cli and ssh 2024-03-31 20:22:57 -04:00
Natan Keddem 141c34d9b4 Merge pull request #1 from akanealw/patch-1
Update README.md
2024-03-28 20:09:02 -04:00
Natan Keddem 2813cf050e added section on file editing 2024-03-28 20:06:32 -04:00
akanealw 458cf05780 Update README.md 2024-03-28 12:29:31 -05:00
akanealw 483043bd4e Update README.md
Added manual installation instructions for Debian.
2024-03-28 12:28:18 -05:00
Natan Keddem ce898250dd updated requirements 2024-03-02 21:59:46 -05:00
Natan Keddem 5cba893282 Update README.md 2024-03-02 21:05:54 -05:00
Natan Keddem db4f340898 add default sort to tables 2023-11-24 19:21:53 -05:00
Natan Keddem f58b03a86b optimize result display 2023-11-24 19:21:06 -05:00
Natan Keddem 61f297aa0b improved job removal 2023-11-23 22:16:30 -05:00
Natan Keddem 425b607e8c cleanup 2023-11-23 22:16:03 -05:00
14 changed files with 347 additions and 82 deletions
+16 -12
View File
@@ -6,12 +6,12 @@ name: Docker
# documentation. # documentation.
on: on:
# schedule: # schedule:
# - cron: '20 13 * * *' # - cron: '20 13 * * *'
push: push:
branches: ["master"] branches: [ "master" ]
# Publish semver tags as releases. # Publish semver tags as releases.
tags: ["v*.*.*"] tags: [ 'v*.*.*' ]
# pull_request: # pull_request:
# branches: [ "master" ] # branches: [ "master" ]
@@ -21,8 +21,10 @@ env:
# github.repository as <account>/<repo> # github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@@ -39,19 +41,21 @@ jobs:
# https://github.com/sigstore/cosign-installer # https://github.com/sigstore/cosign-installer
- name: Install cosign - name: Install cosign
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1 uses: sigstore/cosign-installer@v3.3.0
with: with:
cosign-release: "v2.1.1" cosign-release: 'v2.2.2' # optional
# Workaround: https://github.com/docker/build-push-action/issues/461 # Set up BuildKit Docker container builder to be able to build
- name: Setup Docker buildx # multi-platform images and export cache
uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf # https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
# Login against a Docker registry except on PR # Login against a Docker registry except on PR
# https://github.com/docker/login-action # https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }} - name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -61,7 +65,7 @@ jobs:
# https://github.com/docker/metadata-action # https://github.com/docker/metadata-action
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -69,7 +73,7 @@ jobs:
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.11.6-bookworm FROM python:3.12.2-bookworm
RUN echo "**** install runtime dependencies ****" RUN echo "**** install runtime dependencies ****"
RUN apt update RUN apt update
+94 -2
View File
@@ -1,7 +1,23 @@
# bale: ZFS Snapshot Browser Based GUI # bale: ZFS Snapshot Browser Based GUI
## Demo ## Host Creation
https://github.com/natankeddem/bale/assets/44515217/53c2dc10-afbf-44a2-9546-545b06e7c565 [bale_host_creation.webm](https://github.com/natankeddem/bale/assets/44515217/450afac1-ffa6-4f6f-80b4-1aeafce6a6d7)
## Manual Management Task Handling
[bale_manual_task.webm](https://github.com/natankeddem/bale/assets/44515217/d9728db9-6efa-45ed-8d07-2d925a9249b9)
## Automatic Management Task Handling
[bale_auto_task.webm](https://github.com/natankeddem/bale/assets/44515217/ab648c45-e567-4557-88f9-c11b2b412cef)
## Downloading Files From Snapshots
[bale_file_download.webm](https://github.com/natankeddem/bale/assets/44515217/7db08302-8a8b-47d4-879c-ba310f8628e4)
## Simple Automations
[bale_simple_automation.webm](https://github.com/natankeddem/bale/assets/44515217/0cd6a7da-ff11-4786-88ef-6a644ed431ff)
## ZFS-Autobackup Automations
[bale_zab_automation.webm](https://github.com/natankeddem/bale/assets/44515217/7816ae9c-695c-47f1-9d68-f0075bb8e567)
## ⚠️ **_WARNING_** ## ⚠️ **_WARNING_**
@@ -43,6 +59,82 @@ https://github.com/natankeddem/bale/assets/44515217/53c2dc10-afbf-44a2-9546-545b
ansible-playbook -i inv.yml pve-install.yml ansible-playbook -i inv.yml pve-install.yml
``` ```
### Manual install on Debian
1. Install required packages.
```bash
sudo apt update
sudo apt install -y git python3-pip python3-venv sshpass
```
2. Clone the repository.
```bash
git clone https://github.com/natankeddem/bale.git
```
3. Move to bale directory.
```bash
cd bale
```
4. Create python virtual environment.
```bash
python3 -m venv ./venv
```
5. Activate virtual environment.
```bash
source ./venv/bin/activate
```
6. Install pip3 packages from requirements.txt
```bash
pip3 install -r requirements.txt
```
7. Exit the virtual environment.
```bash
deactivate
```
8. Edit `resources/bale.service` with the actual path you are utilizing on your system.
```bash
nano resources/bale.service
```
9. Change paths in `resources/bale.service` if needed then copy to service directory and activate.
```bash
sudo cp resources/bale.service /etc/systemd/system
sudo systemctl enable bale.service
```
10. Start the service and check status.
```bash
sudo systemctl start bale.service
sudo systemctl status bale.service
```
#### Troubleshooting
If you get an error like this: `bale.service: Failed to locate executable /root/bale/venv/bin/python: No such file or directory`, modify the path in your `/etc/systemd/system/bale.service` file.
```bash
sudo nano /etc/systemd/system/bale.service
```
```bash
sudo systemctl daemon-reload
sudo systemctl start bale.service
sudo systemctl status bale.service
```
### Access GUI ### Access GUI
Access bale by navigating to `http://host:8080`. Access bale by navigating to `http://host:8080`.
+14 -9
View File
@@ -1,3 +1,4 @@
from typing import Optional
from nicegui import ui # type: ignore 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
@@ -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)
+46
View File
@@ -131,6 +131,52 @@ class DInput(ui.input):
self.value = "" self.value = ""
class VInput(ui.input):
def __init__(
self,
label: str | None = None,
*,
placeholder: str | None = None,
value: str = " ",
password: bool = False,
password_toggle_button: bool = False,
on_change: Callable[..., Any] | None = None,
autocomplete: List[str] | None = None,
invalid_characters: str = "",
invalid_values: List[str] = [],
max_length: int = 64,
check: Callable[..., Any] | None = None,
) -> None:
def checks(value: str) -> bool:
if value is None or value == "" or len(value) > max_length:
return False
for invalid_character in invalid_characters:
if invalid_character in value:
return False
for invalid_value in invalid_values:
if invalid_value == value:
return False
if check is not None:
check_status = check(value)
if check_status is not None:
return check_status
return True
super().__init__(
label,
placeholder=placeholder,
value=value,
password=password,
password_toggle_button=password_toggle_button,
on_change=on_change,
autocomplete=autocomplete,
validation={"": lambda value: checks(value)},
)
self.tailwind.width("full")
if value == " ":
self.value = ""
class FInput(ui.input): class FInput(ui.input):
def __init__( def __init__(
self, self,
+23 -4
View File
@@ -116,30 +116,49 @@ class Cli:
self._terminate.clear() self._terminate.clear()
self._busy = False self._busy = False
return Result( return Result(
command=command, return_code=process.returncode, stdout_lines=self.stdout.copy(), stderr_lines=self.stderr.copy(), terminated=terminated, truncated=self._truncated command=command,
return_code=process.returncode,
stdout_lines=self.stdout.copy(),
stderr_lines=self.stderr.copy(),
terminated=terminated,
truncated=self._truncated,
) )
async def shell(self, command: str) -> Result: async def shell(self, command: str, max_output_lines: int = 0) -> Result:
self._busy = True self._busy = True
try: try:
process = await asyncio.create_subprocess_shell(command, stdout=PIPE, stderr=PIPE) process = await asyncio.create_subprocess_shell(command, stdout=PIPE, stderr=PIPE)
if process is not None and process.stdout is not None and process.stderr is not None: if process is not None and process.stdout is not None and process.stderr is not None:
self.clear_buffers() self.stdout.clear()
self.stderr.clear()
self._terminate.clear() self._terminate.clear()
self._truncated = False
terminated = False
now = datetime.now().strftime("%Y/%m/%d %H:%M:%S") now = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
self.prefix_line = f"<{now}> {command}\n" self.prefix_line = f"<{now}> {command}\n"
for terminal in self._stdout_terminals: for terminal in self._stdout_terminals:
terminal.call_terminal_method("write", "\n" + self.prefix_line) terminal.call_terminal_method("write", "\n" + self.prefix_line)
await asyncio.gather( await asyncio.gather(
self._controller(process=process, max_output_lines=max_output_lines),
self._read_stdout(stream=process.stdout), self._read_stdout(stream=process.stdout),
self._read_stderr(stream=process.stderr), self._read_stderr(stream=process.stderr),
) )
if self._terminate.is_set():
terminated = True
await process.wait() await process.wait()
except Exception as e: except Exception as e:
raise e raise e
finally: finally:
self._terminate.clear()
self._busy = False self._busy = False
return Result(command=command, return_code=process.returncode, stdout_lines=self.stdout.copy(), stderr_lines=self.stderr.copy(), terminated=False) return Result(
command=command,
return_code=process.returncode,
stdout_lines=self.stdout.copy(),
stderr_lines=self.stderr.copy(),
terminated=terminated,
truncated=self._truncated,
)
def clear_buffers(self): def clear_buffers(self):
self.prefix_line = "" self.prefix_line = ""
+40 -22
View File
@@ -1,12 +1,10 @@
from typing import Dict, Union from typing import Dict, Optional, Union
import os import os
import asyncio
from pathlib import Path from pathlib import Path
from bale.result import Result from bale.interfaces import cli
from bale.interfaces.cli import Cli
def get_hosts(path): def get_hosts(path: str = "data"):
path = f"{Path(path).resolve()}/config" path = f"{Path(path).resolve()}/config"
hosts = [] hosts = []
try: try:
@@ -20,32 +18,42 @@ def get_hosts(path):
return [] return []
async def get_public_key(path: str) -> str: async def get_public_key(path: str = "data") -> str:
path = Path(path).resolve() path = Path(path).resolve()
if "id_rsa.pub" not in os.listdir(path) or "id_rsa" not in os.listdir(path): if "id_rsa.pub" not in os.listdir(path) or "id_rsa" not in os.listdir(path):
await Cli().shell(f"""ssh-keygen -t rsa -N "" -f {path}/id_rsa""") await cli.Cli().shell(f"""ssh-keygen -t rsa -N "" -f {path}/id_rsa""")
with open(f"{path}/id_rsa.pub", "r", encoding="utf-8") as reader: with open(f"{path}/id_rsa.pub", "r", encoding="utf-8") as reader:
return reader.read() return reader.read()
class Ssh(Cli): class Ssh(cli.Cli):
def __init__(self, path: str, host: str, hostname: str = "", username: str = "", password: Union[str, None] = None, seperator: bytes = b"\n") -> None: def __init__(
self,
host: str,
hostname: str = "",
username: str = "",
password: Optional[str] = None,
options: Optional[Dict[str, str]] = None,
path: str = "data",
seperator: bytes = b"\n",
) -> None:
super().__init__(seperator=seperator) super().__init__(seperator=seperator)
self._raw_path: str = path self._raw_path: str = path
self._path: Path = Path(path).resolve() self._path: Path = Path(path).resolve()
self.host: str = host self.host: str = host.replace(" ", "")
self.password: Union[str, None] = password self.password: Union[str, None] = password
self.use_key: bool = False self.use_key: bool = False
if password is None: if password is None:
self.use_key = True self.use_key = True
self.options: Optional[Dict[str, str]] = options
self.key_path: str = f"{self._path}/id_rsa" self.key_path: str = f"{self._path}/id_rsa"
self._base_cmd: str = "" self._base_command: str = ""
self._full_cmd: str = "" self._full_command: str = ""
self._config_path: str = f"{self._path}/config" self._config_path: str = f"{self._path}/config"
self._config: Dict[str, Dict[str, str]] = {} self._config: Dict[str, Dict[str, str]] = {}
self.read_config() self.read_config()
self.hostname: str = hostname or self._config.get(host, {}).get("HostName", "") self.hostname: str = hostname or self._config.get(host.replace(" ", ""), {}).get("HostName", "")
self.username: str = username or self._config.get(host, {}).get("User", "") self.username: str = username or self._config.get(host.replace(" ", ""), {}).get("User", "")
self.set_config() self.set_config()
def read_config(self) -> None: def read_config(self) -> None:
@@ -57,7 +65,7 @@ class Ssh(Cli):
if line == "" or line.startswith("#"): if line == "" or line.startswith("#"):
continue continue
if line.startswith("Host "): if line.startswith("Host "):
current_host = line.split(" ")[1].strip() current_host = line.split(" ", 1)[1].strip().replace('"', "")
self._config[current_host] = {} self._config[current_host] = {}
else: else:
key, value = line.split(" ", 1) key, value = line.split(" ", 1)
@@ -76,30 +84,40 @@ class Ssh(Cli):
def set_config(self) -> None: def set_config(self) -> None:
self._config[self.host] = { self._config[self.host] = {
"IdentityFile": self.key_path, "IdentityFile": self.key_path,
"PasswordAuthentication": "no",
"StrictHostKeychecking": "no", "StrictHostKeychecking": "no",
"IdentitiesOnly": "yes", "IdentitiesOnly": "yes",
} }
self._config[self.host]["PasswordAuthentication"] = "no" if self.password is None else "yes"
if self.hostname != "": if self.hostname != "":
self._config[self.host]["HostName"] = self.hostname self._config[self.host]["HostName"] = self.hostname
if self.username != "": if self.username != "":
self._config[self.host]["User"] = self.username self._config[self.host]["User"] = self.username
if self.options is not None:
self._config[self.host].update(self.options)
self.write_config() self.write_config()
def remove(self) -> None: def remove(self) -> None:
del self._config[self.host] del self._config[self.host]
self.write_config() self.write_config()
async def execute(self, command: str, max_output_lines: int = 0) -> Result: async def execute(self, command: str, max_output_lines: int = 0) -> cli.Result:
self._base_cmd = f"{'' if self.use_key else f'sshpass -p {self.password} '} ssh -F {self._config_path} {self.host}" self._full_command = f"{self.base_command} {command}"
self._full_cmd = f"{self._base_cmd} {command}" return await super().execute(self._full_command, max_output_lines)
return await super().execute(self._full_cmd, max_output_lines)
async def send_key(self) -> Result: async def shell(self, command: str, max_output_lines: int = 0) -> cli.Result:
self._full_command = f"{self.base_command} {command}"
return await super().shell(self._full_command, max_output_lines)
async def send_key(self) -> cli.Result:
await get_public_key(self._raw_path) await get_public_key(self._raw_path)
cmd = f"sshpass -p {self.password} " f"ssh-copy-id -o IdentitiesOnly=yes -i {self.key_path} " f"-o StrictHostKeychecking=no {self.username}@{self.hostname}" cmd = f"sshpass -p {self.password} " f"ssh-copy-id -o IdentitiesOnly=yes -i {self.key_path} " f"-o StrictHostKeychecking=no {self.username}@{self.hostname}"
return await super().execute(cmd) return await super().shell(cmd)
@property @property
def config_path(self): def config_path(self):
return self._config_path return self._config_path
@property
def base_command(self):
self._base_command = f'{"" if self.use_key else f"sshpass -p {self.password} "} ssh -F {self._config_path} {self.host}'
return self._base_command
+12 -3
View File
@@ -1,4 +1,4 @@
from typing import Any, Dict, Union from typing import Any, Dict, Optional, Union
import re import re
from datetime import datetime from datetime import datetime
from dataclasses import dataclass from dataclasses import dataclass
@@ -243,8 +243,17 @@ class Zfs:
class Ssh(ssh.Ssh, Zfs): class Ssh(ssh.Ssh, Zfs):
def __init__(self, path: str, host: str, hostname: str = "", username: str = "", password: Union[str, None] = None) -> None: def __init__(
super().__init__(path, host, hostname, username, password) self,
host: str,
hostname: str = "",
username: str = "",
password: Optional[str] = None,
options: Optional[Dict[str, str]] = None,
path: str = "data",
seperator: bytes = b"\n",
) -> None:
super().__init__(host, hostname, username, password, options, path, seperator)
Zfs.__init__(self) Zfs.__init__(self)
def notify(self, command: str): def notify(self, command: str):
+9 -13
View File
@@ -83,7 +83,7 @@ class Tab:
@classmethod @classmethod
def register_connection(cls, host: str) -> None: def register_connection(cls, host: str) -> None:
cls._zfs[host] = Ssh(path="data", host=host) cls._zfs[host] = Ssh(host)
async def _display_result(self, result: Result) -> None: async def _display_result(self, result: Result) -> None:
with ui.dialog() as dialog, el.Card(): with ui.dialog() as dialog, el.Card():
@@ -91,20 +91,16 @@ class Tab:
with el.WColumn(): with el.WColumn():
with el.Card() as card: with el.Card() as card:
card.tailwind.width("full") card.tailwind.width("full")
with el.WColumn():
ui.label(f"#> {result.command}").classes("text-secondary")
with el.WRow() as row: with el.WRow() as row:
row.tailwind.justify_content("around") row.tailwind.justify_content("around")
with ui.column() as col: ui.label(f"Host: {result.name}").classes("text-secondary")
col.tailwind.max_width("lg") timestamp = await ui.run_javascript(
ui.label(f"Host Name: {result.name}").classes("text-secondary") f"new Date({result.timestamp} * 1000).toLocaleString(undefined, {{dateStyle: 'short', timeStyle: 'short', hour12: 'false'}});"
ui.label(f"Command: {result.command}").classes("text-secondary") )
timestamp = await ui.run_javascript( ui.label(f"Timestamp: {timestamp}").classes("text-secondary")
f"new Date({result.timestamp} * 1000).toLocaleString(undefined, {{dateStyle: 'short', timeStyle: 'short', hour12: 'false'}});" ui.label(f"Return Code: {result.return_code}").classes("text-secondary")
)
ui.label(f"Timestamp: {timestamp}").classes("text-secondary")
with ui.column() as col:
col.tailwind.max_width("lg")
ui.label(f"Task has failed: {result.failed}").classes("text-secondary")
ui.label(f"Data is cached: {result.cached}").classes("text-secondary")
with el.Card() as card: with el.Card() as card:
with el.WColumn(): with el.WColumn():
terminal = cli.Terminal(options={"rows": 18, "cols": 120, "convertEol": True}) terminal = cli.Terminal(options={"rows": 18, "cols": 120, "convertEol": True})
+13 -6
View File
@@ -48,7 +48,7 @@ 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]
@@ -189,6 +189,7 @@ class Automation(Tab):
var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});; var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});;
return date; return date;
}""", }""",
"sort": "asc",
}, },
{ {
"headerName": "Status", "headerName": "Status",
@@ -268,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()
@@ -767,7 +775,6 @@ class Automation(Tab):
self.scheduler.scheduler.remove_job(job.id) self.scheduler.scheduler.remove_job(job.id)
for host in hosts: for host in hosts:
auto_id = f"{auto_name}@{host}" auto_id = f"{auto_name}@{host}"
if self.previous_prop != "": if self.previous_prop != "":
command = AutomationTemplate(self.previous_prop) command = AutomationTemplate(self.previous_prop)
prop = command.safe_substitute(name=auto_name, host=host) prop = command.safe_substitute(name=auto_name, host=host)
@@ -810,8 +817,8 @@ class Automation(Tab):
) )
elif self.app.value == "remote": elif self.app.value == "remote":
for job in jobs: for job in jobs:
j = job.id.split("@")[0] auto = automation(job)
if j == auto_name: if auto is not None and auto.name == auto_name:
self.scheduler.scheduler.remove_job(job.id) self.scheduler.scheduler.remove_job(job.id)
for host in hosts: for host in hosts:
auto_id = f"{auto_name}@{host}" auto_id = f"{auto_name}@{host}"
+7 -1
View File
@@ -34,7 +34,12 @@ class History(Tab):
"rowSelection": "multiple", "rowSelection": "multiple",
"paginationAutoPageSize": True, "paginationAutoPageSize": True,
"pagination": True, "pagination": True,
"defaultColDef": {"resizable": True, "sortable": True, "suppressMovable": True, "sortingOrder": ["asc", "desc"]}, "defaultColDef": {
"resizable": True,
"sortable": True,
"suppressMovable": True,
"sortingOrder": ["asc", "desc"],
},
"columnDefs": [ "columnDefs": [
{ {
"headerName": "Host", "headerName": "Host",
@@ -57,6 +62,7 @@ class History(Tab):
var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});; var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});;
return date; return date;
}""", }""",
"sort": "desc",
}, },
{ {
"headerName": "Status", "headerName": "Status",
+1
View File
@@ -87,6 +87,7 @@ class Manage(Tab):
var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});; var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});;
return date; return date;
}""", }""",
"sort": "desc",
}, },
{"headerName": "Holds", "field": "userrefs", "filter": "agNumberColumnFilter", "maxWidth": 100}, {"headerName": "Holds", "field": "userrefs", "filter": "agNumberColumnFilter", "maxWidth": 100},
], ],
+13 -3
View File
@@ -5,9 +5,18 @@ logger = logging.getLogger(__name__)
import os import os
if not os.path.exists("data"): if not os.path.exists("data"):
os.makedirs("data") logger.warning("Could not find 'data' directory, verify bind mounts.")
if os.path.exists(".nicegui"):
logger.warning("Creating 'data' directory symlink.")
os.symlink(".nicegui", "data", target_is_directory=True)
else:
logger.warning("Creating 'data' directory, settings will not be persistent.")
os.makedirs("data")
else:
logger.warning("Found 'data' directory.")
os.environ.setdefault("NICEGUI_STORAGE_PATH", "data") os.environ.setdefault("NICEGUI_STORAGE_PATH", "data")
from nicegui import ui # type: ignore
from nicegui import app, ui # type: ignore
ui.card.default_style("max-width: none") ui.card.default_style("max-width: none")
ui.card.default_props("flat bordered") ui.card.default_props("flat bordered")
@@ -22,7 +31,8 @@ from bale import page, logo, scheduler
if __name__ in {"__main__", "__mp_main__"}: if __name__ in {"__main__", "__mp_main__"}:
app.on_startup(lambda: print(f"Starting bale, bound to the following addresses {', '.join(app.urls)}.", flush=True))
page.build() page.build()
s = scheduler.Scheduler() s = scheduler.Scheduler()
ui.timer(0.1, s.start, once=True) ui.timer(0.1, s.start, once=True)
ui.run(title="bale", favicon=logo.favicon, dark=True, reload=False) ui.run(title="bale", favicon=logo.favicon, dark=True, reload=False, show=False, show_welcome_message=False)
+56 -4
View File
@@ -1,8 +1,60 @@
aiofiles==23.2.1
aiohttp==3.9.3
aiosignal==1.3.1
annotated-types==0.6.0
anyio==4.3.0
APScheduler==3.10.4 APScheduler==3.10.4
SQLAlchemy==2.0.22 asyncssh==2.14.0
attrs==23.2.0
bidict==0.23.1
certifi==2024.2.2
cffi==1.16.0
click==8.1.7
colorama==0.4.6
cron-descriptor==1.4.0 cron-descriptor==1.4.0
cron-validator==1.0.8 cron-validator==1.0.8
nicegui==1.4.3 cryptography==42.0.5
docutils==0.19
fastapi==0.109.2
frozenlist==1.4.1
greenlet==3.0.3
h11==0.14.0
httpcore==1.0.4
httptools==0.6.1
httpx==0.27.0
idna==3.6
ifaddr==0.2.0
itsdangerous==2.1.2
Jinja2==3.1.3
markdown2==2.4.13
MarkupSafe==2.1.5
multidict==6.0.5
nicegui==1.4.17
orjson==3.9.15
pscript==0.7.7
pycparser==2.21
pydantic==2.6.3
pydantic_core==2.16.3
Pygments==2.17.2
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
python-engineio==4.9.0
python-multipart==0.0.9
python-socketio==5.11.1
pytz==2024.1
PyYAML==6.0.1
simple-websocket==1.0.0
six==1.16.0
sniffio==1.3.1
SQLAlchemy==2.0.22
starlette==0.36.3
typing_extensions==4.10.0
tzlocal==5.2
uvicorn==0.27.1
uvloop==0.19.0
vbuild==0.8.2
watchfiles==0.21.0
websockets==12.0
wsproto==1.2.0
yarl==1.9.4
zfs-autobackup==3.2 zfs-autobackup==3.2
netifaces==0.11.0
asyncssh==2.14.0