mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-03-09 08:06:17 +00:00
feat: add Home Assistant and NodeRED adapters (#764)
Adapters for Home Assistant and NodeRED integration are added. Akkudoktor-EOS can now be run as Home Assistant add-on and standalone. As Home Assistant add-on EOS uses ingress to fully integrate the EOSdash dashboard in Home Assistant. The fix includes several bug fixes that are not directly related to the adapter implementation but are necessary to keep EOS running properly and to test and document the changes. * fix: development version scheme The development versioning scheme is adaptet to fit to docker and home assistant expectations. The new scheme is x.y.z and x.y.z.dev<hash>. Hash is only digits as expected by home assistant. Development version is appended by .dev as expected by docker. * fix: use mean value in interval on resampling for array When downsampling data use the mean value of all values within the new sampling interval. * fix: default battery ev soc and appliance wh Make the genetic simulation return default values for the battery SoC, electric vehicle SoC and appliance load if these assets are not used. * fix: import json string Strip outer quotes from JSON strings on import to be compliant to json.loads() expectation. * fix: default interval definition for import data Default interval must be defined in lowercase human definition to be accepted by pendulum. * fix: clearoutside schema change * feat: add adapters for integrations Adapters for Home Assistant and NodeRED integration are added. Akkudoktor-EOS can now be run as Home Assistant add-on and standalone. As Home Assistant add-on EOS uses ingress to fully integrate the EOSdash dashboard in Home Assistant. * feat: allow eos to be started with root permissions and drop priviledges Home assistant starts all add-ons with root permissions. Eos now drops root permissions if an applicable user is defined by paramter --run_as_user. The docker image defines the user eos to be used. * feat: make eos supervise and monitor EOSdash Eos now not only starts EOSdash but also monitors EOSdash during runtime and restarts EOSdash on fault. EOSdash logging is captured by EOS and forwarded to the EOS log to provide better visibility. * feat: add duration to string conversion Make to_duration to also return the duration as string on request. * chore: Use info logging to report missing optimization parameters In parameter preparation for automatic optimization an error was logged for missing paramters. Log is now down using the info level. * chore: make EOSdash use the EOS data directory for file import/ export EOSdash use the EOS data directory for file import/ export by default. This allows to use the configuration import/ export function also within docker images. * chore: improve EOSdash config tab display Improve display of JSON code and add more forms for config value update. * chore: make docker image file system layout similar to home assistant Only use /data directory for persistent data. This is handled as a docker volume. The /data volume is mapped to ~/.local/share/net.akkudoktor.eos if using docker compose. * chore: add home assistant add-on development environment Add VSCode devcontainer and task definition for home assistant add-on development. * chore: improve documentation
This commit is contained in:
@@ -19,6 +19,8 @@ over a specified period.
|
||||
|
||||
Documentation can be found at [Akkudoktor-EOS](https://akkudoktor-eos.readthedocs.io/en/latest/).
|
||||
|
||||
---
|
||||
|
||||
## Version Information
|
||||
|
||||
**Current Version:** {__version__}
|
||||
@@ -29,4 +31,5 @@ Documentation can be found at [Akkudoktor-EOS](https://akkudoktor-eos.readthedoc
|
||||
|
||||
|
||||
def About(**kwargs: Any) -> Div:
|
||||
global about_md
|
||||
return Markdown(about_md, **kwargs)
|
||||
|
||||
@@ -5,17 +5,13 @@ for the EOS dashboard.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import requests
|
||||
from fasthtml.common import Select
|
||||
from loguru import logger
|
||||
from monsterui.foundations import stringify
|
||||
from monsterui.franken import ( # Select, TODO: Select from FrankenUI does not work - using Select from FastHTML instead
|
||||
H3,
|
||||
Button,
|
||||
ButtonT,
|
||||
Card,
|
||||
Details,
|
||||
Div,
|
||||
@@ -28,33 +24,12 @@ from monsterui.franken import ( # Select, TODO: Select from FrankenUI does not
|
||||
Summary,
|
||||
UkIcon,
|
||||
)
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
from akkudoktoreos.server.dash.components import Error, Success
|
||||
from akkudoktoreos.server.dash.components import ConfigButton, Error, Success
|
||||
from akkudoktoreos.server.dash.configuration import get_nested_value
|
||||
from akkudoktoreos.server.dash.context import export_import_directory, request_url_for
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
# Directory to export files to, or to import files from
|
||||
export_import_directory = Path(user_config_dir("net.akkudoktor.eosdash", "akkudoktor"))
|
||||
|
||||
|
||||
def AdminButton(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Button:
|
||||
"""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 AdminCache(
|
||||
eos_host: str, eos_port: Union[str, int], data: Optional[dict], config: Optional[dict[str, Any]]
|
||||
@@ -111,9 +86,9 @@ def AdminCache(
|
||||
Grid(
|
||||
DivHStacked(
|
||||
UkIcon(icon="play"),
|
||||
AdminButton(
|
||||
ConfigButton(
|
||||
"Clear all",
|
||||
hx_post="/eosdash/admin",
|
||||
hx_post=request_url_for("/eosdash/admin"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='{"category": "cache", "action": "clear"}',
|
||||
@@ -132,9 +107,9 @@ def AdminCache(
|
||||
Grid(
|
||||
DivHStacked(
|
||||
UkIcon(icon="play"),
|
||||
AdminButton(
|
||||
ConfigButton(
|
||||
"Clear expired",
|
||||
hx_post="/eosdash/admin",
|
||||
hx_post=request_url_for("/eosdash/admin"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='{"category": "cache", "action": "clear-expired"}',
|
||||
@@ -301,14 +276,16 @@ def AdminConfig(
|
||||
)
|
||||
|
||||
# Update for display, in case we added a new file before
|
||||
import_from_file_names = [f.name for f in list(export_import_directory.glob("*.json"))]
|
||||
import_from_file_names = sorted([f.name for f in list(export_import_directory.glob("*.json"))])
|
||||
if config_backup is None:
|
||||
revert_to_backup_metadata_list = ["Backup list not available"]
|
||||
else:
|
||||
revert_to_backup_metadata_list = [
|
||||
f"{backup_meta['date_time']} {backup_meta['version']}"
|
||||
for backup_id, backup_meta in config_backup.items()
|
||||
]
|
||||
revert_to_backup_metadata_list = sorted(
|
||||
[
|
||||
f"{backup_meta['date_time']} {backup_meta['version']}"
|
||||
for backup_id, backup_meta in config_backup.items()
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
category,
|
||||
@@ -319,9 +296,9 @@ def AdminConfig(
|
||||
Grid(
|
||||
DivHStacked(
|
||||
UkIcon(icon="play"),
|
||||
AdminButton(
|
||||
ConfigButton(
|
||||
"Save to file",
|
||||
hx_post="/eosdash/admin",
|
||||
hx_post=request_url_for("/eosdash/admin"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='{"category": "configuration", "action": "save_to_file"}',
|
||||
@@ -341,9 +318,9 @@ def AdminConfig(
|
||||
Grid(
|
||||
DivHStacked(
|
||||
UkIcon(icon="play"),
|
||||
AdminButton(
|
||||
ConfigButton(
|
||||
"Revert to backup",
|
||||
hx_post="/eosdash/admin",
|
||||
hx_post=request_url_for("/eosdash/admin"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='js:{ "category": "configuration", "action": "revert_to_backup", "backup_metadata": document.querySelector("[name=\'selected_backup_metadata\']").value }',
|
||||
@@ -352,6 +329,7 @@ def AdminConfig(
|
||||
*Options(*revert_to_backup_metadata_list),
|
||||
id="backup_metadata",
|
||||
name="selected_backup_metadata", # Name of hidden input field with selected value
|
||||
cls="border rounded px-3 py-2 mr-2",
|
||||
placeholder="Select backup",
|
||||
),
|
||||
),
|
||||
@@ -368,9 +346,9 @@ def AdminConfig(
|
||||
Grid(
|
||||
DivHStacked(
|
||||
UkIcon(icon="play"),
|
||||
AdminButton(
|
||||
ConfigButton(
|
||||
"Export to file",
|
||||
hx_post="/eosdash/admin",
|
||||
hx_post=request_url_for("/eosdash/admin"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='js:{"category": "configuration", "action": "export_to_file", "export_to_file_tag": document.querySelector("[name=\'chosen_export_file_tag\']").value }',
|
||||
@@ -398,9 +376,9 @@ def AdminConfig(
|
||||
Grid(
|
||||
DivHStacked(
|
||||
UkIcon(icon="play"),
|
||||
AdminButton(
|
||||
ConfigButton(
|
||||
"Import from file",
|
||||
hx_post="/eosdash/admin",
|
||||
hx_post=request_url_for("/eosdash/admin"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='js:{ "category": "configuration", "action": "import_from_file", "import_file_name": document.querySelector("[name=\'selected_import_file_name\']").value }',
|
||||
@@ -409,6 +387,7 @@ def AdminConfig(
|
||||
*Options(*import_from_file_names),
|
||||
id="import_file_name",
|
||||
name="selected_import_file_name", # Name of hidden input field with selected value
|
||||
cls="border rounded px-3 py-2 mr-2",
|
||||
placeholder="Select file",
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,35 +2,13 @@
|
||||
# MIT license
|
||||
from typing import Optional
|
||||
|
||||
import bokeh
|
||||
from bokeh.embed import components
|
||||
from bokeh.models import Plot
|
||||
from monsterui.franken import H4, Card, NotStr, Script
|
||||
from bokeh.resources import INLINE
|
||||
from monsterui.franken import H4, Card, NotStr
|
||||
|
||||
bokeh_version = bokeh.__version__
|
||||
|
||||
BokehJS = [
|
||||
Script(
|
||||
src=f"https://cdn.bokeh.org/bokeh/release/bokeh-{bokeh_version}.min.js",
|
||||
crossorigin="anonymous",
|
||||
),
|
||||
Script(
|
||||
src=f"https://cdn.bokeh.org/bokeh/release/bokeh-widgets-{bokeh_version}.min.js",
|
||||
crossorigin="anonymous",
|
||||
),
|
||||
Script(
|
||||
src=f"https://cdn.bokeh.org/bokeh/release/bokeh-tables-{bokeh_version}.min.js",
|
||||
crossorigin="anonymous",
|
||||
),
|
||||
Script(
|
||||
src=f"https://cdn.bokeh.org/bokeh/release/bokeh-gl-{bokeh_version}.min.js",
|
||||
crossorigin="anonymous",
|
||||
),
|
||||
Script(
|
||||
src=f"https://cdn.bokeh.org/bokeh/release/bokeh-mathjax-{bokeh_version}.min.js",
|
||||
crossorigin="anonymous",
|
||||
),
|
||||
]
|
||||
# Javascript for bokeh - to be included by the page
|
||||
BokehJS = [NotStr(INLINE.render_css()), NotStr(INLINE.render_js())]
|
||||
|
||||
|
||||
def bokey_apply_theme_to_plot(plot: Plot, dark: bool) -> None:
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
from typing import Any, Optional, Union
|
||||
import json
|
||||
from typing import Any, Callable, Optional, Union
|
||||
|
||||
from fasthtml.common import H1, Button, Div, Li
|
||||
from fasthtml.common import H1, Button, Div, Li, Select
|
||||
from monsterui.daisy import (
|
||||
Alert,
|
||||
AlertT,
|
||||
)
|
||||
from monsterui.foundations import stringify
|
||||
from monsterui.franken import ( # Button, Does not pass hx_vals
|
||||
from monsterui.franken import ( # Select: Does not work - using Select from FastHTML instead;; Button: Does not pass hx_vals - using Button from FastHTML instead
|
||||
H3,
|
||||
ButtonT,
|
||||
Card,
|
||||
Code,
|
||||
Container,
|
||||
ContainerT,
|
||||
Details,
|
||||
DivHStacked,
|
||||
DivLAligned,
|
||||
DivRAligned,
|
||||
Form,
|
||||
Grid,
|
||||
Input,
|
||||
Option,
|
||||
P,
|
||||
Pre,
|
||||
Summary,
|
||||
TabContainer,
|
||||
UkIcon,
|
||||
)
|
||||
|
||||
from akkudoktoreos.server.dash.context import request_url_for
|
||||
|
||||
scrollbar_viewport_styles = (
|
||||
"scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch;"
|
||||
)
|
||||
@@ -71,11 +79,59 @@ def ScrollArea(
|
||||
)
|
||||
|
||||
|
||||
def JsonView(data: Any) -> Pre:
|
||||
"""Render structured data as formatted JSON inside a styled <pre> block.
|
||||
|
||||
The data is serialized to JSON using indentation for readability and
|
||||
UTF-8 characters are preserved. The JSON is wrapped in a <code> element
|
||||
with a JSON language class to support syntax highlighting, and then
|
||||
placed inside a <pre> container with MonsterUI-compatible styling.
|
||||
|
||||
The JSON output is height-constrained and scrollable to safely display
|
||||
large payloads without breaking the page layout.
|
||||
|
||||
Args:
|
||||
data: Any JSON-serializable Python object to render.
|
||||
|
||||
Returns:
|
||||
A FastHTML `Pre` element containing a formatted JSON representation
|
||||
of the input data.
|
||||
"""
|
||||
code_str = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
return Pre(
|
||||
Code(code_str, cls="language-json"),
|
||||
cls="rounded-lg bg-muted p-3 max-h-[30vh] overflow-y-auto overflow-x-hidden whitespace-pre-wrap",
|
||||
)
|
||||
|
||||
|
||||
def TextView(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Pre:
|
||||
"""Render plain text with preserved line breaks and wrapped long lines.
|
||||
|
||||
This view uses a <pre> element with whitespace wrapping enabled so that
|
||||
newline characters are respected while long lines are wrapped instead
|
||||
of causing horizontal scrolling.
|
||||
|
||||
Args:
|
||||
*c (Any): Positional arguments representing the TextView content.
|
||||
cls (Optional[Union[str, tuple]]): Additional CSS classes for styling. Defaults to None.
|
||||
**kwargs (Any): Additional keyword arguments passed to the `Pre`.
|
||||
|
||||
Returns:
|
||||
A FastHTML `Pre` element that displays the text with preserved
|
||||
formatting and line wrapping.
|
||||
"""
|
||||
new_cls = "whitespace-pre-wrap"
|
||||
if cls:
|
||||
new_cls += f"{stringify(cls)}"
|
||||
kwargs["cls"] = new_cls
|
||||
return Pre(*c, **kwargs)
|
||||
|
||||
|
||||
def Success(*c: Any) -> Alert:
|
||||
return Alert(
|
||||
DivLAligned(
|
||||
UkIcon("check"),
|
||||
P(*c),
|
||||
TextView(*c),
|
||||
),
|
||||
cls=AlertT.success,
|
||||
)
|
||||
@@ -85,12 +141,321 @@ def Error(*c: Any) -> Alert:
|
||||
return Alert(
|
||||
DivLAligned(
|
||||
UkIcon("triangle-alert"),
|
||||
P(*c),
|
||||
TextView(*c),
|
||||
),
|
||||
cls=AlertT.error,
|
||||
)
|
||||
|
||||
|
||||
def ConfigButton(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Button:
|
||||
"""Creates a styled button for configuration 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 configuration actions.
|
||||
"""
|
||||
new_cls = f"px-4 py-2 rounded {ButtonT.primary}"
|
||||
if cls:
|
||||
new_cls += f"{stringify(cls)}"
|
||||
kwargs["cls"] = new_cls
|
||||
return Button(*c, submit=False, **kwargs)
|
||||
|
||||
|
||||
def make_config_update_form() -> Callable[[str, str], Grid]:
|
||||
"""Factory for a form that sets a single configuration value.
|
||||
|
||||
Returns:
|
||||
A function (config_name: str, value: str) -> Grid
|
||||
"""
|
||||
|
||||
def ConfigUpdateForm(config_name: str, value: str) -> Grid:
|
||||
config_id = config_name.lower().replace(".", "-")
|
||||
|
||||
return Grid(
|
||||
DivRAligned(P("update")),
|
||||
Grid(
|
||||
Form(
|
||||
Input(value="update", type="hidden", id="action"),
|
||||
Input(value=config_name, type="hidden", id="key"),
|
||||
Input(value=value, type="text", id="value"),
|
||||
hx_put=request_url_for("/eosdash/configuration"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
),
|
||||
),
|
||||
id=f"{config_id}-update-form",
|
||||
)
|
||||
|
||||
return ConfigUpdateForm
|
||||
|
||||
|
||||
def make_config_update_value_form(
|
||||
available_values: list[str],
|
||||
) -> Callable[[str, str], Grid]:
|
||||
"""Factory for a form that sets a single configuration value with pre-set avaliable values.
|
||||
|
||||
Args:
|
||||
available_values: Allowed values for the configuration
|
||||
|
||||
Returns:
|
||||
A function (config_name: str, value: str) -> Grid
|
||||
"""
|
||||
|
||||
def ConfigUpdateValueForm(config_name: str, value: str) -> Grid:
|
||||
config_id = config_name.lower().replace(".", "-")
|
||||
|
||||
return Grid(
|
||||
DivRAligned(P("update value")),
|
||||
DivHStacked(
|
||||
ConfigButton(
|
||||
"Set",
|
||||
hx_put=request_url_for("/eosdash/configuration"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals=f"""js:{{
|
||||
action: "update",
|
||||
key: "{config_name}",
|
||||
value: document
|
||||
.querySelector("[name='{config_id}_selected_value']")
|
||||
.value
|
||||
}}""",
|
||||
),
|
||||
Select(
|
||||
Option("Select a value...", value="", selected=True, disabled=True),
|
||||
*[
|
||||
Option(
|
||||
val,
|
||||
value=val,
|
||||
selected=(val == value),
|
||||
)
|
||||
for val in available_values
|
||||
],
|
||||
id=f"{config_id}-value-select",
|
||||
name=f"{config_id}_selected_value",
|
||||
required=True,
|
||||
cls="border rounded px-3 py-2 mr-2 col-span-4",
|
||||
),
|
||||
),
|
||||
id=f"{config_id}-update-value-form",
|
||||
)
|
||||
|
||||
return ConfigUpdateValueForm
|
||||
|
||||
|
||||
def make_config_update_list_form(available_values: list[str]) -> Callable[[str, str], Grid]:
|
||||
"""Factory function that creates a ConfigUpdateListForm with pre-set available values.
|
||||
|
||||
Args:
|
||||
available_values: List of available values to choose from
|
||||
|
||||
Returns:
|
||||
A function that creates ConfigUpdateListForm instances with the given available_values.
|
||||
The returned function takes (config_name: str, value: str) and returns a Grid.
|
||||
"""
|
||||
|
||||
def ConfigUpdateListForm(config_name: str, value: str) -> Grid:
|
||||
"""Creates a card with a form to add/remove values from a list.
|
||||
|
||||
Sends to "/eosdash/configuration":
|
||||
The form sends an HTTP PUT request with the following parameters:
|
||||
|
||||
- key (str): The configuration key name (value of config_name parameter)
|
||||
- value (str): A JSON string representing the updated list of values
|
||||
|
||||
The value parameter will always be a valid JSON string representation of a list.
|
||||
|
||||
Args:
|
||||
config_name: The name of the configuration
|
||||
value (str): The current value of the configuration, a list of values in json format.
|
||||
"""
|
||||
current_values = json.loads(value)
|
||||
if current_values is None:
|
||||
current_values = []
|
||||
config_id = config_name.lower().replace(".", "-")
|
||||
|
||||
return Grid(
|
||||
DivRAligned(P("update list")),
|
||||
Grid(
|
||||
# Form to add new value to list
|
||||
DivHStacked(
|
||||
ConfigButton(
|
||||
"Add",
|
||||
hx_put=request_url_for("/eosdash/configuration"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals=f"""js:{{
|
||||
action: "update",
|
||||
key: "{config_name}",
|
||||
value: JSON.stringify(
|
||||
[...new Set([
|
||||
...{json.dumps(current_values)},
|
||||
document.querySelector("[name='{config_id}_selected_add_value']").value.trim()
|
||||
])].filter(v => v !== "")
|
||||
)
|
||||
}}""",
|
||||
),
|
||||
Select(
|
||||
Option("Select a value...", value="", selected=True, disabled=True),
|
||||
*[
|
||||
Option(val, value=val, disabled=val in current_values)
|
||||
for val in available_values
|
||||
],
|
||||
id=f"{config_id}-add-value-select",
|
||||
name=f"{config_id}_selected_add_value", # Name of hidden input with selected value
|
||||
required=True,
|
||||
cls="border rounded px-3 py-2 mr-2 col-span-4",
|
||||
),
|
||||
),
|
||||
# Form to delete value from list
|
||||
DivHStacked(
|
||||
ConfigButton(
|
||||
"Delete",
|
||||
hx_put=request_url_for("/eosdash/configuration"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals=f"""js:{{
|
||||
action: "update",
|
||||
key: "{config_name}",
|
||||
value: JSON.stringify(
|
||||
[...new Set([
|
||||
...{json.dumps(current_values)}
|
||||
])].filter(v => v !== document.querySelector("[name='{config_id}_selected_delete_value']").value.trim())
|
||||
)
|
||||
}}""",
|
||||
),
|
||||
Select(
|
||||
Option("Select a value...", value="", selected=True, disabled=True),
|
||||
*[Option(val, value=val) for val in current_values],
|
||||
id=f"{config_id}-delete-value-select",
|
||||
name=f"{config_id}_selected_delete_value", # Name of hidden input with selected value
|
||||
required=True,
|
||||
cls="border rounded px-3 py-2 mr-2 col-span-4",
|
||||
),
|
||||
),
|
||||
cols=1,
|
||||
),
|
||||
id=f"{config_id}-update-list-form",
|
||||
)
|
||||
|
||||
# Return the function that creates a ConfigUpdateListForm instance
|
||||
return ConfigUpdateListForm
|
||||
|
||||
|
||||
def make_config_update_map_form(
|
||||
available_keys: list[str] | None = None,
|
||||
available_values: list[str] | None = None,
|
||||
) -> Callable[[str, str], Grid]:
|
||||
"""Factory function that creates a ConfigUpdateMapForm.
|
||||
|
||||
Args:
|
||||
available_keys: Optional list of allowed keys (None = free text)
|
||||
available_values: Optional list of allowed values (None = free text)
|
||||
|
||||
Returns:
|
||||
A function that creates ConfigUpdateMapForm instances.
|
||||
The returned function takes (config_name: str, value: str) and returns a Grid.
|
||||
"""
|
||||
|
||||
def ConfigUpdateMapForm(config_name: str, value: str) -> Grid:
|
||||
"""Creates a card with a form to add/update/delete entries in a map."""
|
||||
current_map: dict[str, str] = json.loads(value) or {}
|
||||
config_id = config_name.lower().replace(".", "-")
|
||||
|
||||
return Grid(
|
||||
DivRAligned(P("update map")),
|
||||
Grid(
|
||||
# Add / update key-value pair
|
||||
DivHStacked(
|
||||
ConfigButton(
|
||||
"Set",
|
||||
hx_put=request_url_for("/eosdash/configuration"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals=f"""js:{{
|
||||
action: "update",
|
||||
key: "{config_name}",
|
||||
value: JSON.stringify(
|
||||
Object.assign(
|
||||
{json.dumps(current_map)},
|
||||
{{
|
||||
[document.querySelector("[name='{config_id}_set_key']").value.trim()]:
|
||||
document.querySelector("[name='{config_id}_set_value']").value.trim()
|
||||
}}
|
||||
)
|
||||
)
|
||||
}}""",
|
||||
),
|
||||
(
|
||||
Select(
|
||||
Option("Select key...", value="", selected=True, disabled=True),
|
||||
*[Option(k, value=k) for k in (sorted(available_keys) or [])],
|
||||
name=f"{config_id}_set_key",
|
||||
cls="border rounded px-3 py-2 col-span-2",
|
||||
)
|
||||
if available_keys
|
||||
else Input(
|
||||
name=f"{config_id}_set_key",
|
||||
placeholder="Key",
|
||||
required=True,
|
||||
cls="border rounded px-3 py-2 col-span-2",
|
||||
),
|
||||
),
|
||||
(
|
||||
Select(
|
||||
Option("Select value...", value="", selected=True, disabled=True),
|
||||
*[Option(k, value=k) for k in (sorted(available_values) or [])],
|
||||
name=f"{config_id}_set_value",
|
||||
cls="border rounded px-3 py-2 col-span-2",
|
||||
)
|
||||
if available_values
|
||||
else Input(
|
||||
name=f"{config_id}_set_value",
|
||||
placeholder="Value",
|
||||
required=True,
|
||||
cls="border rounded px-3 py-2 col-span-2",
|
||||
),
|
||||
),
|
||||
),
|
||||
# Delete key
|
||||
DivHStacked(
|
||||
ConfigButton(
|
||||
"Delete",
|
||||
hx_put=request_url_for("/eosdash/configuration"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals=f"""js:{{
|
||||
action: "update",
|
||||
key: "{config_name}",
|
||||
value: JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries({json.dumps(current_map)})
|
||||
.filter(([k]) =>
|
||||
k !== document.querySelector("[name='{config_id}_delete_key']").value
|
||||
)
|
||||
)
|
||||
)
|
||||
}}""",
|
||||
),
|
||||
Select(
|
||||
Option("Select key...", value="", selected=True, disabled=True),
|
||||
*[Option(k, value=k) for k in sorted(current_map.keys())],
|
||||
name=f"{config_id}_delete_key",
|
||||
required=True,
|
||||
cls="border rounded px-3 py-2 col-span-4",
|
||||
),
|
||||
),
|
||||
cols=1,
|
||||
),
|
||||
id=f"{config_id}-update-map-form",
|
||||
)
|
||||
|
||||
return ConfigUpdateMapForm
|
||||
|
||||
|
||||
def ConfigCard(
|
||||
config_name: str,
|
||||
config_type: str,
|
||||
@@ -102,6 +467,7 @@ def ConfigCard(
|
||||
update_error: Optional[str],
|
||||
update_value: Optional[str],
|
||||
update_open: Optional[bool],
|
||||
update_form_factory: Optional[Callable[[str, str], Grid]] = None,
|
||||
) -> Card:
|
||||
"""Creates a styled configuration card for displaying configuration details.
|
||||
|
||||
@@ -113,7 +479,7 @@ def ConfigCard(
|
||||
config_name (str): The name of the configuration.
|
||||
config_type (str): The type of the configuration.
|
||||
read_only (str): Indicates if the configuration is read-only ("rw" for read-write,
|
||||
any other value indicates read-only).
|
||||
any other value indicates read-only).
|
||||
value (str): The current value of the configuration.
|
||||
default (str): The default value of the configuration.
|
||||
description (str): A description of the configuration.
|
||||
@@ -121,7 +487,9 @@ def ConfigCard(
|
||||
update_error (Optional[str]): The error message, if any, during the update process.
|
||||
update_value (Optional[str]): The value to be updated, if different from the current value.
|
||||
update_open (Optional[bool]): A flag indicating whether the update section of the card
|
||||
should be initially expanded.
|
||||
should be initially expanded.
|
||||
update_form_factory (Optional[Callable[[str, str], Grid]]): The factory to create a form to
|
||||
use to update the configuration value. Defaults to simple text input.
|
||||
|
||||
Returns:
|
||||
Card: A styled Card component containing the configuration details.
|
||||
@@ -131,6 +499,11 @@ def ConfigCard(
|
||||
update_value = value
|
||||
if not update_open:
|
||||
update_open = False
|
||||
if not update_form_factory:
|
||||
# Default update form
|
||||
update_form = make_config_update_form()(config_name, update_value)
|
||||
else:
|
||||
update_form = update_form_factory(config_name, update_value)
|
||||
if deprecated:
|
||||
if isinstance(deprecated, bool):
|
||||
deprecated = "Deprecated"
|
||||
@@ -147,12 +520,12 @@ def ConfigCard(
|
||||
P(read_only),
|
||||
),
|
||||
),
|
||||
P(value),
|
||||
JsonView(json.loads(value)),
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
Grid(
|
||||
P(description),
|
||||
TextView(description),
|
||||
P(config_type),
|
||||
)
|
||||
if not deprecated
|
||||
@@ -171,27 +544,18 @@ def ConfigCard(
|
||||
if read_only == "rw" and not deprecated
|
||||
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" and not deprecated
|
||||
else None,
|
||||
update_form if read_only == "rw" and not deprecated else None,
|
||||
# Last error
|
||||
Grid(
|
||||
DivRAligned(P("update error")),
|
||||
P(update_error),
|
||||
TextView(update_error),
|
||||
)
|
||||
if update_error
|
||||
else None,
|
||||
# Provide minimal update form on error if complex update_form is used
|
||||
make_config_update_form()(config_name, update_value)
|
||||
if update_error and update_form_factory is not None
|
||||
else None,
|
||||
cls="space-y-4 gap-4",
|
||||
open=update_open,
|
||||
),
|
||||
@@ -226,7 +590,7 @@ def DashboardFooter(*c: Any, path: str) -> Card:
|
||||
"""
|
||||
return Card(
|
||||
Container(*c, id="footer-content"),
|
||||
hx_get=f"{path}",
|
||||
hx_get=request_url_for(path),
|
||||
hx_trigger="every 5s",
|
||||
hx_target="#footer-content",
|
||||
hx_swap="innerHTML",
|
||||
@@ -266,7 +630,7 @@ def DashboardTabs(dashboard_items: dict[str, str]) -> Card:
|
||||
Li(
|
||||
DashboardTrigger(
|
||||
H3(menu),
|
||||
hx_get=f"{path}",
|
||||
hx_get=request_url_for(path),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='js:{ "dark": window.matchMedia("(prefers-color-scheme: dark)").matches }',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Sequence, TypeVar, Union
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Dict, List, Optional, TypeVar, Union
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
@@ -7,6 +8,7 @@ from monsterui.franken import (
|
||||
H3,
|
||||
H4,
|
||||
Card,
|
||||
CardTitle,
|
||||
Details,
|
||||
Div,
|
||||
DividerLine,
|
||||
@@ -15,6 +17,7 @@ from monsterui.franken import (
|
||||
Form,
|
||||
Grid,
|
||||
Input,
|
||||
LabelCheckboxX,
|
||||
P,
|
||||
Summary,
|
||||
UkIcon,
|
||||
@@ -25,7 +28,15 @@ from pydantic_core import PydanticUndefined
|
||||
from akkudoktoreos.config.config import ConfigEOS
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.prediction.pvforecast import PVForecastPlaneSetting
|
||||
from akkudoktoreos.server.dash.components import ConfigCard
|
||||
from akkudoktoreos.server.dash.components import (
|
||||
ConfigCard,
|
||||
JsonView,
|
||||
TextView,
|
||||
make_config_update_list_form,
|
||||
make_config_update_map_form,
|
||||
make_config_update_value_form,
|
||||
)
|
||||
from akkudoktoreos.server.dash.context import request_url_for
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -33,6 +44,14 @@ T = TypeVar("T")
|
||||
# Dictionary of config names and associated dictionary with keys "value", "result", "error", "open".
|
||||
config_update_latest: dict[str, dict[str, Optional[Union[str, bool]]]] = {}
|
||||
|
||||
# Current state of config displayed
|
||||
config_visible: dict[str, dict] = {
|
||||
"config-visible-read-only": {
|
||||
"label": "Configuration (read-only)",
|
||||
"visible": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_nested_value(
|
||||
dictionary: Union[Dict[str, Any], List[Any]],
|
||||
@@ -178,9 +197,9 @@ def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple
|
||||
return resolved_types
|
||||
|
||||
|
||||
def configuration(
|
||||
def create_config_details(
|
||||
model: type[PydanticBaseModel], values: dict, values_prefix: list[str] = []
|
||||
) -> list[dict]:
|
||||
) -> dict[str, dict]:
|
||||
"""Generate configuration details based on provided values and model metadata.
|
||||
|
||||
Args:
|
||||
@@ -189,9 +208,9 @@ def configuration(
|
||||
values_prefix (list[str]): A list of parent type names that prefixes the model values in the values.
|
||||
|
||||
Returns:
|
||||
list[dict]: A sorted list of configuration details, each represented as a dictionary.
|
||||
dict[dict]: A dictionary of configuration details, each represented as a dictionary.
|
||||
"""
|
||||
configs = []
|
||||
config_details: dict[str, dict] = {}
|
||||
inner_types: set[type[PydanticBaseModel]] = set()
|
||||
|
||||
for field_name, field_info in list(model.model_fields.items()) + list(
|
||||
@@ -244,7 +263,7 @@ def configuration(
|
||||
.replace("NoneType", "None")
|
||||
.replace("<class 'float'>", "float")
|
||||
)
|
||||
configs.append(config)
|
||||
config_details[str(config["name"])] = config
|
||||
found_basic = True
|
||||
else:
|
||||
new_parent_types = parent_types + nested_parent_types
|
||||
@@ -258,18 +277,18 @@ def configuration(
|
||||
)
|
||||
|
||||
extract_nested_models(field_info, [field_name])
|
||||
return sorted(configs, key=lambda x: x["name"])
|
||||
return config_details
|
||||
|
||||
|
||||
def get_configuration(eos_host: str, eos_port: Union[str, int]) -> list[dict]:
|
||||
"""Fetch and process configuration data from the specified EOS server.
|
||||
def get_config(eos_host: str, eos_port: Union[str, int]) -> dict[str, Any]:
|
||||
"""Fetch configuration data from the specified EOS server.
|
||||
|
||||
Args:
|
||||
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.
|
||||
dict[str, Any]: A dict of configuration data.
|
||||
"""
|
||||
server = f"http://{eos_host}:{eos_port}"
|
||||
|
||||
@@ -284,7 +303,7 @@ def get_configuration(eos_host: str, eos_port: Union[str, int]) -> list[dict]:
|
||||
warning_msg = f"Can not retrieve configuration from {server}: {e}, {detail}"
|
||||
logger.warning(warning_msg)
|
||||
|
||||
return configuration(ConfigEOS, config)
|
||||
return config
|
||||
|
||||
|
||||
def ConfigPlanesCard(
|
||||
@@ -341,7 +360,7 @@ def ConfigPlanesCard(
|
||||
# Create cards for all planes
|
||||
rows = []
|
||||
for i in range(0, max_planes):
|
||||
plane_config = configuration(
|
||||
plane_config = create_config_details(
|
||||
PVForecastPlaneSetting(),
|
||||
eos_planes_config,
|
||||
values_prefix=["pvforecast", "planes", str(i)],
|
||||
@@ -352,10 +371,12 @@ def ConfigPlanesCard(
|
||||
plane_value = json.dumps(eos_planes[i])
|
||||
else:
|
||||
plane_value = json.dumps(None)
|
||||
for config in plane_config:
|
||||
for config_key in sorted(plane_config.keys()):
|
||||
config = plane_config[config_key]
|
||||
update_error = config_update_latest.get(config["name"], {}).get("error") # type: ignore
|
||||
update_value = config_update_latest.get(config["name"], {}).get("value") # type: ignore
|
||||
update_open = config_update_latest.get(config["name"], {}).get("open") # type: ignore
|
||||
update_form_factory = None
|
||||
if update_open:
|
||||
planes_update_open = True
|
||||
plane_update_open = True
|
||||
@@ -368,6 +389,12 @@ def ConfigPlanesCard(
|
||||
error_msg = "update_error or update_value or update_open of wrong type."
|
||||
logger.error(error_msg)
|
||||
raise TypeError(error_msg)
|
||||
if config["name"].endswith("pvtechchoice"):
|
||||
update_form_factory = make_config_update_value_form(
|
||||
["crystSi", "CIS", "CdTe", "Unknown"]
|
||||
)
|
||||
elif config["name"].endswith("mountingplace"):
|
||||
update_form_factory = make_config_update_value_form(["free", "building"])
|
||||
plane_rows.append(
|
||||
ConfigCard(
|
||||
config["name"],
|
||||
@@ -380,6 +407,7 @@ def ConfigPlanesCard(
|
||||
update_error,
|
||||
update_value,
|
||||
update_open,
|
||||
update_form_factory,
|
||||
)
|
||||
)
|
||||
rows.append(
|
||||
@@ -396,7 +424,7 @@ def ConfigPlanesCard(
|
||||
P(read_only),
|
||||
),
|
||||
),
|
||||
P(plane_value),
|
||||
JsonView(json.loads(plane_value)),
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
@@ -421,12 +449,12 @@ def ConfigPlanesCard(
|
||||
P(read_only),
|
||||
),
|
||||
),
|
||||
P(value),
|
||||
JsonView(json.loads(value)),
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
Grid(
|
||||
P(description),
|
||||
TextView(description),
|
||||
P(config_type),
|
||||
),
|
||||
# Default
|
||||
@@ -441,9 +469,10 @@ def ConfigPlanesCard(
|
||||
DivRAligned(P("update")),
|
||||
Grid(
|
||||
Form(
|
||||
Input(value="update", type="hidden", id="action"),
|
||||
Input(value=config_name, type="hidden", id="key"),
|
||||
Input(value=planes_update_value, type="text", id="value"),
|
||||
hx_put="/eosdash/configuration",
|
||||
hx_put=request_url_for("/eosdash/configuration"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
),
|
||||
@@ -454,7 +483,7 @@ def ConfigPlanesCard(
|
||||
# Last error
|
||||
Grid(
|
||||
DivRAligned(P("update error")),
|
||||
P(planes_update_error),
|
||||
TextView(planes_update_error),
|
||||
)
|
||||
if planes_update_error
|
||||
else None,
|
||||
@@ -468,33 +497,150 @@ def ConfigPlanesCard(
|
||||
|
||||
|
||||
def Configuration(
|
||||
eos_host: str, eos_port: Union[str, int], configuration: Optional[list[dict]] = None
|
||||
eos_host: str,
|
||||
eos_port: Union[str, int],
|
||||
data: Optional[dict] = None,
|
||||
) -> Div:
|
||||
"""Create a visual representation of the configuration.
|
||||
|
||||
Args:
|
||||
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.
|
||||
data (Optional[dict], optional): Incoming data to trigger config actions. Defaults to None.
|
||||
|
||||
Returns:
|
||||
rows: Rows of configuration details.
|
||||
"""
|
||||
if not configuration:
|
||||
configuration = get_configuration(eos_host, eos_port)
|
||||
global config_visible
|
||||
dark = False
|
||||
|
||||
if data and data.get("action", None):
|
||||
if data.get("dark", None) == "true":
|
||||
dark = True
|
||||
if data["action"] == "visible":
|
||||
renderer = data.get("renderer", None)
|
||||
if renderer:
|
||||
config_visible[renderer]["visible"] = bool(data.get(f"{renderer}-visible", False))
|
||||
elif data["action"] == "update":
|
||||
# This data contains a new value for key
|
||||
key = data["key"]
|
||||
value_json_str: str = data.get("value", "")
|
||||
try:
|
||||
value = json.loads(value_json_str)
|
||||
except:
|
||||
if value_json_str in ("None", "none", "Null", "null"):
|
||||
value = None
|
||||
else:
|
||||
value = value_json_str
|
||||
|
||||
error = None
|
||||
config = None
|
||||
try:
|
||||
server = f"http://{eos_host}:{eos_port}"
|
||||
path = key.replace(".", "/")
|
||||
response = requests.put(f"{server}/v1/config/{path}", json=value, timeout=10)
|
||||
response.raise_for_status()
|
||||
config = response.json()
|
||||
except requests.exceptions.HTTPError as err:
|
||||
try:
|
||||
# Try to get 'detail' from the JSON response
|
||||
detail = response.json().get(
|
||||
"detail", f"No error details for value '{value}' '{response.text}'"
|
||||
)
|
||||
except ValueError:
|
||||
# Response is not JSON
|
||||
detail = f"No error details for value '{value}' '{response.text}'"
|
||||
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": config,
|
||||
"value": value_json_str,
|
||||
"open": True,
|
||||
}
|
||||
|
||||
# (Re-)read configuration details to be shure we display actual data
|
||||
config = get_config(eos_host, eos_port)
|
||||
|
||||
# Process configuration data
|
||||
config_details = create_config_details(ConfigEOS, config)
|
||||
|
||||
ConfigMenu = Card(
|
||||
# CheckboxGroup to toggle config data visibility
|
||||
Grid(
|
||||
*[
|
||||
LabelCheckboxX(
|
||||
label=config_visible[renderer]["label"],
|
||||
id=f"{renderer}-visible",
|
||||
name=f"{renderer}-visible",
|
||||
value="true",
|
||||
checked=config_visible[renderer]["visible"],
|
||||
hx_post=request_url_for("/eosdash/configuration"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='js:{ "action": "visible", "renderer": '
|
||||
+ '"'
|
||||
+ f"{renderer}"
|
||||
+ '", '
|
||||
+ '"dark": window.matchMedia("(prefers-color-scheme: dark)").matches '
|
||||
+ "}",
|
||||
# lbl_cls=f"text-{solution_color[renderer]}",
|
||||
)
|
||||
for renderer in list(config_visible.keys())
|
||||
],
|
||||
cols=4,
|
||||
),
|
||||
header=CardTitle("Choose What's Shown"),
|
||||
)
|
||||
|
||||
rows = []
|
||||
last_category = ""
|
||||
# find some special configuration values
|
||||
max_planes = 0
|
||||
for config in configuration:
|
||||
if config["name"] == "pvforecast.max_planes":
|
||||
try:
|
||||
max_planes = int(config["value"])
|
||||
except:
|
||||
max_planes = 0
|
||||
try:
|
||||
max_planes = int(config_details["pvforecast.max_planes"]["value"])
|
||||
except:
|
||||
max_planes = 0
|
||||
logger.debug(f"max_planes: {max_planes}")
|
||||
|
||||
try:
|
||||
homeassistant_entity_ids = json.loads(
|
||||
config_details["adapter.homeassistant.homeassistant_entity_ids"]["value"]
|
||||
)
|
||||
except:
|
||||
homeassistant_entity_ids = []
|
||||
logger.debug(f"homeassistant_entity_ids: {homeassistant_entity_ids}")
|
||||
|
||||
eos_solution_entity_ids = []
|
||||
try:
|
||||
eos_solution_entity_ids = json.loads(
|
||||
config_details["adapter.homeassistant.eos_solution_entity_ids"]["value"]
|
||||
)
|
||||
except:
|
||||
eos_solution_entity_ids = []
|
||||
logger.debug(f"eos_solution_entity_ids {eos_solution_entity_ids}")
|
||||
|
||||
eos_device_instruction_entity_ids = []
|
||||
try:
|
||||
eos_device_instruction_entity_ids = json.loads(
|
||||
config_details["adapter.homeassistant.eos_device_instruction_entity_ids"]["value"]
|
||||
)
|
||||
except:
|
||||
eos_device_instruction_entity_ids = []
|
||||
logger.debug(f"eos_device_instruction_entity_ids {eos_device_instruction_entity_ids}")
|
||||
|
||||
devices_measurement_keys = []
|
||||
try:
|
||||
devices_measurement_keys = json.loads(config_details["devices.measurement_keys"]["value"])
|
||||
except:
|
||||
devices_measurement_keys = []
|
||||
logger.debug(f"devices_measurement_keys {devices_measurement_keys}")
|
||||
|
||||
# build visual representation
|
||||
for config in configuration:
|
||||
for config_key in sorted(config_details.keys()):
|
||||
config = config_details[config_key]
|
||||
category = config["name"].split(".")[0]
|
||||
if category != last_category:
|
||||
rows.append(H3(category))
|
||||
@@ -512,6 +658,12 @@ def Configuration(
|
||||
error_msg = "update_error or update_value or update_open of wrong type."
|
||||
logger.error(error_msg)
|
||||
raise TypeError(error_msg)
|
||||
if (
|
||||
not config_visible["config-visible-read-only"]["visible"]
|
||||
and config["read-only"] != "rw"
|
||||
):
|
||||
# Do not display read only values
|
||||
continue
|
||||
if (
|
||||
config["type"]
|
||||
== "Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]"
|
||||
@@ -532,7 +684,47 @@ def Configuration(
|
||||
update_open,
|
||||
)
|
||||
)
|
||||
else:
|
||||
elif not config["deprecated"]:
|
||||
update_form_factory = None
|
||||
if config["name"].endswith(".provider"):
|
||||
# Special configuration for prediction provider setting
|
||||
try:
|
||||
provider_ids = json.loads(config_details[config["name"] + "s"]["value"])
|
||||
except:
|
||||
provider_ids = []
|
||||
if config["type"].startswith("Optional[list"):
|
||||
update_form_factory = make_config_update_list_form(provider_ids)
|
||||
else:
|
||||
provider_ids.append("None")
|
||||
update_form_factory = make_config_update_value_form(provider_ids)
|
||||
elif config["name"].startswith("adapter.homeassistant.config_entity_ids"):
|
||||
# Home Assistant adapter config entities
|
||||
update_form_factory = make_config_update_map_form(None, homeassistant_entity_ids)
|
||||
elif config["name"].startswith("adapter.homeassistant.load_emr_entity_ids"):
|
||||
# Home Assistant adapter load energy meter readings entities
|
||||
update_form_factory = make_config_update_list_form(homeassistant_entity_ids)
|
||||
elif config["name"].startswith("adapter.homeassistant.pv_production_emr_entity_ids"):
|
||||
# Home Assistant adapter pv energy meter readings entities
|
||||
update_form_factory = make_config_update_list_form(homeassistant_entity_ids)
|
||||
elif config["name"].startswith("adapter.homeassistant.device_measurement_entity_ids"):
|
||||
# Home Assistant adapter device measurement entities
|
||||
update_form_factory = make_config_update_map_form(
|
||||
devices_measurement_keys, homeassistant_entity_ids
|
||||
)
|
||||
elif config["name"].startswith("adapter.homeassistant.device_instruction_entity_ids"):
|
||||
# Home Assistant adapter device instruction entities
|
||||
update_form_factory = make_config_update_list_form(
|
||||
eos_device_instruction_entity_ids
|
||||
)
|
||||
elif config["name"].startswith("adapter.homeassistant.solution_entity_ids"):
|
||||
# Home Assistant adapter optimization solution entities
|
||||
update_form_factory = make_config_update_list_form(eos_solution_entity_ids)
|
||||
elif config["name"].startswith("ems.mode"):
|
||||
# Energy managemnt mode
|
||||
update_form_factory = make_config_update_value_form(
|
||||
["OPTIMIZATION", "PREDICTION", "None"]
|
||||
)
|
||||
|
||||
rows.append(
|
||||
ConfigCard(
|
||||
config["name"],
|
||||
@@ -545,61 +737,8 @@ def Configuration(
|
||||
update_error,
|
||||
update_value,
|
||||
update_open,
|
||||
update_form_factory,
|
||||
)
|
||||
)
|
||||
return Div(*rows, cls="space-y-4")
|
||||
|
||||
|
||||
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 (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:
|
||||
rows: Rows of configuration details.
|
||||
"""
|
||||
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
|
||||
config = None
|
||||
try:
|
||||
response = requests.put(f"{server}/v1/config/{path}", json=data, timeout=10)
|
||||
response.raise_for_status()
|
||||
config = response.json()
|
||||
except requests.exceptions.HTTPError as err:
|
||||
try:
|
||||
# Try to get 'detail' from the JSON response
|
||||
detail = response.json().get(
|
||||
"detail", f"No error details for data '{data}' '{response.text}'"
|
||||
)
|
||||
except ValueError:
|
||||
# Response is not JSON
|
||||
detail = f"No error details for data '{data}' '{response.text}'"
|
||||
error = f"Can not set {key} on {server}: {err}, {detail}"
|
||||
# Mark all updates as closed
|
||||
for k in config_update_latest:
|
||||
config_update_latest[k]["open"] = False
|
||||
# Remember this update as latest one
|
||||
config_update_latest[key] = {
|
||||
"error": error,
|
||||
"result": config,
|
||||
"value": value,
|
||||
"open": True,
|
||||
}
|
||||
if error or config is None:
|
||||
# Reread configuration to be shure we display actual data
|
||||
return Configuration(eos_host, eos_port)
|
||||
# Use configuration already provided
|
||||
return Configuration(eos_host, eos_port, configuration(ConfigEOS, config))
|
||||
return Div(ConfigMenu, *rows, cls="space-y-3")
|
||||
|
||||
169
src/akkudoktoreos/server/dash/context.py
Normal file
169
src/akkudoktoreos/server/dash/context.py
Normal file
@@ -0,0 +1,169 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
from loguru import logger
|
||||
from platformdirs import user_config_dir
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
# Home assistant token, if running under Home Assistant
|
||||
HASSIO_TOKEN = os.environ.get("HASSIO_TOKEN")
|
||||
|
||||
# Compute global root path at startup
|
||||
# Will be replaced on first request if Ingress is active
|
||||
ROOT_PATH = "/"
|
||||
|
||||
# EOSdash path prefix
|
||||
EOSDASH_ROOT = "eosdash/"
|
||||
|
||||
# Directory to export files to, or to import files from
|
||||
export_import_directory = (
|
||||
Path(os.environ.get("EOS_DATA_DIR", user_config_dir("net.akkudoktor.eosdash", "akkudoktor")))
|
||||
if not HASSIO_TOKEN
|
||||
else Path("/data")
|
||||
)
|
||||
|
||||
|
||||
class IngressMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to handle Home Assistant Ingress path prefixes.
|
||||
|
||||
This middleware enables FastHTML applications to work seamlessly both with
|
||||
and without Home Assistant Ingress. When deployed as a Home Assistant add-on
|
||||
with Ingress enabled, it automatically handles the path prefix routing.
|
||||
|
||||
Home Assistant Ingress proxies add-on traffic through paths like
|
||||
`/api/hassio_ingress/<token>/`, which requires setting the application's
|
||||
root_path for correct URL generation. This middleware detects the Ingress
|
||||
path from the X-Ingress-Path header and configures the request scope
|
||||
accordingly.
|
||||
|
||||
When running standalone (development or direct access), the middleware
|
||||
passes requests through unchanged, allowing normal operation.
|
||||
|
||||
Attributes:
|
||||
None
|
||||
|
||||
Examples:
|
||||
>>> from fasthtml.common import FastHTML
|
||||
>>> from starlette.middleware import Middleware
|
||||
>>>
|
||||
>>> app = FastHTML(middleware=[Middleware(IngressMiddleware)])
|
||||
>>>
|
||||
>>> @app.get("/")
|
||||
>>> def home():
|
||||
... return "Hello World"
|
||||
|
||||
Notes:
|
||||
- All htmx and route URLs should use relative paths (e.g., "/api/data")
|
||||
- The middleware automatically adapts to both Ingress and direct access
|
||||
- No code changes needed when switching between deployment modes
|
||||
"""
|
||||
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||
) -> Response:
|
||||
"""Process the request and set root_path if running under Ingress.
|
||||
|
||||
Args:
|
||||
request: The incoming Starlette Request object.
|
||||
call_next: Callable to invoke the next middleware or route handler.
|
||||
|
||||
Returns:
|
||||
Response: The response from the application after processing.
|
||||
|
||||
Note:
|
||||
The X-Ingress-Path header is automatically added by Home Assistant
|
||||
when proxying requests through Ingress.
|
||||
"""
|
||||
global ROOT_PATH
|
||||
|
||||
# Home Assistant passes the ingress path in this header
|
||||
# Try multiple header variations (case-insensitive)
|
||||
ingress_path = (
|
||||
request.headers.get("X-Ingress-Path", "")
|
||||
or request.headers.get("x-ingress-path", "")
|
||||
or request.headers.get("X-INGRESS-PATH", "")
|
||||
)
|
||||
|
||||
# Debug logging - remove after testing
|
||||
logger.debug(f"All headers: {dict(request.headers)}")
|
||||
logger.debug(f"Ingress path: {ingress_path}")
|
||||
logger.debug(f"Request path: {request.url.path}")
|
||||
|
||||
# Only set root_path if we have an ingress path
|
||||
if ingress_path:
|
||||
ROOT_PATH = ingress_path
|
||||
request.scope["root_path"] = ingress_path
|
||||
# Otherwise, root_path remains empty (normal operation)
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# Helper functions
|
||||
def request_url_for(path: str, root_path: Optional[str] = None) -> str:
|
||||
"""Generate a full URL including the root_path.
|
||||
|
||||
Args:
|
||||
path: Relative path **inside the app** (e.g., "eosdash/footer" or "eosdash/assets/logo.png").
|
||||
root_path: Root path.
|
||||
|
||||
Returns:
|
||||
str: Absolute URL including the root_path.
|
||||
"""
|
||||
global ROOT_PATH, EOSDASH_ROOT
|
||||
|
||||
# Step 1: fallback to global root
|
||||
if root_path is None:
|
||||
root_path = ROOT_PATH
|
||||
|
||||
# Normalize root path
|
||||
root_path = root_path.rstrip("/") + "/"
|
||||
|
||||
# Normalize path
|
||||
if path.startswith(root_path):
|
||||
# Strip root_path prefix
|
||||
path = path[len(root_path) :]
|
||||
|
||||
# Remove leading / if any
|
||||
path = path.lstrip("/")
|
||||
|
||||
# Strip EOSDASH_ROOT if present
|
||||
if path.startswith(EOSDASH_ROOT):
|
||||
path = path[len(EOSDASH_ROOT) :]
|
||||
|
||||
# Build final URL
|
||||
result = root_path + EOSDASH_ROOT + path.lstrip("/")
|
||||
|
||||
# Normalize accidental double slashes (except leading)
|
||||
while "//" in result[1:]:
|
||||
result = result.replace("//", "/")
|
||||
|
||||
logger.debug(f"URL for path '{path}' with root path '{root_path}': '{result}'")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def safe_asset_path(filepath: str) -> Path:
|
||||
"""Return a safe filesystem path for an asset under dash/assets/.
|
||||
|
||||
This prevents directory traversal attacks by restricting paths to
|
||||
the assets folder.
|
||||
|
||||
Args:
|
||||
filepath (str): Relative asset path requested by the client.
|
||||
|
||||
Returns:
|
||||
Path: Absolute Path object pointing to the asset file.
|
||||
|
||||
Raises:
|
||||
ValueError: If the filepath attempts to traverse directories using '../'.
|
||||
"""
|
||||
if ".." in filepath or filepath.startswith("/"):
|
||||
raise ValueError(f"Forbidden file path: {filepath}")
|
||||
|
||||
asset_path = Path(__file__).parent / "dash/assets" / filepath
|
||||
return asset_path
|
||||
@@ -9,8 +9,6 @@ from requests.exceptions import RequestException
|
||||
import akkudoktoreos.server.dash.eosstatus as eosstatus
|
||||
from akkudoktoreos.config.config import get_config
|
||||
|
||||
config_eos = get_config()
|
||||
|
||||
|
||||
def get_alive(eos_host: str, eos_port: Union[str, int]) -> str:
|
||||
"""Fetch alive information from the specified EOS server.
|
||||
@@ -42,9 +40,9 @@ def get_alive(eos_host: str, eos_port: Union[str, int]) -> str:
|
||||
|
||||
def Footer(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> str:
|
||||
if eos_host is None:
|
||||
eos_host = config_eos.server.host
|
||||
eos_host = get_config().server.host
|
||||
if eos_port is None:
|
||||
eos_port = config_eos.server.port
|
||||
eos_port = get_config().server.port
|
||||
alive_icon = None
|
||||
if eos_host is None or eos_port is None:
|
||||
alive = "EOS server not given: {eos_host}:{eos_port}"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Markdown rendering with MonsterUI HTML classes."""
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
from fasthtml.common import FT, Div, NotStr
|
||||
@@ -8,113 +11,138 @@ from markdown_it.renderer import RendererHTML
|
||||
from markdown_it.token import Token
|
||||
from monsterui.foundations import stringify
|
||||
|
||||
# Where to find the static data assets
|
||||
ASSETS_DIR = Path(__file__).parent / "assets"
|
||||
|
||||
ASSETS_PREFIX = "/eosdash/assets/"
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico"}
|
||||
|
||||
|
||||
def file_to_data_uri(file_path: Path) -> str:
|
||||
"""Convert a file to a data URI.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to convert.
|
||||
|
||||
Returns:
|
||||
str: Data URI string with format data:mime/type;base64,encoded_data
|
||||
"""
|
||||
ext = file_path.suffix.lower()
|
||||
|
||||
# Determine MIME type
|
||||
mime, _ = mimetypes.guess_type(str(file_path))
|
||||
if mime is None:
|
||||
mime = f"image/{ext.lstrip('.')}"
|
||||
|
||||
# Read file as bytes and encode to base64
|
||||
raw = file_path.read_bytes()
|
||||
encoded = base64.b64encode(raw).decode("ascii")
|
||||
|
||||
return f"data:{mime};base64,{encoded}"
|
||||
|
||||
|
||||
def render_heading(
|
||||
self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict
|
||||
) -> str:
|
||||
"""Custom renderer for Markdown headings.
|
||||
|
||||
Adds specific CSS classes based on the heading level.
|
||||
|
||||
Parameters:
|
||||
self: The renderer instance.
|
||||
tokens: List of tokens to be rendered.
|
||||
idx: Index of the current token.
|
||||
options: Rendering options.
|
||||
env: Environment sandbox for plugins.
|
||||
|
||||
Returns:
|
||||
The rendered token as a string.
|
||||
"""
|
||||
"""Custom renderer for Markdown headings with MonsterUI styling."""
|
||||
if tokens[idx].markup == "#":
|
||||
tokens[idx].attrSet("class", "uk-heading-divider uk-h1 uk-margin")
|
||||
tokens[idx].attrSet(
|
||||
"class",
|
||||
"scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl mt-8 mb-4 border-b pb-2",
|
||||
)
|
||||
elif tokens[idx].markup == "##":
|
||||
tokens[idx].attrSet("class", "uk-heading-divider uk-h2 uk-margin")
|
||||
tokens[idx].attrSet(
|
||||
"class", "scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight mt-6 mb-3"
|
||||
)
|
||||
elif tokens[idx].markup == "###":
|
||||
tokens[idx].attrSet("class", "uk-heading-divider uk-h3 uk-margin")
|
||||
tokens[idx].attrSet("class", "scroll-m-20 text-2xl font-semibold tracking-tight mt-5 mb-2")
|
||||
elif tokens[idx].markup == "####":
|
||||
tokens[idx].attrSet("class", "uk-heading-divider uk-h4 uk-margin")
|
||||
tokens[idx].attrSet("class", "scroll-m-20 text-xl font-semibold tracking-tight mt-4 mb-2")
|
||||
|
||||
# pass token to default renderer.
|
||||
return self.renderToken(tokens, idx, options, env)
|
||||
|
||||
|
||||
def render_paragraph(
|
||||
self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict
|
||||
) -> str:
|
||||
"""Custom renderer for Markdown paragraphs.
|
||||
|
||||
Adds specific CSS classes.
|
||||
|
||||
Parameters:
|
||||
self: The renderer instance.
|
||||
tokens: List of tokens to be rendered.
|
||||
idx: Index of the current token.
|
||||
options: Rendering options.
|
||||
env: Environment sandbox for plugins.
|
||||
|
||||
Returns:
|
||||
The rendered token as a string.
|
||||
"""
|
||||
tokens[idx].attrSet("class", "uk-paragraph")
|
||||
|
||||
# pass token to default renderer.
|
||||
"""Custom renderer for Markdown paragraphs with MonsterUI styling."""
|
||||
tokens[idx].attrSet("class", "leading-7 [&:not(:first-child)]:mt-6")
|
||||
return self.renderToken(tokens, idx, options, env)
|
||||
|
||||
|
||||
def render_blockquote(
|
||||
self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict
|
||||
) -> str:
|
||||
"""Custom renderer for Markdown blockquotes.
|
||||
"""Custom renderer for Markdown blockquotes with MonsterUI styling."""
|
||||
tokens[idx].attrSet("class", "mt-6 border-l-2 pl-6 italic border-primary")
|
||||
return self.renderToken(tokens, idx, options, env)
|
||||
|
||||
Adds specific CSS classes.
|
||||
|
||||
Parameters:
|
||||
self: The renderer instance.
|
||||
tokens: List of tokens to be rendered.
|
||||
idx: Index of the current token.
|
||||
options: Rendering options.
|
||||
env: Environment sandbox for plugins.
|
||||
def render_list(self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict) -> str:
|
||||
"""Custom renderer for lists with MonsterUI styling."""
|
||||
tokens[idx].attrSet("class", "my-6 ml-6 list-disc [&>li]:mt-2")
|
||||
return self.renderToken(tokens, idx, options, env)
|
||||
|
||||
Returns:
|
||||
The rendered token as a string.
|
||||
"""
|
||||
tokens[idx].attrSet("class", "uk-blockquote")
|
||||
|
||||
# pass token to default renderer.
|
||||
def render_image(
|
||||
self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict
|
||||
) -> str:
|
||||
"""Custom renderer for Markdown images with MonsterUI styling."""
|
||||
token = tokens[idx]
|
||||
src = token.attrGet("src")
|
||||
alt = token.content or ""
|
||||
|
||||
if src:
|
||||
pos = src.find(ASSETS_PREFIX)
|
||||
if pos != -1:
|
||||
asset_rel = src[pos + len(ASSETS_PREFIX) :]
|
||||
fs_path = ASSETS_DIR / asset_rel
|
||||
|
||||
if fs_path.exists():
|
||||
data_uri = file_to_data_uri(fs_path)
|
||||
token.attrSet("src", data_uri)
|
||||
# MonsterUI/shadcn styling for images
|
||||
token.attrSet("class", "rounded-lg border my-6 max-w-full h-auto")
|
||||
|
||||
return self.renderToken(tokens, idx, options, env)
|
||||
|
||||
|
||||
def render_link(self: RendererHTML, tokens: List[Token], idx: int, options: dict, env: dict) -> str:
|
||||
"""Custom renderer for Markdown links.
|
||||
"""Custom renderer for Markdown links with MonsterUI styling."""
|
||||
token = tokens[idx]
|
||||
href = token.attrGet("href")
|
||||
|
||||
Adds the target attribute to open links in a new tab.
|
||||
if href:
|
||||
pos = href.find(ASSETS_PREFIX)
|
||||
if pos != -1:
|
||||
asset_rel = href[pos + len(ASSETS_PREFIX) :]
|
||||
key = asset_rel.rsplit(".", 1)[0]
|
||||
if key in env:
|
||||
return str(env[key])
|
||||
|
||||
Parameters:
|
||||
self: The renderer instance.
|
||||
tokens: List of tokens to be rendered.
|
||||
idx: Index of the current token.
|
||||
options: Rendering options.
|
||||
env: Environment sandbox for plugins.
|
||||
|
||||
Returns:
|
||||
The rendered token as a string.
|
||||
"""
|
||||
tokens[idx].attrSet("class", "uk-link")
|
||||
tokens[idx].attrSet("target", "_blank")
|
||||
|
||||
# pass token to default renderer.
|
||||
# MonsterUI link styling
|
||||
token.attrSet(
|
||||
"class", "font-medium text-primary underline underline-offset-4 hover:text-primary/80"
|
||||
)
|
||||
token.attrSet("target", "_blank")
|
||||
return self.renderToken(tokens, idx, options, env)
|
||||
|
||||
|
||||
# Register all renderers
|
||||
markdown = MarkdownIt("gfm-like")
|
||||
markdown.add_render_rule("heading_open", render_heading)
|
||||
markdown.add_render_rule("paragraph_open", render_paragraph)
|
||||
markdown.add_render_rule("blockquote_open", render_blockquote)
|
||||
markdown.add_render_rule("link_open", render_link)
|
||||
markdown.add_render_rule("image", render_image)
|
||||
markdown.add_render_rule("bullet_list_open", render_list)
|
||||
markdown.add_render_rule("ordered_list_open", render_list)
|
||||
|
||||
|
||||
markdown_cls = "bg-background text-lg ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
# Updated wrapper class to match shadcn/ui theme
|
||||
markdown_cls = "text-foreground space-y-4"
|
||||
|
||||
# markdown_cls = "bg-background text-lg ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
|
||||
|
||||
def Markdown(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> FT:
|
||||
|
||||
@@ -29,6 +29,7 @@ from akkudoktoreos.core.emplan import (
|
||||
from akkudoktoreos.optimization.optimization import OptimizationSolution
|
||||
from akkudoktoreos.server.dash.bokeh import Bokeh, bokey_apply_theme_to_plot
|
||||
from akkudoktoreos.server.dash.components import Error
|
||||
from akkudoktoreos.server.dash.context import request_url_for
|
||||
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime
|
||||
|
||||
# bar width for 1 hour bars (time given in millseconds)
|
||||
@@ -385,7 +386,7 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
|
||||
name=f"{renderer}-visible",
|
||||
value="true",
|
||||
checked=solution_visible[renderer],
|
||||
hx_post="/eosdash/plan",
|
||||
hx_post=request_url_for("/eosdash/plan"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='js:{ "category": "solution", "action": "visible", "renderer": '
|
||||
@@ -412,7 +413,7 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
|
||||
name=f"{renderer}-visible",
|
||||
value="true",
|
||||
checked=solution_visible[renderer],
|
||||
hx_post="/eosdash/plan",
|
||||
hx_post=request_url_for("/eosdash/plan"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='js:{ "category": "solution", "action": "visible", "renderer": '
|
||||
@@ -439,7 +440,7 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
|
||||
name=f"{renderer}-visible",
|
||||
value="true",
|
||||
checked=solution_visible[renderer],
|
||||
hx_post="/eosdash/plan",
|
||||
hx_post=request_url_for("/eosdash/plan"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='js:{ "category": "solution", "action": "visible", "renderer": '
|
||||
@@ -595,7 +596,7 @@ def Plan(eos_host: str, eos_port: Union[str, int], data: Optional[dict] = None)
|
||||
result.raise_for_status()
|
||||
except requests.exceptions.HTTPError as err:
|
||||
detail = result.json()["detail"]
|
||||
return Error(f"Can not retrieve configuration from {server}: {err}, {detail}")
|
||||
return Error(f"Can not retrieve configuration from {server}: {err},\n{detail}")
|
||||
eosstatus.eos_config = SettingsEOS(**result.json())
|
||||
|
||||
# Get the optimization solution
|
||||
@@ -607,7 +608,7 @@ def Plan(eos_host: str, eos_port: Union[str, int], data: Optional[dict] = None)
|
||||
solution_json = result.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
detail = result.json()["detail"]
|
||||
warning_msg = f"Can not retrieve optimization solution from {server}: {e}, {detail}"
|
||||
warning_msg = f"Can not retrieve optimization solution from {server}: {e},\n{detail}"
|
||||
logger.warning(warning_msg)
|
||||
return Error(warning_msg)
|
||||
except Exception as e:
|
||||
@@ -623,7 +624,7 @@ def Plan(eos_host: str, eos_port: Union[str, int], data: Optional[dict] = None)
|
||||
plan_json = result.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
detail = result.json()["detail"]
|
||||
warning_msg = f"Can not retrieve plan from {server}: {e}, {detail}"
|
||||
warning_msg = f"Can not retrieve plan from {server}: {e},\n{detail}"
|
||||
logger.warning(warning_msg)
|
||||
return Error(warning_msg)
|
||||
except Exception as e:
|
||||
|
||||
@@ -9,7 +9,6 @@ import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union
|
||||
|
||||
import psutil
|
||||
@@ -33,7 +32,7 @@ from akkudoktoreos.core.emplan import EnergyManagementPlan, ResourceStatus
|
||||
from akkudoktoreos.core.ems import get_ems
|
||||
from akkudoktoreos.core.emsettings import EnergyManagementMode
|
||||
from akkudoktoreos.core.logabc import LOGGING_LEVELS
|
||||
from akkudoktoreos.core.logging import read_file_log, track_logging_config
|
||||
from akkudoktoreos.core.logging import logging_track_config, read_file_log
|
||||
from akkudoktoreos.core.pydantic import (
|
||||
PydanticBaseModel,
|
||||
PydanticDateTimeData,
|
||||
@@ -54,11 +53,13 @@ from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
||||
from akkudoktoreos.prediction.prediction import get_prediction
|
||||
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
|
||||
from akkudoktoreos.server.rest.error import create_error_page
|
||||
from akkudoktoreos.server.rest.starteosdash import run_eosdash_supervisor
|
||||
from akkudoktoreos.server.rest.tasks import repeat_every
|
||||
from akkudoktoreos.server.server import (
|
||||
drop_root_privileges,
|
||||
fix_data_directories_permissions,
|
||||
get_default_host,
|
||||
get_host_ip,
|
||||
validate_ip_or_hostname,
|
||||
wait_for_port_free,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
@@ -70,15 +71,18 @@ prediction_eos = get_prediction()
|
||||
ems_eos = get_ems()
|
||||
resource_registry_eos = get_resource_registry()
|
||||
|
||||
|
||||
# ------------------------------------
|
||||
# Logging configuration at import time
|
||||
# ------------------------------------
|
||||
|
||||
logger.remove()
|
||||
track_logging_config(config_eos, "logging", None, None)
|
||||
config_eos.track_nested_value("/logging", track_logging_config)
|
||||
logging_track_config(config_eos, "logging", None, None)
|
||||
|
||||
# -----------------------------
|
||||
# Configuration change tracking
|
||||
# -----------------------------
|
||||
|
||||
config_eos.track_nested_value("/logging", logging_track_config)
|
||||
|
||||
# ----------------------------
|
||||
# Safe argparse at import time
|
||||
@@ -114,6 +118,11 @@ parser.add_argument(
|
||||
default=None,
|
||||
help="Enable or disable automatic EOSdash startup. Options: True or False (default: value from config)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--run_as_user",
|
||||
type=str,
|
||||
help="The unprivileged user account the EOS server shall switch to after performing root-level startup tasks.",
|
||||
)
|
||||
|
||||
# Command line arguments
|
||||
args: argparse.Namespace
|
||||
@@ -137,7 +146,7 @@ if args and args.log_level is not None:
|
||||
# Ensure log_level from command line is in config settings
|
||||
if log_level in LOGGING_LEVELS:
|
||||
# Setup console logging level using nested value
|
||||
# - triggers logging configuration by track_logging_config
|
||||
# - triggers logging configuration by logging_track_config
|
||||
config_eos.set_nested_value("logging/console_level", log_level)
|
||||
logger.debug(f"logging/console_level configuration set by argument to {log_level}")
|
||||
|
||||
@@ -188,105 +197,6 @@ if config_eos.server.startup_eosdash:
|
||||
config_eos.set_nested_value("server/eosdash_port", port + 1)
|
||||
|
||||
|
||||
# ----------------------
|
||||
# EOSdash server startup
|
||||
# ----------------------
|
||||
|
||||
|
||||
def start_eosdash(
|
||||
host: str,
|
||||
port: int,
|
||||
eos_host: str,
|
||||
eos_port: int,
|
||||
log_level: str,
|
||||
access_log: bool,
|
||||
reload: bool,
|
||||
eos_dir: str,
|
||||
eos_config_dir: str,
|
||||
) -> subprocess.Popen:
|
||||
"""Start the EOSdash server as a subprocess.
|
||||
|
||||
This function starts the EOSdash server by launching it as a subprocess. It checks if the server
|
||||
is already running on the specified port and either returns the existing process or starts a new
|
||||
one.
|
||||
|
||||
Args:
|
||||
host (str): The hostname for the EOSdash server.
|
||||
port (int): The port for the EOSdash server.
|
||||
eos_host (str): The hostname for the EOS server.
|
||||
eos_port (int): The port for the EOS server.
|
||||
log_level (str): The logging level for the EOSdash server.
|
||||
access_log (bool): Flag to enable or disable access logging.
|
||||
reload (bool): Flag to enable or disable auto-reloading.
|
||||
eos_dir (str): Path to the EOS data directory.
|
||||
eos_config_dir (str): Path to the EOS configuration directory.
|
||||
|
||||
Returns:
|
||||
subprocess.Popen: The process of the EOSdash server.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the EOSdash server fails to start.
|
||||
"""
|
||||
try:
|
||||
validate_ip_or_hostname(host)
|
||||
validate_ip_or_hostname(eos_host)
|
||||
except Exception as ex:
|
||||
error_msg = f"Could not start EOSdash: {ex}"
|
||||
logger.error(error_msg)
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
eosdash_path = Path(__file__).parent.resolve().joinpath("eosdash.py")
|
||||
|
||||
# Do a one time check for port free to generate warnings if not so
|
||||
wait_for_port_free(port, timeout=0, waiting_app_name="EOSdash")
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"akkudoktoreos.server.eosdash",
|
||||
"--host",
|
||||
str(host),
|
||||
"--port",
|
||||
str(port),
|
||||
"--eos-host",
|
||||
str(eos_host),
|
||||
"--eos-port",
|
||||
str(eos_port),
|
||||
"--log_level",
|
||||
log_level,
|
||||
"--access_log",
|
||||
str(access_log),
|
||||
"--reload",
|
||||
str(reload),
|
||||
]
|
||||
# Set environment before any subprocess run, to keep custom config dir
|
||||
env = os.environ.copy()
|
||||
env["EOS_DIR"] = eos_dir
|
||||
env["EOS_CONFIG_DIR"] = eos_config_dir
|
||||
|
||||
try:
|
||||
server_process = subprocess.Popen( # noqa: S603
|
||||
cmd,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
logger.info(f"Started EOSdash with '{cmd}'.")
|
||||
except subprocess.CalledProcessError as ex:
|
||||
error_msg = f"Could not start EOSdash: {ex}"
|
||||
logger.error(error_msg)
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
# Check EOSdash is still running
|
||||
if server_process.poll() is not None:
|
||||
error_msg = f"EOSdash finished immediatedly with code: {server_process.returncode}"
|
||||
logger.error(error_msg)
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
return server_process
|
||||
|
||||
|
||||
# ----------------------
|
||||
# EOS REST Server
|
||||
# ----------------------
|
||||
@@ -389,41 +299,7 @@ async def server_shutdown_task() -> None:
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Lifespan manager for the app."""
|
||||
# On startup
|
||||
if config_eos.server.startup_eosdash:
|
||||
try:
|
||||
if (
|
||||
config_eos.server.eosdash_host is None
|
||||
or config_eos.server.eosdash_port is None
|
||||
or config_eos.server.host is None
|
||||
or config_eos.server.port is None
|
||||
):
|
||||
raise ValueError(
|
||||
f"Invalid configuration for EOSdash server startup.\n"
|
||||
f"- server/startup_eosdash: {config_eos.server.startup_eosdash}\n"
|
||||
f"- server/eosdash_host: {config_eos.server.eosdash_host}\n"
|
||||
f"- server/eosdash_port: {config_eos.server.eosdash_port}\n"
|
||||
f"- server/host: {config_eos.server.host}\n"
|
||||
f"- server/port: {config_eos.server.port}"
|
||||
)
|
||||
|
||||
log_level = (
|
||||
config_eos.logging.console_level if config_eos.logging.console_level else "info"
|
||||
)
|
||||
|
||||
eosdash_process = start_eosdash(
|
||||
host=str(config_eos.server.eosdash_host),
|
||||
port=config_eos.server.eosdash_port,
|
||||
eos_host=str(config_eos.server.host),
|
||||
eos_port=config_eos.server.port,
|
||||
log_level=log_level,
|
||||
access_log=True,
|
||||
reload=False,
|
||||
eos_dir=str(config_eos.general.data_folder_path),
|
||||
eos_config_dir=str(config_eos.general.config_folder_path),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start EOSdash server. Error: {e}")
|
||||
sys.exit(1)
|
||||
asyncio.create_task(run_eosdash_supervisor())
|
||||
|
||||
load_eos_state()
|
||||
|
||||
@@ -606,7 +482,7 @@ async def fastapi_admin_server_shutdown_post() -> dict:
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v1/health")
|
||||
@app.get("/v1/health", tags=["health"])
|
||||
def fastapi_health_get(): # type: ignore
|
||||
"""Health check endpoint to verify that the EOS server is alive."""
|
||||
return JSONResponse(
|
||||
@@ -1190,7 +1066,7 @@ def fastapi_energy_management_optimization_solution_get() -> OptimizationSolutio
|
||||
if solution is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Can not get the optimization solution. Did you configure automatic optimization?",
|
||||
detail="Can not get the optimization solution.\nDid you configure automatic optimization?",
|
||||
)
|
||||
return solution
|
||||
|
||||
@@ -1202,7 +1078,7 @@ def fastapi_energy_management_plan_get() -> EnergyManagementPlan:
|
||||
if plan is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Can not get the energy management plan. Did you configure automatic optimization?",
|
||||
detail="Can not get the energy management plan.\nDid you configure automatic optimization?",
|
||||
)
|
||||
return plan
|
||||
|
||||
@@ -1256,7 +1132,7 @@ async def fastapi_strompreis() -> list[float]:
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Can not get the electricity price forecast: {e}. Did you configure the electricity price forecast provider?",
|
||||
detail=f"Can not get the electricity price forecast: {e}.\nDid you configure the electricity price forecast provider?",
|
||||
)
|
||||
|
||||
return elecprice
|
||||
@@ -1360,7 +1236,7 @@ async def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]:
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Can not get the total load forecast: {e}. Did you configure the load forecast provider?",
|
||||
detail=f"Can not get the total load forecast: {e}.\nDid you configure the load forecast provider?",
|
||||
)
|
||||
|
||||
return prediction_list
|
||||
@@ -1421,7 +1297,7 @@ async def fastapi_gesamtlast_simple(year_energy: float) -> list[float]:
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Can not get the total load forecast: {e}. Did you configure the load forecast provider?",
|
||||
detail=f"Can not get the total load forecast: {e}.\nDid you configure the load forecast provider?",
|
||||
)
|
||||
|
||||
return prediction_list
|
||||
@@ -1616,6 +1492,17 @@ def run_eos() -> None:
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
if args:
|
||||
run_as_user = args.run_as_user
|
||||
else:
|
||||
run_as_user = None
|
||||
|
||||
# Switch data directories ownership to user
|
||||
fix_data_directories_permissions(run_as_user=run_as_user)
|
||||
|
||||
# Switch privileges to run_as_user
|
||||
drop_root_privileges(run_as_user=run_as_user)
|
||||
|
||||
# Wait for EOS port to be free - e.g. in case of restart
|
||||
wait_for_port_free(port, timeout=120, waiting_app_name="EOS")
|
||||
|
||||
@@ -1628,6 +1515,8 @@ def run_eos() -> None:
|
||||
log_level="info", # Fix log level for uvicorn to info
|
||||
access_log=True, # Fix server access logging to True
|
||||
reload=reload,
|
||||
proxy_headers=True,
|
||||
forwarded_allow_ips="*",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to start uvicorn server.")
|
||||
|
||||
@@ -6,25 +6,37 @@ from pathlib import Path
|
||||
|
||||
import psutil
|
||||
import uvicorn
|
||||
from fasthtml.common import FileResponse, JSONResponse
|
||||
from fasthtml.common import Base, FileResponse, JSONResponse
|
||||
from loguru import logger
|
||||
from monsterui.core import FastHTML, Theme
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.requests import Request
|
||||
|
||||
from akkudoktoreos.config.config import get_config
|
||||
from akkudoktoreos.core.logabc import LOGGING_LEVELS
|
||||
from akkudoktoreos.core.logging import track_logging_config
|
||||
from akkudoktoreos.core.logging import logging_track_config
|
||||
from akkudoktoreos.core.version import __version__
|
||||
from akkudoktoreos.server.dash.about import About
|
||||
|
||||
# Pages
|
||||
from akkudoktoreos.server.dash.about import About
|
||||
from akkudoktoreos.server.dash.admin import Admin
|
||||
|
||||
# helpers
|
||||
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.configuration import Configuration
|
||||
from akkudoktoreos.server.dash.context import (
|
||||
IngressMiddleware,
|
||||
safe_asset_path,
|
||||
)
|
||||
from akkudoktoreos.server.dash.footer import Footer
|
||||
from akkudoktoreos.server.dash.plan import Plan
|
||||
from akkudoktoreos.server.dash.prediction import Prediction
|
||||
from akkudoktoreos.server.server import get_default_host, wait_for_port_free
|
||||
from akkudoktoreos.server.server import (
|
||||
drop_root_privileges,
|
||||
get_default_host,
|
||||
wait_for_port_free,
|
||||
)
|
||||
from akkudoktoreos.utils.stringutil import str2bool
|
||||
|
||||
config_eos = get_config()
|
||||
@@ -35,8 +47,8 @@ config_eos = get_config()
|
||||
# ------------------------------------
|
||||
|
||||
logger.remove()
|
||||
track_logging_config(config_eos, "logging", None, None)
|
||||
config_eos.track_nested_value("/logging", track_logging_config)
|
||||
logging_track_config(config_eos, "logging", None, None)
|
||||
config_eos.track_nested_value("/logging", logging_track_config)
|
||||
|
||||
|
||||
# ----------------------------
|
||||
@@ -83,6 +95,12 @@ parser.add_argument(
|
||||
default=False,
|
||||
help="Enable or disable auto-reload. Useful for development. Options: True or False (default: False)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--run_as_user",
|
||||
type=str,
|
||||
help="The unprivileged user account the EOSdash server shall run if started in root-level.",
|
||||
)
|
||||
|
||||
|
||||
# Command line arguments
|
||||
args: argparse.Namespace
|
||||
@@ -110,7 +128,7 @@ else:
|
||||
# Ensure log_level from command line is in config settings
|
||||
if config_eosdash["log_level"] in LOGGING_LEVELS:
|
||||
# Setup console logging level using nested value
|
||||
# - triggers logging configuration by track_logging_config
|
||||
# - triggers logging configuration by logging_track_config
|
||||
config_eos.set_nested_value("logging/console_level", config_eosdash["log_level"])
|
||||
logger.debug(
|
||||
f"logging/console_level configuration set by argument to {config_eosdash['log_level']}"
|
||||
@@ -180,9 +198,11 @@ hdrs = (
|
||||
|
||||
# The EOSdash application
|
||||
app: FastHTML = FastHTML(
|
||||
title="EOSdash",
|
||||
hdrs=hdrs,
|
||||
secret_key=os.getenv("EOS_SERVER__EOSDASH_SESSKEY"),
|
||||
title="EOSdash", # Default page title
|
||||
hdrs=hdrs, # Additional FT elements to add to <HEAD>
|
||||
# htmx=True, # Include HTMX header?
|
||||
middleware=[Middleware(IngressMiddleware)],
|
||||
secret_key=os.getenv("EOS_SERVER__EOSDASH_SESSKEY"), # Signing key for sessions
|
||||
)
|
||||
|
||||
|
||||
@@ -199,37 +219,60 @@ def eos_server() -> tuple[str, int]:
|
||||
return config_eosdash["eos_host"], config_eosdash["eos_port"]
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Routes
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/favicon.ico")
|
||||
def get_eosdash_favicon(): # type: ignore
|
||||
"""Get favicon."""
|
||||
def get_eosdash_favicon(request: Request): # type: ignore
|
||||
"""Get the EOSdash favicon.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming FastHTML request.
|
||||
|
||||
Returns:
|
||||
FileResponse: The favicon file.
|
||||
"""
|
||||
return FileResponse(path=favicon_filepath)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def get_eosdash(): # type: ignore
|
||||
"""Serves the main EOSdash page.
|
||||
def get_eosdash(request: Request): # type: ignore
|
||||
"""Serve the main EOSdash page with navigation links.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming FastHTML request.
|
||||
|
||||
Returns:
|
||||
Page: The main dashboard page with navigation links and footer.
|
||||
"""
|
||||
return Page(
|
||||
None,
|
||||
{
|
||||
"Plan": "/eosdash/plan",
|
||||
"Prediction": "/eosdash/prediction",
|
||||
"Config": "/eosdash/configuration",
|
||||
"Admin": "/eosdash/admin",
|
||||
"About": "/eosdash/about",
|
||||
},
|
||||
About(),
|
||||
Footer(*eos_server()),
|
||||
"/eosdash/footer",
|
||||
root_path: str = request.scope.get("root_path", "")
|
||||
|
||||
return (
|
||||
Base(href=f"{root_path}/") if root_path else None,
|
||||
Page(
|
||||
None,
|
||||
{
|
||||
"Plan": "/eosdash/plan",
|
||||
"Prediction": "/eosdash/prediction",
|
||||
"Config": "/eosdash/configuration",
|
||||
"Admin": "/eosdash/admin",
|
||||
"About": "/eosdash/about",
|
||||
},
|
||||
About(),
|
||||
Footer(*eos_server()),
|
||||
"/eosdash/footer",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/eosdash/footer")
|
||||
def get_eosdash_footer(): # type: ignore
|
||||
"""Serves the EOSdash Foooter information.
|
||||
def get_eosdash_footer(request: Request): # type: ignore
|
||||
"""Serve the EOSdash Footer information.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming FastHTML request.
|
||||
|
||||
Returns:
|
||||
Footer: The Footer component.
|
||||
@@ -238,8 +281,11 @@ def get_eosdash_footer(): # type: ignore
|
||||
|
||||
|
||||
@app.get("/eosdash/about")
|
||||
def get_eosdash_about(): # type: ignore
|
||||
"""Serves the EOSdash About page.
|
||||
def get_eosdash_about(request: Request): # type: ignore
|
||||
"""Serve the EOSdash About page.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming FastHTML request.
|
||||
|
||||
Returns:
|
||||
About: The About page component.
|
||||
@@ -248,8 +294,11 @@ def get_eosdash_about(): # type: ignore
|
||||
|
||||
|
||||
@app.get("/eosdash/admin")
|
||||
def get_eosdash_admin(): # type: ignore
|
||||
"""Serves the EOSdash Admin page.
|
||||
def get_eosdash_admin(request: Request): # type: ignore
|
||||
"""Serve the EOSdash Admin page.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming FastHTML request.
|
||||
|
||||
Returns:
|
||||
Admin: The Admin page component.
|
||||
@@ -258,10 +307,12 @@ def get_eosdash_admin(): # type: ignore
|
||||
|
||||
|
||||
@app.post("/eosdash/admin")
|
||||
def post_eosdash_admin(data: dict): # type: ignore
|
||||
def post_eosdash_admin(request: Request, data: dict): # type: ignore
|
||||
"""Provide control data to the Admin page.
|
||||
|
||||
This endpoint is called from within the Admin page on user actions.
|
||||
Args:
|
||||
request (Request): The incoming FastHTML request.
|
||||
data (dict): User-submitted data from the Admin page.
|
||||
|
||||
Returns:
|
||||
Admin: The Admin page component.
|
||||
@@ -270,8 +321,11 @@ def post_eosdash_admin(data: dict): # type: ignore
|
||||
|
||||
|
||||
@app.get("/eosdash/configuration")
|
||||
def get_eosdash_configuration(): # type: ignore
|
||||
"""Serves the EOSdash Configuration page.
|
||||
def get_eosdash_configuration(request: Request): # type: ignore
|
||||
"""Serve the EOSdash Configuration page.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming FastHTML request.
|
||||
|
||||
Returns:
|
||||
Configuration: The Configuration page component.
|
||||
@@ -280,13 +334,40 @@ def get_eosdash_configuration(): # type: ignore
|
||||
|
||||
|
||||
@app.put("/eosdash/configuration")
|
||||
def put_eosdash_configuration(data: dict): # type: ignore
|
||||
return ConfigKeyUpdate(*eos_server(), data["key"], data["value"])
|
||||
def put_eosdash_configuration(request: Request, data: dict): # type: ignore
|
||||
"""Update a configuration key/value pair.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming FastHTML request.
|
||||
data (dict): Dictionary containing 'key' and 'value' to trigger configuration update.
|
||||
|
||||
Returns:
|
||||
Configuration: The Configuration page component with updated configuration.
|
||||
"""
|
||||
return Configuration(*eos_server(), data)
|
||||
|
||||
|
||||
@app.post("/eosdash/configuration")
|
||||
def post_eosdash_configuration(request: Request, data: dict): # type: ignore
|
||||
"""Provide control data to the configuration page.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming FastHTML request.
|
||||
data (dict): User-submitted data from the configuration page.
|
||||
|
||||
Returns:
|
||||
Configuration: The Configuration page component with updated configuration.
|
||||
"""
|
||||
return Configuration(*eos_server(), data)
|
||||
|
||||
|
||||
@app.get("/eosdash/plan")
|
||||
def get_eosdash_plan(data: dict): # type: ignore
|
||||
"""Serves the EOSdash Plan page.
|
||||
def get_eosdash_plan(request: Request, data: dict): # type: ignore
|
||||
"""Serve the EOSdash Plan page.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming FastHTML request.
|
||||
data (dict): Optional query data.
|
||||
|
||||
Returns:
|
||||
Plan: The Plan page component.
|
||||
@@ -295,10 +376,12 @@ def get_eosdash_plan(data: dict): # type: ignore
|
||||
|
||||
|
||||
@app.post("/eosdash/plan")
|
||||
def post_eosdash_plan(data: dict): # type: ignore
|
||||
def post_eosdash_plan(request: Request, data: dict): # type: ignore
|
||||
"""Provide control data to the Plan page.
|
||||
|
||||
This endpoint is called from within the Plan page on user actions.
|
||||
Args:
|
||||
request (Request): The incoming FastHTML request.
|
||||
data (dict): User-submitted data from the Plan page.
|
||||
|
||||
Returns:
|
||||
Plan: The Plan page component.
|
||||
@@ -307,8 +390,12 @@ def post_eosdash_plan(data: dict): # type: ignore
|
||||
|
||||
|
||||
@app.get("/eosdash/prediction")
|
||||
def get_eosdash_prediction(data: dict): # type: ignore
|
||||
"""Serves the EOSdash Prediction page.
|
||||
def get_eosdash_prediction(request: Request, data: dict): # type: ignore
|
||||
"""Serve the EOSdash Prediction page.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming FastHTML request.
|
||||
data (dict): Optional query data.
|
||||
|
||||
Returns:
|
||||
Prediction: The Prediction page component.
|
||||
@@ -317,8 +404,15 @@ def get_eosdash_prediction(data: dict): # type: ignore
|
||||
|
||||
|
||||
@app.get("/eosdash/health")
|
||||
def get_eosdash_health(): # type: ignore
|
||||
"""Health check endpoint to verify that the EOSdash server is alive."""
|
||||
def get_eosdash_health(request: Request): # type: ignore
|
||||
"""Health check endpoint to verify the EOSdash server is alive.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming FastHTML request.
|
||||
|
||||
Returns:
|
||||
JSONResponse: Server status including PID and version.
|
||||
"""
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "alive",
|
||||
@@ -328,13 +422,37 @@ def get_eosdash_health(): # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@app.get("/eosdash/assets/{fname:path}.{ext:static}")
|
||||
def get_eosdash_assets(fname: str, ext: str): # type: ignore
|
||||
"""Get assets."""
|
||||
asset_filepath = Path(__file__).parent.joinpath(f"dash/assets/{fname}.{ext}")
|
||||
@app.get("/eosdash/assets/{filepath:path}")
|
||||
def get_eosdash_assets(request: Request, filepath: str): # type: ignore
|
||||
"""Serve static assets for EOSdash safely.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming FastHTML request.
|
||||
filepath (str): Relative path of the asset under dash/assets/.
|
||||
|
||||
Returns:
|
||||
FileResponse: The requested asset file if it exists.
|
||||
|
||||
Raises:
|
||||
404: If the file does not exist.
|
||||
403: If the file path is forbidden (directory traversal attempt).
|
||||
"""
|
||||
try:
|
||||
asset_filepath = safe_asset_path(filepath)
|
||||
except ValueError:
|
||||
return {"error": "Forbidden"}, 403
|
||||
|
||||
if not asset_filepath.exists() or not asset_filepath.is_file():
|
||||
return {"error": "File not found"}, 404
|
||||
|
||||
return FileResponse(path=asset_filepath)
|
||||
|
||||
|
||||
# ----------------------
|
||||
# Run the EOSdash server
|
||||
# ----------------------
|
||||
|
||||
|
||||
def run_eosdash() -> None:
|
||||
"""Run the EOSdash server with the specified configurations.
|
||||
|
||||
@@ -348,6 +466,14 @@ def run_eosdash() -> None:
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
if args:
|
||||
run_as_user = args.run_as_user
|
||||
else:
|
||||
run_as_user = None
|
||||
|
||||
# Drop root privileges if running as root
|
||||
drop_root_privileges(run_as_user=run_as_user)
|
||||
|
||||
# Wait for EOSdash port to be free - e.g. in case of restart
|
||||
wait_for_port_free(config_eosdash["eosdash_port"], timeout=120, waiting_app_name="EOSdash")
|
||||
|
||||
@@ -359,6 +485,8 @@ def run_eosdash() -> None:
|
||||
log_level=config_eosdash["log_level"].lower(),
|
||||
access_log=config_eosdash["access_log"],
|
||||
reload=config_eosdash["reload"],
|
||||
proxy_headers=True,
|
||||
forwarded_allow_ips="*",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
||||
269
src/akkudoktoreos/server/rest/starteosdash.py
Normal file
269
src/akkudoktoreos/server/rest/starteosdash.py
Normal file
@@ -0,0 +1,269 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from akkudoktoreos.config.config import get_config
|
||||
from akkudoktoreos.server.server import (
|
||||
validate_ip_or_hostname,
|
||||
wait_for_port_free,
|
||||
)
|
||||
|
||||
config_eos = get_config()
|
||||
|
||||
|
||||
# Loguru to HA stdout
|
||||
logger.add(sys.stdout, format="{time} | {level} | {message}", enqueue=True)
|
||||
|
||||
|
||||
LOG_PATTERN = re.compile(
|
||||
r"""
|
||||
(?:(?P<timestamp>^\S+\s+\S+)\s*\|\s*)? # Optional timestamp
|
||||
(?P<level>TRACE|DEBUG|INFO|WARNING|ERROR|CRITICAL)\s*\|\s* # Log level
|
||||
(?:
|
||||
(?P<file_path>[A-Za-z0-9_\-./]+) # Full file path or filename
|
||||
:
|
||||
(?P<line>\d+) # Line number
|
||||
\s*\|\s*
|
||||
)?
|
||||
(?:(?P<function>[A-Za-z0-9_<>-]+)\s*\|\s*)? # Optional function name
|
||||
(?P<msg>.*) # Message
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
async def forward_stream(stream: asyncio.StreamReader, prefix: str = "") -> None:
|
||||
"""Continuously read log lines from a subprocess and re-log them via Loguru.
|
||||
|
||||
The function reads lines from an ``asyncio.StreamReader`` originating from a
|
||||
subprocess (typically the subprocess's stdout or stderr), parses the log
|
||||
metadata if present (log level, file path, line number, function), and
|
||||
forwards the log entry to Loguru. If the line cannot be parsed, it is logged
|
||||
as an ``INFO`` message with generic metadata.
|
||||
|
||||
Args:
|
||||
stream (asyncio.StreamReader):
|
||||
An asynchronous stream to read from, usually ``proc.stdout`` or
|
||||
``proc.stderr`` from ``asyncio.create_subprocess_exec``.
|
||||
prefix (str, optional):
|
||||
A string prefix added to each forwarded log line. Useful for
|
||||
distinguishing between multiple subprocess sources.
|
||||
Defaults to an empty string.
|
||||
|
||||
Notes:
|
||||
- If the subprocess log line includes a file path (e.g.,
|
||||
``/app/server/main.py:42``), both ``file.name`` and ``file.path`` will
|
||||
be set accordingly in the forwarded Loguru log entry.
|
||||
- If metadata cannot be extracted, fallback values
|
||||
(``subprocess.py`` and ``/subprocess/subprocess.py``) are used.
|
||||
- The function runs until ``stream`` reaches EOF.
|
||||
|
||||
"""
|
||||
while True:
|
||||
line = await stream.readline()
|
||||
if not line:
|
||||
break # End of stream
|
||||
|
||||
raw = line.decode(errors="replace").rstrip()
|
||||
match = LOG_PATTERN.search(raw)
|
||||
|
||||
if match:
|
||||
data = match.groupdict()
|
||||
|
||||
level = data["level"] or "INFO"
|
||||
message = data["msg"]
|
||||
|
||||
# ---- Extract file path and name ----
|
||||
file_path = data["file_path"]
|
||||
if file_path:
|
||||
if "/" in file_path:
|
||||
file_name = file_path.rsplit("/", 1)[1]
|
||||
else:
|
||||
file_name = file_path
|
||||
else:
|
||||
file_name = "subprocess.py"
|
||||
file_path = f"/subprocess/{file_name}"
|
||||
|
||||
# ---- Extract function and line ----
|
||||
func_name = data["function"] or "<subprocess>"
|
||||
line_no = int(data["line"]) if data["line"] else 1
|
||||
|
||||
# ---- Patch logger with realistic metadata ----
|
||||
patched = logger.patch(
|
||||
lambda r: r.update(
|
||||
{
|
||||
"file": {
|
||||
"name": file_name,
|
||||
"path": file_path,
|
||||
},
|
||||
"line": line_no,
|
||||
"function": func_name,
|
||||
"name": "EOSdash",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
patched.log(level, f"{prefix}{message}")
|
||||
|
||||
else:
|
||||
# Fallback: unstructured log line
|
||||
file_name = "subprocess.py"
|
||||
file_path = f"/subprocess/{file_name}"
|
||||
|
||||
logger.patch(
|
||||
lambda r: r.update(
|
||||
{
|
||||
"file": {
|
||||
"name": file_name,
|
||||
"path": file_path,
|
||||
},
|
||||
"line": 1,
|
||||
"function": "<subprocess>",
|
||||
"name": "EOSdash",
|
||||
}
|
||||
)
|
||||
).info(f"{prefix}{raw}")
|
||||
|
||||
|
||||
async def run_eosdash_supervisor() -> None:
|
||||
"""Starts EOSdash, pipes its logs, restarts it if it crashes.
|
||||
|
||||
Runs forever.
|
||||
"""
|
||||
eosdash_path = Path(__file__).parent.resolve().joinpath("eosdash.py")
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
|
||||
if not config_eos.server.startup_eosdash:
|
||||
continue
|
||||
|
||||
if (
|
||||
config_eos.server.eosdash_host is None
|
||||
or config_eos.server.eosdash_port is None
|
||||
or config_eos.server.host is None
|
||||
or config_eos.server.port is None
|
||||
):
|
||||
error_msg = (
|
||||
f"Invalid configuration for EOSdash server startup.\n"
|
||||
f"- server/eosdash_host: {config_eos.server.eosdash_host}\n"
|
||||
f"- server/eosdash_port: {config_eos.server.eosdash_port}\n"
|
||||
f"- server/host: {config_eos.server.host}\n"
|
||||
f"- server/port: {config_eos.server.port}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
continue
|
||||
|
||||
# Get all the parameters
|
||||
host = str(config_eos.server.eosdash_host)
|
||||
port = config_eos.server.eosdash_port
|
||||
eos_host = str(config_eos.server.host)
|
||||
eos_port = config_eos.server.port
|
||||
access_log = True
|
||||
reload = False
|
||||
log_level = config_eos.logging.console_level if config_eos.logging.console_level else "info"
|
||||
|
||||
try:
|
||||
validate_ip_or_hostname(host)
|
||||
validate_ip_or_hostname(eos_host)
|
||||
except Exception as ex:
|
||||
error_msg = f"Could not start EOSdash: {ex}"
|
||||
logger.error(error_msg)
|
||||
continue
|
||||
|
||||
if eos_host != host:
|
||||
# EOSdash runs on a different server - we can not start.
|
||||
error_msg = (
|
||||
f"EOSdash server startup not possible on different hosts.\n"
|
||||
f"- server/eosdash_host: {config_eos.server.eosdash_host}\n"
|
||||
f"- server/host: {config_eos.server.host}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
continue
|
||||
|
||||
# Do a one time check for port free to generate warnings if not so
|
||||
wait_for_port_free(port, timeout=0, waiting_app_name="EOSdash")
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"akkudoktoreos.server.eosdash",
|
||||
"--host",
|
||||
str(host),
|
||||
"--port",
|
||||
str(port),
|
||||
"--eos-host",
|
||||
str(eos_host),
|
||||
"--eos-port",
|
||||
str(eos_port),
|
||||
"--log_level",
|
||||
log_level,
|
||||
"--access_log",
|
||||
str(access_log),
|
||||
"--reload",
|
||||
str(reload),
|
||||
]
|
||||
# Set environment before any subprocess run, to keep custom config dir
|
||||
eos_dir = str(config_eos.package_root_path)
|
||||
eos_data_dir = str(config_eos.general.data_folder_path)
|
||||
eos_config_dir = str(config_eos.general.config_folder_path)
|
||||
env = os.environ.copy()
|
||||
env["EOS_DIR"] = eos_dir
|
||||
env["EOS_DATA_DIR"] = eos_data_dir
|
||||
env["EOS_CONFIG_DIR"] = eos_config_dir
|
||||
|
||||
logger.info("Starting EOSdash subprocess...")
|
||||
|
||||
# Start EOSdash server
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, env=env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.error(
|
||||
"Failed to start EOSdash: 'python' executable '{sys.executable}' not found."
|
||||
)
|
||||
continue
|
||||
except PermissionError:
|
||||
logger.error("Failed to start EOSdash: permission denied on 'eosdash.py'.")
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
logger.warning("EOSdash startup cancelled (shutdown?).")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error launching EOSdash: {e}")
|
||||
continue
|
||||
|
||||
if proc.stdout is None:
|
||||
logger.error("Failed to forward EOSdash output to EOS pipe.")
|
||||
else:
|
||||
# Forward log
|
||||
asyncio.create_task(forward_stream(proc.stdout, prefix="[EOSdash] "))
|
||||
|
||||
if proc.stderr is None:
|
||||
logger.error("Failed to forward EOSdash error output to EOS pipe.")
|
||||
else:
|
||||
# Forward log
|
||||
asyncio.create_task(forward_stream(proc.stderr, prefix="[EOSdash-ERR] "))
|
||||
|
||||
# If we reach here, the subprocess started successfully
|
||||
logger.info("EOSdash subprocess started successfully.")
|
||||
|
||||
# Wait for exit
|
||||
try:
|
||||
exit_code = await proc.wait()
|
||||
logger.error(f"EOSdash exited with code {exit_code}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.warning("EOSdash wait cancelled (shutdown?).")
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error while waiting for EOSdash to terminate: {e}")
|
||||
|
||||
# Restart after a delay
|
||||
logger.info("Restarting EOSdash...")
|
||||
@@ -3,10 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Coroutine, Union
|
||||
|
||||
import loguru
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
NoArgsNoReturnFuncT = Callable[[], None]
|
||||
@@ -37,7 +37,7 @@ def repeat_every(
|
||||
*,
|
||||
seconds: float,
|
||||
wait_first: float | None = None,
|
||||
logger: logging.Logger | None = None,
|
||||
logger: loguru.logger | None = None,
|
||||
raise_exceptions: bool = False,
|
||||
max_repetitions: int | None = None,
|
||||
on_complete: NoArgsNoReturnAnyFuncT | None = None,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Server Module."""
|
||||
|
||||
import grp
|
||||
import ipaddress
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
@@ -148,6 +151,179 @@ def wait_for_port_free(port: int, timeout: int = 0, waiting_app_name: str = "App
|
||||
return True
|
||||
|
||||
|
||||
def drop_root_privileges(run_as_user: Optional[str] = None) -> bool:
|
||||
"""Drop root privileges and switch execution to a less privileged user.
|
||||
|
||||
This function transitions the running process from root (UID 0) to the
|
||||
specified unprivileged user. It sets UID, GID, supplementary groups, and
|
||||
updates environment variables to reflect the new user context.
|
||||
|
||||
If the process is not running as root, no privilege changes are made.
|
||||
|
||||
Args:
|
||||
run_as_user (str | None):
|
||||
The name of the target user to switch to.
|
||||
If ``None`` (default), the current effective user is used and
|
||||
no privilege change is attempted.
|
||||
|
||||
Returns:
|
||||
bool:
|
||||
``True`` if privileges were successfully dropped OR the process is
|
||||
already running as the target user.
|
||||
``False`` if privilege dropping failed.
|
||||
|
||||
Notes:
|
||||
- This must be called very early during startup, before opening files,
|
||||
creating sockets, or starting threads.
|
||||
- Dropping privileges is irreversible within the same process.
|
||||
- The target user must exist inside the container (valid entry in
|
||||
``/etc/passwd`` and ``/etc/group``).
|
||||
"""
|
||||
# Determine current user
|
||||
current_user = pwd.getpwuid(os.geteuid()).pw_name
|
||||
|
||||
# No action needed if already running as the desired user
|
||||
if run_as_user is None or run_as_user == current_user:
|
||||
return True
|
||||
|
||||
# Cannot switch users unless running as root
|
||||
if os.geteuid() != 0:
|
||||
logger.error(
|
||||
f"Privilege switch requested to '{run_as_user}' "
|
||||
f"but process is not root (running as '{current_user}')."
|
||||
)
|
||||
return False
|
||||
|
||||
# Resolve target user info
|
||||
try:
|
||||
pw_record = pwd.getpwnam(run_as_user)
|
||||
except KeyError:
|
||||
logger.error(f"Privilege switch failed: user '{run_as_user}' does not exist.")
|
||||
return False
|
||||
|
||||
user_uid: int = pw_record.pw_uid
|
||||
user_gid: int = pw_record.pw_gid
|
||||
|
||||
try:
|
||||
# Get all groups where the user is listed as a member
|
||||
supplementary_groups: list[int] = [
|
||||
g.gr_gid for g in grp.getgrall() if run_as_user in g.gr_mem
|
||||
]
|
||||
|
||||
# Ensure the primary group is included (it usually is NOT in gr_mem)
|
||||
if user_gid not in supplementary_groups:
|
||||
supplementary_groups.append(user_gid)
|
||||
|
||||
# Apply groups, gid, uid (in that order)
|
||||
os.setgroups(supplementary_groups)
|
||||
os.setgid(user_gid)
|
||||
os.setuid(user_uid)
|
||||
except Exception as e:
|
||||
logger.error(f"Privilege switch failed: {e}")
|
||||
return False
|
||||
|
||||
# Update environment variables to reflect the new user identity
|
||||
os.environ["HOME"] = pw_record.pw_dir
|
||||
os.environ["LOGNAME"] = run_as_user
|
||||
os.environ["USER"] = run_as_user
|
||||
|
||||
# Restrictive umask
|
||||
os.umask(0o077)
|
||||
|
||||
# Verify that privilege drop was successful
|
||||
if os.geteuid() != user_uid or os.getegid() != user_gid:
|
||||
logger.error(
|
||||
f"Privilege drop sanity check failed: now uid={os.geteuid()}, gid={os.getegid()}, "
|
||||
f"expected uid={user_uid}, gid={user_gid}"
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
f"Switched privileges to user '{run_as_user}' "
|
||||
f"(uid={user_uid}, gid={user_gid}, groups={supplementary_groups})"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def fix_data_directories_permissions(run_as_user: Optional[str] = None) -> None:
|
||||
"""Ensure correct ownership for data directories.
|
||||
|
||||
This function recursively updates the owner and group of the data directories and all of its
|
||||
subdirectories and files so that they belong to the given user.
|
||||
|
||||
The function may require root privileges to change file ownership. It logs an error message
|
||||
if a path ownership can not be updated.
|
||||
|
||||
Args:
|
||||
run_as_user (Optional[str]): The user who should own the data directories and files.
|
||||
Defaults to current one.
|
||||
"""
|
||||
from akkudoktoreos.config.config import get_config
|
||||
|
||||
config_eos = get_config()
|
||||
|
||||
base_dirs = [
|
||||
config_eos.general.data_folder_path,
|
||||
config_eos.general.data_output_path,
|
||||
config_eos.general.config_folder_path,
|
||||
config_eos.cache.path(),
|
||||
]
|
||||
|
||||
error_msg: Optional[str] = None
|
||||
|
||||
if run_as_user is None:
|
||||
# Get current user - try to ensure current user can access the data directories
|
||||
run_as_user = pwd.getpwuid(os.geteuid()).pw_name
|
||||
|
||||
try:
|
||||
pw_record = pwd.getpwnam(run_as_user)
|
||||
except KeyError as e:
|
||||
error_msg = f"Data directories '{base_dirs}' permission fix failed: user '{run_as_user}' does not exist."
|
||||
logger.error(error_msg)
|
||||
return
|
||||
|
||||
uid = pw_record.pw_uid
|
||||
gid = pw_record.pw_gid
|
||||
|
||||
# Walk directory tree and fix permissions
|
||||
for base_dir in base_dirs:
|
||||
if base_dir is None:
|
||||
continue
|
||||
# ensure base dir exists
|
||||
try:
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Could not setup data dir '{base_dir}': {e}")
|
||||
continue
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for name in dirs + files:
|
||||
path = os.path.join(root, name)
|
||||
try:
|
||||
os.chown(path, uid, gid)
|
||||
except PermissionError as e:
|
||||
error_msg = f"Permission denied while updating ownership of '{path}' to user '{run_as_user}'"
|
||||
logger.error(error_msg)
|
||||
except Exception as e:
|
||||
error_msg = (
|
||||
f"Updating ownership failed of '{path}' to user '{run_as_user}': {e}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
# Also fix the base directory itself
|
||||
try:
|
||||
os.chown(base_dir, uid, gid)
|
||||
except PermissionError as e:
|
||||
error_msg = (
|
||||
f"Permission denied while updating ownership of '{path}' to user '{run_as_user}'"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
except Exception as e:
|
||||
error_msg = f"Updating ownership failed of '{path}' to user '{run_as_user}': {e}"
|
||||
logger.error(error_msg)
|
||||
|
||||
if error_msg is None:
|
||||
logger.info(f"Updated ownership of '{base_dirs}' recursively to user '{run_as_user}'.")
|
||||
|
||||
|
||||
class ServerCommonSettings(SettingsBaseModel):
|
||||
"""Server Configuration."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user