diff --git a/application/single_app/config.py b/application/single_app/config.py index bc094a2aa..c9ee098da 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.211" +VERSION = "0.250.212" 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 +
+
+
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+
+