mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2025-04-19 00:45:22 +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:
parent
61c5efc74f
commit
7aaf193682
@ -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)
|
||||
|
||||
|
||||
# ------------------------------------------------
|
||||
|
Loading…
x
Reference in New Issue
Block a user