diff --git a/docs/_generated/openapi.md b/docs/_generated/openapi.md index dac73b9e..e847a2ee 100644 --- a/docs/_generated/openapi.md +++ b/docs/_generated/openapi.md @@ -238,9 +238,9 @@ Returns: --- -## PUT /v1/config/reset +## POST /v1/config/reset -**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_update_post_v1_config_reset_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_update_post_v1_config_reset_put) +**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_update_post_v1_config_reset_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_update_post_v1_config_reset_post) Fastapi Config Update Post @@ -257,6 +257,22 @@ Returns: --- +## GET /v1/health + +**Links**: [local](http://localhost:8503/docs#/default/fastapi_health_get_v1_health_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_health_get_v1_health_get) + +Fastapi Health Get + +``` +Health check endpoint to verify that the server is alive. +``` + +**Responses**: + +- **200**: Successful Response + +--- + ## PUT /v1/measurement/data **Links**: [local](http://localhost:8503/docs#/default/fastapi_measurement_data_put_v1_measurement_data_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_measurement_data_put_v1_measurement_data_put) diff --git a/openapi.json b/openapi.json index bc419af2..8216e4d1 100644 --- a/openapi.json +++ b/openapi.json @@ -106,246 +106,6 @@ "title": "BaseBatteryParameters", "type": "object" }, - "GeneralSettings-Input": { - "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files and system location (latitude\nand longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", - "properties": { - "data_cache_subpath": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "default": "cache", - "description": "Sub-path for the EOS cache data directory.", - "title": "Data Cache Subpath" - }, - "data_folder_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Path to EOS data directory.", - "examples": [ - null, - "/home/eos/data" - ], - "title": "Data Folder Path" - }, - "data_output_subpath": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "default": "output", - "description": "Sub-path for the EOS output data directory.", - "title": "Data Output Subpath" - }, - "latitude": { - "anyOf": [ - { - "maximum": 90.0, - "minimum": -90.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": 52.52, - "description": "Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (\u00b0)", - "title": "Latitude" - }, - "longitude": { - "anyOf": [ - { - "maximum": 180.0, - "minimum": -180.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": 13.405, - "description": "Longitude in decimal degrees, within -180 to 180 (\u00b0)", - "title": "Longitude" - } - }, - "title": "GeneralSettings", - "type": "object" - }, - "GeneralSettings-Output": { - "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files and system location (latitude\nand longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", - "properties": { - "config_file_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Path to EOS configuration file.", - "readOnly": true, - "title": "Config File Path" - }, - "config_folder_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Path to EOS configuration directory.", - "readOnly": true, - "title": "Config Folder Path" - }, - "data_cache_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Compute data_cache_path based on data_folder_path.", - "readOnly": true, - "title": "Data Cache Path" - }, - "data_cache_subpath": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "default": "cache", - "description": "Sub-path for the EOS cache data directory.", - "title": "Data Cache Subpath" - }, - "data_folder_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Path to EOS data directory.", - "examples": [ - null, - "/home/eos/data" - ], - "title": "Data Folder Path" - }, - "data_output_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Compute data_output_path based on data_folder_path.", - "readOnly": true, - "title": "Data Output Path" - }, - "data_output_subpath": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "default": "output", - "description": "Sub-path for the EOS output data directory.", - "title": "Data Output Subpath" - }, - "latitude": { - "anyOf": [ - { - "maximum": 90.0, - "minimum": -90.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": 52.52, - "description": "Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (\u00b0)", - "title": "Latitude" - }, - "longitude": { - "anyOf": [ - { - "maximum": 180.0, - "minimum": -180.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": 13.405, - "description": "Longitude in decimal degrees, within -180 to 180 (\u00b0)", - "title": "Longitude" - }, - "timezone": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Compute timezone based on latitude and longitude.", - "readOnly": true, - "title": "Timezone" - } - }, - "required": [ - "timezone", - "data_output_path", - "data_cache_path", - "config_folder_path", - "config_file_path" - ], - "title": "GeneralSettings", - "type": "object" - }, "ConfigEOS": { "additionalProperties": false, "description": "Singleton configuration handler for the EOS application.\n\nConfigEOS extends `SettingsEOS` with support for default configuration paths and automatic\ninitialization.\n\n`ConfigEOS` ensures that only one instance of the class is created throughout the application,\nallowing consistent access to EOS configuration settings. This singleton instance loads\nconfiguration data from a predefined set of directories or creates a default configuration if\nnone is found.\n\nInitialization Process:\n - Upon instantiation, the singleton instance attempts to load a configuration file in this order:\n 1. The directory specified by the `EOS_CONFIG_DIR` environment variable\n 2. The directory specified by the `EOS_DIR` environment variable.\n 3. A platform specific default directory for EOS.\n 4. The current working directory.\n - The first available configuration file found in these directories is loaded.\n - If no configuration file is found, a default configuration file is created in the platform\n specific default directory, and default settings are loaded into it.\n\nAttributes from the loaded configuration are accessible directly as instance attributes of\n`ConfigEOS`, providing a centralized, shared configuration object for EOS.\n\nSingleton Behavior:\n - This class uses the `SingletonMixin` to ensure that all requests for `ConfigEOS` return\n the same instance, which contains the most up-to-date configuration. Modifying the configuration\n in one part of the application reflects across all references to this class.\n\nAttributes:\n config_folder_path (Optional[Path]): Path to the configuration directory.\n config_file_path (Optional[Path]): Path to the configuration file.\n\nRaises:\n FileNotFoundError: If no configuration file is found, and creating a default configuration fails.\n\nExample:\n To initialize and access configuration attributes (only one instance is created):\n ```python\n config_eos = ConfigEOS() # Always returns the same instance\n print(config_eos.prediction.hours) # Access a setting from the loaded configuration\n ```", @@ -877,6 +637,246 @@ "title": "ForecastResponse", "type": "object" }, + "GeneralSettings-Input": { + "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files and system location (latitude\nand longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", + "properties": { + "data_cache_subpath": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": "cache", + "description": "Sub-path for the EOS cache data directory.", + "title": "Data Cache Subpath" + }, + "data_folder_path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Path to EOS data directory.", + "examples": [ + null, + "/home/eos/data" + ], + "title": "Data Folder Path" + }, + "data_output_subpath": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": "output", + "description": "Sub-path for the EOS output data directory.", + "title": "Data Output Subpath" + }, + "latitude": { + "anyOf": [ + { + "maximum": 90.0, + "minimum": -90.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": 52.52, + "description": "Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (\u00b0)", + "title": "Latitude" + }, + "longitude": { + "anyOf": [ + { + "maximum": 180.0, + "minimum": -180.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": 13.405, + "description": "Longitude in decimal degrees, within -180 to 180 (\u00b0)", + "title": "Longitude" + } + }, + "title": "GeneralSettings", + "type": "object" + }, + "GeneralSettings-Output": { + "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files and system location (latitude\nand longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", + "properties": { + "config_file_path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Path to EOS configuration file.", + "readOnly": true, + "title": "Config File Path" + }, + "config_folder_path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Path to EOS configuration directory.", + "readOnly": true, + "title": "Config Folder Path" + }, + "data_cache_path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Compute data_cache_path based on data_folder_path.", + "readOnly": true, + "title": "Data Cache Path" + }, + "data_cache_subpath": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": "cache", + "description": "Sub-path for the EOS cache data directory.", + "title": "Data Cache Subpath" + }, + "data_folder_path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Path to EOS data directory.", + "examples": [ + null, + "/home/eos/data" + ], + "title": "Data Folder Path" + }, + "data_output_path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Compute data_output_path based on data_folder_path.", + "readOnly": true, + "title": "Data Output Path" + }, + "data_output_subpath": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": "output", + "description": "Sub-path for the EOS output data directory.", + "title": "Data Output Subpath" + }, + "latitude": { + "anyOf": [ + { + "maximum": 90.0, + "minimum": -90.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": 52.52, + "description": "Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (\u00b0)", + "title": "Latitude" + }, + "longitude": { + "anyOf": [ + { + "maximum": 180.0, + "minimum": -180.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": 13.405, + "description": "Longitude in decimal degrees, within -180 to 180 (\u00b0)", + "title": "Longitude" + }, + "timezone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Compute timezone based on latitude and longitude.", + "readOnly": true, + "title": "Timezone" + } + }, + "required": [ + "timezone", + "data_output_path", + "data_cache_path", + "config_folder_path", + "config_file_path" + ], + "title": "GeneralSettings", + "type": "object" + }, "GesamtlastRequest": { "properties": { "hours": { @@ -3145,9 +3145,9 @@ } }, "/v1/config/reset": { - "put": { + "post": { "description": "Reset the configuration to the EOS configuration file.\n\nReturns:\n configuration (ConfigEOS): The current configuration after update.", - "operationId": "fastapi_config_update_post_v1_config_reset_put", + "operationId": "fastapi_config_update_post_v1_config_reset_post", "responses": { "200": { "content": { @@ -3166,6 +3166,23 @@ ] } }, + "/v1/health": { + "get": { + "description": "Health check endpoint to verify that the server is alive.", + "operationId": "fastapi_health_get_v1_health_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Fastapi Health Get" + } + }, "/v1/measurement/data": { "put": { "description": "Merge the measurement data given as datetime data into EOS measurements.", diff --git a/requirements.txt b/requirements.txt index 8038da71..6dde9af8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,9 @@ numpydantic==1.6.4 matplotlib==3.10.0 fastapi[standard]==0.115.6 python-fasthtml==0.12.0 +MonsterUI==0.0.29 +markdown-it-py==3.0.0 +mdit-py-plugins==0.4.2 uvicorn==0.34.0 scikit-learn==1.6.1 timezonefinder==6.5.7 diff --git a/src/akkudoktoreos/server/dash/__init__.py b/src/akkudoktoreos/server/dash/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/akkudoktoreos/server/dash/components.py b/src/akkudoktoreos/server/dash/components.py new file mode 100644 index 00000000..638b88b5 --- /dev/null +++ b/src/akkudoktoreos/server/dash/components.py @@ -0,0 +1,170 @@ +from typing import Any, Optional, Union + +from fasthtml.common import H1, Div, Li +from monsterui.daisy import Loading + +# from mdit_py_plugins import plugin1, plugin2 +from monsterui.foundations import stringify +from monsterui.franken import Button, ButtonT, Card, Container, ContainerT, TabContainer + +scrollbar_viewport_styles = ( + "scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch;" +) + +scrollbar_cls = "flex touch-none select-none transition-colors p-[1px]" + + +def ScrollArea( + *c: Any, cls: Optional[Union[str, tuple]] = None, orientation: str = "vertical", **kwargs: Any +) -> Div: + """Creates a styled scroll area. + + Args: + orientation (str): The orientation of the scroll area. Defaults to vertical. + """ + new_cls = "relative overflow-hidden" + if cls: + new_cls += f" {stringify(cls)}" + kwargs["cls"] = new_cls + + content = Div( + Div(*c, style="min-width:100%;display:table;"), + style=f"overflow: {'hidden scroll' if orientation == 'vertical' else 'scroll'}; {scrollbar_viewport_styles}", + cls="w-full h-full rounded-[inherit]", + data_ref="viewport", + ) + + scrollbar = Div( + Div(cls="bg-border rounded-full hidden relative flex-1", data_ref="thumb"), + cls=f"{scrollbar_cls} flex-col h-2.5 w-full border-t border-t-transparent" + if orientation == "horizontal" + else f"{scrollbar_cls} w-2.5 h-full border-l border-l-transparent", + data_ref="scrollbar", + style=f"position: absolute;{'right:0; top:0;' if orientation == 'vertical' else 'bottom:0; left:0;'}", + ) + + return Div( + content, + scrollbar, + role="region", + tabindex="0", + data_orientation=orientation, + data_ref_scrollarea=True, + aria_label="Scrollable content", + **kwargs, + ) + + +def DashboardHeader(title: Optional[str]) -> Div: + """Creates a styled header with a title. + + Args: + title (Optional[str]): The title text for the header. + + Returns: + Div: A styled `Div` element containing the header. + """ + if title is None: + return Div("", cls="header") + return Div(H1(title, cls="text-2xl font-bold mb-4"), cls="header") + + +def DashboardFooter(path: str) -> Card: + """Creates a styled footer with the provided information. + + The footer content is reloaded every 5 seconds from path. + + Args: + path (str): Path to reload footer content from + + Returns: + Card: A styled `Card` element containing the footer. + """ + return Card( + Container(Loading(), id="footer-content"), + hx_get=f"{path}", + hx_trigger="every 5s", + hx_target="#footer-content", + hx_swap="innerHTML", + ) + + +def DashboardTrigger(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Button: + """Creates a styled button for the dashboard trigger. + + Args: + *c: Positional arguments to pass to the button. + cls (Optional[str]): Additional CSS classes for styling. Defaults to None. + **kwargs: Additional keyword arguments for the button. + + Returns: + Button: A styled `Button` component. + """ + new_cls = f"{ButtonT.primary}" + if cls: + new_cls += f" {stringify(cls)}" + kwargs["cls"] = new_cls + return Button(*c, submit=False, **kwargs) + + +def DashboardTabs(dashboard_items: dict[str, str]) -> Card: + """Creates a dashboard tab with dynamic dashboard items. + + Args: + dashboard_items (dict[str, str]): A dictionary of dashboard items where keys are item names + and values are paths for navigation. + + Returns: + Card: A styled `Card` component containing the dashboard tabs. + """ + dash_items = [ + Li( + DashboardTrigger( + menu, + hx_get=f"{path}", + hx_target="#page-content", + hx_swap="innerHTML", + ), + ) + for menu, path in dashboard_items.items() + ] + return Card(TabContainer(*dash_items, cls="gap-4"), alt=True) + + +def DashboardContent(content: Any) -> Card: + """Creates a content section within a styled card. + + Args: + content (Any): The content to display. + + Returns: + Card: A styled `Card` element containing the content. + """ + return Card(ScrollArea(Container(content, id="page-content"), cls="h-[75vh] w-full rounded-md")) + + +def Page( + title: Optional[str], + dashboard_items: dict[str, str], + content: Any, + footer_path: str, +) -> Div: + """Generates a full-page layout with a header, dashboard items, content, and footer. + + Args: + title (Optional[str]): The page title. + dashboard_items (dict[str, str]): A dictionary of dashboard items. + content (Any): The main content for the page. + footer_content (Any): Footer content. + footer_path (Any): Path to reload footer content from. + + Returns: + Div: A `Div` element representing the entire page layout. + """ + return Container( + DashboardHeader(title), + DashboardTabs(dashboard_items), + DashboardContent(content), + DashboardFooter(footer_path), + cls=("bg-background text-foreground w-screen p-4 space-y-4", ContainerT.xl), + ) diff --git a/src/akkudoktoreos/server/dash/configuration.py b/src/akkudoktoreos/server/dash/configuration.py new file mode 100644 index 00000000..0fbb7753 --- /dev/null +++ b/src/akkudoktoreos/server/dash/configuration.py @@ -0,0 +1,245 @@ +import json +from http import HTTPStatus +from typing import Any, Dict, List, Optional, Sequence, TypeVar, Union + +import requests +from monsterui.franken import Table, Tbody, Td, Th, Thead, Tr +from pydantic.fields import ComputedFieldInfo, FieldInfo +from pydantic_core import PydanticUndefined +from requests.exceptions import RequestException + +from akkudoktoreos.config.config import get_config +from akkudoktoreos.core.logging import get_logger +from akkudoktoreos.core.pydantic import PydanticBaseModel + +logger = get_logger(__name__) +config_eos = get_config() + +T = TypeVar("T") + + +def get_nested_value( + dictionary: Union[Dict[str, Any], List[Any]], + keys: Sequence[Union[str, int]], + default: Optional[T] = None, +) -> Union[Any, T]: + """Retrieve a nested value from a dictionary or list using a sequence of keys. + + Args: + dictionary (Union[Dict[str, Any], List[Any]]): The nested dictionary or list to search. + keys (Sequence[Union[str, int]]): A sequence of keys or indices representing the path to the desired value. + default (Optional[T]): A value to return if the path is not found. + + Returns: + Union[Any, T]: The value at the specified nested path, or the default value if not found. + + Raises: + TypeError: If the input is not a dictionary or list, or if keys are not a sequence. + KeyError: If a key is not found in a dictionary. + IndexError: If an index is out of range in a list. + """ + if not isinstance(dictionary, (dict, list)): + raise TypeError("The first argument must be a dictionary or list") + if not isinstance(keys, Sequence): + raise TypeError("Keys must be provided as a sequence (e.g., list, tuple)") + + if not keys: + return dictionary + + try: + # Traverse the structure + current = dictionary + for key in keys: + if isinstance(current, dict) and isinstance(key, str): + current = current[key] + elif isinstance(current, list) and isinstance(key, int): + current = current[key] + else: + raise KeyError(f"Invalid key or index: {key}") + return current + except (KeyError, IndexError, TypeError): + return default + + +def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_field: bool) -> Any: + """Retrieve the default value of a field. + + Args: + field_info (Union[FieldInfo, ComputedFieldInfo]): The field metadata from Pydantic. + regular_field (bool): Indicates if the field is a regular field. + + Returns: + Any: The default value of the field or "N/A" if not a regular field. + """ + 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]]]: + """Resolve nested types within a field and return their structure. + + Args: + field_type (Any): The type of the field to resolve. + parent_types (List[str]): A list of parent type names. + + Returns: + List[tuple[Any, List[str]]]: A list of tuples containing resolved types and their parent hierarchy. + """ + 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 + + +def configuration(values: dict) -> list[dict]: + """Generate configuration details based on provided values and model metadata. + + Args: + values (dict): A dictionary containing the current configuration values. + + Returns: + List[dict]: A sorted list of configuration details, each represented as a dictionary. + """ + 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) + config["value"] = str(get_nested_value(values, parent_types, "")) + config["default"] = str(get_default_value(subfield_info, regular_field)) + config["description"] = ( + subfield_info.description if subfield_info.description else "" + ) + if isinstance(subfield_info, ComputedFieldInfo): + config["read-only"] = "ro" + type_description = str(subfield_info.return_type) + else: + config["read-only"] = "rw" + type_description = str(subfield_info.annotation) + config["type"] = ( + type_description.replace("typing.", "") + .replace("pathlib.", "") + .replace("[", "[ ") + .replace("NoneType", "None") + ) + 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]) + return sorted(configs, key=lambda x: x["name"]) + + +def get_configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> list[dict]: + """Fetch and process configuration data from the specified EOS server. + + Args: + eos_host (Optional[str]): The hostname of the server. + eos_port (Optional[Union[str, int]]): The port of the server. + + Returns: + List[dict]: A list of processed configuration entries. + """ + if eos_host is None: + eos_host = config_eos.server.host + if eos_port is None: + eos_port = config_eos.server.port + + result = requests.Response() + try: + result = requests.get(f"http://{eos_host}:{eos_port}/v1/config") + except RequestException as e: + result.status_code = HTTPStatus.SERVICE_UNAVAILABLE + warning_msg = f"{e}" + logger.warning(warning_msg) + + config_values = {} + if result.status_code == HTTPStatus.OK: + config_values = json.loads(result.content) + + return configuration(config_values) + + +def Configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> Table: + """Create a visual representation of the configuration. + + Args: + eos_host (Optional[str]): The hostname of the EOS server. + eos_port (Optional[Union[str, int]]): The port of the EOS server. + + Returns: + Table: A `monsterui.franken.Table` component displaying configuration details. + """ + flds = "Name", "Type", "RO/RW", "Value", "Default", "Description" + rows = [ + Tr( + Td( + config["name"], + cls="max-w-64 text-wrap break-all", + ), + Td( + config["type"], + cls="max-w-48 text-wrap break-all", + ), + Td( + config["read-only"], + cls="max-w-24 text-wrap break-all", + ), + Td( + config["value"], + cls="max-w-md text-wrap break-all", + ), + Td(config["default"], cls="max-w-48 text-wrap break-all"), + Td( + config["description"], + cls="max-w-prose text-wrap", + ), + cls="", + ) + for config in get_configuration(eos_host, eos_port) + ] + head = Thead(*map(Th, flds), cls="text-left") + return Table(head, Tbody(*rows), cls="w-full uk-table uk-table-divider uk-table-striped") diff --git a/src/akkudoktoreos/server/dash/demo.py b/src/akkudoktoreos/server/dash/demo.py new file mode 100644 index 00000000..8fd02e84 --- /dev/null +++ b/src/akkudoktoreos/server/dash/demo.py @@ -0,0 +1,8 @@ +from fasthtml.common import P + + +def Demo() -> str: + return P( + "Hello, I am the Demo!", + cls="text-center", + ) diff --git a/src/akkudoktoreos/server/dash/footer.py b/src/akkudoktoreos/server/dash/footer.py new file mode 100644 index 00000000..d13d604d --- /dev/null +++ b/src/akkudoktoreos/server/dash/footer.py @@ -0,0 +1,55 @@ +from typing import Optional, Union + +import requests +from fasthtml.common import P +from requests.exceptions import RequestException + +from akkudoktoreos.config.config import get_config +from akkudoktoreos.core.logging import get_logger + +logger = get_logger(__name__) +config_eos = get_config() + +update_count = 0 + + +def get_alive(eos_host: str, eos_port: Union[str, int]) -> str: + """Fetch alive information from the specified EOS server. + + Args: + eos_host (str): The hostname of the server. + eos_port (Union[str, int]): The port of the server. + + Returns: + str: Alive data. + """ + result = requests.Response() + try: + result = requests.get(f"http://{eos_host}:{eos_port}/v1/health") + if result.status_code == 200: + alive = result.json()["status"] + else: + alive = f"Server responded with status code: {result.status_code}" + except RequestException as e: + warning_msg = f"{e}" + logger.warning(warning_msg) + alive = warning_msg + + return alive + + +def Footer(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> str: + global update_count + if eos_host is None: + eos_host = config_eos.server.host + if eos_port is None: + eos_port = config_eos.server.port + if eos_host is None or eos_port is None: + alive = "EOS fserver not given: {eos_host}:{eos_port}" + else: + alive = get_alive(eos_host, eos_port) + update_count += 1 + return P( + f"EOS server {eos_host}:{eos_port} status #{update_count}: {alive}", + cls="text-center", + ) diff --git a/src/akkudoktoreos/server/dash/hello.py b/src/akkudoktoreos/server/dash/hello.py new file mode 100644 index 00000000..62955c9b --- /dev/null +++ b/src/akkudoktoreos/server/dash/hello.py @@ -0,0 +1,22 @@ +from typing import Any + +from fasthtml.common import Div + +from akkudoktoreos.server.dash.markdown import Markdown + +hello_md = """# Akkudoktor EOSdash + +The dashboard for Akkudoktor EOS. + +EOS provides a comprehensive solution for simulating and optimizing an energy system based +on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), +load management (consumer requirements), heat pumps, electric vehicles, and consideration of +electricity price data, this system enables forecasting and optimization of energy flow and costs +over a specified period. + +Documentation can be found at [Akkudoktor-EOS](https://akkudoktor-eos.readthedocs.io/en/latest/). +""" + + +def Hello(**kwargs: Any) -> Div: + return Markdown(hello_md, **kwargs) diff --git a/src/akkudoktoreos/server/dash/markdown.py b/src/akkudoktoreos/server/dash/markdown.py new file mode 100644 index 00000000..24b66b3d --- /dev/null +++ b/src/akkudoktoreos/server/dash/markdown.py @@ -0,0 +1,136 @@ +"""Markdown rendering with MonsterUI HTML classes.""" + +from typing import Any, List, Optional, Union + +from fasthtml.common import FT, Div, NotStr +from markdown_it import MarkdownIt +from markdown_it.renderer import RendererHTML +from markdown_it.token import Token +from monsterui.foundations import stringify + + +def render_heading( + self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict +) -> str: + """Custom renderer for Markdown headings. + + Adds specific CSS classes based on the heading level. + + Parameters: + self: The renderer instance. + tokens: List of tokens to be rendered. + idx: Index of the current token. + options: Rendering options. + env: Environment sandbox for plugins. + + Returns: + The rendered token as a string. + """ + if tokens[idx].markup == "#": + tokens[idx].attrSet("class", "uk-heading-divider uk-h1 uk-margin") + elif tokens[idx].markup == "##": + tokens[idx].attrSet("class", "uk-heading-divider uk-h2 uk-margin") + elif tokens[idx].markup == "###": + tokens[idx].attrSet("class", "uk-heading-divider uk-h3 uk-margin") + elif tokens[idx].markup == "####": + tokens[idx].attrSet("class", "uk-heading-divider uk-h4 uk-margin") + + # pass token to default renderer. + return self.renderToken(tokens, idx, options, env) + + +def render_paragraph( + self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict +) -> str: + """Custom renderer for Markdown paragraphs. + + Adds specific CSS classes. + + Parameters: + self: The renderer instance. + tokens: List of tokens to be rendered. + idx: Index of the current token. + options: Rendering options. + env: Environment sandbox for plugins. + + Returns: + The rendered token as a string. + """ + tokens[idx].attrSet("class", "uk-paragraph") + + # pass token to default renderer. + return self.renderToken(tokens, idx, options, env) + + +def render_blockquote( + self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict +) -> str: + """Custom renderer for Markdown blockquotes. + + Adds specific CSS classes. + + Parameters: + self: The renderer instance. + tokens: List of tokens to be rendered. + idx: Index of the current token. + options: Rendering options. + env: Environment sandbox for plugins. + + Returns: + The rendered token as a string. + """ + tokens[idx].attrSet("class", "uk-blockquote") + + # pass token to default renderer. + return self.renderToken(tokens, idx, options, env) + + +def render_link(self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict) -> str: + """Custom renderer for Markdown links. + + Adds the target attribute to open links in a new tab. + + Parameters: + self: The renderer instance. + tokens: List of tokens to be rendered. + idx: Index of the current token. + options: Rendering options. + env: Environment sandbox for plugins. + + Returns: + The rendered token as a string. + """ + tokens[idx].attrSet("class", "uk-link") + tokens[idx].attrSet("target", "_blank") + + # pass token to default renderer. + return self.renderToken(tokens, idx, options, env) + + +markdown = MarkdownIt("gfm-like") +markdown.add_render_rule("heading_open", render_heading) +markdown.add_render_rule("paragraph_open", render_paragraph) +markdown.add_render_rule("blockquote_open", render_blockquote) +markdown.add_render_rule("link_open", render_link) + + +markdown_cls = "bg-background text-lg ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" + + +def Markdown(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> FT: + """Component to render Markdown content with custom styling. + + Parameters: + c: Markdown content to be rendered. + cls: Optional additional CSS classes to be added. + kwargs: Additional keyword arguments for the Div component. + + Returns: + An FT object representing the rendered HTML content wrapped in a Div component. + """ + new_cls = markdown_cls + if cls: + new_cls += f" {stringify(cls)}" + kwargs["cls"] = new_cls + md_html = markdown.render(*c) + return Div(NotStr(md_html), **kwargs) diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index 73fb2d6e..e96c6911 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -10,7 +10,7 @@ import httpx import uvicorn -from fastapi import FastAPI, Query, Request +from fastapi import FastAPI, Query, Request, status from fastapi.exceptions import HTTPException from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response @@ -34,6 +34,7 @@ from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings from akkudoktoreos.prediction.prediction import PredictionCommonSettings, get_prediction from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings +from akkudoktoreos.server.rest.error import create_error_page from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration logger = get_logger(__name__) @@ -45,98 +46,6 @@ # Command line arguments args = None -ERROR_PAGE_TEMPLATE = """ - - - - - - Energy Optimization System (EOS) Error - - - -
-

