initial
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@ -0,0 +1,11 @@
|
||||
.git/
|
||||
.vscode/
|
||||
.nicegui/
|
||||
data/
|
||||
venv/
|
||||
__pycache__/
|
||||
logs/
|
||||
notes/
|
||||
mpl/
|
||||
mysecret.py
|
||||
test.py
|
||||
171
.gitignore
vendored
Normal file
171
.gitignore
vendored
Normal file
@ -0,0 +1,171 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
.git/
|
||||
.vscode/
|
||||
.nicegui/
|
||||
data/
|
||||
logs/
|
||||
notes/
|
||||
mpl/
|
||||
mysecret.py
|
||||
test.py
|
||||
mount/
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@ -0,0 +1,19 @@
|
||||
FROM python:3.12.2-slim-bookworm
|
||||
|
||||
RUN echo "**** install runtime dependencies ****"
|
||||
RUN apt update
|
||||
|
||||
ADD requirements.txt .
|
||||
RUN python -m pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
WORKDIR /app
|
||||
ADD . /app
|
||||
RUN mkdir -p /app/logs
|
||||
|
||||
RUN chmod 777 /app/resources/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 8080
|
||||
ENV PYTHONUNBUFFERED True
|
||||
|
||||
ENTRYPOINT ["/app/resources/docker-entrypoint.sh"]
|
||||
CMD ["python", "main.py"]
|
||||
42
README.md
Normal file
42
README.md
Normal file
@ -0,0 +1,42 @@
|
||||
# autopve
|
||||
|
||||
## Demo
|
||||
|
||||
COMING SOON
|
||||
|
||||
## Information
|
||||
|
||||
GUI configurable web server for Proxmox automated installation.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dynamic Answers**: Allows multiple answers to be accessable at the same address by responding only to matching system information.
|
||||
- **Answer Inheritance**: Allows defining common configurations in the "Default" answer and then only needing to specify alterations in other answers.
|
||||
- **History**: All answer requests are logged and all system and response information is displayed.
|
||||
- **Live Notifications**: Request activity displayed in realtime.
|
||||
|
||||
### Using Docker
|
||||
|
||||
1. Download `docker-compose.yml`.
|
||||
|
||||
2. Customize the `docker-compose.yml` file to suit your requirements.
|
||||
|
||||
3. Run the application using Docker Compose:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Using Proxmox LXC Container
|
||||
|
||||
1. Download `pve-install.yml` and `inv.yml`.
|
||||
|
||||
2. Ensure you have a compatible Debian template available and updated `inv.yml` accordingly.
|
||||
|
||||
3. Customize the `inv.yml` file to match your specific setup requirements.
|
||||
|
||||
4. Execute the Ansible playbook for Proxmox LXC container installation against your Proxmox host:
|
||||
|
||||
```bash
|
||||
ansible-playbook -i inv.yml pve-install.yml
|
||||
```
|
||||
0
__init__.py
Normal file
0
__init__.py
Normal file
48
autopve.code-workspace
Normal file
48
autopve.code-workspace
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"launch": {
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python: main.py",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "main.py",
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": false
|
||||
},
|
||||
{
|
||||
"name": "Python: Current File",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${file}",
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "ms-python.black-formatter"
|
||||
},
|
||||
"editor.defaultFormatter": "ms-python.black-formatter",
|
||||
"black-formatter.args": [
|
||||
"-l",
|
||||
"180"
|
||||
],
|
||||
"python.terminal.activateEnvironment": true,
|
||||
"editor.suggest.showStatusBar": true,
|
||||
"pylint.args": [
|
||||
"\"pylint.args\": [\"--disable=C0115\", \"--disable=C0116\", \"--disable=C0301\",\"--max-line-length=180\"]"
|
||||
],
|
||||
"python.terminal.activateEnvInCurrentTerminal": true,
|
||||
"terminal.integrated.enablePersistentSessions": true,
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
}
|
||||
0
autopve/__init__.py
Normal file
0
autopve/__init__.py
Normal file
91
autopve/content.py
Normal file
91
autopve/content.py
Normal file
@ -0,0 +1,91 @@
|
||||
import asyncio
|
||||
from nicegui import ui # type: ignore
|
||||
from autopve import elements as el
|
||||
import autopve.logo as logo
|
||||
from autopve.tabs import Tab
|
||||
from autopve.tabs.settings import Global, Network, Disk
|
||||
from autopve.tabs.history import History
|
||||
from autopve.tabs.system import System
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Content:
|
||||
def __init__(self) -> None:
|
||||
self._header = None
|
||||
self._tabs = None
|
||||
self._tab = {}
|
||||
self._spinner = None
|
||||
self._answer = None
|
||||
self._tab_panels = None
|
||||
self._grid = None
|
||||
self._tab_panel = {}
|
||||
self._answer = None
|
||||
self._tasks = []
|
||||
self._manage = None
|
||||
self._automation = None
|
||||
self._history = None
|
||||
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
|
||||
self._content = el.WColumn()
|
||||
self._content.bind_visibility_from(self._header)
|
||||
|
||||
def _build(self):
|
||||
with self._header:
|
||||
with ui.row().classes("w-full h-16 justify-between items-center"):
|
||||
self._tabs = ui.tabs()
|
||||
with self._tabs:
|
||||
self._tab["global"] = ui.tab(name="Global").classes("text-secondary")
|
||||
self._tab["network"] = ui.tab(name="Network").classes("text-secondary")
|
||||
self._tab["disk"] = ui.tab(name="Disk").classes("text-secondary")
|
||||
self._tab["history"] = ui.tab(name="History").classes("text-secondary")
|
||||
if self._answer != "Default":
|
||||
self._tab["system"] = ui.tab(name="System").classes("text-secondary")
|
||||
with ui.row().classes("items-center"):
|
||||
self._answer_display = ui.label(self._answer).classes("text-secondary text-h4")
|
||||
logo.show()
|
||||
with self._content:
|
||||
self._tab_panels = ui.tab_panels(self._tabs, value="Global", on_change=lambda e: self._tab_changed(e), animated=False)
|
||||
self._tab_panels.tailwind.width("full")
|
||||
|
||||
async def _tab_changed(self, e):
|
||||
if e.value == "History":
|
||||
self._history.update_history()
|
||||
|
||||
def _build_tab_panels(self):
|
||||
self._tab_panels.clear()
|
||||
with self._tab_panels:
|
||||
self._global_content = el.ContentTabPanel(self._tab["global"])
|
||||
self._network_content = el.ContentTabPanel(self._tab["network"])
|
||||
self._disk_content = el.ContentTabPanel(self._tab["disk"])
|
||||
self._history_content = el.ContentTabPanel(self._tab["history"])
|
||||
if self._answer != "Default":
|
||||
self._system_content = el.ContentTabPanel(self._tab["system"])
|
||||
with self._global_content:
|
||||
self._global = Global(answer=self._answer)
|
||||
with self._network_content:
|
||||
self._network = Network(answer=self._answer)
|
||||
with self._disk_content:
|
||||
self._disk = Disk(answer=self._answer)
|
||||
with self._history_content:
|
||||
self._history = History(answer=self._answer)
|
||||
if self._answer != "Default":
|
||||
with self._system_content:
|
||||
self._system = System(answer=self._answer)
|
||||
|
||||
async def answer_selected(self, name):
|
||||
self._answer = name
|
||||
self.hide()
|
||||
self._header.clear()
|
||||
self._content.clear()
|
||||
self._build()
|
||||
self._build_tab_panels()
|
||||
self._header.visible = True
|
||||
|
||||
def hide(self):
|
||||
if self._header is not None:
|
||||
self._header.visible = False
|
||||
if self._tab_panels is not None:
|
||||
self._tab_panels.clear()
|
||||
165
autopve/drawer.py
Normal file
165
autopve/drawer.py
Normal file
@ -0,0 +1,165 @@
|
||||
from typing import Optional
|
||||
from nicegui import ui # type: ignore
|
||||
from autopve import elements as el
|
||||
from autopve import storage
|
||||
from autopve.tabs import Tab
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Drawer(object):
|
||||
def __init__(self, main_column, on_click, hide_content) -> None:
|
||||
self._on_click = on_click
|
||||
self._hide_content = hide_content
|
||||
self._main_column = main_column
|
||||
self._header_row = None
|
||||
self._table = None
|
||||
self._name = ""
|
||||
self._answername = ""
|
||||
self._username = ""
|
||||
self._password = ""
|
||||
self._buttons = {}
|
||||
self._selection_mode = None
|
||||
|
||||
def build(self):
|
||||
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:
|
||||
with ui.row():
|
||||
el.IButton(icon="add", on_click=self._display_answer_dialog)
|
||||
self._buttons["remove"] = el.IButton(icon="remove", on_click=lambda: self._modify_answer("remove"))
|
||||
self._buttons["edit"] = el.IButton(icon="edit", on_click=lambda: self._modify_answer("edit"))
|
||||
ui.label(text="ANSWERS").classes("text-secondary")
|
||||
self._table = (
|
||||
ui.table(
|
||||
[
|
||||
{
|
||||
"name": "name",
|
||||
"label": "Name",
|
||||
"field": "name",
|
||||
"required": True,
|
||||
"align": "center",
|
||||
"sortable": True,
|
||||
}
|
||||
],
|
||||
[],
|
||||
row_key="name",
|
||||
pagination={"rowsPerPage": 0, "sortBy": "name"},
|
||||
on_select=lambda e: self._selected(e),
|
||||
)
|
||||
.on("rowClick", self._clicked, [[], ["name"], None])
|
||||
.props("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 storage.answers.keys():
|
||||
self._add_answer_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_answer_to_table(self, name):
|
||||
if len(name) > 0:
|
||||
for row in self._table.rows:
|
||||
if name == row["name"]:
|
||||
return
|
||||
self._table.add_rows({"name": name})
|
||||
self._table.visible = True
|
||||
|
||||
async def _display_answer_dialog(self, name=""):
|
||||
save = None
|
||||
|
||||
with ui.dialog() as answer_dialog, el.Card():
|
||||
with el.DBody(height="[95vh]", width="[360px]"):
|
||||
with el.WColumn():
|
||||
all_answers = list(storage.answers.keys())
|
||||
for answer in list(storage.answers.keys()):
|
||||
all_answers.append(answer.replace(" ", ""))
|
||||
if name != "":
|
||||
if name in all_answers:
|
||||
all_answers.remove(name)
|
||||
if name.replace(" ", "") in all_answers:
|
||||
all_answers.remove(name.replace(" ", ""))
|
||||
|
||||
def answer_check(value: str) -> Optional[bool]:
|
||||
spaceless = value.replace(" ", "")
|
||||
for invalid_value in all_answers:
|
||||
if invalid_value == spaceless:
|
||||
return False
|
||||
return None
|
||||
|
||||
answer_input = el.VInput(label="answer", value=" ", invalid_characters="""'`"$\\;&<>|(){}""", invalid_values=all_answers, check=answer_check, max_length=20)
|
||||
save_ea = el.ErrorAggregator(answer_input)
|
||||
el.DButton("SAVE", on_click=lambda: answer_dialog.submit("save")).bind_enabled_from(save_ea, "no_errors")
|
||||
answer_input.value = name
|
||||
|
||||
result = await answer_dialog
|
||||
if result == "save":
|
||||
answer = answer_input.value.strip()
|
||||
if len(answer) > 0 and name != "Default":
|
||||
if name in storage.answers:
|
||||
del storage.answers[name]
|
||||
for row in self._table.rows:
|
||||
if name == row["name"]:
|
||||
self._table.remove_rows(row)
|
||||
self._add_answer_to_table(answer)
|
||||
|
||||
def _modify_answer(self, mode):
|
||||
self._hide_content()
|
||||
self._selection_mode = mode
|
||||
if mode is None:
|
||||
self._table._props["selected"] = []
|
||||
self._table.props("selection=none")
|
||||
for icon, button in self._buttons.items():
|
||||
button.props(f"icon={icon}")
|
||||
elif self._buttons[mode]._props["icon"] == "close":
|
||||
self._selection_mode = None
|
||||
self._table._props["selected"] = []
|
||||
self._table.props("selection=none")
|
||||
for icon, button in self._buttons.items():
|
||||
button.props(f"icon={icon}")
|
||||
else:
|
||||
self._table.props("selection=single")
|
||||
for icon, button in self._buttons.items():
|
||||
if mode == icon:
|
||||
button.props("icon=close")
|
||||
else:
|
||||
button.props(f"icon={icon}")
|
||||
|
||||
async def _selected(self, e):
|
||||
self._hide_content()
|
||||
if self._selection_mode == "edit":
|
||||
if len(e.selection) > 0 and e.selection[0]["name"] != "Default":
|
||||
await self._display_answer_dialog(name=e.selection[0]["name"])
|
||||
if self._selection_mode == "remove":
|
||||
if len(e.selection) > 0:
|
||||
for row in e.selection:
|
||||
if row["name"] != "Default":
|
||||
if row["name"] in storage.answers:
|
||||
del storage.answers[row["name"]]
|
||||
self._table.remove_rows(row)
|
||||
self._modify_answer(None)
|
||||
|
||||
async def _clicked(self, e):
|
||||
if "name" in e.args[1]:
|
||||
answer = e.args[1]["name"]
|
||||
if self._on_click is not None:
|
||||
await self._on_click(answer)
|
||||
374
autopve/elements.py
Normal file
374
autopve/elements.py
Normal file
@ -0,0 +1,374 @@
|
||||
from typing import Any, Callable, Dict, List, Literal, Optional, Union
|
||||
from nicegui import ui, app, Tailwind
|
||||
from nicegui.elements.notification import NotificationPosition, NotificationType # type: ignore
|
||||
from nicegui.elements.spinner import SpinnerTypes # type: ignore
|
||||
from nicegui.elements.tabs import Tab # type: ignore
|
||||
from nicegui.tailwind_types.height import Height # type: ignore
|
||||
from nicegui.tailwind_types.width import Width # type: ignore
|
||||
from nicegui.elements.mixins.validation_element import ValidationElement # type: ignore
|
||||
from nicegui.events import GenericEventArguments, handle_event # type: ignore
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
orange = "#E97451"
|
||||
dark = "#0E1210"
|
||||
|
||||
|
||||
def load_element_css():
|
||||
ui.add_head_html(
|
||||
f"""
|
||||
<style>
|
||||
.autopve-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:
|
||||
def __init__(self, *elements: ValidationElement) -> None:
|
||||
self.elements: list[ValidationElement] = list(elements)
|
||||
self.enable: bool = True
|
||||
|
||||
def clear(self):
|
||||
self.elements.clear()
|
||||
|
||||
def append(self, element: ValidationElement):
|
||||
self.elements.append(element)
|
||||
|
||||
def remove(self, element: ValidationElement):
|
||||
self.elements.remove(element)
|
||||
|
||||
@property
|
||||
def no_errors(self) -> bool:
|
||||
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):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.tailwind.width("full").align_items("center")
|
||||
|
||||
|
||||
class DBody(ui.column):
|
||||
def __init__(self, height: Height = "[480px]", width: Width = "[240px]") -> None:
|
||||
super().__init__()
|
||||
self.tailwind.align_items("center").justify_content("between")
|
||||
self.tailwind.height(height).width(width)
|
||||
|
||||
|
||||
class WRow(ui.row):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.tailwind.width("full").align_items("center").justify_content("center")
|
||||
|
||||
|
||||
class Card(ui.card):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.tailwind.border_color(f"[{orange}]")
|
||||
|
||||
|
||||
class DInput(ui.input):
|
||||
def __init__(
|
||||
self,
|
||||
label: str | None = None,
|
||||
*,
|
||||
placeholder: str | None = None,
|
||||
value: str = " ",
|
||||
password: bool = False,
|
||||
password_toggle_button: bool = False,
|
||||
on_change: Callable[..., Any] | None = None,
|
||||
autocomplete: List[str] | None = None,
|
||||
validation: Callable[..., Any] = bool,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
label,
|
||||
placeholder=placeholder,
|
||||
value=value,
|
||||
password=password,
|
||||
password_toggle_button=password_toggle_button,
|
||||
on_change=on_change,
|
||||
autocomplete=autocomplete,
|
||||
validation={"": validation},
|
||||
)
|
||||
self.tailwind.width("full")
|
||||
if value == " ":
|
||||
self.value = ""
|
||||
|
||||
|
||||
class VInput(ui.input):
|
||||
def __init__(
|
||||
self,
|
||||
label: str | None = None,
|
||||
*,
|
||||
placeholder: str | None = None,
|
||||
value: str = " ",
|
||||
password: bool = False,
|
||||
password_toggle_button: bool = False,
|
||||
on_change: Callable[..., Any] | None = None,
|
||||
autocomplete: List[str] | None = None,
|
||||
invalid_characters: str = "",
|
||||
invalid_values: List[str] = [],
|
||||
max_length: int = 64,
|
||||
check: Callable[..., Any] | None = None,
|
||||
) -> None:
|
||||
def checks(value: str) -> bool:
|
||||
if value is None or value == "" or len(value) > max_length:
|
||||
return False
|
||||
for invalid_character in invalid_characters:
|
||||
if invalid_character in value:
|
||||
return False
|
||||
for invalid_value in invalid_values:
|
||||
if invalid_value == value:
|
||||
return False
|
||||
if check is not None:
|
||||
check_status = check(value)
|
||||
if check_status is not None:
|
||||
return check_status
|
||||
return True
|
||||
|
||||
super().__init__(
|
||||
label,
|
||||
placeholder=placeholder,
|
||||
value=value,
|
||||
password=password,
|
||||
password_toggle_button=password_toggle_button,
|
||||
on_change=on_change,
|
||||
autocomplete=autocomplete,
|
||||
validation={"": lambda value: checks(value)},
|
||||
)
|
||||
self.tailwind.width("full")
|
||||
if value == " ":
|
||||
self.value = ""
|
||||
|
||||
|
||||
class FInput(ui.input):
|
||||
def __init__(
|
||||
self,
|
||||
label: str | None = None,
|
||||
*,
|
||||
placeholder: str | None = None,
|
||||
value: str = " ",
|
||||
password: bool = False,
|
||||
password_toggle_button: bool = False,
|
||||
on_change: Callable[..., Any] | None = None,
|
||||
autocomplete: List[str] | None = None,
|
||||
validation: Callable[..., Any] = bool,
|
||||
read_only: bool = False,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
label,
|
||||
placeholder=placeholder,
|
||||
value=value,
|
||||
password=password,
|
||||
password_toggle_button=password_toggle_button,
|
||||
on_change=on_change,
|
||||
autocomplete=autocomplete,
|
||||
validation={} if read_only else {"": validation},
|
||||
)
|
||||
self.tailwind.width("[320px]")
|
||||
if value == " ":
|
||||
self.value = ""
|
||||
if read_only:
|
||||
self.props("readonly")
|
||||
|
||||
|
||||
class DSelect(ui.select):
|
||||
def __init__(
|
||||
self,
|
||||
options: Union[List, Dict],
|
||||
*,
|
||||
label: Optional[str] = None,
|
||||
value: Any = 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,
|
||||
new_value_mode=new_value_mode,
|
||||
multiple=multiple,
|
||||
clearable=clearable,
|
||||
)
|
||||
self.tailwind.width("full")
|
||||
if multiple is True:
|
||||
self.props("use-chips")
|
||||
|
||||
|
||||
class Select(ui.select):
|
||||
def __init__(
|
||||
self,
|
||||
options: Union[List, Dict],
|
||||
*,
|
||||
label: Optional[str] = None,
|
||||
value: Any = 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,
|
||||
new_value_mode=new_value_mode,
|
||||
multiple=multiple,
|
||||
clearable=clearable,
|
||||
)
|
||||
self.tailwind.width("1/2")
|
||||
|
||||
|
||||
class DButton(ui.button):
|
||||
def __init__(
|
||||
self,
|
||||
text: str = "",
|
||||
*,
|
||||
on_click: Callable[..., Any] | None = None,
|
||||
color: Optional[str] = "primary",
|
||||
icon: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(text, on_click=on_click, color=color, icon=icon)
|
||||
self.props("size=md")
|
||||
self.tailwind.padding("px-2.5").padding("py-1")
|
||||
|
||||
|
||||
class DCheckbox(ui.checkbox):
|
||||
def __init__(self, text: str = "", *, value: bool = False, on_change: Callable[..., Any] | None = None) -> None:
|
||||
super().__init__(text, value=value, on_change=on_change)
|
||||
self.tailwind.text_color("secondary")
|
||||
|
||||
|
||||
class IButton(ui.button):
|
||||
def __init__(
|
||||
self,
|
||||
text: str = "",
|
||||
*,
|
||||
on_click: Callable[..., Any] | None = None,
|
||||
color: Optional[str] = "primary",
|
||||
icon: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(text, on_click=on_click, color=color, icon=icon)
|
||||
self.props("size=sm")
|
||||
|
||||
|
||||
class SmButton(ui.button):
|
||||
def __init__(
|
||||
self,
|
||||
text: str = "",
|
||||
*,
|
||||
on_click: Callable[..., Any] | None = None,
|
||||
color: Optional[str] = "primary",
|
||||
icon: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(text, on_click=on_click, color=color, icon=icon)
|
||||
self.props("size=sm")
|
||||
self.tailwind.width("16")
|
||||
|
||||
|
||||
class LgButton(ui.button):
|
||||
def __init__(
|
||||
self,
|
||||
text: str = "",
|
||||
*,
|
||||
on_click: Callable[..., Any] | None = None,
|
||||
color: Optional[str] = "primary",
|
||||
icon: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(text, on_click=on_click, color=color, icon=icon)
|
||||
self.props("size=md")
|
||||
|
||||
|
||||
class Notification(ui.notification):
|
||||
def __init__(
|
||||
self,
|
||||
message: Any = "",
|
||||
*,
|
||||
position: NotificationPosition = "bottom",
|
||||
close_button: Union[bool, str] = False,
|
||||
type: NotificationType = None, # pylint: disable=redefined-builtin
|
||||
color: Optional[str] = None,
|
||||
multi_line: bool = False,
|
||||
icon: Optional[str] = None,
|
||||
spinner: bool = False,
|
||||
timeout: Optional[float] = 5.0,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
if multi_line:
|
||||
super().__init__(message, position=position, close_button=True, type=type, color=color, multi_line=True, icon=icon, spinner=spinner, timeout=60)
|
||||
else:
|
||||
super().__init__(message, position=position, type=type, spinner=spinner, timeout=timeout)
|
||||
|
||||
|
||||
class ContentTabPanel(ui.tab_panel):
|
||||
def __init__(self, name: Tab | str) -> None:
|
||||
super().__init__(name)
|
||||
self.style("height: calc(100vh - 150px)")
|
||||
self.tailwind.min_width("[920px]")
|
||||
|
||||
|
||||
class FSelect(ui.select):
|
||||
def __init__(
|
||||
self,
|
||||
options: Union[List, Dict],
|
||||
*,
|
||||
label: Optional[str] = None,
|
||||
value: Any = 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, new_value_mode=new_value_mode, multiple=multiple, clearable=clearable)
|
||||
self.tailwind.width("64")
|
||||
|
||||
|
||||
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("[640px]").width("[640px]")
|
||||
19
autopve/logo.py
Normal file
19
autopve/logo.py
Normal file
@ -0,0 +1,19 @@
|
||||
from nicegui import ui # type: ignore
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logo = """
|
||||
<svg width="50px" height="50px" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="24" cy="24" r="9" fill="#666666" stroke="#E97451" stroke-width="4"/>
|
||||
<circle r="3" transform="matrix(-1 0 0 1 24 24)" fill="white"/>
|
||||
<path d="M9 14C9 14 16.5 2.49997 29.5 6.99998C42.5 11.5 42 24.5 42 24.5" stroke="#E97451" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M39 34C39 34 33 45 19.5 41.5C6 38 6 24 6 24" stroke="#E97451" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M42 8V24" stroke="#E97451" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 24L6 40" stroke="#E97451" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
|
||||
def show():
|
||||
ui.html(logo)
|
||||
74
autopve/page.py
Normal file
74
autopve/page.py
Normal file
@ -0,0 +1,74 @@
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import tomlkit
|
||||
from fastapi import Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from nicegui import app, Client, ui # type: ignore
|
||||
from autopve import elements as el
|
||||
from autopve.drawer import Drawer
|
||||
from autopve.content import Content
|
||||
from autopve import storage
|
||||
import autopve.tabs.history as history
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build():
|
||||
@app.post("/answer")
|
||||
async def post_answer(request: Request) -> PlainTextResponse:
|
||||
def response(answer: str, system_info: Dict[str, Any], data: Dict[str, Any]):
|
||||
toml = tomlkit.dumps(default_data)
|
||||
toml_fixed = ""
|
||||
for line in toml.splitlines():
|
||||
if len(line) > 0 and line[0] == '"':
|
||||
line = line.replace('"', "", 2)
|
||||
toml_fixed = toml_fixed + line + "\n"
|
||||
r = history.Request(answer=answer, response=toml_fixed, system_info=dict(system_info))
|
||||
history.History.add_history(r)
|
||||
for client in Client.instances.values():
|
||||
if not client.has_socket_connection:
|
||||
continue
|
||||
with client:
|
||||
el.Notification(f"New answer request from {r.name} served by {r.answer}!", type="positive", timeout=30)
|
||||
return PlainTextResponse(toml_fixed)
|
||||
|
||||
system_info = await request.json()
|
||||
system_info_raw = json.dumps(system_info)
|
||||
default_data = dict(storage.answer("Default"))
|
||||
answers = list(storage.answers.keys())
|
||||
if "Default" in answers:
|
||||
answers.remove("Default")
|
||||
for answer in answers:
|
||||
answer_data = dict(storage.answer(answer))
|
||||
if "match" in answer_data:
|
||||
if len(answer_data["match"]) > 0 and answer_data["match"] in system_info_raw:
|
||||
if "global" in default_data and "global" in answer_data:
|
||||
default_data["global"].update(answer_data["global"])
|
||||
if "network" in default_data and "network" in answer_data:
|
||||
default_data["network"].update(answer_data["network"])
|
||||
if "disk-setup" in default_data and "disk-setup" in answer_data:
|
||||
default_data["disk-setup"].update(answer_data["disk-setup"])
|
||||
return response(answer, system_info, default_data)
|
||||
return response("Default", system_info, default_data)
|
||||
|
||||
@ui.page("/", response_timeout=30)
|
||||
async def index(client: Client) -> None:
|
||||
app.add_static_files("/static", "static")
|
||||
el.load_element_css()
|
||||
ui.colors(
|
||||
primary=el.orange,
|
||||
secondary=el.orange,
|
||||
accent=el.orange,
|
||||
dark=el.dark,
|
||||
positive="#21BA45",
|
||||
negative="#C10015",
|
||||
info="#5C8984",
|
||||
warning="#F2C037",
|
||||
)
|
||||
column = ui.column()
|
||||
content = Content()
|
||||
drawer = Drawer(column, content.answer_selected, content.hide)
|
||||
drawer.build()
|
||||
107
autopve/storage.py
Normal file
107
autopve/storage.py
Normal file
@ -0,0 +1,107 @@
|
||||
from typing import Any, Dict, Literal
|
||||
from nicegui import app
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
configs_version = int(102)
|
||||
configs_version_string = f"config_{configs_version}"
|
||||
root = app.storage.general.get(configs_version_string, None)
|
||||
if root is None:
|
||||
logger.warning(f"Storage version not found, updating version to {configs_version}.")
|
||||
logger.warning(f"Connections cleared, repeat setup procedure.")
|
||||
app.storage.general[configs_version_string] = {}
|
||||
answers: Dict[str, Any] = app.storage.general[configs_version_string]
|
||||
|
||||
if "Default" not in answers:
|
||||
answers["Default"] = {
|
||||
"global": {
|
||||
"keyboard": "de",
|
||||
"country": "at",
|
||||
"fqdn": "pveauto.testinstall",
|
||||
"mailto": "mail@no.invalid",
|
||||
"timezone": "Europe/Vienna",
|
||||
"root_password": "123456",
|
||||
},
|
||||
"network": {
|
||||
"source": "from-dhcp",
|
||||
},
|
||||
"disk-setup": {
|
||||
"filesystem": "zfs",
|
||||
"zfs.raid": "raid1",
|
||||
"disk_list": ["sda", "sdb"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def answer(name: str) -> dict:
|
||||
if name not in answers:
|
||||
answers[name] = {}
|
||||
return answers[name]
|
||||
|
||||
|
||||
# def algo(answer_name: str) -> dict:
|
||||
# h = answer(answer_name)
|
||||
# if "algo" not in h:
|
||||
# h["algo"] = {}
|
||||
# return h["algo"]
|
||||
|
||||
|
||||
# def algo_sensor(answer_name: str, sensor: str) -> dict:
|
||||
# a = algo(answer_name)
|
||||
# if sensor not in a:
|
||||
# a[sensor] = {}
|
||||
# if "type" not in a[sensor]:
|
||||
# a[sensor]["type"] = "curve"
|
||||
# return a[sensor]
|
||||
|
||||
|
||||
# def curve(answer_name: str, sensor: str) -> dict:
|
||||
# s = algo_sensor(answer_name, sensor)
|
||||
# if "curve" not in s:
|
||||
# s["curve"] = {}
|
||||
# return s["curve"]
|
||||
|
||||
|
||||
# def curve_speed(answer_name: str, sensor: str, default=None) -> dict:
|
||||
# c = curve(answer_name, sensor)
|
||||
# if "speed" not in c:
|
||||
# if default is None:
|
||||
# c["speed"] = {
|
||||
# "Min": None,
|
||||
# "Low": None,
|
||||
# "Medium": None,
|
||||
# "High": None,
|
||||
# "Max": None,
|
||||
# }
|
||||
# else:
|
||||
# c["speed"] = default
|
||||
# return c["speed"]
|
||||
|
||||
|
||||
# def curve_temp(answer_name: str, sensor: str, default=None) -> dict:
|
||||
# c = curve(answer_name, sensor)
|
||||
# if "temp" not in c:
|
||||
# if default is None:
|
||||
# c["temp"] = {
|
||||
# "Min": 30,
|
||||
# "Low": 40,
|
||||
# "Medium": 50,
|
||||
# "High": 60,
|
||||
# "Max": 70,
|
||||
# }
|
||||
# else:
|
||||
# c["temp"] = default
|
||||
# return c["temp"]
|
||||
|
||||
|
||||
# def pid(answer_name: str, sensor: str) -> Dict[str, float]:
|
||||
# s = algo_sensor(answer_name, sensor)
|
||||
# if "pid" not in s:
|
||||
# s["pid"] = {"Kp": 5, "Ki": 0.01, "Kd": 0.1, "Target": 40}
|
||||
# return s["pid"]
|
||||
|
||||
|
||||
# def pid_coefficient(answer_name: str, sensor: str, coefficient: Literal["Kp", "Ki", "Kd", "Target"]) -> float:
|
||||
# return pid(answer_name, sensor)[coefficient]
|
||||
104
autopve/tabs/__init__.py
Normal file
104
autopve/tabs/__init__.py
Normal file
@ -0,0 +1,104 @@
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from dataclasses import dataclass, field
|
||||
from nicegui import app, ui # type: ignore
|
||||
import autopve.elements as el
|
||||
from autopve import storage
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Tab:
|
||||
def __init__(self, answer: Optional[str] = None, table: Optional[str] = None) -> None:
|
||||
self.answer: Optional[str] = answer
|
||||
self.table: Optional[str] = table
|
||||
self.picked_keys: Dict["str", Any] = {}
|
||||
self._build()
|
||||
|
||||
def _build(self):
|
||||
pass
|
||||
|
||||
def key_picker(self, keys: Dict[str, Any]):
|
||||
def keys_controls():
|
||||
with ui.column() as col:
|
||||
col.tailwind.width("[560px]").align_items("center")
|
||||
with ui.card() as card:
|
||||
card.tailwind.width("full")
|
||||
key_select = ui.select(list(keys.keys()), label="key", new_value_mode="add-unique", with_input=True)
|
||||
key_select.tailwind.width("full")
|
||||
with ui.row() as row:
|
||||
row.tailwind.width("full").align_items("center").justify_content("between")
|
||||
with ui.row() as row:
|
||||
row.tailwind.align_items("center")
|
||||
self.current_help = None
|
||||
self.current_key = el.FInput(label="key", on_change=lambda e: key_changed(e), read_only=True)
|
||||
self.current_key.bind_value_from(key_select)
|
||||
with ui.button(icon="help"):
|
||||
self.current_help = ui.tooltip("NA")
|
||||
ui.button(icon="add", on_click=lambda: add_key(self.current_key.value))
|
||||
ui.separator()
|
||||
self.keys_scroll = ui.scroll_area()
|
||||
self.keys_scroll.tailwind.width("full").height("[480px]")
|
||||
self.key_controls = {}
|
||||
items = storage.answer(self.answer)
|
||||
if self.table is not None and self.table in items:
|
||||
for key, value in items[self.table].items():
|
||||
if isinstance(value, list):
|
||||
add_key(key, "[" + ",".join(str(v) for v in value) + "]")
|
||||
else:
|
||||
add_key(key, str(value))
|
||||
|
||||
def add_key(key, value=""):
|
||||
if key is not None and key != "" and key not in self.picked_keys:
|
||||
with self.keys_scroll:
|
||||
with ui.row() as key_row:
|
||||
key_row.tailwind.width("full").align_items("center").justify_content("between")
|
||||
with ui.row() as row:
|
||||
row.tailwind.align_items("center")
|
||||
self.picked_keys[key] = value
|
||||
self.key_controls[key] = {
|
||||
"control": el.FInput(
|
||||
key,
|
||||
password=True if key == "root_password" else False,
|
||||
autocomplete=keys[key]["options"] if "options" in keys[key] else None,
|
||||
on_change=lambda e, key=key: set_key(key, e.value),
|
||||
),
|
||||
"row": key_row,
|
||||
}
|
||||
self.key_controls[key]["control"].value = value
|
||||
if key in keys:
|
||||
with ui.button(icon="help"):
|
||||
ui.tooltip(keys[key]["description"])
|
||||
ui.button(icon="remove", on_click=lambda _, key=key: remove_key(key))
|
||||
|
||||
def remove_key(key):
|
||||
self.keys_scroll.remove(self.key_controls[key]["row"])
|
||||
del self.picked_keys[key]
|
||||
del self.key_controls[key]
|
||||
|
||||
def set_key(key, value: str):
|
||||
if len(value) > 0:
|
||||
if "type" in keys[key]:
|
||||
if keys[key]["type"] == "list":
|
||||
self.picked_keys[key] = value[1:-1].split(",")
|
||||
elif keys[key]["type"] == "int":
|
||||
self.picked_keys[key] = int(value)
|
||||
else:
|
||||
self.picked_keys[key] = value
|
||||
else:
|
||||
if len(value) > 2 and value.strip()[0] == "[" and value.strip()[-1] == "]":
|
||||
self.picked_keys[key] = value[1:-1].split(",")
|
||||
elif value.isnumeric():
|
||||
self.picked_keys[key] = int(value)
|
||||
else:
|
||||
self.picked_keys[key] = value
|
||||
storage.answer(self.answer)[self.table] = self.picked_keys
|
||||
|
||||
def key_changed(e):
|
||||
if self.current_help is not None:
|
||||
if e.value in keys:
|
||||
self.current_help.text = keys[e.value]["description"]
|
||||
else:
|
||||
self.current_help.text = "NA"
|
||||
|
||||
keys_controls()
|
||||
180
autopve/tabs/history.py
Normal file
180
autopve/tabs/history.py
Normal file
@ -0,0 +1,180 @@
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from dataclasses import dataclass, field
|
||||
import time
|
||||
from nicegui import app, ui # type: ignore
|
||||
from . import Tab
|
||||
import autopve.elements as el
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class Request:
|
||||
answer: str
|
||||
response: str
|
||||
system_info: Dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
if "dmi" in self.system_info:
|
||||
if "system" in self.system_info["dmi"]:
|
||||
if "name" in self.system_info["dmi"]["system"]:
|
||||
return self.system_info["dmi"]["system"]["name"]
|
||||
return ""
|
||||
|
||||
|
||||
class SelectionConfirm:
|
||||
def __init__(self, container, label) -> None:
|
||||
self._container = container
|
||||
self._label = label
|
||||
self._visible = None
|
||||
self._request = None
|
||||
self._submitted = None
|
||||
with self._container:
|
||||
self._label = ui.label(self._label).tailwind().text_color("primary")
|
||||
self._done = el.IButton(icon="done", on_click=lambda: self.submit("confirm"))
|
||||
self._cancel = el.IButton(icon="close", on_click=lambda: self.submit("cancel"))
|
||||
|
||||
@property
|
||||
def submitted(self) -> asyncio.Event:
|
||||
if self._submitted is None:
|
||||
self._submitted = asyncio.Event()
|
||||
return self._submitted
|
||||
|
||||
def open(self) -> None:
|
||||
self._container.visible = True
|
||||
|
||||
def close(self) -> None:
|
||||
self._container.visible = False
|
||||
self._container.clear()
|
||||
|
||||
def __await__(self):
|
||||
self._request = None
|
||||
self.submitted.clear()
|
||||
self.open()
|
||||
yield from self.submitted.wait().__await__() # pylint: disable=no-member
|
||||
request = self._request
|
||||
self.close()
|
||||
return request
|
||||
|
||||
def submit(self, request) -> None:
|
||||
self._request = request
|
||||
self.submitted.set()
|
||||
|
||||
|
||||
class History(Tab):
|
||||
_history: List[Dict[str, Any]] = []
|
||||
|
||||
def _build(self):
|
||||
async def display_request(e):
|
||||
if e.args["data"]["system_info"] is not None and e.args["data"]["response"] is not None:
|
||||
with ui.dialog() as dialog, el.Card():
|
||||
with el.DBody(height="fit", width="fit"):
|
||||
with el.WColumn():
|
||||
with ui.tabs().classes("w-full") as tabs:
|
||||
system_info_tab = ui.tab("System Info")
|
||||
response_tab = ui.tab("Response")
|
||||
with ui.tab_panels(tabs, value=system_info_tab):
|
||||
with ui.tab_panel(system_info_tab):
|
||||
system_info = e.args["data"]["system_info"]
|
||||
properties = {"content": {"json": system_info}, "readOnly": True}
|
||||
el.JsonEditor(properties=properties)
|
||||
with ui.tab_panel(response_tab):
|
||||
response = e.args["data"]["response"]
|
||||
ui.code(response).tailwind.height("[640px]").width("[640px]")
|
||||
|
||||
with el.WRow() as row:
|
||||
row.tailwind.height("[40px]")
|
||||
el.DButton("Exit", on_click=lambda: dialog.submit("exit"))
|
||||
await dialog
|
||||
|
||||
with el.WColumn() as col:
|
||||
col.tailwind.height("full")
|
||||
self._confirm = el.WRow()
|
||||
self._confirm.visible = False
|
||||
with el.WRow().classes("justify-between").bind_visibility_from(self._confirm, "visible", value=False):
|
||||
with ui.row().classes("items-center"):
|
||||
el.SmButton(text="Remove", on_click=self._remove_history)
|
||||
with ui.row().classes("items-center"):
|
||||
el.SmButton(text="Refresh", on_click=lambda _: self._grid.update())
|
||||
self._grid = ui.aggrid(
|
||||
{
|
||||
"suppressRowClickSelection": True,
|
||||
"rowSelection": "multiple",
|
||||
"paginationAutoPageSize": True,
|
||||
"pagination": True,
|
||||
"defaultColDef": {
|
||||
"resizable": True,
|
||||
"sortable": True,
|
||||
"suppressMovable": True,
|
||||
"sortingOrder": ["asc", "desc"],
|
||||
},
|
||||
"columnDefs": [
|
||||
{
|
||||
"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;
|
||||
}""",
|
||||
"sort": "desc",
|
||||
},
|
||||
{
|
||||
"headerName": "Name",
|
||||
"field": "name",
|
||||
"filter": "agTextColumnFilter",
|
||||
"flex": 1,
|
||||
},
|
||||
{
|
||||
"headerName": "Answer",
|
||||
"field": "answer",
|
||||
"filter": "agTextColumnFilter",
|
||||
"maxWidth": 200,
|
||||
},
|
||||
],
|
||||
"rowData": self._history,
|
||||
},
|
||||
theme="balham-dark",
|
||||
)
|
||||
self._grid.tailwind().width("full").height("5/6")
|
||||
self._grid.on("cellClicked", lambda e: display_request(e))
|
||||
|
||||
def _set_selection(self, mode=None):
|
||||
row_selection = "single"
|
||||
self._grid.options["columnDefs"][0]["headerCheckboxSelection"] = False
|
||||
self._grid.options["columnDefs"][0]["headerCheckboxSelectionFilteredOnly"] = True
|
||||
self._grid.options["columnDefs"][0]["checkboxSelection"] = False
|
||||
if mode is None:
|
||||
pass
|
||||
elif mode == "single":
|
||||
self._grid.options["columnDefs"][0]["checkboxSelection"] = True
|
||||
elif mode == "multiple":
|
||||
row_selection = "multiple"
|
||||
self._grid.options["columnDefs"][0]["headerCheckboxSelection"] = True
|
||||
self._grid.options["columnDefs"][0]["checkboxSelection"] = True
|
||||
self._grid.options["rowSelection"] = row_selection
|
||||
self._grid.update()
|
||||
|
||||
def update_history(self):
|
||||
self._grid.update()
|
||||
|
||||
@classmethod
|
||||
def add_history(cls, request: Request) -> None:
|
||||
if len(cls._history) > 1000:
|
||||
cls._history.pop(0)
|
||||
cls._history.append({"timestamp": request.timestamp, "name": request.name, "answer": request.answer, "response": request.response, "system_info": request.system_info})
|
||||
|
||||
async def _remove_history(self):
|
||||
self._set_selection(mode="multiple")
|
||||
request = await SelectionConfirm(container=self._confirm, label=">REMOVE<")
|
||||
if request == "confirm":
|
||||
rows = await self._grid.get_selected_rows()
|
||||
for row in rows:
|
||||
self._history.remove(row)
|
||||
self._grid.update()
|
||||
self._set_selection()
|
||||
80
autopve/tabs/settings.py
Normal file
80
autopve/tabs/settings.py
Normal file
@ -0,0 +1,80 @@
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from dataclasses import dataclass, field
|
||||
from nicegui import app, ui # type: ignore
|
||||
from . import Tab
|
||||
import autopve.elements as el
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Global(Tab):
|
||||
def __init__(self, answer: Optional[str] = None) -> None:
|
||||
self.keys = {
|
||||
"keyboard": {"description": "The keyboard layout with the following possible options"},
|
||||
"country": {"description": "The country code in the two letter variant. For example, at, us or fr."},
|
||||
"fqdn": {"description": "The fully qualified domain name of the host. The domain part will be used as the search domain."},
|
||||
"mailto": {"description": "The default email address for the user root."},
|
||||
"timezone": {"description": "The timezone in tzdata format. For example, Europe/Vienna or America/New_York."},
|
||||
"root_password": {"description": "The password for the root user.", "type": "str"},
|
||||
"root_ssh_keys": {"description": "Optional. SSH public keys to add to the root users authorized_keys file after the installation."},
|
||||
"reboot_on_error": {
|
||||
"description": "If set to true, the installer will reboot automatically when an error is encountered. The default behavior is to wait to give the administrator a chance to investigate why the installation failed."
|
||||
},
|
||||
}
|
||||
super().__init__(answer=answer, table="global")
|
||||
|
||||
def _build(self):
|
||||
self.key_picker(keys=self.keys)
|
||||
|
||||
|
||||
class Network(Tab):
|
||||
def __init__(self, answer: Optional[str] = None) -> None:
|
||||
self.keys = {
|
||||
"source": {"description": "Where to source the static network configuration from. This can be from-dhcp or from-answer."},
|
||||
"cidr": {"description": "The IP address in CIDR notation. For example, 192.168.1.10/24."},
|
||||
"dns": {"description": "The IP address of the DNS server."},
|
||||
"gateway": {"description": "The IP address of the default gateway."},
|
||||
"filter": {"description": "Filter against the UDEV properties to select the network card. See filters."},
|
||||
}
|
||||
super().__init__(answer=answer, table="network")
|
||||
|
||||
def _build(self):
|
||||
self.key_picker(keys=self.keys)
|
||||
|
||||
|
||||
class Disk(Tab):
|
||||
def __init__(self, answer: Optional[str] = None) -> None:
|
||||
self.keys = {
|
||||
"filesystem": {"description": "One of the following options: ext4, xfs, zfs, or btrfs.", "options": ["ext4", "xfs", "zfs", "btrfs"]},
|
||||
"disk_list": {"description": 'List of disks to use. Useful if you are sure about the disk names. For example: disk_list = ["sda", "sdb"].'},
|
||||
"filter": {"description": "Filter against UDEV properties to select the disks for the installation. See filters."},
|
||||
"filter_match": {
|
||||
"description": 'Can be "any" or "all". Decides if a match of any filter is enough of if all filters need to match for a disk to be selected. Default is "any".',
|
||||
"options": ["any", "all"],
|
||||
},
|
||||
"zfs.raid": {
|
||||
"description": "The RAID level that should be used. Options are raid0, raid1, raid10, raidz-1, raidz-2, or raidz-3.",
|
||||
"options": ["raid0", "raid1", "raid10", "raidz-1", "raidz-2", "raidz-3"],
|
||||
},
|
||||
"zfs.ashift": {"description": ""},
|
||||
"zfs.arc_max": {"description": ""},
|
||||
"zfs.checksum": {"description": ""},
|
||||
"zfs.compress": {"description": ""},
|
||||
"zfs.copies": {"description": ""},
|
||||
"zfs.hdsize": {"description": ""},
|
||||
"lvm.hdsize": {"description": ""},
|
||||
"lvm.swapsize": {"description": ""},
|
||||
"lvm.maxroot": {"description": ""},
|
||||
"lvm.maxvz": {"description": ""},
|
||||
"lvm.minfree": {"description": ""},
|
||||
"btrfs.raid": {
|
||||
"description": "",
|
||||
"options": ["raid0", "raid1", "raid10"],
|
||||
},
|
||||
"btrfs.hdsize": {"description": ""},
|
||||
}
|
||||
super().__init__(answer=answer, table="disk-setup")
|
||||
|
||||
def _build(self):
|
||||
self.key_picker(keys=self.keys)
|
||||
21
autopve/tabs/system.py
Normal file
21
autopve/tabs/system.py
Normal file
@ -0,0 +1,21 @@
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from dataclasses import dataclass, field
|
||||
from nicegui import app, ui # type: ignore
|
||||
from . import Tab
|
||||
import autopve.elements as el
|
||||
from autopve import storage
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class System(Tab):
|
||||
def __init__(self, answer=None) -> None:
|
||||
super().__init__(answer)
|
||||
|
||||
def _build(self):
|
||||
def set_match(match: str):
|
||||
storage.answer(self.answer)["match"] = match
|
||||
|
||||
answer = storage.answer(self.answer)
|
||||
el.FInput("Match String", value=answer["match"] if "match" in answer else "", on_change=lambda e: set_match(e.value))
|
||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@ -0,0 +1,14 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
autopve:
|
||||
image: autopve:latest
|
||||
ports:
|
||||
- 8080:8080
|
||||
# volumes:
|
||||
# - ~/path/to/data:/app/data
|
||||
# - ~/path/to/logs:/app/logs
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- VERBOSE_LOGGING=TRUE # Optional: Will enable additional logging. Warning logs may contain passwords in plaintext. Sanitize before sharing.
|
||||
20
inv.yml
Normal file
20
inv.yml
Normal file
@ -0,0 +1,20 @@
|
||||
all:
|
||||
hosts:
|
||||
proxmox_host:
|
||||
ansible_host: ##PROXMOXHOST##
|
||||
lxc_hostname:
|
||||
ansible_host: autopve
|
||||
vars:
|
||||
ansible_ssh_common_args: "-o StrictHostKeyChecking=no"
|
||||
ansible_user: root
|
||||
proxmox_api_password: ##PASSWORD##
|
||||
proxmox_api_user: root@pam
|
||||
proxmox_node: ##PROXMOXNODE##
|
||||
template_storage: local
|
||||
lxc_template: debian-12-standard_12.2-1_amd64.tar.zst
|
||||
lxc_hostname: autopve
|
||||
lxc_id: 200
|
||||
lxc_password: ##PASSWORD##
|
||||
lxc_storage: local
|
||||
lxc_network: vmbr0
|
||||
app_name: autopve
|
||||
37
main.py
Normal file
37
main.py
Normal file
@ -0,0 +1,37 @@
|
||||
import mylogging
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
import os
|
||||
|
||||
if not os.path.exists("data"):
|
||||
logger.warning("Could not find 'data' directory, verify bind mounts.")
|
||||
if os.path.exists(".nicegui"):
|
||||
logger.warning("Creating 'data' directory symlink.")
|
||||
os.symlink(".nicegui", "data", target_is_directory=True)
|
||||
else:
|
||||
logger.warning("Creating 'data' directory, settings will not be persistent.")
|
||||
os.makedirs("data")
|
||||
else:
|
||||
logger.warning("Found 'data' directory.")
|
||||
os.environ.setdefault("NICEGUI_STORAGE_PATH", "data")
|
||||
|
||||
|
||||
if __name__ in {"__main__", "__mp_main__"}:
|
||||
from nicegui import app, ui # type: ignore
|
||||
|
||||
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 autopve import page
|
||||
from autopve import logo
|
||||
|
||||
app.on_startup(lambda: print(f"Starting autopve, bound to the following addresses {', '.join(app.urls)}.", flush=True))
|
||||
page.build()
|
||||
ui.run(title="autopve", favicon=logo.logo, dark=True, reload=False, show=False, show_welcome_message=False)
|
||||
115
mylogging.py
Normal file
115
mylogging.py
Normal file
@ -0,0 +1,115 @@
|
||||
import os
|
||||
from logging.config import dictConfig
|
||||
|
||||
lastinfo_path = "logs/lastinfo.log"
|
||||
lastdebug_path = "logs/lastdebug.log"
|
||||
info_path = "logs/info.log"
|
||||
warn_path = "logs/warning.log"
|
||||
|
||||
if not os.path.exists("logs"):
|
||||
os.makedirs("logs")
|
||||
try:
|
||||
os.remove(lastinfo_path)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.remove(lastdebug_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def is_docker():
|
||||
path = "/proc/self/cgroup"
|
||||
status = os.path.exists("/.dockerenv") or os.path.isfile(path) and any("docker" in line for line in open(path))
|
||||
return status
|
||||
|
||||
|
||||
if is_docker() is False or os.environ.get("VERBOSE_LOGGING", "FALSE") == "TRUE":
|
||||
logging_mode = "Verbose "
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": True,
|
||||
"loggers": {
|
||||
"": {
|
||||
"level": "DEBUG",
|
||||
"handlers": ["console", "all_warning", "all_info", "last_info", "last_debug"],
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": "WARNING",
|
||||
"formatter": "fmt",
|
||||
"class": "logging.StreamHandler",
|
||||
"stream": "ext://sys.stdout",
|
||||
},
|
||||
"all_warning": {
|
||||
"level": "WARNING",
|
||||
"formatter": "fmt",
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": warn_path,
|
||||
"maxBytes": 1048576,
|
||||
"backupCount": 10,
|
||||
},
|
||||
"all_info": {
|
||||
"level": "INFO",
|
||||
"formatter": "fmt",
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": info_path,
|
||||
"maxBytes": 1048576,
|
||||
"backupCount": 10,
|
||||
},
|
||||
"last_info": {
|
||||
"level": "INFO",
|
||||
"formatter": "fmt",
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": lastinfo_path,
|
||||
"maxBytes": 1048576,
|
||||
},
|
||||
"last_debug": {
|
||||
"level": "DEBUG",
|
||||
"formatter": "fmt",
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": lastdebug_path,
|
||||
"maxBytes": 1048576,
|
||||
},
|
||||
},
|
||||
"formatters": {
|
||||
"fmt": {"format": "%(asctime)s-%(levelname)s-%(name)s-%(process)d::%(module)s|%(lineno)s:: %(message)s"},
|
||||
},
|
||||
}
|
||||
else:
|
||||
logging_mode = ""
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": True,
|
||||
"loggers": {
|
||||
"": {
|
||||
"level": "DEBUG",
|
||||
"handlers": ["console", "all_warning"],
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": "WARNING",
|
||||
"formatter": "fmt",
|
||||
"class": "logging.StreamHandler",
|
||||
"stream": "ext://sys.stdout",
|
||||
},
|
||||
"all_warning": {
|
||||
"level": "WARNING",
|
||||
"formatter": "fmt",
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": warn_path,
|
||||
"maxBytes": 1048576,
|
||||
"backupCount": 10,
|
||||
},
|
||||
},
|
||||
"formatters": {
|
||||
"fmt": {"format": "%(asctime)s-%(levelname)s-%(name)s-%(process)d::%(module)s|%(lineno)s:: %(message)s"},
|
||||
},
|
||||
}
|
||||
dictConfig(LOGGING_CONFIG)
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"***{logging_mode}Logging Started***")
|
||||
106
pve-install.yml
Normal file
106
pve-install.yml
Normal file
@ -0,0 +1,106 @@
|
||||
# ansible-playbook -i inv.yml pve-install.yml
|
||||
---
|
||||
- name: Build & Start Container
|
||||
hosts: proxmox_host
|
||||
gather_facts: true
|
||||
tasks:
|
||||
- name: Install packages required by proxmox_kvm module...
|
||||
ansible.builtin.apt:
|
||||
pkg:
|
||||
- python3-proxmoxer
|
||||
- python3-requests
|
||||
- xz-utils
|
||||
become: true
|
||||
- name: Create container...
|
||||
community.general.proxmox:
|
||||
api_host: "{{ ansible_host }}"
|
||||
api_user: "{{ proxmox_api_user }}"
|
||||
api_password: "{{ proxmox_api_password }}"
|
||||
node: "{{ proxmox_node }}"
|
||||
hostname: "{{ lxc_hostname }}"
|
||||
vmid: "{{ lxc_id }}"
|
||||
cores: 2
|
||||
disk: 8
|
||||
memory: 2048
|
||||
password: "{{ lxc_password }}"
|
||||
unprivileged: true
|
||||
pubkey: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
|
||||
storage: "{{ lxc_storage }}"
|
||||
ostemplate: "{{ template_storage }}:vztmpl/{{ lxc_template }}"
|
||||
netif: '{"net0":"name=eth0,ip=dhcp,bridge={{ lxc_network }}"}'
|
||||
features:
|
||||
- nesting=1
|
||||
state: present
|
||||
- name: Wait for container to build...
|
||||
ansible.builtin.wait_for:
|
||||
timeout: 10
|
||||
delegate_to: localhost
|
||||
- name: Start the container...
|
||||
community.general.proxmox:
|
||||
api_host: "{{ ansible_host }}"
|
||||
api_user: "{{ proxmox_api_user }}"
|
||||
api_password: "{{ proxmox_api_password }}"
|
||||
node: "{{ proxmox_node }}"
|
||||
hostname: "{{ lxc_hostname }}"
|
||||
state: started
|
||||
unprivileged: no
|
||||
- name: Wait for the container to start...
|
||||
ansible.builtin.wait_for:
|
||||
host: "{{ lxc_hostname }}"
|
||||
port: 22
|
||||
sleep: 3
|
||||
connect_timeout: 5
|
||||
timeout: 60
|
||||
- name: Install App
|
||||
hosts: lxc_hostname
|
||||
gather_facts: true
|
||||
tasks:
|
||||
- name: Package update cache...
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
- name: "Install apt packages required by {{ app_name }}..."
|
||||
ansible.builtin.apt:
|
||||
pkg:
|
||||
- git
|
||||
- python3-pip
|
||||
- python3-venv
|
||||
- name: "Install pip packages required by {{ app_name }}..."
|
||||
ansible.builtin.pip:
|
||||
extra_args: --break-system-packages
|
||||
name:
|
||||
- github3.py
|
||||
- name: Get latest release of a public repository
|
||||
community.general.github_release:
|
||||
user: natankeddem
|
||||
repo: "{{ app_name }}"
|
||||
action: latest_release
|
||||
register: repo
|
||||
- name: Clone repo...
|
||||
ansible.builtin.git:
|
||||
repo: "https://github.com/natankeddem/{{ app_name }}.git"
|
||||
dest: /root/{{ app_name }}
|
||||
version: "{{ repo.tag }}"
|
||||
- name: "Install pip packages required by {{ app_name }}..."
|
||||
ansible.builtin.pip:
|
||||
virtualenv_command: python3 -m venv
|
||||
virtualenv: "/root/{{ app_name }}/venv"
|
||||
requirements: "/root/{{ app_name }}/requirements.txt"
|
||||
state: present
|
||||
- name: "Install {{ app_name }} serivce."
|
||||
ansible.builtin.copy:
|
||||
src: "/root/{{ app_name }}/resources/{{ app_name }}.service"
|
||||
dest: "/etc/systemd/system/{{ app_name }}.service"
|
||||
remote_src: yes
|
||||
owner: root
|
||||
mode: "0755"
|
||||
force: true
|
||||
- name: Reload service daemon...
|
||||
become: true
|
||||
systemd:
|
||||
daemon_reload: true
|
||||
- name: "Start {{ app_name }}..."
|
||||
become: true
|
||||
systemd:
|
||||
name: "{{ app_name }}"
|
||||
state: started
|
||||
enabled: true
|
||||
57
requirements.txt
Normal file
57
requirements.txt
Normal file
@ -0,0 +1,57 @@
|
||||
aiofiles==23.2.1
|
||||
aiohttp==3.9.3
|
||||
aiosignal==1.3.1
|
||||
annotated-types==0.6.0
|
||||
anyio==4.2.0
|
||||
attrs==23.2.0
|
||||
bidict==0.22.1
|
||||
black==24.2.0
|
||||
certifi==2024.2.2
|
||||
charset-normalizer==3.3.2
|
||||
click==8.1.7
|
||||
fastapi==0.109.2
|
||||
frozenlist==1.4.1
|
||||
h11==0.14.0
|
||||
httpcore==1.0.2
|
||||
httptools==0.6.1
|
||||
httpx==0.26.0
|
||||
idna==3.6
|
||||
ifaddr==0.2.0
|
||||
itsdangerous==2.1.2
|
||||
Jinja2==3.1.3
|
||||
markdown2==2.4.12
|
||||
MarkupSafe==2.1.5
|
||||
multidict==6.0.5
|
||||
mypy-extensions==1.0.0
|
||||
nicegui==1.4.15
|
||||
nicegui-highcharts==1.0.1
|
||||
numpy==1.26.4
|
||||
orjson==3.9.13
|
||||
packaging==23.2
|
||||
pathspec==0.12.1
|
||||
platformdirs==4.2.0
|
||||
pscript==0.7.7
|
||||
pydantic==2.6.1
|
||||
pydantic_core==2.16.2
|
||||
Pygments==2.17.2
|
||||
python-dotenv==1.0.1
|
||||
python-engineio==4.9.0
|
||||
python-multipart==0.0.9
|
||||
python-socketio==5.11.1
|
||||
PyYAML==6.0.1
|
||||
requests==2.31.0
|
||||
scipy==1.12.0
|
||||
simple-websocket==1.0.0
|
||||
sniffio==1.3.0
|
||||
starlette==0.36.3
|
||||
tomlkit==0.12.4
|
||||
types-requests==2.31.0.20240125
|
||||
typing_extensions==4.9.0
|
||||
urllib3==2.2.0
|
||||
uvicorn==0.27.1
|
||||
uvloop==0.19.0
|
||||
vbuild==0.8.2
|
||||
watchfiles==0.21.0
|
||||
websockets==12.0
|
||||
wsproto==1.2.0
|
||||
yarl==1.9.4
|
||||
11
resources/autopve.service
Normal file
11
resources/autopve.service
Normal file
@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=autopve Application Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/root/autopve
|
||||
ExecStart=/root/autopve/venv/bin/python /root/autopve/main.py
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
43
resources/docker-entrypoint.sh
Normal file
43
resources/docker-entrypoint.sh
Normal file
@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
set -x
|
||||
# Get the PUID and PGID from environment variables (or use default values 1000 if not set)
|
||||
PUID=${PUID:-1000}
|
||||
PGID=${PGID:-1000}
|
||||
|
||||
# Check if the provided PUID and PGID are non-empty, numeric values; otherwise, assign default values.
|
||||
if ! [[ "$PUID" =~ ^[0-9]+$ ]]; then
|
||||
PUID=1000
|
||||
fi
|
||||
if ! [[ "$PGID" =~ ^[0-9]+$ ]]; then
|
||||
PGID=1000
|
||||
fi
|
||||
# Check if the specified group with PGID exists, if not, create it.
|
||||
if ! getent group "$PGID" >/dev/null; then
|
||||
groupadd -g "$PGID" appgroup
|
||||
fi
|
||||
# Create user.
|
||||
useradd --create-home --shell /bin/bash --uid "$PUID" --gid "$PGID" appuser
|
||||
# Make matplotlib cache folder.
|
||||
mkdir -p /app/mpl
|
||||
# Make user the owner of the app directory.
|
||||
chown -R appuser:appgroup /app
|
||||
# Copy the default .bashrc file to the appuser home directory.
|
||||
mkdir -p /home/appuser/.ssh
|
||||
chown appuser:appgroup /home/appuser/.ssh
|
||||
cp /etc/skel/.bashrc /home/appuser/.bashrc
|
||||
chown appuser:appgroup /home/appuser/.bashrc
|
||||
export HOME=/home/appuser
|
||||
# Set permissions on font directories.
|
||||
if [ -d "/usr/share/fonts" ]; then
|
||||
chmod -R 777 /usr/share/fonts
|
||||
fi
|
||||
if [ -d "/var/cache/fontconfig" ]; then
|
||||
chmod -R 777 /var/cache/fontconfig
|
||||
fi
|
||||
if [ -d "/usr/local/share/fonts" ]; then
|
||||
chmod -R 777 /usr/local/share/fonts
|
||||
fi
|
||||
|
||||
# Switch to appuser and execute the Docker CMD or passed in command-line arguments.
|
||||
# Using setpriv let's it run as PID 1 which is required for proper signal handling (similar to gosu/su-exec).
|
||||
exec setpriv --reuid=$PUID --regid=$PGID --init-groups $@
|
||||
114
static/jse-theme-dark.css
Normal file
114
static/jse-theme-dark.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user