commit 5e33556dcfb9f0c29aa979db16514b815c81c5d6 Author: Natan Keddem Date: Tue Apr 30 19:45:13 2024 -0400 initial diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ec17670 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git/ +.vscode/ +.nicegui/ +data/ +venv/ +__pycache__/ +logs/ +notes/ +mpl/ +mysecret.py +test.py \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2cde77 --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5c4b009 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2dff976 --- /dev/null +++ b/README.md @@ -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 + ``` \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/autopve.code-workspace b/autopve.code-workspace new file mode 100644 index 0000000..c04ae83 --- /dev/null +++ b/autopve.code-workspace @@ -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" + } + } +} \ No newline at end of file diff --git a/autopve/__init__.py b/autopve/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/autopve/content.py b/autopve/content.py new file mode 100644 index 0000000..5e78148 --- /dev/null +++ b/autopve/content.py @@ -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() diff --git a/autopve/drawer.py b/autopve/drawer.py new file mode 100644 index 0000000..71cb038 --- /dev/null +++ b/autopve/drawer.py @@ -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) diff --git a/autopve/elements.py b/autopve/elements.py new file mode 100644 index 0000000..f89f5ae --- /dev/null +++ b/autopve/elements.py @@ -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""" + + """ + ) + ui.add_head_html('') + + +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]") diff --git a/autopve/logo.py b/autopve/logo.py new file mode 100644 index 0000000..1d55cce --- /dev/null +++ b/autopve/logo.py @@ -0,0 +1,19 @@ +from nicegui import ui # type: ignore +import logging + +logger = logging.getLogger(__name__) + +logo = """ + + + + + + + + +""" + + +def show(): + ui.html(logo) diff --git a/autopve/page.py b/autopve/page.py new file mode 100644 index 0000000..4af0eb5 --- /dev/null +++ b/autopve/page.py @@ -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() diff --git a/autopve/storage.py b/autopve/storage.py new file mode 100644 index 0000000..37f37fc --- /dev/null +++ b/autopve/storage.py @@ -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] diff --git a/autopve/tabs/__init__.py b/autopve/tabs/__init__.py new file mode 100644 index 0000000..eff39f6 --- /dev/null +++ b/autopve/tabs/__init__.py @@ -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() diff --git a/autopve/tabs/history.py b/autopve/tabs/history.py new file mode 100644 index 0000000..845df6e --- /dev/null +++ b/autopve/tabs/history.py @@ -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() diff --git a/autopve/tabs/settings.py b/autopve/tabs/settings.py new file mode 100644 index 0000000..aeccb5a --- /dev/null +++ b/autopve/tabs/settings.py @@ -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) diff --git a/autopve/tabs/system.py b/autopve/tabs/system.py new file mode 100644 index 0000000..f716b3c --- /dev/null +++ b/autopve/tabs/system.py @@ -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)) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3323cd4 --- /dev/null +++ b/docker-compose.yml @@ -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. \ No newline at end of file diff --git a/inv.yml b/inv.yml new file mode 100644 index 0000000..ad7fbee --- /dev/null +++ b/inv.yml @@ -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 \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..aadd6d8 --- /dev/null +++ b/main.py @@ -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) diff --git a/mylogging.py b/mylogging.py new file mode 100644 index 0000000..a01c4f8 --- /dev/null +++ b/mylogging.py @@ -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***") diff --git a/pve-install.yml b/pve-install.yml new file mode 100644 index 0000000..27342eb --- /dev/null +++ b/pve-install.yml @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..74c2384 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/resources/autopve.service b/resources/autopve.service new file mode 100644 index 0000000..0fed3e8 --- /dev/null +++ b/resources/autopve.service @@ -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 \ No newline at end of file diff --git a/resources/docker-entrypoint.sh b/resources/docker-entrypoint.sh new file mode 100644 index 0000000..3c6166a --- /dev/null +++ b/resources/docker-entrypoint.sh @@ -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 $@ \ No newline at end of file diff --git a/static/jse-theme-dark.css b/static/jse-theme-dark.css new file mode 100644 index 0000000..4a10a46 --- /dev/null +++ b/static/jse-theme-dark.css @@ -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; + } \ No newline at end of file