mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2025-11-02 07:46:20 +00:00
EOSdash: Improve PV forecast configuration. (#500)
* Allow to configure planes and configuration values of planes separatedly. Make single configuration values for planes explicitly available for configuration. Still allows to also configure a plane by a whole plane value struct. * Enhance admin page by file import and export of the EOS configuration The actual EOS configuration can now be exported to the EOSdash server. From there it can be also imported. For security reasons only import and export from/ to a predefined directory on the EOSdash server is possible. * Improve handling of nested value pathes in pydantic models. Added separate methods for nested path access (get_nested_value, set_nested_value). On value setting the missing fields along the nested path are now added automatically and initialized with default values. Nested path access was before restricted to the EOS configuration and is now part of the pydantic base model. * Makefile Add new target to run rests as CI does on Github. Improve target docs. * Datetimeutil tests Prolong acceptable time difference for comparison of approximately equal times in tests. Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
@@ -4,23 +4,40 @@ This module provides functions to generate administrative UI components
|
||||
for the EOS dashboard.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import requests
|
||||
from fasthtml.common import Div
|
||||
from fasthtml.common import Select
|
||||
from monsterui.foundations import stringify
|
||||
from monsterui.franken import (
|
||||
from monsterui.franken import ( # Select, TODO: Select from FrankenUI does not work - using Select from FastHTML instead
|
||||
H3,
|
||||
Button,
|
||||
ButtonT,
|
||||
Card,
|
||||
Details,
|
||||
Div,
|
||||
DivHStacked,
|
||||
DividerLine,
|
||||
Grid,
|
||||
Input,
|
||||
Options,
|
||||
P,
|
||||
Summary,
|
||||
UkIcon,
|
||||
)
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.server.dash.components import Error, Success
|
||||
from akkudoktoreos.server.dash.configuration import get_nested_value
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Directory to export files to, or to import files from
|
||||
export_import_directory = Path(user_config_dir("net.akkudoktor.eosdash", "akkudoktor"))
|
||||
|
||||
|
||||
def AdminButton(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Button:
|
||||
@@ -41,7 +58,9 @@ def AdminButton(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any)
|
||||
return Button(*c, submit=False, **kwargs)
|
||||
|
||||
|
||||
def AdminConfig(eos_host: str, eos_port: Union[str, int], data: Optional[dict]) -> Card:
|
||||
def AdminConfig(
|
||||
eos_host: str, eos_port: Union[str, int], data: Optional[dict], config: Optional[dict[str, Any]]
|
||||
) -> tuple[str, Union[Card, list[Card]]]:
|
||||
"""Creates a configuration management card with save-to-file functionality.
|
||||
|
||||
Args:
|
||||
@@ -50,13 +69,28 @@ def AdminConfig(eos_host: str, eos_port: Union[str, int], data: Optional[dict])
|
||||
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.
|
||||
tuple[str, Union[Card, list[Card]]]: A tuple containing the configuration category label and the `Card` UI component.
|
||||
"""
|
||||
server = f"http://{eos_host}:{eos_port}"
|
||||
eos_hostname = "EOS server"
|
||||
eosdash_hostname = "EOSdash server"
|
||||
|
||||
category = "configuration"
|
||||
# save config file
|
||||
status = (None,)
|
||||
if data and data["category"] == category:
|
||||
config_file_path = "<unknown>"
|
||||
try:
|
||||
if config:
|
||||
config_file_path = get_nested_value(config, ["general", "config_file_path"])
|
||||
except:
|
||||
pass
|
||||
# export config file
|
||||
export_to_file_next_tag = to_datetime(as_string="YYYYMMDDHHmmss")
|
||||
export_to_file_status = (None,)
|
||||
# import config file
|
||||
import_from_file_status = (None,)
|
||||
|
||||
if data and data.get("category", None) == category:
|
||||
# This data is for us
|
||||
if data["action"] == "save_to_file":
|
||||
# Safe current configuration to file
|
||||
@@ -64,39 +98,156 @@ def AdminConfig(eos_host: str, eos_port: Union[str, int], data: Optional[dict])
|
||||
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:
|
||||
status = Success(f"Saved to '{config_file_path}' on '{eos_hostname}'")
|
||||
except requests.exceptions.HTTPError as e:
|
||||
detail = result.json()["detail"]
|
||||
status = P(
|
||||
f"Can not save actual config to file on {server}: {err}, {detail}",
|
||||
cls="text-left",
|
||||
status = Error(
|
||||
f"Can not save actual config to file on '{eos_hostname}': {e}, {detail}"
|
||||
)
|
||||
except Exception as e:
|
||||
status = Error(f"Can not save actual config to file on '{eos_hostname}': {e}")
|
||||
elif data["action"] == "export_to_file":
|
||||
# Export current configuration to file
|
||||
export_to_file_tag = data.get("export_to_file_tag", export_to_file_next_tag)
|
||||
export_to_file_path = export_import_directory.joinpath(
|
||||
f"eos_config_{export_to_file_tag}.json"
|
||||
)
|
||||
try:
|
||||
if not config:
|
||||
raise ValueError(f"No config from '{eos_hostname}'")
|
||||
export_to_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with export_to_file_path.open("w", encoding="utf-8", newline="\n") as fd:
|
||||
json.dump(config, fd, indent=4, sort_keys=True)
|
||||
export_to_file_status = Success(
|
||||
f"Exported to '{export_to_file_path}' on '{eosdash_hostname}'"
|
||||
)
|
||||
except requests.exceptions.HTTPError as e:
|
||||
detail = result.json()["detail"]
|
||||
export_to_file_status = Error(
|
||||
f"Can not export actual config to '{export_to_file_path}' on '{eosdash_hostname}': {e}, {detail}"
|
||||
)
|
||||
except Exception as e:
|
||||
export_to_file_status = Error(
|
||||
f"Can not export actual config to '{export_to_file_path}' on '{eosdash_hostname}': {e}"
|
||||
)
|
||||
elif data["action"] == "import_from_file":
|
||||
import_file_name = data.get("import_file_name", None)
|
||||
import_from_file_pathes = list(
|
||||
export_import_directory.glob("*.json")
|
||||
) # expand generator object
|
||||
import_file_path = None
|
||||
for f in import_from_file_pathes:
|
||||
if f.name == import_file_name:
|
||||
import_file_path = f
|
||||
if import_file_path:
|
||||
try:
|
||||
with import_file_path.open("r", encoding="utf-8", newline=None) as fd:
|
||||
import_config = json.load(fd)
|
||||
result = requests.put(f"{server}/v1/config", json=import_config)
|
||||
result.raise_for_status()
|
||||
import_from_file_status = Success(
|
||||
f"Config imported from '{import_file_path}' on '{eosdash_hostname}'"
|
||||
)
|
||||
except requests.exceptions.HTTPError as e:
|
||||
detail = result.json()["detail"]
|
||||
import_from_file_status = Error(
|
||||
f"Can not import config from '{import_file_name}' on '{eosdash_hostname}' {e}, {detail}"
|
||||
)
|
||||
except Exception as e:
|
||||
import_from_file_status = Error(
|
||||
f"Can not import config from '{import_file_name}' on '{eosdash_hostname}' {e}"
|
||||
)
|
||||
else:
|
||||
import_from_file_status = Error(
|
||||
f"Can not import config from '{import_file_name}', not found in '{export_import_directory}' on '{eosdash_hostname}'"
|
||||
)
|
||||
|
||||
# Update for display, in case we added a new file before
|
||||
import_from_file_names = [f.name for f in list(export_import_directory.glob("*.json"))]
|
||||
|
||||
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"}',
|
||||
[
|
||||
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"}',
|
||||
),
|
||||
P(f"'{config_file_path}' on '{eos_hostname}'"),
|
||||
),
|
||||
status,
|
||||
),
|
||||
status,
|
||||
cls="list-none",
|
||||
),
|
||||
cls="list-none",
|
||||
P(f"Safe actual configuration to '{config_file_path}' on '{eos_hostname}'."),
|
||||
),
|
||||
P(f"Safe actual configuration to config file on {server}."),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
DivHStacked(
|
||||
UkIcon(icon="play"),
|
||||
AdminButton(
|
||||
"Export to file",
|
||||
hx_post="/eosdash/admin",
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='js:{"category": "configuration", "action": "export_to_file", "export_to_file_tag": document.querySelector("[name=\'chosen_export_file_tag\']").value }',
|
||||
),
|
||||
P("'eos_config_"),
|
||||
Input(
|
||||
id="export_file_tag",
|
||||
name="chosen_export_file_tag",
|
||||
value=export_to_file_next_tag,
|
||||
),
|
||||
P(".json'"),
|
||||
),
|
||||
export_to_file_status,
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
P(
|
||||
f"Export actual configuration to 'eos_config_{export_to_file_next_tag}.json' on '{eosdash_hostname}'."
|
||||
),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
DivHStacked(
|
||||
UkIcon(icon="play"),
|
||||
AdminButton(
|
||||
"Import from file",
|
||||
hx_post="/eosdash/admin",
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='js:{ "category": "configuration", "action": "import_from_file", "import_file_name": document.querySelector("[name=\'selected_import_file_name\']").value }',
|
||||
),
|
||||
Select(
|
||||
*Options(*import_from_file_names),
|
||||
id="import_file_name",
|
||||
name="selected_import_file_name", # Name of hidden input field with selected value
|
||||
placeholder="Select file",
|
||||
),
|
||||
),
|
||||
import_from_file_status,
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
P(f"Import configuration from config file on '{eosdash_hostname}'."),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -113,15 +264,36 @@ def Admin(eos_host: str, eos_port: Union[str, int], data: Optional[dict] = None)
|
||||
Returns:
|
||||
Div: A `Div` component containing the assembled admin interface.
|
||||
"""
|
||||
# Get current configuration from server
|
||||
server = f"http://{eos_host}:{eos_port}"
|
||||
try:
|
||||
result = requests.get(f"{server}/v1/config")
|
||||
result.raise_for_status()
|
||||
config = result.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
config = {}
|
||||
detail = result.json()["detail"]
|
||||
warning_msg = f"Can not retrieve configuration from {server}: {e}, {detail}"
|
||||
logger.warning(warning_msg)
|
||||
return Error(warning_msg)
|
||||
except Exception as e:
|
||||
warning_msg = f"Can not retrieve configuration from {server}: {e}"
|
||||
logger.warning(warning_msg)
|
||||
return Error(warning_msg)
|
||||
|
||||
rows = []
|
||||
last_category = ""
|
||||
for category, admin in [
|
||||
AdminConfig(eos_host, eos_port, data),
|
||||
AdminConfig(eos_host, eos_port, data, config),
|
||||
]:
|
||||
if category != last_category:
|
||||
rows.append(P(category))
|
||||
rows.append(H3(category))
|
||||
rows.append(DividerLine())
|
||||
last_category = category
|
||||
rows.append(admin)
|
||||
if isinstance(admin, list):
|
||||
for card in admin:
|
||||
rows.append(card)
|
||||
else:
|
||||
rows.append(admin)
|
||||
|
||||
return Div(*rows, cls="space-y-4")
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from fasthtml.common import H1, Div, Li
|
||||
from monsterui.daisy import (
|
||||
Alert,
|
||||
AlertT,
|
||||
)
|
||||
from monsterui.foundations import stringify
|
||||
from monsterui.franken import (
|
||||
H3,
|
||||
Button,
|
||||
ButtonT,
|
||||
Card,
|
||||
@@ -68,6 +73,26 @@ def ScrollArea(
|
||||
)
|
||||
|
||||
|
||||
def Success(*c: Any) -> Alert:
|
||||
return Alert(
|
||||
DivLAligned(
|
||||
UkIcon("check"),
|
||||
P(*c),
|
||||
),
|
||||
cls=AlertT.success,
|
||||
)
|
||||
|
||||
|
||||
def Error(*c: Any) -> Alert:
|
||||
return Alert(
|
||||
DivLAligned(
|
||||
UkIcon("triangle-alert"),
|
||||
P(*c),
|
||||
),
|
||||
cls=AlertT.error,
|
||||
)
|
||||
|
||||
|
||||
def ConfigCard(
|
||||
config_name: str,
|
||||
config_type: str,
|
||||
@@ -79,7 +104,28 @@ def ConfigCard(
|
||||
update_value: Optional[str],
|
||||
update_open: Optional[bool],
|
||||
) -> Card:
|
||||
"""Creates a styled configuration card."""
|
||||
"""Creates a styled configuration card for displaying configuration details.
|
||||
|
||||
This function generates a configuration card that is displayed in the UI with
|
||||
various sections such as configuration name, type, description, default value,
|
||||
current value, and error details. It supports both read-only and editable modes.
|
||||
|
||||
Args:
|
||||
config_name (str): The name of the configuration.
|
||||
config_type (str): The type of the configuration.
|
||||
read_only (str): Indicates if the configuration is read-only ("rw" for read-write,
|
||||
any other value indicates read-only).
|
||||
value (str): The current value of the configuration.
|
||||
default (str): The default value of the configuration.
|
||||
description (str): A description of the configuration.
|
||||
update_error (Optional[str]): The error message, if any, during the update process.
|
||||
update_value (Optional[str]): The value to be updated, if different from the current value.
|
||||
update_open (Optional[bool]): A flag indicating whether the update section of the card
|
||||
should be initially expanded.
|
||||
|
||||
Returns:
|
||||
Card: A styled Card component containing the configuration details.
|
||||
"""
|
||||
config_id = config_name.replace(".", "-")
|
||||
if not update_value:
|
||||
update_value = value
|
||||
@@ -207,7 +253,7 @@ def DashboardTabs(dashboard_items: dict[str, str]) -> Card:
|
||||
dash_items = [
|
||||
Li(
|
||||
DashboardTrigger(
|
||||
menu,
|
||||
H3(menu),
|
||||
hx_get=f"{path}",
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
|
||||
@@ -2,17 +2,32 @@ import json
|
||||
from typing import Any, Dict, List, Optional, Sequence, TypeVar, Union
|
||||
|
||||
import requests
|
||||
from monsterui.franken import Div, DividerLine, P
|
||||
from monsterui.franken import (
|
||||
H3,
|
||||
H4,
|
||||
Card,
|
||||
Details,
|
||||
Div,
|
||||
DividerLine,
|
||||
DivLAligned,
|
||||
DivRAligned,
|
||||
Form,
|
||||
Grid,
|
||||
Input,
|
||||
P,
|
||||
Summary,
|
||||
UkIcon,
|
||||
)
|
||||
from pydantic.fields import ComputedFieldInfo, FieldInfo
|
||||
from pydantic_core import PydanticUndefined
|
||||
|
||||
from akkudoktoreos.config.config import get_config
|
||||
from akkudoktoreos.config.config import ConfigEOS
|
||||
from akkudoktoreos.core.logging import get_logger
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.prediction.pvforecast import PVForecastPlaneSetting
|
||||
from akkudoktoreos.server.dash.components import ConfigCard
|
||||
|
||||
logger = get_logger(__name__)
|
||||
config_eos = get_config()
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -53,10 +68,10 @@ def get_nested_value(
|
||||
# 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]
|
||||
if isinstance(current, dict):
|
||||
current = current[str(key)]
|
||||
elif isinstance(current, list):
|
||||
current = current[int(key)]
|
||||
else:
|
||||
raise KeyError(f"Invalid key or index: {key}")
|
||||
return current
|
||||
@@ -106,25 +121,36 @@ def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple
|
||||
return resolved_types
|
||||
|
||||
|
||||
def configuration(values: dict) -> list[dict]:
|
||||
def configuration(
|
||||
model: type[PydanticBaseModel], values: dict, values_prefix: list[str] = []
|
||||
) -> list[dict]:
|
||||
"""Generate configuration details based on provided values and model metadata.
|
||||
|
||||
Args:
|
||||
model (type[PydanticBaseModel]): The Pydantic model to extract configuration from.
|
||||
values (dict): A dictionary containing the current configuration values.
|
||||
values_prefix (list[str]): A list of parent type names that prefixes the model values in the values.
|
||||
|
||||
Returns:
|
||||
List[dict]: A sorted list of configuration details, each represented as a dictionary.
|
||||
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()
|
||||
for field_name, field_info in list(model.model_fields.items()) + list(
|
||||
model.model_computed_fields.items()
|
||||
):
|
||||
|
||||
def extract_nested_models(
|
||||
subfield_info: Union[ComputedFieldInfo, FieldInfo], parent_types: list[str]
|
||||
) -> None:
|
||||
"""Extract nested models from the given subfield information.
|
||||
|
||||
Args:
|
||||
subfield_info (Union[ComputedFieldInfo, FieldInfo]): Field metadata from Pydantic.
|
||||
parent_types (list[str]): A list of parent type names for hierarchical representation.
|
||||
"""
|
||||
nonlocal values, values_prefix
|
||||
regular_field = isinstance(subfield_info, FieldInfo)
|
||||
subtype = subfield_info.annotation if regular_field else subfield_info.return_type
|
||||
|
||||
@@ -141,9 +167,11 @@ def configuration(values: dict) -> list[dict]:
|
||||
continue
|
||||
|
||||
config = {}
|
||||
config["name"] = ".".join(parent_types)
|
||||
config["value"] = str(get_nested_value(values, parent_types, "<unknown>"))
|
||||
config["default"] = str(get_default_value(subfield_info, regular_field))
|
||||
config["name"] = ".".join(values_prefix + parent_types)
|
||||
config["value"] = json.dumps(
|
||||
get_nested_value(values, values_prefix + parent_types, "<unknown>")
|
||||
)
|
||||
config["default"] = json.dumps(get_default_value(subfield_info, regular_field))
|
||||
config["description"] = (
|
||||
subfield_info.description if subfield_info.description else ""
|
||||
)
|
||||
@@ -192,14 +220,188 @@ def get_configuration(eos_host: str, eos_port: Union[str, int]) -> list[dict]:
|
||||
try:
|
||||
result = requests.get(f"{server}/v1/config")
|
||||
result.raise_for_status()
|
||||
config = result.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
config = {}
|
||||
detail = result.json()["detail"]
|
||||
warning_msg = f"Can not retrieve configuration from {server}: {e}, {detail}"
|
||||
logger.warning(warning_msg)
|
||||
return configuration({})
|
||||
config = result.json()
|
||||
|
||||
return configuration(config)
|
||||
return configuration(ConfigEOS, config)
|
||||
|
||||
|
||||
def ConfigPlanesCard(
|
||||
config_name: str,
|
||||
config_type: str,
|
||||
read_only: str,
|
||||
value: str,
|
||||
default: str,
|
||||
description: str,
|
||||
max_planes: int,
|
||||
update_error: Optional[str],
|
||||
update_value: Optional[str],
|
||||
update_open: Optional[bool],
|
||||
) -> Card:
|
||||
"""Creates a styled configuration card for PV planes.
|
||||
|
||||
This function generates a configuration card that is displayed in the UI with
|
||||
various sections such as configuration name, type, description, default value,
|
||||
current value, and error details. It supports both read-only and editable modes.
|
||||
|
||||
Args:
|
||||
config_name (str): The name of the PV planes configuration.
|
||||
config_type (str): The type of the PV planes configuration.
|
||||
read_only (str): Indicates if the PV planes configuration is read-only ("rw" for read-write,
|
||||
any other value indicates read-only).
|
||||
value (str): The current value of the PV planes configuration.
|
||||
default (str): The default value of the PV planes configuration.
|
||||
description (str): A description of the PV planes configuration.
|
||||
max_planes (int): Maximum number of planes that can be set
|
||||
update_error (Optional[str]): The error message, if any, during the update process.
|
||||
update_value (Optional[str]): The value to be updated, if different from the current value.
|
||||
update_open (Optional[bool]): A flag indicating whether the update section of the card
|
||||
should be initially expanded.
|
||||
|
||||
Returns:
|
||||
Card: A styled Card component containing the PV planes configuration details.
|
||||
"""
|
||||
config_id = config_name.replace(".", "-")
|
||||
# Remember overall planes update status
|
||||
planes_update_error = update_error
|
||||
planes_update_value = update_value
|
||||
if not planes_update_value:
|
||||
planes_update_value = value
|
||||
planes_update_open = update_open
|
||||
if not planes_update_open:
|
||||
planes_update_open = False
|
||||
# Create EOS planes configuration
|
||||
eos_planes = json.loads(value)
|
||||
eos_planes_config = {
|
||||
"pvforecast": {
|
||||
"planes": eos_planes,
|
||||
},
|
||||
}
|
||||
# Create cards for all planes
|
||||
rows = []
|
||||
for i in range(0, max_planes):
|
||||
plane_config = configuration(
|
||||
PVForecastPlaneSetting(),
|
||||
eos_planes_config,
|
||||
values_prefix=["pvforecast", "planes", str(i)],
|
||||
)
|
||||
plane_rows = []
|
||||
plane_update_open = False
|
||||
if eos_planes and len(eos_planes) > i:
|
||||
plane_value = json.dumps(eos_planes[i])
|
||||
else:
|
||||
plane_value = json.dumps(None)
|
||||
for config in plane_config:
|
||||
update_error = config_update_latest.get(config["name"], {}).get("error") # type: ignore
|
||||
update_value = config_update_latest.get(config["name"], {}).get("value") # type: ignore
|
||||
update_open = config_update_latest.get(config["name"], {}).get("open") # type: ignore
|
||||
if update_open:
|
||||
planes_update_open = True
|
||||
plane_update_open = True
|
||||
# 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)))
|
||||
plane_rows.append(
|
||||
ConfigCard(
|
||||
config["name"],
|
||||
config["type"],
|
||||
config["read-only"],
|
||||
config["value"],
|
||||
config["default"],
|
||||
config["description"],
|
||||
update_error,
|
||||
update_value,
|
||||
update_open,
|
||||
)
|
||||
)
|
||||
rows.append(
|
||||
Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
Grid(
|
||||
DivLAligned(
|
||||
UkIcon(icon="play"),
|
||||
H4(f"pvforecast.planes.{i}"),
|
||||
),
|
||||
DivRAligned(
|
||||
P(read_only),
|
||||
),
|
||||
),
|
||||
P(plane_value),
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
*plane_rows,
|
||||
cls="space-y-4 gap-4",
|
||||
open=plane_update_open,
|
||||
),
|
||||
cls="w-full",
|
||||
)
|
||||
)
|
||||
|
||||
return Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
Grid(
|
||||
DivLAligned(
|
||||
UkIcon(icon="play"),
|
||||
P(config_name),
|
||||
),
|
||||
DivRAligned(
|
||||
P(read_only),
|
||||
),
|
||||
),
|
||||
P(value),
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
Grid(
|
||||
P(description),
|
||||
P(config_type),
|
||||
),
|
||||
# Default
|
||||
Grid(
|
||||
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=planes_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(planes_update_error),
|
||||
)
|
||||
if planes_update_error
|
||||
else None,
|
||||
# Now come the single element configs
|
||||
*rows,
|
||||
cls="space-y-4 gap-4",
|
||||
open=planes_update_open,
|
||||
),
|
||||
cls="w-full",
|
||||
)
|
||||
|
||||
|
||||
def Configuration(
|
||||
@@ -220,10 +422,19 @@ def Configuration(
|
||||
configuration = get_configuration(eos_host, eos_port)
|
||||
rows = []
|
||||
last_category = ""
|
||||
# find some special configuration values
|
||||
max_planes = 0
|
||||
for config in configuration:
|
||||
if config["name"] == "pvforecast.max_planes":
|
||||
try:
|
||||
max_planes = int(config["value"])
|
||||
except:
|
||||
max_planes = 0
|
||||
# build visual representation
|
||||
for config in configuration:
|
||||
category = config["name"].split(".")[0]
|
||||
if category != last_category:
|
||||
rows.append(P(category))
|
||||
rows.append(H3(category))
|
||||
rows.append(DividerLine())
|
||||
last_category = category
|
||||
update_error = config_update_latest.get(config["name"], {}).get("error")
|
||||
@@ -233,19 +444,39 @@ def Configuration(
|
||||
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"],
|
||||
config["type"],
|
||||
config["read-only"],
|
||||
config["value"],
|
||||
config["default"],
|
||||
config["description"],
|
||||
update_error,
|
||||
update_value,
|
||||
update_open,
|
||||
if (
|
||||
config["type"]
|
||||
== "Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]"
|
||||
):
|
||||
# Special configuration for PV planes
|
||||
rows.append(
|
||||
ConfigPlanesCard(
|
||||
config["name"],
|
||||
config["type"],
|
||||
config["read-only"],
|
||||
config["value"],
|
||||
config["default"],
|
||||
config["description"],
|
||||
max_planes,
|
||||
update_error,
|
||||
update_value,
|
||||
update_open,
|
||||
)
|
||||
)
|
||||
else:
|
||||
rows.append(
|
||||
ConfigCard(
|
||||
config["name"],
|
||||
config["type"],
|
||||
config["read-only"],
|
||||
config["value"],
|
||||
config["default"],
|
||||
config["description"],
|
||||
update_error,
|
||||
update_value,
|
||||
update_open,
|
||||
)
|
||||
)
|
||||
)
|
||||
return Div(*rows, cls="space-y-4")
|
||||
|
||||
|
||||
@@ -272,15 +503,20 @@ def ConfigKeyUpdate(eos_host: str, eos_port: Union[str, int], key: str, value: s
|
||||
data = value
|
||||
|
||||
error = None
|
||||
result = None
|
||||
config = None
|
||||
try:
|
||||
result = requests.put(f"{server}/v1/config/{path}", json=data)
|
||||
result.raise_for_status()
|
||||
response = requests.put(f"{server}/v1/config/{path}", json=data)
|
||||
response.raise_for_status()
|
||||
config = response.json()
|
||||
except requests.exceptions.HTTPError as err:
|
||||
if result:
|
||||
detail = result.json()["detail"]
|
||||
else:
|
||||
detail = "No details"
|
||||
try:
|
||||
# Try to get 'detail' from the JSON response
|
||||
detail = response.json().get(
|
||||
"detail", f"No error details for data '{data}' '{response.text}'"
|
||||
)
|
||||
except ValueError:
|
||||
# Response is not JSON
|
||||
detail = f"No error details for data '{data}' '{response.text}'"
|
||||
error = f"Can not set {key} on {server}: {err}, {detail}"
|
||||
# Mark all updates as closed
|
||||
for k in config_update_latest:
|
||||
@@ -288,12 +524,12 @@ def ConfigKeyUpdate(eos_host: str, eos_port: Union[str, int], key: str, value: s
|
||||
# Remember this update as latest one
|
||||
config_update_latest[key] = {
|
||||
"error": error,
|
||||
"result": result.json() if result else None,
|
||||
"result": config,
|
||||
"value": value,
|
||||
"open": True,
|
||||
}
|
||||
if error or result is None:
|
||||
if error or config 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()))
|
||||
return Configuration(eos_host, eos_port, configuration(ConfigEOS, config))
|
||||
|
||||
@@ -500,13 +500,13 @@ def fastapi_config_put_key(
|
||||
configuration (ConfigEOS): The current configuration after the update.
|
||||
"""
|
||||
try:
|
||||
config_eos.set_config_value(path, value)
|
||||
except IndexError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except KeyError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
config_eos.set_nested_value(path, value)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Error on update of configuration '{path}','{value}': {e}\n{trace}",
|
||||
)
|
||||
|
||||
return config_eos
|
||||
|
||||
@@ -526,7 +526,7 @@ def fastapi_config_get_key(
|
||||
value (Any): The value of the selected nested key.
|
||||
"""
|
||||
try:
|
||||
return config_eos.get_config_value(path)
|
||||
return config_eos.get_nested_value(path)
|
||||
except IndexError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except KeyError as e:
|
||||
|
||||
Reference in New Issue
Block a user