quantum-framework 0.18.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.
- quantum/__init__.py +1 -0
- quantum/cli/__init__.py +2 -0
- quantum/cli/admin.py +131 -0
- quantum/cli/check.py +223 -0
- quantum/cli/commands/__init__.py +15 -0
- quantum/cli/commands/build.py +414 -0
- quantum/cli/commands/dev.py +185 -0
- quantum/cli/commands/docs.py +329 -0
- quantum/cli/commands/lint.py +523 -0
- quantum/cli/commands/migrate.py +548 -0
- quantum/cli/commands/new.py +622 -0
- quantum/cli/commands/serve.py +190 -0
- quantum/cli/commands/test.py +193 -0
- quantum/cli/deploy.py +810 -0
- quantum/cli/hot_reload.py +951 -0
- quantum/cli/jobs.py +356 -0
- quantum/cli/mq.py +582 -0
- quantum/cli/pkg.py +390 -0
- quantum/cli/runner.py +565 -0
- quantum/cli/server_process.py +159 -0
- quantum/cli/utils.py +334 -0
- quantum/compiler/__init__.py +30 -0
- quantum/compiler/base_generator.py +367 -0
- quantum/compiler/cli.py +295 -0
- quantum/compiler/expression_transformer.py +444 -0
- quantum/compiler/javascript/__init__.py +10 -0
- quantum/compiler/javascript/generator.py +659 -0
- quantum/compiler/optimizer.py +270 -0
- quantum/compiler/python/__init__.py +10 -0
- quantum/compiler/python/generator.py +883 -0
- quantum/compiler/python/runtime.py +863 -0
- quantum/compiler/transpiler.py +330 -0
- quantum/core/__init__.py +3 -0
- quantum/core/ast_nodes.py +2619 -0
- quantum/core/config_env.py +54 -0
- quantum/core/docs_links.py +8 -0
- quantum/core/expression_diagnostics.py +87 -0
- quantum/core/expression_stdlib.py +208 -0
- quantum/core/expressions.py +677 -0
- quantum/core/features/agents/src/__init__.py +30 -0
- quantum/core/features/agents/src/ast_node.py +540 -0
- quantum/core/features/conditionals/src/__init__.py +8 -0
- quantum/core/features/conditionals/src/ast_node.py +68 -0
- quantum/core/features/data_import/src/__init__.py +0 -0
- quantum/core/features/data_import/src/ast_node.py +292 -0
- quantum/core/features/data_import/src/runtime.py +533 -0
- quantum/core/features/dump/src/__init__.py +12 -0
- quantum/core/features/dump/src/ast_node.py +106 -0
- quantum/core/features/dump/src/parser.py +61 -0
- quantum/core/features/dump/src/runtime.py +246 -0
- quantum/core/features/functions/src/__init__.py +8 -0
- quantum/core/features/functions/src/ast_node.py +149 -0
- quantum/core/features/game_engine_2d/src/__init__.py +19 -0
- quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
- quantum/core/features/game_engine_2d/src/parser.py +983 -0
- quantum/core/features/invocation/src/__init__.py +0 -0
- quantum/core/features/invocation/src/ast_node.py +147 -0
- quantum/core/features/invocation/src/runtime.py +332 -0
- quantum/core/features/knowledge_base/src/__init__.py +6 -0
- quantum/core/features/knowledge_base/src/ast_node.py +113 -0
- quantum/core/features/knowledge_base/src/parser.py +82 -0
- quantum/core/features/logging/src/__init__.py +12 -0
- quantum/core/features/logging/src/ast_node.py +111 -0
- quantum/core/features/logging/src/parser.py +50 -0
- quantum/core/features/logging/src/runtime.py +190 -0
- quantum/core/features/loops/src/__init__.py +8 -0
- quantum/core/features/loops/src/ast_node.py +60 -0
- quantum/core/features/query/src/__init__.py +0 -0
- quantum/core/features/query/src/query_validators.py +20 -0
- quantum/core/features/state_management/src/__init__.py +11 -0
- quantum/core/features/state_management/src/ast_node.py +121 -0
- quantum/core/features/terminal_engine/src/__init__.py +21 -0
- quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
- quantum/core/features/terminal_engine/src/parser.py +361 -0
- quantum/core/features/testing_engine/src/__init__.py +41 -0
- quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
- quantum/core/features/testing_engine/src/parser.py +604 -0
- quantum/core/features/theming/src/__init__.py +48 -0
- quantum/core/features/theming/src/ast_node.py +137 -0
- quantum/core/features/theming/src/presets.py +405 -0
- quantum/core/features/ui_engine/src/__init__.py +20 -0
- quantum/core/features/ui_engine/src/ast_nodes.py +1913 -0
- quantum/core/features/ui_engine/src/parser.py +1192 -0
- quantum/core/features/websocket/src/__init__.py +24 -0
- quantum/core/features/websocket/src/ast_node.py +247 -0
- quantum/core/guards.py +53 -0
- quantum/core/html_compat.py +299 -0
- quantum/core/parser.py +1551 -0
- quantum/core/parser_registry.py +213 -0
- quantum/core/parsers/__init__.py +76 -0
- quantum/core/parsers/ai/__init__.py +12 -0
- quantum/core/parsers/ai/agent_parser.py +106 -0
- quantum/core/parsers/ai/knowledge_parser.py +86 -0
- quantum/core/parsers/ai/llm_parser.py +78 -0
- quantum/core/parsers/ai/team_parser.py +88 -0
- quantum/core/parsers/base.py +322 -0
- quantum/core/parsers/composition/__init__.py +10 -0
- quantum/core/parsers/composition/import_parser.py +50 -0
- quantum/core/parsers/composition/slot_parser.py +48 -0
- quantum/core/parsers/control_flow/__init__.py +11 -0
- quantum/core/parsers/control_flow/if_parser.py +71 -0
- quantum/core/parsers/control_flow/loop_parser.py +105 -0
- quantum/core/parsers/control_flow/set_parser.py +93 -0
- quantum/core/parsers/data/__init__.py +12 -0
- quantum/core/parsers/data/data_parser.py +229 -0
- quantum/core/parsers/data/invoke_parser.py +133 -0
- quantum/core/parsers/data/query_parser.py +213 -0
- quantum/core/parsers/data/transaction_parser.py +86 -0
- quantum/core/parsers/events/__init__.py +9 -0
- quantum/core/parsers/events/dispatch_event_parser.py +44 -0
- quantum/core/parsers/forms/__init__.py +11 -0
- quantum/core/parsers/forms/action_parser.py +79 -0
- quantum/core/parsers/forms/flash_parser.py +34 -0
- quantum/core/parsers/forms/redirect_parser.py +33 -0
- quantum/core/parsers/functions/__init__.py +11 -0
- quantum/core/parsers/functions/function_parser.py +98 -0
- quantum/core/parsers/functions/param_parser.py +73 -0
- quantum/core/parsers/functions/return_parser.py +31 -0
- quantum/core/parsers/html/__init__.py +10 -0
- quantum/core/parsers/html/component_call_parser.py +130 -0
- quantum/core/parsers/html/html_parser.py +115 -0
- quantum/core/parsers/jobs/__init__.py +11 -0
- quantum/core/parsers/jobs/job_parser.py +71 -0
- quantum/core/parsers/jobs/schedule_parser.py +62 -0
- quantum/core/parsers/jobs/thread_parser.py +57 -0
- quantum/core/parsers/messaging/__init__.py +17 -0
- quantum/core/parsers/messaging/message_ack_parser.py +30 -0
- quantum/core/parsers/messaging/message_nack_parser.py +30 -0
- quantum/core/parsers/messaging/message_parser.py +114 -0
- quantum/core/parsers/messaging/queue_parser.py +61 -0
- quantum/core/parsers/messaging/websocket_parser.py +121 -0
- quantum/core/parsers/routing/__init__.py +9 -0
- quantum/core/parsers/routing/route_parser.py +41 -0
- quantum/core/parsers/scripting/__init__.py +12 -0
- quantum/core/parsers/scripting/pyclass_parser.py +62 -0
- quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
- quantum/core/parsers/scripting/pyimport_parser.py +52 -0
- quantum/core/parsers/scripting/python_parser.py +49 -0
- quantum/core/parsers/services/__init__.py +12 -0
- quantum/core/parsers/services/dump_parser.py +52 -0
- quantum/core/parsers/services/file_parser.py +48 -0
- quantum/core/parsers/services/log_parser.py +46 -0
- quantum/core/parsers/services/mail_parser.py +65 -0
- quantum/core/tiers.py +164 -0
- quantum/core/xml_lines.py +81 -0
- quantum/packages/__init__.py +28 -0
- quantum/packages/manager.py +413 -0
- quantum/packages/manifest.py +351 -0
- quantum/packages/registry.py +399 -0
- quantum/packages/resolver.py +336 -0
- quantum/plugins/__init__.py +33 -0
- quantum/plugins/hooks.py +329 -0
- quantum/plugins/loader.py +479 -0
- quantum/plugins/manifest.py +336 -0
- quantum/plugins/registry.py +371 -0
- quantum/runtime/__init__.py +25 -0
- quantum/runtime/action_handler.py +532 -0
- quantum/runtime/adapters/__init__.py +88 -0
- quantum/runtime/adapters/memory_adapter.py +690 -0
- quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
- quantum/runtime/adapters/redis_adapter.py +582 -0
- quantum/runtime/adapters/sqlite_adapter.py +414 -0
- quantum/runtime/agent_service.py +1133 -0
- quantum/runtime/ast_cache.py +506 -0
- quantum/runtime/auth_service.py +267 -0
- quantum/runtime/component.py +1048 -0
- quantum/runtime/component_composer.py +133 -0
- quantum/runtime/component_resolver.py +174 -0
- quantum/runtime/conditions.py +54 -0
- quantum/runtime/database_service.py +595 -0
- quantum/runtime/db_schema.py +100 -0
- quantum/runtime/dev_panel.py +178 -0
- quantum/runtime/email_service.py +162 -0
- quantum/runtime/error_handler.py +295 -0
- quantum/runtime/execution_context.py +283 -0
- quantum/runtime/executor_registry.py +178 -0
- quantum/runtime/executors/__init__.py +71 -0
- quantum/runtime/executors/ai/__init__.py +12 -0
- quantum/runtime/executors/ai/agent_executor.py +217 -0
- quantum/runtime/executors/ai/knowledge_executor.py +114 -0
- quantum/runtime/executors/ai/llm_executor.py +153 -0
- quantum/runtime/executors/ai/team_executor.py +171 -0
- quantum/runtime/executors/base.py +264 -0
- quantum/runtime/executors/control_flow/__init__.py +12 -0
- quantum/runtime/executors/control_flow/if_executor.py +98 -0
- quantum/runtime/executors/control_flow/loop_executor.py +308 -0
- quantum/runtime/executors/control_flow/redirect_executor.py +43 -0
- quantum/runtime/executors/control_flow/set_executor.py +464 -0
- quantum/runtime/executors/data/__init__.py +12 -0
- quantum/runtime/executors/data/data_executor.py +156 -0
- quantum/runtime/executors/data/invoke_executor.py +228 -0
- quantum/runtime/executors/data/query_executor.py +290 -0
- quantum/runtime/executors/data/transaction_executor.py +91 -0
- quantum/runtime/executors/jobs/__init__.py +11 -0
- quantum/runtime/executors/jobs/job_executor.py +190 -0
- quantum/runtime/executors/jobs/schedule_executor.py +132 -0
- quantum/runtime/executors/jobs/thread_executor.py +127 -0
- quantum/runtime/executors/messaging/__init__.py +17 -0
- quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
- quantum/runtime/executors/messaging/message_executor.py +174 -0
- quantum/runtime/executors/messaging/queue_executor.py +103 -0
- quantum/runtime/executors/messaging/websocket_executor.py +197 -0
- quantum/runtime/executors/scripting/__init__.py +11 -0
- quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
- quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
- quantum/runtime/executors/scripting/python_executor.py +249 -0
- quantum/runtime/executors/services/__init__.py +12 -0
- quantum/runtime/executors/services/dump_executor.py +72 -0
- quantum/runtime/executors/services/file_executor.py +89 -0
- quantum/runtime/executors/services/log_executor.py +77 -0
- quantum/runtime/executors/services/mail_executor.py +81 -0
- quantum/runtime/expression_cache.py +498 -0
- quantum/runtime/file_upload_service.py +326 -0
- quantum/runtime/function_registry.py +118 -0
- quantum/runtime/game_builder.py +166 -0
- quantum/runtime/game_code_generator.py +2371 -0
- quantum/runtime/game_templates.py +2006 -0
- quantum/runtime/godot_code_generator.py +4681 -0
- quantum/runtime/godot_templates.py +1449 -0
- quantum/runtime/job_executor.py +1599 -0
- quantum/runtime/knowledge_service.py +546 -0
- quantum/runtime/llm_cache.py +100 -0
- quantum/runtime/llm_providers.py +704 -0
- quantum/runtime/llm_service.py +287 -0
- quantum/runtime/local_server.py +28 -0
- quantum/runtime/logging_setup.py +141 -0
- quantum/runtime/message_broker.py +364 -0
- quantum/runtime/message_queue_service.py +571 -0
- quantum/runtime/param_validation.py +218 -0
- quantum/runtime/pypy_compat.py +317 -0
- quantum/runtime/python_bridge.py +698 -0
- quantum/runtime/query_validators.py +304 -0
- quantum/runtime/renderer.py +734 -0
- quantum/runtime/service_container.py +470 -0
- quantum/runtime/table_params.py +163 -0
- quantum/runtime/terminal_builder.py +76 -0
- quantum/runtime/terminal_code_generator.py +607 -0
- quantum/runtime/terminal_templates.py +243 -0
- quantum/runtime/testing_builder.py +77 -0
- quantum/runtime/testing_code_generator.py +833 -0
- quantum/runtime/testing_templates.py +85 -0
- quantum/runtime/ui_builder.py +222 -0
- quantum/runtime/ui_console.py +405 -0
- quantum/runtime/ui_desktop.py +56 -0
- quantum/runtime/ui_form_rules.py +193 -0
- quantum/runtime/ui_html_adapter.py +2567 -0
- quantum/runtime/ui_html_templates.py +2106 -0
- quantum/runtime/ui_mobile_adapter.py +1832 -0
- quantum/runtime/ui_mobile_templates.py +1003 -0
- quantum/runtime/ui_page_renderer.py +392 -0
- quantum/runtime/ui_pager.py +73 -0
- quantum/runtime/ui_table_edit.py +139 -0
- quantum/runtime/ui_textual_adapter.py +1866 -0
- quantum/runtime/ui_textual_templates.py +45 -0
- quantum/runtime/ui_tokens.py +465 -0
- quantum/runtime/ui_validator.py +365 -0
- quantum/runtime/ui_view.py +221 -0
- quantum/runtime/validators.py +256 -0
- quantum/runtime/web_server.py +2131 -0
- quantum/runtime/websocket_adapter.py +501 -0
- quantum/runtime/websocket_service.py +585 -0
- quantum/runtime/websocket_transport.py +289 -0
- quantum/runtime/wsgi.py +101 -0
- quantum/services.py +109 -0
- quantum/utils/__init__.py +1 -0
- quantum_admin/__init__.py +4 -0
- quantum_admin/assets/quantum-admin-connectors.js +123 -0
- quantum_admin/assets/quantum-admin.css +2058 -0
- quantum_admin/assets/quantum-source-highlight.js +106 -0
- quantum_admin/backend/__init__.py +4 -0
- quantum_admin/backend/audit_service.py +346 -0
- quantum_admin/backend/auth_service.py +394 -0
- quantum_admin/backend/component_discovery.py +766 -0
- quantum_admin/backend/config_generator.py +586 -0
- quantum_admin/backend/connector_service.py +1016 -0
- quantum_admin/backend/crud.py +557 -0
- quantum_admin/backend/database.py +226 -0
- quantum_admin/backend/db_setup_service.py +193 -0
- quantum_admin/backend/deploy_service.py +698 -0
- quantum_admin/backend/docker_service.py +422 -0
- quantum_admin/backend/environment_service.py +418 -0
- quantum_admin/backend/git_service.py +370 -0
- quantum_admin/backend/health_service.py +390 -0
- quantum_admin/backend/job_service.py +347 -0
- quantum_admin/backend/log_service.py +331 -0
- quantum_admin/backend/main.py +14426 -0
- quantum_admin/backend/models.py +986 -0
- quantum_admin/backend/pipeline_service.py +812 -0
- quantum_admin/backend/resource_manager.py +1118 -0
- quantum_admin/backend/schemas.py +419 -0
- quantum_admin/backend/secret_manager.py +277 -0
- quantum_admin/backend/settings_service.py +488 -0
- quantum_admin/backend/template_service.py +710 -0
- quantum_admin/backend/test_execution_service.py +364 -0
- quantum_admin/backend/test_generator.py +782 -0
- quantum_admin/backend/webhook_service.py +454 -0
- quantum_admin/backend/websocket_manager.py +360 -0
- quantum_admin/components/admin/_layout/AdminShell.q +121 -0
- quantum_admin/components/admin/agents.q +107 -0
- quantum_admin/components/admin/app/[name].q +558 -0
- quantum_admin/components/admin/applications.q +160 -0
- quantum_admin/components/admin/component/[...path].q +205 -0
- quantum_admin/components/admin/components.q +59 -0
- quantum_admin/components/admin/connectors.q +283 -0
- quantum_admin/components/admin/dashboard.q +86 -0
- quantum_admin/components/admin/database.q +95 -0
- quantum_admin/components/admin/features.q +69 -0
- quantum_admin/components/admin/index.q +12 -0
- quantum_admin/components/admin/jobs.q +75 -0
- quantum_admin/components/admin/login.q +65 -0
- quantum_admin/components/admin/logout.q +11 -0
- quantum_admin/components/admin/settings.q +174 -0
- quantum_admin/components/admin/source.q +45 -0
- quantum_admin/components/admin/tests.q +73 -0
- quantum_admin/run.py +13 -0
- quantum_admin/services/__init__.py +16 -0
- quantum_admin/services/_base.py +84 -0
- quantum_admin/services/_component_test_generator.py +205 -0
- quantum_admin/services/_yaml_editor.py +82 -0
- quantum_admin/services/apps.py +330 -0
- quantum_admin/services/auth.py +61 -0
- quantum_admin/services/components.py +210 -0
- quantum_admin/services/connectors.py +164 -0
- quantum_admin/services/projects.py +189 -0
- quantum_admin/services/repository.py +223 -0
- quantum_admin/services/settings.py +150 -0
- quantum_admin/services/yaml_import.py +118 -0
- quantum_framework-0.18.0.dist-info/METADATA +281 -0
- quantum_framework-0.18.0.dist-info/RECORD +333 -0
- quantum_framework-0.18.0.dist-info/WHEEL +5 -0
- quantum_framework-0.18.0.dist-info/entry_points.txt +2 -0
- quantum_framework-0.18.0.dist-info/licenses/LICENSE +21 -0
- quantum_framework-0.18.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The websocket transport that q:websocket never had.
|
|
3
|
+
|
|
4
|
+
Before this, `quantum/runtime/websocket_service.py` was pure bookkeeping: it
|
|
5
|
+
created a connection record in state CONNECTING, nothing ever moved it to
|
|
6
|
+
OPEN, and send_message — which only queues when the state is OPEN — always
|
|
7
|
+
returned False into a list nothing drained. The tag returned a
|
|
8
|
+
connection-shaped dict for a connection that did not exist.
|
|
9
|
+
|
|
10
|
+
This opens real client connections with the `websockets` library, on a
|
|
11
|
+
background asyncio loop, and drives the state machine and the handlers the
|
|
12
|
+
service already defines. It is deliberately a CLIENT transport: `q:websocket
|
|
13
|
+
url="wss://..."` connects a Quantum app to somewhere else, which is what the
|
|
14
|
+
tag's url= attribute always meant.
|
|
15
|
+
|
|
16
|
+
Optional by design: if `websockets` is not installed the service keeps its
|
|
17
|
+
honest stub behaviour and says so, rather than failing to import.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import threading
|
|
24
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
25
|
+
from typing import Any, Callable, Dict, Optional
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger('quantum.websocket')
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
import websockets as _ws
|
|
31
|
+
HAS_WEBSOCKETS = True
|
|
32
|
+
except ImportError: # pragma: no cover - environment dependent
|
|
33
|
+
_ws = None
|
|
34
|
+
HAS_WEBSOCKETS = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WebSocketTransport:
|
|
38
|
+
"""Owns one background event loop and the live client connections on it."""
|
|
39
|
+
|
|
40
|
+
def __init__(self):
|
|
41
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
42
|
+
self._thread: Optional[threading.Thread] = None
|
|
43
|
+
self._sockets: Dict[str, Any] = {} # connection id -> protocol
|
|
44
|
+
self._lock = threading.RLock()
|
|
45
|
+
# q:websocket handlers are Quantum bodies: they run a component
|
|
46
|
+
# context and can block for a while. Running them on the event loop
|
|
47
|
+
# would stall every other connection, so they go to a worker thread.
|
|
48
|
+
#
|
|
49
|
+
# UM executor POR CONEXAO, com um unico worker cada.
|
|
50
|
+
#
|
|
51
|
+
# Era um executor unico com max_workers=1 para o transporte inteiro,
|
|
52
|
+
# justificado por "one, so message order is preserved". A ordem por
|
|
53
|
+
# conexao esta certa; o alcance nao estava. Com um so worker, um
|
|
54
|
+
# handler on-message lento de UMA conexao segurava a fila de TODAS —
|
|
55
|
+
# dez conexoes, e um <q:query> de 2s numa delas atrasava as outras
|
|
56
|
+
# nove em 2s cada. Nada em q:websocket ordena mensagens de conexoes
|
|
57
|
+
# diferentes entre si, entao nao ha ordem a preservar ali.
|
|
58
|
+
#
|
|
59
|
+
# Um worker por conexao mantem a ordem onde ela existe e isola as
|
|
60
|
+
# conexoes umas das outras.
|
|
61
|
+
self._dispatchers: Dict[str, ThreadPoolExecutor] = {}
|
|
62
|
+
self._closed = False
|
|
63
|
+
|
|
64
|
+
def _dispatcher_for(self, conn_id: str) -> Optional[ThreadPoolExecutor]:
|
|
65
|
+
with self._lock:
|
|
66
|
+
pool = self._dispatchers.get(conn_id)
|
|
67
|
+
if pool is not None:
|
|
68
|
+
# Um worker que JA existe continua aceitando trabalho mesmo
|
|
69
|
+
# durante o shutdown: e por ele que passa o on_close de cada
|
|
70
|
+
# conexao sendo fechada. Recusar aqui faria o corpo
|
|
71
|
+
# `on-close` do app nunca rodar num encerramento — que e
|
|
72
|
+
# justamente quando ele importa.
|
|
73
|
+
return pool
|
|
74
|
+
if self._closed:
|
|
75
|
+
return None
|
|
76
|
+
pool = ThreadPoolExecutor(
|
|
77
|
+
max_workers=1,
|
|
78
|
+
thread_name_prefix=f'quantum-ws-{conn_id[:8]}')
|
|
79
|
+
self._dispatchers[conn_id] = pool
|
|
80
|
+
return pool
|
|
81
|
+
|
|
82
|
+
def _fire(self, conn_id: str, fn: Callable, *args) -> None:
|
|
83
|
+
"""Run a user handler off the event loop, never letting it escape."""
|
|
84
|
+
def guarded():
|
|
85
|
+
try:
|
|
86
|
+
fn(*args)
|
|
87
|
+
except Exception:
|
|
88
|
+
logger.exception("q:websocket handler raised")
|
|
89
|
+
|
|
90
|
+
pool = self._dispatcher_for(conn_id)
|
|
91
|
+
if pool is None:
|
|
92
|
+
return
|
|
93
|
+
try:
|
|
94
|
+
pool.submit(guarded)
|
|
95
|
+
except RuntimeError: # pool already shut down
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
# -- loop lifecycle ----------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def _ensure_loop(self) -> asyncio.AbstractEventLoop:
|
|
101
|
+
with self._lock:
|
|
102
|
+
# Um shutdown() nao pode aposentar o transporte: get_transport()
|
|
103
|
+
# devolve um singleton de processo, e reconectar depois de fechar
|
|
104
|
+
# tudo e uso normal.
|
|
105
|
+
self._closed = False
|
|
106
|
+
if self._loop and self._loop.is_running():
|
|
107
|
+
return self._loop
|
|
108
|
+
self._loop = asyncio.new_event_loop()
|
|
109
|
+
|
|
110
|
+
def run():
|
|
111
|
+
asyncio.set_event_loop(self._loop)
|
|
112
|
+
self._loop.run_forever()
|
|
113
|
+
|
|
114
|
+
self._thread = threading.Thread(
|
|
115
|
+
target=run, name="quantum-websocket", daemon=True)
|
|
116
|
+
self._thread.start()
|
|
117
|
+
return self._loop
|
|
118
|
+
|
|
119
|
+
def shutdown(self) -> None:
|
|
120
|
+
# As conexoes sao fechadas FORA do lock.
|
|
121
|
+
#
|
|
122
|
+
# Antes o laco inteiro rodava com self._lock preso. close() espera o
|
|
123
|
+
# `sock.close()` terminar no event loop — e o pump() daquela conexao,
|
|
124
|
+
# ao terminar, faz `with self._lock` no finally, na thread do loop.
|
|
125
|
+
# Com o lock preso aqui, o loop parava no finally, `sock.close()`
|
|
126
|
+
# nunca completava, e cada close() gastava os 5s inteiros do timeout
|
|
127
|
+
# antes de desistir. Cinco conexoes abertas = 25 segundos de shutdown.
|
|
128
|
+
with self._lock:
|
|
129
|
+
self._closed = True
|
|
130
|
+
conn_ids = list(self._sockets)
|
|
131
|
+
|
|
132
|
+
# Fechar PRIMEIRO, com os workers ainda vivos: o on_close de cada
|
|
133
|
+
# conexao roda no worker dela, e o pump, ao terminar, aposenta esse
|
|
134
|
+
# worker sozinho. Recolher os executores antes deixaria o corpo
|
|
135
|
+
# `on-close` do app sem ninguem para executa-lo.
|
|
136
|
+
for conn_id in conn_ids:
|
|
137
|
+
self.close(conn_id)
|
|
138
|
+
|
|
139
|
+
with self._lock:
|
|
140
|
+
if self._loop and self._loop.is_running():
|
|
141
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
142
|
+
self._loop = None
|
|
143
|
+
self._thread = None
|
|
144
|
+
restantes = list(self._dispatchers.values())
|
|
145
|
+
self._dispatchers.clear()
|
|
146
|
+
|
|
147
|
+
# E os workers de handler tambem morrem. Ficavam vivos para sempre:
|
|
148
|
+
# cada reset_transport() em teste deixava mais uma thread
|
|
149
|
+
# 'quantum-ws-handler' parada no processo, e um app que fechasse suas
|
|
150
|
+
# conexoes nunca recuperava a thread.
|
|
151
|
+
for pool in restantes:
|
|
152
|
+
pool.shutdown(wait=False)
|
|
153
|
+
|
|
154
|
+
# -- connecting --------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def connect(
|
|
157
|
+
self,
|
|
158
|
+
conn_id: str,
|
|
159
|
+
url: str,
|
|
160
|
+
on_open: Callable[[], None],
|
|
161
|
+
on_message: Callable[[Any], None],
|
|
162
|
+
on_close: Callable[[], None],
|
|
163
|
+
on_error: Callable[[Exception], None],
|
|
164
|
+
timeout: float = 10.0,
|
|
165
|
+
):
|
|
166
|
+
"""Open a real connection. Returns (ok, error).
|
|
167
|
+
|
|
168
|
+
Blocks up to `timeout` so q:websocket can report a truthful state
|
|
169
|
+
instead of leaving the caller with a record that says CONNECTING
|
|
170
|
+
forever — and returns the failure reason with the answer, because
|
|
171
|
+
the on_error callback lands on the handler thread later and the
|
|
172
|
+
caller needs it now.
|
|
173
|
+
"""
|
|
174
|
+
if not HAS_WEBSOCKETS:
|
|
175
|
+
logger.warning(
|
|
176
|
+
"q:websocket needs the 'websockets' package to open a real "
|
|
177
|
+
"connection (pip install websockets). Leaving %s unconnected.",
|
|
178
|
+
url
|
|
179
|
+
)
|
|
180
|
+
return False, "the 'websockets' package is not installed"
|
|
181
|
+
|
|
182
|
+
loop = self._ensure_loop()
|
|
183
|
+
opened = threading.Event()
|
|
184
|
+
result = {'ok': False, 'error': None}
|
|
185
|
+
|
|
186
|
+
async def pump():
|
|
187
|
+
try:
|
|
188
|
+
async with _ws.connect(url, open_timeout=timeout) as sock:
|
|
189
|
+
with self._lock:
|
|
190
|
+
self._sockets[conn_id] = sock
|
|
191
|
+
result['ok'] = True
|
|
192
|
+
opened.set()
|
|
193
|
+
self._fire(conn_id, on_open)
|
|
194
|
+
async for raw in sock:
|
|
195
|
+
# JSON when it parses, raw text otherwise — the same
|
|
196
|
+
# rule the send side uses.
|
|
197
|
+
try:
|
|
198
|
+
payload = json.loads(raw)
|
|
199
|
+
except (ValueError, TypeError):
|
|
200
|
+
payload = raw
|
|
201
|
+
self._fire(conn_id, on_message, payload)
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
result['error'] = exc
|
|
204
|
+
opened.set()
|
|
205
|
+
self._fire(conn_id, on_error, exc)
|
|
206
|
+
finally:
|
|
207
|
+
with self._lock:
|
|
208
|
+
self._sockets.pop(conn_id, None)
|
|
209
|
+
self._fire(conn_id, on_close)
|
|
210
|
+
# O worker desta conexao acaba com ela. shutdown(wait=False)
|
|
211
|
+
# nao cancela o que ja foi submetido, entao o on_close acima
|
|
212
|
+
# ainda roda; so nao fica uma thread parada por conexao ja
|
|
213
|
+
# encerrada.
|
|
214
|
+
with self._lock:
|
|
215
|
+
pool = self._dispatchers.pop(conn_id, None)
|
|
216
|
+
if pool is not None:
|
|
217
|
+
pool.shutdown(wait=False)
|
|
218
|
+
|
|
219
|
+
asyncio.run_coroutine_threadsafe(pump(), loop)
|
|
220
|
+
if not opened.wait(timeout=timeout + 1):
|
|
221
|
+
logger.warning("q:websocket: connecting to %s timed out", url)
|
|
222
|
+
return False, f"connection to {url} timed out"
|
|
223
|
+
|
|
224
|
+
if result['error'] is not None:
|
|
225
|
+
logger.warning("q:websocket could not connect to %s: %s",
|
|
226
|
+
url, result['error'])
|
|
227
|
+
return False, str(result['error']) or type(result['error']).__name__
|
|
228
|
+
return bool(result['ok']), None
|
|
229
|
+
|
|
230
|
+
# -- sending -----------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def send(self, conn_id: str, data: Any, timeout: float = 5.0) -> bool:
|
|
233
|
+
"""Actually transmit. Returns True only when the frame was written."""
|
|
234
|
+
with self._lock:
|
|
235
|
+
sock = self._sockets.get(conn_id)
|
|
236
|
+
if sock is None or self._loop is None:
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
payload = data if isinstance(data, (str, bytes)) else json.dumps(
|
|
240
|
+
data, default=str)
|
|
241
|
+
fut = asyncio.run_coroutine_threadsafe(sock.send(payload), self._loop)
|
|
242
|
+
try:
|
|
243
|
+
fut.result(timeout=timeout)
|
|
244
|
+
return True
|
|
245
|
+
except Exception as exc:
|
|
246
|
+
logger.warning("q:websocket send failed on %s: %s", conn_id, exc)
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
# -- closing -----------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
def close(self, conn_id: str, timeout: float = 5.0) -> bool:
|
|
252
|
+
with self._lock:
|
|
253
|
+
sock = self._sockets.get(conn_id)
|
|
254
|
+
if sock is None or self._loop is None:
|
|
255
|
+
return False
|
|
256
|
+
fut = asyncio.run_coroutine_threadsafe(sock.close(), self._loop)
|
|
257
|
+
try:
|
|
258
|
+
fut.result(timeout=timeout)
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
with self._lock:
|
|
262
|
+
self._sockets.pop(conn_id, None)
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
def is_open(self, conn_id: str) -> bool:
|
|
266
|
+
with self._lock:
|
|
267
|
+
return conn_id in self._sockets
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
_transport: Optional[WebSocketTransport] = None
|
|
271
|
+
_transport_lock = threading.Lock()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def get_transport() -> WebSocketTransport:
|
|
275
|
+
"""The process-wide transport (one event loop for every connection)."""
|
|
276
|
+
global _transport
|
|
277
|
+
with _transport_lock:
|
|
278
|
+
if _transport is None:
|
|
279
|
+
_transport = WebSocketTransport()
|
|
280
|
+
return _transport
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def reset_transport() -> None:
|
|
284
|
+
"""Drop the transport, closing whatever it holds (tests)."""
|
|
285
|
+
global _transport
|
|
286
|
+
with _transport_lock:
|
|
287
|
+
if _transport is not None:
|
|
288
|
+
_transport.shutdown()
|
|
289
|
+
_transport = None
|
quantum/runtime/wsgi.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Quantum WSGI Entry Point
|
|
3
|
+
|
|
4
|
+
Factory for creating Flask app instances for Gunicorn deployment.
|
|
5
|
+
Loads configuration from environment or config file.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# Ensure src directory is in path
|
|
13
|
+
|
|
14
|
+
from quantum.runtime.web_server import QuantumWebServer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _run_app_startup():
|
|
18
|
+
"""
|
|
19
|
+
Run app-specific startup.py if present.
|
|
20
|
+
|
|
21
|
+
This allows apps to initialize databases, apply runtime patches,
|
|
22
|
+
and perform other setup before the web server starts.
|
|
23
|
+
The startup module should define a run() function.
|
|
24
|
+
|
|
25
|
+
Note that this executes a Python file from `/app` or from the CURRENT
|
|
26
|
+
DIRECTORY, and that `app = create_app()` at the bottom of this module
|
|
27
|
+
runs it at IMPORT time. Importing this module therefore executes the
|
|
28
|
+
working directory's startup.py. That is the documented Docker convention
|
|
29
|
+
and it is kept, but it is loud now: silently executing a file because it
|
|
30
|
+
happens to sit in the directory you launched from is the kind of thing
|
|
31
|
+
that should say so.
|
|
32
|
+
"""
|
|
33
|
+
startup_path = Path('/app/startup.py')
|
|
34
|
+
if not startup_path.exists():
|
|
35
|
+
# Also check current working directory
|
|
36
|
+
startup_path = Path('startup.py')
|
|
37
|
+
if startup_path.exists():
|
|
38
|
+
print(f"[WSGI] executing {startup_path.resolve()}", flush=True)
|
|
39
|
+
import importlib.util
|
|
40
|
+
spec = importlib.util.spec_from_file_location('app_startup', str(startup_path))
|
|
41
|
+
mod = importlib.util.module_from_spec(spec)
|
|
42
|
+
spec.loader.exec_module(mod)
|
|
43
|
+
if hasattr(mod, 'run'):
|
|
44
|
+
mod.run()
|
|
45
|
+
print("[WSGI] App startup.py executed", flush=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def create_app(config_path: str = None) -> 'Flask':
|
|
49
|
+
"""
|
|
50
|
+
Create and configure the Quantum Flask application.
|
|
51
|
+
|
|
52
|
+
This is the factory function used by Gunicorn.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
config_path: Path to quantum.config.yaml. If not provided,
|
|
56
|
+
uses QUANTUM_CONFIG env var or default path.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Configured Flask application instance
|
|
60
|
+
"""
|
|
61
|
+
# Run app startup hooks before creating the server
|
|
62
|
+
_run_app_startup()
|
|
63
|
+
|
|
64
|
+
# Determine config path
|
|
65
|
+
if config_path is None:
|
|
66
|
+
config_path = os.environ.get('QUANTUM_CONFIG', '/app/quantum.config.yaml')
|
|
67
|
+
|
|
68
|
+
# Check for app-specific config first
|
|
69
|
+
if os.path.exists(config_path):
|
|
70
|
+
server = QuantumWebServer(config_path)
|
|
71
|
+
else:
|
|
72
|
+
# Fall back to default config
|
|
73
|
+
server = QuantumWebServer()
|
|
74
|
+
|
|
75
|
+
return server.app
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Create app instance for Gunicorn
|
|
79
|
+
#
|
|
80
|
+
# Usage: gunicorn "quantum.runtime.wsgi:app"
|
|
81
|
+
#
|
|
82
|
+
# This said `src.runtime.wsgi:app` — the layout that stopped existing when
|
|
83
|
+
# `src/` became the `quantum/` package. The command in the one file whose
|
|
84
|
+
# entire purpose is being that command did not import. (web_server.py's
|
|
85
|
+
# docstring had the same defect; this file was missed.)
|
|
86
|
+
app = create_app()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Also support: gunicorn "quantum.runtime.wsgi:create_app()"
|
|
90
|
+
def get_app():
|
|
91
|
+
"""Alternative factory for Gunicorn."""
|
|
92
|
+
return create_app()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == '__main__':
|
|
96
|
+
# For local testing
|
|
97
|
+
app.run(
|
|
98
|
+
host=os.environ.get('QUANTUM_HOST', '0.0.0.0'),
|
|
99
|
+
port=int(os.environ.get('QUANTUM_PORT', 8080)),
|
|
100
|
+
debug=os.environ.get('QUANTUM_DEBUG', 'false').lower() == 'true'
|
|
101
|
+
)
|
quantum/services.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Declared services: Python functions a .q file calls by name (SVC-1..3).
|
|
3
|
+
|
|
4
|
+
# myapp/services.py
|
|
5
|
+
from quantum.services import service
|
|
6
|
+
|
|
7
|
+
@service("projects.list")
|
|
8
|
+
def list_projects(status: str = "active"):
|
|
9
|
+
return [...]
|
|
10
|
+
|
|
11
|
+
# quantum.config.yaml
|
|
12
|
+
services:
|
|
13
|
+
- myapp.services
|
|
14
|
+
|
|
15
|
+
<!-- components/projects.q -->
|
|
16
|
+
<q:invoke name="projects" service="projects.list">
|
|
17
|
+
<q:param name="status" value="active" />
|
|
18
|
+
</q:invoke>
|
|
19
|
+
|
|
20
|
+
This is the alternative to <q:python> for logic that belongs in Python — file
|
|
21
|
+
system, processes, third-party APIs. The Python is written, reviewed and tested
|
|
22
|
+
as ordinary Python; the page only names what it calls. A module runs only when
|
|
23
|
+
quantum.config.yaml lists it: nothing is discovered or imported implicitly.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import importlib
|
|
27
|
+
import threading
|
|
28
|
+
from typing import Any, Callable, Dict, Iterable
|
|
29
|
+
|
|
30
|
+
_registry: Dict[str, Callable[..., Any]] = {}
|
|
31
|
+
_loaded_modules: set = set()
|
|
32
|
+
_lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ServiceError(Exception):
|
|
36
|
+
"""A declared service could not be registered, loaded or found."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def service(name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
40
|
+
"""Register a function under `name` (SVC-1). Returns the function unchanged."""
|
|
41
|
+
if not isinstance(name, str) or not name.strip():
|
|
42
|
+
raise ServiceError("@service needs a name, like @service('projects.list')")
|
|
43
|
+
|
|
44
|
+
def register(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
45
|
+
with _lock:
|
|
46
|
+
existing = _registry.get(name)
|
|
47
|
+
if existing is not None and existing is not func and \
|
|
48
|
+
(existing.__module__, existing.__qualname__) != (func.__module__, func.__qualname__):
|
|
49
|
+
raise ServiceError(
|
|
50
|
+
f"service '{name}' is already registered by "
|
|
51
|
+
f"{existing.__module__}.{existing.__qualname__}")
|
|
52
|
+
_registry[name] = func
|
|
53
|
+
return func
|
|
54
|
+
|
|
55
|
+
return register
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load(modules: Iterable[str], project_dir: str = None) -> None:
|
|
59
|
+
"""Import the modules listed under `services:` in quantum.config.yaml (SVC-2).
|
|
60
|
+
|
|
61
|
+
The project folder goes on sys.path first: the `quantum` console script,
|
|
62
|
+
unlike `python -m`, does not put the current directory there, so
|
|
63
|
+
`services: [myapp.services]` could not be imported by `quantum start`.
|
|
64
|
+
"""
|
|
65
|
+
import os
|
|
66
|
+
import sys
|
|
67
|
+
if isinstance(modules, str):
|
|
68
|
+
modules = [modules]
|
|
69
|
+
raiz = os.path.abspath(project_dir or os.getcwd())
|
|
70
|
+
if modules and raiz not in sys.path:
|
|
71
|
+
sys.path.insert(0, raiz)
|
|
72
|
+
for module in modules or []:
|
|
73
|
+
if module in _loaded_modules:
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
ja_importado = module in sys.modules
|
|
77
|
+
modulo = importlib.import_module(module)
|
|
78
|
+
if ja_importado and not any(f.__module__ == module for f in _registry.values()):
|
|
79
|
+
# Imported before the registry was cleared (or before it
|
|
80
|
+
# existed): its @service decorators already ran and would not
|
|
81
|
+
# run again on a plain import.
|
|
82
|
+
importlib.reload(modulo)
|
|
83
|
+
except ImportError as exc:
|
|
84
|
+
raise ServiceError(
|
|
85
|
+
f"quantum.config.yaml lists service module '{module}', which could not "
|
|
86
|
+
f"be imported: {exc}") from exc
|
|
87
|
+
_loaded_modules.add(module)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get(name: str) -> Callable[..., Any]:
|
|
91
|
+
"""The function registered as `name`, or an error listing what exists (SVC-3)."""
|
|
92
|
+
func = _registry.get(name)
|
|
93
|
+
if func is None:
|
|
94
|
+
disponiveis = ', '.join(sorted(_registry)) or 'none'
|
|
95
|
+
raise ServiceError(
|
|
96
|
+
f"no service named '{name}' (registered: {disponiveis}). Declare it with "
|
|
97
|
+
f"@service('{name}') in a module listed under services: in quantum.config.yaml")
|
|
98
|
+
return func
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def registered() -> Dict[str, Callable[..., Any]]:
|
|
102
|
+
return dict(_registry)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _reset() -> None:
|
|
106
|
+
"""Tests only."""
|
|
107
|
+
with _lock:
|
|
108
|
+
_registry.clear()
|
|
109
|
+
_loaded_modules.clear()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Utils - placeholder para futuras utilidades
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quantum Admin - Connectors Page
|
|
3
|
+
* Dynamic Provider <-> Type filtering
|
|
4
|
+
*/
|
|
5
|
+
(function () {
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
var typeProviders = {
|
|
9
|
+
database: ['postgres', 'mysql', 'mariadb', 'mongodb', 'sqlite', 'influxdb'],
|
|
10
|
+
mq: ['rabbitmq', 'redis_queue', 'kafka'],
|
|
11
|
+
cache: ['redis', 'memcached'],
|
|
12
|
+
storage: ['s3', 'minio', 'local'],
|
|
13
|
+
ai: ['ollama', 'lmstudio', 'anthropic', 'openai', 'openrouter']
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Build reverse map: provider -> type
|
|
17
|
+
var providerType = {};
|
|
18
|
+
Object.keys(typeProviders).forEach(function (type) {
|
|
19
|
+
typeProviders[type].forEach(function (prov) {
|
|
20
|
+
providerType[prov] = type;
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
var typeSelect = document.querySelector('select[name="conn_type"]');
|
|
25
|
+
var providerSelect = document.querySelector('select[name="provider"]');
|
|
26
|
+
|
|
27
|
+
if (!typeSelect || !providerSelect) return;
|
|
28
|
+
|
|
29
|
+
// Store original options with their optgroup labels
|
|
30
|
+
var allOptions = [];
|
|
31
|
+
var optgroups = providerSelect.querySelectorAll('optgroup');
|
|
32
|
+
optgroups.forEach(function (og) {
|
|
33
|
+
var label = og.getAttribute('label') || '';
|
|
34
|
+
var opts = og.querySelectorAll('option');
|
|
35
|
+
opts.forEach(function (opt) {
|
|
36
|
+
allOptions.push({ value: opt.value, text: opt.textContent, group: label });
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
var syncing = false;
|
|
41
|
+
|
|
42
|
+
function rebuildProviderOptions(allowedType) {
|
|
43
|
+
// Remove everything except the placeholder
|
|
44
|
+
while (providerSelect.options.length > 1) {
|
|
45
|
+
providerSelect.remove(1);
|
|
46
|
+
}
|
|
47
|
+
// Remove old optgroups
|
|
48
|
+
var oldGroups = providerSelect.querySelectorAll('optgroup');
|
|
49
|
+
oldGroups.forEach(function (g) { g.remove(); });
|
|
50
|
+
|
|
51
|
+
if (!allowedType) {
|
|
52
|
+
// Show all grouped
|
|
53
|
+
var groups = {};
|
|
54
|
+
allOptions.forEach(function (o) {
|
|
55
|
+
if (!groups[o.group]) groups[o.group] = [];
|
|
56
|
+
groups[o.group].push(o);
|
|
57
|
+
});
|
|
58
|
+
Object.keys(groups).forEach(function (gName) {
|
|
59
|
+
var og = document.createElement('optgroup');
|
|
60
|
+
og.label = gName;
|
|
61
|
+
groups[gName].forEach(function (o) {
|
|
62
|
+
var opt = document.createElement('option');
|
|
63
|
+
opt.value = o.value;
|
|
64
|
+
opt.textContent = o.text;
|
|
65
|
+
og.appendChild(opt);
|
|
66
|
+
});
|
|
67
|
+
providerSelect.appendChild(og);
|
|
68
|
+
});
|
|
69
|
+
} else {
|
|
70
|
+
// Show only providers for this type, flat (no optgroup)
|
|
71
|
+
var allowed = typeProviders[allowedType] || [];
|
|
72
|
+
allOptions.forEach(function (o) {
|
|
73
|
+
if (allowed.indexOf(o.value) !== -1) {
|
|
74
|
+
var opt = document.createElement('option');
|
|
75
|
+
opt.value = o.value;
|
|
76
|
+
opt.textContent = o.text;
|
|
77
|
+
providerSelect.appendChild(opt);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
typeSelect.addEventListener('change', function () {
|
|
84
|
+
if (syncing) return;
|
|
85
|
+
syncing = true;
|
|
86
|
+
var selectedType = typeSelect.value;
|
|
87
|
+
var currentProvider = providerSelect.value;
|
|
88
|
+
rebuildProviderOptions(selectedType);
|
|
89
|
+
|
|
90
|
+
// Try to keep current provider if it belongs to the new type
|
|
91
|
+
if (currentProvider) {
|
|
92
|
+
var allowed = typeProviders[selectedType] || [];
|
|
93
|
+
if (allowed.indexOf(currentProvider) !== -1) {
|
|
94
|
+
providerSelect.value = currentProvider;
|
|
95
|
+
} else {
|
|
96
|
+
providerSelect.value = '';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
syncing = false;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
providerSelect.addEventListener('change', function () {
|
|
103
|
+
if (syncing) return;
|
|
104
|
+
syncing = true;
|
|
105
|
+
var prov = providerSelect.value;
|
|
106
|
+
if (prov && providerType[prov]) {
|
|
107
|
+
typeSelect.value = providerType[prov];
|
|
108
|
+
// Also filter providers to match the auto-selected type
|
|
109
|
+
rebuildProviderOptions(providerType[prov]);
|
|
110
|
+
providerSelect.value = prov;
|
|
111
|
+
}
|
|
112
|
+
syncing = false;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// On load: if type is pre-selected (edit mode), filter providers
|
|
116
|
+
if (typeSelect.value) {
|
|
117
|
+
var editProvider = providerSelect.value;
|
|
118
|
+
rebuildProviderOptions(typeSelect.value);
|
|
119
|
+
if (editProvider) {
|
|
120
|
+
providerSelect.value = editProvider;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
})();
|