Improve EOSdash.

Make EOSdash use UI components from MonsterUI to ease further development.

- Add a first menu with some dummy pages and the configuration page.
- Make the configuration scrollable.
- Add markdown component that uses markdown-it-py (same as used by
  the myth-parser for documentation generation).
- Add bokeh (https://docs.bokeh.org/) component for charts
- Added several prediction charts to demo
- Add a footer that displays connection status with EOS server
- Add logo and favicon

Update EOS server:

- Move error message generation to extra module
- Use redirect instead of proxy

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
Bobby Noelte
2025-02-15 14:09:53 +01:00
parent 80bfe4d0f0
commit ab6a518b5f
34 changed files with 1802 additions and 351 deletions
+188 -155
View File
@@ -1,127 +1,144 @@
import argparse
import os
import sys
import time
from functools import reduce
from typing import Any, Union
import traceback
from pathlib import Path
from typing import Optional
import psutil
import uvicorn
from fasthtml.common import H1, Table, Td, Th, Thead, Titled, Tr, fast_app
from fasthtml.starlette import JSONResponse
from pydantic.fields import ComputedFieldInfo, FieldInfo
from pydantic_core import PydanticUndefined
from fasthtml.common import FileResponse, JSONResponse
from monsterui.core import FastHTML, Theme
from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.server.dash.bokeh import BokehJS
from akkudoktoreos.server.dash.components import Page
# Pages
from akkudoktoreos.server.dash.configuration import Configuration
from akkudoktoreos.server.dash.demo import Demo
from akkudoktoreos.server.dash.footer import Footer
from akkudoktoreos.server.dash.hello import Hello
from akkudoktoreos.server.server import get_default_host, wait_for_port_free
# from akkudoktoreos.server.dash.altair import AltairJS
logger = get_logger(__name__)
config_eos = get_config()
# The favicon for EOSdash
favicon_filepath = Path(__file__).parent.joinpath("dash/assets/favicon/favicon.ico")
if not favicon_filepath.exists():
raise ValueError(f"Does not exist {favicon_filepath}")
# Command line arguments
args = None
args: Optional[argparse.Namespace] = None
def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_field: bool) -> Any:
default_value = ""
if regular_field:
if (val := field_info.default) is not PydanticUndefined:
default_value = val
else:
default_value = "N/A"
return default_value
# Get frankenui and tailwind headers via CDN using Theme.green.headers()
# Add altair headers
# hdrs=(Theme.green.headers(highlightjs=True), AltairJS,)
hdrs = (
Theme.green.headers(highlightjs=True),
BokehJS,
)
def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple[Any, list[str]]]:
resolved_types: list[tuple[Any, list[str]]] = []
origin = getattr(field_type, "__origin__", field_type)
if origin is Union:
for arg in getattr(field_type, "__args__", []):
if arg is not type(None):
resolved_types.extend(resolve_nested_types(arg, parent_types))
else:
resolved_types.append((field_type, parent_types))
return resolved_types
configs = []
inner_types: set[type[PydanticBaseModel]] = set()
for field_name, field_info in list(config_eos.model_fields.items()) + list(
config_eos.model_computed_fields.items()
):
def extract_nested_models(
subfield_info: Union[ComputedFieldInfo, FieldInfo], parent_types: list[str]
) -> None:
regular_field = isinstance(subfield_info, FieldInfo)
subtype = subfield_info.annotation if regular_field else subfield_info.return_type
if subtype in inner_types:
return
nested_types = resolve_nested_types(subtype, [])
found_basic = False
for nested_type, nested_parent_types in nested_types:
if not isinstance(nested_type, type) or not issubclass(nested_type, PydanticBaseModel):
if found_basic:
continue
config = {}
config["name"] = ".".join(parent_types)
try:
config["value"] = reduce(getattr, [config_eos] + parent_types)
except AttributeError:
# Parent value(s) are not set in current config
config["value"] = ""
config["default"] = get_default_value(subfield_info, regular_field)
config["description"] = (
subfield_info.description if subfield_info.description else ""
)
configs.append(config)
found_basic = True
else:
new_parent_types = parent_types + nested_parent_types
inner_types.add(nested_type)
for nested_field_name, nested_field_info in list(
nested_type.model_fields.items()
) + list(nested_type.model_computed_fields.items()):
extract_nested_models(
nested_field_info,
new_parent_types + [nested_field_name],
)
extract_nested_models(field_info, [field_name])
configs = sorted(configs, key=lambda x: x["name"])
app, rt = fast_app(
# The EOSdash application
app: FastHTML = FastHTML(
title="EOSdash",
hdrs=hdrs,
secret_key=os.getenv("EOS_SERVER__EOSDASH_SESSKEY"),
)
def config_table() -> Table:
rows = [
Tr(
Td(config["name"]),
Td(config["value"]),
Td(config["default"]),
Td(config["description"]),
cls="even:bg-purple/5",
)
for config in configs
]
flds = "Name", "Value", "Default", "Description"
head = Thead(*map(Th, flds), cls="bg-purple/10")
return Table(head, *rows, cls="w-full")
def eos_server() -> tuple[str, int]:
"""Retrieves the EOS server host and port configuration.
If `args` is provided, it uses the `eos_host` and `eos_port` from `args`.
Otherwise, it falls back to the values from `config_eos.server`.
Returns:
tuple[str, int]: A tuple containing:
- `eos_host` (str): The EOS server hostname or IP.
- `eos_port` (int): The EOS server port.
"""
if args is None:
eos_host = str(config_eos.server.host)
eos_port = config_eos.server.port
else:
eos_host = args.eos_host
eos_port = args.eos_port
eos_host = eos_host if eos_host else get_default_host()
eos_port = eos_port if eos_port else 8503
return eos_host, eos_port
@rt("/")
def get(): # type: ignore
return Titled("EOS Dashboard", H1("Configuration"), config_table())
@app.get("/favicon.ico")
def get_eosdash_favicon(): # type: ignore
"""Get favicon."""
return FileResponse(path=favicon_filepath)
@app.get("/")
def get_eosdash(): # type: ignore
"""Serves the main EOSdash page.
Returns:
Page: The main dashboard page with navigation links and footer.
"""
return Page(
None,
{
"EOSdash": "/eosdash/hello",
"Config": "/eosdash/configuration",
"Demo": "/eosdash/demo",
},
Hello(),
Footer(*eos_server()),
"/eosdash/footer",
)
@app.get("/eosdash/footer")
def get_eosdash_footer(): # type: ignore
"""Serves the EOSdash Foooter information.
Returns:
Footer: The Footer component.
"""
return Footer(*eos_server())
@app.get("/eosdash/hello")
def get_eosdash_hello(): # type: ignore
"""Serves the EOSdash Hello page.
Returns:
Hello: The Hello page component.
"""
return Hello()
@app.get("/eosdash/configuration")
def get_eosdash_configuration(): # type: ignore
"""Serves the EOSdash Configuration page.
Returns:
Configuration: The Configuration page component.
"""
return Configuration(*eos_server())
@app.get("/eosdash/demo")
def get_eosdash_demo(): # type: ignore
"""Serves the EOSdash Demo page.
Returns:
Demo: The Demo page component.
"""
return Demo(*eos_server())
@app.get("/eosdash/health")
@@ -135,7 +152,14 @@ def get_eosdash_health(): # type: ignore
)
def run_eosdash(host: str, port: int, log_level: str, access_log: bool, reload: bool) -> None:
@app.get("/eosdash/assets/{fname:path}.{ext:static}")
def get_eosdash_assets(fname: str, ext: str): # type: ignore
"""Get assets."""
asset_filepath = Path(__file__).parent.joinpath(f"dash/assets/{fname}.{ext}")
return FileResponse(path=asset_filepath)
def run_eosdash() -> None:
"""Run the EOSdash server with the specified configurations.
This function starts the EOSdash server using the Uvicorn ASGI server. It accepts
@@ -145,65 +169,77 @@ def run_eosdash(host: str, port: int, log_level: str, access_log: bool, reload:
server to the specified host and port, an error message is logged and the
application exits.
Args:
host (str): The hostname to bind the server to.
port (int): The port number to bind the server to.
log_level (str): The log level for the server. Options include "critical", "error",
"warning", "info", "debug", and "trace".
access_log (bool): Whether to enable or disable the access log. Set to True to enable.
reload (bool): Whether to enable or disable auto-reload. Set to True for development.
Returns:
None
"""
# Setup parameters from args, config_eos and default
# Remember parameters that are also in config
# - EOS host
if args and args.eos_host:
eos_host = args.eos_host
elif config_eos.server.host:
eos_host = config_eos.server.host
else:
eos_host = get_default_host()
config_eos.server.host = eos_host
# - EOS port
if args and args.eos_port:
eos_port = args.eos_port
elif config_eos.server.port:
eos_port = config_eos.server.port
else:
eos_port = 8503
config_eos.server.port = eos_port
# - EOSdash host
if args and args.host:
eosdash_host = args.host
elif config_eos.server.eosdash.host:
eosdash_host = config_eos.server.eosdash_host
else:
eosdash_host = get_default_host()
config_eos.server.eosdash_host = eosdash_host
# - EOS port
if args and args.port:
eosdash_port = args.port
elif config_eos.server.eosdash_port:
eosdash_port = config_eos.server.eosdash_port
else:
eosdash_port = 8504
config_eos.server.eosdash_port = eosdash_port
# - log level
if args and args.log_level:
log_level = args.log_level
else:
log_level = "info"
# - access log
if args and args.access_log:
access_log = args.access_log
else:
access_log = False
# - reload
if args and args.reload:
reload = args.reload
else:
reload = False
# Make hostname Windows friendly
if host == "0.0.0.0" and os.name == "nt":
host = "localhost"
if eosdash_host == "0.0.0.0" and os.name == "nt":
eosdash_host = "localhost"
# Wait for EOSdash port to be free - e.g. in case of restart
timeout = 120 # Maximum 120 seconds to wait
process_info: list[dict] = []
for retries in range(int(timeout / 3)):
process_info = []
pids: list[int] = []
for conn in psutil.net_connections(kind="inet"):
if conn.laddr.port == port:
if conn.pid not in pids:
# Get fresh process info
process = psutil.Process(conn.pid)
pids.append(conn.pid)
process_info.append(process.as_dict(attrs=["pid", "cmdline"]))
if len(process_info) == 0:
break
logger.info(f"EOSdash waiting for port `{port}` ...")
time.sleep(3)
if len(process_info) > 0:
logger.warning(f"EOSdash port `{port}` in use.")
for info in process_info:
logger.warning(f"PID: `{info["pid"]}`, CMD: `{info["cmdline"]}`")
# Setup config from args
if args:
if args.eos_host:
config_eos.server.host = args.eos_host
if args.eos_port:
config_eos.server.port = args.eos_port
if args.host:
config_eos.server.eosdash_host = args.host
if args.port:
config_eos.server.eosdash_port = args.port
wait_for_port_free(eosdash_port, timeout=120, waiting_app_name="EOSdash")
try:
uvicorn.run(
"akkudoktoreos.server.eosdash:app",
host=host,
port=port,
log_level=log_level.lower(), # Convert log_level to lowercase
host=eosdash_host,
port=eosdash_port,
log_level=log_level.lower(),
access_log=access_log,
reload=reload,
)
except Exception as e:
logger.error(f"Could not bind to host {host}:{port}. Error: {e}")
logger.error(f"Could not bind to host {eosdash_host}:{eosdash_port}. Error: {e}")
raise e
@@ -212,7 +248,7 @@ def main() -> None:
This function sets up the argument parser to accept command-line arguments for
host, port, log_level, access_log, and reload. It uses default values from the
config_eos module if arguments are not provided. After parsing the arguments,
config module if arguments are not provided. After parsing the arguments,
it starts the EOSdash server with the specified configurations.
Command-line Arguments:
@@ -226,7 +262,6 @@ def main() -> None:
"""
parser = argparse.ArgumentParser(description="Start EOSdash server.")
# Host and port arguments with defaults from config_eos
parser.add_argument(
"--host",
type=str,
@@ -239,8 +274,6 @@ def main() -> None:
default=config_eos.server.eosdash_port,
help="Port for the EOSdash server (default: value from config)",
)
# EOS Host and port arguments with defaults from config_eos
parser.add_argument(
"--eos-host",
type=str,
@@ -253,8 +286,6 @@ def main() -> None:
default=config_eos.server.port,
help="Port of the EOS server (default: value from config)",
)
# Optional arguments for log_level, access_log, and reload
parser.add_argument(
"--log_level",
type=str,
@@ -265,7 +296,7 @@ def main() -> None:
"--access_log",
type=bool,
default=False,
help="Enable or disable access log. Options: True or False (default: True)",
help="Enable or disable access log. Options: True or False (default: False)",
)
parser.add_argument(
"--reload",
@@ -274,13 +305,15 @@ def main() -> None:
help="Enable or disable auto-reload. Useful for development. Options: True or False (default: False)",
)
global args
args = parser.parse_args()
try:
run_eosdash(args.host, args.port, args.log_level, args.access_log, args.reload)
run_eosdash()
except Exception as ex:
error_msg = f"Failed to run EOSdash: {ex}"
logger.error(error_msg)
traceback.print_exc()
sys.exit(1)