2025-01-21 19:20:50 +01:00
|
|
|
import argparse
|
2025-01-24 00:09:28 +01:00
|
|
|
import os
|
2025-01-18 14:26:34 +01:00
|
|
|
import sys
|
2025-02-12 21:35:51 +01:00
|
|
|
import time
|
2025-01-18 14:07:08 +01:00
|
|
|
from functools import reduce
|
|
|
|
from typing import Any, Union
|
2025-01-21 19:20:50 +01:00
|
|
|
|
2025-02-12 21:35:51 +01:00
|
|
|
import psutil
|
2024-12-15 14:40:03 +01:00
|
|
|
import uvicorn
|
2025-01-19 13:00:53 +01:00
|
|
|
from fasthtml.common import H1, Table, Td, Th, Thead, Titled, Tr, fast_app
|
2025-02-12 21:35:51 +01:00
|
|
|
from fasthtml.starlette import JSONResponse
|
2025-01-18 14:07:08 +01:00
|
|
|
from pydantic.fields import ComputedFieldInfo, FieldInfo
|
|
|
|
from pydantic_core import PydanticUndefined
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
|
|
from akkudoktoreos.config.config import get_config
|
2025-01-05 14:41:07 +01:00
|
|
|
from akkudoktoreos.core.logging import get_logger
|
2025-01-18 14:07:08 +01:00
|
|
|
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
|
config_eos = get_config()
|
|
|
|
|
2025-01-21 19:20:50 +01:00
|
|
|
# Command line arguments
|
|
|
|
args = None
|
2024-12-15 14:40:03 +01:00
|
|
|
|
2025-01-18 14:07:08 +01:00
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
2024-12-15 14:40:03 +01:00
|
|
|
configs = []
|
2025-01-18 14:07:08 +01:00
|
|
|
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])
|
2025-01-22 19:03:23 +01:00
|
|
|
configs = sorted(configs, key=lambda x: x["name"])
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
|
|
|
2025-01-19 13:00:53 +01:00
|
|
|
app, rt = fast_app(
|
|
|
|
secret_key=os.getenv("EOS_SERVER__EOSDASH_SESSKEY"),
|
|
|
|
)
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
|
@rt("/")
|
|
|
|
def get(): # type: ignore
|
2025-01-09 16:54:49 +01:00
|
|
|
return Titled("EOS Dashboard", H1("Configuration"), config_table())
|
2024-12-15 14:40:03 +01:00
|
|
|
|
|
|
|
|
2025-02-12 21:35:51 +01:00
|
|
|
@app.get("/eosdash/health")
|
|
|
|
def get_eosdash_health(): # type: ignore
|
|
|
|
"""Health check endpoint to verify that the EOSdash server is alive."""
|
|
|
|
return JSONResponse(
|
|
|
|
{
|
|
|
|
"status": "alive",
|
|
|
|
"pid": psutil.Process().pid,
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2025-01-21 19:20:50 +01:00
|
|
|
def run_eosdash(host: str, port: int, log_level: str, access_log: bool, reload: bool) -> None:
|
|
|
|
"""Run the EOSdash server with the specified configurations.
|
|
|
|
|
|
|
|
This function starts the EOSdash server using the Uvicorn ASGI server. It accepts
|
|
|
|
arguments for the host, port, log level, access log, and reload options. The
|
|
|
|
log level is converted to lowercase to ensure compatibility with Uvicorn's
|
|
|
|
expected log level format. If an error occurs while attempting to bind the
|
|
|
|
server to the specified host and port, an error message is logged and the
|
|
|
|
application exits.
|
|
|
|
|
2025-02-12 21:35:51 +01:00
|
|
|
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.
|
2025-01-21 19:20:50 +01:00
|
|
|
|
|
|
|
Returns:
|
2025-02-12 21:35:51 +01:00
|
|
|
None
|
2025-01-21 19:20:50 +01:00
|
|
|
"""
|
2025-01-24 00:09:28 +01:00
|
|
|
# Make hostname Windows friendly
|
|
|
|
if host == "0.0.0.0" and os.name == "nt":
|
2025-01-21 19:20:50 +01:00
|
|
|
host = "localhost"
|
2025-02-12 21:35:51 +01:00
|
|
|
|
|
|
|
# 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
|
|
|
|
|
2024-12-15 14:40:03 +01:00
|
|
|
try:
|
|
|
|
uvicorn.run(
|
2025-01-21 19:20:50 +01:00
|
|
|
"akkudoktoreos.server.eosdash:app",
|
|
|
|
host=host,
|
|
|
|
port=port,
|
|
|
|
log_level=log_level.lower(), # Convert log_level to lowercase
|
|
|
|
access_log=access_log,
|
|
|
|
reload=reload,
|
2024-12-15 14:40:03 +01:00
|
|
|
)
|
|
|
|
except Exception as e:
|
2025-01-21 19:20:50 +01:00
|
|
|
logger.error(f"Could not bind to host {host}:{port}. Error: {e}")
|
|
|
|
raise e
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
"""Parse command-line arguments and start the EOSdash server with the specified options.
|
|
|
|
|
|
|
|
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,
|
|
|
|
it starts the EOSdash server with the specified configurations.
|
|
|
|
|
|
|
|
Command-line Arguments:
|
2025-01-18 14:26:34 +01:00
|
|
|
--host (str): Host for the EOSdash server (default: value from config).
|
|
|
|
--port (int): Port for the EOSdash server (default: value from config).
|
|
|
|
--eos-host (str): Host for the EOS server (default: value from config).
|
|
|
|
--eos-port (int): Port for the EOS server (default: value from config).
|
2025-01-21 19:20:50 +01:00
|
|
|
--log_level (str): Log level for the server. Options: "critical", "error", "warning", "info", "debug", "trace" (default: "info").
|
|
|
|
--access_log (bool): Enable or disable access log. Options: True or False (default: False).
|
|
|
|
--reload (bool): Enable or disable auto-reload. Useful for development. Options: True or False (default: False).
|
|
|
|
"""
|
|
|
|
parser = argparse.ArgumentParser(description="Start EOSdash server.")
|
|
|
|
|
|
|
|
# Host and port arguments with defaults from config_eos
|
|
|
|
parser.add_argument(
|
|
|
|
"--host",
|
|
|
|
type=str,
|
2025-01-18 14:26:34 +01:00
|
|
|
default=str(config_eos.server.eosdash_host),
|
|
|
|
help="Host for the EOSdash server (default: value from config)",
|
2025-01-21 19:20:50 +01:00
|
|
|
)
|
|
|
|
parser.add_argument(
|
|
|
|
"--port",
|
|
|
|
type=int,
|
2025-01-18 14:26:34 +01:00
|
|
|
default=config_eos.server.eosdash_port,
|
|
|
|
help="Port for the EOSdash server (default: value from config)",
|
2025-01-21 19:20:50 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
# EOS Host and port arguments with defaults from config_eos
|
|
|
|
parser.add_argument(
|
|
|
|
"--eos-host",
|
|
|
|
type=str,
|
2025-01-18 14:26:34 +01:00
|
|
|
default=str(config_eos.server.host),
|
2025-02-12 21:35:51 +01:00
|
|
|
help="Host of the EOS server (default: value from config)",
|
2025-01-21 19:20:50 +01:00
|
|
|
)
|
|
|
|
parser.add_argument(
|
|
|
|
"--eos-port",
|
|
|
|
type=int,
|
2025-01-18 14:26:34 +01:00
|
|
|
default=config_eos.server.port,
|
2025-02-12 21:35:51 +01:00
|
|
|
help="Port of the EOS server (default: value from config)",
|
2025-01-21 19:20:50 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
# Optional arguments for log_level, access_log, and reload
|
|
|
|
parser.add_argument(
|
|
|
|
"--log_level",
|
|
|
|
type=str,
|
|
|
|
default="info",
|
|
|
|
help='Log level for the server. Options: "critical", "error", "warning", "info", "debug", "trace" (default: "info")',
|
|
|
|
)
|
|
|
|
parser.add_argument(
|
|
|
|
"--access_log",
|
|
|
|
type=bool,
|
|
|
|
default=False,
|
|
|
|
help="Enable or disable access log. Options: True or False (default: True)",
|
|
|
|
)
|
|
|
|
parser.add_argument(
|
|
|
|
"--reload",
|
|
|
|
type=bool,
|
|
|
|
default=False,
|
|
|
|
help="Enable or disable auto-reload. Useful for development. Options: True or False (default: False)",
|
|
|
|
)
|
|
|
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
try:
|
|
|
|
run_eosdash(args.host, args.port, args.log_level, args.access_log, args.reload)
|
2025-02-12 21:35:51 +01:00
|
|
|
except Exception as ex:
|
|
|
|
error_msg = f"Failed to run EOSdash: {ex}"
|
|
|
|
logger.error(error_msg)
|
2025-01-18 14:26:34 +01:00
|
|
|
sys.exit(1)
|
2025-01-21 19:20:50 +01:00
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
main()
|