This commit is contained in:
Natan Keddem
2023-11-04 00:00:00 -04:00
commit 9a2c2c2273
37 changed files with 3512 additions and 0 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.git/
.vscode/
.nicegui/
data/
venv/
__pycache__/
logs/
notes/
mpl/
mysecret.py
test.py

95
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,95 @@
name: Docker
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
# schedule:
# - cron: '20 13 * * *'
push:
branches: ["master"]
# Publish semver tags as releases.
tags: ["v*.*.*"]
# pull_request:
# branches: [ "master" ]
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1
with:
cosign-release: "v2.1.1"
# Workaround: https://github.com/docker/build-push-action/issues/461
- name: Setup Docker buildx
uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}

170
.gitignore vendored Normal file
View File

@@ -0,0 +1,170 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.git/
.vscode/
.nicegui/
data/
logs/
notes/
mpl/
mysecret.py
test.py

22
DOCKERFILE Normal file
View File

@@ -0,0 +1,22 @@
FROM python:3.11.6-bookworm
RUN echo "**** install runtime dependencies ****"
RUN apt update
RUN apt install -y \
sshpass \
iputils-ping
ADD requirements.txt .
RUN python -m pip install --no-cache-dir -r requirements.txt
WORKDIR /app
ADD . /app
RUN mkdir -p /app/logs
RUN chmod 777 /app/resources/docker-entrypoint.sh
EXPOSE 8080
ENV PYTHONUNBUFFERED True
ENTRYPOINT ["/app/resources/docker-entrypoint.sh"]
CMD ["python", "main.py"]

46
README Normal file
View File

@@ -0,0 +1,46 @@
# snapper: ZFS Snapshot GUI
## ⚠️ **_WARNING_**
**This utility is currently in early development and may undergo breaking changes in future updates. Your configuration may be lost, and snapshot functionality might be affected. Use with caution; data loss may occur.**
## Features
- **Remote Management**: Snapper handles all interactions over SSH, eliminating the need for local installation. You can manage your ZFS snapshots from anywhere.
- **Multi-Host Support**: Configure Snapper to manage multiple hosts within the same installation, making it a versatile choice for system administrators.
- **User-Friendly GUI**: Easily manage your ZFS snapshots with an intuitive web-based interface that simplifies the process.
- **Automation**: Snapper can automate generic remote and local applications as well as work seamlessly with zfs_autobackup, streamlining your backup and snapshot tasks.
## Installation
### Using Docker
1. Download `docker-compose.yml`.
2. Customize the `docker-compose.yml` file to suit your requirements.
3. Run the application using Docker Compose:
```bash
docker-compose up -d
```
### Using Proxmox LXC Container
1. Download `pve-install.yml` and `inv.yml`.
2. Ensure you have a compatible Debian template available and updated `inv.yml` accordingly.
3. Customize the `inv.yml` file to match your specific setup requirements.
4. Execute the Ansible playbook for Proxmox LXC container installation against your Proxmox host:
```bash
ansible-playbook -i inv.yml pve-install.yml
```
### Access GUI
Access snapper by navigating to `http://host:8080`.
---

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
version: "3.8"
services:
hush:
# image: ghcr.io/natankeddem/snapper:latest
image: snapper:latest
ports:
- 8080:8080
# volumes:
# - ~/path/to/data:/app/.nicegui
# - ~/path/to/logs:/app/logs
environment:
- PUID=1000
- PGID=1000
- VERBOSE_LOGGING=TRUE # Optional: Will enable additional logging. Warning logs may contain passwords in plaintext. Sanitize before sharing.

21
inv.yml Normal file
View File

@@ -0,0 +1,21 @@
all:
hosts:
proxmox_host:
ansible_host: ##PROXMOXHOST##
lxc_hostname:
ansible_host: snapper
vars:
ansible_ssh_common_args: "-o StrictHostKeyChecking=no"
proxmox_host: ##PROXMOXHOST##
ansible_user: root
proxmox_api_password: ##PASSWORD##
proxmox_api_user: root@pam
proxmox_node: alpha
template_storage: local
lxc_template: debian-12-standard_12.2-1_amd64.tar.zst
lxc_hostname: snapper
lxc_id: 200
lxc_password: ##PASSWORD##
lxc_storage: local
lxc_network: vmbr0
app_name: snapper

18
main.py Normal file
View File

@@ -0,0 +1,18 @@
import mylogging
import logging
logger = logging.getLogger(__name__)
import os
if not os.path.exists("data"):
os.makedirs("data")
os.environ.setdefault("NICEGUI_STORAGE_PATH", "data")
from nicegui import ui
from snapper import page, logo, scheduler
if __name__ in {"__main__", "__mp_main__"}:
page.build()
s = scheduler.Scheduler()
ui.timer(0.1, s.start, once=True)
ui.run(title="Snapper", favicon=logo.favicon, dark=True, reload=False)

113
mylogging.py Normal file
View File

@@ -0,0 +1,113 @@
import os
from logging.config import dictConfig
lastinfo_path = "logs/lastinfo.log"
lastdebug_path = "logs/lastdebug.log"
info_path = "logs/info.log"
warn_path = "logs/warning.log"
try:
os.remove(lastinfo_path)
except OSError:
pass
try:
os.remove(lastdebug_path)
except OSError:
pass
def is_docker():
path = "/proc/self/cgroup"
status = os.path.exists("/.dockerenv") or os.path.isfile(path) and any("docker" in line for line in open(path))
return status
if is_docker() is False or os.environ.get("VERBOSE_LOGGING", "FALSE") == "TRUE":
logging_mode = "Verbose "
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": True,
"loggers": {
"": {
"level": "DEBUG",
"handlers": ["console", "all_warning", "all_info", "last_info", "last_debug"],
},
},
"handlers": {
"console": {
"level": "WARNING",
"formatter": "fmt",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
"all_warning": {
"level": "WARNING",
"formatter": "fmt",
"class": "logging.handlers.RotatingFileHandler",
"filename": warn_path,
"maxBytes": 1048576,
"backupCount": 10,
},
"all_info": {
"level": "INFO",
"formatter": "fmt",
"class": "logging.handlers.RotatingFileHandler",
"filename": info_path,
"maxBytes": 1048576,
"backupCount": 10,
},
"last_info": {
"level": "INFO",
"formatter": "fmt",
"class": "logging.handlers.RotatingFileHandler",
"filename": lastinfo_path,
"maxBytes": 1048576,
},
"last_debug": {
"level": "DEBUG",
"formatter": "fmt",
"class": "logging.handlers.RotatingFileHandler",
"filename": lastdebug_path,
"maxBytes": 1048576,
},
},
"formatters": {
"fmt": {"format": "%(asctime)s-%(levelname)s-%(name)s-%(process)d::%(module)s|%(lineno)s:: %(message)s"},
},
}
else:
logging_mode = ""
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": True,
"loggers": {
"": {
"level": "DEBUG",
"handlers": ["console", "all_warning"],
},
},
"handlers": {
"console": {
"level": "WARNING",
"formatter": "fmt",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
"all_warning": {
"level": "WARNING",
"formatter": "fmt",
"class": "logging.handlers.RotatingFileHandler",
"filename": warn_path,
"maxBytes": 1048576,
"backupCount": 10,
},
},
"formatters": {
"fmt": {"format": "%(asctime)s-%(levelname)s-%(name)s-%(process)d::%(module)s|%(lineno)s:: %(message)s"},
},
}
dictConfig(LOGGING_CONFIG)
import logging
logger = logging.getLogger(__name__)
logger.warning(f"***{logging_mode}Logging Started***")

107
pve-install.yml Normal file
View File

@@ -0,0 +1,107 @@
# ansible-playbook -i inv.yml pve-install.yml
---
- name: Build & Start Container
hosts: proxmox_host
gather_facts: true
tasks:
- name: Install packages required by proxmox_kvm module...
ansible.builtin.apt:
pkg:
- python3-proxmoxer
- python3-requests
- xz-utils
become: true
- name: Create container...
community.general.proxmox:
api_host: "{{ ansible_host }}"
api_user: "{{ proxmox_api_user }}"
api_password: "{{ proxmox_api_password }}"
node: "{{ proxmox_node }}"
hostname: "{{ lxc_hostname }}"
vmid: "{{ lxc_id }}"
cores: 2
disk: 8
memory: 2048
password: "{{ lxc_password }}"
unprivileged: true
pubkey: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
storage: "{{ lxc_storage }}"
ostemplate: "{{ template_storage }}:vztmpl/{{ lxc_template }}"
netif: '{"net0":"name=eth0,ip=dhcp,bridge={{ lxc_network }}"}'
features:
- nesting=1
state: present
- name: Wait for container to build...
ansible.builtin.wait_for:
timeout: 10
delegate_to: localhost
- name: Start the container...
community.general.proxmox:
api_host: "{{ ansible_host }}"
api_user: "{{ proxmox_api_user }}"
api_password: "{{ proxmox_api_password }}"
node: "{{ proxmox_node }}"
hostname: "{{ lxc_hostname }}"
state: started
unprivileged: no
- name: Wait for the container to start...
ansible.builtin.wait_for:
host: "{{ lxc_hostname }}"
port: 22
sleep: 3
connect_timeout: 5
timeout: 60
- name: Install App
hosts: lxc_hostname
gather_facts: true
tasks:
- name: Package update cache...
ansible.builtin.apt:
update_cache: true
- name: "Install apt packages required by {{ app_name }}..."
ansible.builtin.apt:
pkg:
- git
- python3-pip
- python3-venv
- sshpass
- name: "Install pip packages required by {{ app_name }}..."
ansible.builtin.pip:
extra_args: --break-system-packages
name:
- github3.py
- name: Get latest release of a public repository
community.general.github_release:
user: natankeddem
repo: "{{ app_name }}"
action: latest_release
register: repo
- name: Clone repo...
ansible.builtin.git:
repo: "https://github.com/natankeddem/{{ app_name }}.git"
dest: /root/{{ app_name }}
version: "{{ repo.tag }}"
- name: "Install pip packages required by {{ app_name }}..."
ansible.builtin.pip:
virtualenv_command: python3 -m venv
virtualenv: "/root/{{ app_name }}/venv"
requirements: "/root/{{ app_name }}/requirements.txt"
state: present
- name: "Install {{ app_name }} serivce."
become: true
ansible.builtin.template:
src: "resources/{{ app_name }}.service"
dest: "/etc/systemd/system/{{ app_name }}.service"
owner: root
mode: "0755"
force: true
- name: Reload service daemon...
become: true
systemd:
daemon_reload: true
- name: "Start {{ app_name }}..."
become: true
systemd:
name: "{{ app_name }}"
state: started
enabled: true

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
APScheduler==3.10.4
SQLAlchemy==2.0.22
cron-descriptor==1.4.0
cron-validator==1.0.8
nicegui==1.3.18
zfs-autobackup==3.2
netifaces==0.11.0

View File

@@ -0,0 +1,40 @@
#!/bin/bash
set -x
# Get the PUID and PGID from environment variables (or use default values 1000 if not set)
PUID=${PUID:-1000}
PGID=${PGID:-1000}
# Check if the provided PUID and PGID are non-empty, numeric values; otherwise, assign default values.
if ! [[ "$PUID" =~ ^[0-9]+$ ]]; then
PUID=1000
fi
if ! [[ "$PGID" =~ ^[0-9]+$ ]]; then
PGID=1000
fi
# Check if the specified group with PGID exists, if not, create it.
if ! getent group "$PGID" >/dev/null; then
groupadd -g "$PGID" appgroup
fi
# Create user.
useradd --create-home --shell /bin/bash --uid "$PUID" --gid "$PGID" appuser
# Make matplotlib cache folder.
mkdir -p /app/mpl
# Make user the owner of the app directory.
chown -R appuser:appgroup /app
# Copy the default .bashrc file to the appuser home directory.
cp /etc/skel/.bashrc /home/appuser/.bashrc
chown appuser:appgroup /home/appuser/.bashrc
export HOME=/home/appuser
# Set permissions on font directories.
if [ -d "/usr/share/fonts" ]; then
chmod -R 777 /usr/share/fonts
fi
if [ -d "/var/cache/fontconfig" ]; then
chmod -R 777 /var/cache/fontconfig
fi
if [ -d "/usr/local/share/fonts" ]; then
chmod -R 777 /usr/local/share/fonts
fi
# Switch to appuser and execute the Docker CMD or passed in command-line arguments.
# Using setpriv let's it run as PID 1 which is required for proper signal handling (similar to gosu/su-exec).
exec setpriv --reuid=$PUID --regid=$PGID --init-groups $@

10
resources/snapper.service Normal file
View File

