43 Commits

Author SHA1 Message Date
Natan Keddem 94fba0b925 added test to http pipe 2023-11-18 20:46:06 -05:00
Natan Keddem 8572ad766b improved browse and find 2023-11-18 20:45:53 -05:00
Natan Keddem 8a2922262e added output truncate to cli 2023-11-18 16:06:01 -05:00
Natan Keddem d322612fc8 refactor and fix table display and sorting 2023-11-17 23:18:03 -05:00
Natan Keddem 3d13876804 optimize startup 2023-11-17 23:16:15 -05:00
Natan Keddem 4685939cae fixed arbitrary target path recall 2023-11-16 21:37:21 -05:00
Natan Keddem dfcafed973 refactor startup builders 2023-11-16 19:34:52 -05:00
Natan Keddem 566fb9442c added arbitrary target path selection 2023-11-16 19:05:53 -05:00
Natan Keddem 3b13ca89bd update to nicegui 1.4.2 2023-11-15 18:38:14 -05:00
Natan Keddem 6e7ebd4c25 make the http pipe dialog wider 2023-11-14 20:33:59 -05:00
Natan Keddem b87a3d79b5 change drawer styling 2023-11-13 22:20:55 -05:00
Natan Keddem f2119ea762 change default load timer 2023-11-13 22:19:38 -05:00
Natan Keddem 6acef6c0a5 refactor page element style 2023-11-13 22:15:36 -05:00
Natan Keddem 1b1cccdc4d change to new shade of orange 2023-11-13 22:14:23 -05:00
Natan Keddem 8808f66f5e reduce result terminal rows 2023-11-13 22:13:53 -05:00
Natan Keddem d3199fa0ad remove notify from zfs queries 2023-11-13 22:13:28 -05:00
Natan Keddem 489c0607b6 added expansion function to drawer 2023-11-12 21:21:26 -05:00
Natan Keddem 9120fcf64f fixed renaming of host 2023-11-12 21:21:26 -05:00
Natan Keddem 40eb701e9b cleanup drawer 2023-11-12 21:21:26 -05:00
Natan Keddem 4c67e050d1 moved defaults to main 2023-11-12 21:21:26 -05:00
Natan Keddem cf32b1c885 modify color theme 2023-11-12 21:21:26 -05:00
Natan Keddem 3c745b9746 fixed zfs data race condition bug 2023-11-12 21:21:26 -05:00
Natan Keddem 50590d924f added notification for auto manage mode 2023-11-12 21:21:26 -05:00
Natan Keddem b6753be56d added default host selection 2023-11-12 21:21:26 -05:00
Natan Keddem f4722af3db added content min width and remove settings 2023-11-12 21:21:22 -05:00
Natan Keddem cdbf77e039 cleanup content 2023-11-12 20:56:33 -05:00
Natan Keddem 1462de5acd added return_code injection for Cli 2023-11-09 20:18:45 -05:00
Natan Keddem 5a7bd1b61d added http pipe processing to Tab 2023-11-09 20:18:04 -05:00
Natan Keddem 5635b4ee42 added pipe enable and processing to automation 2023-11-09 20:16:38 -05:00
Natan Keddem 5b09b53d85 added http pipe setup to history 2023-11-09 20:15:21 -05:00
Natan Keddem 40d4155672 added remove to manage tasks and optimize 2023-11-09 20:14:48 -05:00
Natan Keddem f8c1f56ec1 cleanup manage 2023-11-09 20:13:48 -05:00
Natan Keddem 3614800715 added pipe enables to Automation 2023-11-09 20:13:10 -05:00
Natan Keddem 1d883a91d4 restructure Result 2023-11-09 20:12:50 -05:00
Natan Keddem 6e0f19db26 added custom json_editor 2023-11-09 20:12:29 -05:00
Natan Keddem 22ff08a09b restructure defaults and css loading 2023-11-09 20:08:32 -05:00
Natan Keddem 03c7bfcfaf cleanup elements 2023-11-09 20:05:03 -05:00
Natan Keddem 157c8c7deb checkbox no longer full width 2023-11-09 20:04:17 -05:00
Natan Keddem 10dd8d1d67 workspace update 2023-11-09 20:03:01 -05:00
Natan Keddem e2f36062d6 change static mount to files 2023-11-07 20:38:02 -05:00
Natan Keddem 4183313467 enable auto mode 2023-11-05 20:32:58 -05:00
Natan Keddem df24ffe39f fixed confirm for browse 2023-11-05 20:00:03 -05:00
Natan Keddem 6f625b029b cleanup ssh 2023-11-05 19:59:34 -05:00
20 changed files with 625 additions and 188 deletions
+3 -2
View File
@@ -13,7 +13,7 @@
"request": "launch",
"program": "main.py",
"console": "integratedTerminal",
"justMyCode": true
"justMyCode": false
},
{
"name": "Python: Current File",
@@ -33,6 +33,7 @@
"black-formatter.args": [
"-l",
"180"
]
],
"editor.suggest.showStatusBar": true
}
}
+10 -10
View File
@@ -1,9 +1,8 @@
from nicegui import app, ui
import re
from datetime import datetime
import asyncio
from nicegui import ui
from bale import elements as el
import bale.logo as logo
from bale.tabs import Tab
from bale.tabs.manage import Manage
from bale.tabs.history import History
from bale.tabs.automation import Automation
@@ -28,8 +27,9 @@ class Content:
self._automation = None
self._history = None
def build(self):
async def build(self):
self._header = ui.header(bordered=True).classes("bg-dark q-pt-sm q-pb-xs")
self._header.tailwind.border_color(f"[{el.orange}]").min_width("[920px]")
self._header.visible = False
with self._header:
with ui.row().classes("w-full h-12 justify-between items-center"):
@@ -38,7 +38,6 @@ class Content:
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")
@@ -46,6 +45,9 @@ class Content:
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)
)
default = Tab(spinner=None).common.get("default", "")
if default != "":
await self.host_selected(default)
async def _tab_changed(self, e):
if e.value == "Manage":
@@ -55,14 +57,12 @@ class Content:
def _build_tab_panels(self):
with self._tab_panels:
with ui.tab_panel(self._tab["manage"]).style("height: calc(100vh - 131px)"):
with el.ContentTabPanel(self._tab["manage"]):
self._manage = Manage(spinner=self._spinner, host=self._host)
with ui.tab_panel(self._tab["automation"]).style("height: calc(100vh - 131px)"):
with el.ContentTabPanel(self._tab["automation"]):
self._automation = Automation(spinner=self._spinner, host=self._host)
with ui.tab_panel(self._tab["history"]).style("height: calc(100vh - 131px)"):
with el.ContentTabPanel(self._tab["history"]):
self._history = History(spinner=self._spinner, host=self._host)
with ui.tab_panel(self._tab["settings"]).style("height: calc(100vh - 131px)"):
ui.label("settings tab")
async def host_selected(self, name):
self._host = name
+26 -7
View File
@@ -22,8 +22,20 @@ class Drawer(object):
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():
def toggle_drawer():
if chevron._props["icon"] == "chevron_left":
content.visible = False
drawer.props("width=0")
chevron.props("icon=chevron_right")
chevron.style("top: 16vh").style("right: -24px").style("height: 16vh")
else:
content.visible = True
drawer.props("width=200")
chevron.props("icon=chevron_left")
chevron.style("top: 16vh").style("right: -12px").style("height: 16vh")
with ui.left_drawer(top_corner=True).props("width=226 behavior=desktop bordered").classes("q-pa-none") as drawer:
with ui.column().classes("h-full w-full q-py-xs q-px-md") as content:
self._header_row = el.WRow().classes("justify-between")
self._header_row.tailwind().height("12")
with self._header_row:
@@ -50,12 +62,17 @@ class Drawer(object):
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")
.props("dense flat bordered binary-state-sort hide-header hide-pagination hide-selected-bannerhide-no-data")
)
self._table.tailwind.width("full")
self._table.visible = False
for name in ssh.get_hosts("data"):
self._add_host_to_table(name)
chevron = ui.button(icon="chevron_left", color=None, on_click=toggle_drawer).props("padding=0px")
chevron.classes("absolute")
chevron.style("top: 16vh").style("right: -12px").style("background-color: #0E1210 !important").style("height: 16vh")
chevron.tailwind.border_color("[#E97451]")
chevron.props(f"color=primary text-color=accent")
def _add_host_to_table(self, name):
if len(name) > 0:
@@ -70,9 +87,7 @@ class Drawer(object):
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
)
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")
@@ -107,6 +122,10 @@ class Drawer(object):
result = await host_dialog
if result == "save":
if name != "" and name != host_input.value:
default = Tab(spinner=None).common.get("default", "")
if default == name:
Tab(spinner=None).common["default"] = ""
ssh.Ssh(path="data", host=name).remove()
for row in self._table.rows:
if name == row["name"]:
self._table.remove_rows(row)
+68 -21
View File
@@ -1,6 +1,7 @@
from typing import Any, Callable, Dict, List, Literal, Optional, Union
from nicegui import ui, app, Tailwind
from nicegui.elements.spinner import SpinnerTypes
from nicegui.elements.tabs import Tab
from nicegui.tailwind_types.height import Height
from nicegui.tailwind_types.width import Width
from nicegui.elements.mixins.validation_element import ValidationElement
@@ -10,14 +11,48 @@ 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")
orange = "#E97451"
dark = "#0E1210"
def load_element_css():
ui.add_head_html(
f"""
<style>
.bale-colors,
.q-table--dark,
.q-table--dark .q-table__bottom,
.q-table--dark td,
.q-table--dark th,
.q-table--dark thead,
.q-table--dark tr,
.q-table__card--dark,
body.body--dark .q-drawer,
body.body--dark .q-footer,
body.body--dark .q-header {{
color: {orange} !important;
border-color: {orange} !important;
}}
.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;
align-items: center;
}}
.multi-line-notification {{
white-space: pre-line;
}}
.q-drawer--bordered{{
border-color: {orange} !important;
}}
</style>
"""
)
ui.add_head_html('<link href="static/jse-theme-dark.css" rel="stylesheet">')
class ErrorAggregator:
@@ -127,18 +162,17 @@ class FInput(ui.input):
class DSelect(ui.select):
def __init__(
self,
options: List | Dict,
options: Union[List, Dict],
*,
label: str | None = None,
label: Optional[str] = None,
value: Any = None,
on_change: Callable[..., Any] | None = None,
on_change: Optional[Callable[..., Any]] = None,
with_input: bool = False,
new_value_mode: Optional[Literal["add", "add-unique", "toggle"]] = None,
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
)
super().__init__(options, label=label, value=value, on_change=on_change, with_input=with_input, new_value_mode=new_value_mode, multiple=multiple, clearable=clearable)
self.tailwind.width("full")
if multiple is True:
self.props("use-chips")
@@ -147,18 +181,17 @@ class DSelect(ui.select):
class FSelect(ui.select):
def __init__(
self,
options: List | Dict,
options: Union[List, Dict],
*,
label: str | None = None,
label: Optional[str] = None,
value: Any = None,
on_change: Callable[..., Any] | None = None,
on_change: Optional[Callable[..., Any]] = None,
with_input: bool = False,
new_value_mode: Optional[Literal["add", "add-unique", "toggle"]] = None,
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
)
super().__init__(options, label=label, value=value, on_change=on_change, with_input=with_input, new_value_mode=new_value_mode, multiple=multiple, clearable=clearable)
self.tailwind.width("64")
@@ -179,7 +212,7 @@ class DButton(ui.button):
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")
self.tailwind.text_color("secondary")
class IButton(ui.button):
@@ -264,3 +297,17 @@ def notify(
)
else:
ui.notify(message=message, position="bottom-left", type=type)
class JsonEditor(ui.json_editor):
def __init__(self, properties: Dict, *, on_select: Optional[Callable] = None, on_change: Optional[Callable] = None) -> None:
super().__init__(properties, on_select=on_select, on_change=on_change)
self.classes("jse-theme-dark")
self.tailwind.height("[360px]").width("full")
class ContentTabPanel(ui.tab_panel):
def __init__(self, name: Tab | str) -> None:
super().__init__(name)
self.style("height: calc(100vh - 131px)")
self.tailwind.min_width("[920px]")
+13 -7
View File
@@ -4,7 +4,7 @@ from asyncio.subprocess import Process, PIPE
import contextlib
import shlex
from datetime import datetime
from nicegui import app, ui
from nicegui import ui
from bale.result import Result
import logging
@@ -12,7 +12,6 @@ logger = logging.getLogger(__name__)
def load_terminal_css():
app.add_static_files("/static", "static")
ui.add_head_html('<link href="static/xterm.css" rel="stylesheet">')
@@ -35,6 +34,7 @@ class Cli:
self.stderr: List[str] = []
self._terminate: asyncio.Event = asyncio.Event()
self._busy: bool = False
self._truncated: bool = False
self.prefix_line: str = ""
self._stdout_terminals: List[Terminal] = []
self._stderr_terminals: List[Terminal] = []
@@ -71,8 +71,11 @@ class Cli:
else:
break
async def _controller(self, process: Process) -> None:
async def _controller(self, process: Process, max_output_lines) -> None:
while process.returncode is None:
if max_output_lines > 0 and len(self.stderr) + len(self.stdout) > max_output_lines:
self._truncated = True
process.terminate()
if self._terminate.is_set():
process.terminate()
try:
@@ -84,7 +87,7 @@ class Cli:
def terminate(self) -> None:
self._terminate.set()
async def execute(self, command: str) -> Result:
async def execute(self, command: str, max_output_lines: int = 0) -> Result:
self._busy = True
c = shlex.split(command, posix=False)
try:
@@ -93,13 +96,14 @@ class Cli:
self.stdout.clear()
self.stderr.clear()
self._terminate.clear()
self._truncated = False
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._controller(process=process, max_output_lines=max_output_lines),
self._read_stdout(stream=process.stdout),
self._read_stderr(stream=process.stderr),
)
@@ -111,7 +115,9 @@ class Cli:
finally:
self._terminate.clear()
self._busy = False
return Result(command=command, stdout_lines=self.stdout.copy(), stderr_lines=self.stderr.copy(), terminated=terminated)
return Result(
command=command, return_code=process.returncode, stdout_lines=self.stdout.copy(), stderr_lines=self.stderr.copy(), terminated=terminated, truncated=self._truncated
)
async def shell(self, command: str) -> Result:
self._busy = True
@@ -133,7 +139,7 @@ class Cli:
raise e
finally:
self._busy = False
return Result(command=command, stdout_lines=self.stdout.copy(), stderr_lines=self.stderr.copy(), terminated=False)
return Result(command=command, return_code=process.returncode, stdout_lines=self.stdout.copy(), stderr_lines=self.stderr.copy(), terminated=False)
def clear_buffers(self):
self.prefix_line = ""
+2 -3
View File
@@ -41,7 +41,6 @@ class Ssh(Cli):
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()
@@ -91,10 +90,10 @@ class Ssh(Cli):
del self._config[self.host]
self.write_config()
async def execute(self, command: str) -> Result:
async def execute(self, command: str, max_output_lines: int = 0) -> Result:
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}"
return await super().execute(self._full_cmd)
return await super().execute(self._full_cmd, max_output_lines)
async def send_key(self) -> Result:
await get_public_key(self._raw_path)
+26 -18
View File
@@ -3,7 +3,7 @@ from pathlib import Path
import stat
from datetime import datetime
import uuid
from nicegui import app, events, ui
from nicegui import app, background_tasks, events, ui
from fastapi.responses import StreamingResponse
import asyncssh
from bale import elements as el
@@ -87,7 +87,7 @@ class SshFileBrowse(ui.dialog):
row.tailwind.height("[40px]")
el.DButton("Download", on_click=self._start_download)
ui.button("Exit", on_click=lambda: self.submit("exit"))
await self._update_grid()
await self._update_handler()
async def _connect(self) -> Tuple[asyncssh.SSHClientConnection, asyncssh.SFTPClient]:
ssh = await asyncssh.connect(self._zfs.hostname, username=self._zfs.username, client_keys=[self._zfs.key_path])
@@ -137,7 +137,7 @@ class SshFileBrowse(ui.dialog):
"permissions": attributes.permissions,
}
async def _update_grid(self) -> None:
async def _update_handler(self) -> None:
self._grid.call_api_method("showLoadingOverlay")
if self._ssh is None or self._sftp is None:
self._ssh, self._sftp = await self._connect()
@@ -165,7 +165,7 @@ class SshFileBrowse(ui.dialog):
async def _handle_double_click(self, e: events.GenericEventArguments) -> None:
self.path = e.args["data"]["path"]
if e.args["data"]["type"] == "directory":
await self._update_grid()
await self._update_handler()
else:
await self._start_download(e)
@@ -226,10 +226,10 @@ class SshFileFind(SshFileBrowse):
with el.DBody(height="fit", width="[90vw]"):
with el.WColumn().classes("col"):
filesystems = await self._zfs.filesystems
self._filesystem = el.DSelect(
list(filesystems.data.keys()), label="filesystem", with_input=True, on_change=self._update_grid
)
self._pattern = el.DInput("Pattern", on_change=self._update_grid)
self._filesystem = el.DSelect(list(filesystems.data.keys()), label="filesystem", with_input=True, on_change=self._update_handler)
with el.WRow():
self._pattern = ui.input("Pattern").classes("col").on("keydown.enter", handler=self._update_handler)
el.LgButton(icon="search", on_click=self._update_handler)
self._grid = ui.aggrid(
{
"defaultColDef": {"flex": 1, "sortable": True, "suppressMovable": True, "sortingOrder": ["asc", "desc"]},
@@ -237,12 +237,14 @@ class SshFileFind(SshFileBrowse):
{"field": "name", "headerName": "Name", "flex": 1, "sort": "desc", "resizable": True},
{"field": "location", "headerName": "Location", "flex": 1, "resizable": True},
{
"field": "modified_datetime",
"headerName": "Modified",
"field": "modified_timestamp",
"filter": "agTextColumnFilter",
"maxWidth": 200,
":comparator": """(valueA, valueB, nodeA, nodeB, isInverted) => {
return (nodeA.data.modified_timestamp > nodeB.data.modified_timestamp) ? -1 : 1;
}""",
":cellRenderer": """(data) => {
var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});;
return date;
}""",
},
{
"field": "size",
@@ -264,15 +266,21 @@ class SshFileFind(SshFileBrowse):
row.tailwind.height("[40px]")
el.DButton("Download", on_click=self._start_download)
ui.button("Exit", on_click=lambda: self.submit("exit"))
await self._update_grid()
self._grid.call_api_method("hideOverlay")
async def _update_grid(self) -> None:
self._grid.call_api_method("showLoadingOverlay")
if self._filesystem is not None:
async def _update_handler(self) -> None:
if len(self._pattern.value) > 0 and self._filesystem is not None:
self._grid.call_api_method("showLoadingOverlay")
self._filesystem.props("readonly")
self._pattern.props("readonly")
files = await self._zfs.find_files_in_snapshots(filesystem=self._filesystem.value, pattern=self._pattern.value)
self._grid.options["rowData"] = files.data
self._grid.update()
self._grid.call_api_method("hideOverlay")
if files.truncated is True:
el.notify("Too many files found, truncating list.", type="warning")
self._grid.update()
self._filesystem.props(remove="readonly")
self._pattern.props(remove="readonly")
self._grid.call_api_method("hideOverlay")
async def _handle_double_click(self, e: events.GenericEventArguments) -> None:
await self._start_download(e)
+40 -31
View File
@@ -81,7 +81,7 @@ class Zfs:
command = command if len(command) < 160 else command[:160] + "..."
el.notify(command)
async def execute(self, command: str, notify: bool = True) -> Result:
async def execute(self, command: str, max_output_lines: int = 0, notify: bool = True) -> Result:
if notify:
self.notify(command)
return Result(command=command)
@@ -95,16 +95,17 @@ class Zfs:
def is_query_ready_to_execute(self, query: str, timeout: int):
now = datetime.now()
if query in self._last_run_time:
if query in self._last_run_time and query in self._last_data:
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
def set_query_time(self, query: str):
self._last_run_time[query] = datetime.now()
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
@@ -138,7 +139,7 @@ class Zfs:
else:
with_holds = [snapshot]
if len(with_holds) > 0:
result = await self.execute(f"zfs holds -H -r {with_holds}")
result = await self.execute(f"zfs holds -H -r {with_holds}", notify=False)
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)
@@ -161,35 +162,39 @@ class Zfs:
else:
data = []
result = Result(data=data, cached=True)
self.set_query_time(query)
return result
async def find_files_in_snapshots(self, filesystem: str, pattern: str) -> Result:
filesystems = await self.filesystems
if filesystem in filesystems.data.keys():
if "mountpoint" in filesystems.data[filesystem]:
command = f"find {filesystems.data[filesystem]['mountpoint']}/.zfs/snapshot -type f -name '{pattern}' -printf '%h\t%f\t%s\t%T@\n'"
result = await self.execute(command=command, notify=False)
files = []
for line in result.stdout_lines:
matches = re.match(
"^(?P<location>[^\t]+)\t(?P<name>[^\t]+)\t(?P<bytes>[^\t]+)\t(?P<modified_timestamp>[^\n]+)",
line,
)
if matches is not None:
md = matches.groupdict()
md["path"] = f"{md['location']}/{md['name']}"
md["size"] = format_bytes(int(md["bytes"]))
md["modified_datetime"] = datetime.fromtimestamp(float(md["modified_timestamp"])).strftime("%Y/%m/%d %H:%M:%S")
files.append(md)
result.data = files
return result
try:
filesystems = await self.filesystems
command = f"find {filesystems.data[filesystem]['mountpoint']}/.zfs/snapshot -type f -name '{pattern}' -printf '%h\t%f\t%s\t%T@\n'"
result = await self.execute(command=command, notify=False, max_output_lines=1000)
files = []
for line in result.stdout_lines:
matches = re.match(
"^(?P<location>[^\t]+)\t(?P<name>[^\t]+)\t(?P<bytes>[^\t]+)\t(?P<modified_timestamp>[^\n]+)",
line,
)
if matches is not None:
md = matches.groupdict()
md["path"] = f"{md['location']}/{md['name']}"
md["bytes"] = int(md["bytes"])
md["size"] = format_bytes(md["bytes"])
md["modified_datetime"] = datetime.fromtimestamp(float(md["modified_timestamp"])).strftime("%Y/%m/%d %H:%M:%S")
md["modified_timestamp"] = float(md["modified_timestamp"])
files.append(md)
result.data = files
return result
except KeyError:
pass
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")
result = await self.execute("zfs list -Hp -t filesystem -o name,used,avail,refer,mountpoint", notify=False)
filesystems = dict()
for line in result.stdout_lines:
matches = re.match(
@@ -204,27 +209,31 @@ class Zfs:
result.data = self._last_data[query]
else:
result = Result(data=self._last_data[query], cached=True)
self.set_query_time(query)
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")
result = await self.execute("zfs list -Hp -t snapshot -o name,used,creation,userrefs", notify=False)
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"]))
md["used_bytes"] = int(md["used_bytes"])
md["creation"] = int(md["creation"])
md["creation_date"] = datetime.fromtimestamp(md["creation"]).strftime("%Y/%m/%d")
md["creation_time"] = datetime.fromtimestamp(md["creation"]).strftime("%H:%M")
md["used"] = format_bytes(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)
self.set_query_time(query)
return result
@@ -236,10 +245,10 @@ class Ssh(ssh.Ssh, Zfs):
def notify(self, command: str):
super().notify(f"<{self.host}> {command}")
async def execute(self, command: str, notify: bool = True) -> Result:
async def execute(self, command: str, max_output_lines: int = 0, notify: bool = True) -> Result:
if notify:
self.notify(command)
result = await super().execute(command)
result = await super().execute(command, max_output_lines)
if result.stderr != "":
el.notify(result.stderr, type="negative")
result.name = self.host
+2 -2
View File
@@ -6,14 +6,14 @@ 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"/>
<path fill="#E97451" 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>
<path fill="#E97451" 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>
"""
+12 -27
View File
@@ -1,4 +1,5 @@
from nicegui import ui
import asyncio
from nicegui import app, Client, ui
from bale import elements as el
from bale.drawer import Drawer
from bale.content import Content
@@ -9,39 +10,23 @@ 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.page("/", response_timeout=30)
async def index(client: Client) -> None:
app.add_static_files("/static", "static")
el.load_element_css()
cli.load_terminal_css()
ui.colors(
primary=el.orange,
secondary=el.orange,
accent="#d946ef",
accent=el.orange,
dark=el.dark,
positive="#21ba45",
negative="#c10015",
info="#31ccec",
warning="#f2c037",
positive="#21BA45",
negative="#C10015",
info="#5C8984",
warning="#F2C037",
)
column = ui.column()
content = Content()
drawer = Drawer(column, content.host_selected, content.hide)
drawer.build()
content.build()
await content.build()
+12 -4
View File
@@ -1,4 +1,4 @@
from typing import Any, List
from typing import Any, List, Optional
from dataclasses import dataclass, field
from datetime import datetime
from copy import deepcopy
@@ -9,17 +9,21 @@ import time
class Result:
name: str = ""
command: str = ""
return_code: int = 0
return_code: Optional[int] = 0
stdout_lines: List[str] = field(default_factory=list)
stderr_lines: List[str] = field(default_factory=list)
terminated: bool = False
truncated: 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 failed(self) -> bool:
return False if self.status == "success" else True
@property
def date(self) -> str:
return datetime.fromtimestamp(self.timestamp).strftime("%Y/%m/%d")
@@ -36,8 +40,13 @@ class Result:
def stderr(self) -> str:
return "".join(self.stderr_lines)
@property
def properties(self) -> List:
return list(self.to_dict().keys())
def to_dict(self):
d = deepcopy(self.__dict__)
d["failed"] = self.failed
d["date"] = self.date
d["time"] = self.time
d["stdout"] = self.stdout
@@ -52,7 +61,6 @@ class Result:
self.stderr_lines = d["stderr_lines"]
self.terminated = d["terminated"]
self.data = d["data"]
self.failed = d["failed"]
self.trace = d["trace"]
self.cached = d["cached"]
self.status = d["status"]
+2
View File
@@ -19,6 +19,8 @@ class Automation:
triggers: Dict[str, str]
options: Union[Dict[str, Any], None] = None
timestamp: float = field(default_factory=time.time)
pipe_success: bool
pipe_error: bool
def to_dict(self) -> Dict[str, Any]:
return self.__dict__
+68 -4
View File
@@ -1,9 +1,12 @@
from typing import Any, Dict, List, Union
from dataclasses import dataclass, field
import string
import asyncio
from datetime import datetime
import time
from nicegui import ui
import json
import httpx
from nicegui import app, ui
from bale.interfaces.zfs import Ssh
from bale import elements as el
from bale.result import Result
@@ -21,6 +24,10 @@ class Task:
timestamp: float = field(default_factory=time.time)
class PipeTemplate(string.Template):
delimiter = ""
class SelectionConfirm:
def __init__(self, container, label) -> None:
self._container = container
@@ -98,7 +105,7 @@ class Tab:
ui.label(f"Time: {result.time}").classes("text-secondary")
with el.Card() as card:
with el.WColumn():
terminal = cli.Terminal(options={"rows": 20, "cols": 120, "convertEol": True})
terminal = cli.Terminal(options={"rows": 18, "cols": 120, "convertEol": True})
for line in result.stdout_lines:
terminal.call_terminal_method("write", line)
for line in result.stderr_lines:
@@ -115,11 +122,14 @@ class Tab:
self._history.pop(0)
self._history.append(r)
def _add_task(self, action: str, command: str, hosts: Union[List[str], None] = None) -> None:
def _add_task(self, action: str, command: str, hosts: Union[List[str], None] = None) -> List[Task]:
if hosts is None:
hosts = [self.host]
tasks = []
for host in hosts:
self._tasks.append(Task(action=action, command=command, host=host, status="pending"))
tasks.append(Task(action=action, command=command, host=host, status="pending"))
self._tasks.extend(tasks)
return tasks
def _remove_task(self, timestamp: str):
for task in self._tasks:
@@ -143,6 +153,48 @@ class Tab:
self._grid.options["rowSelection"] = row_selection
self._grid.update()
def get_pipe(self, pipe):
if pipe not in self.pipes:
self.pipes[pipe] = {}
return self.pipes[pipe]
def get_pipe_status(self, pipe, status):
if status not in self.get_pipe(pipe):
self.get_pipe(pipe)[status] = {}
return self.get_pipe(pipe)[status]
def process_pipe_data(self, result: Result, data: Any):
template = PipeTemplate(json.dumps(data))
json_string = template.safe_substitute(
name=result.name,
command=result.command,
return_code=result.return_code,
stdout_lines=result.stdout_lines,
stderr_lines=result.stderr_lines,
terminated=result.terminated,
data=result.data,
trace=result.trace,
cached=result.cached,
status=result.status,
timestamp=result.timestamp,
failed=result.failed,
date=result.date,
time=result.time,
stdout=result.stdout,
stderr=result.stderr,
)
json_string = json_string.replace("\n", r"\n").replace("\b", r"\b").replace("\f", r"\f").replace("\r", r"\r").replace("\t", r"\t")
return json.loads(json_string)
def pipe_result(self, result: Result):
http = self.get_pipe("http")
if http.get("enable", False) is True:
status = "success" if result.status == "success" else "error"
url = http[status]["url"]
data = self.process_pipe_data(result=result, data=http[status]["data"])
headers = http[status]["headers"]
httpx.post(url=url, json=data, headers=headers)
@property
def zfs(self) -> Ssh:
return self._zfs[self.host]
@@ -150,3 +202,15 @@ class Tab:
@property
def _zfs_hosts(self) -> List[str]:
return list(self._zfs.keys())
@property
def common(self) -> Dict[str, Any]:
if "common" not in app.storage.general:
app.storage.general["common"] = {}
return app.storage.general["common"]
@property
def pipes(self) -> Dict[str, Any]:
if "pipes" not in self.common:
self.common["pipes"] = {}
return self.common["pipes"]
+27 -3
View File
@@ -51,6 +51,11 @@ async def automation_job(**kwargs) -> None:
if job_handlers[d.id].is_busy is False:
result = await job_handlers[d.id].execute(command.safe_substitute(host=d.host))
result.name = d.host
result.status = "success" if result.return_code == 0 else "error"
if d.pipe_success is True and result.status == "success":
tab.pipe_result(result=result)
if d.pipe_error is True and result.status != "success":
tab.pipe_result(result=result)
tab.add_history(result=result)
else:
logger.warning("Job Skipped!")
@@ -60,6 +65,10 @@ async def automation_job(**kwargs) -> None:
if job_handlers[d.id].is_busy is False:
result = await job_handlers[d.id].execute(command.safe_substitute(host=d.host))
result.name = d.host
if d.pipe_success is True and result.status == "success":
tab.pipe_result(result=result)
if d.pipe_error is True and result.status != "success":
tab.pipe_result(result=result)
tab.add_history(result=result)
else:
logger.warning("Job Skipped!")
@@ -69,6 +78,10 @@ async def automation_job(**kwargs) -> None:
if job_handlers[d.id].is_busy is False:
result = await job_handlers[d.id].execute(command.safe_substitute(host=d.host))
result.name = d.host
if d.pipe_success is True and result.status == "success":
tab.pipe_result(result=result)
if d.pipe_error is True and result.status != "success":
tab.pipe_result(result=result)
tab.add_history(result=result)
else:
logger.warning("Job Skipped!")
@@ -478,10 +491,10 @@ class Automation(Tab):
row.tailwind.width("[860px]").justify_content("center")
with ui.column() as col:
col.tailwind.height("full").width("[420px]")
self.hosts = el.DSelect(source_hosts, label="Source Host(s)", multiple=True, with_input=True)
self.target_host = el.DSelect(target_host, 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.target_path = el.DSelect(self.target_paths, value="", label="Target Path", new_value_mode="add-unique", on_change=target_path_selected)
self.hosts = el.DSelect(source_hosts, label="Source Host(s)", multiple=True, with_input=True)
all_fs_to_lists()
with ui.scroll_area().classes("col"):
self.parentchildren = el.DSelect(
@@ -522,6 +535,8 @@ class Automation(Tab):
while target_path not in self.target_path.options and tries < 20:
await asyncio.sleep(0.1)
tries = tries + 1
if target_path not in self.target_paths:
self.target_paths.append(target_path)
self.target_path.value = target_path
self.parentchildren.value = self.fs["values"].get("parentchildren", None)
self.parent.value = self.fs["values"].get("parent", None)
@@ -699,7 +714,7 @@ class Automation(Tab):
with ui.dialog() as automation_dialog, el.Card():
with el.DBody(height="[90vh]", width="fit"):
with ui.stepper().props("flat").classes("full-size-stepper") as self.stepper:
with ui.stepper() as self.stepper:
with ui.step("Schedule Setup"):
with el.WColumn().classes("col justify-between"):
with ui.row().classes("col") as row:
@@ -707,6 +722,9 @@ class Automation(Tab):
with ui.column() as col:
col.tailwind.height("full").width("[420px]")
self.auto_name = el.DInput(label="Name", value=" ", validation=validate_name)
with el.WRow():
self.pipe_success = el.DCheckbox("Pipe Success", value=self.job_data.get("pipe_success", False))
self.pipe_error = el.DCheckbox("Pipe Error", value=self.job_data.get("pipe_error", False))
self.schedule_em = el.ErrorAggregator(self.auto_name)
if name != "":
self.app = el.DInput(label="Application", value=self.job_data["app"]).props("readonly")
@@ -778,6 +796,8 @@ class Automation(Tab):
target_path=self.target_path.value,
target_paths=self.target_path.options,
filesystems=self.fs,
pipe_success=self.pipe_success.value,
pipe_error=self.pipe_error.value,
)
self.scheduler.scheduler.add_job(
automation_job,
@@ -803,6 +823,8 @@ class Automation(Tab):
command=self.command.value,
schedule_mode=self.schedule_mode.value,
triggers=self.picked_triggers,
pipe_success=self.pipe_success.value,
pipe_error=self.pipe_error.value,
)
self.scheduler.scheduler.add_job(
automation_job,
@@ -823,6 +845,8 @@ class Automation(Tab):
command=self.command.value,
schedule_mode=self.schedule_mode.value,
triggers=self.picked_triggers,
pipe_success=self.pipe_success.value,
pipe_error=self.pipe_error.value,
)
self.scheduler.scheduler.add_job(
automation_job,
+89 -3
View File
@@ -1,6 +1,8 @@
from datetime import datetime
import json
from nicegui import ui, events
import httpx
from . import SelectionConfirm, Tab
from nicegui import ui
from bale import elements as el
from bale.result import Result
from bale.interfaces import zfs
@@ -23,6 +25,7 @@ class History(Tab):
with el.WRow().classes("justify-between").bind_visibility_from(self._confirm, "visible", value=False):
with ui.row().classes("items-center"):
el.SmButton(text="Remove", on_click=self._remove_history)
el.SmButton(text="HTTP Pipe", on_click=self._setup_http_pipe)
with ui.row().classes("items-center"):
el.SmButton(text="Refresh", on_click=lambda _: self._grid.update())
self._grid = ui.aggrid(
@@ -45,8 +48,16 @@ class History(Tab):
"filter": "agTextColumnFilter",
"flex": 1,
},
{"headerName": "Date", "field": "date", "filter": "agDateColumnFilter", "maxWidth": 100},
{"headerName": "Time", "field": "time", "maxWidth": 100},
{
"headerName": "Timestamp",
"field": "timestamp",
"filter": "agTextColumnFilter",
"maxWidth": 200,
":cellRenderer": """(data) => {
var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});;
return date;
}""",
},
{
"headerName": "Status",
"field": "status",
@@ -77,3 +88,78 @@ class History(Tab):
self._history.remove(row)
self._grid.update()
self._set_selection()
async def _setup_http_pipe(self):
http = {}
def set_url(status: str, url: str) -> None:
http[status]["url"] = url
def set_content(status: str, e: events.JsonEditorChangeEventArguments) -> None:
http[status]["data"] = e.content["json"]["data"]
http[status]["headers"] = e.content["json"]["headers"]
def test(status):
try:
url = http[status]["url"]
data = self.process_pipe_data(result=Result(name=self.host, command="TEST COMMAND", status=status), data=http[status]["data"])
headers = http[status]["headers"]
post = httpx.post(url=url, json=data, headers=headers)
print(post.status_code)
if post.status_code == 200:
el.notify("Test successful!", type="positive")
else:
el.notify(f"Test failed with status code {post.status_code}!", type="negative")
except:
el.notify("Test failed!", type="negative")
def show_controls(status):
if status not in http:
http[status] = {}
url = el.DInput(label="URL", on_change=lambda e: set_url(status, e.value))
rps = Result().properties
sv = []
for rp in rps:
sv.append(f"{{{rp}}}")
properties = {"content": {"json": {"data": {}, "headers": {}, "Special Values": sv}}}
editor = el.JsonEditor(properties=properties, on_change=lambda e: set_content(status, e))
url.value = self.get_pipe_status("http", status).get("url", "https://www.ntfy.sh/")
editor.properties["content"]["json"]["data"] = self.get_pipe_status("http", status).get(
"data",
{
"topic": "mytopic",
"tags": ["turtle"],
"title": "Successful Automation Run for {name}",
"message": "{command}",
},
)
editor.properties["content"]["json"]["headers"] = self.get_pipe_status("http", status).get("headers", {"Authorization": "Bearer tk_..."})
editor.update()
http[status]["data"] = editor.properties["content"]["json"]["data"]
http[status]["headers"] = editor.properties["content"]["json"]["headers"]
with ui.dialog() as host_dialog, el.Card():
with el.DBody(height="[90vh]", width="[720px]"):
with ui.stepper() as stepper:
with ui.step("General"):
with el.WColumn().classes("col justify-start"):
enable = el.DCheckbox("Enable")
enable.value = self.get_pipe("http").get("enable", False)
el.LgButton("NEXT", on_click=lambda _: stepper.next())
with ui.step("On Success"):
with el.WColumn().classes("col justify-start"):
show_controls(status="success")
with el.WRow():
el.LgButton("TEST", on_click=lambda _: test(status="success"))
el.LgButton("NEXT", on_click=lambda _: stepper.next())
with ui.step("On Error"):
with el.WColumn().classes("col justify-start"):
show_controls(status="error")
with el.WRow():
el.LgButton("TEST", on_click=lambda _: test(status="error"))
el.DButton("SAVE", on_click=lambda: host_dialog.submit("save"))
result = await host_dialog
if result == "save":
http["enable"] = enable.value
self.pipes["http"] = http
+100 -42
View File
@@ -1,6 +1,6 @@
import asyncio
from copy import deepcopy
from nicegui import ui
from nicegui import background_tasks, ui
from . import SelectionConfirm, Tab, Task
from bale.result import Result
from bale import elements as el
@@ -13,6 +13,21 @@ logger = logging.getLogger(__name__)
class Manage(Tab):
def _build(self):
def set_auto(value: bool) -> None:
self.common.update({"auto": value})
if value is True:
el.notify("Automatic task handling enabled.", type="info")
else:
el.notify("Automatic task handling disabled.", type="info")
def set_default(value) -> None:
if value is True:
self.common.update({"default": self.host})
el.notify(f"Default host is now {self.host}.", type="info")
else:
self.common.update({"default": ""})
el.notify("Default host is now unset.", type="info")
with el.WColumn() as col:
col.tailwind.height("full")
self._confirm = el.WRow()
@@ -27,8 +42,11 @@ class Manage(Tab):
el.SmButton(text="Browse", on_click=self._browse)
el.SmButton(text="Find", on_click=self._find)
with ui.row().classes("items-center"):
self._auto = ui.checkbox("Auto")
self._auto.props(f"left-label keep-color color=primary")
self._default = ui.checkbox("Default", value=True if self.common.get("default", "") == self.host else False, on_change=lambda e: set_default(e.value))
self._default.props("left-label keep-color color=primary")
self._default.tailwind.text_color("primary")
self._auto = ui.checkbox("Auto", value=self.common.get("auto", False), on_change=lambda e: set_auto(e.value))
self._auto.props("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)
@@ -57,11 +75,19 @@ class Manage(Tab):
"field": "used",
"maxWidth": 100,
":comparator": """(valueA, valueB, nodeA, nodeB, isInverted) => {
return (nodeA.data.used_bytes > nodeB.data.used_bytes) ? -1 : 1;
}""",
return (nodeA.data.used_bytes > nodeB.data.used_bytes) ? -1 : 1;
}""",
},
{
"headerName": "Created",
"field": "creation",
"filter": "agTextColumnFilter",
"maxWidth": 200,
":cellRenderer": """(data) => {
var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});;
return date;
}""",
},
{"headerName": "Creation Date", "field": "creation_date", "filter": "agDateColumnFilter", "maxWidth": 150},
{"headerName": "Creation Time", "field": "creation_time", "maxWidth": 150},
{"headerName": "Holds", "field": "userrefs", "filter": "agNumberColumnFilter", "maxWidth": 100},
],
"rowData": [],
@@ -74,20 +100,23 @@ class Manage(Tab):
self._spinner.visible = True
self.zfs.invalidate_query()
snapshots = await self.zfs.snapshots
await self.zfs.filesystems
await self.zfs.holds_for_snapshot()
background_tasks.create(self.zfs.filesystems, name="zfs_filesystems")
background_tasks.create(self.zfs.holds_for_snapshot(), name="zfs_holds")
self._grid.options["rowData"] = list(snapshots.data.values())
self._grid.update()
self._spinner.visible = False
async def _browse(self) -> None:
self._set_selection(mode="multiple")
self._set_selection(mode="single")
result = await SelectionConfirm(container=self._confirm, label=">BROWSE<")
if result == "confirm":
rows = await self._grid.get_selected_rows()
filesystems = await self.zfs.filesystems
mount_path = filesystems.data[rows[0]["filesystem"]]["mountpoint"]
await sshdl.SshFileBrowse(zfs=self.zfs, path=f"{mount_path}/.zfs/snapshot/{rows[0]['name']}")
try:
filesystems = await self.zfs.filesystems
mount_path = filesystems.data[rows[0]["filesystem"]]["mountpoint"]
await sshdl.SshFileBrowse(zfs=self.zfs, path=f"{mount_path}/.zfs/snapshot/{rows[0]['name']}")
except KeyError:
el.notify(f"Unable to browse {rows[0]['filesystem']}", type="warning")
self._set_selection()
async def _find(self) -> None:
@@ -111,11 +140,15 @@ class Manage(Tab):
result = await dialog
if result == "create":
for filesystem in filesystems.value:
self._add_task(
tasks = self._add_task(
"create",
zfs.SnapshotCreate(name=f"{filesystem}@{name.value}", recursive=recursive.value).command,
hosts=zfs_hosts.value,
)
if self._auto.value is True:
for task in tasks:
await self._run_task(task=task, spinner=self._spinner)
await self.display_snapshots()
async def _destroy_snapshot(self):
with ui.dialog() as dialog, el.Card():
@@ -132,11 +165,15 @@ class Manage(Tab):
if result == "destroy":
rows = await self._grid.get_selected_rows()
for row in rows:
self._add_task(
tasks = self._add_task(
"destroy",
zfs.SnapshotDestroy(name=f"{row['filesystem']}@{row['name']}", recursive=recursive.value).command,
hosts=zfs_hosts.value,
)
if self._auto.value is True:
for task in tasks:
await self._run_task(task=task, spinner=self._spinner)
await self.display_snapshots()
self._set_selection()
async def _rename_snapshot(self):
@@ -163,11 +200,15 @@ class Manage(Tab):
if mode.value == "replace":
rename = row["name"].replace(original.value, replace.value)
if row["name"] != rename:
self._add_task(
tasks = self._add_task(
"rename",
zfs.SnapshotRename(name=f"{row['filesystem']}@{row['name']}", new_name=rename, recursive=recursive.value).command,
hosts=zfs_hosts.value,
)
if self._auto.value is True:
for task in tasks:
await self._run_task(task=task, spinner=self._spinner)
await self.display_snapshots()
else:
el.notify(f"Skipping rename of {row['filesystem']}@{row['name']}!")
self._set_selection()
@@ -188,7 +229,7 @@ class Manage(Tab):
if result == "hold":
rows = await self._grid.get_selected_rows()
for row in rows:
self._add_task(
tasks = self._add_task(
"hold",
zfs.SnapshotHold(
name=f"{row['filesystem']}@{row['name']}",
@@ -197,6 +238,10 @@ class Manage(Tab):
).command,
hosts=zfs_hosts.value,
)
if self._auto.value is True:
for task in tasks:
await self._run_task(task=task, spinner=self._spinner)
await self.display_snapshots()
self._set_selection()
async def _release_snapshot(self):
@@ -227,7 +272,7 @@ class Manage(Tab):
if len(tags.value) > 0:
for tag in tags.value:
for row in rows:
self._add_task(
tasks = self._add_task(
"release",
zfs.SnapshotRelease(
name=f"{row['filesystem']}@{row['name']}",
@@ -236,35 +281,40 @@ class Manage(Tab):
).command,
hosts=zfs_hosts.value,
)
if self._auto.value is True:
for task in tasks:
await self._run_task(task=task, spinner=self._spinner)
await self.display_snapshots()
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
def _update_task_status(self, timestamp, status, result=None):
for task in self._tasks:
if timestamp == task.timestamp:
task.status = status
if result is not None:
task.result = deepcopy(result)
self.add_history(deepcopy(result))
return task
async def _run_task(self, task: Task, spinner: el.Spinner):
spinner.visible = True
if task.status == "pending":
self._update_task_status(task.timestamp, "running")
result = await self.zfs.execute(task.command)
if result.stdout == "" and result.stderr == "":
status = "success"
else:
status = "error"
self._update_task_status(task.timestamp, status, result)
spinner.visible = False
async def _display_tasks(self):
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
await self._run_task(task=task, spinner=spinner)
grid.update()
async def dry_run():
spinner.visible = True
@@ -280,7 +330,15 @@ class Manage(Tab):
for grow in grid.options["rowData"]:
if row["command"] == grow.command:
grow.status = "pending"
grid.update()
grid.update()
async def remove():
rows = await grid.get_selected_rows()
for row in rows:
for task in self._tasks:
if row["timestamp"] == task.timestamp:
self._tasks.remove(task)
grid.update()
async def display_result(e):
if e.args["data"]["result"] is not None:
@@ -344,7 +402,7 @@ class Manage(Tab):
el.DButton("Apply", on_click=apply)
el.DButton("Dry Run", on_click=dry_run)
el.DButton("Reset", on_click=reset)
el.DButton("Remove", on_click=lambda: dialog.submit("finish"))
el.DButton("Remove", on_click=remove)
el.DButton("Exit", on_click=lambda: dialog.submit("exit"))
el.Spinner(master=spinner)
-3
View File
@@ -1,3 +0,0 @@
import logging
logger = logging.getLogger(__name__)
+10
View File
@@ -8,6 +8,16 @@ if not os.path.exists("data"):
os.makedirs("data")
os.environ.setdefault("NICEGUI_STORAGE_PATH", "data")
from nicegui import ui
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")
ui.stepper.default_props("flat")
ui.stepper.default_classes("full-size-stepper")
from bale import page, logo, scheduler
+1 -1
View File
@@ -2,7 +2,7 @@ APScheduler==3.10.4
SQLAlchemy==2.0.22
cron-descriptor==1.4.0
cron-validator==1.0.8
nicegui==1.4.1
nicegui==1.4.2
zfs-autobackup==3.2
netifaces==0.11.0
asyncssh==2.14.0
+114
View File
@@ -0,0 +1,114 @@
.jse-theme-dark {
--jse-theme: dark;
/* over all fonts, sizes, and colors */
--jse-theme-color: #E48257;
--jse-theme-color-highlight: #E48257;
--jse-background-color: #1e1e1e;
--jse-text-color: #d4d4d4;
/* main, menu, modal */
--jse-main-border: 1px solid #4f4f4f;
--jse-menu-color: #fff;
--jse-modal-background: #2f2f2f;
--jse-modal-overlay-background: rgba(0, 0, 0, 0.5);
--jse-modal-code-background: #2f2f2f;
/* tooltip in text mode */
--jse-tooltip-color: var(--jse-text-color);
--jse-tooltip-background: #4b4b4b;
--jse-tooltip-border: 1px solid #737373;
--jse-tooltip-action-button-color: inherit;
--jse-tooltip-action-button-background: #737373;
/* panels: navigation bar, gutter, search box */
--jse-panel-background: #333333;
--jse-panel-background-border: 1px solid #464646;
--jse-panel-color: var(--jse-text-color);
--jse-panel-color-readonly: #737373;
--jse-panel-border: 1px solid #3c3c3c;
--jse-panel-button-color-highlight: #e5e5e5;
--jse-panel-button-background-highlight: #464646;
/* navigation-bar */
--jse-navigation-bar-background: #656565;
--jse-navigation-bar-background-highlight: #7e7e7e;
--jse-navigation-bar-dropdown-color: var(--jse-text-color);
/* context menu */
--jse-context-menu-background: #4b4b4b;
--jse-context-menu-background-highlight: #595959;
--jse-context-menu-separator-color: #595959;
--jse-context-menu-color: var(--jse-text-color);
--jse-context-menu-pointer-background: #737373;
--jse-context-menu-pointer-background-highlight: #818181;
--jse-context-menu-pointer-color: var(--jse-context-menu-color);
/* contents: json key and values */
--jse-key-color: #9cdcfe;
--jse-value-color: var(--jse-text-color);
--jse-value-color-number: #b5cea8;
--jse-value-color-boolean: #569cd6;
--jse-value-color-null: #569cd6;
--jse-value-color-string: #ce9178;
--jse-value-color-url: #ce9178;
--jse-delimiter-color: #949494;
--jse-edit-outline: 2px solid var(--jse-text-color);
/* contents: selected or hovered */
--jse-selection-background-color: #464646;
--jse-selection-background-inactive-color: #333333;
--jse-hover-background-color: #343434;
--jse-active-line-background-color: rgba(255, 255, 255, 0.06);
--jse-search-match-background-color: #343434;
/* contents: section of collapsed items in an array */
--jse-collapsed-items-background-color: #333333;
--jse-collapsed-items-selected-background-color: #565656;
--jse-collapsed-items-link-color: #b2b2b2;
--jse-collapsed-items-link-color-highlight: #ec8477;
/* contents: highlighting of search results */
--jse-search-match-color: #724c27;
--jse-search-match-outline: 1px solid #966535;
--jse-search-match-active-color: #9f6c39;
--jse-search-match-active-outline: 1px solid #bb7f43;
/* contents: inline tags inside the JSON document */
--jse-tag-background: #444444;
--jse-tag-color: #bdbdbd;
/* contents: table */
--jse-table-header-background: #333333;
--jse-table-header-background-highlight: #424242;
--jse-table-row-odd-background: rgba(255, 255, 255, 0.1);
/* controls in modals: inputs, buttons, and `a` */
--jse-input-background: #3d3d3d;
--jse-input-border: var(--jse-main-border);
--jse-button-background: #808080;
--jse-button-background-highlight: #7a7a7a;
--jse-button-color: #e0e0e0;
--jse-button-secondary-background: #494949;
--jse-button-secondary-background-highlight: #5d5d5d;
--jse-button-secondary-background-disabled: #9d9d9d;
--jse-button-secondary-color: var(--jse-text-color);
--jse-a-color: #E48257;
--jse-a-color-highlight: #E48257;
/* svelte-select */
--background: #3d3d3d;
--border: 1px solid #4f4f4f;
--list-background: #3d3d3d;
--item-hover-bg: #505050;
--multi-item-bg: #5b5b5b;
--input-color: #d4d4d4;
--multi-clear-bg: #8a8a8a;
--multi-item-clear-icon-color: #d4d4d4;
--multi-item-outline: 1px solid #696969;
--list-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.4);
/* color picker */
--jse-color-picker-background: #656565;
--jse-color-picker-border-box-shadow: #8c8c8c 0 0 0 1px;
}