Improve EOSdash.

Make EOSdash use UI components from MonsterUI to ease further development.

- Add a first menu with some dummy pages and the configuration page.
- Make the configuration scrollable.
- Add markdown component that uses markdown-it-py (same as used by
  the myth-parser for documentation generation).
- Add bokeh (https://docs.bokeh.org/) component for charts
- Added several prediction charts to demo
- Add a footer that displays connection status with EOS server
- Add logo and favicon

Update EOS server:

- Move error message generation to extra module
- Use redirect instead of proxy

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
Bobby Noelte
2025-01-22 23:47:28 +01:00
parent 80bfe4d0f0
commit ab6a518b5f
34 changed files with 1802 additions and 351 deletions

View File

@@ -7,12 +7,10 @@ import os
import signal
import subprocess
import sys
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union
import httpx
import psutil
import uvicorn
from fastapi import Body, FastAPI
@@ -48,8 +46,9 @@ from akkudoktoreos.prediction.load import LoadCommonSettings
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
from akkudoktoreos.prediction.prediction import PredictionCommonSettings, get_prediction
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
from akkudoktoreos.server.rest.error import create_error_page
from akkudoktoreos.server.rest.tasks import repeat_every
from akkudoktoreos.server.server import get_default_host
from akkudoktoreos.server.server import get_default_host, wait_for_port_free
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
logger = get_logger(__name__)
@@ -61,98 +60,6 @@ ems_eos = get_ems()
# Command line arguments
args = None
ERROR_PAGE_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Energy Optimization System (EOS) Error</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: #f5f5f5;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
padding: 20px;
box-sizing: border-box;
}
.error-container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
max-width: 500px;
width: 100%;
text-align: center;
}
.error-code {
font-size: 4rem;
font-weight: bold;
color: #e53e3e;
margin: 0;
}
.error-title {
font-size: 1.5rem;
color: #2d3748;
margin: 1rem 0;
}
.error-message {
color: #4a5568;
margin-bottom: 1.5rem;
}
.error-details {
background: #f7fafc;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1.5rem;
text-align: left;
font-family: monospace;
white-space: pre-wrap;
word-break: break-word;
}
.back-button {
background: #3182ce;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
text-decoration: none;
display: inline-block;
transition: background-color 0.2s;
}
.back-button:hover {
background: #2c5282;
}
</style>
</head>
<body>
<div class="error-container">
<h1 class="error-code">STATUS_CODE</h1>
<h2 class="error-title">ERROR_TITLE</h2>
<p class="error-message">ERROR_MESSAGE</p>
<div class="error-details">ERROR_DETAILS</div>
<a href="/docs" class="back-button">Back to Home</a>
</div>
</body>
</html>
"""
def create_error_page(
status_code: str, error_title: str, error_message: str, error_details: str
) -> str:
"""Create an error page by replacing placeholders in the template."""
return (
ERROR_PAGE_TEMPLATE.replace("STATUS_CODE", status_code)
.replace("ERROR_TITLE", error_title)
.replace("ERROR_MESSAGE", error_message)
.replace("ERROR_DETAILS", error_details)
)
# ----------------------
# EOSdash server startup
@@ -194,18 +101,8 @@ def start_eosdash(
"""
eosdash_path = Path(__file__).parent.resolve().joinpath("eosdash.py")
# Check if the EOSdash process is still/ already running, e.g. in case of server restart
process_info = None
for conn in psutil.net_connections(kind="inet"):
if conn.laddr.port == port:
process = psutil.Process(conn.pid)
# Get the fresh process info
process_info = process.as_dict(attrs=["pid", "cmdline"])
break
if process_info:
# Just warn
logger.warning(f"EOSdash port `{port}` still/ already in use.")
logger.warning(f"PID: `{process_info['pid']}`, CMD: `{process_info['cmdline']}`")
# 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,
@@ -391,9 +288,6 @@ app = FastAPI(
)
server_dir = Path(__file__).parent.resolve()
class PdfResponse(FileResponse):
media_type = "application/pdf"
@@ -523,7 +417,7 @@ def fastapi_health_get(): # type: ignore
@app.post("/v1/config/reset", tags=["config"])
def fastapi_config_reset_post() -> ConfigEOS:
"""Reset the configuration.
"""Reset the configuration to the EOS configuration file.
Returns:
configuration (ConfigEOS): The current configuration after update.
@@ -812,6 +706,49 @@ def fastapi_prediction_series_get(
return PydanticDateTimeSeries.from_series(pdseries)
@app.get("/v1/prediction/dataframe", tags=["prediction"])
def fastapi_prediction_dataframe_get(
keys: Annotated[list[str], Query(description="Prediction keys.")],
start_datetime: Annotated[
Optional[str],
Query(description="Starting datetime (inclusive)."),
] = None,
end_datetime: Annotated[
Optional[str],
Query(description="Ending datetime (exclusive)."),
] = None,
interval: Annotated[
Optional[str],
Query(description="Time duration for each interval. Defaults to 1 hour."),
] = None,
) -> PydanticDateTimeDataFrame:
"""Get prediction for given key within given date range as series.
Args:
key (str): Prediction key
start_datetime (Optional[str]): Starting datetime (inclusive).
Defaults to start datetime of latest prediction.
end_datetime (Optional[str]: Ending datetime (exclusive).
Defaults to end datetime of latest prediction.
"""
for key in keys:
if key not in prediction_eos.record_keys:
raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.")
if start_datetime is None:
start_datetime = prediction_eos.start_datetime
else:
start_datetime = to_datetime(start_datetime)
if end_datetime is None:
end_datetime = prediction_eos.end_datetime
else:
end_datetime = to_datetime(end_datetime)
df = prediction_eos.keys_to_dataframe(
keys=keys, start_datetime=start_datetime, end_datetime=end_datetime, interval=interval
)
return PydanticDateTimeDataFrame.from_dataframe(df, tz=config_eos.general.timezone)
@app.get("/v1/prediction/list", tags=["prediction"])
def fastapi_prediction_list_get(
key: Annotated[str, Query(description="Prediction key.")],
@@ -1223,75 +1160,66 @@ def site_map() -> RedirectResponse:
return RedirectResponse(url="/docs")
# Keep the proxy last to handle all requests that are not taken by the Rest API.
# Keep the redirect last to handle all requests that are not taken by the Rest API.
@app.delete("/{path:path}", include_in_schema=False)
async def proxy_delete(request: Request, path: str) -> Response:
return await proxy(request, path)
async def redirect_delete(request: Request, path: str) -> Response:
return redirect(request, path)
@app.get("/{path:path}", include_in_schema=False)
async def proxy_get(request: Request, path: str) -> Response:
return await proxy(request, path)
async def redirect_get(request: Request, path: str) -> Response:
return redirect(request, path)
@app.post("/{path:path}", include_in_schema=False)
async def proxy_post(request: Request, path: str) -> Response:
return await proxy(request, path)
async def redirect_post(request: Request, path: str) -> Response:
return redirect(request, path)
@app.put("/{path:path}", include_in_schema=False)
async def proxy_put(request: Request, path: str) -> Response:
return await proxy(request, path)
async def redirect_put(request: Request, path: str) -> Response:
return redirect(request, path)
async def proxy(request: Request, path: str) -> Union[Response | RedirectResponse | HTMLResponse]:
def redirect(request: Request, path: str) -> Union[HTMLResponse, RedirectResponse]:
# Path is not for EOSdash
if not (path.startswith("eosdash") or path == ""):
host = config_eos.server.eosdash_host
if host is None:
host = config_eos.server.host
host = str(host)
port = config_eos.server.eosdash_port
if port is None:
port = 8504
# Make hostname Windows friendly
if host == "0.0.0.0" and os.name == "nt":
host = "localhost"
url = f"http://{host}:{port}/"
error_page = create_error_page(
status_code="404",
error_title="Page Not Found",
error_message=f"""<pre>
URL is unknown: '{request.url}'
Did you want to connect to <a href="{url}" class="back-button">EOSdash</a>?
</pre>
""",
error_details="Unknown URL",
)
return HTMLResponse(content=error_page, status_code=404)
# Make hostname Windows friendly
host = str(config_eos.server.eosdash_host)
if host == "0.0.0.0" and os.name == "nt":
host = "localhost"
if host and config_eos.server.eosdash_port:
# Proxy to EOSdash server
# Redirect to EOSdash server
url = f"http://{host}:{config_eos.server.eosdash_port}/{path}"
headers = dict(request.headers)
return RedirectResponse(url=url, status_code=303)
data = await request.body()
try:
async with httpx.AsyncClient() as client:
if request.method == "GET":
response = await client.get(url, headers=headers)
elif request.method == "POST":
response = await client.post(url, headers=headers, content=data)
elif request.method == "PUT":
response = await client.put(url, headers=headers, content=data)
elif request.method == "DELETE":
response = await client.delete(url, headers=headers, content=data)
except Exception as e:
error_page = create_error_page(
status_code="404",
error_title="Page Not Found",
error_message=f"""<pre>
EOSdash server not reachable: '{url}'
Did you start the EOSdash server
or set 'startup_eosdash'?
If there is no application server intended please
set 'eosdash_host' or 'eosdash_port' to None.
</pre>
""",
error_details=f"{e}",
)
return HTMLResponse(content=error_page, status_code=404)
return Response(
content=response.content,
status_code=response.status_code,
headers=dict(response.headers),
)
else:
# Redirect the root URL to the site map
return RedirectResponse(url="/docs")
# Redirect the root URL to the site map
return RedirectResponse(url="/docs", status_code=303)
def run_eos(host: str, port: int, log_level: str, access_log: bool, reload: bool) -> None:
@@ -1320,26 +1248,7 @@ def run_eos(host: str, port: int, log_level: str, access_log: bool, reload: bool
host = "localhost"
# Wait for EOS port to be free - e.g. in case of restart
timeout = 120 # Maximum 120 seconds to wait
process_info: list[dict] = []
for retries in range(int(timeout / 10)):
process_info = []
pids: list[int] = []
for conn in psutil.net_connections(kind="inet"):
if conn.laddr.port == port:
if conn.pid not in pids:
# Get fresh process info
process = psutil.Process(conn.pid)
pids.append(conn.pid)
process_info.append(process.as_dict(attrs=["pid", "cmdline"]))
if len(process_info) == 0:
break
logger.info(f"EOS waiting for port `{port}` ...")
time.sleep(10)
if len(process_info) > 0:
logger.warning(f"EOS port `{port}` in use.")
for info in process_info:
logger.warning(f"PID: `{info["pid"]}`, CMD: `{info["cmdline"]}`")
wait_for_port_free(port, timeout=120, waiting_app_name="EOS")
try:
uvicorn.run(