@@ -0,0 +1,10 @@
[Unit]
Description=Snapper Application Service
After=network.target
[Service]
Type=simple
ExecStart=/root/snapper/venv/bin/python /root/snapper/main.py
[Install]
WantedBy=multi-user.target

33
snapper.code-workspace Normal file
View File

@@ -0,0 +1,33 @@
{
"folders": [
{
"path": "."
}
],
"launch": {
"version": "0.2.0",
"configurations": [
{
"name": "Python: main.py",
"type": "python",
"request": "launch",
"program": "main.py",
"console": "integratedTerminal",
"justMyCode": true
},
{
"name": "Python: Current File",
"type": "python",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": false
}
]
},
"settings": {
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
}
}
}

0
snapper/__init__.py Normal file
View File

0
snapper/apps/__init__.py Normal file
View File

151
snapper/apps/zab.py Normal file
View File

@@ -0,0 +1,151 @@
options = {
"dry-run": {
"control": "label",
"required": False,
"description": "Dry run, dont change anything, just show what would be done (still does all read-only operations)",
},
"verbose": {"control": "label", "required": False, "description": "verbose output"},
"debug": {"control": "label", "required": False, "description": "Show zfs commands that are executed, stops after an exception."},
"debug-output": {"control": "label", "required": False, "description": "Show zfs commands and their output/exit codes. (noisy)"},
"progress": {
"control": "label",
"required": False,
"description": "show zfs progress output. Enabled automaticly on ttys. (use --no-progress to disable)",
},
"utc": {
"control": "label",
"required": False,
"description": "Use UTC instead of local time when dealing with timestamps for both formatting and parsing. To snapshot in an ISO 8601 compliant time format you may for example specify --snapshot-format '{}-%Y-%m-%dT%H:%M:%SZ'. Changing this parameter after-the-fact (existing snapshots) will cause their timestamps to be interpreted as a different time than before.",
},
"version": {"control": "label", "required": False, "description": "Show version."},
"ssh-config": {"control": "input", "required": True, "description": "Custom ssh client config."},
"ssh-source": {"control": "input", "required": True, "description": "Source host to pull backup from."},
"ssh-target": {"control": "input", "required": True, "description": "Target host to push backup to."},
"property-format": {"control": "input", "required": False, "description": "Dataset selection string format. Default: autobackup:{}"},
"snapshot-format": {"control": "input", "required": False, "description": "ZFS Snapshot string format. Default: {}-%Y%m%d%H%M%S}"},
"hold-format": {"control": "input", "required": False, "description": "FORMAT ZFS hold string format. Default: zfs_autobackup:{}"},
"strip-path": {"control": "input", "required": False, "description": "Number of directories to strip from target path."},
"exclude-unchanged": {
"control": "input",
"required": False,
"description": "Exclude datasets that have less than BYTES data changed since any last snapshot. (Use with proxmox HA replication)",
},
"exclude-received": {
"control": "label",
"required": False,
"description": "Exclude datasets that have the origin of their autobackup: property as 'received'. This can avoid recursive replication between two backup partners.",
},
"no-snapshot": {
"control": "label",
"required": False,
"description": "Don't create new snapshots (useful for finishing uncompleted backups, or cleanups)",
},
"pre-snapshot-cmd": {
"control": "input",
"required": False,
"description": "Run COMMAND before snapshotting (can be used multiple times.",
},
"post-snapshot-cmd": {
"control": "input",
"required": False,
"description": "Run COMMAND after snapshotting (can be used multiple times.",
},
"min-change": {"control": "input", "required": False, "description": "Only create snapshot if enough bytes are changed. (default 1)"},
"allow-empty": {
"control": "label",
"required": False,
"description": "If nothing has changed, still create empty snapshots. (Same as --min-change=0)",
},
"other-snapshots": {
"control": "label",
"required": False,
"description": "Send over other snapshots as well, not just the ones created by this tool.",
},
"set-snapshot-properties": {"control": "input", "required": False, "description": "List of properties to set on the snapshot."},
"no-send": {
"control": "label",
"required": False,
"description": "Don't transfer snapshots (useful for cleanups, or if you want a separate send-cronjob)",
},
"no-holds": {
"control": "label",
"required": False,
"description": "Don't hold snapshots. (Faster. Allows you to destroy common snapshot.)",
},
"clear-refreservation": {
"control": "label",
"required": False,
"description": "Filter 'refreservation' property. (recommended, saves space. same as --filter-properties refreservation)",
},
"clear-mountpoint": {
"control": "label",
"required": False,
"description": "Set property canmount=noauto for new datasets. (recommended, prevents mount conflicts. same as --set-properties canmount=noauto)",
},
"filter-properties ": {
"control": "input",
"required": False,
"description": "List of properties to 'filter' when receiving filesystems. (you can still restore them with zfs inherit -S)",
},
"set-properties": {
"control": "input",
"required": False,
"description": "List of propererties to override when receiving filesystems. (you can still restore them with zfs inherit -S)",
},
"rollback": {
"control": "label",
"required": False,
"description": "Rollback changes to the latest target snapshot before starting. (normally you can prevent changes by setting the readonly property on the target_path to on)",
},
"force": {
"control": "label",
"required": False,
"description": "Use zfs -F option to force overwrite/rollback. (Useful with --strip-path=1, but use with care)",
},
"destroy-incompatible": {
"control": "label",
"required": False,
"description": "Destroy incompatible snapshots on target. Use with care! (implies --rollback)",
},
"ignore-transfer-errors": {
"control": "label",
"required": False,
"description": "Ignore transfer errors (still checks if received filesystem exists. useful for acltype errors)",
},
"decrypt": {"control": "label", "required": False, "description": "Decrypt data before sending it over."},
"encrypt": {"control": "label", "required": False, "description": "Encrypt data after receiving it."},
"zfs-compressed": {"control": "input", "required": False, "description": "Transfer blocks that already have zfs-compression as-is."},
"compress": {
"control": "input",
"required": False,
"description": "Use compression during transfer, defaults to zstd-fast if TYPE is not specified. (gzip, pigz-fast, pigz-slow, zstd-fast, zstd-slow, zstd-adapt, xz, lzo, lz4)",
},
"rate": {"control": "input", "required": False, "description": "Limit data transfer rate in Bytes/sec (e.g. 128K. requires mbuffer.)"},
"buffer": {
"control": "input",
"required": False,
"description": "Add zfs send and recv buffers to smooth out IO bursts. (e.g. 128M. requires mbuffer)",
},
"send-pipe": {
"control": "input",
"required": False,
"description": "pipe zfs send output through COMMAND (can be used multiple times)",
},
"recv-pipe": {"control": "input", "required": False, "description": "pipe zfs recv input through COMMAND (can be used multiple times)"},
"no-thinning": {"control": "label", "required": False, "description": "Do not destroy any snapshots."},
"keep-source": {
"control": "input",
"required": False,
"description": "Thinning schedule for old source snapshots. Default: 10,1d1w,1w1m,1m1y",
},
"keep-target": {
"control": "input",
"required": False,
"description": "Thinning schedule for old target snapshots. Default: 10,1d1w,1w1m,1m1y",
},
"destroy-missing": {
"control": "input",
"required": False,
"description": "Destroy datasets on target that are missing on the source. Specify the time since the last snapshot, e.g: --destroy-missing 30d",
},
}

79
snapper/content.py Normal file
View File

@@ -0,0 +1,79 @@
from nicegui import app, ui
import re
from datetime import datetime
import asyncio
from snapper import elements as el
import snapper.logo as logo
from snapper.tabs.manage import Manage
from snapper.tabs.history import History
from snapper.tabs.automation import Automation
import logging
logger = logging.getLogger(__name__)
class Content:
def __init__(self) -> None:
self._header = None
self._tabs = None
self._tab = {}
self._spinner = None
self._host = None
self._tab_panels = None
self._grid = None
self._tab_panel = {}
self._host = None
self._tasks = []
self._manage = None
self._automation = None
self._history = None
def build(self):
self._header = ui.header(bordered=True).classes("bg-dark q-pt-sm q-pb-xs")
self._header.visible = False
with self._header:
with ui.row().classes("w-full h-12 justify-between items-center"):
self._tabs = ui.tabs()
with self._tabs:
self._tab["manage"] = ui.tab(name="Manage").classes("text-secondary")
self._tab["automation"] = ui.tab(name="Automation").classes("text-secondary")
self._tab["history"] = ui.tab(name="History").classes("text-secondary")
self._tab["settings"] = ui.tab(name="Settings").classes("text-secondary")
with ui.row().classes("items-center"):
self._spinner = el.Spinner()
self._host_display = ui.label().classes("text-secondary text-h4")
logo.show()
self._tab_panels = (
ui.tab_panels(self._tabs, value="Manage", on_change=lambda e: self._tab_changed(e), animated=False)
.classes("w-full h-full")
.bind_visibility_from(self._header)
)
async def _tab_changed(self, e):
if e.value == "Manage":
await self._manage.display_snapshots()
if e.value == "History":
self._history.update_history()
def _build_tab_panels(self):
with self._tab_panels:
with ui.tab_panel(self._tab["manage"]).style("height: calc(100vh - 61px)"):
self._manage = Manage(spinner=self._spinner, host=self._host)
with ui.tab_panel(self._tab["automation"]).style("height: calc(100vh - 61px)"):
self._automation = Automation(spinner=self._spinner, host=self._host)
with ui.tab_panel(self._tab["history"]).style("height: calc(100vh - 61px)"):
self._history = History(spinner=self._spinner, host=self._host)
with ui.tab_panel(self._tab["settings"]).style("height: calc(100vh - 61px)"):
ui.label("settings tab")
async def host_selected(self, name):
self._host = name
self._host_display.text = name
self.hide()
self._build_tab_panels()
self._header.visible = True
await self._manage.display_snapshots()
def hide(self):
self._header.visible = False
self._tab_panels.clear()

154
snapper/drawer.py Normal file
View File

@@ -0,0 +1,154 @@
from nicegui import ui
from snapper import elements as el
from snapper.tabs import Tab
from snapper.interfaces import ssh
import logging
logger = logging.getLogger(__name__)
class Drawer(object):
def __init__(self, main_column, on_click, hide_content) -> None:
self._on_click = on_click
self._hide_content = hide_content
self._main_column = main_column
self._header_row = None
self._table = None
self._name = ""
self._hostname = ""
self._username = ""
self._password = ""
self._buttons = {}
self._selection_mode = None
def build(self):
with ui.left_drawer(top_corner=True, bordered=True).props("width=200").classes("q-pt-sm q-pb-xs"):
with el.WColumn():
self._header_row = el.WRow().classes("justify-between")
self._header_row.tailwind().height("12")
with self._header_row:
with ui.row():
el.IButton(icon="add", on_click=self._display_host_dialog)
self._buttons["remove"] = el.IButton(icon="remove", on_click=lambda: self._modify_host("remove"))
self._buttons["edit"] = el.IButton(icon="edit", on_click=lambda: self._modify_host("edit"))
ui.label(text="HOSTS").classes("text-secondary")
self._table = (
ui.table(
[
{
"name": "name",
"label": "Name",
"field": "name",
"required": True,
"align": "center",
"sortable": True,
}
],
[],
row_key="name",
pagination={"rowsPerPage": 0, "sortBy": "name"},
on_select=lambda e: self._selected(e),
)
.on("rowClick", self._clicked, [[], ["name"], None])
.props("hide-header hide-pagination hide-selected-banner dense flat bordered binary-state-sort")
.classes("w-full text-secondary")
)
self._table.visible = False
for name in ssh.get_hosts("data"):
self._add_host_to_table(name)
def _add_host_to_table(self, name):
if len(name) > 0:
for row in self._table.rows:
if name == row["name"]:
return
self._table.add_rows({"name": name})
self._table.visible = True
Tab.register_connection(name)
async def _display_host_dialog(self, name=""):
save = None
async def send_key():
s = ssh.Ssh(
"data", host=host_input.value, hostname=hostname_input.value, username=username_input.value, password=password_input.value
)
result = await s.send_key()
if result.stdout.strip() != "":
el.notify(result.stdout.strip(), multi_line=True, type="positive")
if result.stderr.strip() != "":
el.notify(result.stderr.strip(), multi_line=True, type="negative")
with ui.dialog() as host_dialog, el.Card():
with el.DBody(height="[90vh]"):
with el.WColumn():
host_input = el.DInput(label="Host", value=" ")
hostname_input = el.DInput(label="Hostname", value=" ")
username_input = el.DInput(label="Username", value=" ")
save_em = el.ErrorAggregator(host_input, hostname_input, username_input)
with el.Card() as c:
c.tailwind.width("full")
password_input = el.DInput(label="Password", value=" ").props("type=password")
send_em = el.ErrorAggregator(hostname_input, username_input, password_input)
el.DButton("SEND KEY", on_click=send_key).bind_enabled_from(send_em, "no_errors").tailwind.width("full")
with el.Card() as c:
c.tailwind.width("full")
with ui.scroll_area() as s:
s.tailwind.height("[200px]")
public_key = await ssh.get_public_key("data")
ui.label(public_key).classes("text-secondary break-all")
el.DButton("SAVE", on_click=lambda: host_dialog.submit("save")).bind_enabled_from(save_em, "no_errors")
host_input.value = name
if name != "":
s = ssh.Ssh(path="data", host=name)
hostname_input.value = s.hostname
username_input.value = s.username
result = await host_dialog
if result == "save":
if name != "" and name != host_input.value:
for row in self._table.rows:
if name == row["name"]:
self._table.remove_rows(row)
ssh.Ssh(path="data", host=host_input.value, hostname=hostname_input.value, username=username_input.value)
self._add_host_to_table(host_input.value)
def _modify_host(self, mode):
self._hide_content()
self._selection_mode = mode
if mode is None:
self._table._props["selected"] = []
self._table.props("selection=none")
for icon, button in self._buttons.items():
button.props(f"icon={icon}")
elif self._buttons[mode]._props["icon"] == "close":
self._selection_mode = None
self._table._props["selected"] = []
self._table.props("selection=none")
for icon, button in self._buttons.items():
button.props(f"icon={icon}")
else:
self._table.props("selection=single")
for icon, button in self._buttons.items():
if mode == icon:
button.props("icon=close")
else:
button.props(f"icon={icon}")
async def _selected(self, e):
self._hide_content()
if self._selection_mode == "edit":
if len(e.selection) > 0:
await self._display_host_dialog(name=e.selection[0]["name"])
if self._selection_mode == "remove":
if len(e.selection) > 0:
for row in e.selection:
ssh.Ssh(path="data", host=row["name"]).remove()
self._table.remove_rows(row)
self._modify_host(None)
async def _clicked(self, e):
if "name" in e.args[1]:
host = e.args[1]["name"]
if self._on_click is not None:
await self._on_click(host)

