mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
feat(eosdash): add the profile page for battery and inverter profiles
The new Profiles page of EOSdash lists the user and packaged battery and inverter profiles and edits them as JSON: preview without saving, save, save as and delete, or revert a user profile to the packaged one. Saved profiles apply at once. One toolbar row holds the profile kind and profile selection, the Edit button, the Share link and the status of the last action. A compact grid summarizes the profile. A battery profile shows its charge and discharge limit maps as heat maps as EOS evaluates them (with the exact hard cutoffs) and its efficiency curves; an inverter profile its power at 45 degC, the DC→AC and AC→DC efficiency curves over the load fraction and the power derating over the ambient temperature with 45 degC marked. Both show the profile as highlighted JSON with a copy button. The sections are collapsible and keep their state across actions. The editor is shown only in edit mode: Edit in the toolbar opens it above the plots, Close in the editor or Close editor in the toolbar ends it. Edit mode is kept after preview, save and save as and across profile changes. Share opens a new issue with the battery or inverter profile issue form, filled in with the profile, so users can propose their profiles for packaging with EOS; each form asks for consent to publish the values under Apache-2.0. EOS sends nothing itself. Additional changes - Add a compact variant to ConfigSection and status messages in the theme colors, readable in dark mode, to the EOSdash components. - Use the muted theme color for explanatory texts. - Check profile names before saving. - Show the paste instruction for profiles too large for the share URL as text, not only as tooltip; name the selections for screen readers. - Keep the toolbar layout when the profiles cannot be listed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assisted-by: Claude Code (claude-opus-5-5)
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
faf37abfe7
commit
3bcc902104
@@ -3,13 +3,10 @@ import re
|
||||
from typing import Any, Callable, Optional, Union
|
||||
|
||||
from fasthtml.common import H1, Button, Div, Li, Select
|
||||
from monsterui.daisy import (
|
||||
Alert,
|
||||
AlertT,
|
||||
)
|
||||
from monsterui.foundations import stringify
|
||||
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,
|
||||
H4,
|
||||
ButtonT,
|
||||
Card,
|
||||
Code,
|
||||
@@ -150,24 +147,48 @@ def TextView(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) ->
|
||||
return Pre(*c, **kwargs)
|
||||
|
||||
|
||||
def Success(*c: Any) -> Alert:
|
||||
return Alert(
|
||||
# Colors of the status messages from the theme of EOSdash, readable in light and
|
||||
# dark mode: (box, icon). daisyUI's alert colors come from its own color theme and
|
||||
# give dark text on a green or gray background in dark mode.
|
||||
STATUS_TONES: dict[str, tuple[str, str]] = {
|
||||
"success": ("bg-primary/10 border-primary/40", "text-primary"),
|
||||
"info": ("bg-muted border-border", "text-muted-foreground"),
|
||||
"error": ("bg-destructive/10 border-destructive/40", "text-destructive"),
|
||||
}
|
||||
|
||||
|
||||
def StatusMessage(tone: str, icon: str, *c: Any) -> Div:
|
||||
"""A status message box in the colors of the EOSdash theme.
|
||||
|
||||
Args:
|
||||
tone: ``"success"``, ``"info"`` or ``"error"``, see ``STATUS_TONES``.
|
||||
icon: Name of the icon.
|
||||
*c: Content of the message.
|
||||
|
||||
Returns:
|
||||
The message box, with ``role="alert"``.
|
||||
"""
|
||||
box_cls, icon_cls = STATUS_TONES[tone]
|
||||
return Div(
|
||||
DivLAligned(
|
||||
UkIcon("check"),
|
||||
TextView(*c),
|
||||
UkIcon(icon, cls=icon_cls),
|
||||
*c,
|
||||
),
|
||||
cls=AlertT.success,
|
||||
role="alert",
|
||||
cls=f"rounded-lg border p-3 text-foreground {box_cls}",
|
||||
)
|
||||
|
||||
|
||||
def Error(*c: Any) -> Alert:
|
||||
return Alert(
|
||||
DivLAligned(
|
||||
UkIcon("triangle-alert"),
|
||||
TextView(*c),
|
||||
),
|
||||
cls=AlertT.error,
|
||||
)
|
||||
def Success(*c: Any) -> Div:
|
||||
return StatusMessage("success", "check", TextView(*c))
|
||||
|
||||
|
||||
def Info(*c: Any) -> Div:
|
||||
return StatusMessage("info", "info", TextView(*c))
|
||||
|
||||
|
||||
def Error(*c: Any) -> Div:
|
||||
return StatusMessage("error", "triangle-alert", TextView(*c))
|
||||
|
||||
|
||||
def ConfigButton(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Button:
|
||||
@@ -188,7 +209,7 @@ def ConfigButton(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any
|
||||
return Button(*c, submit=False, **kwargs)
|
||||
|
||||
|
||||
def UpdateError(error_text: str) -> Alert:
|
||||
def UpdateError(error_text: str) -> Div:
|
||||
"""Renders a compact error with collapsible full detail.
|
||||
|
||||
Extracts the short pydantic validation message (text after
|
||||
@@ -199,7 +220,7 @@ def UpdateError(error_text: str) -> Alert:
|
||||
error_text: The full error string from a config update failure.
|
||||
|
||||
Returns:
|
||||
Alert: A collapsible error element with error styling.
|
||||
Div: A collapsible error element with error styling.
|
||||
"""
|
||||
short = None
|
||||
match = re.search(r"validation error for [^\n]+\n([^\n]+)\n\s+([^\[]+)", error_text)
|
||||
@@ -208,11 +229,12 @@ def UpdateError(error_text: str) -> Alert:
|
||||
if not short:
|
||||
short = error_text.splitlines()[0].strip()
|
||||
|
||||
return Alert(
|
||||
box_cls, icon_cls = STATUS_TONES["error"]
|
||||
return Div(
|
||||
Details(
|
||||
Summary(
|
||||
DivLAligned(
|
||||
UkIcon("triangle-alert"),
|
||||
UkIcon("triangle-alert", cls=icon_cls),
|
||||
P(short, cls="text-sm ml-2"),
|
||||
),
|
||||
cls="list-none cursor-pointer",
|
||||
@@ -222,7 +244,8 @@ def UpdateError(error_text: str) -> Alert:
|
||||
cls="rounded-lg bg-muted p-3 mt-2 max-h-[30vh] overflow-y-auto overflow-x-hidden whitespace-pre-wrap text-xs",
|
||||
),
|
||||
),
|
||||
cls=AlertT.error,
|
||||
role="alert",
|
||||
cls=f"rounded-lg border p-3 text-foreground {box_cls}",
|
||||
)
|
||||
|
||||
|
||||
@@ -1158,20 +1181,32 @@ def ConfigCard(
|
||||
)
|
||||
|
||||
|
||||
def ConfigSection(title: str, *content: Any, open: bool = False) -> Details:
|
||||
"""Collapsible section for grouping configuration entries."""
|
||||
def ConfigSection(
|
||||
title: str, *content: Any, open: bool = False, compact: bool = False, **kwargs: Any
|
||||
) -> Details:
|
||||
"""Collapsible section for grouping configuration entries.
|
||||
|
||||
Args:
|
||||
title: Title shown in the summary.
|
||||
*content: Content of the section.
|
||||
open: Whether the section is open.
|
||||
compact: Smaller title and less padding, e.g. for pages with plots.
|
||||
**kwargs: Further attributes of the ``details`` element, e.g. ``data_*``.
|
||||
"""
|
||||
heading = H4(title, cls="text-base") if compact else H3(title, cls="ml-2")
|
||||
return Details(
|
||||
Summary(
|
||||
Div(
|
||||
UkIcon("chevron-right", cls="transition-transform group-open:rotate-90"),
|
||||
H3(title, cls="ml-2"),
|
||||
heading,
|
||||
cls="flex items-center gap-2 cursor-pointer",
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
Div(*content, cls="space-y-3 mt-3"),
|
||||
Div(*content, cls="space-y-2 mt-2" if compact else "space-y-3 mt-3"),
|
||||
open=open,
|
||||
cls="group border rounded-lg p-2",
|
||||
cls="group border rounded-md px-3 py-2" if compact else "group border rounded-lg p-2",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ from akkudoktoreos.server.dash.context import (
|
||||
from akkudoktoreos.server.dash.footer import Footer
|
||||
from akkudoktoreos.server.dash.plan import Plan
|
||||
from akkudoktoreos.server.dash.prediction import Prediction
|
||||
from akkudoktoreos.server.dash.profile import Profile
|
||||
from akkudoktoreos.server.server import (
|
||||
drop_root_privileges,
|
||||
get_default_host,
|
||||
@@ -293,6 +294,7 @@ def get_eosdash(request: Request): # type: ignore
|
||||
"Plan": "/eosdash/plan",
|
||||
"Prediction": "/eosdash/prediction",
|
||||
"Config": "/eosdash/configuration",
|
||||
"Profiles": "/eosdash/profile",
|
||||
"Admin": "/eosdash/admin",
|
||||
"About": "/eosdash/about",
|
||||
},
|
||||
@@ -410,6 +412,34 @@ def post_eosdash_configuration(request: Request, data: dict): # type: ignore
|
||||
return Configuration(*eos_server(), data)
|
||||
|
||||
|
||||
@app.get("/eosdash/profile")
|
||||
def get_eosdash_profile(request: Request, data: dict): # type: ignore
|
||||
"""Serve the EOSdash battery profile page.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming FastHTML request.
|
||||
data (dict): Optional query data (e.g. dark mode).
|
||||
|
||||
Returns:
|
||||
Profile: The battery profile page component.
|
||||
"""
|
||||
return Profile(*eos_server(), data)
|
||||
|
||||
|
||||
@app.post("/eosdash/profile")
|
||||
def post_eosdash_profile(request: Request, data: dict): # type: ignore
|
||||
"""Run an action of the battery profile page (select, save, save as, delete).
|
||||
|
||||
Args:
|
||||
request (Request): The incoming FastHTML request.
|
||||
data (dict): User-submitted data from the battery profile page.
|
||||
|
||||
Returns:
|
||||
Profile: The battery profile page component.
|
||||
"""
|
||||
return Profile(*eos_server(), data)
|
||||
|
||||
|
||||
@app.get("/eosdash/plan")
|
||||
def get_eosdash_plan(request: Request, data: dict): # type: ignore
|
||||
"""Serve the EOSdash Plan page.
|
||||
|
||||
Reference in New Issue
Block a user