mirror of
				https://github.com/Akkudoktor-EOS/EOS.git
				synced 2025-10-31 14:56:21 +00:00 
			
		
		
		
	EOSdash: Enable EOS configuration by EOSdash. (#477)
Improve config page to edit actual configuration used by EOS. Add admin page to save the actual configuration to the configuration file. Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
		| @@ -430,7 +430,13 @@ Returns: | ||||
| **Request Body**: | ||||
|  | ||||
| - `application/json`: { | ||||
|   "description": "The value to assign to the specified configuration path.", | ||||
|   "anyOf": [ | ||||
|     {}, | ||||
|     { | ||||
|       "type": "null" | ||||
|     } | ||||
|   ], | ||||
|   "description": "The value to assign to the specified configuration path (can be None).", | ||||
|   "title": "Value" | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										11
									
								
								openapi.json
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								openapi.json
									
									
									
									
									
								
							| @@ -3453,12 +3453,17 @@ | ||||
|                     "content": { | ||||
|                         "application/json": { | ||||
|                             "schema": { | ||||
|                                 "description": "The value to assign to the specified configuration path.", | ||||
|                                 "anyOf": [ | ||||
|                                     {}, | ||||
|                                     { | ||||
|                                         "type": "null" | ||||
|                                     } | ||||
|                                 ], | ||||
|                                 "description": "The value to assign to the specified configuration path (can be None).", | ||||
|                                 "title": "Value" | ||||
|                             } | ||||
|                         } | ||||
|                     }, | ||||
|                     "required": true | ||||
|                     } | ||||
|                 }, | ||||
|                 "responses": { | ||||
|                     "200": { | ||||
|   | ||||
| @@ -529,7 +529,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): | ||||
|         if not self.general.config_file_path: | ||||
|             raise ValueError("Configuration file path unknown.") | ||||
|         with self.general.config_file_path.open("w", encoding="utf-8", newline="\n") as f_out: | ||||
|             json_str = super().model_dump_json() | ||||
|             json_str = super().model_dump_json(indent=4) | ||||
|             f_out.write(json_str) | ||||
|  | ||||
|     def update(self) -> None: | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import traceback | ||||
| from typing import Any, ClassVar, Optional | ||||
|  | ||||
| import numpy as np | ||||
| @@ -305,12 +306,13 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas | ||||
|         if EnergyManagement._last_datetime is None: | ||||
|             # Never run before | ||||
|             try: | ||||
|                 # Try to run a first energy management. May fail due to config incomplete. | ||||
|                 self.run() | ||||
|                 # Remember energy run datetime. | ||||
|                 EnergyManagement._last_datetime = current_datetime | ||||
|                 # Try to run a first energy management. May fail due to config incomplete. | ||||
|                 self.run() | ||||
|             except Exception as e: | ||||
|                 message = f"EOS init: {e}" | ||||
|                 trace = "".join(traceback.TracebackException.from_exception(e).format()) | ||||
|                 message = f"EOS init: {e}\n{trace}" | ||||
|                 logger.error(message) | ||||
|             return | ||||
|  | ||||
| @@ -328,7 +330,8 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas | ||||
|         try: | ||||
|             self.run() | ||||
|         except Exception as e: | ||||
|             message = f"EOS run: {e}" | ||||
|             trace = "".join(traceback.TracebackException.from_exception(e).format()) | ||||
|             message = f"EOS run: {e}\n{trace}" | ||||
|             logger.error(message) | ||||
|  | ||||
|         # Remember the energy management run - keep on interval even if we missed some intervals | ||||
|   | ||||
| @@ -1,9 +1,20 @@ | ||||
| from typing import Optional | ||||
|  | ||||
| from pydantic import Field | ||||
| from pydantic import Field, field_validator | ||||
|  | ||||
| from akkudoktoreos.config.configabc import SettingsBaseModel | ||||
| from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider | ||||
| from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings | ||||
| from akkudoktoreos.prediction.prediction import get_prediction | ||||
|  | ||||
| prediction_eos = get_prediction() | ||||
|  | ||||
| # Valid elecprice providers | ||||
| elecprice_providers = [ | ||||
|     provider.provider_id() | ||||
|     for provider in prediction_eos.providers | ||||
|     if isinstance(provider, ElecPriceProvider) | ||||
| ] | ||||
|  | ||||
|  | ||||
| class ElecPriceCommonSettings(SettingsBaseModel): | ||||
| @@ -21,3 +32,13 @@ class ElecPriceCommonSettings(SettingsBaseModel): | ||||
|     provider_settings: Optional[ElecPriceImportCommonSettings] = Field( | ||||
|         default=None, description="Provider settings", examples=[None] | ||||
|     ) | ||||
|  | ||||
|     # Validators | ||||
|     @field_validator("provider", mode="after") | ||||
|     @classmethod | ||||
|     def validate_provider(cls, value: Optional[str]) -> Optional[str]: | ||||
|         if value is None or value in elecprice_providers: | ||||
|             return value | ||||
|         raise ValueError( | ||||
|             f"Provider '{value}' is not a valid electricity price provider: {elecprice_providers}." | ||||
|         ) | ||||
|   | ||||
| @@ -2,14 +2,24 @@ | ||||
|  | ||||
| from typing import Optional, Union | ||||
|  | ||||
| from pydantic import Field | ||||
| from pydantic import Field, field_validator | ||||
|  | ||||
| from akkudoktoreos.config.configabc import SettingsBaseModel | ||||
| from akkudoktoreos.core.logging import get_logger | ||||
| from akkudoktoreos.prediction.loadabc import LoadProvider | ||||
| from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings | ||||
| from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings | ||||
| from akkudoktoreos.prediction.prediction import get_prediction | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
| prediction_eos = get_prediction() | ||||
|  | ||||
| # Valid load providers | ||||
| load_providers = [ | ||||
|     provider.provider_id() | ||||
|     for provider in prediction_eos.providers | ||||
|     if isinstance(provider, LoadProvider) | ||||
| ] | ||||
|  | ||||
|  | ||||
| class LoadCommonSettings(SettingsBaseModel): | ||||
| @@ -24,3 +34,11 @@ class LoadCommonSettings(SettingsBaseModel): | ||||
|     provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = ( | ||||
|         Field(default=None, description="Provider settings", examples=[None]) | ||||
|     ) | ||||
|  | ||||
|     # Validators | ||||
|     @field_validator("provider", mode="after") | ||||
|     @classmethod | ||||
|     def validate_provider(cls, value: Optional[str]) -> Optional[str]: | ||||
|         if value is None or value in load_providers: | ||||
|             return value | ||||
|         raise ValueError(f"Provider '{value}' is not a valid load provider: {load_providers}.") | ||||
|   | ||||
| @@ -6,10 +6,20 @@ from pydantic import Field, computed_field, field_validator, model_validator | ||||
|  | ||||
| from akkudoktoreos.config.configabc import SettingsBaseModel | ||||
| from akkudoktoreos.core.logging import get_logger | ||||
| from akkudoktoreos.prediction.prediction import get_prediction | ||||
| from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider | ||||
| from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings | ||||
| from akkudoktoreos.utils.docs import get_model_structure_from_examples | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
| prediction_eos = get_prediction() | ||||
|  | ||||
| # Valid PV forecast providers | ||||
| pvforecast_providers = [ | ||||
|     provider.provider_id() | ||||
|     for provider in prediction_eos.providers | ||||
|     if isinstance(provider, PVForecastProvider) | ||||
| ] | ||||
|  | ||||
|  | ||||
| class PVForecastPlaneSetting(SettingsBaseModel): | ||||
| @@ -130,6 +140,16 @@ class PVForecastCommonSettings(SettingsBaseModel): | ||||
|  | ||||
|     max_planes: ClassVar[int] = 6  # Maximum number of planes that can be set | ||||
|  | ||||
|     # Validators | ||||
|     @field_validator("provider", mode="after") | ||||
|     @classmethod | ||||
|     def validate_provider(cls, value: Optional[str]) -> Optional[str]: | ||||
|         if value is None or value in pvforecast_providers: | ||||
|             return value | ||||
|         raise ValueError( | ||||
|             f"Provider '{value}' is not a valid PV forecast provider: {pvforecast_providers}." | ||||
|         ) | ||||
|  | ||||
|     @field_validator("planes") | ||||
|     def validate_planes( | ||||
|         cls, planes: Optional[list[PVForecastPlaneSetting]] | ||||
|   | ||||
| @@ -2,11 +2,22 @@ | ||||
|  | ||||
| from typing import Optional | ||||
|  | ||||
| from pydantic import Field | ||||
| from pydantic import Field, field_validator | ||||
|  | ||||
| from akkudoktoreos.config.configabc import SettingsBaseModel | ||||
| from akkudoktoreos.prediction.prediction import get_prediction | ||||
| from akkudoktoreos.prediction.weatherabc import WeatherProvider | ||||
| from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings | ||||
|  | ||||
| prediction_eos = get_prediction() | ||||
|  | ||||
| # Valid weather providers | ||||
| weather_providers = [ | ||||
|     provider.provider_id() | ||||
|     for provider in prediction_eos.providers | ||||
|     if isinstance(provider, WeatherProvider) | ||||
| ] | ||||
|  | ||||
|  | ||||
| class WeatherCommonSettings(SettingsBaseModel): | ||||
|     """Weather Forecast Configuration.""" | ||||
| @@ -20,3 +31,13 @@ class WeatherCommonSettings(SettingsBaseModel): | ||||
|     provider_settings: Optional[WeatherImportCommonSettings] = Field( | ||||
|         default=None, description="Provider settings", examples=[None] | ||||
|     ) | ||||
|  | ||||
|     # Validators | ||||
|     @field_validator("provider", mode="after") | ||||
|     @classmethod | ||||
|     def validate_provider(cls, value: Optional[str]) -> Optional[str]: | ||||
|         if value is None or value in weather_providers: | ||||
|             return value | ||||
|         raise ValueError( | ||||
|             f"Provider '{value}' is not a valid weather provider: {weather_providers}." | ||||
|         ) | ||||
|   | ||||
							
								
								
									
										127
									
								
								src/akkudoktoreos/server/dash/admin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								src/akkudoktoreos/server/dash/admin.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,127 @@ | ||||
| """Admin UI components for EOS Dashboard. | ||||
|  | ||||
| This module provides functions to generate administrative UI components | ||||
| for the EOS dashboard. | ||||
| """ | ||||
|  | ||||
| from typing import Any, Optional, Union | ||||
|  | ||||
| import requests | ||||
| from fasthtml.common import Div | ||||
| from monsterui.foundations import stringify | ||||
| from monsterui.franken import ( | ||||
|     Button, | ||||
|     ButtonT, | ||||
|     Card, | ||||
|     Details, | ||||
|     DivHStacked, | ||||
|     DividerLine, | ||||
|     Grid, | ||||
|     P, | ||||
|     Summary, | ||||
|     UkIcon, | ||||
| ) | ||||
|  | ||||
|  | ||||
| def AdminButton(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Button: | ||||
|     """Creates a styled button for administrative actions. | ||||
|  | ||||
|     Args: | ||||
|         *c (Any): Positional arguments representing the button's content. | ||||
|         cls (Optional[Union[str, tuple]]): Additional CSS classes for styling. Defaults to None. | ||||
|         **kwargs (Any): Additional keyword arguments passed to the `Button`. | ||||
|  | ||||
|     Returns: | ||||
|         Button: A styled `Button` component for admin actions. | ||||
|     """ | ||||
|     new_cls = f"{ButtonT.primary}" | ||||
|     if cls: | ||||
|         new_cls += f" {stringify(cls)}" | ||||
|     kwargs["cls"] = new_cls | ||||
|     return Button(*c, submit=False, **kwargs) | ||||
|  | ||||
|  | ||||
| def AdminConfig(eos_host: str, eos_port: Union[str, int], data: Optional[dict]) -> Card: | ||||
|     """Creates a configuration management card with save-to-file functionality. | ||||
|  | ||||
|     Args: | ||||
|         eos_host (str): The hostname of the EOS server. | ||||
|         eos_port (Union[str, int]): The port of the EOS server. | ||||
|         data (Optional[dict]): Incoming data containing action and category for processing. | ||||
|  | ||||
|     Returns: | ||||
|         tuple[str, Card]: A tuple containing the configuration category label and the `Card` UI component. | ||||
|     """ | ||||
|     server = f"http://{eos_host}:{eos_port}" | ||||
|  | ||||
|     category = "configuration" | ||||
|     status = (None,) | ||||
|     if data and data["category"] == category: | ||||
|         # This data is for us | ||||
|         if data["action"] == "save_to_file": | ||||
|             # Safe current configuration to file | ||||
|             try: | ||||
|                 result = requests.put(f"{server}/v1/config/file") | ||||
|                 result.raise_for_status() | ||||
|                 config_file_path = result.json()["general"]["config_file_path"] | ||||
|                 status = P( | ||||
|                     f"Actual config saved to {config_file_path} on {server}", | ||||
|                     cls="text-left", | ||||
|                 ) | ||||
|             except requests.exceptions.HTTPError as err: | ||||
|                 detail = result.json()["detail"] | ||||
|                 status = P( | ||||
|                     f"Can not save actual config to file on {server}: {err}, {detail}", | ||||
|                     cls="text-left", | ||||
|                 ) | ||||
|     return ( | ||||
|         category, | ||||
|         Card( | ||||
|             Details( | ||||
|                 Summary( | ||||
|                     Grid( | ||||
|                         DivHStacked( | ||||
|                             UkIcon(icon="play"), | ||||
|                             AdminButton( | ||||
|                                 "Save to file", | ||||
|                                 hx_post="/eosdash/admin", | ||||
|                                 hx_target="#page-content", | ||||
|                                 hx_swap="innerHTML", | ||||
|                                 hx_vals='{"category": "configuration", "action": "save_to_file"}', | ||||
|                             ), | ||||
|                         ), | ||||
|                         status, | ||||
|                     ), | ||||
|                     cls="list-none", | ||||
|                 ), | ||||
|                 P(f"Safe actual configuration to config file on {server}."), | ||||
|             ), | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def Admin(eos_host: str, eos_port: Union[str, int], data: Optional[dict] = None) -> Div: | ||||
|     """Generates the administrative dashboard layout. | ||||
|  | ||||
|     This includes configuration management and other administrative tools. | ||||
|  | ||||
|     Args: | ||||
|         eos_host (str): The hostname of the EOS server. | ||||
|         eos_port (Union[str, int]): The port of the EOS server. | ||||
|         data (Optional[dict], optional): Incoming data to trigger admin actions. Defaults to None. | ||||
|  | ||||
|     Returns: | ||||
|         Div: A `Div` component containing the assembled admin interface. | ||||
|     """ | ||||
|     rows = [] | ||||
|     last_category = "" | ||||
|     for category, admin in [ | ||||
|         AdminConfig(eos_host, eos_port, data), | ||||
|     ]: | ||||
|         if category != last_category: | ||||
|             rows.append(P(category)) | ||||
|             rows.append(DividerLine()) | ||||
|             last_category = category | ||||
|         rows.append(admin) | ||||
|  | ||||
|     return Div(*rows, cls="space-y-4") | ||||
| @@ -8,19 +8,19 @@ from bokeh.models import Plot | ||||
| from monsterui.franken import H4, Card, NotStr, Script | ||||
|  | ||||
| BokehJS = [ | ||||
|     Script(src="https://cdn.bokeh.org/bokeh/release/bokeh-3.6.3.min.js", crossorigin="anonymous"), | ||||
|     Script(src="https://cdn.bokeh.org/bokeh/release/bokeh-3.7.0.min.js", crossorigin="anonymous"), | ||||
|     Script( | ||||
|         src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.6.3.min.js", | ||||
|         src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.7.0.min.js", | ||||
|         crossorigin="anonymous", | ||||
|     ), | ||||
|     Script( | ||||
|         src="https://cdn.bokeh.org/bokeh/release/bokeh-tables-3.6.3.min.js", crossorigin="anonymous" | ||||
|         src="https://cdn.bokeh.org/bokeh/release/bokeh-tables-3.7.0.min.js", crossorigin="anonymous" | ||||
|     ), | ||||
|     Script( | ||||
|         src="https://cdn.bokeh.org/bokeh/release/bokeh-gl-3.6.3.min.js", crossorigin="anonymous" | ||||
|         src="https://cdn.bokeh.org/bokeh/release/bokeh-gl-3.7.0.min.js", crossorigin="anonymous" | ||||
|     ), | ||||
|     Script( | ||||
|         src="https://cdn.bokeh.org/bokeh/release/bokeh-mathjax-3.6.3.min.js", | ||||
|         src="https://cdn.bokeh.org/bokeh/release/bokeh-mathjax-3.7.0.min.js", | ||||
|         crossorigin="anonymous", | ||||
|     ), | ||||
| ] | ||||
|   | ||||
| @@ -1,8 +1,6 @@ | ||||
| from typing import Any, Optional, Union | ||||
|  | ||||
| from fasthtml.common import H1, Div, Li | ||||
|  | ||||
| # from mdit_py_plugins import plugin1, plugin2 | ||||
| from monsterui.foundations import stringify | ||||
| from monsterui.franken import ( | ||||
|     Button, | ||||
| @@ -13,6 +11,7 @@ from monsterui.franken import ( | ||||
|     Details, | ||||
|     DivLAligned, | ||||
|     DivRAligned, | ||||
|     Form, | ||||
|     Grid, | ||||
|     Input, | ||||
|     P, | ||||
| @@ -70,8 +69,22 @@ def ScrollArea( | ||||
|  | ||||
|  | ||||
| def ConfigCard( | ||||
|     config_name: str, config_type: str, read_only: str, value: str, default: str, description: str | ||||
|     config_name: str, | ||||
|     config_type: str, | ||||
|     read_only: str, | ||||
|     value: str, | ||||
|     default: str, | ||||
|     description: str, | ||||
|     update_error: Optional[str], | ||||
|     update_value: Optional[str], | ||||
|     update_open: Optional[bool], | ||||
| ) -> Card: | ||||
|     """Creates a styled configuration card.""" | ||||
|     config_id = config_name.replace(".", "-") | ||||
|     if not update_value: | ||||
|         update_value = value | ||||
|     if not update_open: | ||||
|         update_open = False | ||||
|     return Card( | ||||
|         Details( | ||||
|             Summary( | ||||
| @@ -85,24 +98,45 @@ def ConfigCard( | ||||
|                             P(read_only), | ||||
|                         ), | ||||
|                     ), | ||||
|                     Input(value=value) if read_only == "rw" else P(value), | ||||
|                     P(value), | ||||
|                 ), | ||||
|                 # cls="flex cursor-pointer list-none items-center gap-4", | ||||
|                 cls="list-none", | ||||
|             ), | ||||
|             Grid( | ||||
|                 P(description), | ||||
|                 P(config_type), | ||||
|             ), | ||||
|             # Default | ||||
|             Grid( | ||||
|                 DivRAligned( | ||||
|                     P("default") if read_only == "rw" else P(""), | ||||
|                 ), | ||||
|                 P(default) if read_only == "rw" else P(""), | ||||
|                 DivRAligned(P("default")), | ||||
|                 P(default), | ||||
|             ) | ||||
|             if read_only == "rw" | ||||
|             else None, | ||||
|             # Set value | ||||
|             Grid( | ||||
|                 DivRAligned(P("update")), | ||||
|                 Grid( | ||||
|                     Form( | ||||
|                         Input(value=config_name, type="hidden", id="key"), | ||||
|                         Input(value=update_value, type="text", id="value"), | ||||
|                         hx_put="/eosdash/configuration", | ||||
|                         hx_target="#page-content", | ||||
|                         hx_swap="innerHTML", | ||||
|                     ), | ||||
|                 ), | ||||
|             ) | ||||
|             if read_only == "rw" | ||||
|             else None, | ||||
|             # Last error | ||||
|             Grid( | ||||
|                 DivRAligned(P("update error")), | ||||
|                 P(update_error), | ||||
|             ) | ||||
|             if update_error | ||||
|             else None, | ||||
|             cls="space-y-4 gap-4", | ||||
|             open=update_open, | ||||
|         ), | ||||
|         cls="w-full", | ||||
|     ) | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import json | ||||
| from typing import Any, Dict, List, Optional, Sequence, TypeVar, Union | ||||
|  | ||||
| import requests | ||||
| from monsterui.franken import Div, DividerLine, P, Table, Tbody, Td, Th, Thead, Tr | ||||
| from monsterui.franken import Div, DividerLine, P | ||||
| from pydantic.fields import ComputedFieldInfo, FieldInfo | ||||
| from pydantic_core import PydanticUndefined | ||||
|  | ||||
| @@ -15,6 +16,10 @@ config_eos = get_config() | ||||
|  | ||||
| T = TypeVar("T") | ||||
|  | ||||
| # Latest configuration update results | ||||
| # Dictionary of config names and associated dictionary with keys "value", "result", "error", "open". | ||||
| config_update_latest: dict[str, dict[str, Optional[Union[str, bool]]]] = {} | ||||
|  | ||||
|  | ||||
| def get_nested_value( | ||||
|     dictionary: Union[Dict[str, Any], List[Any]], | ||||
| @@ -151,8 +156,8 @@ def configuration(values: dict) -> list[dict]: | ||||
|                     config["type"] = ( | ||||
|                         type_description.replace("typing.", "") | ||||
|                         .replace("pathlib.", "") | ||||
|                         .replace("[", "[ ") | ||||
|                         .replace("NoneType", "None") | ||||
|                         .replace("<class 'float'>", "float") | ||||
|                     ) | ||||
|                     configs.append(config) | ||||
|                     found_basic = True | ||||
| @@ -171,20 +176,16 @@ def configuration(values: dict) -> list[dict]: | ||||
|     return sorted(configs, key=lambda x: x["name"]) | ||||
|  | ||||
|  | ||||
| def get_configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> list[dict]: | ||||
| def get_configuration(eos_host: str, eos_port: 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. | ||||
|         eos_host (str): The hostname of the EOS server. | ||||
|         eos_port (Union[str, int]): The port of the EOS 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 | ||||
|     server = f"http://{eos_host}:{eos_port}" | ||||
|  | ||||
|     # Get current configuration from server | ||||
| @@ -201,25 +202,37 @@ def get_configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int | ||||
|     return configuration(config) | ||||
|  | ||||
|  | ||||
| def Configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> Div: | ||||
| def Configuration( | ||||
|     eos_host: str, eos_port: Union[str, int], configuration: Optional[list[dict]] = None | ||||
| ) -> Div: | ||||
|     """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. | ||||
|         eos_host (str): The hostname of the EOS server. | ||||
|         eos_port (Union[str, int]): The port of the EOS server. | ||||
|         configuration (Optional[list[dict]]): Optional configuration. If not provided it will be | ||||
|             retrievd from EOS. | ||||
|  | ||||
|     Returns: | ||||
|         Table: A `monsterui.franken.Table` component displaying configuration details. | ||||
|         rows:  Rows of configuration details. | ||||
|     """ | ||||
|     flds = "Name", "Type", "RO/RW", "Value", "Default", "Description" | ||||
|     if not configuration: | ||||
|         configuration = get_configuration(eos_host, eos_port) | ||||
|     rows = [] | ||||
|     last_category = "" | ||||
|     for config in get_configuration(eos_host, eos_port): | ||||
|     for config in configuration: | ||||
|         category = config["name"].split(".")[0] | ||||
|         if category != last_category: | ||||
|             rows.append(P(category)) | ||||
|             rows.append(DividerLine()) | ||||
|             last_category = category | ||||
|         update_error = config_update_latest.get(config["name"], {}).get("error") | ||||
|         update_value = config_update_latest.get(config["name"], {}).get("value") | ||||
|         update_open = config_update_latest.get(config["name"], {}).get("open") | ||||
|         # Make mypy happy - should never trigger | ||||
|         assert isinstance(update_error, (str, type(None))) | ||||
|         assert isinstance(update_value, (str, type(None))) | ||||
|         assert isinstance(update_open, (bool, type(None))) | ||||
|         rows.append( | ||||
|             ConfigCard( | ||||
|                 config["name"], | ||||
| @@ -228,48 +241,59 @@ def Configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) | ||||
|                 config["value"], | ||||
|                 config["default"], | ||||
|                 config["description"], | ||||
|                 update_error, | ||||
|                 update_value, | ||||
|                 update_open, | ||||
|             ) | ||||
|         ) | ||||
|     return Div(*rows, cls="space-y-4") | ||||
|  | ||||
|  | ||||
| def ConfigurationOrg(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> Table: | ||||
|     """Create a visual representation of the configuration. | ||||
| def ConfigKeyUpdate(eos_host: str, eos_port: Union[str, int], key: str, value: str) -> P: | ||||
|     """Update configuration key and 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. | ||||
|         eos_host (str): The hostname of the EOS server. | ||||
|         eos_port (Union[str, int]): The port of the EOS server. | ||||
|         key (str): configuration key in dot notation | ||||
|         value (str): configuration value as json string | ||||
|  | ||||
|     Returns: | ||||
|         Table: A `monsterui.franken.Table` component displaying configuration details. | ||||
|         rows:  Rows of 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") | ||||
|     server = f"http://{eos_host}:{eos_port}" | ||||
|     path = key.replace(".", "/") | ||||
|     try: | ||||
|         data = json.loads(value) | ||||
|     except: | ||||
|         if value in ("None", "none", "Null", "null"): | ||||
|             data = None | ||||
|         else: | ||||
|             data = value | ||||
|  | ||||
|     error = None | ||||
|     result = None | ||||
|     try: | ||||
|         result = requests.put(f"{server}/v1/config/{path}", json=data) | ||||
|         result.raise_for_status() | ||||
|     except requests.exceptions.HTTPError as err: | ||||
|         if result: | ||||
|             detail = result.json()["detail"] | ||||
|         else: | ||||
|             detail = "No details" | ||||
|         error = f"Can not set {key} on {server}: {err}, {detail}" | ||||
|     # Mark all updates as closed | ||||
|     for k in config_update_latest: | ||||
|         config_update_latest[k]["open"] = False | ||||
|     # Remember this update as latest one | ||||
|     config_update_latest[key] = { | ||||
|         "error": error, | ||||
|         "result": result.json() if result else None, | ||||
|         "value": value, | ||||
|         "open": True, | ||||
|     } | ||||
|     if error or result is None: | ||||
|         # Reread configuration to be shure we display actual data | ||||
|         return Configuration(eos_host, eos_port) | ||||
|     # Use configuration already provided | ||||
|     return Configuration(eos_host, eos_port, configuration(result.json())) | ||||
|   | ||||
| @@ -486,7 +486,9 @@ def fastapi_config_put_key( | ||||
|     path: str = FastapiPath( | ||||
|         ..., description="The nested path to the configuration key (e.g., general/latitude)." | ||||
|     ), | ||||
|     value: Any = Body(..., description="The value to assign to the specified configuration path."), | ||||
|     value: Optional[Any] = Body( | ||||
|         None, description="The value to assign to the specified configuration path (can be None)." | ||||
|     ), | ||||
| ) -> ConfigEOS: | ||||
|     """Update a nested key or index in the config model. | ||||
|  | ||||
| @@ -848,7 +850,7 @@ def fastapi_prediction_update( | ||||
|         trace = "".join(traceback.TracebackException.from_exception(e).format()) | ||||
|         raise HTTPException( | ||||
|             status_code=400, | ||||
|             detail=f"Error on prediction update: {e}{trace}", | ||||
|             detail=f"Error on prediction update: {e}\n{trace}", | ||||
|         ) | ||||
|     return Response() | ||||
|  | ||||
|   | ||||
| @@ -12,18 +12,17 @@ from monsterui.core import FastHTML, Theme | ||||
|  | ||||
| from akkudoktoreos.config.config import get_config | ||||
| from akkudoktoreos.core.logging import get_logger | ||||
| 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.admin import Admin | ||||
| from akkudoktoreos.server.dash.bokeh import BokehJS | ||||
| from akkudoktoreos.server.dash.components import Page | ||||
| from akkudoktoreos.server.dash.configuration import ConfigKeyUpdate, 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() | ||||
|  | ||||
| @@ -37,8 +36,7 @@ args: Optional[argparse.Namespace] = None | ||||
|  | ||||
|  | ||||
| # Get frankenui and tailwind headers via CDN using Theme.green.headers() | ||||
| # Add altair headers | ||||
| # hdrs=(Theme.green.headers(highlightjs=True), AltairJS,) | ||||
| # Add Bokeh headers | ||||
| hdrs = ( | ||||
|     Theme.green.headers(highlightjs=True), | ||||
|     BokehJS, | ||||
| @@ -94,6 +92,7 @@ def get_eosdash():  # type: ignore | ||||
|             "EOSdash": "/eosdash/hello", | ||||
|             "Config": "/eosdash/configuration", | ||||
|             "Demo": "/eosdash/demo", | ||||
|             "Admin": "/eosdash/admin", | ||||
|         }, | ||||
|         Hello(), | ||||
|         Footer(*eos_server()), | ||||
| @@ -121,6 +120,21 @@ def get_eosdash_hello():  # type: ignore | ||||
|     return Hello() | ||||
|  | ||||
|  | ||||
| @app.get("/eosdash/admin") | ||||
| def get_eosdash_admin():  # type: ignore | ||||
|     """Serves the EOSdash Admin page. | ||||
|  | ||||
|     Returns: | ||||
|         Admin: The Admin page component. | ||||
|     """ | ||||
|     return Admin(*eos_server()) | ||||
|  | ||||
|  | ||||
| @app.post("/eosdash/admin") | ||||
| def post_eosdash_admin(data: dict):  # type: ignore | ||||
|     return Admin(*eos_server(), data) | ||||
|  | ||||
|  | ||||
| @app.get("/eosdash/configuration") | ||||
| def get_eosdash_configuration():  # type: ignore | ||||
|     """Serves the EOSdash Configuration page. | ||||
| @@ -131,6 +145,11 @@ def get_eosdash_configuration():  # type: ignore | ||||
|     return Configuration(*eos_server()) | ||||
|  | ||||
|  | ||||
| @app.put("/eosdash/configuration") | ||||
| def put_eosdash_configuration(data: dict):  # type: ignore | ||||
|     return ConfigKeyUpdate(*eos_server(), data["key"], data["value"]) | ||||
|  | ||||
|  | ||||
| @app.get("/eosdash/demo") | ||||
| def get_eosdash_demo():  # type: ignore | ||||
|     """Serves the EOSdash Demo page. | ||||
|   | ||||
| @@ -59,8 +59,8 @@ def test_invalid_provider(provider, config_eos): | ||||
|             }, | ||||
|         } | ||||
|     } | ||||
|     config_eos.merge_settings_from_dict(settings) | ||||
|     assert not provider.enabled() | ||||
|     with pytest.raises(ValueError, match="not a valid electricity price provider"): | ||||
|         config_eos.merge_settings_from_dict(settings) | ||||
|  | ||||
|  | ||||
| # ------------------------------------------------ | ||||
|   | ||||
| @@ -59,8 +59,8 @@ def test_invalid_provider(provider, config_eos): | ||||
|             }, | ||||
|         } | ||||
|     } | ||||
|     config_eos.merge_settings_from_dict(settings) | ||||
|     assert not provider.enabled() | ||||
|     with pytest.raises(ValueError, match="not a valid PV forecast provider"): | ||||
|         config_eos.merge_settings_from_dict(settings) | ||||
|  | ||||
|  | ||||
| # ------------------------------------------------ | ||||
|   | ||||
| @@ -79,8 +79,8 @@ def test_invalid_provider(provider, config_eos): | ||||
|             "provider": "<invalid>", | ||||
|         } | ||||
|     } | ||||
|     config_eos.merge_settings_from_dict(settings) | ||||
|     assert not provider.enabled() | ||||
|     with pytest.raises(ValueError, match="not a valid weather provider"): | ||||
|         config_eos.merge_settings_from_dict(settings) | ||||
|  | ||||
|  | ||||
| def test_invalid_coordinates(provider, config_eos): | ||||
|   | ||||
| @@ -59,8 +59,8 @@ def test_invalid_provider(provider, config_eos, monkeypatch): | ||||
|             }, | ||||
|         } | ||||
|     } | ||||
|     config_eos.merge_settings_from_dict(settings) | ||||
|     assert provider.enabled() == False | ||||
|     with pytest.raises(ValueError, match="not a valid weather provider"): | ||||
|         config_eos.merge_settings_from_dict(settings) | ||||
|  | ||||
|  | ||||
| # ------------------------------------------------ | ||||
|   | ||||
		Reference in New Issue
	
	Block a user