264
snapper/elements.py Normal file
View File

@@ -0,0 +1,264 @@
from typing import Any, Callable, Dict, List, Literal, Optional, Union
from nicegui import ui, app, Tailwind
from nicegui.elements.spinner import SpinnerTypes
from nicegui.tailwind_types.height import Height
from nicegui.tailwind_types.width import Width
from nicegui.elements.mixins.validation_element import ValidationElement
from nicegui.events import GenericEventArguments, handle_event
from snapper.interfaces import cli
import logging
logger = logging.getLogger(__name__)
orange = "#f59e0b"
dark = "#171717"
ui.card.default_style("max-width: none")
ui.card.default_props("flat bordered")
ui.input.default_props("outlined dense hide-bottom-space")
ui.button.default_props("outline dense")
ui.select.default_props("outlined dense dense-options")
ui.checkbox.default_props("dense")
class ErrorAggregator:
def __init__(self, *elements: ValidationElement) -> None:
self.elements: list[ValidationElement] = list(elements)
self.enable: bool = True
def clear(self):
self.elements.clear()
def append(self, element: ValidationElement):
self.elements.append(element)
def remove(self, element: ValidationElement):
self.elements.remove(element)
@property
def no_errors(self) -> bool:
validators = all(validation(element.value) for element in self.elements for validation in element.validation.values())
return self.enable and validators
class WColumn(ui.column):
def __init__(self) -> None:
super().__init__()
self.tailwind.width("full").align_items("center")
class DBody(ui.column):
def __init__(self, height: Height = "[480px]", width: Width = "[240px]") -> None:
super().__init__()
self.tailwind.align_items("center").justify_content("between")
self.tailwind.height(height).width(width)
class WRow(ui.row):
def __init__(self) -> None:
super().__init__()
self.tailwind.width("full").align_items("center").justify_content("center")
class Card(ui.card):
def __init__(self) -> None:
super().__init__()
self.tailwind.border_color(f"[{orange}]")
class DInput(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,
validation: Callable[..., Any] = bool,
) -> None:
super().__init__(
label,
placeholder=placeholder,
value=value,
password=password,
password_toggle_button=password_toggle_button,
on_change=on_change,
autocomplete=autocomplete,
validation={"": validation},
)
self.tailwind.width("full")
if value == " ":
self.value = ""
class FInput(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,
validation: Callable[..., Any] = bool,
read_only: bool = False,
) -> None:
super().__init__(
label,
placeholder=placeholder,
value=value,
password=password,
password_toggle_button=password_toggle_button,
on_change=on_change,
autocomplete=autocomplete,
validation={} if read_only else {"": validation},
)
self.tailwind.width("64")
if value == " ":
self.value = ""
if read_only:
self.props("readonly")
class DSelect(ui.select):
def __init__(
self,
options: List | Dict,
*,
label: str | None = None,
value: Any = None,
on_change: Callable[..., Any] | None = None,
with_input: bool = False,
multiple: bool = False,
clearable: bool = False,
) -> None:
super().__init__(
options, label=label, value=value, on_change=on_change, with_input=with_input, multiple=multiple, clearable=clearable
)
self.tailwind.width("full").max_height("[40px]")
class FSelect(ui.select):
def __init__(
self,
options: List | Dict,
*,
label: str | None = None,
value: Any = None,
on_change: Callable[..., Any] | None = None,
with_input: bool = False,
multiple: bool = False,
clearable: bool = False,
) -> None:
super().__init__(
options, label=label, value=value, on_change=on_change, with_input=with_input, multiple=multiple, clearable=clearable
)
self.tailwind.width("64")
class DButton(ui.button):
def __init__(
self,
text: str = "",
*,
on_click: Callable[..., Any] | None = None,
color: Optional[str] = "primary",
icon: str | None = None,
) -> None:
super().__init__(text, on_click=on_click, color=color, icon=icon)
self.props("size=md")
self.tailwind.padding("px-2.5").padding("py-1")
class DCheckbox(ui.checkbox):
def __init__(self, text: str = "", *, value: bool = False, on_change: Callable[..., Any] | None = None) -> None:
super().__init__(text, value=value, on_change=on_change)
self.tailwind.width("full").text_color("secondary")
class IButton(ui.button):
def __init__(
self,
text: str = "",
*,
on_click: Callable[..., Any] | None = None,
color: Optional[str] = "primary",
icon: str | None = None,
) -> None:
super().__init__(text, on_click=on_click, color=color, icon=icon)
self.props("size=sm")
class SmButton(ui.button):
def __init__(
self,
text: str = "",
*,
on_click: Callable[..., Any] | None = None,
color: Optional[str] = "primary",
icon: str | None = None,
) -> None:
super().__init__(text, on_click=on_click, color=color, icon=icon)
self.props("size=sm")
self.tailwind.width("16")
class LgButton(ui.button):
def __init__(
self,
text: str = "",
*,
on_click: Callable[..., Any] | None = None,
color: Optional[str] = "primary",
icon: str | None = None,
) -> None:
super().__init__(text, on_click=on_click, color=color, icon=icon)
self.props("size=md")
class Spinner(ui.spinner):
def __init__(
self,
type: SpinnerTypes | None = "bars",
*,
size: str = "lg",
color: str | None = "primary",
thickness: float = 5,
master: ui.spinner | None = None,
) -> None:
super().__init__(type, size=size, color=color, thickness=thickness)
self.visible = False
if master is not None:
self.bind_visibility_from(master, "visible")
def notify(
message: Any,
*,
type: Optional[
Literal[ # pylint: disable=redefined-builtin
"positive",
"negative",
"warning",
"info",
"ongoing",
]
] = None,
multi_line: bool = False,
) -> None:
if multi_line:
ui.notify(
message=message,
position="bottom-left",
multi_line=True,
close_button=True,
classes="multi-line-notification",
type=type,
timeout=20000,
)
else:
ui.notify(message=message, position="bottom-left", type=type)

View File

189
snapper/interfaces/cli.py Normal file
View File

@@ -0,0 +1,189 @@
from typing import Any, Callable, Dict, List, Union
from dataclasses import dataclass
import asyncio
from asyncio.subprocess import Process, PIPE
import contextlib
import shlex
from datetime import datetime
from snapper.result import Result
from nicegui import app, ui
from nicegui.element import Element
from nicegui.events import GenericEventArguments, handle_event
import logging
logger = logging.getLogger(__name__)
app.add_static_files("/static", "static")
ui.add_head_html('<link href="static/xterm.css" rel="stylesheet">')
ui.add_head_html('<script type="text/javascript" src="static/xterm.js"></script>')
class Terminal(ui.element, component="../../static/terminal.js", libraries=["../../static/xterm.js"]): # type: ignore[call-arg]
def __init__(
self,
options: Dict,
on_init: Callable[..., Any] | None = None,
) -> None:
super().__init__()
self._props["options"] = options
self.is_initialized = False
if on_init:
def handle_on_init(e: GenericEventArguments) -> None:
self.is_initialized = True
handle_event(
on_init,
GenericEventArguments(sender=self, client=self.client, args=e),
)
self.on("init", handle_on_init)
def call_terminal_method(self, name: str, *args) -> None:
self.run_method("call_api_method", name, *args)
def run_method(self, name: str, *args: Any) -> None:
if not self.is_initialized:
return
super().run_method(name, *args)
class Cli:
def __init__(self, seperator: Union[bytes, None] = b"\n") -> None:
self.seperator: Union[bytes, None] = seperator
self.stdout: List[str] = []
self.stderr: List[str] = []
self._terminate: asyncio.Event = asyncio.Event()
self._busy: bool = False
self.prefix_line: str = ""
self._stdout_terminals: List[Terminal] = []
self._stderr_terminals: List[Terminal] = []
async def _wait_on_stream(self, stream: asyncio.streams.StreamReader) -> Union[str, None]:
if self.seperator is None:
buf = await stream.read(140)
else:
try:
buf = await stream.readuntil(self.seperator)
except asyncio.exceptions.IncompleteReadError as e:
buf = e.partial
except Exception as e:
raise e
return buf.decode("utf-8")
async def _read_stdout(self, stream: asyncio.streams.StreamReader) -> None:
while True:
buf = await self._wait_on_stream(stream=stream)
if buf:
self.stdout.append(buf)
for terminal in self._stdout_terminals:
terminal.call_terminal_method("write", buf)
else:
break
async def _read_stderr(self, stream: asyncio.streams.StreamReader) -> None:
while True:
buf = await self._wait_on_stream(stream=stream)
if buf:
self.stderr.append(buf)
for terminal in self._stderr_terminals:
terminal.call_terminal_method("write", buf)
else:
break
async def _controller(self, process: Process) -> None:
while process.returncode is None:
if self._terminate.is_set():
process.terminate()
try:
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(process.wait(), 0.1)
except Exception as e:
print(e)
def terminate(self) -> None:
self._terminate.set()
async def execute(self, command: str) -> Result:
self._busy = True
c = shlex.split(command, posix=False)
try:
process = await asyncio.create_subprocess_exec(*c, stdout=PIPE, stderr=PIPE)
if process.stdout is not None and process.stderr is not None:
self.stdout.clear()
self.stderr.clear()
self._terminate.clear()
terminated = False
now = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
self.prefix_line = f"<{now}> {command}\n"
for terminal in self._stdout_terminals:
terminal.call_terminal_method("write", "\n" + self.prefix_line)
await asyncio.gather(
self._controller(process=process),
self._read_stdout(stream=process.stdout),
self._read_stderr(stream=process.stderr),
)
if self._terminate.is_set():
terminated = True
await process.wait()
except Exception as e:
raise e
finally:
self._terminate.clear()
self._busy = False
return Result(command=command, stdout_lines=self.stdout.copy(), stderr_lines=self.stderr.copy(), terminated=terminated)
async def shell(self, command: str) -> Result:
self._busy = True
try:
process = await asyncio.create_subprocess_shell(command, stdout=PIPE, stderr=PIPE)
if process.stdout is not None and process.stderr is not None:
self.stdout.clear()
self.stderr.clear()
self._terminate.clear()
now = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
self.prefix_line = f"<{now}> {command}\n"
for terminal in self._stdout_terminals:
terminal.call_terminal_method("write", "\n" + self.prefix_line)
await asyncio.gather(
self._read_stdout(stream=process.stdout),
self._read_stderr(stream=process.stderr),
)
await process.wait()
except Exception as e:
raise e
finally:
self._busy = False
return Result(command=command, stdout_lines=self.stdout.copy(), stderr_lines=self.stderr.copy(), terminated=False)
def register_stdout_terminal(self, terminal: Terminal) -> None:
if terminal not in self._stdout_terminals:
terminal.call_terminal_method("write", self.prefix_line)
for line in self.stdout:
terminal.call_terminal_method("write", line)
self._stdout_terminals.append(terminal)
def register_stderr_terminal(self, terminal: Terminal) -> None:
if terminal not in self._stderr_terminals:
for line in self.stderr:
terminal.call_terminal_method("write", line)
self._stderr_terminals.append(terminal)
def release_stdout_terminal(self, terminal: Terminal) -> None:
if terminal in self._stdout_terminals:
self._stdout_terminals.remove(terminal)
def release_stderr_terminal(self, terminal: Terminal) -> None:
if terminal in self._stderr_terminals:
self._stderr_terminals.remove(terminal)
def register_terminal(self, terminal: Terminal) -> None:
self.register_stdout_terminal(terminal=terminal)
self.register_stderr_terminal(terminal=terminal)
def release_terminal(self, terminal: Terminal) -> None:
self.release_stdout_terminal(terminal=terminal)
self.release_stderr_terminal(terminal=terminal)
@property
def is_busy(self):
return self._busy

