mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2025-04-19 08:55:15 +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**:
|
**Request Body**:
|
||||||
|
|
||||||
- `application/json`: {
|
- `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"
|
"title": "Value"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
11
openapi.json
11
openapi.json
@ -3453,12 +3453,17 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"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"
|
"title": "Value"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"required": true
|
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
|
@ -529,7 +529,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
|||||||
if not self.general.config_file_path:
|
if not self.general.config_file_path:
|
||||||
raise ValueError("Configuration file path unknown.")
|
raise ValueError("Configuration file path unknown.")
|
||||||
with self.general.config_file_path.open("w", encoding="utf-8", newline="\n") as f_out:
|
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)
|
f_out.write(json_str)
|
||||||
|
|
||||||
def update(self) -> None:
|
def update(self) -> None:
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import traceback
|
||||||
from typing import Any, ClassVar, Optional
|
from typing import Any, ClassVar, Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@ -305,12 +306,13 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
|
|||||||
if EnergyManagement._last_datetime is None:
|
if EnergyManagement._last_datetime is None:
|
||||||
# Never run before
|
# Never run before
|
||||||
try:
|
try:
|
||||||
# Try to run a first energy management. May fail due to config incomplete.
|
|
||||||
self.run()
|
|
||||||
# Remember energy run datetime.
|
# Remember energy run datetime.
|
||||||
EnergyManagement._last_datetime = current_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:
|
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)
|
logger.error(message)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -328,7 +330,8 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
|
|||||||
try:
|
try:
|
||||||
self.run()
|
self.run()
|
||||||
except Exception as e:
|
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)
|
logger.error(message)
|
||||||
|
|
||||||
# Remember the energy management run - keep on interval even if we missed some intervals
|
# Remember the energy management run - keep on interval even if we missed some intervals
|
||||||
|
@ -1,9 +1,20 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
|
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
||||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
|
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):
|
class ElecPriceCommonSettings(SettingsBaseModel):
|
||||||
@ -21,3 +32,13 @@ class ElecPriceCommonSettings(SettingsBaseModel):
|
|||||||
provider_settings: Optional[ElecPriceImportCommonSettings] = Field(
|
provider_settings: Optional[ElecPriceImportCommonSettings] = Field(
|
||||||
default=None, description="Provider settings", examples=[None]
|
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 typing import Optional, Union
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
|
from akkudoktoreos.prediction.loadabc import LoadProvider
|
||||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
||||||
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
|
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
|
||||||
|
from akkudoktoreos.prediction.prediction import get_prediction
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
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):
|
class LoadCommonSettings(SettingsBaseModel):
|
||||||
@ -24,3 +34,11 @@ class LoadCommonSettings(SettingsBaseModel):
|
|||||||
provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = (
|
provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = (
|
||||||
Field(default=None, description="Provider settings", examples=[None])
|
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.config.configabc import SettingsBaseModel
|
||||||
from akkudoktoreos.core.logging import get_logger
|
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.prediction.pvforecastimport import PVForecastImportCommonSettings
|
||||||
from akkudoktoreos.utils.docs import get_model_structure_from_examples
|
from akkudoktoreos.utils.docs import get_model_structure_from_examples
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
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):
|
class PVForecastPlaneSetting(SettingsBaseModel):
|
||||||
@ -130,6 +140,16 @@ class PVForecastCommonSettings(SettingsBaseModel):
|
|||||||
|
|
||||||
max_planes: ClassVar[int] = 6 # Maximum number of planes that can be set
|
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")
|
@field_validator("planes")
|
||||||
def validate_planes(
|
def validate_planes(
|
||||||
cls, planes: Optional[list[PVForecastPlaneSetting]]
|
cls, planes: Optional[list[PVForecastPlaneSetting]]
|
||||||
|
@ -2,11 +2,22 @@
|
|||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
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
|
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):
|
class WeatherCommonSettings(SettingsBaseModel):
|
||||||
"""Weather Forecast Configuration."""
|
"""Weather Forecast Configuration."""
|
||||||
@ -20,3 +31,13 @@ class WeatherCommonSettings(SettingsBaseModel):
|
|||||||
provider_settings: Optional[WeatherImportCommonSettings] = Field(
|
provider_settings: Optional[WeatherImportCommonSettings] = Field(
|
||||||
default=None, description="Provider settings", examples=[None]
|
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
|
from monsterui.franken import H4, Card, NotStr, Script
|
||||||
|
|
||||||
BokehJS = [
|
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(
|
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",
|
crossorigin="anonymous",
|
||||||
),
|
),
|
||||||
Script(
|
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(
|
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(
|
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",
|
crossorigin="anonymous",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
from typing import Any, Optional, Union
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
from fasthtml.common import H1, Div, Li
|
from fasthtml.common import H1, Div, Li
|
||||||
|
|
||||||
# from mdit_py_plugins import plugin1, plugin2
|
|
||||||
from monsterui.foundations import stringify
|
from monsterui.foundations import stringify
|
||||||
from monsterui.franken import (
|
from monsterui.franken import (
|
||||||
Button,
|
Button,
|
||||||
@ -13,6 +11,7 @@ from monsterui.franken import (
|
|||||||
Details,
|
Details,
|
||||||
DivLAligned,
|
DivLAligned,
|
||||||
DivRAligned,
|
DivRAligned,
|
||||||
|
Form,
|
||||||
Grid,
|
Grid,
|
||||||
Input,
|
Input,
|
||||||
P,
|
P,
|
||||||
@ -70,8 +69,22 @@ def ScrollArea(
|
|||||||
|
|
||||||
|
|
||||||
def ConfigCard(
|
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:
|
) -> 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(
|
return Card(
|
||||||
Details(
|
Details(
|
||||||
Summary(
|
Summary(
|
||||||
@ -85,24 +98,45 @@ def ConfigCard(
|
|||||||
P(read_only),
|
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",
|
cls="list-none",
|
||||||
),
|
),
|
||||||
Grid(
|
Grid(
|
||||||
P(description),
|
P(description),
|
||||||
P(config_type),
|
P(config_type),
|
||||||
),
|
),
|
||||||
|
# Default
|
||||||
Grid(
|
Grid(
|
||||||
DivRAligned(
|
DivRAligned(P("default")),
|
||||||
P("default") if read_only == "rw" else P(""),
|
P(default),
|
||||||
),
|
|
||||||
P(default) if read_only == "rw" else P(""),
|
|
||||||
)
|
)
|
||||||
if read_only == "rw"
|
if read_only == "rw"
|
||||||
else None,
|
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",
|
cls="space-y-4 gap-4",
|
||||||
|
open=update_open,
|
||||||
),
|
),
|
||||||
cls="w-full",
|
cls="w-full",
|
||||||
)
|
)
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
|
import json
|
||||||
from typing import Any, Dict, List, Optional, Sequence, TypeVar, Union
|
from typing import Any, Dict, List, Optional, Sequence, TypeVar, Union
|
||||||
|
|
||||||
import requests
|
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.fields import ComputedFieldInfo, FieldInfo
|
||||||
from pydantic_core import PydanticUndefined
|
from pydantic_core import PydanticUndefined
|
||||||
|
|
||||||
@ -15,6 +16,10 @@ config_eos = get_config()
|
|||||||
|
|
||||||
T = TypeVar("T")
|
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(
|
def get_nested_value(
|
||||||
dictionary: Union[Dict[str, Any], List[Any]],
|
dictionary: Union[Dict[str, Any], List[Any]],
|
||||||
@ -151,8 +156,8 @@ def configuration(values: dict) -> list[dict]:
|
|||||||
config["type"] = (
|
config["type"] = (
|
||||||
type_description.replace("typing.", "")
|
type_description.replace("typing.", "")
|
||||||
.replace("pathlib.", "")
|
.replace("pathlib.", "")
|
||||||
.replace("[", "[ ")
|
|
||||||
.replace("NoneType", "None")
|
.replace("NoneType", "None")
|
||||||
|
.replace("<class 'float'>", "float")
|
||||||
)
|
)
|
||||||
configs.append(config)
|
configs.append(config)
|
||||||
found_basic = True
|
found_basic = True
|
||||||
@ -171,20 +176,16 @@ def configuration(values: dict) -> list[dict]:
|
|||||||
return sorted(configs, key=lambda x: x["name"])
|
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.
|
"""Fetch and process configuration data from the specified EOS server.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
eos_host (Optional[str]): The hostname of the server.
|
eos_host (str): The hostname of the EOS server.
|
||||||
eos_port (Optional[Union[str, int]]): The port of the server.
|
eos_port (Union[str, int]): The port of the EOS server.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List[dict]: A list of processed configuration entries.
|
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}"
|
server = f"http://{eos_host}:{eos_port}"
|
||||||
|
|
||||||
# Get current configuration from server
|
# 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)
|
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.
|
"""Create a visual representation of the configuration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
eos_host (Optional[str]): The hostname of the EOS server.
|
eos_host (str): The hostname of the EOS server.
|
||||||
eos_port (Optional[Union[str, int]]): The port 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:
|
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 = []
|
rows = []
|
||||||
last_category = ""
|
last_category = ""
|
||||||
for config in get_configuration(eos_host, eos_port):
|
for config in configuration:
|
||||||
category = config["name"].split(".")[0]
|
category = config["name"].split(".")[0]
|
||||||
if category != last_category:
|
if category != last_category:
|
||||||
rows.append(P(category))
|
rows.append(P(category))
|
||||||
rows.append(DividerLine())
|
rows.append(DividerLine())
|
||||||
last_category = category
|
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(
|
rows.append(
|
||||||
ConfigCard(
|
ConfigCard(
|
||||||
config["name"],
|
config["name"],
|
||||||
@ -228,48 +241,59 @@ def Configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]])
|
|||||||
config["value"],
|
config["value"],
|
||||||
config["default"],
|
config["default"],
|
||||||
config["description"],
|
config["description"],
|
||||||
|
update_error,
|
||||||
|
update_value,
|
||||||
|
update_open,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return Div(*rows, cls="space-y-4")
|
return Div(*rows, cls="space-y-4")
|
||||||
|
|
||||||
|
|
||||||
def ConfigurationOrg(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> Table:
|
def ConfigKeyUpdate(eos_host: str, eos_port: Union[str, int], key: str, value: str) -> P:
|
||||||
"""Create a visual representation of the configuration.
|
"""Update configuration key and create a visual representation of the configuration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
eos_host (Optional[str]): The hostname of the EOS server.
|
eos_host (str): The hostname of the EOS server.
|
||||||
eos_port (Optional[Union[str, int]]): The port 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:
|
Returns:
|
||||||
Table: A `monsterui.franken.Table` component displaying configuration details.
|
rows: Rows of configuration details.
|
||||||
"""
|
"""
|
||||||
flds = "Name", "Type", "RO/RW", "Value", "Default", "Description"
|
server = f"http://{eos_host}:{eos_port}"
|
||||||
rows = [
|
path = key.replace(".", "/")
|
||||||
Tr(
|
try:
|
||||||
Td(
|
data = json.loads(value)
|
||||||
config["name"],
|
except:
|
||||||
cls="max-w-64 text-wrap break-all",
|
if value in ("None", "none", "Null", "null"):
|
||||||
),
|
data = None
|
||||||
Td(
|
else:
|
||||||
config["type"],
|
data = value
|
||||||
cls="max-w-48 text-wrap break-all",
|
|
||||||
),
|
error = None
|
||||||
Td(
|
result = None
|
||||||
config["read-only"],
|
try:
|
||||||
cls="max-w-24 text-wrap break-all",
|
result = requests.put(f"{server}/v1/config/{path}", json=data)
|
||||||
),
|
result.raise_for_status()
|
||||||
Td(
|
except requests.exceptions.HTTPError as err:
|
||||||
config["value"],
|
if result:
|
||||||
cls="max-w-md text-wrap break-all",
|
detail = result.json()["detail"]
|
||||||
),
|
else:
|
||||||
Td(config["default"], cls="max-w-48 text-wrap break-all"),
|
detail = "No details"
|
||||||
Td(
|
error = f"Can not set {key} on {server}: {err}, {detail}"
|
||||||
config["description"],
|
# Mark all updates as closed
|
||||||
cls="max-w-prose text-wrap",
|
for k in config_update_latest:
|
||||||
),
|
config_update_latest[k]["open"] = False
|
||||||
cls="",
|
# Remember this update as latest one
|
||||||
)
|
config_update_latest[key] = {
|
||||||
for config in get_configuration(eos_host, eos_port)
|
"error": error,
|
||||||
]
|
"result": result.json() if result else None,
|
||||||
head = Thead(*map(Th, flds), cls="text-left")
|
"value": value,
|
||||||
return Table(head, Tbody(*rows), cls="w-full uk-table uk-table-divider uk-table-striped")
|
"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(
|
path: str = FastapiPath(
|
||||||
..., description="The nested path to the configuration key (e.g., general/latitude)."
|
..., 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:
|
) -> ConfigEOS:
|
||||||
"""Update a nested key or index in the config model.
|
"""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())
|
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Error on prediction update: {e}{trace}",
|
detail=f"Error on prediction update: {e}\n{trace}",
|
||||||
)
|
)
|
||||||
return Response()
|
return Response()
|
||||||
|
|
||||||
|
@ -12,18 +12,17 @@ from monsterui.core import FastHTML, Theme
|
|||||||
|
|
||||||
from akkudoktoreos.config.config import get_config
|
from akkudoktoreos.config.config import get_config
|
||||||
from akkudoktoreos.core.logging import get_logger
|
from akkudoktoreos.core.logging import get_logger
|
||||||
from akkudoktoreos.server.dash.bokeh import BokehJS
|
|
||||||
from akkudoktoreos.server.dash.components import Page
|
|
||||||
|
|
||||||
# Pages
|
# 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.demo import Demo
|
||||||
from akkudoktoreos.server.dash.footer import Footer
|
from akkudoktoreos.server.dash.footer import Footer
|
||||||
from akkudoktoreos.server.dash.hello import Hello
|
from akkudoktoreos.server.dash.hello import Hello
|
||||||
from akkudoktoreos.server.server import get_default_host, wait_for_port_free
|
from akkudoktoreos.server.server import get_default_host, wait_for_port_free
|
||||||
|
|
||||||
# from akkudoktoreos.server.dash.altair import AltairJS
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
config_eos = get_config()
|
config_eos = get_config()
|
||||||
|
|
||||||
@ -37,8 +36,7 @@ args: Optional[argparse.Namespace] = None
|
|||||||
|
|
||||||
|
|
||||||
# Get frankenui and tailwind headers via CDN using Theme.green.headers()
|
# Get frankenui and tailwind headers via CDN using Theme.green.headers()
|
||||||
# Add altair headers
|
# Add Bokeh headers
|
||||||
# hdrs=(Theme.green.headers(highlightjs=True), AltairJS,)
|
|
||||||
hdrs = (
|
hdrs = (
|
||||||
Theme.green.headers(highlightjs=True),
|
Theme.green.headers(highlightjs=True),
|
||||||
BokehJS,
|
BokehJS,
|
||||||
@ -94,6 +92,7 @@ def get_eosdash(): # type: ignore
|
|||||||
"EOSdash": "/eosdash/hello",
|
"EOSdash": "/eosdash/hello",
|
||||||
"Config": "/eosdash/configuration",
|
"Config": "/eosdash/configuration",
|
||||||
"Demo": "/eosdash/demo",
|
"Demo": "/eosdash/demo",
|
||||||
|
"Admin": "/eosdash/admin",
|
||||||
},
|
},
|
||||||
Hello(),
|
Hello(),
|
||||||
Footer(*eos_server()),
|
Footer(*eos_server()),
|
||||||
@ -121,6 +120,21 @@ def get_eosdash_hello(): # type: ignore
|
|||||||
return Hello()
|
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")
|
@app.get("/eosdash/configuration")
|
||||||
def get_eosdash_configuration(): # type: ignore
|
def get_eosdash_configuration(): # type: ignore
|
||||||
"""Serves the EOSdash Configuration page.
|
"""Serves the EOSdash Configuration page.
|
||||||
@ -131,6 +145,11 @@ def get_eosdash_configuration(): # type: ignore
|
|||||||
return Configuration(*eos_server())
|
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")
|
@app.get("/eosdash/demo")
|
||||||
def get_eosdash_demo(): # type: ignore
|
def get_eosdash_demo(): # type: ignore
|
||||||
"""Serves the EOSdash Demo page.
|
"""Serves the EOSdash Demo page.
|
||||||
|
@ -59,8 +59,8 @@ def test_invalid_provider(provider, config_eos):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
config_eos.merge_settings_from_dict(settings)
|
with pytest.raises(ValueError, match="not a valid electricity price provider"):
|
||||||
assert not provider.enabled()
|
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)
|
with pytest.raises(ValueError, match="not a valid PV forecast provider"):
|
||||||
assert not provider.enabled()
|
config_eos.merge_settings_from_dict(settings)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------
|
# ------------------------------------------------
|
||||||
|
@ -79,8 +79,8 @@ def test_invalid_provider(provider, config_eos):
|
|||||||
"provider": "<invalid>",
|
"provider": "<invalid>",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
config_eos.merge_settings_from_dict(settings)
|
with pytest.raises(ValueError, match="not a valid weather provider"):
|
||||||
assert not provider.enabled()
|
config_eos.merge_settings_from_dict(settings)
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_coordinates(provider, config_eos):
|
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)
|
with pytest.raises(ValueError, match="not a valid weather provider"):
|
||||||
assert provider.enabled() == False
|
config_eos.merge_settings_from_dict(settings)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------
|
# ------------------------------------------------
|
||||||
|
Loading…
x
Reference in New Issue
Block a user