mirror of
https://github.com/ublue-os/forge.git
synced 2025-07-06 09:45:46 +03:00
feat: add graphical user interface (#34)
This commit is contained in:
parent
c3ed45a21b
commit
4f2130bcce
43 changed files with 2939 additions and 971 deletions
35
anvil/nicegui/main.py
Normal file
35
anvil/nicegui/main.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
import pages
|
||||
import pages.about
|
||||
import pages.ansible
|
||||
import pages.home
|
||||
import pages.registry
|
||||
import theme
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
|
||||
@ui.page("/")
|
||||
def index_page() -> None:
|
||||
with theme.frame("Home"):
|
||||
pages.home.content()
|
||||
|
||||
|
||||
@ui.page("/ansible")
|
||||
def ansible_page() -> None:
|
||||
with theme.frame("Ansible"):
|
||||
pages.ansible.content()
|
||||
|
||||
|
||||
@ui.page("/registry")
|
||||
def registry_page() -> None:
|
||||
with theme.frame("Registry"):
|
||||
pages.registry.content()
|
||||
|
||||
|
||||
@ui.page("/about")
|
||||
def about_page() -> None:
|
||||
with theme.frame("About"):
|
||||
pages.about.content()
|
||||
|
||||
|
||||
ui.run(title="uBlue Forge", port=3000)
|
8
anvil/nicegui/menu.py
Normal file
8
anvil/nicegui/menu.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from nicegui import ui
|
||||
|
||||
|
||||
def menu() -> None:
|
||||
ui.link("Home", "/").classes(replace="text-white")
|
||||
ui.link("Ansible", "/ansible").classes(replace="text-white")
|
||||
ui.link("Registry", "/registry").classes(replace="text-white")
|
||||
ui.link("About", "/about").classes(replace="text-white")
|
1
anvil/nicegui/pages/__init__.py
Normal file
1
anvil/nicegui/pages/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from nicegui import ui
|
10
anvil/nicegui/pages/about.py
Normal file
10
anvil/nicegui/pages/about.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
from nicegui import ui
|
||||
import os
|
||||
|
||||
|
||||
def content() -> None:
|
||||
project_root = os.environ['NICEGUI_DIR']
|
||||
ui.label("Work in progress...").classes("text-h6")
|
||||
ui.image(project_root + "/pages/assets/work-in-progress.png").classes(
|
||||
"w-[200%]"
|
||||
)
|
121
anvil/nicegui/pages/ansible.py
Normal file
121
anvil/nicegui/pages/ansible.py
Normal file
|
@ -0,0 +1,121 @@
|
|||
import ansible_runner
|
||||
import re
|
||||
import asyncio
|
||||
import os
|
||||
from nicegui import ui
|
||||
from theme import GuiProgressSpinner
|
||||
from utils import local_file_picker
|
||||
|
||||
ANSIBLE_EXTRA_VARS = None
|
||||
|
||||
|
||||
# Ansible integration
|
||||
@ui.refreshable # https://www.reddit.com/r/nicegui/comments/1bphjk5/comment/kx7l5kj/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button
|
||||
async def load_configuration_file() -> None:
|
||||
global ANSIBLE_EXTRA_VARS
|
||||
result = await local_file_picker(
|
||||
directory="/data", multiple=False, file_name_filter=".yml"
|
||||
)
|
||||
file_path = result[0]
|
||||
with open(file_path, "r") as file:
|
||||
data = file.read()
|
||||
## Give feedback to user
|
||||
ui.notify(f"You chose {file_path}")
|
||||
## Display content
|
||||
ui.code(content=data, language="yaml")
|
||||
## Preserve configuration file path for ansible-playbook --extra-vars
|
||||
ANSIBLE_EXTRA_VARS = f"@{file_path}"
|
||||
|
||||
|
||||
async def run_ansible_playbook(
|
||||
playbook_name: str, gui_log: ui.log, gui_spinner: GuiProgressSpinner
|
||||
) -> None:
|
||||
# Clear log console
|
||||
gui_log.clear()
|
||||
# Enable spinner
|
||||
gui_spinner.enable()
|
||||
# Run ansible playbook
|
||||
project_root = os.environ["ANSIBLE_DIR"]
|
||||
playbook_path = f"{project_root}/playbooks/"
|
||||
extra_vars_file = ANSIBLE_EXTRA_VARS
|
||||
thread, runner = ansible_runner.interface.run_command_async(
|
||||
executable_cmd="ansible-playbook",
|
||||
cmdline_args=[
|
||||
f"{playbook_path}/{playbook_name}",
|
||||
# playbook_path + playbook_name,
|
||||
"--extra-vars",
|
||||
extra_vars_file,
|
||||
],
|
||||
)
|
||||
# Parse and display output from ansible playbook
|
||||
## Remove color characters from response until clear how to display them in a log
|
||||
output_parser = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-9;#]+[mGK]?)")
|
||||
# show log from asynchronous job
|
||||
processed_events = set() # Set to store processed events
|
||||
while runner.rc is None:
|
||||
for event in runner.events:
|
||||
# Make sure log is displayed during playbook run
|
||||
await asyncio.sleep(0.1)
|
||||
# Check if event has been processed already
|
||||
event_key = (event["uuid"], event["counter"])
|
||||
if event_key not in processed_events:
|
||||
# Add event to processed set
|
||||
processed_events.add(event_key)
|
||||
# Process event
|
||||
ansible_log = format(output_parser.sub("", event["stdout"]))
|
||||
# Push log to ui
|
||||
gui_log.push(ansible_log)
|
||||
# Disable spinner
|
||||
gui_spinner.disable()
|
||||
|
||||
|
||||
# Page content
|
||||
def content() -> None:
|
||||
with ui.row().classes("w-full"):
|
||||
with ui.row().classes("w-full"):
|
||||
with ui.card().classes("h-full"):
|
||||
ui.button(
|
||||
text="Load configuration file",
|
||||
on_click=lambda: load_configuration_file.refresh(),
|
||||
icon="folder",
|
||||
)
|
||||
## show configuration file with ui.code
|
||||
load_configuration_file()
|
||||
with ui.row().classes("w-full"):
|
||||
# First Row
|
||||
with ui.card().classes("h-full"):
|
||||
with ui.row().classes("no-wrap"):
|
||||
ui.label("Build").classes("text-h5")
|
||||
gui_build_progress = GuiProgressSpinner()
|
||||
ui.button(
|
||||
text="Clone project",
|
||||
on_click=lambda: run_ansible_playbook(
|
||||
playbook_name="project_clone.yml",
|
||||
gui_log=gui_playbook_log,
|
||||
gui_spinner=gui_build_progress,
|
||||
),
|
||||
)
|
||||
ui.button(
|
||||
text="Build project",
|
||||
on_click=lambda: run_ansible_playbook(
|
||||
"project_build.yml",
|
||||
gui_log=gui_playbook_log,
|
||||
gui_spinner=gui_build_progress,
|
||||
),
|
||||
)
|
||||
# Second Row
|
||||
with ui.card().classes("h-full"):
|
||||
with ui.row().classes("no-wrap"):
|
||||
ui.label("Deploy").classes("text-h6")
|
||||
gui_deploy_progress = GuiProgressSpinner
|
||||
ui.button(
|
||||
"Deploy VM",
|
||||
on_click=lambda: ui.notify("This playbook is not implemented yet"),
|
||||
)
|
||||
|
||||
with ui.row().classes("w-full"):
|
||||
with ui.card().classes("w-full"):
|
||||
ui.label("Playbook Log").classes("text-h6")
|
||||
ui.button("Clear Log", on_click=lambda: gui_playbook_log.clear())
|
||||
|
||||
gui_playbook_log = ui.log().classes("w-full h-full")
|
BIN
anvil/nicegui/pages/assets/work-in-progress.png
Normal file
BIN
anvil/nicegui/pages/assets/work-in-progress.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 524 KiB |
10
anvil/nicegui/pages/home.py
Normal file
10
anvil/nicegui/pages/home.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
from nicegui import ui
|
||||
import os
|
||||
|
||||
|
||||
def content() -> None:
|
||||
project_root = os.environ['NICEGUI_DIR']
|
||||
ui.label("Work in progress...").classes("text-h6")
|
||||
ui.image(project_root + "/pages/assets/work-in-progress.png").classes(
|
||||
"w-[200%]"
|
||||
)
|
10
anvil/nicegui/pages/registry.py
Normal file
10
anvil/nicegui/pages/registry.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
from nicegui import ui
|
||||
import os
|
||||
|
||||
|
||||
def content() -> None:
|
||||
project_root = os.environ['NICEGUI_DIR']
|
||||
ui.label("Work in progress...").classes("text-h6")
|
||||
ui.image(project_root + "/pages/assets/work-in-progress.png").classes(
|
||||
"w-[200%]"
|
||||
)
|
44
anvil/nicegui/theme.py
Normal file
44
anvil/nicegui/theme.py
Normal file
|
@ -0,0 +1,44 @@
|
|||
from contextlib import contextmanager
|
||||
from menu import menu
|
||||
from nicegui import ui
|
||||
|
||||
|
||||
class GuiProgressSpinner(ui.spinner):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
type: str = "dots",
|
||||
size: str = "lg",
|
||||
color: str | None = "red",
|
||||
thickness: float = 5
|
||||
) -> None:
|
||||
super().__init__(type, size=size, color=color, thickness=thickness)
|
||||
with self, ui.spinner():
|
||||
self.visible = False
|
||||
|
||||
def enable(self) -> None:
|
||||
self.set_visibility(True)
|
||||
|
||||
def disable(self) -> None:
|
||||
self.set_visibility(False)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def frame(navigation_title: str, enable_right_drawer: bool = False):
|
||||
"""Custom page frame to share the same styling and behavior across all pages"""
|
||||
ui.colors(primary="#4051b5", secondary="#dddbff", accent="#171d9a")
|
||||
with ui.header():
|
||||
with ui.row():
|
||||
menu()
|
||||
ui.space()
|
||||
with ui.link(target="https://github.com/ublue-os/forge", new_tab=True):
|
||||
ui.icon("eva-github").classes("text-2xl")
|
||||
|
||||
with ui.column().classes():
|
||||
ui.label(navigation_title).classes("text-h4")
|
||||
yield
|
||||
|
||||
with ui.footer(value=False):
|
||||
ui.add_head_html(
|
||||
'<link href="https://unpkg.com/eva-icons@1.1.3/style/eva-icons.css" rel="stylesheet" />'
|
||||
)
|
116
anvil/nicegui/utils.py
Normal file
116
anvil/nicegui/utils.py
Normal file
|
@ -0,0 +1,116 @@
|
|||
from pathlib import Path
|
||||
import platform
|
||||
from typing import Optional
|
||||
from nicegui import events, ui
|
||||
|
||||
|
||||
# most of the local_file_picker class is from https://github.com/zauberzeug/nicegui/blob/main/examples/local_file_picker/local_file_picker.py
|
||||
# i just added the file_name_filter
|
||||
class local_file_picker(ui.dialog):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
directory: str,
|
||||
*,
|
||||
upper_limit: Optional[str] = ...,
|
||||
multiple: bool = False,
|
||||
show_hidden_files: bool = False,
|
||||
file_name_filter: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Local File Picker
|
||||
|
||||
This is a simple file picker that allows you to select a file from the local filesystem where NiceGUI is running.
|
||||
|
||||
:param directory: The directory to start in.
|
||||
:param upper_limit: The directory to stop at (None: no limit, default: same as the starting directory).
|
||||
:param multiple: Whether to allow multiple files to be selected.
|
||||
:param show_hidden_files: Whether to show hidden files.
|
||||
:param file_name_filter: Filter files in directory based on file name
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
self.path = Path(directory).expanduser()
|
||||
if upper_limit is None:
|
||||
self.upper_limit = None
|
||||
else:
|
||||
self.upper_limit = Path(
|
||||
directory if upper_limit == ... else upper_limit
|
||||
).expanduser()
|
||||
self.show_hidden_files = show_hidden_files
|
||||
self.file_name_filter = file_name_filter
|
||||
with self, ui.card():
|
||||
self.add_drives_toggle()
|
||||
self.grid = (
|
||||
ui.aggrid(
|
||||
{
|
||||
"columnDefs": [{"field": "name", "headerName": "File"}],
|
||||
"rowSelection": "multiple" if multiple else "single",
|
||||
},
|
||||
html_columns=[0],
|
||||
)
|
||||
.classes("w-96")
|
||||
.on("cellDoubleClicked", self.handle_double_click)
|
||||
)
|
||||
with ui.row().classes("w-full justify-end"):
|
||||
ui.button("Cancel", on_click=self.close).props("outline")
|
||||
ui.button("Ok", on_click=self._handle_ok)
|
||||
self.update_grid()
|
||||
|
||||
def add_drives_toggle(self):
|
||||
if platform.system() == "Windows":
|
||||
import win32api
|
||||
|
||||
drives = win32api.GetLogicalDriveStrings().split("\000")[:-1]
|
||||
self.drives_toggle = ui.toggle(
|
||||
drives, value=drives[0], on_change=self.update_drive
|
||||
)
|
||||
|
||||
def update_drive(self):
|
||||
self.path = Path(self.drives_toggle.value).expanduser()
|
||||
self.update_grid()
|
||||
|
||||
def update_grid(self) -> None:
|
||||
paths = list(self.path.glob("*"))
|
||||
if not self.show_hidden_files:
|
||||
paths = [p for p in paths if not p.name.startswith(".")]
|
||||
if self.file_name_filter: # Add this condition
|
||||
paths = [
|
||||
p for p in paths if p.is_file() and p.suffix == self.file_name_filter
|
||||
]
|
||||
paths.sort(key=lambda p: p.name.lower())
|
||||
paths.sort(key=lambda p: not p.is_dir())
|
||||
|
||||
self.grid.options["rowData"] = [
|
||||
{
|
||||
"name": f"📁 <strong>{p.name}</strong>" if p.is_dir() else p.name,
|
||||
"path": str(p),
|
||||
}
|
||||
for p in paths
|
||||
]
|
||||
if (
|
||||
self.upper_limit is None
|
||||
and self.path != self.path.parent
|
||||
or self.upper_limit is not None
|
||||
and self.path != self.upper_limit
|
||||
):
|
||||
self.grid.options["rowData"].insert(
|
||||
0,
|
||||
{
|
||||
"name": "📁 <strong>..</strong>",
|
||||
"path": str(self.path.parent),
|
||||
},
|
||||
)
|
||||
self.grid.update()
|
||||
|
||||
def handle_double_click(self, e: events.GenericEventArguments) -> None:
|
||||
self.path = Path(e.args["data"]["path"])
|
||||
if self.path.is_dir():
|
||||
self.update_grid()
|
||||
else:
|
||||
self.submit([str(self.path)])
|
||||
|
||||
async def _handle_ok(self):
|
||||
rows = await ui.run_javascript(
|
||||
f"getElement({self.grid.id}).gridOptions.api.getSelectedRows()"
|
||||
)
|
||||
self.submit([r["path"] for r in rows])
|
Loading…
Add table
Add a link
Reference in a new issue