diff --git a/docs/_generated/openapi.md b/docs/_generated/openapi.md index 209d656e..3d8b2bf8 100644 --- a/docs/_generated/openapi.md +++ b/docs/_generated/openapi.md @@ -288,6 +288,22 @@ Returns: --- +## GET /v1/health + +**Links**: [local](http://localhost:8503/docs#/default/fastapi_health_get_v1_health_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_health_get_v1_health_get) + +Fastapi Health Get + +``` +Health check endpoint to verify that the server is alive. +``` + +**Responses**: + +- **200**: Successful Response + +--- + ## PUT /v1/measurement/data **Links**: [local](http://localhost:8503/docs#/default/fastapi_measurement_data_put_v1_measurement_data_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_measurement_data_put_v1_measurement_data_put) diff --git a/openapi.json b/openapi.json index 3df0c99e..229db215 100644 --- a/openapi.json +++ b/openapi.json @@ -3160,7 +3160,10 @@ "description": "Successful Response" } }, - "summary": "Fastapi Config Update Post" + "summary": "Fastapi Config Update Post", + "tags": [ + "config" + ] } }, "/v1/config/value": { @@ -3212,7 +3215,27 @@ "description": "Validation Error" } }, - "summary": "Fastapi Config Value Put" + "summary": "Fastapi Config Value Put", + "tags": [ + "config" + ] + } + }, + "/v1/health": { + "get": { + "description": "Health check endpoint to verify that the server is alive.", + "operationId": "fastapi_health_get_v1_health_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Fastapi Health Get" } }, "/v1/measurement/data": { diff --git a/src/akkudoktoreos/server/dash/components.py b/src/akkudoktoreos/server/dash/components.py index 105dd8f1..0ddaa03e 100644 --- a/src/akkudoktoreos/server/dash/components.py +++ b/src/akkudoktoreos/server/dash/components.py @@ -1,6 +1,6 @@ from typing import Any, Optional, Union -from fasthtml.common import FT, H1, Div, Li, P +from fasthtml.common import FT, H1, Div, Li from monsterui.foundations import stringify from monsterui.franken import ( Button, @@ -64,7 +64,7 @@ def Markdown(md: str) -> FT: return render_md(md) -def Header(title: Optional[str]) -> Div: +def DashboardHeader(title: Optional[str]) -> Div: """Creates a styled header with a title. Args: @@ -78,16 +78,24 @@ def Header(title: Optional[str]) -> Div: return Div(H1(title, cls="text-2xl font-bold mb-4"), cls="header") -def Footer(info: str) -> Card: +def DashboardFooter(path: str) -> Card: """Creates a styled footer with the provided information. + The footer content is reloaded every 5 seconds from path. + Args: - info (str): Footer information text. + path (str): Path to reload footer content from Returns: Card: A styled `Card` element containing the footer. """ - return Card(Container(P(info, cls="text-sm font-medium"), cls="footer")) + return Card( + Container("Footer", id="footer-content"), + hx_get=f"{path}", + hx_trigger="every 5s", + hx_target="#footer-content", + hx_swap="innerHTML", + ) def DashboardTrigger(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Button: @@ -129,10 +137,10 @@ def DashboardTabs(dashboard_items: dict[str, str]) -> Card: ) for menu, path in dashboard_items.items() ] - return Card(TabContainer(*dash_items), alt=True) + return Card(TabContainer(*dash_items, cls="gap-4"), alt=True) -def Content(content: Any) -> Card: +def DashboardContent(content: Any) -> Card: """Creates a content section within a styled card. Args: @@ -141,11 +149,14 @@ def Content(content: Any) -> Card: Returns: Card: A styled `Card` element containing the content. """ - return Card(Container(content, id="page-content")) + return Card(ScrollArea(Container(content, id="page-content"), cls="h-[75vh] w-full rounded-md")) def Page( - title: Optional[str], dashboard_items: dict[str, str], content: Any, footer_info: str + title: Optional[str], + dashboard_items: dict[str, str], + content: Any, + footer_path: str, ) -> Div: """Generates a full-page layout with a header, dashboard items, content, and footer. @@ -153,15 +164,16 @@ def Page( title (Optional[str]): The page title. dashboard_items (dict[str, str]): A dictionary of dashboard items. content (Any): The main content for the page. - footer_info (str): Footer information text. + footer_content (Any): Footer content. + footer_path (Any): Path to reload footer content from. Returns: Div: A `Div` element representing the entire page layout. """ return Container( - Header(title), + DashboardHeader(title), DashboardTabs(dashboard_items), - Content(content), - Footer(footer_info), + DashboardContent(content), + DashboardFooter(footer_path), cls=("w-screen p-4 space-y-4", ContainerT.xl), ) diff --git a/src/akkudoktoreos/server/dash/configuration.py b/src/akkudoktoreos/server/dash/configuration.py index 1da95660..2368a2c3 100644 --- a/src/akkudoktoreos/server/dash/configuration.py +++ b/src/akkudoktoreos/server/dash/configuration.py @@ -1,7 +1,6 @@ import json -from functools import reduce from http import HTTPStatus -from typing import Any, Dict, List, Optional, TypeVar, Union +from typing import Any, Dict, List, Optional, Sequence, TypeVar, Union import requests from monsterui.franken import Table, Tbody, Td, Th, Thead, Tr @@ -12,7 +11,6 @@ from akkudoktoreos.config.config import get_config from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.pydantic import PydanticBaseModel -from akkudoktoreos.server.dash.components import ScrollArea logger = get_logger(__name__) config_eos = get_config() @@ -21,37 +19,45 @@ def get_nested_value( - dictionary: Dict[str, Any], keys: List[str], default: Optional[T] = None + dictionary: Union[Dict[str, Any], List[Any]], + keys: Sequence[Union[str, int]], + default: Optional[T] = None, ) -> Union[Any, T]: - """Retrieve a nested value from a dictionary using a list of keys. + """Retrieve a nested value from a dictionary or list using a sequence of keys. Args: - dictionary (Dict[str, Any]): The nested dictionary to search. - keys (List[str]): A list of keys representing the path to the desired value. + dictionary (Union[Dict[str, Any], List[Any]]): The nested dictionary or list to search. + keys (Sequence[Union[str, int]]): A sequence of keys or indices representing the path to the desired value. default (Optional[T]): A value to return if the path is not found. Returns: Union[Any, T]: The value at the specified nested path, or the default value if not found. Raises: - TypeError: If the dictionary is not of type `dict` or keys is not a `list`. + TypeError: If the input is not a dictionary or list, or if keys are not a sequence. + KeyError: If a key is not found in a dictionary. + IndexError: If an index is out of range in a list. """ - # Validate input type - if not isinstance(dictionary, dict): - raise TypeError("First argument must be a dictionary") + if not isinstance(dictionary, (dict, list)): + raise TypeError("The first argument must be a dictionary or list") + if not isinstance(keys, Sequence): + raise TypeError("Keys must be provided as a sequence (e.g., list, tuple)") - # Validate keys input - if not isinstance(keys, list): - raise TypeError("Keys must be provided as a list") - - # Empty key list returns the entire dictionary if not keys: return dictionary try: - # Use reduce for a functional approach - return reduce(lambda d, key: d[key], keys, dictionary) - except (KeyError, TypeError): + # Traverse the structure + current = dictionary + for key in keys: + if isinstance(current, dict) and isinstance(key, str): + current = current[key] + elif isinstance(current, list) and isinstance(key, int): + current = current[key] + else: + raise KeyError(f"Invalid key or index: {key}") + return current + except (KeyError, IndexError, TypeError): return default @@ -177,7 +183,6 @@ def get_configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int Returns: List[dict]: A list of processed configuration entries. """ - config_eos = get_config() if eos_host is None: eos_host = config_eos.server.host if eos_port is None: @@ -237,7 +242,4 @@ def Configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) for config in get_configuration(eos_host, eos_port) ] head = Thead(*map(Th, flds), cls="bg-lime-400 text-left") - return ScrollArea( - Table(head, Tbody(*rows), cls="w-full"), - cls="h-[75vh] w-full rounded-md", - ) + return Table(head, Tbody(*rows), cls="w-full") diff --git a/src/akkudoktoreos/server/dash/hello.py b/src/akkudoktoreos/server/dash/hello.py index 08f4126f..3759737f 100644 --- a/src/akkudoktoreos/server/dash/hello.py +++ b/src/akkudoktoreos/server/dash/hello.py @@ -1,6 +1,6 @@ from fasthtml.common import Div -from akkudoktoreos.server.dash.components import Markdown, ScrollArea +from akkudoktoreos.server.dash.components import Markdown hello_md = """# Akkudoktor EOSdash @@ -17,7 +17,4 @@ def Hello() -> Div: - return ScrollArea( - Markdown(hello_md), - cls="h-[75vh] w-full rounded-md", - ) + return Markdown(hello_md) diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index eb15490b..bae8972a 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -10,7 +10,7 @@ import httpx import uvicorn -from fastapi import FastAPI, Query, Request +from fastapi import FastAPI, Query, Request, status from fastapi.exceptions import HTTPException from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response @@ -144,7 +144,13 @@ class PdfResponse(FileResponse): media_type = "application/pdf" -@app.put("/v1/config/value") +@app.get("/v1/health", status_code=status.HTTP_200_OK) +async def fastapi_health_get(): # type: ignore + """Health check endpoint to verify that the server is alive.""" + return {"status": "alive"} + + +@app.put("/v1/config/value", tags=["config"]) def fastapi_config_value_put( key: Annotated[str, Query(description="configuration key")], value: Annotated[Any, Query(description="configuration value")], @@ -169,7 +175,7 @@ def fastapi_config_value_put( return config_eos -@app.post("/v1/config/update") +@app.post("/v1/config/update", tags=["config"]) def fastapi_config_update_post() -> ConfigEOS: """Reset the configuration to the EOS configuration file. diff --git a/src/akkudoktoreos/server/eosdash.py b/src/akkudoktoreos/server/eosdash.py index 64323ba3..f5067871 100644 --- a/src/akkudoktoreos/server/eosdash.py +++ b/src/akkudoktoreos/server/eosdash.py @@ -13,6 +13,7 @@ # Pages from akkudoktoreos.server.dash.configuration import Configuration from akkudoktoreos.server.dash.demo import Demo +from akkudoktoreos.server.dash.footer import Footer from akkudoktoreos.server.dash.hello import Hello logger = get_logger(__name__) @@ -44,10 +45,26 @@ def get_eosdash(): # type: ignore "Demo": "/eosdash/demo", }, Hello(), - "Footer_Info", + "/eosdash/footer", ) +@app.get("/eosdash/footer") +def get_eosdash_footer(): # type: ignore + """Serves the EOSdash Foooter information. + + Returns: + Footer: The Footer component. + """ + if args is None: + eos_host = None + eos_port = None + else: + eos_host = args.eos_host + eos_port = args.eos_port + return Footer(eos_host, eos_port) + + @app.get("/eosdash/hello") def get_eosdash_hello(): # type: ignore """Serves the EOSdash Hello page.