mirror of
https://github.com/Cian-H/I-Form_Server_Node_Deployer.git
synced 2025-12-23 06:32:08 +00:00
303 lines
9.9 KiB
Python
303 lines
9.9 KiB
Python
from collections import defaultdict
|
|
from functools import wraps
|
|
from pathlib import Path
|
|
from typing import Callable, DefaultDict, Optional, Tuple
|
|
|
|
import flet as ft
|
|
from node_deployer.create_disk import create_ignition_disk
|
|
from node_deployer.ip_interface import IPAddress
|
|
|
|
from .disk_dropdown import disk_dropdown
|
|
from .types import CreateDiskArgs
|
|
|
|
|
|
# Hotkeys will be mapped using using a dict to avoid a big, slow if-elif-else chain
|
|
def _no_hotkey() -> Callable[[ft.KeyboardEvent], None]:
|
|
def dummy_func(e: ft.KeyboardEvent) -> None:
|
|
pass
|
|
|
|
return dummy_func
|
|
|
|
|
|
HOTKEY_MAP: DefaultDict[str, Callable[[ft.KeyboardEvent], None]] = defaultdict(_no_hotkey)
|
|
|
|
|
|
def main(page: ft.Page) -> None:
|
|
page.title = "I-Form Server Node Deployer"
|
|
page.vertical_alignment = ft.MainAxisAlignment.CENTER
|
|
|
|
# TODO: Add a progress bar
|
|
# TODO: Add save/load functionality?
|
|
|
|
# Lets start with the easiest part: a logo in the top left
|
|
if page.platform_brightness == ft.ThemeMode.DARK:
|
|
logo = ft.Image(str(Path(__file__).parent / "assets/logo_dark.png"), width=210)
|
|
else:
|
|
logo = ft.Image(str(Path(__file__).parent / "assets/logo_light.png"), width=210)
|
|
|
|
logo_container = ft.Container(
|
|
content=logo,
|
|
padding=10,
|
|
alignment=ft.alignment.top_left,
|
|
)
|
|
page.add(logo_container)
|
|
|
|
# These fields are used to get the parameters for the disk creation
|
|
disk, dd_element = disk_dropdown(tooltip="Select the disk to write to", label="Disk")
|
|
hostname = ft.TextField(
|
|
value="host", label="Hostname", text_align=ft.TextAlign.LEFT, width=page.window_width // 3
|
|
)
|
|
password = ft.TextField(
|
|
label="Password",
|
|
password=True,
|
|
can_reveal_password=True,
|
|
text_align=ft.TextAlign.LEFT,
|
|
width=page.window_width // 3,
|
|
)
|
|
switch_ip = ft.TextField(
|
|
label="Switch IP", text_align=ft.TextAlign.LEFT, width=page.window_width // 3
|
|
)
|
|
switch_port = ft.TextField(
|
|
label="Switch Port",
|
|
value="4789",
|
|
text_align=ft.TextAlign.LEFT,
|
|
width=page.window_width // 3,
|
|
)
|
|
swarm_token = ft.TextField(
|
|
label="Swarm Token", text_align=ft.TextAlign.LEFT, width=int((2 * page.window_width) // 3)
|
|
)
|
|
|
|
# Add varnames, as they will be useful for unpacking later
|
|
disk.__varname__ = "disk"
|
|
hostname.__varname__ = "hostname"
|
|
password.__varname__ = "password"
|
|
switch_ip.__varname__ = "switch_ip"
|
|
switch_port.__varname__ = "switch_port"
|
|
swarm_token.__varname__ = "swarm_token"
|
|
|
|
# This wrapper validates the value of the field before passing it to the function
|
|
def validate_value[
|
|
F
|
|
](func: Callable[[], F]) -> Callable[
|
|
[], Optional[F]
|
|
]: # mypy PEP 695 support can't come quickly enough # noqa
|
|
#! It is important that bool(F) evaluates to False if the value is invalid
|
|
@wraps(func)
|
|
def wrapped() -> Optional[F]:
|
|
out: F = func()
|
|
if out:
|
|
return out
|
|
else:
|
|
return None
|
|
|
|
return wrapped
|
|
|
|
# The following closures are used to get the values of the fields as the correct datatype
|
|
@validate_value
|
|
def get_disk() -> str:
|
|
return disk.value if disk.value is not None else ""
|
|
|
|
@validate_value
|
|
def get_hostname() -> str:
|
|
return hostname.value if hostname.value is not None else ""
|
|
|
|
@validate_value
|
|
def get_password() -> str:
|
|
return password.value if password.value is not None else ""
|
|
|
|
@validate_value
|
|
def get_switch_ip() -> IPAddress:
|
|
ip = IPAddress("0.0.0.0")
|
|
try:
|
|
ip = IPAddress(switch_ip.value)
|
|
except ValueError:
|
|
pass
|
|
return ip
|
|
|
|
@validate_value
|
|
def get_switch_port() -> int:
|
|
return int(switch_port.value if switch_port.value is not None else "0")
|
|
|
|
@validate_value
|
|
def get_swarm_token() -> str:
|
|
return swarm_token.value if swarm_token.value is not None else ""
|
|
|
|
# A bidirectional dictionary gives us a stateless bidirectional map between
|
|
# fields and their fetch functions
|
|
type FieldFetch = Tuple[ft.TextField | ft.Dropdown, Callable]
|
|
field_fetch_map: Tuple[
|
|
FieldFetch, FieldFetch, FieldFetch, FieldFetch, FieldFetch, FieldFetch
|
|
] = (
|
|
(disk, get_disk),
|
|
(hostname, get_hostname),
|
|
(password, get_password),
|
|
(switch_ip, get_switch_ip),
|
|
(switch_port, get_switch_port),
|
|
(swarm_token, get_swarm_token),
|
|
)
|
|
|
|
# This button triggers the confirmation popup before calling the disk creation function
|
|
def confirm_disk_creation(*_) -> None:
|
|
# Fetch the values of the fields
|
|
vals: CreateDiskArgs = {
|
|
"disk": "",
|
|
"hostname": "",
|
|
"password": "",
|
|
"switch_ip": IPAddress("0.0.0.0"),
|
|
"switch_port": 0,
|
|
"swarm_token": "",
|
|
}
|
|
invalid_values = False
|
|
for field, fetch_func in field_fetch_map:
|
|
value = fetch_func()
|
|
varname: str = str(field.__varname__)
|
|
if varname in CreateDiskArgs.__annotations__.keys():
|
|
target_type = CreateDiskArgs.__annotations__[varname]
|
|
else:
|
|
raise KeyError(f"Field {varname} is not in CreateDiskArgs")
|
|
if value is None:
|
|
# If invalid values, highlight the field and set the error text
|
|
field.error_text = "This field is required"
|
|
field.border_color = "RED"
|
|
field.update()
|
|
invalid_values = True
|
|
else:
|
|
# If valid values, ensure the field is not highlighted and clear the error text
|
|
field.error_text = None
|
|
field.border_color = None
|
|
field.update()
|
|
typed_val = target_type(value)
|
|
vals[varname] = typed_val # type: ignore #! This is a false positive, an invalid literal would have been caught by the if statement above
|
|
|
|
if invalid_values:
|
|
return
|
|
|
|
disk_val: str = vals["disk"]
|
|
hostname_val: str = vals["hostname"]
|
|
password_val: str = vals["password"]
|
|
switch_ip_val: IPAddress = vals["switch_ip"]
|
|
switch_port_val: int = vals["switch_port"]
|
|
swarm_token_val: str = vals["swarm_token"]
|
|
|
|
# The following closures build the confirmation popup
|
|
# Also: nested closures, eww. It feels dirty, but maintains the functional style
|
|
# and most of its benefits. I'm tempting the functional gods to punish me with
|
|
# side-effects here though...
|
|
def close_dlg(*_) -> None:
|
|
dlg.open = False
|
|
if "Y" in HOTKEY_MAP.keys():
|
|
del HOTKEY_MAP["Y"]
|
|
if "N" in HOTKEY_MAP.keys():
|
|
del HOTKEY_MAP["N"]
|
|
HOTKEY_MAP["Enter"] = confirm_disk_creation
|
|
page.update()
|
|
|
|
# This closure is called when the confirm disk creation button is pressed
|
|
def trigger_disk_creation(*_) -> None:
|
|
dlg.title = None
|
|
dlg.content = ft.Row(
|
|
controls=[
|
|
ft.Text("Creating disk..."),
|
|
ft.ProgressRing(),
|
|
],
|
|
alignment=ft.MainAxisAlignment.CENTER,
|
|
)
|
|
dlg.actions = []
|
|
page.update()
|
|
create_ignition_disk(
|
|
disk=disk_val,
|
|
hostname=hostname_val,
|
|
password=password_val,
|
|
switch_ip=switch_ip_val,
|
|
switch_port=switch_port_val,
|
|
swarm_token=swarm_token_val,
|
|
)
|
|
dlg.content = ft.Text("Ignition disk created!")
|
|
dlg.actions = [
|
|
ft.TextButton("OK", on_click=close_dlg),
|
|
]
|
|
page.update()
|
|
|
|
dlg = ft.AlertDialog(
|
|
modal=True,
|
|
title=ft.Text("Please confirm"),
|
|
content=ft.Text(f"Overwrite disk {disk_val}?"),
|
|
actions=[
|
|
ft.TextButton("Yes", on_click=trigger_disk_creation),
|
|
ft.TextButton("No", on_click=close_dlg),
|
|
],
|
|
actions_alignment=ft.MainAxisAlignment.END,
|
|
)
|
|
|
|
# Finally, we open the dialog popup and switch the hotkeys over
|
|
page.dialog = dlg
|
|
dlg.open = True
|
|
if "Enter" in HOTKEY_MAP.keys():
|
|
del HOTKEY_MAP["Enter"]
|
|
HOTKEY_MAP["Y"] = trigger_disk_creation
|
|
HOTKEY_MAP["N"] = close_dlg
|
|
page.update()
|
|
|
|
|
|
disk_creation_dialog = ft.FilledButton(
|
|
text="Create Ignition Disk",
|
|
on_click=confirm_disk_creation,
|
|
width=page.window_width // 3,
|
|
)
|
|
|
|
# Then, we arrange the fields into rows and columns
|
|
disk_row = ft.Row(
|
|
controls=[
|
|
dd_element,
|
|
disk_creation_dialog,
|
|
],
|
|
alignment=ft.MainAxisAlignment.CENTER,
|
|
)
|
|
|
|
node_row = ft.Row(
|
|
controls=[
|
|
hostname,
|
|
password,
|
|
],
|
|
alignment=ft.MainAxisAlignment.CENTER,
|
|
)
|
|
|
|
switch_row = ft.Row(
|
|
controls=[
|
|
switch_ip,
|
|
switch_port,
|
|
],
|
|
alignment=ft.MainAxisAlignment.CENTER,
|
|
)
|
|
|
|
swarm_token_row = ft.Row(
|
|
controls=[
|
|
swarm_token,
|
|
],
|
|
alignment=ft.MainAxisAlignment.CENTER,
|
|
)
|
|
|
|
stacked_rows = ft.Column(
|
|
[disk_row, node_row, switch_row, swarm_token_row],
|
|
alignment=ft.MainAxisAlignment.CENTER,
|
|
)
|
|
|
|
# Finally, we finish constructing the UI by adding the rows to the page
|
|
page.add(stacked_rows)
|
|
|
|
# As a final task, we define the opening screen hotkey events
|
|
HOTKEY_MAP["Enter"] = confirm_disk_creation
|
|
|
|
def quit_app(e: ft.KeyboardEvent) -> None:
|
|
if e.ctrl:
|
|
page.window_close()
|
|
|
|
HOTKEY_MAP["Q"] = quit_app
|
|
|
|
def on_key_press(e: ft.KeyboardEvent) -> None:
|
|
HOTKEY_MAP[e.key](e)
|
|
|
|
page.on_keyboard_event = on_key_press
|
|
|
|
page.update() # This shouldn't be necessary, but ensures the UI is rendered correctly
|