127
snapper/interfaces/ssh.py Normal file
View File

@@ -0,0 +1,127 @@
from typing import Dict, Union
import os
import asyncio
from pathlib import Path
from snapper.result import Result
from snapper.interfaces.cli import Cli
def get_hosts(path):
path = f"{Path(path).resolve()}/config"
hosts = []
try:
with open(path, "r", encoding="utf-8") as f:
lines = f.readlines()
for line in lines:
if line.startswith("Host "):
hosts.append(line.split(" ")[1].strip())
return hosts
except FileNotFoundError:
return []
async def get_public_key(path: str) -> str:
path = Path(path).resolve()
if "id_rsa.pub" not in os.listdir(path) or "id_rsa" not in os.listdir(path):
await Cli().shell(f"""ssh-keygen -t rsa -N "" -f {path}/id_rsa""")
with open(f"{path}/id_rsa.pub", "r", encoding="utf-8") as reader:
return reader.read()
class Ssh:
def __init__(
self, path: str, host: str, hostname: str = "", username: str = "", password: Union[str, None] = None, seperator: bytes = b"\n"
) -> None:
self._raw_path: str = path
self._path: Path = Path(path).resolve()
self.host: str = host
self.password: Union[str, None] = password
self.use_key: bool = False
if password is None:
self.use_key = True
self._key_path: str = f"{self._path}/id_rsa"
self._base_cmd: str = ""
self._full_cmd: str = ""
self._cli = Cli(seperator=seperator)
self._config_path: str = f"{self._path}/config"
self._config: Dict[str, Dict[str, str]] = {}
self.read_config()
self.hostname: str = hostname or self._config.get(host, {}).get("HostName", "")
self.username: str = username or self._config.get(host, {}).get("User", "")
self.set_config()
def read_config(self) -> None:
try:
with open(self._config_path, "r", encoding="utf-8") as f:
lines = f.readlines()
for line in lines:
line = line.strip()
if line == "" or line.startswith("#"):
continue
if line.startswith("Host "):
current_host = line.split(" ")[1].strip()
self._config[current_host] = {}
else:
key, value = line.split(" ", 1)
self._config[current_host][key.strip()] = value.strip()
except FileNotFoundError:
self._config = {}
def write_config(self) -> None:
with open(self._config_path, "w", encoding="utf-8") as f:
for host, config in self._config.items():
f.write(f"Host {host}\n")
for key, value in config.items():
f.write(f" {key} {value}\n")
f.write("\n")
def set_config(self) -> None:
self._config[self.host] = {
"IdentityFile": self._key_path,
"PasswordAuthentication": "no",
"StrictHostKeychecking": "no",
"IdentitiesOnly": "yes",
}
if self.hostname != "":
self._config[self.host]["HostName"] = self.hostname
if self.username != "":
self._config[self.host]["User"] = self.username
self.write_config()
def remove(self) -> None:
del self._config[self.host]
self.write_config()
async def execute(self, command: str) -> Result:
# self._base_cmd = (
# f"{'' if self.use_key else f'sshpass -p {self.password} '}"
# f"ssh -o IdentitiesOnly=yes"
# f" -F {self._config_path}"
# f" -o StrictHostKeychecking=no"
# f" {self.host}"
# )
self._base_cmd = f"{'' if self.use_key else f'sshpass -p {self.password} '} ssh -F {self._config_path} {self.host}"
self._full_cmd = f"{self._base_cmd} {command}"
# proc = await asyncio.create_subprocess_shell(
# self._full_cmd, shell=True, stderr=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE
# )
# stdout, stderr = await proc.communicate()
# result = self._cli.execute(self._full_cmd)
# return Result(command=self._full_cmd, stdout=stdout.decode(), stderr=stderr.decode())
return await self._cli.execute(self._full_cmd)
async def send_key(self) -> Result:
await get_public_key(self._raw_path)
cmd = (
f"sshpass -p {self.password} "
f"ssh-copy-id -o IdentitiesOnly=yes -i {self._key_path} "
f"-o StrictHostKeychecking=no {self.username}@{self.hostname}"
)
# proc = await asyncio.create_subprocess_shell(cmd, shell=True, stderr=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE)
# stdout, stderr = await proc.communicate()
# return Result(command=cmd, stdout=stdout.decode(), stderr=stderr.decode())
return await self._cli.execute(cmd)
@property
def config_path(self):
return self._config_path

211
snapper/interfaces/zfs.py Normal file
View File

@@ -0,0 +1,211 @@
from typing import Any, Dict, Union
import re
from datetime import datetime
from dataclasses import dataclass
from snapper.result import Result
from snapper.interfaces import ssh
from snapper import elements as el
import logging
logger = logging.getLogger(__name__)
@dataclass(kw_only=True)
class Snapshot:
name: str
action: str = "snapshot"
recursive: bool = False
@property
def command(self):
return f"zfs {self.action}{self._recursive} {self.name}"
@property
def _recursive(self):
return " -r" if self.recursive else " "
@dataclass(kw_only=True)
class SnapshotCreate(Snapshot):
pass
@dataclass(kw_only=True)
class SnapshotDestroy(Snapshot):
action: str = "destroy"
@dataclass(kw_only=True)
class SnapshotRename(Snapshot):
new_name: str
action: str = "rename"
@property
def command(self):
return f"{super().command} {self.new_name}"
@dataclass(kw_only=True)
class SnapshotHold(Snapshot):
action: str = "hold"
tag: str = "keep"
@property
def command(self):
return f"zfs {self.action}{self._recursive} {self.tag} {self.name}"
@dataclass(kw_only=True)
class SnapshotRelease(SnapshotHold):
action: str = "release"
def format_bytes(size: Union[int, float]) -> str:
# 2**10 = 1024
power = 2**10
n = 0
suffixs = {0: "", 1: "K", 2: "M", 3: "G", 4: "T"}
while size > power:
size = size / power
n += 1
s = ("%.3f" % size).rstrip("0").rstrip(".")
return f"{s}{suffixs[n]}B"
class Zfs:
def __init__(self) -> None:
self._last_run_time: Dict[str, datetime] = {}
self._last_data: Dict[str, Any] = {}
def notify(self, command: str):
el.notify(command)
async def execute(self, command: str) -> Result:
self.notify(command)
return Result(command=command)
def invalidate_query(self, query: Union[str, None] = None):
if query is None:
self._last_run_time = {}
else:
if query in self._last_run_time:
del self._last_run_time[query]
def is_query_ready_to_execute(self, query: str, timeout: int):
now = datetime.now()
if query in self._last_run_time:
if (now - self._last_run_time[query]).total_seconds() > timeout:
self._last_run_time[query] = now
return True
else:
return False
else:
self._last_run_time[query] = now
return True
async def add_filesystem_prop(self, filesystem: str, prop: str, value: str) -> Result:
result = await self.execute(f"zfs set {prop}={value} {filesystem}")
return result
async def remove_filesystem_prop(self, filesystem: str, prop: str) -> Result:
result = await self.execute(f"zfs inherit {prop} {filesystem}")
return result
async def filesystems_with_prop(self, prop: str) -> Result:
result = await self.execute(f"zfs get -Hp -t filesystem,volume {prop}")
filesystems = []
for line in result.stdout_lines:
matches = re.match("^(?P<name>[^\t]+)\t(?P<property>[^\t]+)\t(?P<value>[^\t]+)\t(?P<source>[^\n]+)", line)
if matches is not None:
md = matches.groupdict()
if md["property"] == prop and (md["value"] == "true" or md["value"] == "parent") and md["source"] == "local":
filesystems.append(md["name"])
result = Result(data=filesystems, cached=False)
return result
async def holds_for_snapshot(self, snapshot: str = "") -> Result:
query = "holds_for_snapshot"
if self.is_query_ready_to_execute(query, 60):
result = await self.execute("zfs holds -H -r $(zfs list -t snapshot -H -o name)")
tags: Dict[str, list[str]] = {}
for line in result.stdout_lines:
matches = re.match("^(?P<filesystem>[^@]+)@(?P<name>[^\t]+)\t(?P<tag>[^\t]+)\t(?P<creation>[^\n]+)", line)
if matches is not None:
md = matches.groupdict()
s = f"{md['filesystem']}@{md['name']}"
if s not in tags:
tags[s] = []
tags[s].append(md["tag"])
self._last_data[query] = tags
if snapshot in self._last_data[query]:
result.data = self._last_data[query][snapshot]
else:
result.data = []
else:
if snapshot in self._last_data[query]:
data = self._last_data[query][snapshot]
else:
data = []
result = Result(data=data, cached=True)
return result
@property
async def filesystems(self) -> Result:
query = "filesystems"
if self.is_query_ready_to_execute(query, 60):
result = await self.execute("zfs list -Hp -t filesystem -o name,used,avail,refer,mountpoint")
filesystems = dict()
for line in result.stdout_lines:
matches = re.match(
"^(?P<filesystem>[^\t]+)\t(?P<used_bytes>[^\t]+)\t(?P<avail_bytes>[^\t]+)\t(?P<refer_bytes>[^\t]+)\t(?P<mountpoint>[^\n]+)",
line,
)
if matches is not None:
md = matches.groupdict()
filesystem = md.pop("filesystem")
filesystems[filesystem] = md
self._last_data[query] = filesystems
result.data = self._last_data[query]
else:
result = Result(data=self._last_data[query], cached=True)
return result
@property
async def snapshots(self) -> Result:
query = "snapshots"
if self.is_query_ready_to_execute(query, 60):
result = await self.execute("zfs list -Hp -t snapshot -o name,used,creation,userrefs")
snapshots = dict()
for line in result.stdout_lines:
matches = re.match(
"^(?P<filesystem>[^@]+)@(?P<name>[^\t]+)\t(?P<used_bytes>[^\t]+)\t(?P<creation>[^\t]+)\t(?P<userrefs>[^\n]+)", line
)
if matches is not None:
md = matches.groupdict()
md["creation_date"] = datetime.fromtimestamp(int(md["creation"])).strftime("%Y/%m/%d")
md["creation_time"] = datetime.fromtimestamp(int(md["creation"])).strftime("%H:%M")
md["used"] = format_bytes(int(md["used_bytes"]))
snapshot = f"{md['filesystem']}@{md['name']}"
snapshots[snapshot] = md
self._last_data[query] = snapshots
result.data = self._last_data[query]
else:
result = Result(data=self._last_data[query], cached=True)
return result
class Ssh(ssh.Ssh, Zfs):
def __init__(self, path: str, host: str, hostname: str = "", username: str = "", password: Union[str, None] = None) -> None:
super().__init__(path, host, hostname, username, password)
Zfs.__init__(self)
def notify(self, command: str):
el.notify(f"<{self.host}> {command}")
async def execute(self, command: str) -> Result:
self.notify(command)
result = await super().execute(command)
if result.stderr != "":
el.notify(result.stderr, type="negative")
result.name = self.host
return result

21
snapper/logo.py Normal file
View File

