Frontend (Streamlit)
Zweck
src/frontend/ ist die Verwaltungsoberfläche für die Stammdaten und die Mandanten‑Registry. Sie ist eine eigenständige Anwendung neben dem Adapter: eigener Prozess, eigener Container, eigener Port (8501), eigene Konfigurationsdatei. Sie greift nie auf die Datenbank zu, sondern ausschließlich auf die REST‑API des Adapters.
Was die Oberfläche tut, beschreibt die Nutzerdokumentation — Zuordnungen pflegen und Mandanten verwalten. Hier steht, wie sie gebaut ist.
Aufbau
src/frontend/
main.py Anmeldung und Navigation
pages/
tenant_index_page.py uom_index_page.py
observed_property_index_page.py uom_key_index_page.py
telemetry_key_index_page.py
api/
frost_adapter_api_service.py der einzige Ort mit `requests`
unreached_tenants.py die eine gemeinsame Warnung
Die Schichtung ist flach und hat nur eine Regel:
pages/ ──► api/ ──► REST-API des Adapters
Keine Seite setzt einen HTTP‑Aufruf ab. requests kommt ausschließlich in api/frost_adapter_api_service.py vor; eine Seite kennt nur benannte Funktionen wie get_all_uoms() oder sync_observed_properties(id).
|
Das Frontend importiert aus dem Adapter genau eine Sache: die Routenkonstanten aus |
Anmeldung und Navigation
main.py ist der Einstiegspunkt und tut zwei Dinge. Ohne Anmeldung zeigt es ausschließlich eine Login‑Seite und ruft st.login(); die Anmeldung selbst läuft über den OIDC‑Block [auth] in .streamlit/secrets.toml gegen Keycloak. Nach der Anmeldung baut es die Navigation aus fünf st.Page‑Objekten und stellt einen Logout‑Knopf in die Seitenleiste.
Die Reihenfolge in der Navigation ist gesetzt: Tenants steht vorn, danach die vier Stammdatentabellen.
Das Tabellenmuster
Vier der fünf Seiten sind dieselbe Seite mit anderen Spalten: ein st.data_editor über einen DataFrame, darüber ein Save‑Knopf. Beim Speichern liest submit() die drei Listen, die Streamlit im Session State führt, und übersetzt jede in einen API‑Aufruf:
| Liste | Aufruf |
|---|---|
|
|
|
|
|
|
Der Remount‑Schlüssel
Jede Seite führt einen Zähler im Session State und baut daraus den Schlüssel des Editors:
data_editor_gen_key = "uom_edit_gen"
data_editor_key = f"uom_edit_{st.session_state[data_editor_gen_key]}"
Nach jedem Speichern wird der Zähler erhöht. Grund: Ein fehlgeschlagener Schreibvorgang ändert die zugrunde liegenden Daten nicht, und ohne neuen Schlüssel bliebe der Editor mit den alten Änderungen und Löschmarkierungen stehen. Der neue Schlüssel erzwingt ein Neu‑Einhängen.
Namen in der Oberfläche, Ids in der API
Wo eine Spalte eine Beziehung zeigt, steht in der Tabelle der Name und in der API die Id. telemetry_key_index_page.py baut dafür zwei Abbildungen (uom_name_to_id, observed_propert_name_to_id) und benutzt dieselben Namen zugleich als options der SelectboxColumn — der Editor kann damit keinen Namen anbieten, der sich nicht auflösen lässt.
Zwei Zeitlimits
api/frost_adapter_api_service.py kennt genau zwei Werte, und die Unterscheidung ist inhaltlich:
| Konstante | Wert | Gilt für |
|---|---|---|
|
5 s |
Zugriffe, die nur die Postgres‑Datenbank des Adapters lesen oder schreiben — die sind entweder schnell oder kaputt. |
|
60 s |
Jeder Aufruf, der über den Adapter hinaus nach Keycloak, ThingsBoard oder in den FROST eines Mandanten greift: die Erreichbarkeitsprüfungen, der Abgleich, die Mandanten‑Schreibvorgänge und alle Stammdaten‑Schreibvorgänge, die fächern. |
Die zweite Gruppe braucht das längere Limit, weil eine Seite, die vorher aufgibt, eine funktionierende Prüfung oder einen bereits committeten Schreibvorgang als Fehlschlag meldet.
DataFrame oder JSON
Die meisten Funktionen geben einen DataFrame zurück, und ein leerer DataFrame ist zugleich das, womit ein fehlgeschlagener Aufruf antwortet — für eine Tabelle heißt beides „nichts anzuzeigen“.
Die Mandanten‑Schreibvorgänge sind die Ausnahme und lesen rohes JSON. create_tenant und update_tenant antworten mit dem verschachtelten {tenant, sync}, und die Seite muss den Bericht daraus lesen. Ein DataFrame könnte hier den entscheidenden Unterschied nicht ausdrücken: Schreibvorgang fehlgeschlagen (None) ist etwas anderes als Mandant angelegt, Replikation nicht durchgelaufen (sync: null).
Die Warnung über nicht erreichte Mandanten
unreached_tenants.py enthält die eine Warnung, die jede Stammdatenseite nach einem Speichern zeigt, wenn failed_tenants gefüllt ist. Der Schreibvorgang hat Postgres erreicht — und Postgres ist kanonisch —, aber nicht den FROST jedes Mandanten. Das ist eine Verzögerung und keine verlorene Änderung, deshalb eine Warnung mit einer Handlungsanweisung statt eines Fehlers.
Der Wiederholungsweg ist der einzige Grund, warum der Text ein Parameter ist:
-
Eine
ObservedPropertyhat eine Zuordnungszeile und damit den Knopf Sync observed properties. -
Ein Telemetry Key oder eine Maßeinheit existiert nur in den Datastreams, die sie tragen. Es gibt nichts erneut zu übertragen — der Text verweist deshalb auf das Log und auf Handarbeit.
Die Tenants‑Seite
Sie folgt demselben Tabellenmuster, hat aber drei Eigenheiten:
- Beim Rendern wird nichts geprüft.
-
Die Registry muss sich anzeigen lassen, ohne ein einziges Fremdsystem anzufassen. Ein Mandant hat deshalb keinen Status, bis jemand Test connection oder Check all connections klickt. Die Ergebnisse liegen im Session State und nicht in den Zeilendaten.
- Der Slug wird abgefangen, nicht angeboten.
-
Wird er im Editor geändert, meldet die Seite, dass umbenannt nicht geht und der Mandant zu löschen und neu anzulegen ist. Die API weist es ebenfalls zurück — die Prüfung hier existiert, damit die Meldung erklärt statt nur abzulehnen.
- Das ThingsBoard‑Geheimnis ist immer leer.
-
Die API gibt es nie zurück. Leer beim Speichern bedeutet „gespeichertes behalten“, ein eingetippter Wert ersetzt es. Welches Feld das ist, entscheidet die Edition: unter
peder API‑Key, untercedas Passwort. Der CE‑Benutzername ist kein Geheimnis und verhält sich wie jedes andere Feld. - Die Edition wird erfragt, nicht konfiguriert.
-
Die Seite holt sie über
GET /configurationvom Adapter — als einziger Aufruf dieses Moduls zwischengespeichert (60 Sekunden), weil Streamlit das Seitenskript bei jeder Interaktion neu ausführt und sich dieser Wert nur bei einem Neustart des Adapters ändern kann. Ein fehlgeschlagener Abruf wird nicht zwischengespeichert. Der Frontend‑Container hat keine.env, und die Variable dorthin zu duplizieren hieße, zwei Quellen für einen Wert zu führen, der übereinstimmen muss — ein Formular gegen die falsche Edition sammelt Zugangsdaten, die der Adapter anschließend bei jedem Speichern ablehnt. Antwortet der Adapter nicht, sagt die Seite das und rendert die Tabelle nicht, statt Felder zu raten.
Im Session State stehen zwei Dinge, und beide halten bewusst nur das Handlungsbedürftige: Die Prüfergebnisse pro Slug, und aus einem Abgleichbericht ausschließlich die Liste unmanaged_in_use. Ein sauberer Abgleich ist ein Toast und hinterlässt nichts — was im Zustand steht, bedeutet damit „braucht Aufmerksamkeit“ und nicht „das ist zuletzt passiert“.
Eine neue Seite hinzufügen
-
Eine Funktion je Operation in
api/frost_adapter_api_service.pyergänzen, mit dem Basispfad ausbase_routesund dem passenden der beiden Zeitlimits. -
Ein Modul in
pages/anlegen, das dem Tabellenmuster folgt: Zähler und Editor‑Schlüssel,submit()über die drei Listen,st.title(…),st.data_editor(…). -
Die Seite in
main.pyalsst.Pageeintragen und in die Liste vonst.navigationaufnehmen. -
Fächert ein Schreibvorgang über alle Mandanten, das Ergebnis durch
warn_about_unreached_tenants(…)schicken und einen Wiederholungstext angeben, der zu dieser Entität tatsächlich passt.
Konfiguration
Das Frontend liest .streamlit/secrets.toml und nicht .env. Vorlage ist .streamlit/secrets-example.toml; keine der beiden echten Dateien ist eingecheckt.
| Schlüssel | Bedeutung |
|---|---|
|
Basis‑URL der Adapter‑API. Lokal |
|
Der OIDC‑Block für |
Im Compose‑Betrieb wird die Container‑Fassung als Datei eingehängt:
volumes:
- ./.streamlit/secrets-docker.toml:/app/.streamlit/secrets.toml:ro