Repository: Akkudoktor-EOS/EOS

Issue:
[BUG]: Negativ values in load_mean_adjusted
### Describe the issue:

I get incorrect values from the load prediction (load_mean_adjusted)

I put these values to /v1/measurement/data
[payload_load_measurement.json](https://github.com/user-attachments/files/18563754/payload_load_measurement.json)

Then I verfiied that the data is in the system (now there are already some more data than in the file above) via /v1/measurement/series?key=measurement_load0_mr (all other loadx are empty)
[measurement_export.json](https://github.com/user-attachments/files/18563739/measurement_export.json)

I get a negativ load prediction via /v1/prediction/list?key=load_mean_adjusted
[load_prediction.json](https://github.com/user-attachments/files/18563785/load_prediction.json)


### Reproduceable code example:

```python

```

### Error message:

```shell

```

### Version information:

[f09658578a9898dd29eecafe3ddaf99fc3677c29](https://github.com/Akkudoktor-EOS/EOS/commit/f09658578a9898dd29eecafe3ddaf99fc3677c29)
running via docker

Discussion:

Patch:
diff --git a/docs/_generated/openapi.md b/docs/_generated/openapi.md
index dff19f3..76f2c9c 100644
--- a/docs/_generated/openapi.md
+++ b/docs/_generated/openapi.md
@@ -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"
 }
 
diff --git a/openapi.json b/openapi.json
index 82891fc..b6c0eb8 100644
--- a/openapi.json
+++ b/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": {
diff --git a/src/akkudoktoreos/config/config.py b/src/akkudoktoreos/config/config.py
index adf3794..1996209 100644
--- a/src/akkudoktoreos/config/config.py
+++ b/src/akkudoktoreos/config/config.py
@@ -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:
diff --git a/src/akkudoktoreos/core/ems.py b/src/akkudoktoreos/core/ems.py
index 58ca3a1..d989792 100644
--- a/src/akkudoktoreos/core/ems.py
+++ b/src/akkudoktoreos/core/ems.py
@@ -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
diff --git a/src/akkudoktoreos/prediction/elecprice.py b/src/akkudoktoreos/prediction/elecprice.py
index b41359b..266ec1e 100644
--- a/src/akkudoktoreos/prediction/elecprice.py
+++ b/src/akkudoktoreos/prediction/elecprice.py
@@ -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}."
+        )
diff --git a/src/akkudoktoreos/prediction/load.py b/src/akkudoktoreos/prediction/load.py
index 5e25b4c..c46a705 100644
--- a/src/akkudoktoreos/prediction/load.py
+++ b/src/akkudoktoreos/prediction/load.py
@@ -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}.")
diff --git a/src/akkudoktoreos/prediction/loadakkudoktor.py b/src/akkudoktoreos/prediction/loadakkudoktor.py
index b10196a..cdeefbd 100644
--- a/src/akkudoktoreos/prediction/loadakkudoktor.py
+++ b/src/akkudoktoreos/prediction/loadakkudoktor.py
@@ -122,10 +122,11 @@ class LoadAkkudoktor(LoadProvider):
             }
             if date.day_of_week < 5:
                 # Monday to Friday (0..4)
-                values["load_mean_adjusted"] = hourly_stats[0] + weekday_adjust[date.hour]
+                value_adjusted = hourly_stats[0] + weekday_adjust[date.hour]
             else:
                 # Saturday, Sunday (5, 6)
-                values["load_mean_adjusted"] = hourly_stats[0] + weekend_adjust[date.hour]
+                value_adjusted = hourly_stats[0] + weekend_adjust[date.hour]
+            values["load_mean_adjusted"] = max(0, value_adjusted)
             self.update_value(date, values)
             date += to_duration("1 hour")
         # We are working on fresh data (no cache), report update time
diff --git a/src/akkudoktoreos/prediction/pvforecast.py b/src/akkudoktoreos/prediction/pvforecast.py
index bbfcc8e..8744f14 100644
--- a/src/akkudoktoreos/prediction/pvforecast.py
+++ b/src/akkudoktoreos/prediction/pvforecast.py
@@ -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]]
diff --git a/src/akkudoktoreos/prediction/weather.py b/src/akkudoktoreos/prediction/weather.py
index 94f12c3..60a7eba 100644
--- a/src/akkudoktoreos/prediction/weather.py
+++ b/src/akkudoktoreos/prediction/weather.py
@@ -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}."
+        )
diff --git a/src/akkudoktoreos/server/dash/admin.py b/src/akkudoktoreos/server/dash/admin.py
new file mode 100644
index 0000000..1a8fd09
--- /dev/null
+++ b/src/akkudoktoreos/server/dash/admin.py
@@ -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")
diff --git a/src/akkudoktoreos/server/dash/bokeh.py b/src/akkudoktoreos/server/dash/bokeh.py
index 4e27648..560b7f8 100644
--- a/src/akkudoktoreos/server/dash/bokeh.py
+++ b/src/akkudoktoreos/server/dash/bokeh.py
@@ -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",
     ),
 ]
diff --git a/src/akkudoktoreos/server/dash/components.py b/src/akkudoktoreos/server/dash/components.py
index 325ac3f..eb737c5 100644
--- a/src/akkudoktoreos/server/dash/components.py
+++ b/src/akkudoktoreos/server/dash/components.py
@@ -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")),
+                P(default),
+            )
+            if read_only == "rw"
+            else None,
+            # Set value
             Grid(
-                DivRAligned(
-                    P("default") if read_only == "rw" else P(""),
+                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",
+                    ),
                 ),
-                P(default) if read_only == "rw" else P(""),
             )
             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",
     )
diff --git a/src/akkudoktoreos/server/dash/configuration.py b/src/akkudoktoreos/server/dash/configuration.py
index df29f48..ec0caa2 100644
--- a/src/akkudoktoreos/server/dash/configuration.py
+++ b/src/akkudoktoreos/server/dash/configuration.py
@@ -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()))
diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py
index 7b0e441..323e1a7 100755
--- a/src/akkudoktoreos/server/eos.py
+++ b/src/akkudoktoreos/server/eos.py
@@ -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()
 
diff --git a/src/akkudoktoreos/server/eosdash.py b/src/akkudoktoreos/server/eosdash.py
index 06a6c79..7f9d4d5 100644
--- a/src/akkudoktoreos/server/eosdash.py
+++ b/src/akkudoktoreos/server/eosdash.py
@@ -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.


Test patch:
diff --git a/tests/test_elecpriceimport.py b/tests/test_elecpriceimport.py
index a2a09fd..420f15e 100644
--- a/tests/test_elecpriceimport.py
+++ b/tests/test_elecpriceimport.py
@@ -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)
 
 
 # ------------------------------------------------
diff --git a/tests/test_pvforecastimport.py b/tests/test_pvforecastimport.py
index 934e37d..a7664cc 100644
--- a/tests/test_pvforecastimport.py
+++ b/tests/test_pvforecastimport.py
@@ -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)
 
 
 # ------------------------------------------------
diff --git a/tests/test_weatherclearoutside.py b/tests/test_weatherclearoutside.py
index 623ed89..fe1b97d 100644
--- a/tests/test_weatherclearoutside.py
+++ b/tests/test_weatherclearoutside.py
@@ -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):
diff --git a/tests/test_weatherimport.py b/tests/test_weatherimport.py
index e445c01..1d66683 100644
--- a/tests/test_weatherimport.py
+++ b/tests/test_weatherimport.py
@@ -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)
 
 
 # ------------------------------------------------