STATUS_CODE

-

ERROR_TITLE

-

ERROR_MESSAGE

-
ERROR_DETAILS
- Back to Home -
- - -""" - - -def create_error_page( - status_code: str, error_title: str, error_message: str, error_details: str -) -> str: - """Create an error page by replacing placeholders in the template.""" - return ( - ERROR_PAGE_TEMPLATE.replace("STATUS_CODE", status_code) - .replace("ERROR_TITLE", error_title) - .replace("ERROR_MESSAGE", error_message) - .replace("ERROR_DETAILS", error_details) - ) - # ---------------------- # EOSdash server startup @@ -230,14 +139,18 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: root_path=str(Path(__file__).parent), ) -server_dir = Path(__file__).parent.resolve() - class PdfResponse(FileResponse): media_type = "application/pdf" -@app.put("/v1/config/reset", tags=["config"]) +@app.get("/v1/health", status_code=status.HTTP_200_OK) +async def fastapi_health_get(): # type: ignore + """Health check endpoint to verify that the server is alive.""" + return {"status": "alive"} + + +@app.post("/v1/config/reset", tags=["config"]) def fastapi_config_update_post() -> ConfigEOS: """Reset the configuration to the EOS configuration file. diff --git a/src/akkudoktoreos/server/eosdash.py b/src/akkudoktoreos/server/eosdash.py index de332bb0..f5067871 100644 --- a/src/akkudoktoreos/server/eosdash.py +++ b/src/akkudoktoreos/server/eosdash.py @@ -1,124 +1,104 @@ import argparse import os import sys -from functools import reduce -from typing import Any, Union +from typing import Optional import uvicorn -from fasthtml.common import H1, Table, Td, Th, Thead, Titled, Tr, fast_app -from pydantic.fields import ComputedFieldInfo, FieldInfo -from pydantic_core import PydanticUndefined +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.components import Page -logger = get_logger(__name__) +# 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 +logger = get_logger(__name__) config_eos = get_config() # Command line arguments -args = None +args: Optional[argparse.Namespace] = None +# The EOSdash application +app: FastHTML = FastHTML( + title="EOSdash", + hdrs=Theme.green.headers(highlightjs=True), + secret_key=os.getenv("EOS_SERVER__EOSDASH_SESSKEY"), +) -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 +@app.get("/") +def get_eosdash(): # type: ignore + """Serves the main EOSdash page. -def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple[Any, list[str]]]: - resolved_types: list[tuple[Any, list[str]]] = [] + Returns: + Page: The main dashboard page with navigation links and footer. + """ + return Page( + None, + { + "EOSdash": "/eosdash/hello", + "Config": "/eosdash/configuration", + "Demo": "/eosdash/demo", + }, + Hello(), + "/eosdash/footer", + ) - 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)) + +@app.get("/eosdash/footer") +def get_eosdash_footer(): # type: ignore + """Serves the EOSdash Foooter information. + + Returns: + Footer: The Footer component. + """ + if args is None: + eos_host = None + eos_port = None 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( - secret_key=os.getenv("EOS_SERVER__EOSDASH_SESSKEY"), -) + eos_host = args.eos_host + eos_port = args.eos_port + return Footer(eos_host, eos_port) -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") +@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. + """ + if args is None: + eos_host = None + eos_port = None + else: + eos_host = args.eos_host + eos_port = args.eos_port + return Configuration(eos_host, eos_port) -@rt("/") -def get(): # type: ignore - return Titled("EOS Dashboard", H1("Configuration"), config_table()) +@app.get("/eosdash/demo") +def get_eosdash_demo(): # type: ignore + """Serves the EOSdash Demo page. + + Returns: + Demo: The Demo page component. + """ + return Demo() def run_eosdash(host: str, port: int, log_level: str, access_log: bool, reload: bool) -> None: @@ -131,16 +111,16 @@ 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. - Parameters: - 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. + 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 + None """ # Make hostname Windows friendly if host == "0.0.0.0" and os.name == "nt": @@ -150,7 +130,7 @@ def run_eosdash(host: str, port: int, log_level: str, access_log: bool, reload: "akkudoktoreos.server.eosdash:app", host=host, port=port, - log_level=log_level.lower(), # Convert log_level to lowercase + log_level=log_level.lower(), access_log=access_log, reload=reload, ) @@ -164,7 +144,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: @@ -178,7 +158,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, @@ -191,8 +170,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, @@ -205,8 +182,6 @@ def main() -> None: default=config_eos.server.port, help="Port for the EOS server (default: value from config)", ) - - # Optional arguments for log_level, access_log, and reload parser.add_argument( "--log_level", type=str, @@ -217,7 +192,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", @@ -226,6 +201,7 @@ 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: diff --git a/src/akkudoktoreos/server/rest/error.py b/src/akkudoktoreos/server/rest/error.py new file mode 100644 index 00000000..dd8d9ee7 --- /dev/null +++ b/src/akkudoktoreos/server/rest/error.py @@ -0,0 +1,91 @@ +ERROR_PAGE_TEMPLATE = """ + + + + + + Energy Optimization System (EOS) Error + + + +
+

STATUS_CODE

+

ERROR_TITLE

+

ERROR_MESSAGE

+
ERROR_DETAILS
+ Back to Home +
+ + +""" + + +def create_error_page( + status_code: str, error_title: str, error_message: str, error_details: str +) -> str: + """Create an error page by replacing placeholders in the template.""" + return ( + ERROR_PAGE_TEMPLATE.replace("STATUS_CODE", status_code) + .replace("ERROR_TITLE", error_title) + .replace("ERROR_MESSAGE", error_message) + .replace("ERROR_DETAILS", error_details) + )