From ae6713f0d20a52dc142ce971ca3b072e99c5b043 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 17 Aug 2026 21:09:27 -0400 Subject: [PATCH] Add read-only Yamcs mission control action (v0.250.209) Adds a first-class `yamcs` action type built on the official `yamcs-client` package, with its own configuration panel and Test Connection button in the Add/Edit Action modal. Exposes eleven read-only Semantic Kernel functions: list_instances, list_links, list_parameters, describe_parameter, list_commands, get_parameter_values, list_parameter_history, list_events, list_packets, list_alarms, and a gated execute_archive_sql. The action is strictly read-only. It cannot issue commands, set parameter values, run scripts, or change data link state. This is enforced three ways: only read functions are declared, `read_only` is forced true during manifest normalization, and archive SQL is disabled by default and allowlisted to SELECT/SHOW/DESC/DESCRIBE with a forbidden-keyword guard when enabled. `list_commands` returns mission database definitions only. Supports username/password, API key, bearer token, and unauthenticated Yamcs servers, plus reusable workspace identities. Results are bounded by row count, serialized byte limit, and request timeout, and error text is scrubbed of credentials before being returned or logged. Notes: - `yamcs-client==2.1.0` is LGPL-3.0, the first LGPL dependency in this repo. It is consumed as an unmodified, dynamically linked pip dependency. - It vendorizes its own protobuf runtime, verified not to conflict with the pinned protobuf==6.33.5. - Server URL normalization detects the scheme with an explicit ^https?:// check rather than urlparse, because urlparse misreads the common Yamcs `host:port` form as a URL scheme and the Yamcs client would then default a schemeless address to plain HTTP. Validation: - functional_tests/test_yamcs_action_plugin.py (14/14) - functional_tests/route_tests (12/12) - Snowflake and Tableau regression suites (9/9) - Credential mapping, client constructor, and protobuf coexistence verified against a real yamcs-client==2.1.0 install Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../single_app/functions_governance.py | 2 + application/single_app/functions_keyvault.py | 8 + .../functions_workspace_identities.py | 27 + .../single_app/functions_yamcs_operations.py | 162 ++++ application/single_app/requirements.txt | 1 + .../single_app/route_backend_plugins.py | 220 +++++ .../single_app/semantic_kernel_loader.py | 8 + .../logged_plugin_loader.py | 25 + .../plugin_health_checker.py | 67 +- .../semantic_kernel_plugins/yamcs_plugin.py | 818 +++++++++++++++++ .../yamcs_plugin_factory.py | 53 ++ .../static/js/plugin_modal_stepper.js | 405 ++++++++- .../static/js/workspace/view-utils.js | 1 + .../static/json/schemas/yamcs.definition.json | 3 + ...mcs_plugin.additional_settings.schema.json | 64 ++ .../single_app/templates/_plugin_modal.html | 169 ++++ docs/explanation/features/YAMCS_ACTION.md | 132 +++ docs/explanation/features/index.md | 2 + docs/explanation/release_notes.md | 27 + functional_tests/test_yamcs_action_plugin.py | 845 ++++++++++++++++++ 21 files changed, 3033 insertions(+), 8 deletions(-) create mode 100644 application/single_app/functions_yamcs_operations.py create mode 100644 application/single_app/semantic_kernel_plugins/yamcs_plugin.py create mode 100644 application/single_app/semantic_kernel_plugins/yamcs_plugin_factory.py create mode 100644 application/single_app/static/json/schemas/yamcs.definition.json create mode 100644 application/single_app/static/json/schemas/yamcs_plugin.additional_settings.schema.json create mode 100644 docs/explanation/features/YAMCS_ACTION.md create mode 100644 functional_tests/test_yamcs_action_plugin.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 4ce4fc421..675ef29d8 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -96,7 +96,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.208" +VERSION = "0.250.209" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_governance.py b/application/single_app/functions_governance.py index a7fae3fea..e394cffe8 100644 --- a/application/single_app/functions_governance.py +++ b/application/single_app/functions_governance.py @@ -81,6 +81,7 @@ "databricks": "databricks", "snowflake": "snowflake", "tableau": "tableau", + "yamcs": "yamcs", "chart": "chart", "azure_maps": "azure_maps", "blob_storage": "blob_storage", @@ -98,6 +99,7 @@ "databricks": "Databricks", "snowflake": "Snowflake", "tableau": "Tableau", + "yamcs": "Yamcs", "chart": "Chart", "azure_maps": "Azure Maps", "blob_storage": "Blob Storage", diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index 91cb024bd..fd781587c 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -12,6 +12,7 @@ import app_settings_cache from functions_mcp_operations import MCP_CUSTOM_HEADERS_FIELD, MCP_PLUGIN_TYPE from functions_snowflake_operations import SNOWFLAKE_PLUGIN_TYPE, SNOWFLAKE_SENSITIVE_ADDITIONAL_FIELDS +from functions_yamcs_operations import YAMCS_PLUGIN_TYPE, YAMCS_SENSITIVE_ADDITIONAL_FIELDS try: from azure.identity import DefaultAzureCredential @@ -407,6 +408,12 @@ def _is_snowflake_plugin(plugin_dict): return isinstance(plugin_type, str) and plugin_type.lower() == SNOWFLAKE_PLUGIN_TYPE +def _is_yamcs_plugin(plugin_dict): + """Return True when the plugin manifest is a Yamcs action.""" + plugin_type = (plugin_dict or {}).get("type", "") + return isinstance(plugin_type, str) and plugin_type.lower() == YAMCS_PLUGIN_TYPE + + def _is_mcp_plugin(plugin_dict): """Return True when the plugin manifest is an MCP action.""" plugin_type = (plugin_dict or {}).get("type", "") @@ -418,6 +425,7 @@ def _is_sensitive_plugin_additional_field(plugin_dict, field_name): return ( _is_sql_sensitive_additional_field(plugin_dict, field_name) or (_is_snowflake_plugin(plugin_dict) and field_name in SNOWFLAKE_SENSITIVE_ADDITIONAL_FIELDS) + or (_is_yamcs_plugin(plugin_dict) and field_name in YAMCS_SENSITIVE_ADDITIONAL_FIELDS) ) diff --git a/application/single_app/functions_workspace_identities.py b/application/single_app/functions_workspace_identities.py index d4ca34b65..3a43747dd 100644 --- a/application/single_app/functions_workspace_identities.py +++ b/application/single_app/functions_workspace_identities.py @@ -71,6 +71,8 @@ ACTION_IDENTITY_SNOWFLAKE_AUTH_TYPES = {"api_key", "bearer_token", "username_password"} ACTION_IDENTITY_TABLEAU_TYPES = {"tableau"} ACTION_IDENTITY_TABLEAU_AUTH_TYPES = {"api_key", "username_password"} +ACTION_IDENTITY_YAMCS_TYPES = {"yamcs"} +ACTION_IDENTITY_YAMCS_AUTH_TYPES = {"api_key", "bearer_token", "username_password"} def _now_iso() -> str: @@ -470,6 +472,8 @@ def _get_action_identity_auth_types_for_plugin(action_data: Dict[str, Any]) -> S return ACTION_IDENTITY_SNOWFLAKE_AUTH_TYPES if plugin_type in ACTION_IDENTITY_TABLEAU_TYPES: return ACTION_IDENTITY_TABLEAU_AUTH_TYPES + if plugin_type in ACTION_IDENTITY_YAMCS_TYPES: + return ACTION_IDENTITY_YAMCS_AUTH_TYPES return ACTION_IDENTITY_AUTH_TYPES @@ -527,6 +531,8 @@ def _apply_action_identity_auth(action_data: Dict[str, Any], identity_auth: Dict _apply_snowflake_action_identity_auth(action_auth, additional_fields, identity_auth) elif plugin_type in ACTION_IDENTITY_TABLEAU_TYPES: _apply_tableau_action_identity_auth(action_auth, additional_fields, identity_auth) + elif plugin_type in ACTION_IDENTITY_YAMCS_TYPES: + _apply_yamcs_action_identity_auth(action_auth, additional_fields, identity_auth) else: _apply_generic_action_identity_auth(action_auth, identity_auth) @@ -637,6 +643,27 @@ def _apply_snowflake_action_identity_auth( additional_fields["auth_method"] = "oauth" +def _apply_yamcs_action_identity_auth( + action_auth: Dict[str, Any], + additional_fields: Dict[str, Any], + identity_auth: Dict[str, Any], +) -> None: + auth_type = _normalize_text(identity_auth.get("auth_type"), 50).lower() + if auth_type == "username_password": + action_auth["type"] = "username_password" + action_auth["identity"] = identity_auth.get("username", "") + action_auth["key"] = identity_auth.get("password", "") + additional_fields["auth_method"] = "username_password" + elif auth_type == "api_key": + action_auth["type"] = "key" + action_auth["key"] = _identity_secret(identity_auth) + additional_fields["auth_method"] = "api_key" + elif auth_type == "bearer_token": + action_auth["type"] = "key" + action_auth["key"] = _identity_secret(identity_auth) + additional_fields["auth_method"] = "bearer_token" + + def _apply_generic_action_identity_auth(action_auth: Dict[str, Any], identity_auth: Dict[str, Any]) -> None: auth_type = _normalize_text(identity_auth.get("auth_type"), 50).lower() if auth_type == "api_key": diff --git a/application/single_app/functions_yamcs_operations.py b/application/single_app/functions_yamcs_operations.py new file mode 100644 index 000000000..f2435706f --- /dev/null +++ b/application/single_app/functions_yamcs_operations.py @@ -0,0 +1,162 @@ +# functions_yamcs_operations.py +"""Shared defaults and normalization helpers for Yamcs mission control action plugins.""" + +import re +from typing import Any, Dict, Optional + + +YAMCS_PLUGIN_TYPE = "yamcs" +YAMCS_DEFAULT_PROCESSOR = "realtime" +YAMCS_DEFAULT_PORT = 8090 +YAMCS_SCHEME_PATTERN = re.compile(r"^https?://", re.IGNORECASE) + +YAMCS_AUTH_METHOD_USERNAME_PASSWORD = "username_password" +YAMCS_AUTH_METHOD_API_KEY = "api_key" +YAMCS_AUTH_METHOD_BEARER_TOKEN = "bearer_token" +YAMCS_AUTH_METHOD_NONE = "none" +YAMCS_SUPPORTED_AUTH_METHODS = { + YAMCS_AUTH_METHOD_USERNAME_PASSWORD, + YAMCS_AUTH_METHOD_API_KEY, + YAMCS_AUTH_METHOD_BEARER_TOKEN, + YAMCS_AUTH_METHOD_NONE, +} +YAMCS_SUPPORTED_AUTH_TYPES = {"NoAuth", "key", "identity", "username_password"} + +# Yamcs archive SQL is a full engine that also supports DDL/DML. Only these leading +# keywords are accepted, and only when archive SQL is explicitly enabled. +YAMCS_ALLOWED_READ_STATEMENTS = { + "DESC", + "DESCRIBE", + "SELECT", + "SHOW", +} + +# Secrets always live in auth.key, but the constant keeps redaction plumbing symmetric +# with the other connector action types. +YAMCS_SENSITIVE_ADDITIONAL_FIELDS = { + "api_key", + "access_token", + "password", + "token", +} + +YAMCS_DEFAULT_MAX_ROWS = 500 +YAMCS_MIN_MAX_ROWS = 1 +YAMCS_MAX_MAX_ROWS = 5000 +YAMCS_DEFAULT_TIMEOUT = 30 +YAMCS_MIN_TIMEOUT = 1 +YAMCS_MAX_TIMEOUT = 300 +YAMCS_DEFAULT_BYTE_LIMIT = 250000 +YAMCS_MIN_BYTE_LIMIT = 1000 +YAMCS_MAX_BYTE_LIMIT = 2000000 + + +def _as_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value in [None, ""]: + return default + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +def _as_int(value: Any, default: int, minimum: int, maximum: int) -> int: + try: + parsed_value = int(value) + except (TypeError, ValueError): + parsed_value = default + return max(minimum, min(maximum, parsed_value)) + + +def normalize_yamcs_server_url(endpoint: Any) -> str: + """Normalize a Yamcs server base URL so the Yamcs client can derive TLS from the scheme. + + Yamcs addresses are commonly written as ``host:port``. ``urlparse`` misreads that form as + a scheme, so the scheme is detected explicitly rather than inferred from ``urlparse``. + """ + value = str(endpoint or "").strip().rstrip("/") + if not value: + return "" + + if not YAMCS_SCHEME_PATTERN.match(value): + value = f"https://{value}" + return value.rstrip("/") + + +def normalize_yamcs_auth_method( + additional_fields: Optional[Dict[str, Any]] = None, + auth_type: str = "username_password", +) -> str: + """Return the Yamcs auth method represented by a manifest.""" + fields = additional_fields if isinstance(additional_fields, dict) else {} + aliases = { + "apikey": YAMCS_AUTH_METHOD_API_KEY, + "api_key": YAMCS_AUTH_METHOD_API_KEY, + "bearer": YAMCS_AUTH_METHOD_BEARER_TOKEN, + "bearer_token": YAMCS_AUTH_METHOD_BEARER_TOKEN, + "access_token": YAMCS_AUTH_METHOD_BEARER_TOKEN, + "token": YAMCS_AUTH_METHOD_BEARER_TOKEN, + "noauth": YAMCS_AUTH_METHOD_NONE, + "none": YAMCS_AUTH_METHOD_NONE, + "anonymous": YAMCS_AUTH_METHOD_NONE, + "password": YAMCS_AUTH_METHOD_USERNAME_PASSWORD, + "username_password": YAMCS_AUTH_METHOD_USERNAME_PASSWORD, + } + + explicit_method = str(fields.get("auth_method") or "").strip().lower().replace("-", "_") + explicit_method = aliases.get(explicit_method, explicit_method) + if explicit_method in YAMCS_SUPPORTED_AUTH_METHODS: + return explicit_method + + identity_auth_type = str(fields.get("identity_auth_type") or "").strip().lower().replace("-", "_") + identity_auth_type = aliases.get(identity_auth_type, identity_auth_type) + if identity_auth_type in YAMCS_SUPPORTED_AUTH_METHODS: + return identity_auth_type + + normalized_auth_type = str(auth_type or "").strip() + if normalized_auth_type == "NoAuth": + return YAMCS_AUTH_METHOD_NONE + if normalized_auth_type == "key": + return YAMCS_AUTH_METHOD_API_KEY + return YAMCS_AUTH_METHOD_USERNAME_PASSWORD + + +def normalize_yamcs_additional_fields( + additional_fields: Optional[Dict[str, Any]] = None, + auth_type: str = "username_password", +) -> Dict[str, Any]: + """Normalize Yamcs additionalFields with bounded, read-only-safe defaults.""" + fields = dict(additional_fields or {}) if isinstance(additional_fields, dict) else {} + fields["server_url"] = normalize_yamcs_server_url( + fields.get("server_url") or fields.get("serverUrl") or "" + ) + fields["instance"] = str(fields.get("instance") or fields.get("yamcs_instance") or "").strip() + fields["processor"] = str( + fields.get("processor") or fields.get("yamcs_processor") or "" + ).strip() or YAMCS_DEFAULT_PROCESSOR + fields["auth_method"] = normalize_yamcs_auth_method(fields, auth_type=auth_type) + fields["tls_verify"] = _as_bool(fields.get("tls_verify"), default=True) + # Yamcs actions never issue commands or write parameters; the flag is stored for parity + # with the other connector action types and is always forced on. + fields["read_only"] = True + fields["enable_archive_sql"] = _as_bool(fields.get("enable_archive_sql"), default=False) + fields["max_rows"] = _as_int( + fields.get("max_rows"), + YAMCS_DEFAULT_MAX_ROWS, + YAMCS_MIN_MAX_ROWS, + YAMCS_MAX_MAX_ROWS, + ) + fields["timeout"] = _as_int( + fields.get("timeout"), + YAMCS_DEFAULT_TIMEOUT, + YAMCS_MIN_TIMEOUT, + YAMCS_MAX_TIMEOUT, + ) + fields["byte_limit"] = _as_int( + fields.get("byte_limit"), + YAMCS_DEFAULT_BYTE_LIMIT, + YAMCS_MIN_BYTE_LIMIT, + YAMCS_MAX_BYTE_LIMIT, + ) + return fields diff --git a/application/single_app/requirements.txt b/application/single_app/requirements.txt index 9a7d74f29..10e67d913 100644 --- a/application/single_app/requirements.txt +++ b/application/single_app/requirements.txt @@ -51,6 +51,7 @@ ffmpeg-python==0.2.0 semantic-kernel==1.39.4 snowflake-connector-python[pandas]==3.18.0 tableauserverclient==0.40 +yamcs-client==2.1.0 protobuf==6.33.5 redis==5.3.1 smbprotocol==1.15.0 diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index b480315d3..7e0ae59cb 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -83,6 +83,16 @@ normalize_tableau_additional_fields, normalize_tableau_server_url, ) +from functions_yamcs_operations import ( + YAMCS_AUTH_METHOD_API_KEY, + YAMCS_AUTH_METHOD_BEARER_TOKEN, + YAMCS_AUTH_METHOD_NONE, + YAMCS_AUTH_METHOD_USERNAME_PASSWORD, + YAMCS_DEFAULT_PROCESSOR, + YAMCS_PLUGIN_TYPE, + normalize_yamcs_additional_fields, + normalize_yamcs_server_url, +) from functions_mcp_operations import ( MCP_CUSTOM_HEADERS_FIELD, MCP_PLUGIN_TYPE, @@ -251,6 +261,27 @@ def _apply_plugin_runtime_defaults(plugin_payload): plugin_payload['type'] = TABLEAU_PLUGIN_TYPE plugin_payload['auth'] = auth plugin_payload['additionalFields'] = additional_fields + elif plugin_type == YAMCS_PLUGIN_TYPE: + auth = plugin_payload.get('auth') if isinstance(plugin_payload.get('auth'), dict) else {} + auth_type = str(auth.get('type') or 'username_password').strip() or 'username_password' + auth['type'] = auth_type + additional_fields = plugin_payload.get('additionalFields') if isinstance(plugin_payload.get('additionalFields'), dict) else {} + additional_fields = normalize_yamcs_additional_fields(additional_fields, auth_type=auth_type) + if auth_type == 'username_password': + additional_fields['auth_method'] = YAMCS_AUTH_METHOD_USERNAME_PASSWORD + elif auth_type == 'NoAuth': + additional_fields['auth_method'] = YAMCS_AUTH_METHOD_NONE + elif auth_type == 'key' and additional_fields.get('auth_method') == YAMCS_AUTH_METHOD_NONE: + additional_fields['auth_method'] = YAMCS_AUTH_METHOD_API_KEY + + endpoint = normalize_yamcs_server_url(plugin_payload.get('endpoint') or additional_fields.get('server_url') or '') + if endpoint: + plugin_payload['endpoint'] = endpoint + additional_fields['server_url'] = endpoint + + plugin_payload['type'] = YAMCS_PLUGIN_TYPE + plugin_payload['auth'] = auth + plugin_payload['additionalFields'] = additional_fields elif plugin_type == SIMPLECHAT_PLUGIN_TYPE: if not str(plugin_payload.get('endpoint') or '').strip(): plugin_payload['endpoint'] = SIMPLECHAT_DEFAULT_ENDPOINT @@ -391,6 +422,20 @@ def get_plugin_types(allowed_type_filter=None): }, 'metadata': {'description': 'Example Tableau plugin'}, } + elif 'yamcs' in module_name.lower(): + safe_manifest = { + 'endpoint': 'https://yamcs.example.com:8090', + 'auth': {'type': 'username_password', 'identity': 'operator', 'key': 'dummy'}, + 'additionalFields': { + 'server_url': 'https://yamcs.example.com:8090', + 'instance': 'simulator', + 'processor': YAMCS_DEFAULT_PROCESSOR, + 'auth_method': YAMCS_AUTH_METHOD_USERNAME_PASSWORD, + 'max_rows': 500, + 'timeout': 30, + }, + 'metadata': {'description': 'Example Yamcs mission control action'}, + } elif 'sql' in module_name.lower(): safe_manifest = { 'database_type': 'sqlite', @@ -2482,3 +2527,178 @@ def capture_response_headers(response_headers, _): 'success': False, 'error': 'Cosmos DB authentication failed or the account could not be reached. Verify the endpoint and the selected authentication settings.' }), 400 + + +@bpap.route('/api/plugins/test-yamcs-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_yamcs_connection(): + """Test a Yamcs mission control server connection using the configured credentials.""" + data = request.get_json(silent=True) or {} + user_id = get_current_user_id() + server_url = normalize_yamcs_server_url(data.get('server_url') or data.get('endpoint') or '') + instance = (data.get('instance') or '').strip() + auth_method = (data.get('auth_method') or YAMCS_AUTH_METHOD_USERNAME_PASSWORD).strip().lower() + username = (data.get('username') or '').strip() + auth_key = (data.get('auth_key') or '').strip() + tls_verify = data.get('tls_verify', True) + if isinstance(tls_verify, str): + tls_verify = tls_verify.strip().lower() in {'1', 'true', 'yes', 'on'} + timeout = min(max(int(data.get('timeout', 10) or 10), 1), 30) + + if not server_url: + return jsonify({'success': False, 'error': 'Yamcs server URL is required.'}), 400 + if not instance: + return jsonify({'success': False, 'error': 'Yamcs instance is required.'}), 400 + if auth_method not in { + YAMCS_AUTH_METHOD_USERNAME_PASSWORD, + YAMCS_AUTH_METHOD_API_KEY, + YAMCS_AUTH_METHOD_BEARER_TOKEN, + YAMCS_AUTH_METHOD_NONE, + }: + return jsonify({ + 'success': False, + 'error': "Yamcs auth_method must be 'username_password', 'api_key', 'bearer_token', or 'none'." + }), 400 + + try: + existing_plugin = _load_existing_plugin_for_test(data.get('existing_plugin'), user_id) + except PermissionError as exc: + return jsonify({'success': False, 'error': str(exc)}), 403 + except LookupError as exc: + return jsonify({'success': False, 'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'success': False, 'error': str(exc)}), 400 + + existing_auth = {} + if isinstance(existing_plugin, dict) and isinstance(existing_plugin.get('auth'), dict): + existing_auth = existing_plugin['auth'] + + if auth_method != YAMCS_AUTH_METHOD_NONE: + if auth_key in ('', ui_trigger_word): + auth_key = existing_auth.get('key', '') + if auth_key == ui_trigger_word: + return jsonify({ + 'success': False, + 'error': 'Stored Yamcs credential could not be resolved for testing. Re-enter the credential.' + }), 400 + + try: + auth_key = _resolve_secret_value_for_plugin_test(auth_key, 'auth.key', plugin_label='Yamcs') + except ValueError as exc: + return jsonify({'success': False, 'error': str(exc)}), 400 + + if not auth_key: + credential_label = 'password' if auth_method == YAMCS_AUTH_METHOD_USERNAME_PASSWORD else 'credential' + return jsonify({'success': False, 'error': f'A Yamcs {credential_label} is required for this authentication method.'}), 400 + + if auth_method == YAMCS_AUTH_METHOD_USERNAME_PASSWORD: + if not username: + username = existing_auth.get('identity', '') + if not username: + return jsonify({'success': False, 'error': 'A Yamcs username is required for username/password authentication.'}), 400 + + client = None + try: + try: + from yamcs.client import APIKeyCredentials, Credentials, YamcsClient + except ImportError: + return jsonify({ + 'success': False, + 'error': 'Yamcs client library is not installed on the server. Install yamcs-client to use Yamcs actions.' + }), 400 + + if auth_method == YAMCS_AUTH_METHOD_NONE: + credentials = None + elif auth_method == YAMCS_AUTH_METHOD_API_KEY: + credentials = APIKeyCredentials(auth_key) + elif auth_method == YAMCS_AUTH_METHOD_BEARER_TOKEN: + credentials = Credentials(access_token=auth_key) + else: + credentials = Credentials(username=username, password=auth_key) + + client = YamcsClient( + server_url, + credentials=credentials, + tls_verify=tls_verify, + user_agent='SimpleChat', + ) + session = getattr(getattr(client, 'ctx', None), 'session', None) + if session is not None: + original_request = session.request + + def request_with_timeout(*args, **kwargs): + kwargs.setdefault('timeout', timeout) + return original_request(*args, **kwargs) + + session.request = request_with_timeout + + server_info = client.get_server_info() + instance_names = [str(getattr(item, 'name', '')) for item in client.list_instances()] + if instance not in instance_names: + log_event( + '[PLUGINS] Yamcs connection test could not find the configured instance', + extra={ + 'user_id': user_id, + 'server_url': server_url, + 'instance': instance, + 'auth_method': auth_method, + }, + level=logging.WARNING, + ) + return jsonify({ + 'success': False, + 'error': f"Connected to Yamcs, but instance '{instance}' was not found. Available instances: {', '.join(instance_names) or 'none'}." + }), 404 + + log_event( + '[PLUGINS] Yamcs connection test succeeded', + extra={ + 'user_id': user_id, + 'server_url': server_url, + 'instance': instance, + 'auth_method': auth_method, + 'instance_count': len(instance_names), + }, + level=logging.INFO, + ) + return jsonify({ + 'success': True, + 'message': ( + f"Successfully connected to Yamcs {getattr(server_info, 'version', '') or ''} at {server_url}. " + f"Instance '{instance}' is available ({len(instance_names)} instance(s) total)." + ).strip() + }) + except Exception as exc: + status_code = getattr(getattr(exc, 'response', None), 'status_code', None) + raw_message = str(exc) + if status_code in (401, 403) or 'unauthorized' in raw_message.lower() or 'forbidden' in raw_message.lower(): + error_msg = 'Yamcs authentication failed. Verify the selected authentication method and credentials.' + status = 403 + elif status_code == 404: + error_msg = 'The Yamcs server responded, but the requested resource was not found. Verify the server URL.' + status = 404 + else: + error_msg = 'Yamcs connection failed. Verify the server URL, TLS settings, and network access.' + status = 400 + + log_event( + f'[PLUGINS] Yamcs connection test failed: {exc}', + extra={ + 'user_id': user_id, + 'server_url': server_url, + 'instance': instance, + 'auth_method': auth_method, + 'status_code': status_code, + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + return jsonify({'success': False, 'error': error_msg}), status + finally: + if client is not None: + try: + client.close() + except Exception: + pass diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index d83ecdb19..6e8e8c47b 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -52,6 +52,7 @@ from functions_databricks_operations import DATABRICKS_LEGACY_TABLE_PLUGIN_TYPE, DATABRICKS_PLUGIN_TYPE from functions_snowflake_operations import SNOWFLAKE_PLUGIN_TYPE, SNOWFLAKE_SENSITIVE_ADDITIONAL_FIELDS from functions_tableau_operations import TABLEAU_PLUGIN_TYPE +from functions_yamcs_operations import YAMCS_PLUGIN_TYPE, YAMCS_SENSITIVE_ADDITIONAL_FIELDS from functions_keyvault import ( SQL_PLUGIN_SENSITIVE_ADDITIONAL_FIELDS, SQL_PLUGIN_SENSITIVE_AUTH_FIELDS, @@ -110,6 +111,7 @@ from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory from semantic_kernel_plugins.snowflake_plugin_factory import SnowflakePluginFactory from semantic_kernel_plugins.tableau_plugin_factory import TableauPluginFactory +from semantic_kernel_plugins.yamcs_plugin_factory import YamcsPluginFactory from functions_agent_scope import find_agent_by_scope, is_selected_agent_scope_enabled import app_settings_cache @@ -1476,6 +1478,9 @@ def normalize(s): elif plugin_type == TABLEAU_PLUGIN_TYPE: plugin = TableauPluginFactory.create_from_config(manifest) print(f"[SK_LOADER] Created Tableau plugin: {name}") + elif plugin_type == YAMCS_PLUGIN_TYPE: + plugin = YamcsPluginFactory.create_from_config(manifest) + print(f"[SK_LOADER] Created Yamcs plugin: {name}") elif plugin_type == MCP_PLUGIN_TYPE or normalized_type == normalize(MCP_PLUGIN_TYPE): plugin = McpPluginFactory.create_from_config(manifest) print(f"[SK_LOADER] Created MCP plugin: {name}") @@ -2106,6 +2111,7 @@ def _is_sensitive_plugin_additional_field(plugin_manifest, field_name): return ( _is_sql_sensitive_plugin_field(plugin_manifest, field_name) or (plugin_type == SNOWFLAKE_PLUGIN_TYPE and field_name in SNOWFLAKE_SENSITIVE_ADDITIONAL_FIELDS) + or (plugin_type == YAMCS_PLUGIN_TYPE and field_name in YAMCS_SENSITIVE_ADDITIONAL_FIELDS) ) @@ -2352,6 +2358,8 @@ def normalize(s): plugin = SnowflakePluginFactory.create_from_config(manifest) elif plugin_type == TABLEAU_PLUGIN_TYPE: plugin = TableauPluginFactory.create_from_config(manifest) + elif plugin_type == YAMCS_PLUGIN_TYPE: + plugin = YamcsPluginFactory.create_from_config(manifest) elif plugin_type == MCP_PLUGIN_TYPE or normalized_type == normalize(MCP_PLUGIN_TYPE): plugin = McpPluginFactory.create_from_config(manifest) else: diff --git a/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py b/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py index ee93dfb40..114ebf272 100644 --- a/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py +++ b/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py @@ -21,6 +21,7 @@ from functions_mcp_operations import MCP_PLUGIN_TYPE from functions_snowflake_operations import SNOWFLAKE_PLUGIN_TYPE from functions_tableau_operations import TABLEAU_PLUGIN_TYPE +from functions_yamcs_operations import YAMCS_PLUGIN_TYPE from semantic_kernel_plugins.databricks_plugin_factory import DatabricksPluginFactory from semantic_kernel_plugins.mcp_plugin_factory import McpPluginFactory from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory @@ -28,6 +29,7 @@ from semantic_kernel_plugins.sql_schema_plugin import SQLSchemaPlugin from semantic_kernel_plugins.sql_query_plugin import SQLQueryPlugin from semantic_kernel_plugins.tableau_plugin_factory import TableauPluginFactory +from semantic_kernel_plugins.yamcs_plugin_factory import YamcsPluginFactory from app_settings_cache import get_settings_cache class LoggedPluginLoader: @@ -125,6 +127,8 @@ def _create_plugin_instance(self, manifest: Dict[str, Any]): return self._create_snowflake_plugin(manifest) elif plugin_type == TABLEAU_PLUGIN_TYPE: return self._create_tableau_plugin(manifest) + elif plugin_type == YAMCS_PLUGIN_TYPE: + return self._create_yamcs_plugin(manifest) elif plugin_type == MCP_PLUGIN_TYPE: return self._create_mcp_plugin(manifest) elif plugin_type == 'python': @@ -263,6 +267,27 @@ def _create_tableau_plugin(self, manifest: Dict[str, Any]): self.logger.error(f"Failed to create Tableau plugin: {e}") return None + def _create_yamcs_plugin(self, manifest: Dict[str, Any]): + """Create a Yamcs plugin instance.""" + plugin_name = manifest.get('name') + try: + plugin_instance = YamcsPluginFactory.create_from_config(manifest) + log_event( + "[LOGGED_PLUGIN_LOADER] Successfully created Yamcs plugin instance using factory", + extra={"plugin_name": plugin_name}, + level=logging.INFO, + ) + return plugin_instance + except Exception as e: + log_event( + "[LOGGED_PLUGIN_LOADER] General error creating Yamcs plugin", + extra={"plugin_name": plugin_name, "error": str(e)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + self.logger.error(f"Failed to create Yamcs plugin: {e}") + return None + def _create_mcp_plugin(self, manifest: Dict[str, Any]): """Create an MCP plugin instance.""" plugin_name = manifest.get('name') diff --git a/application/single_app/semantic_kernel_plugins/plugin_health_checker.py b/application/single_app/semantic_kernel_plugins/plugin_health_checker.py index fcb12109a..b3850004f 100644 --- a/application/single_app/semantic_kernel_plugins/plugin_health_checker.py +++ b/application/single_app/semantic_kernel_plugins/plugin_health_checker.py @@ -42,6 +42,21 @@ normalize_tableau_additional_fields, normalize_tableau_server_url, ) +from functions_yamcs_operations import ( + YAMCS_AUTH_METHOD_API_KEY, + YAMCS_AUTH_METHOD_BEARER_TOKEN, + YAMCS_AUTH_METHOD_NONE, + YAMCS_AUTH_METHOD_USERNAME_PASSWORD, + YAMCS_MAX_MAX_ROWS, + YAMCS_MAX_TIMEOUT, + YAMCS_MIN_MAX_ROWS, + YAMCS_MIN_TIMEOUT, + YAMCS_PLUGIN_TYPE, + YAMCS_SUPPORTED_AUTH_METHODS, + YAMCS_SUPPORTED_AUTH_TYPES, + normalize_yamcs_additional_fields, + normalize_yamcs_server_url, +) from functions_mcp_operations import ( MCP_CUSTOM_HEADERS_FIELD, MCP_MAX_RETRY_BACKOFF_SECONDS, @@ -237,7 +252,57 @@ def validate_plugin_manifest(manifest: Dict[str, Any], plugin_type: str) -> Tupl continue if parsed_value < minimum or parsed_value > maximum: errors.append(f"Tableau additionalFields.{field_name} must be between {minimum} and {maximum}") - + + elif plugin_type == YAMCS_PLUGIN_TYPE: + auth = manifest.get('auth', {}) if isinstance(manifest.get('auth'), dict) else {} + auth_type = str(auth.get('type') or 'username_password').strip() + raw_additional_fields = manifest.get('additionalFields', {}) + if not isinstance(raw_additional_fields, dict): + raw_additional_fields = {} + additional_fields = normalize_yamcs_additional_fields(raw_additional_fields, auth_type=auth_type) + endpoint = normalize_yamcs_server_url( + manifest.get('endpoint') or additional_fields.get('server_url') or '' + ) + parsed_endpoint = urlparse(endpoint) + identity_id = str(manifest.get('identity_id') or '').strip() + raw_auth_method = str(raw_additional_fields.get('auth_method') or '').strip().lower() + auth_method = additional_fields.get('auth_method') + + if parsed_endpoint.scheme not in {'http', 'https'} or not parsed_endpoint.netloc: + errors.append("Yamcs plugin requires an http(s) Yamcs server URL") + if not additional_fields.get('instance'): + errors.append("Yamcs plugin requires additionalFields.instance") + if auth_type not in YAMCS_SUPPORTED_AUTH_TYPES: + errors.append("Yamcs plugin supports auth.type values 'NoAuth', 'key', 'identity', or 'username_password'") + if raw_auth_method and raw_auth_method not in YAMCS_SUPPORTED_AUTH_METHODS: + errors.append("Yamcs plugin supports additionalFields.auth_method values 'username_password', 'api_key', 'bearer_token', or 'none'") + if auth_type == 'identity' and not auth.get('identity') and not identity_id: + errors.append("Yamcs reusable identity auth requires auth.identity or identity_id") + elif auth_method == YAMCS_AUTH_METHOD_USERNAME_PASSWORD: + if auth_type == 'username_password' and (not auth.get('identity') or not auth.get('key')): + errors.append("Yamcs username/password auth requires auth.identity and auth.key") + elif auth_method in {YAMCS_AUTH_METHOD_API_KEY, YAMCS_AUTH_METHOD_BEARER_TOKEN}: + if auth_type == 'key' and not auth.get('key'): + errors.append("Yamcs API key and bearer token auth require auth.key") + elif auth_method == YAMCS_AUTH_METHOD_NONE and auth_type not in {'NoAuth', 'identity'}: + errors.append("Yamcs unauthenticated access requires auth.type='NoAuth'") + + yamcs_range_fields = { + 'max_rows': (YAMCS_MIN_MAX_ROWS, YAMCS_MAX_MAX_ROWS), + 'timeout': (YAMCS_MIN_TIMEOUT, YAMCS_MAX_TIMEOUT), + } + for field_name, (minimum, maximum) in yamcs_range_fields.items(): + raw_value = raw_additional_fields.get(field_name) + if raw_value in [None, '']: + continue + try: + parsed_value = int(raw_value) + except (TypeError, ValueError): + errors.append(f"Yamcs additionalFields.{field_name} must be an integer") + continue + if parsed_value < minimum or parsed_value > maximum: + errors.append(f"Yamcs additionalFields.{field_name} must be between {minimum} and {maximum}") + elif plugin_type in ['sql_query', 'sql_schema']: additional_fields = manifest.get('additionalFields', {}) if not isinstance(additional_fields, dict): diff --git a/application/single_app/semantic_kernel_plugins/yamcs_plugin.py b/application/single_app/semantic_kernel_plugins/yamcs_plugin.py new file mode 100644 index 000000000..cfdb4766d --- /dev/null +++ b/application/single_app/semantic_kernel_plugins/yamcs_plugin.py @@ -0,0 +1,818 @@ +# yamcs_plugin.py +"""Semantic Kernel plugin for read-only Yamcs mission control access.""" + +import itertools +import json +import logging +import re +from datetime import date, datetime, time, timedelta, timezone +from decimal import Decimal +from typing import Any, Callable, Dict, Iterable, List, Optional + +from semantic_kernel.functions import kernel_function + +from functions_appinsights import log_event +from functions_debug import debug_print +from functions_yamcs_operations import ( + YAMCS_ALLOWED_READ_STATEMENTS, + YAMCS_AUTH_METHOD_API_KEY, + YAMCS_AUTH_METHOD_BEARER_TOKEN, + YAMCS_AUTH_METHOD_NONE, + YAMCS_AUTH_METHOD_USERNAME_PASSWORD, + YAMCS_DEFAULT_PROCESSOR, + YAMCS_PLUGIN_TYPE, + YAMCS_SUPPORTED_AUTH_METHODS, + YAMCS_SUPPORTED_AUTH_TYPES, + normalize_yamcs_additional_fields, + normalize_yamcs_server_url, +) +from semantic_kernel_plugins.base_plugin import BasePlugin +from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger + + +class YamcsPlugin(BasePlugin): + """Yamcs connector plugin focused on read-only telemetry, MDB, and archive retrieval. + + Commanding is intentionally unsupported. This plugin never issues commands, writes + parameter values, runs scripts, or changes data link state. + """ + + _SQL_COMMENT_PATTERN = re.compile(r"(--[^\r\n]*|/\*.*?\*/)", re.DOTALL) + _FIRST_WORD_PATTERN = re.compile(r"^\s*([A-Za-z]+)", re.IGNORECASE) + _LIMIT_PATTERN = re.compile(r"\blimit\b", re.IGNORECASE) + _FORBIDDEN_READ_ONLY_PATTERN = re.compile( + r"\b(ALTER|CREATE|DELETE|DROP|INSERT|LOAD|MERGE|SET|TRUNCATE|UPDATE|UPSERT)\b", + re.IGNORECASE, + ) + _SECRET_REDACTION_PATTERN = re.compile( + r"(?i)(password|api\s*key|access\s*token|token|secret)\s*[=:]\s*[^\s,;]+" + ) + + def __init__(self, manifest: Optional[Dict[str, Any]] = None): + super().__init__(manifest) + self.manifest = manifest or {} + self._metadata = self.manifest.get("metadata", {}) or {} + self._auth = self.manifest.get("auth", {}) if isinstance(self.manifest.get("auth"), dict) else {} + self.auth_type = str(self._auth.get("type") or "username_password").strip() + self._additional_fields = normalize_yamcs_additional_fields( + self.manifest.get("additionalFields", {}), + auth_type=self.auth_type, + ) + self.server_url = ( + normalize_yamcs_server_url(self.manifest.get("endpoint")) + or self._additional_fields.get("server_url") + or "" + ) + self.instance = self._additional_fields.get("instance") or "" + self.processor = self._additional_fields.get("processor") or YAMCS_DEFAULT_PROCESSOR + self.auth_method = self._additional_fields.get("auth_method") or YAMCS_AUTH_METHOD_USERNAME_PASSWORD + self.tls_verify = bool(self._additional_fields.get("tls_verify", True)) + self.enable_archive_sql = bool(self._additional_fields.get("enable_archive_sql", False)) + self.max_rows = int(self._additional_fields.get("max_rows") or 500) + self.timeout = int(self._additional_fields.get("timeout") or 30) + self.byte_limit = int(self._additional_fields.get("byte_limit") or 250000) + self._validate_configuration() + + @property + def display_name(self) -> str: + return "Yamcs" + + @property + def metadata(self) -> Dict[str, Any]: + user_description = self._metadata.get( + "description", + "Yamcs mission control action using the official Yamcs Python client.", + ) + description = ( + f"{user_description}\n\n" + "This action reads telemetry, mission database definitions, and archived data from a " + "Yamcs server so the agent can analyze spacecraft and ground segment state. " + "It is strictly read-only: it cannot issue commands, set parameter values, run scripts, " + "or enable/disable data links. Use list_instances and list_parameters to discover what " + "is available before requesting values or history." + ) + return { + "name": self.manifest.get("name", "yamcs"), + "type": YAMCS_PLUGIN_TYPE, + "description": description, + "methods": [ + { + "name": "list_instances", + "description": "List Yamcs instances available on the configured server.", + "parameters": [], + "returns": {"type": "dict", "description": "Instance names, states, and mission times."}, + }, + { + "name": "list_links", + "description": "List data links and their status for a Yamcs instance.", + "parameters": [ + {"name": "instance", "type": "str", "description": "Optional Yamcs instance name.", "required": False}, + ], + "returns": {"type": "dict", "description": "Link names, status, and packet counts."}, + }, + { + "name": "list_parameters", + "description": "List mission database parameters, optionally filtered by name text or parameter type.", + "parameters": [ + {"name": "search", "type": "str", "description": "Optional case-insensitive text to match in the parameter name.", "required": False}, + {"name": "parameter_type", "type": "str", "description": "Optional parameter type such as float or integer.", "required": False}, + {"name": "instance", "type": "str", "description": "Optional Yamcs instance name.", "required": False}, + ], + "returns": {"type": "dict", "description": "Parameter names, types, units, and descriptions."}, + }, + { + "name": "describe_parameter", + "description": "Describe a single mission database parameter, including type, units, and enumerations.", + "parameters": [ + {"name": "parameter", "type": "str", "description": "Qualified XTCE name or NAMESPACE/NAME alias.", "required": True}, + {"name": "instance", "type": "str", "description": "Optional Yamcs instance name.", "required": False}, + ], + "returns": {"type": "dict", "description": "Parameter definition details."}, + }, + { + "name": "list_commands", + "description": "List mission database command definitions. This only describes commands and never issues them.", + "parameters": [ + {"name": "search", "type": "str", "description": "Optional case-insensitive text to match in the command name.", "required": False}, + {"name": "instance", "type": "str", "description": "Optional Yamcs instance name.", "required": False}, + ], + "returns": {"type": "dict", "description": "Command names, significance, and argument definitions."}, + }, + { + "name": "get_parameter_values", + "description": "Get the latest processed values for one or more parameters.", + "parameters": [ + {"name": "parameters", "type": "str", "description": "Comma-separated parameter names.", "required": True}, + {"name": "instance", "type": "str", "description": "Optional Yamcs instance name.", "required": False}, + {"name": "processor", "type": "str", "description": "Optional processor name.", "required": False}, + ], + "returns": {"type": "dict", "description": "Engineering and raw values with monitoring status."}, + }, + { + "name": "list_parameter_history", + "description": "Read archived values for a parameter over a time range.", + "parameters": [ + {"name": "parameter", "type": "str", "description": "Qualified XTCE name or NAMESPACE/NAME alias.", "required": True}, + {"name": "start", "type": "str", "description": "Optional ISO-8601 start time.", "required": False}, + {"name": "stop", "type": "str", "description": "Optional ISO-8601 stop time.", "required": False}, + {"name": "instance", "type": "str", "description": "Optional Yamcs instance name.", "required": False}, + ], + "returns": {"type": "dict", "description": "Time-ordered archived parameter values."}, + }, + { + "name": "list_events", + "description": "Read archived Yamcs events, optionally filtered by severity, source, or text.", + "parameters": [ + {"name": "severity", "type": "str", "description": "Optional minimum severity such as warning or critical.", "required": False}, + {"name": "source", "type": "str", "description": "Optional event source.", "required": False}, + {"name": "text_filter", "type": "str", "description": "Optional text to match in the event message.", "required": False}, + {"name": "start", "type": "str", "description": "Optional ISO-8601 start time.", "required": False}, + {"name": "stop", "type": "str", "description": "Optional ISO-8601 stop time.", "required": False}, + {"name": "instance", "type": "str", "description": "Optional Yamcs instance name.", "required": False}, + ], + "returns": {"type": "dict", "description": "Archived events with severity, source, and message."}, + }, + { + "name": "list_packets", + "description": "Read archived telemetry packet metadata over a time range.", + "parameters": [ + {"name": "name", "type": "str", "description": "Optional packet name.", "required": False}, + {"name": "start", "type": "str", "description": "Optional ISO-8601 start time.", "required": False}, + {"name": "stop", "type": "str", "description": "Optional ISO-8601 stop time.", "required": False}, + {"name": "instance", "type": "str", "description": "Optional Yamcs instance name.", "required": False}, + ], + "returns": {"type": "dict", "description": "Packet names, times, sizes, and links."}, + }, + { + "name": "list_alarms", + "description": "Read archived alarms over a time range.", + "parameters": [ + {"name": "name", "type": "str", "description": "Optional alarm or parameter name.", "required": False}, + {"name": "start", "type": "str", "description": "Optional ISO-8601 start time.", "required": False}, + {"name": "stop", "type": "str", "description": "Optional ISO-8601 stop time.", "required": False}, + {"name": "instance", "type": "str", "description": "Optional Yamcs instance name.", "required": False}, + ], + "returns": {"type": "dict", "description": "Alarms with severity, trigger time, and acknowledgement state."}, + }, + { + "name": "execute_archive_sql", + "description": "Run a read-only Yamcs archive SQL statement. Disabled unless archive SQL is enabled on the action.", + "parameters": [ + {"name": "statement", "type": "str", "description": "Read-only SELECT, SHOW, or DESCRIBE statement.", "required": True}, + {"name": "instance", "type": "str", "description": "Optional Yamcs instance name.", "required": False}, + ], + "returns": {"type": "dict", "description": "Columns and rows returned by the archive query."}, + }, + ], + } + + def get_functions(self) -> List[str]: + return [ + "list_instances", + "list_links", + "list_parameters", + "describe_parameter", + "list_commands", + "get_parameter_values", + "list_parameter_history", + "list_events", + "list_packets", + "list_alarms", + "execute_archive_sql", + ] + + def _validate_configuration(self) -> None: + if not self.server_url: + raise ValueError("Yamcs action requires a server URL.") + if not self.instance: + raise ValueError("Yamcs action requires additionalFields.instance.") + if self.auth_type not in YAMCS_SUPPORTED_AUTH_TYPES: + raise ValueError( + "Yamcs action supports auth.type values 'NoAuth', 'key', 'identity', or 'username_password'." + ) + if self.auth_method not in YAMCS_SUPPORTED_AUTH_METHODS: + raise ValueError( + "Yamcs action supports auth methods username_password, api_key, bearer_token, or none." + ) + if self.auth_type == "identity": + if not (self._auth.get("identity") or self.manifest.get("identity_id")): + raise ValueError("Yamcs reusable identity auth requires auth.identity or identity_id.") + return + if self.auth_method == YAMCS_AUTH_METHOD_USERNAME_PASSWORD: + if not self._auth.get("identity"): + raise ValueError("Yamcs username/password auth requires a username in auth.identity.") + if not self._auth.get("key"): + raise ValueError("Yamcs username/password auth requires a password in auth.key.") + elif self.auth_method in {YAMCS_AUTH_METHOD_API_KEY, YAMCS_AUTH_METHOD_BEARER_TOKEN}: + if not self._auth.get("key"): + raise ValueError("Yamcs API key and bearer token auth require auth.key.") + + def _build_credentials(self): + """Build a Yamcs credentials object for the configured auth method.""" + if self.auth_method == YAMCS_AUTH_METHOD_NONE: + return None + + try: + from yamcs.client import APIKeyCredentials, Credentials + except ImportError as exc: + raise ImportError( + "Yamcs client library is not installed. Install yamcs-client to use Yamcs actions." + ) from exc + + auth_key = str(self._auth.get("key") or "") + if self.auth_method == YAMCS_AUTH_METHOD_API_KEY: + return APIKeyCredentials(auth_key) + if self.auth_method == YAMCS_AUTH_METHOD_BEARER_TOKEN: + return Credentials(access_token=auth_key) + return Credentials(username=str(self._auth.get("identity") or ""), password=auth_key) + + def _connect(self): + try: + from yamcs.client import YamcsClient + except ImportError as exc: + raise ImportError( + "Yamcs client library is not installed. Install yamcs-client to use Yamcs actions." + ) from exc + + debug_print( + f"[YAMCS_PLUGIN] Opening Yamcs connection server_url={self.server_url} " + f"instance={self.instance} processor={self.processor} auth_method={self.auth_method} " + f"tls_verify={self.tls_verify} timeout={self.timeout}" + ) + client = YamcsClient( + self.server_url, + credentials=self._build_credentials(), + tls_verify=self.tls_verify, + user_agent="SimpleChat", + ) + # The Yamcs client wraps a requests.Session; apply the configured timeout to it so a + # slow or unreachable ground segment cannot hang an agent turn indefinitely. + session = getattr(getattr(client, "ctx", None), "session", None) + if session is not None: + session.request = self._with_timeout(session.request) + return client + + def _with_timeout(self, request_callable: Callable) -> Callable: + configured_timeout = self.timeout + + def request_with_timeout(*args, **kwargs): + kwargs.setdefault("timeout", configured_timeout) + return request_callable(*args, **kwargs) + + return request_with_timeout + + def _resolve_instance(self, instance: str = "") -> str: + return str(instance or self.instance or "").strip() + + def _resolve_processor(self, processor: str = "") -> str: + return str(processor or self.processor or YAMCS_DEFAULT_PROCESSOR).strip() + + def _parse_datetime(self, value: Any, field_name: str) -> Optional[datetime]: + if value in [None, ""]: + return None + if isinstance(value, datetime): + parsed = value + else: + raw_value = str(value).strip() + normalized = raw_value[:-1] + "+00:00" if raw_value.endswith("Z") else raw_value + try: + parsed = datetime.fromisoformat(normalized) + except ValueError as exc: + raise ValueError(f"{field_name} must be an ISO-8601 timestamp.") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + def _serialize_value(self, value: Any) -> Any: + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, Decimal): + return str(value) + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, (date, time)): + return value.isoformat() + if isinstance(value, timedelta): + return value.total_seconds() + if isinstance(value, bytes): + return value.hex() + if isinstance(value, (list, tuple, set)): + return [self._serialize_value(item) for item in value] + if isinstance(value, dict): + return {str(key): self._serialize_value(item) for key, item in value.items()} + return str(value) + + def _extract(self, source: Any, attribute_names: Iterable[str]) -> Dict[str, Any]: + """Read named attributes off a Yamcs model object, tolerating version differences.""" + extracted: Dict[str, Any] = {} + for attribute_name in attribute_names: + try: + extracted[attribute_name] = self._serialize_value(getattr(source, attribute_name, None)) + except Exception: + extracted[attribute_name] = None + return extracted + + def _bounded(self, results: Iterable[Any], limit: Optional[int] = None) -> List[Any]: + """Materialize at most `limit` items so a broad query cannot walk an entire archive.""" + max_items = limit if isinstance(limit, int) and limit > 0 else self.max_rows + return list(itertools.islice(results, max_items)) + + def _truncate_rows(self, rows: List[Dict[str, Any]]) -> Dict[str, Any]: + serialized_rows = json.dumps(rows, default=str) + if len(serialized_rows) <= self.byte_limit: + return {"rows": rows, "truncated_by_bytes": False} + + trimmed_rows: List[Dict[str, Any]] = [] + current_size = 2 + for row in rows: + row_size = len(json.dumps(row, default=str)) + 1 + if trimmed_rows and current_size + row_size > self.byte_limit: + break + trimmed_rows.append(row) + current_size += row_size + return {"rows": trimmed_rows or rows[:1], "truncated_by_bytes": True} + + def _success_rows(self, rows: List[Dict[str, Any]], requested_limit: int, **extra: Any) -> Dict[str, Any]: + truncation = self._truncate_rows(rows) + final_rows = truncation["rows"] + payload = { + "success": True, + "server_url": self.server_url, + "rows": final_rows, + "row_count": len(final_rows), + "truncated": bool(truncation["truncated_by_bytes"] or len(rows) >= requested_limit), + } + payload.update(extra) + return payload + + def _safe_error_message(self, exc: Exception, fallback: str) -> str: + raw_message = str(getattr(exc, "message", None) or exc or fallback) + sanitized = self._SECRET_REDACTION_PATTERN.sub(r"\1=[REDACTED]", raw_message) + return sanitized[:500] or fallback + + def _error_response(self, message: str, error_type: str = "validation", **extra: Any) -> Dict[str, Any]: + payload = { + "success": False, + "error": message, + "error_type": error_type, + } + payload.update(extra) + return payload + + def _run(self, operation_name: str, operation: Callable[[Any], Dict[str, Any]]) -> Dict[str, Any]: + """Open a Yamcs client, run a read-only operation, and normalize failures.""" + client = None + try: + client = self._connect() + result = operation(client) + debug_print( + f"[YAMCS_PLUGIN] {operation_name} succeeded instance={self.instance} " + f"row_count={result.get('row_count')} truncated={result.get('truncated')}" + ) + return result + except ValueError as exc: + return self._error_response(str(exc), error_type="validation") + except ImportError as exc: + return self._error_response(str(exc), error_type="dependency") + except Exception as exc: + message = self._safe_error_message(exc, f"Yamcs {operation_name} failed.") + debug_print( + f"[YAMCS_PLUGIN] {operation_name} failed server_url={self.server_url} " + f"instance={self.instance} exception_type={type(exc).__name__} message={message}" + ) + log_event( + f"[YAMCS_PLUGIN] Yamcs {operation_name} failed: {exc}", + extra={ + "server_url": self.server_url, + "instance": self.instance, + "operation": operation_name, + "plugin_name": self.manifest.get("name"), + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + return self._error_response(message, error_type="yamcs", operation=operation_name) + finally: + if client is not None: + try: + client.close() + debug_print("[YAMCS_PLUGIN] Yamcs connection closed.") + except Exception: + pass + + def _first_word(self, statement: str) -> str: + first_word_match = self._FIRST_WORD_PATTERN.match(statement or "") + return first_word_match.group(1).upper() if first_word_match else "" + + def _validate_read_only_statement(self, statement: str) -> Optional[str]: + normalized = self._SQL_COMMENT_PATTERN.sub(" ", str(statement or "")).strip() + if not normalized: + return "Yamcs archive SQL statement is required." + + statements = [part.strip() for part in normalized.split(";") if part.strip()] + if len(statements) != 1: + return "Only one Yamcs archive SQL statement can be executed at a time." + + first_word = self._first_word(statements[0]) + if first_word not in YAMCS_ALLOWED_READ_STATEMENTS: + return f"Only read-only Yamcs archive SQL statements are allowed. Found: {first_word or 'UNKNOWN'}." + + forbidden_match = self._FORBIDDEN_READ_ONLY_PATTERN.search(statements[0]) + if forbidden_match: + return f"Read-only mode blocks Yamcs archive SQL keyword: {forbidden_match.group(1).upper()}." + return None + + def _apply_statement_limit(self, statement: str) -> str: + stripped = str(statement or "").strip().rstrip(";") + if self._first_word(stripped) == "SELECT" and not self._LIMIT_PATTERN.search(stripped): + return f"{stripped} limit {self.max_rows}" + return stripped + + @plugin_function_logger("YamcsPlugin") + @kernel_function(description="List Yamcs instances available on the configured server.", name="list_instances") + def list_instances(self) -> Dict[str, Any]: + def operation(client): + instances = self._bounded(client.list_instances()) + rows = [self._extract(item, ("name", "state", "failure_cause", "mission_time")) for item in instances] + return self._success_rows(rows, self.max_rows) + + return self._run("list_instances", operation) + + @plugin_function_logger("YamcsPlugin") + @kernel_function(description="List Yamcs data links and their status for an instance.", name="list_links") + def list_links(self, instance: str = "") -> Dict[str, Any]: + def operation(client): + resolved_instance = self._resolve_instance(instance) + links = self._bounded(client.list_links(resolved_instance)) + rows = [ + self._extract(item, ("name", "class_name", "enabled", "status", "in_count", "out_count")) + for item in links + ] + return self._success_rows(rows, self.max_rows, instance=resolved_instance) + + return self._run("list_links", operation) + + @plugin_function_logger("YamcsPlugin") + @kernel_function( + description="List Yamcs mission database parameters, optionally filtered by name text or parameter type.", + name="list_parameters", + ) + def list_parameters(self, search: str = "", parameter_type: str = "", instance: str = "") -> Dict[str, Any]: + def operation(client): + resolved_instance = self._resolve_instance(instance) + mdb = client.get_mdb(instance=resolved_instance) + normalized_search = str(search or "").strip().lower() + normalized_type = str(parameter_type or "").strip() or None + parameters = mdb.list_parameters(parameter_type=normalized_type) + if normalized_search: + parameters = ( + item + for item in parameters + if normalized_search in str(getattr(item, "qualified_name", "") or "").lower() + or normalized_search in str(getattr(item, "name", "") or "").lower() + ) + rows = [ + self._extract(item, ("name", "qualified_name", "type", "units", "data_source", "description")) + for item in self._bounded(parameters) + ] + return self._success_rows(rows, self.max_rows, instance=resolved_instance, search=normalized_search) + + return self._run("list_parameters", operation) + + @plugin_function_logger("YamcsPlugin") + @kernel_function( + description="Describe a single Yamcs mission database parameter, including type, units, and enumerations.", + name="describe_parameter", + ) + def describe_parameter(self, parameter: str, instance: str = "") -> Dict[str, Any]: + def operation(client): + parameter_name = str(parameter or "").strip() + if not parameter_name: + raise ValueError("Yamcs parameter name is required.") + resolved_instance = self._resolve_instance(instance) + mdb = client.get_mdb(instance=resolved_instance) + found = mdb.get_parameter(parameter_name) + details = self._extract( + found, + ("name", "qualified_name", "type", "units", "data_source", "description", "long_description", "aliases"), + ) + details["enum_values"] = [ + self._extract(enum_value, ("value", "label", "description")) + for enum_value in (getattr(found, "enum_values", None) or []) + ] + return { + "success": True, + "server_url": self.server_url, + "instance": resolved_instance, + "parameter": details, + } + + return self._run("describe_parameter", operation) + + @plugin_function_logger("YamcsPlugin") + @kernel_function( + description="List Yamcs mission database command definitions. This describes commands only and never issues them.", + name="list_commands", + ) + def list_commands(self, search: str = "", instance: str = "") -> Dict[str, Any]: + def operation(client): + resolved_instance = self._resolve_instance(instance) + mdb = client.get_mdb(instance=resolved_instance) + normalized_search = str(search or "").strip().lower() + commands = mdb.list_commands() + if normalized_search: + commands = ( + item + for item in commands + if normalized_search in str(getattr(item, "qualified_name", "") or "").lower() + or normalized_search in str(getattr(item, "name", "") or "").lower() + ) + rows = [] + for item in self._bounded(commands): + row = self._extract(item, ("name", "qualified_name", "description", "abstract")) + row["arguments"] = [ + self._extract(argument, ("name", "description", "initial_value")) + for argument in (getattr(item, "arguments", None) or []) + ] + rows.append(row) + return self._success_rows(rows, self.max_rows, instance=resolved_instance) + + return self._run("list_commands", operation) + + @plugin_function_logger("YamcsPlugin") + @kernel_function( + description="Get the latest processed values for one or more Yamcs parameters.", + name="get_parameter_values", + ) + def get_parameter_values(self, parameters: str, instance: str = "", processor: str = "") -> Dict[str, Any]: + def operation(client): + requested = [name.strip() for name in str(parameters or "").split(",") if name.strip()] + if not requested: + raise ValueError("At least one Yamcs parameter name is required.") + requested = requested[: self.max_rows] + + resolved_instance = self._resolve_instance(instance) + resolved_processor = self._resolve_processor(processor) + processor_client = client.get_processor(instance=resolved_instance, processor=resolved_processor) + values = processor_client.get_parameter_values(requested, from_cache=True) + + rows = [] + for requested_name, value in zip(requested, values or []): + if value is None: + rows.append({"name": requested_name, "available": False}) + continue + row = self._extract( + value, + ("name", "generation_time", "reception_time", "eng_value", "raw_value", "monitoring_result", "validity_status"), + ) + row["requested_name"] = requested_name + row["available"] = True + rows.append(row) + + return self._success_rows( + rows, + len(requested), + instance=resolved_instance, + processor=resolved_processor, + ) + + return self._run("get_parameter_values", operation) + + @plugin_function_logger("YamcsPlugin") + @kernel_function( + description="Read archived Yamcs values for a parameter over a time range.", + name="list_parameter_history", + ) + def list_parameter_history( + self, + parameter: str, + start: str = "", + stop: str = "", + instance: str = "", + ) -> Dict[str, Any]: + def operation(client): + parameter_name = str(parameter or "").strip() + if not parameter_name: + raise ValueError("Yamcs parameter name is required.") + start_time = self._parse_datetime(start, "start") + stop_time = self._parse_datetime(stop, "stop") + resolved_instance = self._resolve_instance(instance) + archive = client.get_archive(instance=resolved_instance) + values = archive.list_parameter_values( + parameter_name, + start=start_time, + stop=stop_time, + page_size=min(self.max_rows, 500), + descending=True, + ) + rows = [ + self._extract( + item, + ("generation_time", "reception_time", "eng_value", "raw_value", "monitoring_result", "validity_status"), + ) + for item in self._bounded(values) + ] + return self._success_rows( + rows, + self.max_rows, + instance=resolved_instance, + parameter=parameter_name, + ) + + return self._run("list_parameter_history", operation) + + @plugin_function_logger("YamcsPlugin") + @kernel_function( + description="Read archived Yamcs events, optionally filtered by severity, source, or message text.", + name="list_events", + ) + def list_events( + self, + severity: str = "", + source: str = "", + text_filter: str = "", + start: str = "", + stop: str = "", + instance: str = "", + ) -> Dict[str, Any]: + def operation(client): + start_time = self._parse_datetime(start, "start") + stop_time = self._parse_datetime(stop, "stop") + resolved_instance = self._resolve_instance(instance) + archive = client.get_archive(instance=resolved_instance) + events = archive.list_events( + source=str(source or "").strip() or None, + severity=str(severity or "").strip() or None, + text_filter=str(text_filter or "").strip() or None, + start=start_time, + stop=stop_time, + page_size=min(self.max_rows, 500), + descending=True, + ) + rows = [ + self._extract( + item, + ("generation_time", "reception_time", "severity", "message", "event_type", "source", "sequence_number"), + ) + for item in self._bounded(events) + ] + return self._success_rows(rows, self.max_rows, instance=resolved_instance) + + return self._run("list_events", operation) + + @plugin_function_logger("YamcsPlugin") + @kernel_function( + description="Read archived Yamcs telemetry packet metadata over a time range.", + name="list_packets", + ) + def list_packets( + self, + name: str = "", + start: str = "", + stop: str = "", + instance: str = "", + ) -> Dict[str, Any]: + def operation(client): + start_time = self._parse_datetime(start, "start") + stop_time = self._parse_datetime(stop, "stop") + resolved_instance = self._resolve_instance(instance) + archive = client.get_archive(instance=resolved_instance) + packets = archive.list_packets( + name=str(name or "").strip() or None, + start=start_time, + stop=stop_time, + page_size=min(self.max_rows, 500), + descending=True, + ) + rows = [ + self._extract( + item, + ("name", "generation_time", "reception_time", "sequence_number", "link", "size"), + ) + for item in self._bounded(packets) + ] + return self._success_rows(rows, self.max_rows, instance=resolved_instance) + + return self._run("list_packets", operation) + + @plugin_function_logger("YamcsPlugin") + @kernel_function(description="Read archived Yamcs alarms over a time range.", name="list_alarms") + def list_alarms( + self, + name: str = "", + start: str = "", + stop: str = "", + instance: str = "", + ) -> Dict[str, Any]: + def operation(client): + start_time = self._parse_datetime(start, "start") + stop_time = self._parse_datetime(stop, "stop") + resolved_instance = self._resolve_instance(instance) + archive = client.get_archive(instance=resolved_instance) + alarms = archive.list_alarms( + name=str(name or "").strip() or None, + start=start_time, + stop=stop_time, + page_size=min(self.max_rows, 500), + descending=True, + ) + rows = [ + self._extract( + item, + ("name", "severity", "trigger_time", "update_time", "is_acknowledged", "acknowledged_by", "violation_count", "count"), + ) + for item in self._bounded(alarms) + ] + return self._success_rows(rows, self.max_rows, instance=resolved_instance) + + return self._run("list_alarms", operation) + + @plugin_function_logger("YamcsPlugin") + @kernel_function( + description="Run a read-only Yamcs archive SQL statement. Disabled unless archive SQL is enabled on this action.", + name="execute_archive_sql", + ) + def execute_archive_sql(self, statement: str, instance: str = "") -> Dict[str, Any]: + if not self.enable_archive_sql: + return self._error_response( + "Yamcs archive SQL is disabled for this action. Enable it in the action configuration to use this function.", + error_type="disabled", + ) + + validation_error = self._validate_read_only_statement(statement) + if validation_error: + return self._error_response(validation_error, error_type="validation") + + bounded_statement = self._apply_statement_limit(statement) + + def operation(client): + resolved_instance = self._resolve_instance(instance) + archive = client.get_archive(instance=resolved_instance) + result_set = archive.execute_sql(bounded_statement) + fetched = self._bounded(result_set) + columns = [ + str(getattr(column, "name", column)) + for column in (getattr(result_set, "columns", None) or []) + ] + rows = [] + for row in fetched: + if isinstance(row, dict): + rows.append({str(key): self._serialize_value(value) for key, value in row.items()}) + elif columns: + rows.append( + { + column: self._serialize_value(row[index] if index < len(row) else None) + for index, column in enumerate(columns) + } + ) + else: + rows.append({"value": self._serialize_value(row)}) + return self._success_rows( + rows, + self.max_rows, + instance=resolved_instance, + statement=bounded_statement, + columns=columns, + ) + + return self._run("execute_archive_sql", operation) diff --git a/application/single_app/semantic_kernel_plugins/yamcs_plugin_factory.py b/application/single_app/semantic_kernel_plugins/yamcs_plugin_factory.py new file mode 100644 index 000000000..0c2adb269 --- /dev/null +++ b/application/single_app/semantic_kernel_plugins/yamcs_plugin_factory.py @@ -0,0 +1,53 @@ +# yamcs_plugin_factory.py +"""Factory for creating Yamcs Semantic Kernel plugins from action manifests.""" + +from typing import Any, Dict + +from functions_yamcs_operations import ( + YAMCS_AUTH_METHOD_API_KEY, + YAMCS_AUTH_METHOD_NONE, + YAMCS_AUTH_METHOD_USERNAME_PASSWORD, + YAMCS_PLUGIN_TYPE, + normalize_yamcs_additional_fields, + normalize_yamcs_server_url, +) +from semantic_kernel_plugins.yamcs_plugin import YamcsPlugin + + +class YamcsPluginFactory: + """Create Yamcs plugin instances from stored action manifests.""" + + @classmethod + def create_from_config(cls, config: Dict[str, Any]) -> YamcsPlugin: + manifest = cls.normalize_manifest(config) + return YamcsPlugin(manifest) + + @classmethod + def normalize_manifest(cls, config: Dict[str, Any]) -> Dict[str, Any]: + manifest = dict(config or {}) + auth = manifest.get("auth") if isinstance(manifest.get("auth"), dict) else {} + auth = dict(auth) + additional_fields = manifest.get("additionalFields") if isinstance(manifest.get("additionalFields"), dict) else {} + additional_fields = dict(additional_fields) + + auth_type = str(auth.get("type") or "username_password").strip() or "username_password" + auth["type"] = auth_type + additional_fields = normalize_yamcs_additional_fields(additional_fields, auth_type=auth_type) + + if auth_type == "username_password": + additional_fields["auth_method"] = YAMCS_AUTH_METHOD_USERNAME_PASSWORD + elif auth_type == "NoAuth": + additional_fields["auth_method"] = YAMCS_AUTH_METHOD_NONE + elif auth_type == "key" and additional_fields.get("auth_method") == YAMCS_AUTH_METHOD_NONE: + additional_fields["auth_method"] = YAMCS_AUTH_METHOD_API_KEY + + endpoint = normalize_yamcs_server_url(manifest.get("endpoint") or additional_fields.get("server_url") or "") + if endpoint: + manifest["endpoint"] = endpoint + additional_fields["server_url"] = endpoint + + manifest["type"] = YAMCS_PLUGIN_TYPE + manifest["auth"] = auth + manifest["additionalFields"] = additional_fields + manifest.setdefault("metadata", {}) + return manifest diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js index 3ea7f6509..13d934270 100644 --- a/application/single_app/static/js/plugin_modal_stepper.js +++ b/application/single_app/static/js/plugin_modal_stepper.js @@ -12,6 +12,7 @@ const MCP_ACTION_IDENTITY_AUTH_TYPES = ['api_key', 'bearer_token', 'managed_iden const DATABRICKS_ACTION_IDENTITY_AUTH_TYPES = ['api_key', 'bearer_token', 'managed_identity']; const SNOWFLAKE_ACTION_IDENTITY_AUTH_TYPES = ['api_key', 'bearer_token', 'username_password']; const TABLEAU_ACTION_IDENTITY_AUTH_TYPES = ['api_key', 'username_password']; +const YAMCS_ACTION_IDENTITY_AUTH_TYPES = ['api_key', 'bearer_token', 'username_password']; const BLOB_STORAGE_PLUGIN_TYPE = 'blob_storage'; const DATABRICKS_PLUGIN_TYPE = 'databricks'; const DATABRICKS_DEFAULT_CLOUD = 'azure_commercial'; @@ -23,6 +24,12 @@ const SNOWFLAKE_AUTH_METHOD_OAUTH = 'oauth'; const TABLEAU_PLUGIN_TYPE = 'tableau'; const TABLEAU_AUTH_METHOD_PAT = 'personal_access_token'; const TABLEAU_AUTH_METHOD_USERNAME_PASSWORD = 'username_password'; +const YAMCS_PLUGIN_TYPE = 'yamcs'; +const YAMCS_DEFAULT_PROCESSOR = 'realtime'; +const YAMCS_AUTH_METHOD_USERNAME_PASSWORD = 'username_password'; +const YAMCS_AUTH_METHOD_API_KEY = 'api_key'; +const YAMCS_AUTH_METHOD_BEARER_TOKEN = 'bearer_token'; +const YAMCS_AUTH_METHOD_NONE = 'none'; const publicWorkspacePlural = window.getPublicWorkspaceLabel ? window.getPublicWorkspaceLabel('plural') : 'Public Workspaces'; const MCP_PLUGIN_TYPE = 'mcp'; const KEY_VAULT_SECRET_REMINDERS_METADATA_FIELD = 'key_vault_secret_reminders'; @@ -499,6 +506,9 @@ export class PluginModalStepper { if (kind === 'tableau') { return this.actionIdentities.filter(identity => TABLEAU_ACTION_IDENTITY_AUTH_TYPES.includes(this.getIdentityAuthType(identity))); } + if (kind === 'yamcs') { + return this.actionIdentities.filter(identity => YAMCS_ACTION_IDENTITY_AUTH_TYPES.includes(this.getIdentityAuthType(identity))); + } return this.actionIdentities; } @@ -508,6 +518,7 @@ export class PluginModalStepper { this.populateActionIdentitySelector('databricks', 'databricks-identity-select', 'databricks-action-identity-group', 'databricks-identity-status'); this.populateActionIdentitySelector('snowflake', 'snowflake-identity-select', 'snowflake-action-identity-group', 'snowflake-identity-status'); this.populateActionIdentitySelector('tableau', 'tableau-identity-select', 'tableau-action-identity-group', 'tableau-identity-status'); + this.populateActionIdentitySelector('yamcs', 'yamcs-identity-select', 'yamcs-action-identity-group', 'yamcs-identity-status'); this.populateActionIdentitySelector('generic', 'plugin-auth-identity-select-generic', 'generic-action-identity-group', 'plugin-auth-identity-status-generic'); this.populateActionIdentitySelector('sql', 'sql-identity-select', 'sql-action-identity-group', 'sql-identity-status'); } @@ -570,6 +581,7 @@ export class PluginModalStepper { databricks: 'databricks-identity-select', snowflake: 'snowflake-identity-select', tableau: 'tableau-identity-select', + yamcs: 'yamcs-identity-select', generic: 'plugin-auth-identity-select-generic', sql: 'sql-identity-select' }; @@ -587,6 +599,7 @@ export class PluginModalStepper { databricks: 'databricks-identity-select', snowflake: 'snowflake-identity-select', tableau: 'tableau-identity-select', + yamcs: 'yamcs-identity-select', generic: 'plugin-auth-identity-select-generic', sql: 'sql-identity-select' }; @@ -621,9 +634,15 @@ export class PluginModalStepper { return; } - const authSelect = document.getElementById(kind === 'openapi' - ? 'plugin-auth-type' - : (kind === 'mcp' ? 'mcp-auth-method' : (kind === 'databricks' ? 'databricks-auth-method' : (kind === 'snowflake' ? 'snowflake-auth-method' : (kind === 'tableau' ? 'tableau-auth-method' : 'plugin-auth-type-generic'))))); + const authSelectIds = { + openapi: 'plugin-auth-type', + mcp: 'mcp-auth-method', + databricks: 'databricks-auth-method', + snowflake: 'snowflake-auth-method', + tableau: 'tableau-auth-method', + yamcs: 'yamcs-auth-method' + }; + const authSelect = document.getElementById(authSelectIds[kind] || 'plugin-auth-type-generic'); if (authSelect) { authSelect.disabled = !!selectedIdentity; } @@ -637,6 +656,8 @@ export class PluginModalStepper { this.toggleSnowflakeAuthFields(); } else if (kind === 'tableau') { this.toggleTableauAuthFields(); + } else if (kind === 'yamcs') { + this.toggleYamcsAuthFields(); } else { this.toggleGenericAuthFields(); } @@ -667,6 +688,8 @@ export class PluginModalStepper { document.getElementById('snowflake-identity-select').addEventListener('change', () => this.handleActionIdentityChange('snowflake')); document.getElementById('tableau-auth-method').addEventListener('change', () => this.toggleTableauAuthFields()); document.getElementById('tableau-identity-select').addEventListener('change', () => this.handleActionIdentityChange('tableau')); + document.getElementById('yamcs-auth-method').addEventListener('change', () => this.toggleYamcsAuthFields()); + document.getElementById('yamcs-identity-select').addEventListener('change', () => this.handleActionIdentityChange('yamcs')); document.getElementById('plugin-auth-identity-select-generic').addEventListener('change', () => this.handleActionIdentityChange('generic')); const msGraphMailSendMode = document.getElementById('msgraph-mail-send-mode'); if (msGraphMailSendMode) { @@ -708,6 +731,11 @@ export class PluginModalStepper { testCosmosBtn.addEventListener('click', () => this.testCosmosConnection()); } + const testYamcsBtn = document.getElementById('yamcs-test-connection-btn'); + if (testYamcsBtn) { + testYamcsBtn.addEventListener('click', () => this.testYamcsConnection()); + } + const keyVaultReminderToggle = document.getElementById('plugin-key-vault-reminder-enabled'); if (keyVaultReminderToggle) { keyVaultReminderToggle.addEventListener('change', () => this.toggleKeyVaultReminderFields()); @@ -1254,6 +1282,10 @@ export class PluginModalStepper { return !!(type && type.toLowerCase() === TABLEAU_PLUGIN_TYPE); } + isYamcsType(type = this.selectedType) { + return !!(type && type.toLowerCase() === YAMCS_PLUGIN_TYPE); + } + isMcpType(type = this.selectedType) { return !!(type && type.toLowerCase() === MCP_PLUGIN_TYPE); } @@ -2369,6 +2401,156 @@ export class PluginModalStepper { }; } + normalizeYamcsServerUrl(serverUrl = '') { + const value = String(serverUrl || '').trim().replace(/\/+$/, ''); + if (!value) { + return ''; + } + if (!/^https?:\/\//i.test(value)) { + return `https://${value}`; + } + return value; + } + + getYamcsIdentityAuthMethod(identity) { + const authType = this.getIdentityAuthType(identity); + if (authType === 'api_key') { + return YAMCS_AUTH_METHOD_API_KEY; + } + if (authType === 'bearer_token') { + return YAMCS_AUTH_METHOD_BEARER_TOKEN; + } + return YAMCS_AUTH_METHOD_USERNAME_PASSWORD; + } + + formatYamcsAuthMethod(authMethod) { + if (authMethod === YAMCS_AUTH_METHOD_API_KEY) { + return 'API Key'; + } + if (authMethod === YAMCS_AUTH_METHOD_BEARER_TOKEN) { + return 'Bearer Token'; + } + if (authMethod === YAMCS_AUTH_METHOD_NONE) { + return 'No Authentication'; + } + return 'Username and Password'; + } + + toggleYamcsAuthFields() { + const selectedIdentity = this.getSelectedActionIdentity('yamcs'); + const authMethodSelect = document.getElementById('yamcs-auth-method'); + const usernamePasswordGroup = document.getElementById('yamcs-username-password-group'); + const apiKeyGroup = document.getElementById('yamcs-api-key-group'); + const bearerTokenGroup = document.getElementById('yamcs-bearer-token-group'); + + if (authMethodSelect) { + authMethodSelect.disabled = Boolean(selectedIdentity); + if (selectedIdentity) { + authMethodSelect.value = this.getYamcsIdentityAuthMethod(selectedIdentity); + } + } + + [usernamePasswordGroup, apiKeyGroup, bearerTokenGroup].forEach(group => { + if (group) { + group.classList.add('d-none'); + } + }); + + if (selectedIdentity) { + return; + } + + const authMethod = authMethodSelect?.value || YAMCS_AUTH_METHOD_USERNAME_PASSWORD; + if (authMethod === YAMCS_AUTH_METHOD_USERNAME_PASSWORD) { + usernamePasswordGroup?.classList.remove('d-none'); + } else if (authMethod === YAMCS_AUTH_METHOD_API_KEY) { + apiKeyGroup?.classList.remove('d-none'); + } else if (authMethod === YAMCS_AUTH_METHOD_BEARER_TOKEN) { + bearerTokenGroup?.classList.remove('d-none'); + } + } + + populateYamcsForm(plugin) { + const additionalFields = plugin.additionalFields || plugin.additional_fields || {}; + const auth = plugin.auth || {}; + const serverUrl = this.normalizeYamcsServerUrl(plugin.endpoint || additionalFields.server_url || ''); + + document.getElementById('yamcs-server-url').value = serverUrl; + document.getElementById('yamcs-instance').value = additionalFields.instance || ''; + document.getElementById('yamcs-processor').value = additionalFields.processor || YAMCS_DEFAULT_PROCESSOR; + document.getElementById('yamcs-max-rows').value = additionalFields.max_rows || 500; + document.getElementById('yamcs-timeout').value = additionalFields.timeout || 30; + document.getElementById('yamcs-tls-verify').checked = additionalFields.tls_verify !== false; + document.getElementById('yamcs-enable-archive-sql').checked = additionalFields.enable_archive_sql === true; + + let authMethod = additionalFields.auth_method || YAMCS_AUTH_METHOD_USERNAME_PASSWORD; + if (auth.type === 'NoAuth') { + authMethod = YAMCS_AUTH_METHOD_NONE; + } else if (auth.type === 'username_password') { + authMethod = YAMCS_AUTH_METHOD_USERNAME_PASSWORD; + document.getElementById('yamcs-username').value = auth.identity || ''; + document.getElementById('yamcs-password').value = auth.key || ''; + } else if (auth.type === 'key') { + if (authMethod === YAMCS_AUTH_METHOD_BEARER_TOKEN) { + document.getElementById('yamcs-bearer-token').value = auth.key || ''; + } else { + authMethod = YAMCS_AUTH_METHOD_API_KEY; + document.getElementById('yamcs-api-key').value = auth.key || ''; + } + } + + document.getElementById('yamcs-auth-method').value = authMethod; + this.setSelectedActionIdentity('yamcs', plugin.identity_id || ''); + this.handleActionIdentityChange('yamcs'); + } + + getYamcsConfiguration() { + const serverUrl = this.normalizeYamcsServerUrl(document.getElementById('yamcs-server-url')?.value || ''); + const selectedIdentity = this.getSelectedActionIdentity('yamcs'); + const authMethod = selectedIdentity + ? this.getYamcsIdentityAuthMethod(selectedIdentity) + : (document.getElementById('yamcs-auth-method')?.value || YAMCS_AUTH_METHOD_USERNAME_PASSWORD); + const additionalFields = { + server_url: serverUrl, + instance: document.getElementById('yamcs-instance')?.value.trim() || '', + processor: document.getElementById('yamcs-processor')?.value.trim() || YAMCS_DEFAULT_PROCESSOR, + auth_method: authMethod, + tls_verify: document.getElementById('yamcs-tls-verify')?.checked !== false, + read_only: true, + enable_archive_sql: document.getElementById('yamcs-enable-archive-sql')?.checked === true, + max_rows: parseInt(document.getElementById('yamcs-max-rows')?.value, 10) || 500, + timeout: parseInt(document.getElementById('yamcs-timeout')?.value, 10) || 30 + }; + const auth = {}; + let identityId = ''; + + if (selectedIdentity) { + identityId = selectedIdentity.id || selectedIdentity.identity_id || ''; + auth.type = 'identity'; + auth.identity = identityId; + additionalFields.identity_auth_type = this.getIdentityAuthType(selectedIdentity); + } else if (authMethod === YAMCS_AUTH_METHOD_USERNAME_PASSWORD) { + auth.type = 'username_password'; + auth.identity = document.getElementById('yamcs-username')?.value.trim() || ''; + auth.key = document.getElementById('yamcs-password')?.value.trim() || ''; + } else if (authMethod === YAMCS_AUTH_METHOD_API_KEY) { + auth.type = 'key'; + auth.key = document.getElementById('yamcs-api-key')?.value.trim() || ''; + } else if (authMethod === YAMCS_AUTH_METHOD_BEARER_TOKEN) { + auth.type = 'key'; + auth.key = document.getElementById('yamcs-bearer-token')?.value.trim() || ''; + } else { + auth.type = 'NoAuth'; + } + + return { + endpoint: serverUrl, + auth, + additionalFields, + identityId + }; + } + initializeDocumentSearchConfiguration() { const defaults = { 'document-search-scope': 'all', @@ -3226,7 +3408,7 @@ export class PluginModalStepper { } isStructuredConfigType(type = this.selectedType) { - return this.isSqlType(type) || this.isCosmosType(type) || this.isDocumentSearchType(type) || this.isBlobStorageType(type) || this.isDatabricksType(type) || this.isSnowflakeType(type) || this.isTableauType(type) || this.isMcpType(type) || this.isSimpleChatType(type) || this.isMsGraphType(type) || this.isAzureMapsType(type) || this.isChartType(type); + return this.isSqlType(type) || this.isCosmosType(type) || this.isDocumentSearchType(type) || this.isBlobStorageType(type) || this.isDatabricksType(type) || this.isSnowflakeType(type) || this.isTableauType(type) || this.isYamcsType(type) || this.isMcpType(type) || this.isSimpleChatType(type) || this.isMsGraphType(type) || this.isAzureMapsType(type) || this.isChartType(type); } showConfigSectionForType() { @@ -3240,6 +3422,7 @@ export class PluginModalStepper { databricks: document.getElementById('databricks-config-section'), snowflake: document.getElementById('snowflake-config-section'), tableau: document.getElementById('tableau-config-section'), + yamcs: document.getElementById('yamcs-config-section'), simpleChat: document.getElementById('simplechat-config-section'), msGraph: document.getElementById('msgraph-config-section'), mcp: document.getElementById('mcp-config-section'), @@ -3279,6 +3462,9 @@ export class PluginModalStepper { } else if (this.isTableauType()) { showOnly('tableau'); this.toggleTableauAuthFields(); + } else if (this.isYamcsType()) { + showOnly('yamcs'); + this.toggleYamcsAuthFields(); } else if (this.isMcpType()) { showOnly('mcp'); this.initializeMcpConfiguration(); @@ -3348,6 +3534,8 @@ export class PluginModalStepper { titleEl.textContent = 'Snowflake Configuration'; } else if (isTableauType) { titleEl.textContent = 'Tableau Configuration'; + } else if (this.isYamcsType()) { + titleEl.textContent = 'Yamcs Configuration'; } else if (isMcpType) { titleEl.textContent = 'MCP Server Configuration'; } else if (this.isSimpleChatType()) { @@ -3538,6 +3726,7 @@ export class PluginModalStepper { const databricksSection = document.getElementById('databricks-config-section'); const snowflakeSection = document.getElementById('snowflake-config-section'); const tableauSection = document.getElementById('tableau-config-section'); + const yamcsSection = document.getElementById('yamcs-config-section'); const mcpSection = document.getElementById('mcp-config-section'); const simpleChatSection = document.getElementById('simplechat-config-section'); const msGraphSection = document.getElementById('msgraph-config-section'); @@ -3551,6 +3740,7 @@ export class PluginModalStepper { const isDatabricksVisible = !databricksSection.classList.contains('d-none'); const isSnowflakeVisible = !snowflakeSection.classList.contains('d-none'); const isTableauVisible = !tableauSection.classList.contains('d-none'); + const isYamcsVisible = !yamcsSection.classList.contains('d-none'); const isMcpVisible = !mcpSection.classList.contains('d-none'); const isSimpleChatVisible = !simpleChatSection.classList.contains('d-none'); const isMsGraphVisible = !msGraphSection.classList.contains('d-none'); @@ -3870,6 +4060,50 @@ export class PluginModalStepper { this.showError('Tableau timeout must be between 1 and 300 seconds.'); return false; } + } else if (isYamcsVisible) { + const serverUrl = this.normalizeYamcsServerUrl(document.getElementById('yamcs-server-url').value); + const instance = document.getElementById('yamcs-instance').value.trim(); + const selectedIdentity = this.getSelectedActionIdentity('yamcs'); + const authMethod = document.getElementById('yamcs-auth-method').value; + const username = document.getElementById('yamcs-username').value.trim(); + const password = document.getElementById('yamcs-password').value.trim(); + const apiKey = document.getElementById('yamcs-api-key').value.trim(); + const bearerToken = document.getElementById('yamcs-bearer-token').value.trim(); + const maxRows = parseInt(document.getElementById('yamcs-max-rows').value, 10); + const timeout = parseInt(document.getElementById('yamcs-timeout').value, 10); + + if (!serverUrl || !/^https?:\/\//i.test(serverUrl)) { + this.showError('Yamcs server URL must be an http or https URL.'); + return false; + } + if (!instance) { + this.showError('Yamcs instance is required.'); + return false; + } + if (![YAMCS_AUTH_METHOD_USERNAME_PASSWORD, YAMCS_AUTH_METHOD_API_KEY, YAMCS_AUTH_METHOD_BEARER_TOKEN, YAMCS_AUTH_METHOD_NONE].includes(authMethod)) { + this.showError('Select a supported Yamcs authentication method.'); + return false; + } + if (!selectedIdentity && authMethod === YAMCS_AUTH_METHOD_USERNAME_PASSWORD && (!username || !password)) { + this.showError('Yamcs username and password are required for username/password authentication.'); + return false; + } + if (!selectedIdentity && authMethod === YAMCS_AUTH_METHOD_API_KEY && !apiKey) { + this.showError('Yamcs API key is required for API key authentication.'); + return false; + } + if (!selectedIdentity && authMethod === YAMCS_AUTH_METHOD_BEARER_TOKEN && !bearerToken) { + this.showError('Yamcs bearer token is required for bearer token authentication.'); + return false; + } + if (Number.isNaN(maxRows) || maxRows < 1 || maxRows > 5000) { + this.showError('Yamcs max rows must be between 1 and 5000.'); + return false; + } + if (Number.isNaN(timeout) || timeout < 1 || timeout > 300) { + this.showError('Yamcs timeout must be between 1 and 300 seconds.'); + return false; + } } else if (isMcpVisible) { const transport = document.getElementById('mcp-transport').value; const endpoint = document.getElementById('mcp-endpoint').value.trim(); @@ -4446,6 +4680,102 @@ export class PluginModalStepper { } } + async testYamcsConnection() { + const btn = document.getElementById('yamcs-test-connection-btn'); + const resultDiv = document.getElementById('yamcs-test-connection-result'); + const alertDiv = document.getElementById('yamcs-test-connection-alert'); + if (!btn || !resultDiv || !alertDiv) return; + + const serverUrl = this.normalizeYamcsServerUrl(document.getElementById('yamcs-server-url')?.value || ''); + const instance = document.getElementById('yamcs-instance')?.value?.trim() || ''; + const selectedIdentity = this.getSelectedActionIdentity('yamcs'); + const authMethod = selectedIdentity + ? this.getYamcsIdentityAuthMethod(selectedIdentity) + : (document.getElementById('yamcs-auth-method')?.value || YAMCS_AUTH_METHOD_USERNAME_PASSWORD); + const tlsVerify = document.getElementById('yamcs-tls-verify')?.checked !== false; + const timeout = parseInt(document.getElementById('yamcs-timeout')?.value, 10) || 10; + + if (!serverUrl || !instance) { + resultDiv.classList.remove('d-none'); + alertDiv.className = 'alert alert-warning mb-0 py-2 px-3 small'; + alertDiv.textContent = 'Server URL and instance are required before testing the Yamcs connection.'; + return; + } + if (selectedIdentity) { + resultDiv.classList.remove('d-none'); + alertDiv.className = 'alert alert-warning mb-0 py-2 px-3 small'; + alertDiv.textContent = 'Save the action first to test a connection that uses a reusable identity.'; + return; + } + + let authKey = ''; + let username = ''; + if (authMethod === YAMCS_AUTH_METHOD_USERNAME_PASSWORD) { + username = document.getElementById('yamcs-username')?.value?.trim() || ''; + authKey = document.getElementById('yamcs-password')?.value?.trim() || ''; + } else if (authMethod === YAMCS_AUTH_METHOD_API_KEY) { + authKey = document.getElementById('yamcs-api-key')?.value?.trim() || ''; + } else if (authMethod === YAMCS_AUTH_METHOD_BEARER_TOKEN) { + authKey = document.getElementById('yamcs-bearer-token')?.value?.trim() || ''; + } + + const existingPluginContext = this.getTestPluginContext(); + if (authMethod !== YAMCS_AUTH_METHOD_NONE && !authKey && !existingPluginContext) { + resultDiv.classList.remove('d-none'); + alertDiv.className = 'alert alert-warning mb-0 py-2 px-3 small'; + alertDiv.textContent = 'A credential is required before testing an authenticated Yamcs connection.'; + return; + } + + const originalText = btn.innerHTML; + btn.innerHTML = 'Testing...'; + btn.disabled = true; + resultDiv.classList.add('d-none'); + + try { + const payload = { + server_url: serverUrl, + instance, + auth_method: authMethod, + tls_verify: tlsVerify, + timeout + }; + + if (authMethod === YAMCS_AUTH_METHOD_USERNAME_PASSWORD) { + payload.username = username; + } + if (authMethod !== YAMCS_AUTH_METHOD_NONE) { + payload.auth_key = authKey; + } + if (existingPluginContext) { + payload.existing_plugin = existingPluginContext; + } + + const response = await fetch('/api/plugins/test-yamcs-connection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await response.json(); + + resultDiv.classList.remove('d-none'); + if (data.success) { + alertDiv.className = 'alert alert-success mb-0 py-2 px-3 small'; + alertDiv.innerHTML = '' + this.escapeHtml(data.message || 'Connection successful!'); + } else { + alertDiv.className = 'alert alert-danger mb-0 py-2 px-3 small'; + alertDiv.innerHTML = '' + this.escapeHtml(data.error || 'Connection failed.'); + } + } catch (error) { + resultDiv.classList.remove('d-none'); + alertDiv.className = 'alert alert-danger mb-0 py-2 px-3 small'; + alertDiv.innerHTML = 'Test failed: ' + this.escapeHtml(error.message || 'Network error'); + } finally { + btn.innerHTML = originalText; + btn.disabled = false; + } + } + initializeSqlConfiguration() { // Set default values document.getElementById('sql-read-only').value = 'true'; @@ -5026,6 +5356,8 @@ export class PluginModalStepper { this.populateSnowflakeForm(plugin); } else if (this.isTableauType(plugin.type)) { this.populateTableauForm(plugin); + } else if (this.isYamcsType(plugin.type)) { + this.populateYamcsForm(plugin); } else if (this.isMcpType(plugin.type)) { this.populateMcpForm(plugin); } else if (this.isSimpleChatType(plugin.type)) { @@ -5083,6 +5415,7 @@ export class PluginModalStepper { const databricksSection = document.getElementById('databricks-config-section'); const snowflakeSection = document.getElementById('snowflake-config-section'); const tableauSection = document.getElementById('tableau-config-section'); + const yamcsSection = document.getElementById('yamcs-config-section'); const mcpSection = document.getElementById('mcp-config-section'); const azureMapsSection = document.getElementById('azure-maps-config-section'); const isOpenApiVisible = !openApiSection.classList.contains('d-none'); @@ -5093,6 +5426,7 @@ export class PluginModalStepper { const isDatabricksVisible = !databricksSection.classList.contains('d-none'); const isSnowflakeVisible = !snowflakeSection.classList.contains('d-none'); const isTableauVisible = !tableauSection.classList.contains('d-none'); + const isYamcsVisible = !yamcsSection.classList.contains('d-none'); const isMcpVisible = !mcpSection.classList.contains('d-none'); const isAzureMapsVisible = !azureMapsSection.classList.contains('d-none'); @@ -5355,6 +5689,12 @@ export class PluginModalStepper { auth = tableauConfig.auth; additionalFields = tableauConfig.additionalFields; identityId = tableauConfig.identityId; + } else if (isYamcsVisible) { + const yamcsConfig = this.getYamcsConfiguration(); + endpoint = yamcsConfig.endpoint; + auth = yamcsConfig.auth; + additionalFields = yamcsConfig.additionalFields; + identityId = yamcsConfig.identityId; } else if (isMcpVisible) { const mcpConfig = this.getMcpConfiguration(); endpoint = mcpConfig.endpoint; @@ -5478,6 +5818,7 @@ export class PluginModalStepper { const isDatabricksType = this.isDatabricksType(); const isSnowflakeType = this.isSnowflakeType(); const isTableauType = this.isTableauType(); + const isYamcsType = this.isYamcsType(); const isMcpType = this.isMcpType(); const isSimpleChatType = this.isSimpleChatType(); const isMsGraphType = this.isMsGraphType(); @@ -5568,10 +5909,10 @@ export class PluginModalStepper { } const databaseType = this.getSqlDatabaseType(); - if (!isSqlType && !isCosmosType && !isDocumentSearchType && !isBlobStorageType && !isDatabricksType && !isSnowflakeType && !isTableauType && !isMcpType && !isSimpleChatType && !isMsGraphType && !isAzureMapsType && !isChartType && databaseType) { + if (!isSqlType && !isCosmosType && !isDocumentSearchType && !isBlobStorageType && !isDatabricksType && !isSnowflakeType && !isTableauType && !isYamcsType && !isMcpType && !isSimpleChatType && !isMsGraphType && !isAzureMapsType && !isChartType && databaseType) { document.getElementById('summary-plugin-database-type').textContent = databaseType; databaseTypeRow.style.display = ''; - } else if (!isSqlType && !isCosmosType && !isDocumentSearchType && !isBlobStorageType && !isDatabricksType && !isSnowflakeType && !isTableauType && !isMcpType && !isSimpleChatType && !isMsGraphType && !isAzureMapsType && !isChartType) { + } else if (!isSqlType && !isCosmosType && !isDocumentSearchType && !isBlobStorageType && !isDatabricksType && !isSnowflakeType && !isTableauType && !isYamcsType && !isMcpType && !isSimpleChatType && !isMsGraphType && !isAzureMapsType && !isChartType) { databaseTypeRow.style.display = 'none'; } @@ -5584,6 +5925,7 @@ export class PluginModalStepper { this.populateDatabricksSummary(); this.populateSnowflakeSummary(); this.populateTableauSummary(); + this.populateYamcsSummary(); this.populateMcpSummary(); this.populateSimpleChatSummary(); this.populateMsGraphSummary(); @@ -5625,6 +5967,8 @@ export class PluginModalStepper { return SNOWFLAKE_DEFAULT_ENDPOINT; } else if (isTableauType) { return this.normalizeTableauServerUrl(document.getElementById('tableau-server-url')?.value || ''); + } else if (this.isYamcsType()) { + return this.normalizeYamcsServerUrl(document.getElementById('yamcs-server-url')?.value || ''); } else if (isMcpType) { const transport = document.getElementById('mcp-transport')?.value || 'streamable_http'; return transport === 'stdio' ? MCP_STDIO_ENDPOINT : document.getElementById('mcp-endpoint').value.trim(); @@ -5683,6 +6027,11 @@ export class PluginModalStepper { return 'Reusable Identity'; } return this.formatTableauAuthMethod(document.getElementById('tableau-auth-method')?.value || TABLEAU_AUTH_METHOD_PAT); + } else if (this.isYamcsType()) { + if (this.getSelectedActionIdentity('yamcs')) { + return 'Reusable Identity'; + } + return this.formatYamcsAuthMethod(document.getElementById('yamcs-auth-method')?.value || YAMCS_AUTH_METHOD_USERNAME_PASSWORD); } else if (isMcpType) { if (this.getSelectedActionIdentity('mcp')) { return 'Reusable Identity'; @@ -5979,6 +6328,34 @@ export class PluginModalStepper { tableauSection.classList.remove('d-none'); } + populateYamcsSummary() { + const yamcsSection = document.getElementById('summary-yamcs-section'); + if (!yamcsSection) { + return; + } + + if (!this.isYamcsType()) { + yamcsSection.classList.add('d-none'); + return; + } + + const selectedIdentity = this.getSelectedActionIdentity('yamcs'); + const authMethod = selectedIdentity + ? this.getYamcsIdentityAuthMethod(selectedIdentity) + : (document.getElementById('yamcs-auth-method')?.value || YAMCS_AUTH_METHOD_USERNAME_PASSWORD); + + document.getElementById('summary-yamcs-instance').textContent = document.getElementById('yamcs-instance')?.value.trim() || '-'; + document.getElementById('summary-yamcs-processor').textContent = document.getElementById('yamcs-processor')?.value.trim() || YAMCS_DEFAULT_PROCESSOR; + document.getElementById('summary-yamcs-auth-method').textContent = selectedIdentity + ? `Reusable Identity (${this.formatYamcsAuthMethod(authMethod)})` + : this.formatYamcsAuthMethod(authMethod); + document.getElementById('summary-yamcs-tls-verify').textContent = document.getElementById('yamcs-tls-verify')?.checked === false ? 'Disabled' : 'Enabled'; + document.getElementById('summary-yamcs-max-rows').textContent = document.getElementById('yamcs-max-rows')?.value.trim() || '500'; + document.getElementById('summary-yamcs-timeout').textContent = `${document.getElementById('yamcs-timeout')?.value || '30'} seconds`; + document.getElementById('summary-yamcs-archive-sql').textContent = document.getElementById('yamcs-enable-archive-sql')?.checked === true ? 'Enabled (read-only)' : 'Disabled'; + yamcsSection.classList.remove('d-none'); + } + populateMcpSummary() { const mcpSection = document.getElementById('summary-mcp-section'); if (!mcpSection) { @@ -6219,6 +6596,7 @@ export class PluginModalStepper { const isDocumentSearchType = this.isDocumentSearchType(); const isDatabricksType = this.isDatabricksType(); const isTableauType = this.isTableauType(); + const isYamcsType = this.isYamcsType(); const isMcpType = this.isMcpType(); const isSimpleChatType = this.isSimpleChatType(); const isMsGraphType = this.isMsGraphType(); @@ -6237,6 +6615,8 @@ export class PluginModalStepper { currentEndpoint = this.normalizeDatabricksWorkspaceUrl(document.getElementById('databricks-workspace-url')?.value || ''); } else if (isTableauType) { currentEndpoint = this.normalizeTableauServerUrl(document.getElementById('tableau-server-url')?.value || ''); + } else if (isYamcsType) { + currentEndpoint = this.normalizeYamcsServerUrl(document.getElementById('yamcs-server-url')?.value || ''); } else if (isMcpType) { currentEndpoint = this.getEndpointValue(); } else if (isSimpleChatType) { @@ -6283,6 +6663,17 @@ export class PluginModalStepper { } else if (!selectedIdentity && currentAuthType === TABLEAU_AUTH_METHOD_USERNAME_PASSWORD) { currentAuthKey = document.getElementById('tableau-password')?.value || ''; } + } else if (isYamcsType) { + const selectedIdentity = this.getSelectedActionIdentity('yamcs'); + const yamcsAuthMethod = document.getElementById('yamcs-auth-method')?.value || YAMCS_AUTH_METHOD_USERNAME_PASSWORD; + currentAuthType = selectedIdentity ? 'identity' : yamcsAuthMethod; + if (!selectedIdentity && yamcsAuthMethod === YAMCS_AUTH_METHOD_USERNAME_PASSWORD) { + currentAuthKey = document.getElementById('yamcs-password')?.value || ''; + } else if (!selectedIdentity && yamcsAuthMethod === YAMCS_AUTH_METHOD_API_KEY) { + currentAuthKey = document.getElementById('yamcs-api-key')?.value || ''; + } else if (!selectedIdentity && yamcsAuthMethod === YAMCS_AUTH_METHOD_BEARER_TOKEN) { + currentAuthKey = document.getElementById('yamcs-bearer-token')?.value || ''; + } } else if (isMcpType) { const selectedIdentity = this.getSelectedActionIdentity('mcp'); currentAuthType = selectedIdentity ? 'identity' : (document.getElementById('mcp-auth-method')?.value || 'none'); @@ -6324,6 +6715,8 @@ export class PluginModalStepper { currentAdditionalFields = JSON.stringify(this.getDocumentSearchAdditionalFields(), null, 2); } else if (isTableauType) { currentAdditionalFields = JSON.stringify(this.getTableauConfiguration().additionalFields, null, 2); + } else if (isYamcsType) { + currentAdditionalFields = JSON.stringify(this.getYamcsConfiguration().additionalFields, null, 2); } else if (isMcpType) { currentAdditionalFields = JSON.stringify(this.getMcpConfiguration().additionalFields, null, 2); } else if (isSimpleChatType) { diff --git a/application/single_app/static/js/workspace/view-utils.js b/application/single_app/static/js/workspace/view-utils.js index a415b1c7b..2dee40f19 100644 --- a/application/single_app/static/js/workspace/view-utils.js +++ b/application/single_app/static/js/workspace/view-utils.js @@ -63,6 +63,7 @@ export function getTypeIcon(type) { if (t === "databricks" || t === "databricks_table") return "bi-bricks"; if (t.includes("snowflake")) return "bi-snow2"; if (t.includes("tableau")) return "bi-bar-chart"; + if (t.includes("yamcs")) return "bi-broadcast-pin"; if (t.includes("http") || t.includes("smart_http")) return "bi-cloud-arrow-up"; if (t.includes("azure_function")) return "bi-lightning"; if (t.includes("blob")) return "bi-file-earmark"; diff --git a/application/single_app/static/json/schemas/yamcs.definition.json b/application/single_app/static/json/schemas/yamcs.definition.json new file mode 100644 index 000000000..0255c5e18 --- /dev/null +++ b/application/single_app/static/json/schemas/yamcs.definition.json @@ -0,0 +1,3 @@ +{ + "allowedAuthTypes": ["username_password", "key", "identity", "NoAuth"] +} diff --git a/application/single_app/static/json/schemas/yamcs_plugin.additional_settings.schema.json b/application/single_app/static/json/schemas/yamcs_plugin.additional_settings.schema.json new file mode 100644 index 000000000..3ebe88e43 --- /dev/null +++ b/application/single_app/static/json/schemas/yamcs_plugin.additional_settings.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Yamcs Plugin Additional Settings", + "type": "object", + "properties": { + "server_url": { + "type": "string", + "description": "Yamcs server base URL, for example https://yamcs.example.com:8090. TLS is derived from the scheme." + }, + "instance": { + "type": "string", + "description": "Default Yamcs instance name, for example simulator." + }, + "processor": { + "type": "string", + "default": "realtime", + "description": "Default Yamcs processor used to read live parameter values." + }, + "auth_method": { + "type": "string", + "enum": ["username_password", "api_key", "bearer_token", "none"], + "default": "username_password", + "description": "Authentication method used by the Yamcs client." + }, + "tls_verify": { + "type": "boolean", + "default": true, + "description": "Verify the Yamcs server TLS certificate. Disable only for internal ground segments using self-signed certificates." + }, + "read_only": { + "type": "boolean", + "default": true, + "description": "Always true. Yamcs actions never issue commands, write parameter values, or change data link state." + }, + "enable_archive_sql": { + "type": "boolean", + "default": false, + "description": "Allow read-only Yamcs archive SQL statements. Disabled by default." + }, + "max_rows": { + "type": "integer", + "default": 500, + "minimum": 1, + "maximum": 5000, + "description": "Maximum rows returned by any single Yamcs retrieval." + }, + "timeout": { + "type": "integer", + "default": 30, + "minimum": 1, + "maximum": 300, + "description": "Yamcs HTTP request timeout in seconds." + }, + "byte_limit": { + "type": "integer", + "default": 250000, + "minimum": 1000, + "maximum": 2000000, + "description": "Approximate serialized result size limit before truncating returned rows." + } + }, + "required": ["server_url", "instance", "auth_method"], + "additionalProperties": true +} diff --git a/application/single_app/templates/_plugin_modal.html b/application/single_app/templates/_plugin_modal.html index f93063be1..bf57cee0f 100644 --- a/application/single_app/templates/_plugin_modal.html +++ b/application/single_app/templates/_plugin_modal.html @@ -485,6 +485,121 @@
API Information
+
+
+ Yamcs action: Connect to a Yamcs mission control server for read-only telemetry, mission database, and archive retrieval. +
+
+ Read-only by design. + This action can never issue commands, set parameter values, run scripts, or change data link state. +
+ +
+ +
+ + +
Base URL of the Yamcs server, including the port. TLS is derived from the scheme.
+
+
+
+ + +
Default Yamcs instance used by this action.
+
+
+ + +
Processor used to read live parameter values.
+
+
+
+ +
+ +
+ + +
+
+
+ + +
Choose "No Authentication" only for unsecured Yamcs instances such as a local simulator.
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + +
Maximum rows returned by any single retrieval.
+
+
+ + +
Request timeout for Yamcs HTTP calls.
+
+
+
+ + +
Uncheck only for internal ground segments using self-signed certificates.
+
+
+
+
+ + +
Allows SELECT, SHOW, and DESCRIBE statements against the Yamcs archive. Disabled by default.
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
Built-in action: SimpleChat does not require a URL or external credentials. @@ -1796,6 +1911,60 @@
+
+
+
+ Yamcs Configuration +
+
+
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+
+