topicgate 1.1.0__py3-none-any.whl
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.
- topicgate/__init__.py +0 -0
- topicgate/__main__.py +5 -0
- topicgate/app/__init__.py +0 -0
- topicgate/app/app_dependencies.py +148 -0
- topicgate/app/broker_runtime_state.py +43 -0
- topicgate/app/models/__init__.py +1 -0
- topicgate/app/models/broker_snapshot.py +107 -0
- topicgate/app/models/mcp_setup.py +20 -0
- topicgate/app/services/__init__.py +0 -0
- topicgate/app/services/broker_profile_service.py +245 -0
- topicgate/app/services/broker_resolver.py +58 -0
- topicgate/app/services/broker_snapshot_service.py +403 -0
- topicgate/app/services/control_operation_service.py +158 -0
- topicgate/app/services/mcp_setup_service.py +192 -0
- topicgate/app/services/observation_cache_service.py +256 -0
- topicgate/app/services/observation_retention_policy_service.py +33 -0
- topicgate/app/services/persistence_lifecycle.py +25 -0
- topicgate/app/services/service_container.py +20 -0
- topicgate/app/services/service_item.py +13 -0
- topicgate/app/topicgate_runtime.py +496 -0
- topicgate/assets/icon.png +0 -0
- topicgate/assets/icon.svg +37 -0
- topicgate/core/__init__.py +0 -0
- topicgate/core/config/__init__.py +0 -0
- topicgate/core/config/app_config.py +7 -0
- topicgate/core/config/mqtt_config.py +10 -0
- topicgate/core/interfaces/__init__.py +0 -0
- topicgate/core/interfaces/broker_profile_store.py +26 -0
- topicgate/core/interfaces/mqtt_message_processor.py +12 -0
- topicgate/core/interfaces/observer_repository.py +82 -0
- topicgate/core/interfaces/topic_message_store.py +13 -0
- topicgate/core/models/__init__.py +0 -0
- topicgate/core/models/broker_profile.py +15 -0
- topicgate/core/models/broker_profile_identity.py +14 -0
- topicgate/core/models/broker_summary.py +14 -0
- topicgate/core/models/connection_status.py +9 -0
- topicgate/core/models/message_filter.py +9 -0
- topicgate/core/models/mqtt_message.py +13 -0
- topicgate/core/models/mqtt_observation.py +36 -0
- topicgate/core/models/mqtt_state.py +5 -0
- topicgate/core/models/observation_cache_administration.py +135 -0
- topicgate/core/models/observation_deletion_preview.py +39 -0
- topicgate/core/models/observation_retention_policy.py +65 -0
- topicgate/core/models/observer_model.py +22 -0
- topicgate/core/models/observer_workspace.py +13 -0
- topicgate/core/models/subscription.py +29 -0
- topicgate/core/models/topic_message.py +16 -0
- topicgate/core/mqtt_topics.py +46 -0
- topicgate/core/observer_limits.py +8 -0
- topicgate/core/payload_limits.py +6 -0
- topicgate/gui/__init__.py +0 -0
- topicgate/gui/app.py +97 -0
- topicgate/gui/broker_state_reader.py +29 -0
- topicgate/gui/components/about_dialog.py +100 -0
- topicgate/gui/components/add_subscription_dialog.py +59 -0
- topicgate/gui/components/application_header.py +137 -0
- topicgate/gui/components/broker_settings_dialog.py +223 -0
- topicgate/gui/components/connection_controls.py +123 -0
- topicgate/gui/components/connection_status.py +7 -0
- topicgate/gui/components/log_console.py +26 -0
- topicgate/gui/components/mcp_setup_dialog.py +198 -0
- topicgate/gui/components/observer_tree.py +434 -0
- topicgate/gui/components/onboarding_panel.py +74 -0
- topicgate/gui/components/publish_pane.py +60 -0
- topicgate/gui/components/snapshot_panel.py +343 -0
- topicgate/gui/components/stored_observations_dialog.py +611 -0
- topicgate/gui/components/subscription_settings.py +151 -0
- topicgate/gui/components/topic_details.py +51 -0
- topicgate/gui/components/topic_metadata.py +79 -0
- topicgate/gui/components/workspace_pane.py +30 -0
- topicgate/gui/gui.py +5 -0
- topicgate/gui/main_view_model.py +814 -0
- topicgate/gui/main_window.py +986 -0
- topicgate/gui/observer_state_reader.py +47 -0
- topicgate/gui/settings_migration.py +32 -0
- topicgate/gui/theme.py +61 -0
- topicgate/infrastructure/__init__.py +0 -0
- topicgate/infrastructure/credentials/__init__.py +1 -0
- topicgate/infrastructure/credentials/credential_store.py +10 -0
- topicgate/infrastructure/credentials/os_credential_store.py +20 -0
- topicgate/infrastructure/database/__init__.py +0 -0
- topicgate/infrastructure/database/base.py +5 -0
- topicgate/infrastructure/database/database_context.py +56 -0
- topicgate/infrastructure/database/mappers/broker_profile_mapper.py +42 -0
- topicgate/infrastructure/database/mappers/config_mapper.py +48 -0
- topicgate/infrastructure/database/mappers/mapper_helper.py +24 -0
- topicgate/infrastructure/database/mappers/observation_retention_policy_mapper.py +53 -0
- topicgate/infrastructure/database/mappers/observer_workspace_mapper.py +42 -0
- topicgate/infrastructure/database/mappers/subscription_mapper.py +38 -0
- topicgate/infrastructure/database/mappers/topic_message_mapper.py +37 -0
- topicgate/infrastructure/database/migrations.py +57 -0
- topicgate/infrastructure/database/models/__init__.py +25 -0
- topicgate/infrastructure/database/models/app_config_row.py +18 -0
- topicgate/infrastructure/database/models/broker_profile_row.py +35 -0
- topicgate/infrastructure/database/models/mqtt_config_row.py +18 -0
- topicgate/infrastructure/database/models/mqtt_message_row.py +33 -0
- topicgate/infrastructure/database/models/observation_retention_policy_row.py +59 -0
- topicgate/infrastructure/database/models/observer_workspace_row.py +39 -0
- topicgate/infrastructure/database/models/subscription_row.py +18 -0
- topicgate/infrastructure/mqtt/__init__.py +0 -0
- topicgate/infrastructure/mqtt/async_callback_bridge.py +105 -0
- topicgate/infrastructure/mqtt/callbacks/__init__.py +0 -0
- topicgate/infrastructure/mqtt/callbacks/basic_callbacks.py +61 -0
- topicgate/infrastructure/mqtt/callbacks/observer_repository_callbacks.py +79 -0
- topicgate/infrastructure/mqtt/mqtt_callbacks.py +53 -0
- topicgate/infrastructure/mqtt/mqtt_client.py +356 -0
- topicgate/infrastructure/mqtt/mqtt_gate.py +122 -0
- topicgate/infrastructure/repository/__init__.py +0 -0
- topicgate/infrastructure/repository/broker_config_repository.py +68 -0
- topicgate/infrastructure/repository/broker_repository.py +170 -0
- topicgate/infrastructure/repository/observation_retention_policy_repository.py +42 -0
- topicgate/infrastructure/repository/observer_mqtt_repository.py +335 -0
- topicgate/infrastructure/repository/subscription_repository.py +117 -0
- topicgate/infrastructure/repository/topic_message_repository.py +438 -0
- topicgate/mcp/__init__.py +0 -0
- topicgate/mcp/api/broker_api.py +48 -0
- topicgate/mcp/api/connection_api.py +83 -0
- topicgate/mcp/api/dashboard_api.py +790 -0
- topicgate/mcp/api/dashboard_snapshot.py +278 -0
- topicgate/mcp/api/mcp_api.py +18 -0
- topicgate/mcp/api/preview/dashboard_preview.py +6 -0
- topicgate/mcp/api/publish_api.py +62 -0
- topicgate/mcp/api/snapshot_api.py +78 -0
- topicgate/mcp/api/subscription_api.py +134 -0
- topicgate/mcp/api/topic_api.py +77 -0
- topicgate/mcp/capabilities.py +12 -0
- topicgate/mcp/instructions.py +3 -0
- topicgate/mcp/middleware/__init__.py +6 -0
- topicgate/mcp/middleware/error_handling_middleware.py +49 -0
- topicgate/mcp/middleware/logging_middleware.py +43 -0
- topicgate/mcp/models.py +28 -0
- topicgate/mcp/server.py +147 -0
- topicgate/paths.py +36 -0
- topicgate/presentation/__init__.py +39 -0
- topicgate/presentation/retention_presentation.py +187 -0
- topicgate/presentation/snapshot_presentation.py +130 -0
- topicgate/presentation/topic_presentation.py +482 -0
- topicgate/processors/__init__.py +0 -0
- topicgate/processors/observation_retention_processor.py +35 -0
- topicgate/processors/observer_model_mqtt_message_processor.py +79 -0
- topicgate/processors/observer_model_processor.py +223 -0
- topicgate/processors/subscription_manager.py +90 -0
- topicgate-1.1.0.dist-info/METADATA +222 -0
- topicgate-1.1.0.dist-info/RECORD +148 -0
- topicgate-1.1.0.dist-info/WHEEL +5 -0
- topicgate-1.1.0.dist-info/entry_points.txt +3 -0
- topicgate-1.1.0.dist-info/licenses/LICENCE +21 -0
- topicgate-1.1.0.dist-info/top_level.txt +1 -0
topicgate/__init__.py
ADDED
|
File without changes
|
topicgate/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from topicgate.app.services.service_item import ServiceItem
|
|
5
|
+
from topicgate.app.services.persistence_lifecycle import PersistenceLifecycle
|
|
6
|
+
from topicgate.app.services.broker_profile_service import BrokerProfileService
|
|
7
|
+
from topicgate.app.services.observation_cache_service import ObservationCacheService
|
|
8
|
+
from topicgate.app.services.observation_retention_policy_service import (
|
|
9
|
+
ObservationRetentionPolicyService,
|
|
10
|
+
)
|
|
11
|
+
from topicgate.app.services.broker_snapshot_service import BrokerSnapshotService
|
|
12
|
+
from topicgate.app.services.control_operation_service import ControlOperationService
|
|
13
|
+
from topicgate.app.services.mcp_setup_service import McpSetupService
|
|
14
|
+
from topicgate.app.broker_runtime_state import BrokerRuntimeState
|
|
15
|
+
from topicgate.app.topicgate_runtime import TopicGateRuntime
|
|
16
|
+
from topicgate.core.interfaces.observer_repository import ObserverRepository
|
|
17
|
+
from topicgate.core.models.broker_profile import BrokerProfile
|
|
18
|
+
from topicgate.core.models.mqtt_observation import MqttObservation
|
|
19
|
+
from topicgate.core.models.topic_message import TopicMessage
|
|
20
|
+
from topicgate.infrastructure.database.database_context import DatabaseContext
|
|
21
|
+
from topicgate.infrastructure.credentials.credential_store import CredentialStore
|
|
22
|
+
from topicgate.infrastructure.credentials.os_credential_store import OSCredentialStore
|
|
23
|
+
from topicgate.infrastructure.repository.observer_mqtt_repository import (
|
|
24
|
+
ObserverMqttRepository,
|
|
25
|
+
)
|
|
26
|
+
from topicgate.infrastructure.repository.topic_message_repository import (
|
|
27
|
+
TopicMessageRepository,
|
|
28
|
+
)
|
|
29
|
+
from topicgate.infrastructure.repository.observation_retention_policy_repository import (
|
|
30
|
+
ObservationRetentionPolicyRepository,
|
|
31
|
+
)
|
|
32
|
+
from topicgate.paths import prepare_database_path, sqlite_url
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AppDependencies:
|
|
36
|
+
"""Build application components and expose their lifecycle order."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
data_dir: Path | None = None,
|
|
41
|
+
credential_store: CredentialStore | None = None,
|
|
42
|
+
*,
|
|
43
|
+
control_owner: str = "application",
|
|
44
|
+
) -> None:
|
|
45
|
+
|
|
46
|
+
database_path = prepare_database_path(data_dir)
|
|
47
|
+
self._db_context = DatabaseContext(sqlite_url(database_path))
|
|
48
|
+
self.database_path = database_path
|
|
49
|
+
self.credential_store = (
|
|
50
|
+
OSCredentialStore() if credential_store is None else credential_store
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
self.broker_runtime_state = BrokerRuntimeState()
|
|
54
|
+
self.observation_retention_policy = ObservationRetentionPolicyRepository(
|
|
55
|
+
self._db_context
|
|
56
|
+
)
|
|
57
|
+
self.retention_policy = ObservationRetentionPolicyService(
|
|
58
|
+
self.observation_retention_policy
|
|
59
|
+
)
|
|
60
|
+
self.topic_messages = TopicMessageRepository(
|
|
61
|
+
self._db_context,
|
|
62
|
+
policy_provider=self.retention_policy.get,
|
|
63
|
+
)
|
|
64
|
+
self.observation_cache = ObservationCacheService(
|
|
65
|
+
self.topic_messages,
|
|
66
|
+
self.retention_policy,
|
|
67
|
+
)
|
|
68
|
+
self.control_operations = ControlOperationService(
|
|
69
|
+
self._db_context,
|
|
70
|
+
control_owner,
|
|
71
|
+
)
|
|
72
|
+
self.persistence = PersistenceLifecycle(
|
|
73
|
+
self.topic_messages,
|
|
74
|
+
self._db_context,
|
|
75
|
+
)
|
|
76
|
+
self.broker_profiles = BrokerProfileService(
|
|
77
|
+
self._db_context,
|
|
78
|
+
credential_store=self.credential_store,
|
|
79
|
+
runtime_state=self.broker_runtime_state,
|
|
80
|
+
topic_messages=self.topic_messages,
|
|
81
|
+
)
|
|
82
|
+
profile = self.broker_profiles.get_profile()
|
|
83
|
+
|
|
84
|
+
self.broker_runtime_state.repositories.update(
|
|
85
|
+
{
|
|
86
|
+
item.id: self._create_observer_repository(item)
|
|
87
|
+
for item in self.broker_profiles.get_all_profiles()
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
self.runtime = TopicGateRuntime(
|
|
92
|
+
self.broker_profiles,
|
|
93
|
+
self.broker_runtime_state.repositories,
|
|
94
|
+
profile.id,
|
|
95
|
+
self._create_observer_repository,
|
|
96
|
+
self.observation_cache,
|
|
97
|
+
self.control_operations,
|
|
98
|
+
)
|
|
99
|
+
self.snapshot_service = BrokerSnapshotService(self.runtime)
|
|
100
|
+
self.mcp_setup = McpSetupService(
|
|
101
|
+
self.runtime,
|
|
102
|
+
self.snapshot_service,
|
|
103
|
+
self._db_context,
|
|
104
|
+
self.credential_store,
|
|
105
|
+
database_path.parent,
|
|
106
|
+
database_path,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
self.service_items: tuple[ServiceItem, ...] = (
|
|
110
|
+
self.persistence,
|
|
111
|
+
self.runtime,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _create_observer_repository(
|
|
115
|
+
self,
|
|
116
|
+
profile: BrokerProfile,
|
|
117
|
+
) -> ObserverRepository:
|
|
118
|
+
return ObserverMqttRepository(
|
|
119
|
+
profile.config,
|
|
120
|
+
list(profile.workspace.subscriptions),
|
|
121
|
+
profile.workspace.model,
|
|
122
|
+
retention_policy=self.retention_policy.get,
|
|
123
|
+
observation_sink=lambda observation: self._persist_observation(
|
|
124
|
+
profile.id,
|
|
125
|
+
observation,
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def _persist_observation(
|
|
130
|
+
self,
|
|
131
|
+
broker_id: UUID,
|
|
132
|
+
observation: MqttObservation,
|
|
133
|
+
) -> None:
|
|
134
|
+
if observation.observation_id is None:
|
|
135
|
+
raise ValueError("A live observation requires an observation ID.")
|
|
136
|
+
self.topic_messages.update_message(
|
|
137
|
+
TopicMessage(
|
|
138
|
+
broker_id=broker_id,
|
|
139
|
+
topic=observation.topic,
|
|
140
|
+
payload=observation.payload,
|
|
141
|
+
qos=observation.qos,
|
|
142
|
+
retain=observation.retain,
|
|
143
|
+
received_at=observation.received_at,
|
|
144
|
+
payload_size=observation.payload_size or len(observation.payload),
|
|
145
|
+
message_count=observation.message_count,
|
|
146
|
+
observation_id=observation.observation_id,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from uuid import UUID
|
|
2
|
+
|
|
3
|
+
from topicgate.core.interfaces.observer_repository import ObserverRepository
|
|
4
|
+
from topicgate.core.models.observer_model import ObserverModel
|
|
5
|
+
from topicgate.core.models.broker_profile import BrokerProfile
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BrokerRuntimeState:
|
|
9
|
+
"""In-memory observer models and repositories, keyed by broker identity."""
|
|
10
|
+
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._models: dict[UUID, ObserverModel] = {}
|
|
13
|
+
self._repositories: dict[UUID, ObserverRepository] = {}
|
|
14
|
+
self._profile_handles: dict[UUID, BrokerProfile] = {}
|
|
15
|
+
self._config_ids: dict[UUID, int | None] = {}
|
|
16
|
+
|
|
17
|
+
def get_model(self, broker_id: UUID) -> ObserverModel | None:
|
|
18
|
+
return self._models.get(broker_id)
|
|
19
|
+
|
|
20
|
+
def set_model(self, broker_id: UUID, model: ObserverModel) -> None:
|
|
21
|
+
self._models[broker_id] = model
|
|
22
|
+
|
|
23
|
+
def remove(self, broker_id: UUID) -> None:
|
|
24
|
+
self._models.pop(broker_id, None)
|
|
25
|
+
self._repositories.pop(broker_id, None)
|
|
26
|
+
self._profile_handles.pop(broker_id, None)
|
|
27
|
+
self._config_ids.pop(broker_id, None)
|
|
28
|
+
|
|
29
|
+
def get_config_id(self, broker_id: UUID, persisted_id: int | None) -> int | None:
|
|
30
|
+
return self._config_ids.get(broker_id, persisted_id)
|
|
31
|
+
|
|
32
|
+
def set_config_id(self, broker_id: UUID, config_id: int | None) -> None:
|
|
33
|
+
self._config_ids[broker_id] = config_id
|
|
34
|
+
|
|
35
|
+
def get_profile_handle(self, broker_id: UUID) -> BrokerProfile | None:
|
|
36
|
+
return self._profile_handles.get(broker_id)
|
|
37
|
+
|
|
38
|
+
def set_profile_handle(self, profile: BrokerProfile) -> None:
|
|
39
|
+
self._profile_handles[profile.id] = profile
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def repositories(self) -> dict[UUID, ObserverRepository]:
|
|
43
|
+
return self._repositories
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application-level read models."""
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from topicgate.core.models.mqtt_observation import ObservationSource
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SnapshotPayloadEncoding(StrEnum):
|
|
10
|
+
UTF8 = "utf-8"
|
|
11
|
+
BASE64 = "base64"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SnapshotTopicStatus(StrEnum):
|
|
15
|
+
LIVE = "live"
|
|
16
|
+
CACHED = "cached"
|
|
17
|
+
STALE = "stale"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SnapshotLimitation(StrEnum):
|
|
21
|
+
CURRENT_STATE_ONLY = "current_state_only"
|
|
22
|
+
RETAINED_DELIVERY_UNCONFIRMED = "retained_delivery_unconfirmed"
|
|
23
|
+
BROKER_DISCONNECTED = "broker_disconnected"
|
|
24
|
+
OBSERVATION_NOT_STARTED = "observation_not_started"
|
|
25
|
+
STORED_STATE_PREDATES_OBSERVATION = "stored_state_predates_observation"
|
|
26
|
+
DROPPED_MESSAGES = "dropped_messages"
|
|
27
|
+
STALE_STATES_OMITTED = "stale_states_omitted"
|
|
28
|
+
RESULT_LIMIT_REACHED = "result_limit_reached"
|
|
29
|
+
PAYLOAD_TRUNCATED = "payload_truncated"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class SnapshotBrokerIdentity:
|
|
34
|
+
id: UUID
|
|
35
|
+
name: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class SnapshotPayload:
|
|
40
|
+
encoding: SnapshotPayloadEncoding
|
|
41
|
+
value: str
|
|
42
|
+
original_size: int
|
|
43
|
+
available_size: int
|
|
44
|
+
rendered_size: int
|
|
45
|
+
ingestion_truncated: bool
|
|
46
|
+
rendering_truncated: bool
|
|
47
|
+
truncated: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class SnapshotTopicState:
|
|
52
|
+
topic: str
|
|
53
|
+
payload: SnapshotPayload
|
|
54
|
+
qos: int
|
|
55
|
+
retain: bool
|
|
56
|
+
received_at: datetime
|
|
57
|
+
age_seconds: float
|
|
58
|
+
message_count: int
|
|
59
|
+
source: ObservationSource
|
|
60
|
+
status: SnapshotTopicStatus
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class SnapshotFreshness:
|
|
65
|
+
max_age_seconds: float | None
|
|
66
|
+
stale_count: int
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class SnapshotResultLimit:
|
|
71
|
+
limit: int
|
|
72
|
+
total: int
|
|
73
|
+
returned: int
|
|
74
|
+
omitted: int
|
|
75
|
+
omitted_as_stale: int
|
|
76
|
+
omitted_by_limit: int
|
|
77
|
+
truncated: bool
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class SnapshotSettling:
|
|
82
|
+
requested_seconds: float
|
|
83
|
+
maximum_seconds: float
|
|
84
|
+
actual_seconds: float
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class SnapshotCompleteness:
|
|
89
|
+
is_complete: bool
|
|
90
|
+
limitations: tuple[SnapshotLimitation, ...]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class BrokerSnapshot:
|
|
95
|
+
broker: SnapshotBrokerIdentity
|
|
96
|
+
connection_status: str
|
|
97
|
+
captured_at: datetime
|
|
98
|
+
connected_at: datetime | None
|
|
99
|
+
observation_started_at: datetime | None
|
|
100
|
+
observed_for_seconds: float | None
|
|
101
|
+
topic_filter: str
|
|
102
|
+
topics: tuple[SnapshotTopicState, ...]
|
|
103
|
+
dropped_message_count: int
|
|
104
|
+
freshness: SnapshotFreshness
|
|
105
|
+
results: SnapshotResultLimit
|
|
106
|
+
settling: SnapshotSettling
|
|
107
|
+
completeness: SnapshotCompleteness
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class McpPreflightCheck:
|
|
7
|
+
name: str
|
|
8
|
+
status: str
|
|
9
|
+
detail: str
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class McpSetupInformation:
|
|
14
|
+
version: str
|
|
15
|
+
executable_path: Path
|
|
16
|
+
data_path: Path
|
|
17
|
+
database_path: Path
|
|
18
|
+
command: str
|
|
19
|
+
command_prefix_arguments: tuple[str, ...]
|
|
20
|
+
|
|
File without changes
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from dataclasses import replace
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from topicgate.app.broker_runtime_state import BrokerRuntimeState
|
|
5
|
+
from topicgate.core.config.app_config import AppConfig
|
|
6
|
+
from topicgate.core.config.mqtt_config import MqttConfig
|
|
7
|
+
from topicgate.core.models.broker_profile import BrokerProfile
|
|
8
|
+
from topicgate.core.models.observer_model import ObserverModel
|
|
9
|
+
from topicgate.core.models.mqtt_observation import (
|
|
10
|
+
MqttObservation,
|
|
11
|
+
ObservationSource,
|
|
12
|
+
)
|
|
13
|
+
from topicgate.core.interfaces.topic_message_store import TopicMessageStore
|
|
14
|
+
from topicgate.core.models.observer_workspace import ObserverWorkspace
|
|
15
|
+
from topicgate.infrastructure.credentials.credential_store import CredentialStore
|
|
16
|
+
from topicgate.infrastructure.database.database_context import DatabaseContext
|
|
17
|
+
from topicgate.infrastructure.repository.broker_config_repository import (
|
|
18
|
+
BrokerConfigRepository,
|
|
19
|
+
)
|
|
20
|
+
from topicgate.infrastructure.repository.broker_repository import BrokerRepository
|
|
21
|
+
from topicgate.infrastructure.repository.subscription_repository import (
|
|
22
|
+
SubscriptionRepository,
|
|
23
|
+
)
|
|
24
|
+
from topicgate.processors.observer_model_processor import ObserverModelProcessor
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BrokerProfileService:
|
|
28
|
+
"""Compose broker identity, persisted config, credentials, and runtime state."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
settings: AppConfig | DatabaseContext | None = None,
|
|
33
|
+
db: DatabaseContext | AppConfig | None = None,
|
|
34
|
+
*,
|
|
35
|
+
credential_store: CredentialStore,
|
|
36
|
+
runtime_state: BrokerRuntimeState | None = None,
|
|
37
|
+
topic_messages: TopicMessageStore | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
if isinstance(settings, DatabaseContext):
|
|
40
|
+
self._db = settings
|
|
41
|
+
supplied_settings = db if isinstance(db, AppConfig) else None
|
|
42
|
+
else:
|
|
43
|
+
self._db = db if isinstance(db, DatabaseContext) else DatabaseContext(
|
|
44
|
+
"sqlite:///:memory:"
|
|
45
|
+
)
|
|
46
|
+
supplied_settings = settings
|
|
47
|
+
self._credentials = credential_store
|
|
48
|
+
self._runtime_state = runtime_state or BrokerRuntimeState()
|
|
49
|
+
self.brokers = BrokerRepository(self._db)
|
|
50
|
+
self.configs = BrokerConfigRepository(self._db)
|
|
51
|
+
self.subscriptions = SubscriptionRepository(self._db)
|
|
52
|
+
self._topic_messages = topic_messages
|
|
53
|
+
self._settings_id = supplied_settings.id if supplied_settings else None
|
|
54
|
+
|
|
55
|
+
identities = self.brokers.list_profiles()
|
|
56
|
+
if not identities:
|
|
57
|
+
initial = supplied_settings.mqtt if supplied_settings else MqttConfig(
|
|
58
|
+
"localhost", 1883, "", ""
|
|
59
|
+
)
|
|
60
|
+
self._create_seed_profile("Default", initial, is_active=True)
|
|
61
|
+
self._create_seed_profile(
|
|
62
|
+
"Local MQTT", MqttConfig("localhost", 1883, "", "")
|
|
63
|
+
)
|
|
64
|
+
elif supplied_settings is not None:
|
|
65
|
+
active_id = self.brokers.get_profile().id
|
|
66
|
+
if self._credentials.get_password(active_id) is None:
|
|
67
|
+
self._store_password(active_id, supplied_settings.mqtt.password)
|
|
68
|
+
|
|
69
|
+
self._settings = (
|
|
70
|
+
supplied_settings
|
|
71
|
+
if supplied_settings is not None and not identities
|
|
72
|
+
else AppConfig(self.get_profile().config, id=self._settings_id)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def get_profile(self, profile_id: UUID | None = None) -> BrokerProfile:
|
|
76
|
+
identity = self.brokers.get_profile(profile_id)
|
|
77
|
+
config = self.configs.get(identity.id)
|
|
78
|
+
config = replace(
|
|
79
|
+
config,
|
|
80
|
+
password=self._credentials.get_password(identity.id) or "",
|
|
81
|
+
id=self._runtime_state.get_config_id(identity.id, config.id),
|
|
82
|
+
)
|
|
83
|
+
subscriptions = self.subscriptions.list_for_workspace(identity.workspace_id)
|
|
84
|
+
model = self._runtime_state.get_model(identity.id)
|
|
85
|
+
if model is None:
|
|
86
|
+
model = self._hydrate_observer_model(identity.id, subscriptions)
|
|
87
|
+
workspace = ObserverWorkspace(
|
|
88
|
+
id=identity.workspace_id,
|
|
89
|
+
profile_id=identity.id,
|
|
90
|
+
model=model,
|
|
91
|
+
subscriptions=subscriptions,
|
|
92
|
+
)
|
|
93
|
+
return BrokerProfile(
|
|
94
|
+
id=identity.id,
|
|
95
|
+
name=identity.name,
|
|
96
|
+
config=config,
|
|
97
|
+
workspace_id=identity.workspace_id,
|
|
98
|
+
workspace=workspace,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def get_all_profiles(self) -> tuple[BrokerProfile, ...]:
|
|
102
|
+
return tuple(self.get_profile(item.id) for item in self.brokers.list_profiles())
|
|
103
|
+
|
|
104
|
+
def create_profile(self, name: str, config: MqttConfig) -> BrokerProfile:
|
|
105
|
+
name = self.brokers.validate_profile_name(name)
|
|
106
|
+
with self._db.transaction() as session:
|
|
107
|
+
config_id = self.configs.create(config, session=session)
|
|
108
|
+
identity = self.brokers.create_profile(
|
|
109
|
+
name, config_id, session=session
|
|
110
|
+
)
|
|
111
|
+
self._store_password(identity.id, config.password)
|
|
112
|
+
self._runtime_state.set_config_id(identity.id, config.id)
|
|
113
|
+
profile = self.get_profile(identity.id)
|
|
114
|
+
self._runtime_state.set_profile_handle(profile)
|
|
115
|
+
self.save()
|
|
116
|
+
return profile
|
|
117
|
+
|
|
118
|
+
def update_profile(self, profile: BrokerProfile) -> None:
|
|
119
|
+
identity = self.brokers.get_profile(profile.id)
|
|
120
|
+
if (
|
|
121
|
+
profile.workspace.profile_id != profile.id
|
|
122
|
+
or profile.workspace_id != identity.workspace_id
|
|
123
|
+
or profile.workspace.id != identity.workspace_id
|
|
124
|
+
):
|
|
125
|
+
raise ValueError("The workspace must belong to the broker profile.")
|
|
126
|
+
self.brokers.update_profile_name(profile.id, profile.name)
|
|
127
|
+
self.configs.update(profile.id, profile.config)
|
|
128
|
+
self.subscriptions.replace_all(
|
|
129
|
+
identity.workspace_id, profile.workspace.subscriptions
|
|
130
|
+
)
|
|
131
|
+
self._store_password(profile.id, profile.config.password)
|
|
132
|
+
self._runtime_state.set_config_id(profile.id, profile.config.id)
|
|
133
|
+
self._runtime_state.set_profile_handle(profile)
|
|
134
|
+
if identity.is_active:
|
|
135
|
+
self._settings = AppConfig(profile.config, id=self._settings_id)
|
|
136
|
+
self.save()
|
|
137
|
+
|
|
138
|
+
def delete_profile(self, profile_id: UUID) -> BrokerProfile:
|
|
139
|
+
profile = self._runtime_state.get_profile_handle(profile_id) or self.get_profile(
|
|
140
|
+
profile_id
|
|
141
|
+
)
|
|
142
|
+
self.brokers.delete_profile(profile_id)
|
|
143
|
+
self._delete_password(profile_id)
|
|
144
|
+
self._runtime_state.remove(profile_id)
|
|
145
|
+
self.save()
|
|
146
|
+
return profile
|
|
147
|
+
|
|
148
|
+
def select_active_profile(self, profile_id: UUID) -> None:
|
|
149
|
+
self.brokers.select_active_profile(profile_id)
|
|
150
|
+
self._settings = AppConfig(self.get_profile().config, id=self._settings_id)
|
|
151
|
+
|
|
152
|
+
def activate_profile(
|
|
153
|
+
self, profile_id: UUID, config: MqttConfig | None = None
|
|
154
|
+
) -> None:
|
|
155
|
+
if config is not None:
|
|
156
|
+
self.configs.update(profile_id, config)
|
|
157
|
+
self._store_password(profile_id, config.password)
|
|
158
|
+
self._runtime_state.set_config_id(profile_id, config.id)
|
|
159
|
+
self.select_active_profile(profile_id)
|
|
160
|
+
|
|
161
|
+
def replace_subscriptions(self, workspace_id: UUID, subscriptions) -> None:
|
|
162
|
+
self.subscriptions.replace_all(workspace_id, tuple(subscriptions))
|
|
163
|
+
|
|
164
|
+
def update_observer_workspace(self, workspace: ObserverWorkspace) -> None:
|
|
165
|
+
self.replace_subscriptions(workspace.id, workspace.subscriptions)
|
|
166
|
+
|
|
167
|
+
def update_observer_model(self, model: ObserverModel) -> None:
|
|
168
|
+
self._runtime_state.set_model(self.brokers.get_profile().id, model)
|
|
169
|
+
self.save()
|
|
170
|
+
|
|
171
|
+
def get_observer_model(self) -> ObserverModel:
|
|
172
|
+
return self.get_profile().workspace.model
|
|
173
|
+
|
|
174
|
+
def get_observer_workspace(self) -> ObserverWorkspace:
|
|
175
|
+
return self.get_profile().workspace
|
|
176
|
+
|
|
177
|
+
def get_mqtt(self) -> MqttConfig:
|
|
178
|
+
return self.get_profile().config
|
|
179
|
+
|
|
180
|
+
def update_mqtt(self, config: MqttConfig) -> None:
|
|
181
|
+
profile_id = self.brokers.get_profile().id
|
|
182
|
+
self.configs.update(profile_id, config)
|
|
183
|
+
self._store_password(profile_id, config.password)
|
|
184
|
+
self._runtime_state.set_config_id(profile_id, config.id)
|
|
185
|
+
self._settings = AppConfig(config, id=self._settings_id)
|
|
186
|
+
self.save()
|
|
187
|
+
|
|
188
|
+
def get(self) -> AppConfig:
|
|
189
|
+
current = self.get_mqtt()
|
|
190
|
+
if self._settings.mqtt != current:
|
|
191
|
+
self._settings = AppConfig(current, id=self._settings_id)
|
|
192
|
+
return self._settings
|
|
193
|
+
|
|
194
|
+
def update(self, settings: AppConfig) -> None:
|
|
195
|
+
self.update_mqtt(settings.mqtt)
|
|
196
|
+
self._settings = settings
|
|
197
|
+
|
|
198
|
+
def save(self) -> None:
|
|
199
|
+
"""Compatibility hook; repositories commit each mutation."""
|
|
200
|
+
|
|
201
|
+
def _create_seed_profile(
|
|
202
|
+
self, name: str, config: MqttConfig, *, is_active: bool = False
|
|
203
|
+
) -> None:
|
|
204
|
+
with self._db.transaction() as session:
|
|
205
|
+
config_id = self.configs.create(config, session=session)
|
|
206
|
+
identity = self.brokers.create_profile(
|
|
207
|
+
name, config_id, is_active=is_active, session=session
|
|
208
|
+
)
|
|
209
|
+
self._store_password(identity.id, config.password)
|
|
210
|
+
self._runtime_state.set_config_id(identity.id, config.id)
|
|
211
|
+
|
|
212
|
+
def _hydrate_observer_model(self, broker_id: UUID, subscriptions) -> ObserverModel:
|
|
213
|
+
model = ObserverModelProcessor.add_topics(
|
|
214
|
+
ObserverModel(root_stats=[]),
|
|
215
|
+
(item.topic_filter for item in subscriptions),
|
|
216
|
+
)
|
|
217
|
+
if self._topic_messages is None:
|
|
218
|
+
return model
|
|
219
|
+
for message in self._topic_messages.get_latest_messages(broker_id):
|
|
220
|
+
node = ObserverModelProcessor.find_or_create_node(model, message.topic)
|
|
221
|
+
state = MqttObservation(
|
|
222
|
+
name=node.segment,
|
|
223
|
+
topic=message.topic,
|
|
224
|
+
payload=message.payload,
|
|
225
|
+
qos=message.qos,
|
|
226
|
+
retain=message.retain,
|
|
227
|
+
recieved_at=message.received_at,
|
|
228
|
+
payload_size=message.payload_size,
|
|
229
|
+
message_count=message.message_count,
|
|
230
|
+
source=ObservationSource.STORED,
|
|
231
|
+
observation_id=message.observation_id,
|
|
232
|
+
)
|
|
233
|
+
node.state = state
|
|
234
|
+
model.topic_states[message.topic] = state
|
|
235
|
+
return model
|
|
236
|
+
|
|
237
|
+
def _store_password(self, profile_id: UUID, password: str) -> None:
|
|
238
|
+
if password:
|
|
239
|
+
self._credentials.set_password(profile_id, password)
|
|
240
|
+
else:
|
|
241
|
+
self._delete_password(profile_id)
|
|
242
|
+
|
|
243
|
+
def _delete_password(self, profile_id: UUID) -> None:
|
|
244
|
+
if self._credentials.get_password(profile_id) is not None:
|
|
245
|
+
self._credentials.delete_password(profile_id)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from uuid import UUID
|
|
2
|
+
|
|
3
|
+
from topicgate.app.topicgate_runtime import TopicGateRuntime
|
|
4
|
+
from topicgate.core.models.broker_summary import BrokerSummary
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BrokerNotFoundError(LookupError):
|
|
8
|
+
"""Raised when a broker selector does not match a configured broker."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AmbiguousBrokerError(ValueError):
|
|
12
|
+
"""Raised when a broker name matches more than one configured broker."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BrokerResolver:
|
|
16
|
+
"""Resolve broker summaries by UUID or case-insensitive profile name."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, runtime: TopicGateRuntime) -> None:
|
|
19
|
+
self._runtime = runtime
|
|
20
|
+
|
|
21
|
+
def resolve(self, selector: UUID | str) -> BrokerSummary:
|
|
22
|
+
brokers = self._runtime.list_brokers()
|
|
23
|
+
broker_id = self._parse_uuid(selector)
|
|
24
|
+
if broker_id is not None:
|
|
25
|
+
match = next((item for item in brokers if item.id == broker_id), None)
|
|
26
|
+
if match is None:
|
|
27
|
+
raise BrokerNotFoundError(f"Unknown broker UUID: {broker_id}")
|
|
28
|
+
return match
|
|
29
|
+
|
|
30
|
+
name = str(selector).strip()
|
|
31
|
+
matches = tuple(
|
|
32
|
+
item for item in brokers if item.name.casefold() == name.casefold()
|
|
33
|
+
)
|
|
34
|
+
if not matches:
|
|
35
|
+
raise BrokerNotFoundError(f"Unknown broker name: {name!r}")
|
|
36
|
+
if len(matches) > 1:
|
|
37
|
+
matching_ids = ", ".join(str(item.id) for item in matches)
|
|
38
|
+
raise AmbiguousBrokerError(
|
|
39
|
+
f"Broker name {name!r} is ambiguous; matching UUIDs: {matching_ids}"
|
|
40
|
+
)
|
|
41
|
+
return matches[0]
|
|
42
|
+
|
|
43
|
+
def resolve_or_active(
|
|
44
|
+
self,
|
|
45
|
+
selector: UUID | str | None,
|
|
46
|
+
) -> BrokerSummary:
|
|
47
|
+
if selector is None:
|
|
48
|
+
return self._runtime.active_broker
|
|
49
|
+
return self.resolve(selector)
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _parse_uuid(selector: UUID | str) -> UUID | None:
|
|
53
|
+
if isinstance(selector, UUID):
|
|
54
|
+
return selector
|
|
55
|
+
try:
|
|
56
|
+
return UUID(selector.strip())
|
|
57
|
+
except (AttributeError, ValueError):
|
|
58
|
+
return None
|