@@ -0,0 +1,21 @@
from nicegui import ui
import logging
logger = logging.getLogger(__name__)
# https://www.svgviewer.dev/s/130897/turtle
logo = """
<svg xmlns="http://www.w3.org/2000/svg" width="42" height="42" viewBox="0 0 42 42">
<path fill="orange" d="m26.001 26.422-3.594 2.075v7.209c3.739-.449 7.072-2.704 9.135-6.086l-5.541-3.199zm-16.749-9.67a15.142 15.142 0 0 0 0 10.339l5.359-3.094v-4.151c-4.116-2.375-2.764-1.595-5.359-3.094zm1.219-2.529c2.578 1.488 2.013 1.161 5.54 3.199l3.594-2.075v-7.21c-3.62.435-7.014 2.603-9.135 6.086zm5.54 12.199-5.541 3.2c2.018 3.308 5.321 5.627 9.136 6.085v-7.209l-3.594-2.075zm4.994-8.65-3.594 2.075v4.151l3.594 2.075 3.594-2.075v-4.151zm1.4-9.634v7.209l3.594 2.075 5.54-3.199c-2.02-3.321-5.339-5.629-9.134-6.086zm10.354 8.615L27.4 19.847v4.151l5.359 3.094a15.142 15.142 0 0 0 0-10.339zM21.006 0c-2.752 0-5.178 1.862-5.178 6.211a14.479 14.479 0 0 1 10.357-.001c0-4.223-2.316-6.21-5.179-6.21zm11.157 10.374c2.031 2.285 3.392 5.104 3.976 8.106l.012.066c6.414-3.641 1.456-11.252-3.988-8.173zm3.918 15.272-.01.05c-.637 2.968-2.045 5.774-4.15 8.037 5.27 3.144 10.511-4.294 4.16-8.088zM5.86 18.547c.626-3.007 1.827-5.752 3.989-8.172C4.471 7.322-.615 14.871 5.86 18.547zm.071 7.099c-6.313 3.771-1.195 11.283 4.161 8.088-2.23-2.384-3.761-5.682-4.161-8.088zm12.513 12.715 1.29 2.822c.498 1.09 2.049 1.088 2.546 0l1.29-2.822a14.496 14.496 0 0 1-5.126 0z"/>
</svg>"""
# favicon = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAABINJREFUaEPVWj1oVEEQnr0IF87WWKTwp1OjjSA2EdMYNHhgcWvAUjvFwkZLtbSyEO20FOJLIVwwok1EGxFsNNHKaAoLtfXIgbkn3/PmHCe7b9/L7al5TSDv7cx88/PN7O4ZKvBMT0/vXVtbqxtjjhDRWJqmo8aYaoGlhT9J07RtjPlMRItpmj4fGhpqzszMvAsJMHkfWGuPEdElIjoREjSg9/NEdDNJkqc++U4A9Xq9Vq1Wbxljzg7IsFJi0zS91263LzabzZZeuA6AtfYAEd0nov2ltAz+47dEdCZJkjdS1R8AusY/JqLRGPZsqXQyMT86lRjiIAM1clyC6AFA2gwPD7+M4fmJHR/p/MFXPaO/trbSwsouSt7viwHk7erq6mFOpx6ARqNxN0bO2z1LZPcsOg0FiDuvD/UNAjUxOzt7DoIyAF22edKv5JHad7o9+cgrBpEAgMVvI/2qwvpJsBMDgNa+qVKnjsvK5P1YrFSaT5JkyqBJdTqdpRguQd4DBB54+0urlv0d2/aVEB08sdIIsiqVyj7TaDQuG2NuxAAAQ/G4UgQAttd+0XikFKI0Ta8Ya22TiE7GAPAPZMwBwAci2h1LOTw9seNTljLMOGAmPEvfRqJ5v2vvMlJotchgFkoBvEcNyDS6/mIi03N1fKH3/zwmCunQTs4GQGttmud9bZirEGXxsizkuQsAv9dspPsHgF57cTQjgbwnCEB6jwWdfmh7Ml3vuVDzADBTXXgylcmCo66NP+uxlX7vA5ELwMXrMIpZxGd8UQA6miF9LhC5AHRYZVog1wHA94RSSK5DsaM/4NHpGOobpQBI7zPbFJl7fJFyDXnaMaHOnQtAzzYy99mDLiBaaZFvZEQkYNRIXiEHi5jzUoYSChB2KRhGIuUwMvsUMhDdD3it7BuIrEytDRWx9DLGAC7eB6eSzEiAWljZ6TSYjYIMHygZGchjRsobSTSQYAT0Ap1Wrjx2cbre0Lj2DbLGcslfvCwNwDcyc67msRMb6Ns3/BUAvh1XDAAhxindB1wLBgmgSNH2XQO+FCkSAf7Gl0J/BYBWDmZyUaqcTF0TKLMU7+Dg2RDnbziFeMzVNBraoMO4kVordw8shzhulIgyb0dDbBRkIU4ZOdvAMJ5dWAFqg//vO//xfaMdxNGDHK2ndA3gmAQKZKORQlw1oVMG38Ao3tjzqOw77EKjHMg4rYc5mefaM0WnUb1x0X0m1BtK7QdkFEKHWEUBAHjeHiPETKWm0SIbEI5EUQDSeFePCTFTcFPv2u+GlAJEEQCy87pqScpwsRFv6nOPVVyzvBbsKtI8AK6+oB1VcKxYLnywxVTn42cN1AUgdMwe0uGIwlzUo0Xf/gHMAkChI5JQ09Lvs6PFmIe7ZQ3o9/vscBdCrLVRjtelQbJpcVPq12C1/tfxehcArlP7vuCQKaQvOkJ0uAFwvy84sDjWFRMbIlkldLZT1vh1V0wQEPOSD/Jkp47sffclXzeVcEcc9Zo14hUrTPRfs3IYN/VFN4PY1D81kAW1aX/soVnhf/65zU8sHJ18FN1uYgAAAABJRU5ErkJggg=="
favicon = """
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-3 -3 48 48" style="enable-background:new -3 -3 48 48" xml:space="preserve" width="42" height="42">
<rect x="-3" y="-3" height="48" width="48" rx="3" ry="3"/>
<path fill="orange" d="m26.001 26.422-3.594 2.075v7.209c3.739-.449 7.072-2.704 9.135-6.086l-5.541-3.199zm-16.749-9.67a15.142 15.142 0 0 0 0 10.339l5.359-3.094v-4.151c-4.116-2.375-2.764-1.595-5.359-3.094zm1.219-2.529c2.578 1.488 2.013 1.161 5.54 3.199l3.594-2.075v-7.21c-3.62.435-7.014 2.603-9.135 6.086zm5.541 12.199-5.541 3.2c2.018 3.308 5.321 5.627 9.136 6.085v-7.209l-3.594-2.075zm4.994-8.651-3.594 2.075v4.151l3.594 2.075 3.595-2.075v-4.151Zm1.4-9.634v7.209L26 17.421l5.54-3.199c-2.02-3.321-5.339-5.629-9.134-6.086zm10.354 8.615-5.359 3.094v4.151l5.359 3.094a15.142 15.142 0 0 0 0-10.339zM21.006 0c-2.752 0-5.178 1.862-5.178 6.211a14.479 14.479 0 0 1 10.357-.001c0-4.223-2.316-6.21-5.179-6.21zm11.158 10.374c2.031 2.285 3.392 5.104 3.976 8.106l.012.066c6.414-3.641 1.456-11.252-3.988-8.173zm3.918 15.272-.01.05c-.637 2.968-2.045 5.774-4.15 8.037 5.27 3.144 10.511-4.294 4.16-8.088zM5.86 18.547c.626-3.007 1.827-5.752 3.989-8.172C4.471 7.322-.615 14.871 5.86 18.547zm.071 7.099c-6.313 3.771-1.195 11.283 4.161 8.088-2.23-2.384-3.761-5.682-4.161-8.088zm12.512 12.715 1.29 2.822c.498 1.09 2.049 1.088 2.546 0l1.29-2.822a14.496 14.496 0 0 1-5.126 0z"/></svg>
"""
def show():
ui.html(logo)

45
snapper/page.py Normal file
View File

@@ -0,0 +1,45 @@
from nicegui import ui
from snapper import elements as el
from snapper.drawer import Drawer
from snapper.content import Content
import logging
logger = logging.getLogger(__name__)
def build():
@ui.page("/")
def page():
ui.add_head_html(
"""
<style>
.full-size-stepper,
.full-size-stepper .q-stepper__content,
.full-size-stepper .q-stepper__step-content,
.full-size-stepper .q-stepper__step-inner {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
}
.multi-line-notification {
white-space: pre-line;
}
</style>
"""
)
ui.colors(
primary=el.orange,
secondary=el.orange,
accent="#d946ef",
dark=el.dark,
positive="#21ba45",
negative="#c10015",
info="#31ccec",
warning="#f2c037",
)
column = ui.column()
content = Content()
drawer = Drawer(column, content.host_selected, content.hide)
drawer.build()
content.build()

39
snapper/result.py Normal file
View File

@@ -0,0 +1,39 @@
from typing import Any, List
from dataclasses import dataclass, field
from datetime import datetime
import time
@dataclass(kw_only=True)
class Result:
name: str = ""
command: str = ""
return_code: int = 0
stdout_lines: List[str] = field(default_factory=list)
stderr_lines: List[str] = field(default_factory=list)
terminated: bool = False
data: Any = None
failed: bool = False
trace: str = ""
cached: bool = False
status: str = "success"
timestamp: float = field(default_factory=time.time)
@property
def date(self) -> str:
return datetime.fromtimestamp(self.timestamp).strftime("%Y/%m/%d")
@property
def time(self) -> str:
return datetime.fromtimestamp(self.timestamp).strftime("%H:%M:%S")
@property
def stdout(self) -> str:
return "".join(self.stdout_lines)
@property
def stderr(self) -> str:
return "".join(self.stderr_lines)
def to_dict(self):
return self.__dict__

51
snapper/scheduler.py Normal file
View File

@@ -0,0 +1,51 @@
import asyncio
from dataclasses import dataclass, field
from typing import Any, Dict, List, Union
from pathlib import Path
from functools import cache
from datetime import datetime
import time
from apscheduler.schedulers.asyncio import AsyncIOScheduler
@dataclass(kw_only=True)
class Automation:
id: str
app: str
hosts: List[str]
command: str
schedule_mode: str
triggers: Dict[str, str]
options: Union[Dict[str, Any], None] = None
timestamp: float = field(default_factory=time.time)
def to_dict(self) -> Dict[str, Any]:
return self.__dict__
@dataclass(kw_only=True)
class Zfs_Autobackup(Automation):
app: str = "zfs_autobackup"
execute_mode: str = "local"
target_host: str
target_path: str
target_paths: List[str]
filesystems: List[str]
class _Scheduler:
def __init__(self) -> None:
path = Path("data").resolve()
url = f"sqlite:///{path}/scheduler.sqlite"
self.scheduler = AsyncIOScheduler()
self.scheduler.add_jobstore("sqlalchemy", url=url)
async def start(self) -> None:
self.scheduler.start()
while True:
await asyncio.sleep(1000)
@cache
def Scheduler() -> _Scheduler:
return _Scheduler()

96
snapper/tabs/__init__.py Normal file
View File

@@ -0,0 +1,96 @@
from typing import Any, Dict, List, Union
from dataclasses import dataclass, field
import asyncio
from datetime import datetime
import time
from nicegui import ui
from snapper.interfaces.zfs import Ssh
from snapper import elements as el
from snapper.result import Result
from snapper.interfaces import cli
@dataclass(kw_only=True)
class Task:
action: str
command: str
status: str
host: str
result: Union[Result, None] = None
history: float = field(default_factory=time.time)
timestamp: float = field(default_factory=time.time)
class Tab:
_zfs: Dict[str, Ssh] = {}
_history: List[Result] = []
_tasks: List[Task] = []
def __init__(self, spinner, host=None) -> None:
self._spinner: el.Spinner = spinner
self.host: str = host
self._build()
def _build(self):
pass
@classmethod
def register_connection(cls, host: str) -> None:
cls._zfs[host] = Ssh(path="data", host=host)
async def _display_result(self, result: Result) -> None:
def print_to_terminal(e):
for line in result.stdout_lines:
e.sender.call_terminal_method("write", line)
for line in result.stderr_lines:
e.sender.call_terminal_method("write", line)
with ui.dialog() as dialog, el.Card():
with el.DBody(height="[90vh]", width="[90vw]"):
with el.WColumn():
with el.Card() as card:
card.tailwind.width("11/12")
with el.WRow() as row:
row.tailwind.justify_content("around")
with ui.column() as col:
col.tailwind.max_width("lg")
ui.label(f"Host Name: {result.name}").classes("text-secondary")
ui.label(f"Command: {result.command}").classes("text-secondary")
ui.label(f"Date: {result.date}").classes("text-secondary")
with ui.column() as col:
col.tailwind.max_width("lg")
ui.label(f"Task has failed: {result.failed}").classes("text-secondary")
ui.label(f"Data is cached: {result.cached}").classes("text-secondary")
ui.label(f"Time: {result.time}").classes("text-secondary")
with el.Card() as card:
card.tailwind.width("11/12").justify_items("center")
cli.Terminal(options={"rows": 20, "cols": 120, "convertEol": True}, on_init=lambda e: print_to_terminal(e))
with el.WRow() as row:
row.tailwind.height("[40px]")
el.DButton("Exit", on_click=lambda: dialog.submit("exit"))
await dialog
def add_history(self, result: Result) -> None:
result.status = "error" if result.failed else "success"
r = result.to_dict()
self._history.append(r)
def _add_task(self, action: str, command: str, hosts: Union[List[str], None] = None) -> None:
if hosts is None:
hosts = [self.host]
for host in hosts:
self._tasks.append(Task(action=action, command=command, host=host, status="pending"))
def _remove_task(self, timestamp: str):
for task in self._tasks:
if task.timestamp == timestamp:
self._tasks.remove(task)
return task
@property
def zfs(self) -> Ssh:
return self._zfs[self.host]
@property
def _zfs_hosts(self) -> List[str]:
return list(self._zfs.keys())

