mirror of
https://github.com/natankeddem/bale.git
synced 2026-05-03 14:12:54 +00:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36ee1f94cd | |||
| 07ce7e0bae | |||
| ffbc9b71c0 | |||
| f3ef97a342 | |||
| 94fba0b925 | |||
| 8572ad766b | |||
| 8a2922262e | |||
| d322612fc8 | |||
| 3d13876804 | |||
| 4685939cae | |||
| dfcafed973 | |||
| 566fb9442c | |||
| 3b13ca89bd | |||
| 6e7ebd4c25 | |||
| b87a3d79b5 | |||
| f2119ea762 | |||
| 6acef6c0a5 | |||
| 1b1cccdc4d | |||
| 8808f66f5e | |||
| d3199fa0ad | |||
| 489c0607b6 | |||
| 9120fcf64f | |||
| 40eb701e9b | |||
| 4c67e050d1 | |||
| cf32b1c885 | |||
| 3c745b9746 | |||
| 50590d924f | |||
| b6753be56d | |||
| f4722af3db | |||
| cdbf77e039 | |||
| 1462de5acd | |||
| 5a7bd1b61d | |||
| 5635b4ee42 | |||
| 5b09b53d85 | |||
| 40d4155672 | |||
| f8c1f56ec1 | |||
| 3614800715 | |||
| 1d883a91d4 | |||
| 6e0f19db26 | |||
| 22ff08a09b | |||
| 03c7bfcfaf | |||
| 157c8c7deb | |||
| 10dd8d1d67 | |||
| e2f36062d6 | |||
| 4183313467 | |||
| df24ffe39f | |||
| 6f625b029b |
+3
-2
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
+73
-23
@@ -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:
|
||||
@@ -36,8 +71,11 @@ class ErrorAggregator:
|
||||
|
||||
@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
|
||||
if len(self.elements) > 0:
|
||||
validators = all(validation(element.value) for element in self.elements for validation in element.validation.values())
|
||||
return self.enable and validators
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class WColumn(ui.column):
|
||||
@@ -127,18 +165,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 +184,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 +215,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 +300,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
@@ -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 = ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
+27
-19
@@ -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",
|
||||
"maxWidth": 200,
|
||||
":comparator": """(valueA, valueB, nodeA, nodeB, isInverted) => {
|
||||
return (nodeA.data.modified_timestamp > nodeB.data.modified_timestamp) ? -1 : 1;
|
||||
}""",
|
||||
"field": "modified_timestamp",
|
||||
"filter": "agTextColumnFilter",
|
||||
"maxWidth": 125,
|
||||
":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
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -11,6 +11,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
@dataclass(kw_only=True)
|
||||
class Automation:
|
||||
id: str
|
||||
name: str
|
||||
app: str
|
||||
hosts: List[str]
|
||||
host: str
|
||||
@@ -19,6 +20,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__
|
||||
@@ -28,6 +31,7 @@ class Automation:
|
||||
class Zfs_Autobackup(Automation):
|
||||
app: str = "zfs_autobackup"
|
||||
execute_mode: str = "local"
|
||||
prop: str
|
||||
target_host: str
|
||||
target_path: str
|
||||
target_paths: List[str]
|
||||
|
||||
+72
-6
@@ -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
|
||||
@@ -90,15 +97,17 @@ class Tab:
|
||||
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")
|
||||
timestamp = await ui.run_javascript(
|
||||
f"new Date({result.timestamp} * 1000).toLocaleString(undefined, {{dateStyle: 'short', timeStyle: 'short', hour12: 'false'}});"
|
||||
)
|
||||
ui.label(f"Timestamp: {timestamp}").classes("text-secondary")
|
||||
with ui.column() as col:
|
||||
col.tailwind.max_width("lg")
|
||||
ui.label(f"Task has failed: {result.failed}").classes("text-secondary")
|
||||
ui.label(f"Data is cached: {result.cached}").classes("text-secondary")
|
||||
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 +124,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 +155,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 +204,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"]
|
||||
|
||||
+84
-41
@@ -1,4 +1,4 @@
|
||||
from typing import Any, Dict, List, Union
|
||||
from typing import Any, Callable, Dict, List, Union
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import json
|
||||
@@ -36,21 +36,26 @@ def populate_job_handler(app: str, job_id: str, host: str):
|
||||
return job_handlers[job_id]
|
||||
|
||||
|
||||
class CommandTemplate(string.Template):
|
||||
class AutomationTemplate(string.Template):
|
||||
delimiter = ""
|
||||
|
||||
|
||||
async def automation_job(**kwargs) -> None:
|
||||
if "data" in kwargs:
|
||||
jd = json.loads(kwargs["data"])
|
||||
command = CommandTemplate(jd["command"])
|
||||
command = AutomationTemplate(jd["command"])
|
||||
tab = Tab(host=None, spinner=None)
|
||||
if jd["app"] == "zfs_autobackup":
|
||||
d = scheduler.Zfs_Autobackup(**jd)
|
||||
populate_job_handler(app=d.app, job_id=d.id, host=d.host)
|
||||
if job_handlers[d.id].is_busy is False:
|
||||
result = await job_handlers[d.id].execute(command.safe_substitute(host=d.host))
|
||||
result = await job_handlers[d.id].execute(command.safe_substitute(name=d.name, 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!")
|
||||
@@ -58,8 +63,12 @@ async def automation_job(**kwargs) -> None:
|
||||
d = scheduler.Automation(**jd)
|
||||
populate_job_handler(app=d.app, job_id=d.id, host=d.host)
|
||||
if job_handlers[d.id].is_busy is False:
|
||||
result = await job_handlers[d.id].execute(command.safe_substitute(host=d.host))
|
||||
result = await job_handlers[d.id].execute(command.safe_substitute(name=d.name, 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!")
|
||||
@@ -67,8 +76,12 @@ async def automation_job(**kwargs) -> None:
|
||||
d = scheduler.Automation(**jd)
|
||||
populate_job_handler(app=d.app, job_id=d.id, host=d.host)
|
||||
if job_handlers[d.id].is_busy is False:
|
||||
result = await job_handlers[d.id].execute(command.safe_substitute(host=d.host))
|
||||
result = await job_handlers[d.id].execute(command.safe_substitute(name=d.name, 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!")
|
||||
@@ -86,7 +99,7 @@ class Automation(Tab):
|
||||
self.job_data: Dict[str, str] = {}
|
||||
self.job_names: List[str] = []
|
||||
self.default_options: Dict[str, str] = {}
|
||||
self.build_command: str = ""
|
||||
self.build_command: Callable
|
||||
self.target_host: el.DSelect
|
||||
self.target_paths: List[str] = [""]
|
||||
self.target_path: el.DSelect
|
||||
@@ -148,8 +161,16 @@ class Automation(Tab):
|
||||
"maxWidth": 150,
|
||||
},
|
||||
{"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": "Next Run",
|
||||
"field": "next_run",
|
||||
"filter": "agTextColumnFilter",
|
||||
"maxWidth": 125,
|
||||
":cellRenderer": """(data) => {
|
||||
var date = new Date(data.value * 1000).toLocaleString(undefined, {dateStyle: 'short', timeStyle: 'short', hour12: false});;
|
||||
return date;
|
||||
}""",
|
||||
},
|
||||
{
|
||||
"headerName": "Status",
|
||||
"field": "status",
|
||||
@@ -214,11 +235,9 @@ class Automation(Tab):
|
||||
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")
|
||||
next_run = job.next_run_time.timestamp()
|
||||
else:
|
||||
next_run_date = "NA"
|
||||
next_run_time = "NA"
|
||||
next_run = "NA"
|
||||
if "data" in job.kwargs:
|
||||
jd = json.loads(job.kwargs["data"])
|
||||
if self.host == jd["host"]:
|
||||
@@ -226,8 +245,7 @@ class Automation(Tab):
|
||||
{
|
||||
"name": job.id.split("@")[0],
|
||||
"command": jd["command"],
|
||||
"next_run_date": next_run_date,
|
||||
"next_run_time": next_run_time,
|
||||
"next_run": next_run,
|
||||
"status": "",
|
||||
}
|
||||
)
|
||||
@@ -290,26 +308,17 @@ class Automation(Tab):
|
||||
await self._create_automation(rows[0]["name"])
|
||||
self._set_selection()
|
||||
|
||||
async def _add_prop_to_fs(
|
||||
self,
|
||||
host: str,
|
||||
prop: str,
|
||||
value: str,
|
||||
module: str = "autobackup",
|
||||
filesystems: Union[List[str], None] = None,
|
||||
) -> None:
|
||||
async def _add_prop_to_fs(self, host: str, prop: str, value: str, filesystems: Union[List[str], None] = None) -> None:
|
||||
if filesystems is not None:
|
||||
full_prop = f"{module}:{prop}"
|
||||
for fs in filesystems:
|
||||
result = await self._zfs[host].add_filesystem_prop(filesystem=fs, prop=full_prop, value=value)
|
||||
result = await self._zfs[host].add_filesystem_prop(filesystem=fs, prop=prop, value=value)
|
||||
self.add_history(result=result)
|
||||
|
||||
async def _remove_prop_from_all_fs(self, host: str, prop: str, module: str = "autobackup") -> None:
|
||||
full_prop = f"{module}:{prop}"
|
||||
filesystems_with_prop_result = await self._zfs[host].filesystems_with_prop(full_prop)
|
||||
async def _remove_prop_from_all_fs(self, host: str, prop: str) -> None:
|
||||
filesystems_with_prop_result = await self._zfs[host].filesystems_with_prop(prop)
|
||||
filesystems_with_prop = list(filesystems_with_prop_result.data)
|
||||
for fs in filesystems_with_prop:
|
||||
result = await self._zfs[host].remove_filesystem_prop(filesystem=fs, prop=full_prop)
|
||||
result = await self._zfs[host].remove_filesystem_prop(filesystem=fs, prop=prop)
|
||||
self.add_history(result=result)
|
||||
|
||||
async def _create_automation(self, name: str = "") -> None:
|
||||
@@ -403,15 +412,16 @@ class Automation(Tab):
|
||||
self.target_path.update()
|
||||
self.target_path.value = ""
|
||||
|
||||
async def target_path_selected() -> None:
|
||||
self.build_command()
|
||||
|
||||
def build_command() -> None:
|
||||
try:
|
||||
prop_suffix = self.prop.value.split(":")[1]
|
||||
except IndexError:
|
||||
prop_suffix = ""
|
||||
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.lower()}" + target_path
|
||||
base = base + f" {prop_suffix}" + target_path
|
||||
self.command.value = base
|
||||
|
||||
def all_fs_to_lists():
|
||||
@@ -458,6 +468,17 @@ class Automation(Tab):
|
||||
self.children.update()
|
||||
self.exclude.update()
|
||||
|
||||
def validate_prop(value):
|
||||
parts = value.split(":")
|
||||
for part in parts:
|
||||
if part.find(" ") != -1:
|
||||
return False
|
||||
if len(part) < 1:
|
||||
return False
|
||||
if len(parts) != 2:
|
||||
return False
|
||||
return True
|
||||
|
||||
if name == "":
|
||||
self.default_options = {
|
||||
"verbose": "",
|
||||
@@ -478,10 +499,12 @@ 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.prop = el.DInput(label="Property", value="autobackup:{name}", on_change=build_command, validation=validate_prop)
|
||||
self.app_em.append(self.prop)
|
||||
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=build_command)
|
||||
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(
|
||||
@@ -516,12 +539,15 @@ class Automation(Tab):
|
||||
col.tailwind.height("full").width("[420px]")
|
||||
options_controls()
|
||||
if name != "":
|
||||
self.prop.value = self.job_data.get("prop", "autobackup:{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
|
||||
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 +725,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 +733,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")
|
||||
@@ -735,7 +764,9 @@ class Automation(Tab):
|
||||
with el.WRow() as row:
|
||||
row.tailwind.height("[40px]")
|
||||
self.as_spinner = el.Spinner()
|
||||
self.app_em = el.ErrorAggregator()
|
||||
self.save = el.DButton("SAVE", on_click=lambda: automation_dialog.submit("save"))
|
||||
self.save.bind_enabled_from(self.app_em, "no_errors")
|
||||
el.Spinner(master=self.as_spinner)
|
||||
self.auto_name.value = name
|
||||
if name != "":
|
||||
@@ -756,11 +787,13 @@ class Automation(Tab):
|
||||
self.scheduler.scheduler.remove_job(job.id)
|
||||
for host in hosts:
|
||||
auto_id = f"{auto_name}@{host}"
|
||||
await self._remove_prop_from_all_fs(host=host, prop=auto_name)
|
||||
await self._add_prop_to_fs(host=host, prop=auto_name, value="true", filesystems=self.parentchildren.value)
|
||||
await self._add_prop_to_fs(host=host, prop=auto_name, value="parent", filesystems=self.parent.value)
|
||||
await self._add_prop_to_fs(host=host, prop=auto_name, value="child", filesystems=self.children.value)
|
||||
await self._add_prop_to_fs(host=host, prop=auto_name, value="false", filesystems=self.exclude.value)
|
||||
command = AutomationTemplate(self.prop.value)
|
||||
prop = command.safe_substitute(name=auto_name, host=host)
|
||||
await self._remove_prop_from_all_fs(host=host, prop=prop)
|
||||
await self._add_prop_to_fs(host=host, prop=prop, value="true", filesystems=self.parentchildren.value)
|
||||
await self._add_prop_to_fs(host=host, prop=prop, value="parent", filesystems=self.parent.value)
|
||||
await self._add_prop_to_fs(host=host, prop=prop, value="child", filesystems=self.children.value)
|
||||
await self._add_prop_to_fs(host=host, prop=prop, value="false", filesystems=self.exclude.value)
|
||||
self.fs["values"] = {}
|
||||
self.fs["values"]["parentchildren"] = self.parentchildren.value
|
||||
self.fs["values"]["parent"] = self.parent.value
|
||||
@@ -768,6 +801,7 @@ class Automation(Tab):
|
||||
self.fs["values"]["exclude"] = self.exclude.value
|
||||
auto = scheduler.Zfs_Autobackup(
|
||||
id=auto_id,
|
||||
name=auto_name,
|
||||
hosts=hosts,
|
||||
host=host,
|
||||
command="python -m zfs_autobackup.ZfsAutobackup" + self.command.value,
|
||||
@@ -778,6 +812,9 @@ 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,
|
||||
prop=self.prop.value,
|
||||
)
|
||||
self.scheduler.scheduler.add_job(
|
||||
automation_job,
|
||||
@@ -797,12 +834,15 @@ class Automation(Tab):
|
||||
auto_id = f"{auto_name}@{host}"
|
||||
auto = scheduler.Automation(
|
||||
id=auto_id,
|
||||
name=auto_name,
|
||||
app=self.app.value,
|
||||
hosts=hosts,
|
||||
host=host,
|
||||
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,
|
||||
@@ -817,12 +857,15 @@ class Automation(Tab):
|
||||
auto_id = f"{auto_name}@{self.host}"
|
||||
auto = scheduler.Automation(
|
||||
id=auto_id,
|
||||
name=auto_name,
|
||||
app=self.app.value,
|
||||
hosts=hosts,
|
||||
host=self.host,
|
||||
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
@@ -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": 125,
|
||||
":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
@@ -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": 125,
|
||||
":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)
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user