leaf-framework 0.10.25__tar.gz → 0.11.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/PKG-INFO +3 -5
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/README.md +1 -1
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/adapters/control_spec.py +58 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/adapters/equipment_adapter.py +15 -1
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/config/configuration.yaml +3 -11
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/data/marketplace.yaml +5 -1
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/ble_watcher.py +0 -1
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/mqtt_external_event_watcher.py +0 -19
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/output_modules/mqtt.py +1 -1
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/output_modules/topic_store.py +1 -1
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/measure.py +23 -1
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/register/equipment_actions.yaml +1 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/register/metadata.py +10 -1
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/registry/discovery.py +1 -2
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/registry/registry.py +0 -7
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/start.py +1 -1
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/constants.py +1 -6
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/interface.py +0 -6
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/markdown/configuration_help.md +5 -3
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/markdown/documentation_main.md +3 -6
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/tabs/adapters.py +96 -13
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/utils.py +50 -1
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/pyproject.toml +2 -4
- leaf_framework-0.10.25/leaf/measurement_handler/handler.py +0 -11
- leaf_framework-0.10.25/leaf/modules/input_modules/db_watcher.py +0 -15
- leaf_framework-0.10.25/leaf/modules/output_modules/keydb.py +0 -243
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/LICENSE +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/adapters/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/adapters/core_adapters/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/adapters/core_adapters/continuous_experiment_adapter.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/adapters/core_adapters/discrete_experiment_adapter.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/adapters/core_adapters/streaming_adapter.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/adapters/core_adapters/upload_adapter.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/error_handler/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/error_handler/error_holder.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/error_handler/exceptions.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/measurement_handler/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/measurement_handler/measurements.yaml +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/measurement_handler/terms.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/.gitkeep +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/event_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/external_api_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/external_event_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/file_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/http_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/lads_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/modbus_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/mqtt_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/opc_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/polling_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/input_modules/simple_watcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/measurement_modules/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/measurement_modules/carbon_dioxide.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/measurement_modules/dissolved_oxygen.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/measurement_modules/fluorescence.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/measurement_modules/measurement_module.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/measurement_modules/optical_density.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/measurement_modules/ph.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/measurement_modules/temperature.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/output_modules/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/output_modules/file.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/output_modules/output_module.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/output_modules/sql.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/control.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/error.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/external_event_phase.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/initialisation.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/phase.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/start.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/phase_modules/stop.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/process_modules/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/process_modules/continous_module.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/process_modules/discrete_module.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/process_modules/external_event_process.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/process_modules/process_module.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/modules/process_modules/upload_module.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/register/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/register/abstract_leaf.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/register/equipment_data.yaml +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/register/py.typed +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/register/topic_utilities.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/registry/loader.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/registry/utils.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/adapter_ui_extension.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/auth.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/components/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/components/app_control.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/components/header.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/components/update_notice.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/desktop_launcher.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/images/icon.icns +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/images/icon.ico +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/images/icon.svg +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/markdown/documentation_protips.md +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/scripts.js +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/styles.css +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/tabs/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/tabs/configuration.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/tabs/control.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/tabs/documentation.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/tabs/logs.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/tabs/monitoring.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/ui/update_checker.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/utility/config_utils.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/utility/logger/__init__.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/utility/logger/logger_utils.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/utility/running_adapters.py +0 -0
- {leaf_framework-0.10.25 → leaf_framework-0.11.1}/leaf/utility/running_utilities.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: leaf-framework
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.1
|
|
4
4
|
Summary: Lightweight Adapter Framework (LEAF)
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
@@ -12,16 +12,14 @@ Classifier: Programming Language :: Python :: 3
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
-
Requires-Dist: aioredis (>=2.0.1,<2.1.0)
|
|
16
15
|
Requires-Dist: cairosvg (>=2.9.1,<3.0.0)
|
|
17
16
|
Requires-Dist: dateparser (>=1.4.3,<1.5.0)
|
|
18
17
|
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
19
|
-
Requires-Dist:
|
|
18
|
+
Requires-Dist: leafobject (>=0.0.14,<0.0.15)
|
|
20
19
|
Requires-Dist: nicegui (>=3.18.0,<3.19.0)
|
|
21
20
|
Requires-Dist: paho-mqtt (>=2.1.0,<2.2.0)
|
|
22
21
|
Requires-Dist: pillow (>=13.0.0,<14.0.0)
|
|
23
22
|
Requires-Dist: pyyaml (>=6.0.3,<6.1.0)
|
|
24
|
-
Requires-Dist: redis (>=8.1.0,<8.2.0)
|
|
25
23
|
Requires-Dist: requests (>=2.34.2,<2.35.0)
|
|
26
24
|
Requires-Dist: watchdog (>=7.0.0,<7.1.0)
|
|
27
25
|
Project-URL: Documentation, https://leaf-framework.org
|
|
@@ -54,7 +52,7 @@ LEAF checks PyPI for a newer release one hour after it starts, and weekly from t
|
|
|
54
52
|
|
|
55
53
|
To check without waiting for the schedule, **click the version number** in the header. The result appears as a banner: green when this install is current, amber when a newer release exists, red when PyPI could not be reached.
|
|
56
54
|
|
|
57
|
-
Set `LEAF_DISABLE_UPDATE_CHECK=1` to switch the check off entirely. Installs running from a source checkout are told about the new version but never overwritten -- update those with `git pull`. To update by hand:
|
|
55
|
+
Set `LEAF_DISABLE_UPDATE_CHECK=1` to switch the check off entirely. Set `LEAF_DISABLE_ADAPTER_INSTALL=1` to stop the web interface from installing or uninstalling adapters, e.g. on a shared deployment whose adapters come from its image. Installs running from a source checkout are told about the new version but never overwritten -- update those with `git pull`. To update by hand:
|
|
58
56
|
|
|
59
57
|
```bash
|
|
60
58
|
pip install --upgrade leaf-framework
|
|
@@ -24,7 +24,7 @@ LEAF checks PyPI for a newer release one hour after it starts, and weekly from t
|
|
|
24
24
|
|
|
25
25
|
To check without waiting for the schedule, **click the version number** in the header. The result appears as a banner: green when this install is current, amber when a newer release exists, red when PyPI could not be reached.
|
|
26
26
|
|
|
27
|
-
Set `LEAF_DISABLE_UPDATE_CHECK=1` to switch the check off entirely. Installs running from a source checkout are told about the new version but never overwritten -- update those with `git pull`. To update by hand:
|
|
27
|
+
Set `LEAF_DISABLE_UPDATE_CHECK=1` to switch the check off entirely. Set `LEAF_DISABLE_ADAPTER_INSTALL=1` to stop the web interface from installing or uninstalling adapters, e.g. on a shared deployment whose adapters come from its image. Installs running from a source checkout are told about the new version but never overwritten -- update those with `git pull`. To update by hand:
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
30
|
pip install --upgrade leaf-framework
|
|
@@ -10,6 +10,7 @@ import os
|
|
|
10
10
|
from typing import cast
|
|
11
11
|
|
|
12
12
|
import yaml
|
|
13
|
+
from leafobject import LeafPoint # pyright: ignore[reportMissingTypeStubs]
|
|
13
14
|
|
|
14
15
|
Control = dict[str, object]
|
|
15
16
|
|
|
@@ -122,3 +123,60 @@ def check_control(controls: list[Control], data: str) -> None:
|
|
|
122
123
|
raise ValueError(f"{metric} cannot be set to {value!r}")
|
|
123
124
|
elif not isinstance(value, str) or not value:
|
|
124
125
|
raise ValueError(f"{metric} needs a text value")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _part(point: dict[str, object], key: str) -> dict[str, object]:
|
|
129
|
+
"""A copy of point[key] when it is a dict, else an empty dict."""
|
|
130
|
+
part = point.get(key)
|
|
131
|
+
if isinstance(part, dict):
|
|
132
|
+
return dict(cast(dict[str, object], part))
|
|
133
|
+
return {}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _field_value(value: object) -> int | float | str:
|
|
137
|
+
"""value as a LeafPoint field allows it: a number or text, anything else as its JSON."""
|
|
138
|
+
if isinstance(value, (int, float, str)):
|
|
139
|
+
return value
|
|
140
|
+
return json.dumps(value)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def result_record(
|
|
144
|
+
data: str, source: str, error: Exception | None = None, instance_tags: dict[str, str] | None = None
|
|
145
|
+
) -> dict[str, object]:
|
|
146
|
+
"""
|
|
147
|
+
A control message as the LeafPoint LEAF publishes on <control topic>/result, so that what was
|
|
148
|
+
sent to the equipment is stored with the measurements.
|
|
149
|
+
|
|
150
|
+
source ("ui" or "mqtt"), status ("applied" or "rejected") and a rejection's "error" are tags,
|
|
151
|
+
for consumers to filter on. Built as a leafobject LeafPoint, so tags are text and there is
|
|
152
|
+
always a field: a message without fields keeps its raw text in "message".
|
|
153
|
+
"""
|
|
154
|
+
point: dict[str, object] = {}
|
|
155
|
+
try:
|
|
156
|
+
parsed = cast(object, json.loads(data))
|
|
157
|
+
if isinstance(parsed, dict):
|
|
158
|
+
point = cast(dict[str, object], parsed)
|
|
159
|
+
except ValueError:
|
|
160
|
+
pass
|
|
161
|
+
tags: dict[str, str] = {}
|
|
162
|
+
for key, value in _part(point, "tags").items():
|
|
163
|
+
tags[key] = str(value)
|
|
164
|
+
# The instance's organisation and department, where the message didn't set them
|
|
165
|
+
if instance_tags is not None:
|
|
166
|
+
for key, value in instance_tags.items():
|
|
167
|
+
_ = tags.setdefault(key, value)
|
|
168
|
+
fields: dict[str, int | float | str] = {}
|
|
169
|
+
for key, value in _part(point, "fields").items():
|
|
170
|
+
fields[key] = _field_value(value)
|
|
171
|
+
tags["source"] = source
|
|
172
|
+
tags["status"] = "applied"
|
|
173
|
+
if error is not None:
|
|
174
|
+
tags["status"] = "rejected"
|
|
175
|
+
tags["error"] = str(error)
|
|
176
|
+
if not fields:
|
|
177
|
+
fields["message"] = data
|
|
178
|
+
record = LeafPoint()
|
|
179
|
+
record.set_measurement(str(point.get("measurement", "control")))
|
|
180
|
+
record.set_tags(tags)
|
|
181
|
+
record.set_fields(fields)
|
|
182
|
+
return {**record.to_json()}
|
|
@@ -4,7 +4,7 @@ from abc import ABC, abstractmethod
|
|
|
4
4
|
from threading import Event
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
-
from leaf.adapters.control_spec import Control, check_control
|
|
7
|
+
from leaf.adapters.control_spec import Control, check_control, result_record
|
|
8
8
|
from leaf.error_handler import exceptions
|
|
9
9
|
from leaf.error_handler.error_holder import ErrorHolder
|
|
10
10
|
from leaf.error_handler.exceptions import LEAFError
|
|
@@ -254,7 +254,10 @@ class EquipmentAdapter(ABC):
|
|
|
254
254
|
except Exception as e:
|
|
255
255
|
# The error holder reports it later; log it now so the sender can see why nothing happened
|
|
256
256
|
self._logger.warning(f"Control message on {topic} rejected: {e}")
|
|
257
|
+
self._report_control(topic, data, "mqtt", e)
|
|
257
258
|
self._handle_exception(exceptions.AdapterLogicError(f"Controller failed: {e}"))
|
|
259
|
+
return
|
|
260
|
+
self._report_control(topic, data, "mqtt")
|
|
258
261
|
|
|
259
262
|
def has_controller(self) -> bool:
|
|
260
263
|
"""True when the instance runs with control: true and a controller is attached."""
|
|
@@ -281,8 +284,19 @@ class EquipmentAdapter(ABC):
|
|
|
281
284
|
self._controller.control(topic, data)
|
|
282
285
|
except Exception as e:
|
|
283
286
|
# Reported like a control message from MQTT, so it also reaches the error topic
|
|
287
|
+
self._report_control(topic, data, "ui", e)
|
|
284
288
|
self._handle_exception(exceptions.AdapterLogicError(f"Controller failed: {e}"))
|
|
285
289
|
raise
|
|
290
|
+
self._report_control(topic, data, "ui")
|
|
291
|
+
|
|
292
|
+
def _report_control(self, topic: str, data: str, source: str, error: Exception | None = None) -> None:
|
|
293
|
+
"""Publish what happened to a control message on <control topic>/result, so it is stored with the data."""
|
|
294
|
+
# Never let the record break the command it describes
|
|
295
|
+
try:
|
|
296
|
+
record = result_record(data, source, error, self._metadata_manager.instance_tags())
|
|
297
|
+
_ = self._output.transmit(f"{topic}/result", record)
|
|
298
|
+
except Exception as e:
|
|
299
|
+
self._logger.warning(f"Could not publish the control record for {topic}: {e}")
|
|
286
300
|
|
|
287
301
|
def _subscribe_control(self) -> None:
|
|
288
302
|
if self._controller is not None:
|
|
@@ -26,24 +26,16 @@ OUTPUTS:
|
|
|
26
26
|
# protocol: v3 #OPTIONAL expects "v3" "v5"
|
|
27
27
|
# transport: tcp #OPTIONAL expects "tcp", "websockets", "unix"
|
|
28
28
|
# tls: False #OPTIONAL expects True, False
|
|
29
|
-
# fallback:
|
|
29
|
+
# fallback: FILE #OPTIONAL expects "SQL", "FILE"
|
|
30
30
|
|
|
31
31
|
# SQL output requires 'filename' of database
|
|
32
32
|
# and optionally an alternative 'fallback' output
|
|
33
33
|
# - plugin: SQL
|
|
34
34
|
# filename: messages.db
|
|
35
|
-
# fallback:
|
|
36
|
-
|
|
37
|
-
# KEYDB output requires 'host', 'port', 'db'
|
|
38
|
-
# and optionally an alternative 'fallback' output
|
|
39
|
-
# - plugin: KEYDB
|
|
40
|
-
# host: localhost
|
|
41
|
-
# port: 6379
|
|
42
|
-
# db: 0
|
|
43
|
-
# fallback: FILE #OPTIONAL expects "MQTT", "SQL", "FILE"
|
|
35
|
+
# fallback: FILE #OPTIONAL expects "MQTT", "FILE"
|
|
44
36
|
|
|
45
37
|
# FILE output requires 'filename'
|
|
46
38
|
# and optionally an alternative 'fallback' output
|
|
47
39
|
# - plugin: FILE
|
|
48
40
|
# filename: local.json
|
|
49
|
-
# fallback:
|
|
41
|
+
# fallback: SQL #OPTIONAL expects "MQTT", "SQL"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
- adapter_id: Table
|
|
5
5
|
image: "./images/table.png"
|
|
6
|
-
description: "File conversion module to parse tabular data from various file formats into
|
|
6
|
+
description: "File conversion module to parse tabular data from various file formats into LeafPoint messages."
|
|
7
7
|
equipment_data:
|
|
8
8
|
version: "1.0"
|
|
9
9
|
manufacturer: LEAF
|
|
@@ -233,6 +233,10 @@
|
|
|
233
233
|
application: Monitoring
|
|
234
234
|
# requirements:
|
|
235
235
|
repo_url: https://git.wur.nl/leaf/adapters/leaf-ramsmart.git
|
|
236
|
+
# Private repository: installed with a username and token handed out per user. One deploy
|
|
237
|
+
# token (read_repository, with an expiry) per person, named after them; lost → revoke and reissue.
|
|
238
|
+
access: restricted
|
|
239
|
+
request_access: mailto:access@leaf-framework.org?subject=Access%20to%20LEAF-RAMSMART
|
|
236
240
|
name: LEAF-RAMSMART
|
|
237
241
|
- adapter_id: BluetoothScanner
|
|
238
242
|
image: icon:bi-bluetooth
|
|
@@ -241,22 +241,3 @@ class MQTTExternalEventWatcher(ExternalEventWatcher):
|
|
|
241
241
|
bool: True if the client is connected, False otherwise.
|
|
242
242
|
"""
|
|
243
243
|
return self.client.is_connected()
|
|
244
|
-
|
|
245
|
-
def _handle_return_code(self, return_code: int) -> ClientUnreachableError | None:
|
|
246
|
-
"""
|
|
247
|
-
Handle MQTT return codes.
|
|
248
|
-
|
|
249
|
-
Args:
|
|
250
|
-
return_code (int): The return code to handle.
|
|
251
|
-
|
|
252
|
-
Returns:
|
|
253
|
-
Optional[ClientUnreachableError]: An error if one occurred, None otherwise.
|
|
254
|
-
"""
|
|
255
|
-
if return_code == mqtt.MQTT_ERR_SUCCESS:
|
|
256
|
-
return None
|
|
257
|
-
message = {
|
|
258
|
-
mqtt.MQTT_ERR_NO_CONN: "Can't connect to broker",
|
|
259
|
-
mqtt.MQTT_ERR_QUEUE_SIZE: "Message queue size limit reached",
|
|
260
|
-
}.get(return_code, f"Unknown error with return code {return_code}")
|
|
261
|
-
|
|
262
|
-
return ClientUnreachableError(message, output_module=self, severity=SeverityLevel.INFO)
|
|
@@ -527,7 +527,7 @@ class MQTT(OutputModule):
|
|
|
527
527
|
Retrieve and remove a stored message from the fallback.
|
|
528
528
|
|
|
529
529
|
MQTT does not buffer messages locally, so this delegates to the fallback
|
|
530
|
-
module (e.g.,
|
|
530
|
+
module (e.g., SQL or FILE) to retrieve buffered messages.
|
|
531
531
|
|
|
532
532
|
Args:
|
|
533
533
|
key (Optional[str]): A key for which to pop data. Defaults to None.
|
|
@@ -98,7 +98,7 @@ def history_key(topic: str, point: object) -> str:
|
|
|
98
98
|
|
|
99
99
|
|
|
100
100
|
def _point_sample(point: object) -> tuple[float, float] | None:
|
|
101
|
-
"""(timestamp, value) of a LeafPoint
|
|
101
|
+
"""(timestamp, value) of a LeafPoint JSON dict with a numeric fields.value, or None."""
|
|
102
102
|
if not isinstance(point, dict):
|
|
103
103
|
return None
|
|
104
104
|
point_dict = cast(dict[str, object], point)
|
|
@@ -43,6 +43,7 @@ class MeasurePhase(PhaseModule):
|
|
|
43
43
|
|
|
44
44
|
super().__init__(term_builder, metadata_manager=metadata_manager, error_holder=error_holder)
|
|
45
45
|
self._maximum_message_size: int = maximum_message_size
|
|
46
|
+
self._tag_mismatches: set[tuple[str, str]] = set()
|
|
46
47
|
# Guards against a second measurement running concurrently with one still
|
|
47
48
|
# draining (e.g. a generator-based interpreter that streams for a long time,
|
|
48
49
|
# or forever). Without this, an overlapping event (e.g. FileWatcher's
|
|
@@ -231,6 +232,7 @@ class MeasurePhase(PhaseModule):
|
|
|
231
232
|
list[tuple]: A list of (action, result) tuples.
|
|
232
233
|
"""
|
|
233
234
|
if isinstance(result, dict):
|
|
235
|
+
self._add_instance_tags(cast(object, result))
|
|
234
236
|
measurement = self._resolve_measurement(result)
|
|
235
237
|
action = self._term_builder(experiment_id=experiment_id, measurement=measurement)
|
|
236
238
|
return [(action, result)]
|
|
@@ -239,6 +241,7 @@ class MeasurePhase(PhaseModule):
|
|
|
239
241
|
point_json = point_json_of(payload)
|
|
240
242
|
if point_json is not None:
|
|
241
243
|
result = point_json
|
|
244
|
+
self._add_instance_tags(cast(object, result))
|
|
242
245
|
measurement = self._resolve_measurement(result)
|
|
243
246
|
action = self._term_builder(experiment_id=experiment_id, measurement=measurement)
|
|
244
247
|
return [(action, result)]
|
|
@@ -254,6 +257,7 @@ class MeasurePhase(PhaseModule):
|
|
|
254
257
|
|
|
255
258
|
groups: dict[str, list] = {}
|
|
256
259
|
for item in converted:
|
|
260
|
+
self._add_instance_tags(cast(object, item))
|
|
257
261
|
if isinstance(item, dict):
|
|
258
262
|
key = self._resolve_measurement(item)
|
|
259
263
|
else:
|
|
@@ -270,6 +274,24 @@ class MeasurePhase(PhaseModule):
|
|
|
270
274
|
self._handle_exception(excp)
|
|
271
275
|
return []
|
|
272
276
|
|
|
277
|
+
def _add_instance_tags(self, item: object) -> None:
|
|
278
|
+
"""Tag a point with its instance's organisation and department, unless the adapter already did."""
|
|
279
|
+
if self._metadata_manager is None or not isinstance(item, dict) or "fields" not in item:
|
|
280
|
+
return
|
|
281
|
+
tags = cast(dict[str, object], item).setdefault("tags", {})
|
|
282
|
+
if not isinstance(tags, dict):
|
|
283
|
+
return
|
|
284
|
+
for key, value in self._metadata_manager.instance_tags().items():
|
|
285
|
+
current = cast(dict[str, object], tags).get(key)
|
|
286
|
+
if current is None:
|
|
287
|
+
tags[key] = value
|
|
288
|
+
elif str(current) != value and (key, str(current)) not in self._tag_mismatches:
|
|
289
|
+
# Kept as the adapter set it; warned once so every measurement doesn't repeat it
|
|
290
|
+
self._tag_mismatches.add((key, str(current)))
|
|
291
|
+
logger.warning(
|
|
292
|
+
f"Tag {key}={current!r} differs from the configured {key} {value!r}; keeping {current!r}"
|
|
293
|
+
)
|
|
294
|
+
|
|
273
295
|
def _resolve_measurement(self, item: dict) -> str:
|
|
274
296
|
"""
|
|
275
297
|
Resolve the measurement label used for the topic's last segment.
|
|
@@ -291,7 +313,7 @@ class MeasurePhase(PhaseModule):
|
|
|
291
313
|
|
|
292
314
|
|
|
293
315
|
def point_json_of(item: object) -> dict[str, object] | None:
|
|
294
|
-
"""JSON of
|
|
316
|
+
"""JSON of a leafobject LeafPoint (or anything else with to_json()), else None."""
|
|
295
317
|
if isinstance(item, dict):
|
|
296
318
|
return None
|
|
297
319
|
to_json: object = getattr(item, "to_json", None)
|
|
@@ -3,6 +3,7 @@ running: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/running
|
|
|
3
3
|
heartbeat: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/heartbeat
|
|
4
4
|
error: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/error
|
|
5
5
|
control: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/control
|
|
6
|
+
control_result: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/control/result
|
|
6
7
|
experiment:
|
|
7
8
|
start: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/experiment/start
|
|
8
9
|
stop: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/experiment/stop
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
|
-
from typing import Any
|
|
3
|
+
from typing import Any, cast
|
|
4
4
|
|
|
5
5
|
import yaml # type: ignore
|
|
6
6
|
|
|
@@ -60,6 +60,15 @@ class MetadataManager:
|
|
|
60
60
|
return self._instance_data
|
|
61
61
|
return self._instance_data.get(key)
|
|
62
62
|
|
|
63
|
+
def instance_tags(self) -> dict[str, str]:
|
|
64
|
+
"""The organisation and department tags every point of this instance carries."""
|
|
65
|
+
tags: dict[str, str] = {}
|
|
66
|
+
for key in ("organisation", "department"):
|
|
67
|
+
value = cast(object, self._instance_data.get(key)) # pyright: ignore[reportUnknownMemberType]
|
|
68
|
+
if value:
|
|
69
|
+
tags[key] = str(value)
|
|
70
|
+
return tags
|
|
71
|
+
|
|
63
72
|
def get_instance_id(self) -> str:
|
|
64
73
|
"""Convenience accessor for 'instance_id' in equipment."""
|
|
65
74
|
return self._instance_data.get("instance_id", "")
|
|
@@ -16,8 +16,7 @@ from leaf.utility.logger.logger_utils import get_logger
|
|
|
16
16
|
root_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")
|
|
17
17
|
adapter_dir = os.path.join(root_dir, "adapters")
|
|
18
18
|
core_adapter_dir = os.path.join(adapter_dir, "core_adapters")
|
|
19
|
-
|
|
20
|
-
default_equipment_locations = [core_adapter_dir, functional_adapter_dir]
|
|
19
|
+
default_equipment_locations = [core_adapter_dir]
|
|
21
20
|
|
|
22
21
|
output_module_dir = os.path.join(root_dir, "modules", "output_modules")
|
|
23
22
|
input_module_dir = os.path.join(root_dir, "modules", "input_modules")
|
|
@@ -56,13 +56,6 @@ def get_external_input(code: str) -> type[ExternalEventWatcher]:
|
|
|
56
56
|
return cls
|
|
57
57
|
|
|
58
58
|
|
|
59
|
-
def all_registered(plugin_type: PluginType) -> dict[str, type[Any]]:
|
|
60
|
-
"""
|
|
61
|
-
Return all registered classes of a given plugin type.
|
|
62
|
-
"""
|
|
63
|
-
return dict(_registry[plugin_type])
|
|
64
|
-
|
|
65
|
-
|
|
66
59
|
def discover_from_config(config: dict[str, Any], external_path: str = None) -> None:
|
|
67
60
|
"""
|
|
68
61
|
Discover and register only the plugins referenced in the given configuration.
|
|
@@ -111,7 +111,7 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace:
|
|
|
111
111
|
"--port",
|
|
112
112
|
type=int,
|
|
113
113
|
default=4242,
|
|
114
|
-
help="Port to run the NiceGUI interface on. Default is
|
|
114
|
+
help="Port to run the NiceGUI interface on. Default is 4242.",
|
|
115
115
|
)
|
|
116
116
|
|
|
117
117
|
parser.add_argument(
|
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
"""Constants for LEAF UI."""
|
|
2
2
|
|
|
3
3
|
# Server constants
|
|
4
|
-
DEFAULT_PORT =
|
|
4
|
+
DEFAULT_PORT = 4242
|
|
5
5
|
MARKETPLACE_URL = "https://gitlab.com/leaf-framework/leaf-marketplace/-/raw/main/public/adapter.yaml"
|
|
6
6
|
MAX_LOG_LINES = 1000
|
|
7
|
-
CONFIGURATION_FILENAME = "configuration.yaml"
|
|
8
7
|
|
|
9
8
|
# UI Constants
|
|
10
9
|
CARD_WIDTH_CLASS = "w-64"
|
|
11
|
-
HEADER_CLASSES = "leaf-header"
|
|
12
10
|
CARD_CLASSES = "leaf-card"
|
|
13
11
|
TAB_CLASSES = "leaf-tab"
|
|
14
|
-
|
|
15
|
-
# Color constants
|
|
16
|
-
STATUS_COLORS = {"online": "status-online", "offline": "status-offline", "warning": "status-warning"}
|
|
@@ -38,12 +38,6 @@ def get_startup_error() -> str | None:
|
|
|
38
38
|
return _startup_error
|
|
39
39
|
|
|
40
40
|
|
|
41
|
-
def clear_startup_error() -> None:
|
|
42
|
-
"""Clear the startup error."""
|
|
43
|
-
global _startup_error
|
|
44
|
-
_startup_error = None
|
|
45
|
-
|
|
46
|
-
|
|
47
41
|
def start_nicegui(port: int = DEFAULT_PORT, config_path: Path | None = None, auto_open_browser: bool = True) -> None:
|
|
48
42
|
"""
|
|
49
43
|
Start the LEAF NiceGUI web interface.
|
|
@@ -15,10 +15,12 @@ EQUIPMENT_INSTANCES:
|
|
|
15
15
|
Configure data destinations:
|
|
16
16
|
```yaml
|
|
17
17
|
OUTPUTS:
|
|
18
|
-
- plugin:
|
|
19
|
-
|
|
20
|
-
port:
|
|
18
|
+
- plugin: MQTT
|
|
19
|
+
broker: localhost
|
|
20
|
+
port: 1883
|
|
21
21
|
fallback: FILE
|
|
22
|
+
- plugin: FILE
|
|
23
|
+
filename: fallback.json
|
|
22
24
|
```
|
|
23
25
|
|
|
24
26
|
#### Available Adapters
|
|
@@ -41,13 +41,10 @@ OUTPUTS:
|
|
|
41
41
|
- plugin: MQTT
|
|
42
42
|
broker: localhost
|
|
43
43
|
port: 1883
|
|
44
|
-
fallback: KEYDB
|
|
45
|
-
|
|
46
|
-
- plugin: KEYDB
|
|
47
|
-
host: localhost
|
|
48
|
-
port: 6379
|
|
49
|
-
db: 0
|
|
50
44
|
fallback: FILE
|
|
45
|
+
|
|
46
|
+
- plugin: FILE
|
|
47
|
+
filename: fallback.json
|
|
51
48
|
```
|
|
52
49
|
|
|
53
50
|
###### Available Adapters
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""LEAF Adapters tab."""
|
|
2
2
|
|
|
3
|
+
from collections.abc import Mapping
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
import httpx
|
|
@@ -8,7 +9,7 @@ from nicegui import ui
|
|
|
8
9
|
|
|
9
10
|
from leaf.registry.discovery import get_all_adapter_codes
|
|
10
11
|
from leaf.ui.constants import CARD_CLASSES, CARD_WIDTH_CLASS, MARKETPLACE_URL
|
|
11
|
-
from leaf.ui.utils import install_adapter, uninstall_adapter
|
|
12
|
+
from leaf.ui.utils import adapter_install_disabled, check_repository_url, install_adapter, uninstall_adapter
|
|
12
13
|
from leaf.utility.logger.logger_utils import get_logger
|
|
13
14
|
|
|
14
15
|
logger = get_logger(__name__, log_file="input_module.log")
|
|
@@ -160,6 +161,96 @@ def load_bundled_marketplace() -> list | None:
|
|
|
160
161
|
return None
|
|
161
162
|
|
|
162
163
|
|
|
164
|
+
# install_adapter/uninstall_adapter refuse too; these only leave out buttons that would not work
|
|
165
|
+
def _install_button(adapter: Mapping[str, object]) -> None:
|
|
166
|
+
"""The marketplace card's Install button, unless adapter installs are disabled."""
|
|
167
|
+
if adapter_install_disabled():
|
|
168
|
+
return
|
|
169
|
+
if adapter.get("access") == "restricted":
|
|
170
|
+
_ = ui.button("Install with token", icon="lock", on_click=lambda: _token_dialog(adapter)).classes(
|
|
171
|
+
"btn-secondary w-full rounded-lg transition-colors"
|
|
172
|
+
)
|
|
173
|
+
return
|
|
174
|
+
_ = ui.button("Install", icon="download", on_click=lambda: install_adapter(dict(adapter))).classes(
|
|
175
|
+
"btn-secondary w-full rounded-lg transition-colors"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _token_dialog(adapter: Mapping[str, object]) -> None:
|
|
180
|
+
"""Asks for the access token of a restricted adapter, which is only installable with one."""
|
|
181
|
+
name = str(adapter.get("name") or adapter.get("adapter_id"))
|
|
182
|
+
with ui.dialog() as dialog, ui.card().classes("w-96"):
|
|
183
|
+
_ = ui.label(f"{name} is available on request").classes("text-h6")
|
|
184
|
+
_ = ui.label("Enter the username and token you received, e.g. a GitLab deploy token.").classes("text-body")
|
|
185
|
+
username = ui.input("Username").classes("w-full")
|
|
186
|
+
token = ui.input("Token", password=True, password_toggle_button=True).classes("w-full")
|
|
187
|
+
request = adapter.get("request_access")
|
|
188
|
+
if request:
|
|
189
|
+
_ = ui.link("Request access", str(request), new_tab=True)
|
|
190
|
+
|
|
191
|
+
async def install() -> None:
|
|
192
|
+
_ = dialog.close()
|
|
193
|
+
await install_adapter(dict(adapter), (str(username.value), str(token.value)))
|
|
194
|
+
|
|
195
|
+
with ui.row().classes("w-full justify-end"):
|
|
196
|
+
_ = ui.button("Cancel", on_click=dialog.close).classes("btn-tertiary")
|
|
197
|
+
_ = ui.button("Install", icon="download", on_click=install).classes("btn-secondary")
|
|
198
|
+
_ = dialog.open()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _install_from_url_button() -> None:
|
|
202
|
+
"""Installs an adapter that is not in the marketplace, unless adapter installs are disabled."""
|
|
203
|
+
if adapter_install_disabled():
|
|
204
|
+
return
|
|
205
|
+
_ = ui.button("Install from git URL", icon="link", on_click=_url_dialog).classes("btn-secondary mb-4")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _url_dialog() -> None:
|
|
209
|
+
"""Asks for the git URL of an adapter, plus a username and token if its repository is private."""
|
|
210
|
+
with ui.dialog() as dialog, ui.card().classes("w-96"):
|
|
211
|
+
_ = ui.label("Install from a git URL").classes("text-h6")
|
|
212
|
+
_ = ui.label("Only install adapters from sources you trust: they run on this machine.").classes("text-body")
|
|
213
|
+
url = ui.input("Git URL", placeholder="https://gitlab.com/group/leaf-adapter.git").classes("w-full")
|
|
214
|
+
username = ui.input("Username (private repositories only)").classes("w-full")
|
|
215
|
+
token = ui.input("Token (private repositories only)", password=True, password_toggle_button=True).classes(
|
|
216
|
+
"w-full"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
async def install() -> None:
|
|
220
|
+
repository = str(url.value).strip()
|
|
221
|
+
credentials = None
|
|
222
|
+
if username.value or token.value:
|
|
223
|
+
credentials = (str(username.value), str(token.value))
|
|
224
|
+
problem = check_repository_url(repository, credentials is not None)
|
|
225
|
+
if problem:
|
|
226
|
+
ui.notify(problem, type="negative", position="top")
|
|
227
|
+
return
|
|
228
|
+
_ = dialog.close()
|
|
229
|
+
name = repository.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git")
|
|
230
|
+
await install_adapter({"name": name, "repo_url": repository}, credentials)
|
|
231
|
+
|
|
232
|
+
with ui.row().classes("w-full justify-end"):
|
|
233
|
+
_ = ui.button("Cancel", on_click=dialog.close).classes("btn-tertiary")
|
|
234
|
+
_ = ui.button("Install", icon="download", on_click=install).classes("btn-secondary")
|
|
235
|
+
_ = dialog.open()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _uninstall_button(adapter: Mapping[str, object]) -> None:
|
|
239
|
+
"""The installed adapter card's Uninstall button, unless adapter installs are disabled."""
|
|
240
|
+
if adapter_install_disabled():
|
|
241
|
+
return
|
|
242
|
+
_ = ui.button("Uninstall", icon="delete", on_click=lambda: uninstall_adapter(dict(adapter))).classes(
|
|
243
|
+
"btn-tertiary w-full rounded-lg transition-colors"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _install_disabled_note() -> None:
|
|
248
|
+
"""Says why the marketplace has no Install buttons."""
|
|
249
|
+
if not adapter_install_disabled():
|
|
250
|
+
return
|
|
251
|
+
_ = ui.label("Installing and uninstalling adapters is disabled on this LEAF.").classes("text-body mb-4")
|
|
252
|
+
|
|
253
|
+
|
|
163
254
|
def build_adapters_content(container: ui.column) -> None:
|
|
164
255
|
"""
|
|
165
256
|
Build the adapters content (installed and marketplace).
|
|
@@ -228,12 +319,7 @@ def build_adapters_content(container: ui.column) -> None:
|
|
|
228
319
|
ui.label(installed_adapter["code"]).classes("text-lg font-bold text-heading mb-1")
|
|
229
320
|
ui.label(installed_adapter["name"]).classes("text-sm text-body mb-3")
|
|
230
321
|
|
|
231
|
-
|
|
232
|
-
return lambda: uninstall_adapter(adapter)
|
|
233
|
-
|
|
234
|
-
ui.button(
|
|
235
|
-
"Uninstall", icon="delete", on_click=make_uninstall_handler(installed_adapter)
|
|
236
|
-
).classes("btn-tertiary w-full rounded-lg transition-colors")
|
|
322
|
+
_uninstall_button(installed_adapter)
|
|
237
323
|
else:
|
|
238
324
|
with ui.card().classes("bg-info-card border border-default p-4 mb-8"):
|
|
239
325
|
with ui.row().classes("items-center"):
|
|
@@ -246,6 +332,8 @@ def build_adapters_content(container: ui.column) -> None:
|
|
|
246
332
|
with ui.row().classes("items-center mb-4"):
|
|
247
333
|
ui.icon("store", size="1.5rem").classes("text-icon")
|
|
248
334
|
ui.label("Adapter Marketplace").classes("text-xl font-semibold text-subheading ml-2")
|
|
335
|
+
_install_disabled_note()
|
|
336
|
+
_install_from_url_button()
|
|
249
337
|
|
|
250
338
|
# Container for marketplace adapters
|
|
251
339
|
marketplace_container = ui.column().classes("w-full")
|
|
@@ -350,12 +438,7 @@ def build_adapters_content(container: ui.column) -> None:
|
|
|
350
438
|
ui.label(adapter["adapter_id"]).classes("text-lg font-bold text-heading mb-1")
|
|
351
439
|
ui.label(adapter.get("name", "No description")).classes("text-sm text-body mb-3")
|
|
352
440
|
|
|
353
|
-
|
|
354
|
-
return lambda: install_adapter(adptr)
|
|
355
|
-
|
|
356
|
-
ui.button(
|
|
357
|
-
"Install", icon="download", on_click=make_install_handler(adapter)
|
|
358
|
-
).classes("btn-secondary w-full rounded-lg transition-colors")
|
|
441
|
+
_install_button(adapter)
|
|
359
442
|
except Exception as e:
|
|
360
443
|
logger.error(f"Failed to load marketplace: {e}", exc_info=True)
|
|
361
444
|
# Remove loading indicator
|