649
snapper/tabs/automation.py Normal file
View File

@@ -0,0 +1,649 @@
from typing import Any, Dict, List, Union
import asyncio
from datetime import datetime
import json
from apscheduler.triggers.combining import AndTrigger
from apscheduler.triggers.combining import OrTrigger
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
from . import Tab
from nicegui import ui, Tailwind
from snapper import elements as el
from snapper.result import Result
from snapper.interfaces import cli
from snapper.interfaces import ssh
from snapper.interfaces import zfs
from snapper.apps import zab
from snapper import scheduler
from cron_validator import CronValidator
from cron_descriptor import get_description
import logging
logger = logging.getLogger(__name__)
job_handlers: Dict[str, Any] = {}
async def automation_job(**kwargs) -> None:
if "data" in kwargs:
jd = json.loads(kwargs["data"])
tab = Tab(host=None, spinner=None)
if jd["app"] == "local":
d = scheduler.Automation(**jd)
if d.id not in job_handlers:
job_handlers[d.id] = cli.Cli()
if job_handlers[d.id].is_busy is False:
result = await job_handlers[d.id].execute(d.command)
tab.host = d.hosts[0]
tab.add_history(result=result)
else:
print("JOB SKIPPED!")
elif jd["app"] == "zfs_autobackup":
d = scheduler.Zfs_Autobackup(**jd)
if d.id not in job_handlers:
job_handlers[d.id] = cli.Cli()
if job_handlers[d.id].is_busy is False:
result = await job_handlers[d.id].execute("python -m zfs_autobackup.ZfsAutobackup" + d.command)
tab.host = d.hosts[0]
tab.add_history(result=result)
else:
print("JOB SKIPPED!")
class Automation(Tab):
def __init__(self, spinner, host: Union[str, None] = None) -> None:
self._automations: List[Dict[str, str]] = []
self._selection_mode = None
self.scheduler = scheduler.Scheduler()
self.options: Dict[str, Any] = {}
self.picked_options: Dict[str, str] = {}
self.triggers: Dict[str, str] = {}
self.picked_triggers: Dict[str, str] = {}
self.job_data: Dict[str, str] = {}
self.job_names: List[str] = []
self.default_options: Dict[str, str] = {}
self.build_command: str = ""
self.target_host: el.DSelect
self.target_paths: List[str] = [""]
self.target_path: el.DSelect
self.filesystems: el.DSelect
self.current_option: el.FSelect
self.options_scroll: ui.scroll_area
self.option_controls: Dict[str, Dict[str, Any]] = {}
self.current_help: ui.tooltip
self.default_triggers: Dict[str, Dict[str, str]] = {}
self.current_trigger: el.FSelect
self.stepper: ui.stepper
self.auto_name: el.DInput
self.schedule_em: el.ErrorAggregator
self.app: el.DSelect
self.schedule_mode: el.DSelect
self.ss_spinner: el.Spinner
self.as_spinner: el.Spinner
self.command: el.DInput
self.save: el.DButton
self.triggers_scroll: ui.scroll_area
self.trigger_controls: Dict[str, str] = {}
self.hosts: el.DSelect
super().__init__(spinner, host)
def _build(self) -> None:
with el.WColumn() as col:
col.tailwind.height("full")
with el.WRow().classes("justify-between"):
with ui.row().classes("items-center"):
el.SmButton("Create", on_click=self._create_automation)
el.SmButton("Remove", on_click=self._remove_automation)
el.SmButton("Edit", on_click=self._edit_automation)
# el.SmButton("Duplicate", on_click=self._duplicate_automation)
el.SmButton("Run Now", on_click=self._run_automation)
with ui.row().classes("items-center"):
el.SmButton(text="Refresh", on_click=self._update_automations)
self._grid = ui.aggrid(
{
"suppressRowClickSelection": True,
"rowSelection": "single",
"paginationAutoPageSize": True,
"pagination": True,
"defaultColDef": {"flex": 1, "resizable": True, "sortable": True, "autoHeight": True, "wrapText": True},
"columnDefs": [
{
"headerName": "Name",
"field": "name",
"headerCheckboxSelection": True,
"headerCheckboxSelectionFilteredOnly": True,
"checkboxSelection": True,
"filter": "agTextColumnFilter",
"maxWidth": 110,
},
{"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": "Status",
"field": "status",
"filter": "agTextColumnFilter",
"maxWidth": 100,
"cellClassRules": {
"text-red-300": "x == 'error'",
"text-green-300": "x == 'success'",
},
},
],
"rowData": self._automations,
},
theme="balham-dark",
)
self._grid.tailwind().width("full").height("5/6")
self._grid.on("cellClicked", lambda e: self._display_job(e))
self._update_automations()
async def _display_job(self, job_data) -> None:
def register_terminal(e):
if job_data.args["data"]["name"] in job_handlers:
job_handlers[job_data.args["data"]["name"]].register_terminal(e.sender)
spinner.bind_visibility_from(job_handlers[job_data.args["data"]["name"]], "is_busy")
async def run():
for job in self.scheduler.scheduler.get_jobs():
if job.id == job_data.args["data"]["name"]:
job.modify(next_run_time=datetime.now())
def terminate():
if job_data.args["data"]["name"] in job_handlers:
job_handlers[job_data.args["data"]["name"]].terminate()
with ui.dialog() as dialog, el.Card():
with el.DBody(height="[90vh]", width="[90vw]"):
with el.WColumn():
terminal = cli.Terminal(options={"rows": 30, "cols": 120, "convertEol": True}, on_init=lambda e: register_terminal(e))
with el.WRow() as row:
row.tailwind.height("[40px]")
spinner = el.Spinner()
el.LgButton("Run Now", on_click=run)
el.LgButton("Terminate", on_click=terminate)
el.LgButton("Exit", on_click=lambda: dialog.submit("exit"))
el.Spinner(master=spinner)
await dialog
if job_data.args["data"]["name"] in job_handlers:
job_handlers[job_data.args["data"]["name"]].release_terminal(terminal)
def _update_automations(self) -> None:
self._automations.clear()
for job in self.scheduler.scheduler.get_jobs():
if job.next_run_time is not None:
next_run_date = job.next_run_time.strftime("%Y/%m/%d")
next_run_time = job.next_run_time.strftime("%H:%M")
else:
next_run_date = "NA"
next_run_time = "NA"
if "data" in job.kwargs:
jd = json.loads(job.kwargs["data"])
if self.host in jd["hosts"]:
self._automations.append(
{
"name": job.id,
"command": jd["command"],
"next_run_date": next_run_date,
"next_run_time": next_run_time,
"status": "",
}
)
self._grid.update()
async def _remove_automation(self) -> None:
rows = await self._grid.get_selected_rows()
if len(rows) == 1:
self.scheduler.scheduler.remove_job(rows[0]["name"])
self._automations.remove(rows[0])
self._grid.update()
async def _run_automation(self) -> None:
rows = await self._grid.get_selected_rows()
if len(rows) == 1:
for job in self.scheduler.scheduler.get_jobs():
if job.id == rows[0]["name"]:
job.modify(next_run_time=datetime.now())
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,
coalesce=True,
max_instances=1,
replace_existing=True,
)
async def _edit_automation(self) -> None:
rows = await self._grid.get_selected_rows()
if len(rows) > 0:
await self._create_automation(rows[0]["name"])
async def _add_prop_to_fs(self, prop: str, module: str = "autobackup", filesystems: Union[List[str], None] = None) -> None:
if filesystems is not None:
full_prop = f"{module}:{prop}"
filesystems_with_prop_result = await self.zfs.filesystems_with_prop(full_prop)
filesystems_with_prop = list(filesystems_with_prop_result.data)
for fs in filesystems:
if fs in filesystems_with_prop:
filesystems_with_prop.remove(fs)
else:
result = await self.zfs.add_filesystem_prop(filesystem=fs, prop=full_prop, value="parent")
self.add_history(result=result)
for fs in filesystems_with_prop:
result = await self.zfs.remove_filesystem_prop(filesystem=fs, prop=full_prop)
self.add_history(result=result)
async def _remove_prop_from_all_fs(self, prop: str, module: str = "autobackup") -> None:
full_prop = f"{module}:{prop}"
filesystems_with_prop_result = await self.zfs.filesystems_with_prop(full_prop)
filesystems_with_prop = list(filesystems_with_prop_result.data)
for fs in filesystems_with_prop:
result = await self.zfs.remove_filesystem_prop(filesystem=fs, prop=full_prop)
self.add_history(result=result)
async def _create_automation(self, name: str = "") -> None:
tw_rows = Tailwind().width("full").align_items("center").justify_content("between")
self.options = {}
self.picked_options = {}
self.triggers = {}
self.picked_triggers = {}
self.job_data = {}
if name != "":
job = self.scheduler.scheduler.get_job(name)
self.job_data.update(json.loads(job.kwargs["data"]))
else:
job = None
jobs = self.scheduler.scheduler.get_jobs()
self.job_names = []
for job in jobs:
self.job_names.append(job.id)
def validate_name(n):
if len(n) > 0 and (n not in self.job_names or name != ""):
return True
return False
def add_option(option, value=""):
# valid_required_option = True
# if self.options[option]["required"] is True and value == "":
# valid_required_option = False
if (
option is not None
and option != ""
and option not in self.picked_options
and not (self.options[option]["required"] is True and value == "")
):
with self.options_scroll:
with ui.row() as option_row:
option_row.tailwind(tw_rows)
with ui.row() as row:
row.tailwind.align_items("center")
self.picked_options[option] = value
self.option_controls[option] = {
"control": el.FInput(
option,
on_change=lambda e, option=option: set_option(option, e.value),
read_only=self.options[option]["control"] == "label",
),
"row": option_row,
}
self.option_controls[option]["control"].value = value
with ui.button(icon="help"):
ui.tooltip(self.options[option]["description"])
# if self.options[option]["control"] == "label":
# self.option_controls[option]["control"].props("readonly")
if self.options[option]["required"] is not True:
ui.button(icon="remove", on_click=lambda _, option=option: remove_option(option)).tailwind.margin("mr-8")
self.build_command()
def remove_option(option):
self.options_scroll.remove(self.option_controls[option]["row"])
del self.picked_options[option]
del self.option_controls[option]
self.build_command()
def set_option(option, value):
self.picked_options[option] = value
self.build_command()
def option_changed(e):
self.current_help.text = self.options[e.value]["description"]
async def zab_controls():
async def target_host_selected():
if self.target_host.value != "":
if "ssh-target" in self.option_controls:
self.option_controls["ssh-target"]["control"].value = self.target_host.value
else:
add_option("ssh-target", self.target_host.value)
fs = await self._zfs[self.target_host.value].filesystems
self.target_paths.clear()
self.target_paths.append("")
self.target_paths.extend(list(fs.data))
self.target_path.update()
self.target_path.value = ""
else:
if "ssh-target" in self.option_controls:
remove_option("ssh-target")
self.target_paths.clear()
self.target_paths.append("")
self.target_path.update()
self.target_path.value = ""
async def target_path_selected():
self.build_command()
def build_command():
base = ""
for key, value in self.picked_options.items():
base = base + f" --{key}{f' {value}' if value != '' else ''}"
target_path = f"{f' {self.target_path.value}' if self.target_path.value == '' else ''}"
base = base + f" {self.auto_name.value}" + target_path
self.command.value = base
if name == "":
self.default_options = {
"verbose": "",
"clear-mountpoint": "",
"ssh-source": self.zfs.host,
"ssh-config": self.zfs.config_path,
}
else:
self.default_options = self.job_data["options"]
self.options = zab.options
self.build_command = build_command
filesystems = await self.zfs.filesystems
hosts = [""]
hosts.extend(self._zfs_hosts)
self.target_host = el.DSelect(hosts, label="Target Host", on_change=target_host_selected)
self.target_paths = [""]
self.target_path = el.DSelect(self.target_paths, value="", label="Target Path", on_change=target_path_selected)
self.filesystems = el.DSelect(list(filesystems.data), label="Source Filesystems", with_input=True, multiple=True)
self.save.bind_enabled_from(self.filesystems, "value", backward=lambda x: len(x) > 0)
options_controls()
if name != "":
self.target_host.value = self.job_data.get("target_host", "")
target_path = self.job_data.get("target_path", "")
tries = 0
while target_path not in self.target_path.options and tries < 20:
await asyncio.sleep(0.1)
tries = tries + 1
self.target_path.value = target_path
self.filesystems.value = self.job_data.get("filesystems", "")
def options_controls():
with ui.row() as row:
row.tailwind(tw_rows)
with ui.row() as row:
row.tailwind.align_items("center")
self.current_option = el.FSelect(
list(self.options.keys()), label="Option", with_input=True, on_change=lambda e: option_changed(e)
)
with ui.button(icon="help"):
self.current_help = ui.tooltip("")
ui.button(icon="add", on_click=lambda: add_option(self.current_option.value)).tailwind.margin("mr-8")
self.options_scroll = ui.scroll_area().classes("col")
self.options_scroll.tailwind.width("full")
self.option_controls = {}
for option, value in self.default_options.items():
add_option(option, value)
self.build_command()
def add_trigger(trigger, value=""):
if trigger is not None:
mixed_triggers = False
if self.schedule_mode.value == "And":
for picked_trigger in self.picked_triggers.values():
if trigger != picked_trigger["type"]:
mixed_triggers = True
if mixed_triggers is False:
with self.triggers_scroll:
with ui.row() as trigger_row:
with ui.row() as row:
ts = str(datetime.now().timestamp())
if trigger == "Cron":
trigger_validation = cron_validation
if value == "":
value = "*/30 * * * *"
elif trigger == "Interval":
trigger_validation = interval_validation
if value == "":
value = "00:00:00:30:00"
self.picked_triggers[ts] = {"type": trigger, "value": value}
self.trigger_controls[ts] = {}
self.trigger_controls[ts]["row"] = trigger_row
row.tailwind.align_items("center")
trigger_row.tailwind(tw_rows)
self.trigger_controls[ts]["control"] = el.FInput(
trigger,
value=value,
on_change=lambda e, ts=ts: set_trigger(ts, e.value),
validation=trigger_validation,
)
self.schedule_em.append(self.trigger_controls[ts]["control"])
with ui.button(icon="help"):
self.trigger_controls[ts]["tooltip"] = ui.tooltip("")
set_trigger_tooltip(self.trigger_controls[ts])
ui.button(
icon="remove",
on_click=lambda _, ts=ts: remove_trigger(ts),
).tailwind.margin("mr-8")
else:
el.notify("Mixing trigger types in Anding Mode disabled.", type="negative")
def remove_trigger(ts):
self.schedule_em.remove(self.trigger_controls[ts]["control"])
self.triggers_scroll.remove(self.trigger_controls[ts]["row"])
del self.picked_triggers[ts]
del self.trigger_controls[ts]
def set_trigger(ts, value):
self.picked_triggers[ts]["value"] = value
set_trigger_tooltip(self.trigger_controls[ts])
def set_trigger_tooltip(controls):
if "control" in controls:
if controls["control"]._props["label"] == "Cron":
try:
controls["tooltip"].text = get_description(controls["control"].value)
except Exception:
controls["tooltip"].text = "Invalid Cron Syntax"
elif controls["control"]._props["label"] == "Interval":
controls["tooltip"].text = "WW:DD:HH:MM:SS"
controls["tooltip"].update()
def cron_validation(value):
try:
CronValidator.parse(value)
return True
except Exception:
return False
def interval_validation(value: str):
intervals = value.split(":")
for interval in intervals:
if interval.isdecimal() is False:
return False
if len(intervals) != 5:
return False
return True
def trigger_controls():
self.picked_triggers = {}
if name == "":
self.default_triggers = {"id": {"type": "Cron", "value": ""}}
else:
self.default_triggers = self.job_data["triggers"]
with ui.row() as row:
row.tailwind(tw_rows)
self.current_trigger = el.FSelect(["Cron", "Interval"], value="Cron", label="Trigger", with_input=True)
ui.button(icon="add", on_click=lambda: add_trigger(self.current_trigger.value)).tailwind.margin("mr-8")
self.triggers_scroll = ui.scroll_area().classes("col")
self.triggers_scroll.tailwind.width("full")
self.trigger_controls = {}
for value in self.default_triggers.values():
add_trigger(value["type"], value["value"])
def schedule_mode_change():
self.schedule_em.clear()
self.schedule_em.append(self.auto_name)
triggers_col.clear()
with triggers_col:
trigger_controls()
async def schedule_done():
self.ss_spinner.visible = True
options_col.clear()
if self.app.value is not None:
with options_col:
if self.app.value == "zfs_autobackup":
await zab_controls()
if self.app.value == "local":
local_controls()
if self.app.value == "remote":
remote_controls()
self.ss_spinner.visible = False
self.stepper.next()
def local_controls():
command_input = el.DInput("Command").bind_value_to(self.command, "value")
if name != "":
command_input.value = self.job_data["command"]
def remote_controls():
command_input = el.DInput("Command").bind_value_to(self.command, "value")
self.hosts = el.DSelect(self._zfs_hosts, value=self.host, label="Hosts", with_input=True, multiple=True)
self.save.bind_enabled_from(self.hosts, "value", backward=lambda x: len(x) > 0)
if name != "":
command_input.value = self.job_data["command"]
self.hosts.value = self.job_data["hosts"]
def string_to_interval(string: str):
interval = string.split(":", 4)
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])
)
def build_triggers():
combine = AndTrigger if self.schedule_mode.value == "And" else OrTrigger
triggers = []
for value in self.picked_triggers.values():
if "Cron" == value["type"]:
triggers.append(CronTrigger().from_crontab(value["value"]))
elif "Interval" == value["type"]:
triggers.append(string_to_interval(value["value"]))
return combine(triggers)
def validate_hosts(e):
if len(e.sender.value) > 0:
self.schedule_em.enable = True
else:
self.schedule_em.enable = False
with ui.dialog() as automation_dialog, el.Card():
with el.DBody(height="[90vh]", width="[480px]"):
with ui.stepper().props("flat").classes("full-size-stepper") as self.stepper:
with ui.step("Schedule Setup"):
with el.WColumn().classes("col justify-between"):
with el.WColumn().classes("col"):
self.auto_name = el.DInput(label="Name", value=" ", validation=validate_name)
self.schedule_em = el.ErrorAggregator(self.auto_name)
if name != "":
self.app = el.DInput(label="Application", value=self.job_data["app"]).props("readonly")
else:
self.app = el.DSelect(
["zfs_autobackup", "local", "remote"],
value="zfs_autobackup",
label="Application",
)
self.schedule_mode = el.DSelect(
["Or", "And"], value="Or", label="Schedule Mode", on_change=schedule_mode_change
)
triggers_col = el.WColumn().classes("col")
with triggers_col:
trigger_controls()
with el.WRow():
self.ss_spinner = el.Spinner()
with ui.stepper_navigation() as nav:
nav.tailwind.padding("pt-0")
n = el.LgButton("NEXT", on_click=schedule_done)
n.bind_enabled_from(self.schedule_em, "no_errors")
el.Spinner(master=self.ss_spinner)
with ui.step("Application Setup"):
with el.WColumn().classes("col justify-between"):
options_col = el.WColumn().classes("col")
with el.WColumn():
self.command = el.DInput(" ").props("readonly")
with el.WRow() as row:
row.tailwind.height("[40px]")
self.as_spinner = el.Spinner()
self.save = el.DButton("SAVE", on_click=lambda: automation_dialog.submit("save"))
el.Spinner(master=self.as_spinner)
self.auto_name.value = name
if name != "":
self.auto_name.props("readonly")
self.schedule_mode.value = self.job_data["schedule_mode"]
result = await automation_dialog
if result == "save":
auto: Union[scheduler.Automation, scheduler.Zfs_Autobackup]
if hasattr(self, "hosts"):
hosts = self.hosts.value
else:
hosts = [self.host]
if self.app.value == "zfs_autobackup":
await self._add_prop_to_fs(prop=self.auto_name.value, filesystems=self.filesystems.value)
auto = scheduler.Zfs_Autobackup(
id=self.auto_name.value,
hosts=hosts,
command=self.command.value,
schedule_mode=self.schedule_mode.value,
triggers=self.picked_triggers,
options=self.picked_options,
target_host=self.target_host.value,
target_path=self.target_path.value,
target_paths=self.target_path.options,
filesystems=self.filesystems.value,
)
if self.auto_name.value not in job_handlers:
job_handlers[self.auto_name.value] = cli.Cli()
else:
auto = scheduler.Automation(
id=self.auto_name.value,
app=self.app.value,
hosts=hosts,
command=self.command.value,
schedule_mode=self.schedule_mode.value,
triggers=self.picked_triggers,
)
if self.auto_name.value not in job_handlers:
job_handlers[self.auto_name.value] = cli.Cli()
self.scheduler.scheduler.add_job(
automation_job,
trigger=build_triggers(),
kwargs={"data": json.dumps(auto.to_dict())},
id=self.auto_name.value,
coalesce=True,
max_instances=1,
replace_existing=True,
)
el.notify("Automation stored successfully!", position="bottom-right", type="positive")
self._update_automations()
elif self.stepper.value == "Application Setup":
pass

