mirror of
https://github.com/natankeddem/bale.git
synced 2026-05-03 06:02:54 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fada159fa0 | |||
| e09f68424e | |||
| e5941a87ad | |||
| 0425cdd110 | |||
| c3ee280cd8 | |||
| 141c34d9b4 | |||
| 2813cf050e | |||
| 458cf05780 | |||
| 483043bd4e | |||
| ce898250dd | |||
| 5cba893282 | |||
| db4f340898 | |||
| f58b03a86b | |||
| 61f297aa0b | |||
| 425b607e8c |
@@ -6,12 +6,12 @@ name: Docker
|
|||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# schedule:
|
# schedule:
|
||||||
# - cron: '20 13 * * *'
|
# - cron: '20 13 * * *'
|
||||||
push:
|
push:
|
||||||
branches: ["master"]
|
branches: [ "master" ]
|
||||||
# Publish semver tags as releases.
|
# Publish semver tags as releases.
|
||||||
tags: ["v*.*.*"]
|
tags: [ 'v*.*.*' ]
|
||||||
# pull_request:
|
# pull_request:
|
||||||
# branches: [ "master" ]
|
# branches: [ "master" ]
|
||||||
|
|
||||||
@@ -21,8 +21,10 @@ env:
|
|||||||
# github.repository as <account>/<repo>
|
# github.repository as <account>/<repo>
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -39,19 +41,21 @@ jobs:
|
|||||||
# https://github.com/sigstore/cosign-installer
|
# https://github.com/sigstore/cosign-installer
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1
|
uses: sigstore/cosign-installer@v3.3.0
|
||||||
with:
|
with:
|
||||||
cosign-release: "v2.1.1"
|
cosign-release: 'v2.2.2' # optional
|
||||||
|
|
||||||
# Workaround: https://github.com/docker/build-push-action/issues/461
|
# Set up BuildKit Docker container builder to be able to build
|
||||||
- name: Setup Docker buildx
|
# multi-platform images and export cache
|
||||||
uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf
|
# https://github.com/docker/setup-buildx-action
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||||
|
|
||||||
# Login against a Docker registry except on PR
|
# Login against a Docker registry except on PR
|
||||||
# https://github.com/docker/login-action
|
# https://github.com/docker/login-action
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c
|
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -61,7 +65,7 @@ jobs:
|
|||||||
# https://github.com/docker/metadata-action
|
# https://github.com/docker/metadata-action
|
||||||
- name: Extract Docker metadata
|
- name: Extract Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
|
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
@@ -69,7 +73,7 @@ jobs:
|
|||||||
# https://github.com/docker/build-push-action
|
# https://github.com/docker/build-push-action
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: build-and-push
|
id: build-and-push
|
||||||
uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a
|
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.11.6-bookworm
|
FROM python:3.12.2-bookworm
|
||||||
|
|
||||||
RUN echo "**** install runtime dependencies ****"
|
RUN echo "**** install runtime dependencies ****"
|
||||||
RUN apt update
|
RUN apt update
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
# bale: ZFS Snapshot Browser Based GUI
|
# bale: ZFS Snapshot Browser Based GUI
|
||||||
|
|
||||||
## Demo
|
## Host Creation
|
||||||
https://github.com/natankeddem/bale/assets/44515217/53c2dc10-afbf-44a2-9546-545b06e7c565
|
[bale_host_creation.webm](https://github.com/natankeddem/bale/assets/44515217/450afac1-ffa6-4f6f-80b4-1aeafce6a6d7)
|
||||||
|
|
||||||
|
## Manual Management Task Handling
|
||||||
|
[bale_manual_task.webm](https://github.com/natankeddem/bale/assets/44515217/d9728db9-6efa-45ed-8d07-2d925a9249b9)
|
||||||
|
|
||||||
|
## Automatic Management Task Handling
|
||||||
|
[bale_auto_task.webm](https://github.com/natankeddem/bale/assets/44515217/ab648c45-e567-4557-88f9-c11b2b412cef)
|
||||||
|
|
||||||
|
## Downloading Files From Snapshots
|
||||||
|
[bale_file_download.webm](https://github.com/natankeddem/bale/assets/44515217/7db08302-8a8b-47d4-879c-ba310f8628e4)
|
||||||
|
|
||||||
|
## Simple Automations
|
||||||
|
[bale_simple_automation.webm](https://github.com/natankeddem/bale/assets/44515217/0cd6a7da-ff11-4786-88ef-6a644ed431ff)
|
||||||
|
|
||||||
|
## ZFS-Autobackup Automations
|
||||||
|
[bale_zab_automation.webm](https://github.com/natankeddem/bale/assets/44515217/7816ae9c-695c-47f1-9d68-f0075bb8e567)
|
||||||
|
|
||||||
|
|
||||||
## ⚠️ **_WARNING_**
|
## ⚠️ **_WARNING_**
|
||||||
|
|
||||||
@@ -43,6 +59,82 @@ https://github.com/natankeddem/bale/assets/44515217/53c2dc10-afbf-44a2-9546-545b
|
|||||||
ansible-playbook -i inv.yml pve-install.yml
|
ansible-playbook -i inv.yml pve-install.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Manual install on Debian
|
||||||
|
|
||||||
|
1. Install required packages.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y git python3-pip python3-venv sshpass
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Clone the repository.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/natankeddem/bale.git
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Move to bale directory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd bale
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Create python virtual environment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv ./venv
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Activate virtual environment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ./venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Install pip3 packages from requirements.txt
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Exit the virtual environment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deactivate
|
||||||
|
```
|
||||||
|
|
||||||
|
8. Edit `resources/bale.service` with the actual path you are utilizing on your system.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano resources/bale.service
|
||||||
|
```
|
||||||
|
|
||||||
|
9. Change paths in `resources/bale.service` if needed then copy to service directory and activate.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp resources/bale.service /etc/systemd/system
|
||||||
|
sudo systemctl enable bale.service
|
||||||
|
```
|
||||||
|
10. Start the service and check status.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start bale.service
|
||||||
|
sudo systemctl status bale.service
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Troubleshooting
|
||||||
|
If you get an error like this: `bale.service: Failed to locate executable /root/bale/venv/bin/python: No such file or directory`, modify the path in your `/etc/systemd/system/bale.service` file.
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/systemd/system/bale.service
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl start bale.service
|
||||||
|
sudo systemctl status bale.service
|
||||||
|
```
|
||||||
|
|
||||||
### Access GUI
|
### Access GUI
|
||||||
|
|
||||||
Access bale by navigating to `http://host:8080`.
|
Access bale by navigating to `http://host:8080`.
|
||||||
|
|||||||
+14
-9
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -116,30 +116,49 @@ class Cli:
|
|||||||
self._terminate.clear()
|
self._terminate.clear()
|
||||||
self._busy = False
|
self._busy = False
|
||||||
return Result(
|
return Result(
|
||||||
command=command, return_code=process.returncode, stdout_lines=self.stdout.copy(), stderr_lines=self.stderr.copy(), terminated=terminated, truncated=self._truncated
|
command=command,
|
||||||
|
return_code=process.returncode,
|
||||||
|
stdout_lines=self.stdout.copy(),
|
||||||
|
stderr_lines=self.stderr.copy(),
|
||||||
|
terminated=terminated,
|
||||||
|
truncated=self._truncated,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def shell(self, command: str) -> Result:
|
async def shell(self, command: str, max_output_lines: int = 0) -> Result:
|
||||||
self._busy = True
|
self._busy = True
|
||||||
try:
|
try:
|
||||||
process = await asyncio.create_subprocess_shell(command, stdout=PIPE, stderr=PIPE)
|
process = await asyncio.create_subprocess_shell(command, stdout=PIPE, stderr=PIPE)
|
||||||
if process is not None and process.stdout is not None and process.stderr is not None:
|
if process is not None and process.stdout is not None and process.stderr is not None:
|
||||||
self.clear_buffers()
|
self.stdout.clear()
|
||||||
|
self.stderr.clear()
|
||||||
self._terminate.clear()
|
self._terminate.clear()
|
||||||
|
self._truncated = False
|
||||||
|
terminated = False
|
||||||
now = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
|
now = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
|
||||||
self.prefix_line = f"<{now}> {command}\n"
|
self.prefix_line = f"<{now}> {command}\n"
|
||||||
for terminal in self._stdout_terminals:
|
for terminal in self._stdout_terminals:
|
||||||
terminal.call_terminal_method("write", "\n" + self.prefix_line)
|
terminal.call_terminal_method("write", "\n" + self.prefix_line)
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
|
self._controller(process=process, max_output_lines=max_output_lines),
|
||||||
self._read_stdout(stream=process.stdout),
|
self._read_stdout(stream=process.stdout),
|
||||||
self._read_stderr(stream=process.stderr),
|
self._read_stderr(stream=process.stderr),
|
||||||
)
|
)
|
||||||
|
if self._terminate.is_set():
|
||||||
|
terminated = True
|
||||||
await process.wait()
|
await process.wait()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise e
|
raise e
|
||||||
finally:
|
finally:
|
||||||
|
self._terminate.clear()
|
||||||
self._busy = False
|
self._busy = False
|
||||||
return Result(command=command, return_code=process.returncode, stdout_lines=self.stdout.copy(), stderr_lines=self.stderr.copy(), terminated=False)
|
return Result(
|
||||||
|
command=command,
|
||||||
|
return_code=process.returncode,
|
||||||
|
stdout_lines=self.stdout.copy(),
|
||||||
|
stderr_lines=self.stderr.copy(),
|
||||||
|
terminated=terminated,
|
||||||
|
truncated=self._truncated,
|
||||||
|
)
|
||||||
|
|
||||||
def clear_buffers(self):
|
def clear_buffers(self):
|
||||||
self.prefix_line = ""
|
self.prefix_line = ""
|
||||||
|
|||||||
+40
-22
@@ -1,12 +1,10 @@
|
|||||||
from typing import Dict, Union
|
from typing import Dict, Optional, Union
|
||||||
import os
|
import os
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from bale.result import Result
|
from bale.interfaces import cli
|
||||||
from bale.interfaces.cli import Cli
|
|
||||||
|
|
||||||
|
|
||||||
def get_hosts(path):
|
def get_hosts(path: str = "data"):
|
||||||
path = f"{Path(path).resolve()}/config"
|
path = f"{Path(path).resolve()}/config"
|
||||||
hosts = []
|
hosts = []
|
||||||
try:
|
try:
|
||||||
@@ -20,32 +18,42 @@ def get_hosts(path):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def get_public_key(path: str) -> str:
|
async def get_public_key(path: str = "data") -> str:
|
||||||
path = Path(path).resolve()
|
path = Path(path).resolve()
|
||||||
if "id_rsa.pub" not in os.listdir(path) or "id_rsa" not in os.listdir(path):
|
if "id_rsa.pub" not in os.listdir(path) or "id_rsa" not in os.listdir(path):
|
||||||
await Cli().shell(f"""ssh-keygen -t rsa -N "" -f {path}/id_rsa""")
|
await cli.Cli().shell(f"""ssh-keygen -t rsa -N "" -f {path}/id_rsa""")
|
||||||
with open(f"{path}/id_rsa.pub", "r", encoding="utf-8") as reader:
|
with open(f"{path}/id_rsa.pub", "r", encoding="utf-8") as reader:
|
||||||
return reader.read()
|
return reader.read()
|
||||||
|
|
||||||
|
|
||||||
class Ssh(Cli):
|
class Ssh(cli.Cli):
|
||||||
def __init__(self, path: str, host: str, hostname: str = "", username: str = "", password: Union[str, None] = None, seperator: bytes = b"\n") -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
hostname: str = "",
|
||||||
|
username: str = "",
|
||||||
|
password: Optional[str] = None,
|
||||||
|
options: Optional[Dict[str, str]] = None,
|
||||||
|
path: str = "data",
|
||||||
|
seperator: bytes = b"\n",
|
||||||
|
) -> None:
|
||||||
super().__init__(seperator=seperator)
|
super().__init__(seperator=seperator)
|
||||||
self._raw_path: str = path
|
self._raw_path: str = path
|
||||||
self._path: Path = Path(path).resolve()
|
self._path: Path = Path(path).resolve()
|
||||||
self.host: str = host
|
self.host: str = host.replace(" ", "")
|
||||||
self.password: Union[str, None] = password
|
self.password: Union[str, None] = password
|
||||||
self.use_key: bool = False
|
self.use_key: bool = False
|
||||||
if password is None:
|
if password is None:
|
||||||
self.use_key = True
|
self.use_key = True
|
||||||
|
self.options: Optional[Dict[str, str]] = options
|
||||||
self.key_path: str = f"{self._path}/id_rsa"
|
self.key_path: str = f"{self._path}/id_rsa"
|
||||||
self._base_cmd: str = ""
|
self._base_command: str = ""
|
||||||
self._full_cmd: str = ""
|
self._full_command: str = ""
|
||||||
self._config_path: str = f"{self._path}/config"
|
self._config_path: str = f"{self._path}/config"
|
||||||
self._config: Dict[str, Dict[str, str]] = {}
|
self._config: Dict[str, Dict[str, str]] = {}
|
||||||
self.read_config()
|
self.read_config()
|
||||||
self.hostname: str = hostname or self._config.get(host, {}).get("HostName", "")
|
self.hostname: str = hostname or self._config.get(host.replace(" ", ""), {}).get("HostName", "")
|
||||||
self.username: str = username or self._config.get(host, {}).get("User", "")
|
self.username: str = username or self._config.get(host.replace(" ", ""), {}).get("User", "")
|
||||||
self.set_config()
|
self.set_config()
|
||||||
|
|
||||||
def read_config(self) -> None:
|
def read_config(self) -> None:
|
||||||
@@ -57,7 +65,7 @@ class Ssh(Cli):
|
|||||||
if line == "" or line.startswith("#"):
|
if line == "" or line.startswith("#"):
|
||||||
continue
|
continue
|
||||||
if line.startswith("Host "):
|
if line.startswith("Host "):
|
||||||
current_host = line.split(" ")[1].strip()
|
current_host = line.split(" ", 1)[1].strip().replace('"', "")
|
||||||
self._config[current_host] = {}
|
self._config[current_host] = {}
|
||||||
else:
|
else:
|
||||||
key, value = line.split(" ", 1)
|
key, value = line.split(" ", 1)
|
||||||
@@ -76,30 +84,40 @@ class Ssh(Cli):
|
|||||||
def set_config(self) -> None:
|
def set_config(self) -> None:
|
||||||
self._config[self.host] = {
|
self._config[self.host] = {
|
||||||
"IdentityFile": self.key_path,
|
"IdentityFile": self.key_path,
|
||||||
"PasswordAuthentication": "no",
|
|
||||||
"StrictHostKeychecking": "no",
|
"StrictHostKeychecking": "no",
|
||||||
"IdentitiesOnly": "yes",
|
"IdentitiesOnly": "yes",
|
||||||
}
|
}
|
||||||
|
self._config[self.host]["PasswordAuthentication"] = "no" if self.password is None else "yes"
|
||||||
if self.hostname != "":
|
if self.hostname != "":
|
||||||
self._config[self.host]["HostName"] = self.hostname
|
self._config[self.host]["HostName"] = self.hostname
|
||||||
if self.username != "":
|
if self.username != "":
|
||||||
self._config[self.host]["User"] = self.username
|
self._config[self.host]["User"] = self.username
|
||||||
|
if self.options is not None:
|
||||||
|
self._config[self.host].update(self.options)
|
||||||
self.write_config()
|
self.write_config()
|
||||||
|
|
||||||
def remove(self) -> None:
|
def remove(self) -> None:
|
||||||
del self._config[self.host]
|
del self._config[self.host]
|
||||||
self.write_config()
|
self.write_config()
|
||||||
|
|
||||||
async def execute(self, command: str, max_output_lines: int = 0) -> Result:
|
async def execute(self, command: str, max_output_lines: int = 0) -> cli.Result:
|
||||||
self._base_cmd = f"{'' if self.use_key else f'sshpass -p {self.password} '} ssh -F {self._config_path} {self.host}"
|
self._full_command = f"{self.base_command} {command}"
|
||||||
self._full_cmd = f"{self._base_cmd} {command}"
|
return await super().execute(self._full_command, max_output_lines)
|
||||||
return await super().execute(self._full_cmd, max_output_lines)
|
|
||||||
|
|
||||||
async def send_key(self) -> Result:
|
async def shell(self, command: str, max_output_lines: int = 0) -> cli.Result:
|
||||||
|
self._full_command = f"{self.base_command} {command}"
|
||||||
|
return await super().shell(self._full_command, max_output_lines)
|
||||||
|
|
||||||
|
async def send_key(self) -> cli.Result:
|
||||||
await get_public_key(self._raw_path)
|
await get_public_key(self._raw_path)
|
||||||
cmd = f"sshpass -p {self.password} " f"ssh-copy-id -o IdentitiesOnly=yes -i {self.key_path} " f"-o StrictHostKeychecking=no {self.username}@{self.hostname}"
|
cmd = f"sshpass -p {self.password} " f"ssh-copy-id -o IdentitiesOnly=yes -i {self.key_path} " f"-o StrictHostKeychecking=no {self.username}@{self.hostname}"
|
||||||
return await super().execute(cmd)
|
return await super().shell(cmd)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def config_path(self):
|
def config_path(self):
|
||||||
return self._config_path
|
return self._config_path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_command(self):
|
||||||
|
self._base_command = f'{"" if self.use_key else f"sshpass -p {self.password} "} ssh -F {self._config_path} {self.host}'
|
||||||
|
return self._base_command
|
||||||
|
|||||||
+12
-3
@@ -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
@@ -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
@@ -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}"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
Reference in New Issue
Block a user