mirror of
https://github.com/natankeddem/bale.git
synced 2026-04-23 06:50:41 +00:00
initial
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal 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
95
.github/workflows/docker-publish.yml
vendored
Normal 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
170
.gitignore
vendored
Normal 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
22
DOCKERFILE
Normal 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
46
README
Normal 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
15
docker-compose.yml
Normal 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
21
inv.yml
Normal 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
18
main.py
Normal 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
113
mylogging.py
Normal 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
107
pve-install.yml
Normal 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
7
requirements.txt
Normal 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
|
||||
40
resources/docker-entrypoint.sh
Normal file
40
resources/docker-entrypoint.sh
Normal 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
10
resources/snapper.service
Normal 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
33
snapper.code-workspace
Normal 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
0
snapper/__init__.py
Normal file
0
snapper/apps/__init__.py
Normal file
0
snapper/apps/__init__.py
Normal file
151
snapper/apps/zab.py
Normal file
151
snapper/apps/zab.py
Normal 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
79
snapper/content.py
Normal 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
154
snapper/drawer.py
Normal 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
264
snapper/elements.py
Normal 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)
|
||||
0
snapper/interfaces/__init__.py
Normal file
0
snapper/interfaces/__init__.py
Normal file
189
snapper/interfaces/cli.py
Normal file
189
snapper/interfaces/cli.py
Normal 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
127
snapper/interfaces/ssh.py
Normal 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
211
snapper/interfaces/zfs.py
Normal 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
21
snapper/logo.py
Normal 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
45
snapper/page.py
Normal 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
39
snapper/result.py
Normal 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
51
snapper/scheduler.py
Normal 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
96
snapper/tabs/__init__.py
Normal 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
649
snapper/tabs/automation.py
Normal 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
77
snapper/tabs/history.py
Normal 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
386
snapper/tabs/manage.py
Normal 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
3
snapper/tabs/settings.py
Normal file
@@ -0,0 +1,3 @@
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
50
static/terminal.js
Normal file
50
static/terminal.js
Normal 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
209
static/xterm.css
Normal 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
2
static/xterm.js
Normal file
File diff suppressed because one or more lines are too long
1
static/xterm.js.map
Normal file
1
static/xterm.js.map
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user