77
snapper/tabs/history.py Normal file
View File

@@ -0,0 +1,77 @@
from datetime import datetime
from . import Tab
from nicegui import ui
from snapper import elements as el
from snapper.result import Result
from snapper.interfaces import zfs
import logging
logger = logging.getLogger(__name__)
class History(Tab):
def _build(self):
async def display_result(e):
if e.args["data"] is not None:
result = Result(**e.args["data"])
await self._display_result(result)
with el.WColumn() as col:
col.tailwind.height("full")
with el.WRow().classes("justify-between"):
with ui.row().classes("items-center"):
el.SmButton(text="Remove", on_click=self._remove_history)
with ui.row().classes("items-center"):
el.SmButton(text="Refresh", on_click=lambda _: self._grid.update())
self._grid = ui.aggrid(
{
"suppressRowClickSelection": True,
"rowSelection": "multiple",
"paginationAutoPageSize": True,
"pagination": True,
"defaultColDef": {"flex": 1, "resizable": True, "sortable": True},
"columnDefs": [
{
"headerName": "Host",
"field": "name",
"headerCheckboxSelection": True,
"headerCheckboxSelectionFilteredOnly": True,
"checkboxSelection": True,
"filter": "agTextColumnFilter",
"maxWidth": 100,
},
{
"headerName": "Command",
"field": "command",
"filter": "agTextColumnFilter",
"flex": 1,
},
{"headerName": "Date", "field": "date", "filter": "agDateColumnFilter"},
{"headerName": "Time", "field": "time"},
{
"headerName": "Status",
"field": "status",
"filter": "agTextColumnFilter",
"maxWidth": 100,
# "cellDataType": "text",
"cellClassRules": {
"text-red-300": "x == 'error'",
"text-green-300": "x == 'success'",
},
},
],
"rowData": self._history,
},
theme="balham-dark",
)
self._grid.tailwind().width("full").height("5/6")
self._grid.on("cellClicked", lambda e: display_result(e))
def update_history(self):
self._grid.update()
async def _remove_history(self):
rows = await self._grid.get_selected_rows()
for row in rows:
self._history.remove(row)
self._grid.update()

386
snapper/tabs/manage.py Normal file
View File

