This commit is contained in:
Natan Keddem
2024-04-30 19:45:13 -04:00
commit 5e33556dcf
26 changed files with 2023 additions and 0 deletions

11
.dockerignore Normal file
View File

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

171
.gitignore vendored Normal file
View 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
View 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
View 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
View File

48
autopve.code-workspace Normal file
View 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
View File

91
autopve/content.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View File

@ -0,0 +1,114 @@
.jse-theme-dark {
--jse-theme: dark;
/* over all fonts, sizes, and colors */
--jse-theme-color: #E48257;
--jse-theme-color-highlight: #E48257;
--jse-background-color: #1e1e1e;
--jse-text-color: #d4d4d4;
/* main, menu, modal */
--jse-main-border: 1px solid #4f4f4f;
--jse-menu-color: #fff;
--jse-modal-background: #2f2f2f;
--jse-modal-overlay-background: rgba(0, 0, 0, 0.5);
--jse-modal-code-background: #2f2f2f;
/* tooltip in text mode */
--jse-tooltip-color: var(--jse-text-color);
--jse-tooltip-background: #4b4b4b;
--jse-tooltip-border: 1px solid #737373;
--jse-tooltip-action-button-color: inherit;
--jse-tooltip-action-button-background: #737373;
/* panels: navigation bar, gutter, search box */
--jse-panel-background: #333333;
--jse-panel-background-border: 1px solid #464646;
--jse-panel-color: var(--jse-text-color);
--jse-panel-color-readonly: #737373;
--jse-panel-border: 1px solid #3c3c3c;
--jse-panel-button-color-highlight: #e5e5e5;
--jse-panel-button-background-highlight: #464646;
/* navigation-bar */
--jse-navigation-bar-background: #656565;
--jse-navigation-bar-background-highlight: #7e7e7e;
--jse-navigation-bar-dropdown-color: var(--jse-text-color);
/* context menu */
--jse-context-menu-background: #4b4b4b;
--jse-context-menu-background-highlight: #595959;
--jse-context-menu-separator-color: #595959;
--jse-context-menu-color: var(--jse-text-color);
--jse-context-menu-pointer-background: #737373;
--jse-context-menu-pointer-background-highlight: #818181;
--jse-context-menu-pointer-color: var(--jse-context-menu-color);
/* contents: json key and values */
--jse-key-color: #9cdcfe;
--jse-value-color: var(--jse-text-color);
--jse-value-color-number: #b5cea8;
--jse-value-color-boolean: #569cd6;
--jse-value-color-null: #569cd6;
--jse-value-color-string: #ce9178;
--jse-value-color-url: #ce9178;
--jse-delimiter-color: #949494;
--jse-edit-outline: 2px solid var(--jse-text-color);
/* contents: selected or hovered */
--jse-selection-background-color: #464646;
--jse-selection-background-inactive-color: #333333;
--jse-hover-background-color: #343434;
--jse-active-line-background-color: rgba(255, 255, 255, 0.06);
--jse-search-match-background-color: #343434;
/* contents: section of collapsed items in an array */
--jse-collapsed-items-background-color: #333333;
--jse-collapsed-items-selected-background-color: #565656;
--jse-collapsed-items-link-color: #b2b2b2;
--jse-collapsed-items-link-color-highlight: #ec8477;
/* contents: highlighting of search results */
--jse-search-match-color: #724c27;
--jse-search-match-outline: 1px solid #966535;
--jse-search-match-active-color: #9f6c39;
--jse-search-match-active-outline: 1px solid #bb7f43;
/* contents: inline tags inside the JSON document */
--jse-tag-background: #444444;
--jse-tag-color: #bdbdbd;
/* contents: table */
--jse-table-header-background: #333333;
--jse-table-header-background-highlight: #424242;
--jse-table-row-odd-background: rgba(255, 255, 255, 0.1);
/* controls in modals: inputs, buttons, and `a` */
--jse-input-background: #3d3d3d;
--jse-input-border: var(--jse-main-border);
--jse-button-background: #808080;
--jse-button-background-highlight: #7a7a7a;
--jse-button-color: #e0e0e0;
--jse-button-secondary-background: #494949;
--jse-button-secondary-background-highlight: #5d5d5d;
--jse-button-secondary-background-disabled: #9d9d9d;
--jse-button-secondary-color: var(--jse-text-color);
--jse-a-color: #E48257;
--jse-a-color-highlight: #E48257;
/* svelte-select */
--background: #3d3d3d;
--border: 1px solid #4f4f4f;
--list-background: #3d3d3d;
--item-hover-bg: #505050;
--multi-item-bg: #5b5b5b;
--input-color: #d4d4d4;
--multi-clear-bg: #8a8a8a;
--multi-item-clear-icon-color: #d4d4d4;
--multi-item-outline: 1px solid #696969;
--list-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.4);
/* color picker */
--jse-color-picker-background: #656565;
--jse-color-picker-border-box-shadow: #8c8c8c 0 0 0 1px;
}