@@ -0,0 +1,386 @@
import asyncio
from copy import deepcopy
from nicegui import ui
from . import Tab, Task
from snapper.result import Result
from snapper import elements as el
import snapper.interfaces.zfs as zfs
import logging
logger = logging.getLogger(__name__)
class SelectionConfirm:
def __init__(self, container, label) -> None:
self._container = container
self._label = label
self._visible = None
self._result = None
self._submitted = None
with self._container:
self._label = ui.label(self._label).tailwind().text_color("primary")
self._done = el.IButton(icon="done", on_click=lambda: self.submit("confirm"))
self._cancel = el.IButton(icon="close", on_click=lambda: self.submit("cancel"))
@property
def submitted(self) -> asyncio.Event:
if self._submitted is None:
self._submitted = asyncio.Event()
return self._submitted
def open(self) -> None:
self._container.visible = True
def close(self) -> None:
self._container.visible = False
self._container.clear()
def __await__(self):
self._result = None
self.submitted.clear()
self.open()
yield from self.submitted.wait().__await__() # pylint: disable=no-member
result = self._result
self.close()
return result
def submit(self, result) -> None:
self._result = result
self.submitted.set()
class Manage(Tab):
def _build(self):
with el.WColumn() as col:
col.tailwind.height("full")
self._confirm = el.WRow()
self._confirm.visible = False
with el.WRow().classes("justify-between").bind_visibility_from(self._confirm, "visible", value=False):
with ui.row().classes("items-center"):
el.SmButton(text="Create", on_click=self._create_snapshot)
el.SmButton(text="Destroy", on_click=self._destroy_snapshot)
el.SmButton(text="Rename", on_click=self._rename_snapshot)
el.SmButton(text="Hold", on_click=self._hold_snapshot)
el.SmButton(text="Release", on_click=self._release_snapshot)
with ui.row().classes("items-center"):
self._auto = ui.checkbox("Auto")
self._auto.props(f"left-label keep-color color=primary")
self._auto.tailwind.text_color("primary")
el.SmButton(text="Tasks", on_click=self._display_tasks)
el.SmButton(text="Refresh", on_click=self.display_snapshots)
self._grid = ui.aggrid(
{
"suppressRowClickSelection": True,
"rowSelection": "multiple",
"paginationAutoPageSize": True,
"pagination": True,
"defaultColDef": {"flex": 1, "resizable": True, "sortable": True},
"columnDefs": [
{"headerName": "Name", "field": "name", "filter": "agTextColumnFilter"},
{"headerName": "Filesystem", "field": "filesystem", "filter": "agTextColumnFilter"},
{"headerName": "Used", "field": "used"},
{"headerName": "Used Bytes", "field": "used_bytes", "filter": "agNumberColumnFilter"},
{"headerName": "Creation Date", "field": "creation_date", "filter": "agDateColumnFilter"},
{"headerName": "Creation Time", "field": "creation_time"},
{"headerName": "Holds", "field": "userrefs", "filter": "agNumberColumnFilter"},
],
"rowData": [],
},
theme="balham-dark",
)
self._grid.tailwind().width("full").height("5/6")
async def display_snapshots(self):
self._spinner.visible = True
self.zfs.invalidate_query()
snapshots = await self.zfs.snapshots
await self.zfs.filesystems
await self.zfs.holds_for_snapshot()
self._grid.options["rowData"] = list(snapshots.data.values())
self._grid.update()
self._spinner.visible = False
def _set_selection(self, mode=None):
row_selection = "single"
name_def = {
"headerName": "Name",
"field": "name",
"filter": "agTextColumnFilter",
"headerCheckboxSelection": False,
"headerCheckboxSelectionFilteredOnly": True,
"checkboxSelection": False,
}
if mode is None:
pass
elif mode == "single":
name_def["checkboxSelection"] = True
elif mode == "multiple":
row_selection = "multiple"
name_def["headerCheckboxSelection"] = True
name_def["checkboxSelection"] = True
self._grid.options["columnDefs"][0] = name_def
self._grid.options["rowSelection"] = row_selection
self._grid.update()
async def _create_snapshot(self):
with ui.dialog() as dialog, el.Card():
self._spinner.visible = True
with el.DBody():
with el.WColumn():
zfs_hosts = el.DSelect(self._zfs_hosts, value=[self.host], with_input=True, multiple=True, label="Hosts")
filesystem_list = await self.zfs.filesystems
filesystem_list = list(filesystem_list.data.keys())
filesystems = el.DSelect(filesystem_list, with_input=True, multiple=True, label="Filesystems")
name = el.DInput(label="Name")
recursive = el.DCheckbox("Recursive")
with el.WRow():
el.DButton("Create", on_click=lambda: dialog.submit("create"))
self._spinner.visible = False
result = await dialog
if result == "create":
for filesystem in filesystems.value:
self._add_task(
"create",
zfs.SnapshotCreate(name=f"{filesystem}@{name.value}", recursive=recursive.value).command,
hosts=zfs_hosts.value,
)
async def _destroy_snapshot(self):
with ui.dialog() as dialog, el.Card():
with el.DBody():
with el.WColumn():
zfs_hosts = el.DSelect(self._zfs_hosts, value=[self.host], with_input=True, multiple=True, label="Hosts")
recursive = el.DCheckbox("Recursive")
with el.WRow():
el.DButton("Destroy", on_click=lambda: dialog.submit("destroy"))
self._set_selection(mode="multiple")
result = await SelectionConfirm(container=self._confirm, label=">DESTROY<")
if result == "confirm":
result = await dialog
if result == "destroy":
rows = await self._grid.get_selected_rows()
for row in rows:
self._add_task(
"destroy",
zfs.SnapshotDestroy(name=f"{row['filesystem']}@{row['name']}", recursive=recursive.value).command,
hosts=zfs_hosts.value,
)
self._set_selection()
async def _rename_snapshot(self):
with ui.dialog() as dialog, el.Card():
with el.DBody():
with el.WColumn():
zfs_hosts = el.DSelect(self._zfs_hosts, value=[self.host], with_input=True, multiple=True, label="Hosts")
mode = el.DSelect(["full", "replace"], value="full", label="Mode")
recursive = el.DCheckbox("Recursive")
new_name = el.DInput(label="New Name").bind_visibility_from(mode, "value", value="full")
original = el.DInput(label="Original").bind_visibility_from(mode, "value", value="replace")
replace = el.DInput(label="Replace").bind_visibility_from(mode, "value", value="replace")
with el.WRow():
el.DButton("Rename", on_click=lambda: dialog.submit("rename"))
self._set_selection(mode="multiple")
result = await SelectionConfirm(container=self._confirm, label=">RENAME<")
if result == "confirm":
result = await dialog
if result == "rename":
rows = await self._grid.get_selected_rows()
for row in rows:
if mode.value == "full":
rename = new_name.value
if mode.value == "replace":
rename = row["name"].replace(original.value, replace.value)
if row["name"] != rename:
self._add_task(
"rename",
zfs.SnapshotRename(
name=f"{row['filesystem']}@{row['name']}", new_name=rename, recursive=recursive.value
).command,
hosts=zfs_hosts.value,
)
else:
el.notify(f"Skipping rename of {row['filesystem']}@{row['name']}!")
self._set_selection()
async def _hold_snapshot(self):
with ui.dialog() as dialog, el.Card():
with el.DBody():
with el.WColumn():
zfs_hosts = el.DSelect(self._zfs_hosts, value=[self.host], with_input=True, multiple=True, label="Hosts")
tag = el.DInput(label="Tag")
recursive = el.DCheckbox("Recursive")
with el.WRow():
el.DButton("Hold", on_click=lambda: dialog.submit("hold"))
self._set_selection(mode="multiple")
result = await SelectionConfirm(container=self._confirm, label=">HOLD<")
if result == "confirm":
result = await dialog
if result == "hold":
rows = await self._grid.get_selected_rows()
for row in rows:
self._add_task(
"hold",
zfs.SnapshotHold(
name=f"{row['filesystem']}@{row['name']}",
tag=tag.value,
recursive=recursive.value,
).command,
hosts=zfs_hosts.value,
)
self._set_selection()
async def _release_snapshot(self):
all_tags = []
with ui.dialog() as dialog, el.Card():
with el.DBody():
with el.WColumn():
zfs_hosts = el.DSelect(self._zfs_hosts, value=[self.host], with_input=True, multiple=True, label="Hosts")
tags = el.DSelect(all_tags, with_input=True, multiple=True, label="Tags")
recursive = el.DCheckbox("Recursive")
with el.WRow():
el.DButton("Release", on_click=lambda: dialog.submit("release"))
self._set_selection(mode="multiple")
result = await SelectionConfirm(container=self._confirm, label=">RELEASE<")
if result == "confirm":
self._spinner.visible = True
rows = await self._grid.get_selected_rows()
for row in rows:
holds = await self.zfs.holds_for_snapshot(f"{row['filesystem']}@{row['name']}")
all_tags.extend(holds.data)
if len(all_tags) > 0:
tags.update()
self._spinner.visible = False
result = await dialog
if result == "release":
if len(tags.value) > 0:
for tag in tags.value:
for row in rows:
self._add_task(
"release",
zfs.SnapshotRelease(
name=f"{row['filesystem']}@{row['name']}",
tag=tag,
recursive=recursive.value,
).command,
hosts=zfs_hosts.value,
)
self._set_selection()
async def _display_tasks(self):
def update_status(timestamp, status, result=None):
for row in grid.options["rowData"]:
if timestamp == row.timestamp:
row.status = status
if result is not None:
row.result = deepcopy(result)
self.add_history(deepcopy(result))
grid.update()
return row
async def apply():
spinner.visible = True
rows = await grid.get_selected_rows()
for row in rows:
task = Task(**row)
if task.status == "pending":
update_status(task.timestamp, "running")
result = await self.zfs.execute(task.command)
if result.stdout == "" and result.stderr == "":
status = "success"
result.failed = False
else:
status = "error"
result.failed = True
update_status(task.timestamp, status, result)
spinner.visible = False
async def dry_run():
spinner.visible = True
rows = await grid.get_selected_rows()
for row in rows:
if row["status"] == "pending":
await zfs.Zfs().execute(row["command"])
spinner.visible = False
async def reset():
rows = await grid.get_selected_rows()
for row in rows:
for grow in grid.options["rowData"]:
if row["command"] == grow.command:
grow.status = "pending"
grid.update()
async def display_result(e):
if e.args["data"]["result"] is not None:
result = Result(**e.args["data"]["result"])
await self._display_result(result=result)
with ui.dialog() as dialog, el.Card():
with el.DBody(height="[80vh]", width="[80vw]"):
with el.WColumn().classes("col"):
grid = ui.aggrid(
{
"suppressRowClickSelection": True,
"rowSelection": "multiple",
"paginationAutoPageSize": True,
"pagination": True,
"defaultColDef": {"sortable": True},
"columnDefs": [
{
"headerName": "Host",
"field": "host",
"headerCheckboxSelection": True,
"headerCheckboxSelectionFilteredOnly": True,
"checkboxSelection": True,
"filter": "agTextColumnFilter",
"maxWidth": 100,
},
{
"headerName": "Action",
"field": "action",
"filter": "agTextColumnFilter",
"maxWidth": 100,
},
{
"headerName": "Command",
"field": "command",
"filter": "agTextColumnFilter",
"flex": 1,
},
{
"headerName": "Status",
"field": "status",
"filter": "agTextColumnFilter",
"maxWidth": 100,
"cellClassRules": {
"text-blue-300": "x == 'pending'",
"text-yellow-300": "x == 'running'",
"text-red-300": "x == 'error'",
"text-green-300": "x == 'success'",
},
},
],
"rowData": self._tasks,
},
theme="balham-dark",
).on("cellClicked", lambda e: display_result(e))
grid.tailwind().width("full").height("full")
grid.call_api_method("selectAll")
with el.WRow() as row:
row.tailwind.height("[40px]")
spinner = el.Spinner()
ui.button("Apply", on_click=apply).props("outline square").classes("text-secondary")
ui.button("Dry Run", on_click=dry_run).props("outline square").classes("text-secondary")
ui.button("Reset", on_click=reset).props("outline square").classes("text-secondary")
ui.button("Remove", on_click=lambda: dialog.submit("finish")).props("outline square").classes("text-secondary")
ui.button("Exit", on_click=lambda: dialog.submit("exit")).props("outline square").classes("text-secondary")
el.Spinner(master=spinner)
await dialog
tasks = list()
for task in self._tasks:
if task.status != "success":
tasks.append(task)
self._tasks = tasks
await self.display_snapshots()

3
snapper/tabs/settings.py Normal file
View File

@@ -0,0 +1,3 @@
import logging
logger = logging.getLogger(__name__)

50
static/terminal.js Normal file
View File

@@ -0,0 +1,50 @@
export default {
template: "<div></div>",
async mounted() {
await this.load_resource("https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css");
this.terminal = new Terminal(this.options);
this.terminal.open(this.$el);
// this.$emit("init", {});
const connectInterval = setInterval(async () => {
if (window.socket.id === undefined) return;
this.$emit("init", { socket_id: window.socket.id });
clearInterval(connectInterval);
}, 100);
},
beforeDestroy() {
this.terminal.dispose();
},
beforeUnmount() {
this.terminal.dispose();
},
methods: {
load_resource(url) {
return new Promise((resolve, reject) => {
const dataAttribute = `data-${url.split("/").pop().replace(/\./g, "-")}`;
if (document.querySelector(`[${dataAttribute}]`)) {
resolve();
return;
}
let element;
if (url.endsWith(".css")) {
element = document.createElement("link");
element.setAttribute("rel", "stylesheet");
element.setAttribute("href", url);
} else if (url.endsWith(".js")) {
element = document.createElement("script");
element.setAttribute("src", url);
}
element.setAttribute(dataAttribute, "");
document.head.appendChild(element);
element.onload = resolve;
element.onerror = reject;
});
},
call_api_method(name, ...args) {
this.terminal[name](...args);
},
},
props: {
options: Object,
}
};

209
static/xterm.css Normal file
View File

@@ -0,0 +1,209 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}

2
static/xterm.js Normal file

File diff suppressed because one or more lines are too long

1
static/xterm.js.map Normal file

File diff suppressed because one or more lines are too long