diff --git a/application/single_app/config.py b/application/single_app/config.py index 934e47af..1e83e045 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.239.004" +VERSION = "0.239.013" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_activity_logging.py b/application/single_app/functions_activity_logging.py index 2a653a47..efb6e780 100644 --- a/application/single_app/functions_activity_logging.py +++ b/application/single_app/functions_activity_logging.py @@ -1393,3 +1393,332 @@ def log_retention_policy_force_push( level=logging.ERROR ) debug_print(f"⚠️ Warning: Failed to log retention policy force push: {str(e)}") + + +# === AGENT & ACTION ACTIVITY LOGGING === + +def log_agent_creation( + user_id: str, + agent_id: str, + agent_name: str, + agent_display_name: Optional[str] = None, + scope: str = 'personal', + group_id: Optional[str] = None +) -> None: + """ + Log an agent creation activity. + + Args: + user_id: The ID of the user who created the agent + agent_id: The unique ID of the new agent + agent_name: The name of the agent + agent_display_name: The display name of the agent + scope: 'personal', 'group', or 'global' + group_id: The group ID (only for group scope) + """ + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'agent_creation', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'entity_type': 'agent', + 'operation': 'create', + 'entity': { + 'id': agent_id, + 'name': agent_name, + 'display_name': agent_display_name or agent_name + }, + 'workspace_type': scope, + 'workspace_context': {} + } + if scope == 'group' and group_id: + activity_record['workspace_context']['group_id'] = group_id + + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"Agent created: {agent_name} ({scope}) by user {user_id}", + extra=activity_record, + level=logging.INFO + ) + debug_print(f"✅ Agent creation logged: {agent_name} ({scope})") + except Exception as e: + log_event( + message=f"Error logging agent creation: {str(e)}", + extra={'user_id': user_id, 'agent_id': agent_id, 'scope': scope, 'error': str(e)}, + level=logging.ERROR + ) + debug_print(f"⚠️ Warning: Failed to log agent creation: {str(e)}") + + +def log_agent_update( + user_id: str, + agent_id: str, + agent_name: str, + agent_display_name: Optional[str] = None, + scope: str = 'personal', + group_id: Optional[str] = None +) -> None: + """ + Log an agent update activity. + + Args: + user_id: The ID of the user who updated the agent + agent_id: The unique ID of the agent + agent_name: The name of the agent + agent_display_name: The display name of the agent + scope: 'personal', 'group', or 'global' + group_id: The group ID (only for group scope) + """ + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'agent_update', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'entity_type': 'agent', + 'operation': 'update', + 'entity': { + 'id': agent_id, + 'name': agent_name, + 'display_name': agent_display_name or agent_name + }, + 'workspace_type': scope, + 'workspace_context': {} + } + if scope == 'group' and group_id: + activity_record['workspace_context']['group_id'] = group_id + + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"Agent updated: {agent_name} ({scope}) by user {user_id}", + extra=activity_record, + level=logging.INFO + ) + debug_print(f"✅ Agent update logged: {agent_name} ({scope})") + except Exception as e: + log_event( + message=f"Error logging agent update: {str(e)}", + extra={'user_id': user_id, 'agent_id': agent_id, 'scope': scope, 'error': str(e)}, + level=logging.ERROR + ) + debug_print(f"⚠️ Warning: Failed to log agent update: {str(e)}") + + +def log_agent_deletion( + user_id: str, + agent_id: str, + agent_name: str, + scope: str = 'personal', + group_id: Optional[str] = None +) -> None: + """ + Log an agent deletion activity. + + Args: + user_id: The ID of the user who deleted the agent + agent_id: The unique ID of the agent + agent_name: The name of the agent + scope: 'personal', 'group', or 'global' + group_id: The group ID (only for group scope) + """ + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'agent_deletion', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'entity_type': 'agent', + 'operation': 'delete', + 'entity': { + 'id': agent_id, + 'name': agent_name + }, + 'workspace_type': scope, + 'workspace_context': {} + } + if scope == 'group' and group_id: + activity_record['workspace_context']['group_id'] = group_id + + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"Agent deleted: {agent_name} ({scope}) by user {user_id}", + extra=activity_record, + level=logging.INFO + ) + debug_print(f"✅ Agent deletion logged: {agent_name} ({scope})") + except Exception as e: + log_event( + message=f"Error logging agent deletion: {str(e)}", + extra={'user_id': user_id, 'agent_id': agent_id, 'scope': scope, 'error': str(e)}, + level=logging.ERROR + ) + debug_print(f"⚠️ Warning: Failed to log agent deletion: {str(e)}") + + +def log_action_creation( + user_id: str, + action_id: str, + action_name: str, + action_type: Optional[str] = None, + scope: str = 'personal', + group_id: Optional[str] = None +) -> None: + """ + Log an action/plugin creation activity. + + Args: + user_id: The ID of the user who created the action + action_id: The unique ID of the new action + action_name: The name of the action + action_type: The type of the action (e.g., 'openapi', 'sql_query') + scope: 'personal', 'group', or 'global' + group_id: The group ID (only for group scope) + """ + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'action_creation', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'entity_type': 'action', + 'operation': 'create', + 'entity': { + 'id': action_id, + 'name': action_name, + 'type': action_type + }, + 'workspace_type': scope, + 'workspace_context': {} + } + if scope == 'group' and group_id: + activity_record['workspace_context']['group_id'] = group_id + + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"Action created: {action_name} ({scope}) by user {user_id}", + extra=activity_record, + level=logging.INFO + ) + debug_print(f"✅ Action creation logged: {action_name} ({scope})") + except Exception as e: + log_event( + message=f"Error logging action creation: {str(e)}", + extra={'user_id': user_id, 'action_id': action_id, 'scope': scope, 'error': str(e)}, + level=logging.ERROR + ) + debug_print(f"⚠️ Warning: Failed to log action creation: {str(e)}") + + +def log_action_update( + user_id: str, + action_id: str, + action_name: str, + action_type: Optional[str] = None, + scope: str = 'personal', + group_id: Optional[str] = None +) -> None: + """ + Log an action/plugin update activity. + + Args: + user_id: The ID of the user who updated the action + action_id: The unique ID of the action + action_name: The name of the action + action_type: The type of the action + scope: 'personal', 'group', or 'global' + group_id: The group ID (only for group scope) + """ + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'action_update', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'entity_type': 'action', + 'operation': 'update', + 'entity': { + 'id': action_id, + 'name': action_name, + 'type': action_type + }, + 'workspace_type': scope, + 'workspace_context': {} + } + if scope == 'group' and group_id: + activity_record['workspace_context']['group_id'] = group_id + + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"Action updated: {action_name} ({scope}) by user {user_id}", + extra=activity_record, + level=logging.INFO + ) + debug_print(f"✅ Action update logged: {action_name} ({scope})") + except Exception as e: + log_event( + message=f"Error logging action update: {str(e)}", + extra={'user_id': user_id, 'action_id': action_id, 'scope': scope, 'error': str(e)}, + level=logging.ERROR + ) + debug_print(f"⚠️ Warning: Failed to log action update: {str(e)}") + + +def log_action_deletion( + user_id: str, + action_id: str, + action_name: str, + action_type: Optional[str] = None, + scope: str = 'personal', + group_id: Optional[str] = None +) -> None: + """ + Log an action/plugin deletion activity. + + Args: + user_id: The ID of the user who deleted the action + action_id: The unique ID of the action + action_name: The name of the action + action_type: The type of the action + scope: 'personal', 'group', or 'global' + group_id: The group ID (only for group scope) + """ + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'action_deletion', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'entity_type': 'action', + 'operation': 'delete', + 'entity': { + 'id': action_id, + 'name': action_name, + 'type': action_type + }, + 'workspace_type': scope, + 'workspace_context': {} + } + if scope == 'group' and group_id: + activity_record['workspace_context']['group_id'] = group_id + + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"Action deleted: {action_name} ({scope}) by user {user_id}", + extra=activity_record, + level=logging.INFO + ) + debug_print(f"✅ Action deletion logged: {action_name} ({scope})") + except Exception as e: + log_event( + message=f"Error logging action deletion: {str(e)}", + extra={'user_id': user_id, 'action_id': action_id, 'scope': scope, 'error': str(e)}, + level=logging.ERROR + ) + debug_print(f"⚠️ Warning: Failed to log action deletion: {str(e)}") diff --git a/application/single_app/functions_global_actions.py b/application/single_app/functions_global_actions.py index 91f0d9f9..4d7293cd 100644 --- a/application/single_app/functions_global_actions.py +++ b/application/single_app/functions_global_actions.py @@ -60,12 +60,13 @@ def get_global_action(action_id, return_type=SecretReturnType.TRIGGER): return None -def save_global_action(action_data): +def save_global_action(action_data, user_id=None): """ Save or update a global action. Args: action_data (dict): Action data to save + user_id (str, optional): The user ID of the person performing the action Returns: dict: Saved action data or None if failed @@ -76,8 +77,27 @@ def save_global_action(action_data): action_data['id'] = str(uuid.uuid4()) # Add metadata action_data['is_global'] = True - action_data['created_at'] = datetime.utcnow().isoformat() - action_data['updated_at'] = datetime.utcnow().isoformat() + now = datetime.utcnow().isoformat() + + # Check if this is a new action or an update to preserve created_by/created_at + existing_action = None + try: + existing_action = cosmos_global_actions_container.read_item( + item=action_data['id'], + partition_key=action_data['id'] + ) + except Exception: + pass + + if existing_action: + action_data['created_by'] = existing_action.get('created_by', user_id) + action_data['created_at'] = existing_action.get('created_at', now) + else: + action_data['created_by'] = user_id + action_data['created_at'] = now + action_data['modified_by'] = user_id + action_data['modified_at'] = now + action_data['updated_at'] = now print(f"💾 Saving global action: {action_data.get('name', 'Unknown')}") # Store secrets in Key Vault before upsert action_data = keyvault_plugin_save_helper(action_data, scope_value=action_data.get('id'), scope="global") diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index 5cf6a3d4..87976510 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -163,25 +163,46 @@ def get_global_agent(agent_id): return None -def save_global_agent(agent_data): +def save_global_agent(agent_data, user_id=None): """ Save or update a global agent. Args: agent_data (dict): Agent data to save + user_id (str, optional): The user ID of the person performing the action Returns: dict: Saved agent data or None if failed """ try: - user_id = get_current_user_id() + if user_id is None: + user_id = get_current_user_id() cleaned_agent = sanitize_agent_payload(agent_data) if 'id' not in cleaned_agent: cleaned_agent['id'] = str(uuid.uuid4()) cleaned_agent['is_global'] = True cleaned_agent['is_group'] = False - cleaned_agent['created_at'] = datetime.utcnow().isoformat() - cleaned_agent['updated_at'] = datetime.utcnow().isoformat() + now = datetime.utcnow().isoformat() + + # Check if this is a new agent or an update to preserve created_by/created_at + existing_agent = None + try: + existing_agent = cosmos_global_agents_container.read_item( + item=cleaned_agent['id'], + partition_key=cleaned_agent['id'] + ) + except Exception: + pass + + if existing_agent: + cleaned_agent['created_by'] = existing_agent.get('created_by', user_id) + cleaned_agent['created_at'] = existing_agent.get('created_at', now) + else: + cleaned_agent['created_by'] = user_id + cleaned_agent['created_at'] = now + cleaned_agent['modified_by'] = user_id + cleaned_agent['modified_at'] = now + cleaned_agent['updated_at'] = now log_event( "Saving global agent.", extra={"agent_name": cleaned_agent.get('name', 'Unknown')}, diff --git a/application/single_app/functions_group_actions.py b/application/single_app/functions_group_actions.py index bc6aa4ea..c0d264b1 100644 --- a/application/single_app/functions_group_actions.py +++ b/application/single_app/functions_group_actions.py @@ -82,14 +82,36 @@ def get_group_action( return _clean_action(action, group_id, return_type) -def save_group_action(group_id: str, action_data: Dict[str, Any]) -> Dict[str, Any]: +def save_group_action(group_id: str, action_data: Dict[str, Any], user_id: Optional[str] = None) -> Dict[str, Any]: """Create or update a group action entry.""" payload = dict(action_data) action_id = payload.get("id") or str(uuid.uuid4()) payload["id"] = action_id payload["group_id"] = group_id - payload["last_updated"] = datetime.utcnow().isoformat() + now = datetime.utcnow().isoformat() + payload["last_updated"] = now + + # Track who created/modified this action + existing_action = None + try: + existing_action = cosmos_group_actions_container.read_item( + item=action_id, + partition_key=group_id, + ) + except exceptions.CosmosResourceNotFoundError: + pass + except Exception: + pass + + if existing_action: + payload["created_by"] = existing_action.get("created_by", user_id) + payload["created_at"] = existing_action.get("created_at", now) + else: + payload["created_by"] = user_id + payload["created_at"] = now + payload["modified_by"] = user_id + payload["modified_at"] = now payload.setdefault("name", "") payload.setdefault("displayName", payload.get("name", "")) diff --git a/application/single_app/functions_group_agents.py b/application/single_app/functions_group_agents.py index 8bf6f87c..7cbb8324 100644 --- a/application/single_app/functions_group_agents.py +++ b/application/single_app/functions_group_agents.py @@ -63,16 +63,38 @@ def get_group_agent(group_id: str, agent_id: str) -> Optional[Dict[str, Any]]: return None -def save_group_agent(group_id: str, agent_data: Dict[str, Any]) -> Dict[str, Any]: +def save_group_agent(group_id: str, agent_data: Dict[str, Any], user_id: Optional[str] = None) -> Dict[str, Any]: """Create or update a group agent entry.""" payload = sanitize_agent_payload(agent_data) agent_id = payload.get("id") or str(uuid.uuid4()) payload["id"] = agent_id payload["group_id"] = group_id - payload["last_updated"] = datetime.utcnow().isoformat() + now = datetime.utcnow().isoformat() + payload["last_updated"] = now payload["is_global"] = False payload["is_group"] = True + # Track who created/modified this agent + existing_agent = None + try: + existing_agent = cosmos_group_agents_container.read_item( + item=agent_id, + partition_key=group_id, + ) + except exceptions.CosmosResourceNotFoundError: + pass + except Exception: + pass + + if existing_agent: + payload["created_by"] = existing_agent.get("created_by", user_id) + payload["created_at"] = existing_agent.get("created_at", now) + else: + payload["created_by"] = user_id + payload["created_at"] = now + payload["modified_by"] = user_id + payload["modified_at"] = now + # Required/defaulted fields payload.setdefault("name", "") payload.setdefault("display_name", payload.get("name", "")) diff --git a/application/single_app/functions_personal_actions.py b/application/single_app/functions_personal_actions.py index 6345438e..91d849f3 100644 --- a/application/single_app/functions_personal_actions.py +++ b/application/single_app/functions_personal_actions.py @@ -113,15 +113,26 @@ def save_personal_action(user_id, action_data): existing_action = get_personal_action(user_id, action_data['name']) # Preserve existing ID if updating, or generate new ID if creating + now = datetime.utcnow().isoformat() if existing_action: - # Update existing action - preserve the original ID + # Update existing action - preserve the original ID and creation tracking action_data['id'] = existing_action['id'] + action_data['created_by'] = existing_action.get('created_by', user_id) + action_data['created_at'] = existing_action.get('created_at', now) elif 'id' not in action_data or not action_data['id']: # New action - generate UUID for ID action_data['id'] = str(uuid.uuid4()) - + action_data['created_by'] = user_id + action_data['created_at'] = now + else: + # Has an ID but no existing action found - treat as new + action_data['created_by'] = user_id + action_data['created_at'] = now + action_data['modified_by'] = user_id + action_data['modified_at'] = now + action_data['user_id'] = user_id - action_data['last_updated'] = datetime.utcnow().isoformat() + action_data['last_updated'] = now # Validate required fields required_fields = ['name', 'displayName', 'type', 'description'] diff --git a/application/single_app/functions_personal_agents.py b/application/single_app/functions_personal_agents.py index a4a5e47d..3c6c275e 100644 --- a/application/single_app/functions_personal_agents.py +++ b/application/single_app/functions_personal_agents.py @@ -128,9 +128,33 @@ def save_personal_agent(user_id, agent_data): cleaned_agent.setdefault(field, '') if 'id' not in cleaned_agent: cleaned_agent['id'] = str(f"{user_id}_{cleaned_agent.get('name', 'default')}") - + + # Check if this is a new agent or an update to preserve created_by/created_at + existing_agent = None + try: + existing_agent = cosmos_personal_agents_container.read_item( + item=cleaned_agent['id'], + partition_key=user_id + ) + except exceptions.CosmosResourceNotFoundError: + pass + except Exception: + pass + + now = datetime.utcnow().isoformat() + if existing_agent: + # Preserve original creation tracking + cleaned_agent['created_by'] = existing_agent.get('created_by', user_id) + cleaned_agent['created_at'] = existing_agent.get('created_at', now) + else: + # New agent + cleaned_agent['created_by'] = user_id + cleaned_agent['created_at'] = now + cleaned_agent['modified_by'] = user_id + cleaned_agent['modified_at'] = now + cleaned_agent['user_id'] = user_id - cleaned_agent['last_updated'] = datetime.utcnow().isoformat() + cleaned_agent['last_updated'] = now cleaned_agent['is_global'] = False cleaned_agent['is_group'] = False diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index 57097ee5..2f631af7 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -23,6 +23,11 @@ from functions_appinsights import log_event from json_schema_validation import validate_agent from swagger_wrapper import swagger_route, get_auth_security +from functions_activity_logging import ( + log_agent_creation, + log_agent_update, + log_agent_deletion, +) bpa = Blueprint('admin_agents', __name__) @@ -147,6 +152,18 @@ def set_user_agents(): for agent_name in agents_to_delete: delete_personal_agent(user_id, agent_name) + # Log individual agent activities + for agent in filtered_agents: + a_name = agent.get('name', '') + a_id = agent.get('id', '') + a_display = agent.get('display_name', a_name) + if a_name in current_agent_names: + log_agent_update(user_id=user_id, agent_id=a_id, agent_name=a_name, agent_display_name=a_display, scope='personal') + else: + log_agent_creation(user_id=user_id, agent_id=a_id, agent_name=a_name, agent_display_name=a_display, scope='personal') + for agent_name in agents_to_delete: + log_agent_deletion(user_id=user_id, agent_id=agent_name, agent_name=agent_name, scope='personal') + log_event("User agents updated", extra={"user_id": user_id, "agents_count": len(filtered_agents)}) return jsonify({'success': True}) @@ -175,6 +192,9 @@ def delete_user_agent(agent_name): # Delete from personal_agents container delete_personal_agent(user_id, agent_name) + # Log agent deletion activity + log_agent_deletion(user_id=user_id, agent_id=agent_to_delete.get('id', agent_name), agent_name=agent_name, scope='personal') + # Check if there are any agents left and if they match global_selected_agent remaining_agents = get_personal_agents(user_id) if len(remaining_agents) > 0: @@ -270,11 +290,12 @@ def create_group_agent_route(): cleaned_payload.pop(key, None) try: - saved = save_group_agent(active_group, cleaned_payload) + saved = save_group_agent(active_group, cleaned_payload, user_id=user_id) except Exception as exc: debug_print('Failed to save group agent: %s', exc) return jsonify({'error': 'Unable to save agent'}), 500 + log_agent_creation(user_id=user_id, agent_id=saved.get('id', ''), agent_name=saved.get('name', ''), agent_display_name=saved.get('display_name', ''), scope='group', group_id=active_group) return jsonify(saved), 201 @@ -325,11 +346,12 @@ def update_group_agent_route(agent_id): return jsonify({'error': str(exc)}), 400 try: - saved = save_group_agent(active_group, cleaned_payload) + saved = save_group_agent(active_group, cleaned_payload, user_id=user_id) except Exception as exc: debug_print('Failed to update group agent %s: %s', agent_id, exc) return jsonify({'error': 'Unable to update agent'}), 500 + log_agent_update(user_id=user_id, agent_id=agent_id, agent_name=saved.get('name', ''), agent_display_name=saved.get('display_name', ''), scope='group', group_id=active_group) return jsonify(saved), 200 @@ -360,6 +382,7 @@ def delete_group_agent_route(agent_id): if not removed: return jsonify({'error': 'Agent not found'}), 404 + log_agent_deletion(user_id=user_id, agent_id=agent_id, agent_name=agent_id, scope='group', group_id=active_group) return jsonify({'message': 'Agent deleted'}), 200 # User endpoint to set selected agent (new model, not legacy default_agent) @@ -504,10 +527,11 @@ def add_agent(): cleaned_agent['id'] = '15b0c92a-741d-42ff-ba0b-367c7ee0c848' # Save to global agents container - result = save_global_agent(cleaned_agent) + result = save_global_agent(cleaned_agent, user_id=str(get_current_user_id())) if not result: return jsonify({'error': 'Failed to save agent.'}), 500 + log_agent_creation(user_id=str(get_current_user_id()), agent_id=cleaned_agent.get('id', ''), agent_name=cleaned_agent.get('name', ''), agent_display_name=cleaned_agent.get('display_name', ''), scope='global') log_event("Agent added", extra={"action": "add", "agent": {k: v for k, v in cleaned_agent.items() if k != 'id'}, "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- setattr(builtins, "kernel_reload_needed", True) @@ -615,10 +639,11 @@ def edit_agent(agent_name): return jsonify({'error': 'Agent not found.'}), 404 # Save the updated agent - result = save_global_agent(cleaned_agent) + result = save_global_agent(cleaned_agent, user_id=str(get_current_user_id())) if not result: return jsonify({'error': 'Failed to save agent.'}), 500 + log_agent_update(user_id=str(get_current_user_id()), agent_id=cleaned_agent.get('id', ''), agent_name=agent_name, agent_display_name=cleaned_agent.get('display_name', ''), scope='global') log_event( f"Agent {agent_name} edited", extra={ @@ -660,6 +685,7 @@ def delete_agent(agent_name): if not success: return jsonify({'error': 'Failed to delete agent.'}), 500 + log_agent_deletion(user_id=str(get_current_user_id()), agent_id=agent_to_delete.get('id', ''), agent_name=agent_name, scope='global') log_event("Agent deleted", extra={"action": "delete", "agent_name": agent_name, "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- setattr(builtins, "kernel_reload_needed", True) diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index 77aab866..63c7854e 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -32,6 +32,11 @@ from functions_debug import debug_print from json_schema_validation import validate_plugin +from functions_activity_logging import ( + log_action_creation, + log_action_update, + log_action_deletion, +) def discover_plugin_types(): # Dynamically discover allowed plugin types from available plugin classes. @@ -345,6 +350,19 @@ def set_user_plugins(): except Exception as e: debug_print(f"Error saving personal actions for user {user_id}: {e}") return jsonify({'error': 'Failed to save plugins'}), 500 + + # Log individual action activities + for plugin in filtered_plugins: + p_name = plugin.get('name', '') + p_id = plugin.get('id', '') + p_type = plugin.get('type', '') + if p_name in current_action_names: + log_action_update(user_id=user_id, action_id=p_id, action_name=p_name, action_type=p_type, scope='personal') + else: + log_action_creation(user_id=user_id, action_id=p_id, action_name=p_name, action_type=p_type, scope='personal') + for plugin_name in (current_action_names - new_plugin_names): + log_action_deletion(user_id=user_id, action_id=plugin_name, action_name=plugin_name, scope='personal') + log_event("User plugins updated", extra={"user_id": user_id, "plugins_count": len(filtered_plugins)}) return jsonify({'success': True}) @@ -360,6 +378,7 @@ def delete_user_plugin(plugin_name): if not deleted: return jsonify({'error': 'Plugin not found.'}), 404 + log_action_deletion(user_id=user_id, action_id=plugin_name, action_name=plugin_name, scope='personal') log_event("User plugin deleted", extra={"user_id": user_id, "plugin_name": plugin_name}) return jsonify({'success': True}) @@ -460,6 +479,13 @@ def create_group_action_route(): for key in ('group_id', 'last_updated', 'user_id', 'is_global', 'is_group', 'scope'): payload.pop(key, None) + # Handle endpoint based on plugin type (same logic as personal plugins) + plugin_type = payload.get('type', '') + if plugin_type in ['sql_schema', 'sql_query']: + payload.setdefault('endpoint', f'sql://{plugin_type}') + elif plugin_type == 'msgraph': + payload.setdefault('endpoint', 'https://graph.microsoft.com') + # Merge with schema to ensure all required fields are present (same as global actions) schema_dir = os.path.join(current_app.root_path, 'static', 'json', 'schemas') merged = get_merged_plugin_settings(payload.get('type'), payload, schema_dir) @@ -467,11 +493,12 @@ def create_group_action_route(): payload['additionalFields'] = merged.get('additionalFields', payload.get('additionalFields', {})) try: - saved = save_group_action(active_group, payload) + saved = save_group_action(active_group, payload, user_id=user_id) except Exception as exc: debug_print('Failed to save group action: %s', exc) return jsonify({'error': 'Unable to save action'}), 500 + log_action_creation(user_id=user_id, action_id=saved.get('id', ''), action_name=saved.get('name', ''), action_type=saved.get('type', ''), scope='group', group_id=active_group) return jsonify(saved), 201 @@ -516,6 +543,13 @@ def update_group_action_route(action_id): merged['is_group'] = True merged['id'] = existing.get('id', action_id) + # Handle endpoint based on plugin type (same logic as personal plugins) + plugin_type = merged.get('type', '') + if plugin_type in ['sql_schema', 'sql_query']: + merged.setdefault('endpoint', f'sql://{plugin_type}') + elif plugin_type == 'msgraph': + merged.setdefault('endpoint', 'https://graph.microsoft.com') + try: validate_group_action_payload(merged, partial=False) except ValueError as exc: @@ -528,11 +562,12 @@ def update_group_action_route(action_id): merged['additionalFields'] = schema_merged.get('additionalFields', merged.get('additionalFields', {})) try: - saved = save_group_action(active_group, merged) + saved = save_group_action(active_group, merged, user_id=user_id) except Exception as exc: debug_print('Failed to update group action %s: %s', action_id, exc) return jsonify({'error': 'Unable to update action'}), 500 + log_action_update(user_id=user_id, action_id=action_id, action_name=saved.get('name', ''), action_type=saved.get('type', ''), scope='group', group_id=active_group) return jsonify(saved), 200 @@ -563,6 +598,7 @@ def delete_group_action_route(action_id): if not removed: return jsonify({'error': 'Action not found'}), 404 + log_action_deletion(user_id=user_id, action_id=action_id, action_name=action_id, scope='group', group_id=active_group) return jsonify({'message': 'Action deleted'}), 200 @bpap.route('/api/user/plugins/types', methods=['GET']) @@ -692,9 +728,10 @@ def add_plugin(): new_plugin['id'] = plugin_id # Save to global actions container - save_global_action(new_plugin) + save_global_action(new_plugin, user_id=str(get_current_user_id())) - log_event("Plugin added", extra={"action": "add", "plugin": new_plugin, "user": str(getattr(request, 'user', 'unknown'))}) + log_action_creation(user_id=str(get_current_user_id()), action_id=plugin_id, action_name=new_plugin.get('name', ''), action_type=new_plugin.get('type', ''), scope='global') + log_event("Plugin added", extra={"action": "add", "plugin": new_plugin, "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- setattr(builtins, "kernel_reload_needed", True) @@ -753,9 +790,10 @@ def edit_plugin(plugin_name): # Delete old and save updated if 'id' in found_plugin: delete_global_action(found_plugin['id']) - save_global_action(updated_plugin) + save_global_action(updated_plugin, user_id=str(get_current_user_id())) - log_event("Plugin edited", extra={"action": "edit", "plugin": updated_plugin, "user": str(getattr(request, 'user', 'unknown'))}) + log_action_update(user_id=str(get_current_user_id()), action_id=updated_plugin.get('id', ''), action_name=plugin_name, action_type=updated_plugin.get('type', ''), scope='global') + log_event("Plugin edited", extra={"action": "edit", "plugin": updated_plugin, "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- setattr(builtins, "kernel_reload_needed", True) return jsonify({'success': True}) @@ -796,7 +834,8 @@ def delete_plugin(plugin_name): if 'id' in plugin_to_delete: delete_global_action(plugin_to_delete['id']) - log_event("Plugin deleted", extra={"action": "delete", "plugin_name": plugin_name, "user": str(getattr(request, 'user', 'unknown'))}) + log_action_deletion(user_id=str(get_current_user_id()), action_id=plugin_to_delete.get('id', ''), action_name=plugin_name, action_type=plugin_to_delete.get('type', ''), scope='global') + log_event("Plugin deleted", extra={"action": "delete", "plugin_name": plugin_name, "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- setattr(builtins, "kernel_reload_needed", True) return jsonify({'success': True}) @@ -928,4 +967,116 @@ def _merge_group_and_global_actions(group_actions, global_actions): return normalized_actions +@bpap.route('/api/plugins/test-sql-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_sql_connection(): + """Test a SQL database connection using provided configuration.""" + data = request.get_json(silent=True) or {} + database_type = (data.get('database_type') or 'sqlserver').lower() + connection_method = data.get('connection_method', 'parameters') + connection_string = data.get('connection_string', '') + server = data.get('server', '') + database = data.get('database', '') + port = data.get('port', '') + driver = data.get('driver', '') + username = data.get('username', '') + password = data.get('password', '') + auth_type = data.get('auth_type', 'username_password') + timeout = min(int(data.get('timeout', 10)), 15) # Cap at 15 seconds for test + + # Map azure_sql to sqlserver + if database_type in ('azure_sql', 'azuresql'): + database_type = 'sqlserver' + + try: + if database_type == 'sqlserver': + import pyodbc + if connection_method == 'connection_string' and connection_string: + conn = pyodbc.connect(connection_string, timeout=timeout) + else: + if not server or not database: + return jsonify({'success': False, 'error': 'Server and database are required for individual parameters connection.'}), 400 + drv = driver or 'ODBC Driver 17 for SQL Server' + conn_str = f"DRIVER={{{drv}}};SERVER={server};DATABASE={database}" + if port: + conn_str += f",{port}" + if auth_type == 'username_password' and username and password: + conn_str += f";UID={username};PWD={password}" + elif auth_type == 'managed_identity': + conn_str += ";Authentication=ActiveDirectoryMsi" + elif auth_type == 'integrated': + conn_str += ";Trusted_Connection=yes" + conn = pyodbc.connect(conn_str, timeout=timeout) + cursor = conn.cursor() + cursor.execute("SELECT 1") + cursor.close() + conn.close() + return jsonify({'success': True, 'message': f'Successfully connected to {data.get("database", "database")} on {data.get("server", "server")}.'}) + + elif database_type == 'postgresql': + import psycopg2 + if connection_method == 'connection_string' and connection_string: + conn = psycopg2.connect(connection_string, connect_timeout=timeout) + else: + if not server or not database: + return jsonify({'success': False, 'error': 'Server and database are required.'}), 400 + conn_params = {'host': server, 'database': database, 'connect_timeout': timeout} + if port: + conn_params['port'] = int(port) + if username: + conn_params['user'] = username + if password: + conn_params['password'] = password + conn = psycopg2.connect(**conn_params) + cursor = conn.cursor() + cursor.execute("SELECT 1") + cursor.close() + conn.close() + return jsonify({'success': True, 'message': f'Successfully connected to PostgreSQL database {data.get("database", "")}.'}) + + elif database_type == 'mysql': + import pymysql + if connection_method == 'connection_string' and connection_string: + # pymysql doesn't natively parse connection strings, so use params + return jsonify({'success': False, 'error': 'MySQL test connection requires individual parameters, not a connection string.'}), 400 + if not server or not database: + return jsonify({'success': False, 'error': 'Server and database are required.'}), 400 + conn_params = {'host': server, 'database': database, 'connect_timeout': timeout} + if port: + conn_params['port'] = int(port) + if username: + conn_params['user'] = username + if password: + conn_params['password'] = password + conn = pymysql.connect(**conn_params) + cursor = conn.cursor() + cursor.execute("SELECT 1") + cursor.close() + conn.close() + return jsonify({'success': True, 'message': f'Successfully connected to MySQL database {data.get("database", "")}.'}) + + elif database_type == 'sqlite': + import sqlite3 + db_path = connection_string or database + if not db_path: + return jsonify({'success': False, 'error': 'Database path is required for SQLite.'}), 400 + conn = sqlite3.connect(db_path, timeout=timeout) + cursor = conn.cursor() + cursor.execute("SELECT 1") + cursor.close() + conn.close() + return jsonify({'success': True, 'message': f'Successfully connected to SQLite database.'}) + + else: + return jsonify({'success': False, 'error': f'Unsupported database type: {database_type}'}), 400 + except ImportError as e: + return jsonify({'success': False, 'error': f'Database driver not installed: {str(e)}'}), 400 + except Exception as e: + error_msg = str(e) + # Sanitize error message to avoid leaking sensitive details + if 'password' in error_msg.lower() or 'pwd' in error_msg.lower(): + error_msg = 'Authentication failed. Please check your credentials.' + return jsonify({'success': False, 'error': f'Connection failed: {error_msg}'}), 400 diff --git a/application/single_app/route_backend_user_agreement.py b/application/single_app/route_backend_user_agreement.py index f46559ff..b76213b3 100644 --- a/application/single_app/route_backend_user_agreement.py +++ b/application/single_app/route_backend_user_agreement.py @@ -130,7 +130,7 @@ def api_accept_user_agreement(): return jsonify({"error": "workspace_id and workspace_type are required"}), 400 # Validate workspace type - valid_types = ["personal", "group", "public"] + valid_types = ["personal", "group", "public", "chat"] if workspace_type not in valid_types: return jsonify({"error": f"Invalid workspace_type. Must be one of: {', '.join(valid_types)}"}), 400 diff --git a/application/single_app/static/css/styles.css b/application/single_app/static/css/styles.css index e537590d..eacc8859 100644 --- a/application/single_app/static/css/styles.css +++ b/application/single_app/static/css/styles.css @@ -502,6 +502,95 @@ main { flex-grow: 1; } +/* ============================================ + Item cards (agents/actions grid view) + ============================================ */ +.item-card { + cursor: default; + transition: all 0.3s ease; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + background-color: #ffffff; +} + +.item-card:hover { + border-color: #adb5bd; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.item-card .card-title { + font-weight: 600; + font-size: 0.9rem; + color: #212529; +} + +.item-card .card-text { + color: #6c757d; + font-size: 0.8rem; + line-height: 1.4; +} + +.item-card .item-card-icon { + color: #0d6efd; +} + +.item-card .item-card-buttons { + border-top: 1px solid #f0f0f0; + padding-top: 0.5rem; +} + +/* Dark mode for item cards */ +[data-bs-theme="dark"] .item-card { + background-color: #343a40; + border: 1px solid #495057; + color: #e9ecef; +} + +[data-bs-theme="dark"] .item-card:hover { + background-color: #3d444b; + border-color: #6c757d; +} + +[data-bs-theme="dark"] .item-card .card-title { + color: #e9ecef; +} + +[data-bs-theme="dark"] .item-card .card-text { + color: #adb5bd; +} + +[data-bs-theme="dark"] .item-card .item-card-icon { + color: #6ea8fe; +} + +[data-bs-theme="dark"] .item-card .item-card-buttons { + border-top-color: #495057; +} + +/* Improved table column layout for agents and actions */ +.item-list-table th:nth-child(1), +.item-list-table td:nth-child(1) { + width: 28%; + min-width: 140px; +} + +.item-list-table th:nth-child(2), +.item-list-table td:nth-child(2) { + width: 47%; + max-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.item-list-table th:nth-child(3), +.item-list-table td:nth-child(3) { + width: 25%; + min-width: 160px; + white-space: nowrap; +} + /* Connection type buttons */ .connection-type-btn { border: 2px solid #dee2e6; @@ -854,3 +943,171 @@ main { [data-bs-theme="dark"] .message-content a:visited { color: #b399ff !important; /* Purple-ish for visited links */ } + +/* ============================================ + Rendered Markdown — table & code block styles + Shared by agent detail view, template preview, + and any non-chat area that renders Markdown. + ============================================ */ + +/* --- Tables --- */ +.rendered-markdown table { + width: 100%; + max-width: 100%; + margin: 0.75rem 0; + border-collapse: collapse; + border-spacing: 0; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + overflow: hidden; + background-color: var(--bs-body-bg); + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + font-size: 0.875rem; + display: block; + overflow-x: auto; + white-space: nowrap; + -webkit-overflow-scrolling: touch; +} + +@media (min-width: 768px) { + .rendered-markdown table { + display: table; + white-space: normal; + } +} + +.rendered-markdown table th, +.rendered-markdown table td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #dee2e6; + border-right: 1px solid #dee2e6; + text-align: left; + vertical-align: top; + word-wrap: break-word; + line-height: 1.4; +} + +.rendered-markdown table th:last-child, +.rendered-markdown table td:last-child { + border-right: none; +} + +.rendered-markdown table thead th { + background-color: #f8f9fa; + font-weight: 600; + color: #495057; + border-bottom: 2px solid #dee2e6; +} + +.rendered-markdown table tbody tr:nth-child(even) { + background-color: rgba(0, 0, 0, 0.02); +} + +.rendered-markdown table tbody tr:hover { + background-color: rgba(0, 0, 0, 0.04); + transition: background-color 0.15s ease-in-out; +} + +.rendered-markdown table th[align="center"], +.rendered-markdown table td[align="center"] { + text-align: center; +} + +.rendered-markdown table th[align="right"], +.rendered-markdown table td[align="right"] { + text-align: right; +} + +/* Dark mode tables */ +[data-bs-theme="dark"] .rendered-markdown table { + border-color: #495057; + background-color: var(--bs-dark); + color: #e9ecef; +} + +[data-bs-theme="dark"] .rendered-markdown table th, +[data-bs-theme="dark"] .rendered-markdown table td { + border-color: #495057; +} + +[data-bs-theme="dark"] .rendered-markdown table thead th { + background-color: #343a40; + color: #e9ecef; + border-bottom-color: #495057; +} + +[data-bs-theme="dark"] .rendered-markdown table tbody tr:nth-child(even) { + background-color: rgba(255, 255, 255, 0.05); +} + +[data-bs-theme="dark"] .rendered-markdown table tbody tr:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.rendered-markdown table code { + background-color: rgba(0, 0, 0, 0.1); + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-size: 0.8em; +} + +[data-bs-theme="dark"] .rendered-markdown table code { + background-color: rgba(255, 255, 255, 0.1); +} + +/* --- Code blocks --- */ +.rendered-markdown pre, +.rendered-markdown pre[class*="language-"] { + overflow-x: auto; + max-width: 100%; + width: 100%; + box-sizing: border-box; + display: block; + white-space: pre; + background-color: #1e1e1e; + color: #d4d4d4; + border-radius: 0.375rem; + padding: 1rem; + margin: 0.75rem 0; + font-size: 0.85rem; + line-height: 1.5; +} + +.rendered-markdown pre code { + display: block; + min-width: 0; + max-width: 100%; + overflow-x: auto; + white-space: pre; + background: transparent; + color: inherit; + padding: 0; + font-size: inherit; +} + +/* Inline code */ +.rendered-markdown code:not(pre code) { + background-color: rgba(0, 0, 0, 0.06); + padding: 0.15rem 0.35rem; + border-radius: 0.25rem; + font-size: 0.85em; + color: #d63384; +} + +[data-bs-theme="dark"] .rendered-markdown code:not(pre code) { + background-color: rgba(255, 255, 255, 0.1); + color: #e685b5; +} + +/* Blockquotes */ +.rendered-markdown blockquote { + border-left: 4px solid #dee2e6; + padding-left: 1em; + color: #6c757d; + margin: 0.75rem 0; +} + +[data-bs-theme="dark"] .rendered-markdown blockquote { + border-left-color: #495057; + color: #adb5bd; +} diff --git a/application/single_app/static/js/plugin_common.js b/application/single_app/static/js/plugin_common.js index e40158b9..29a88a24 100644 --- a/application/single_app/static/js/plugin_common.js +++ b/application/single_app/static/js/plugin_common.js @@ -2,6 +2,10 @@ // Shared logic for admin_plugins.js and workspace_plugins.js // Exports: functions for modal field handling, validation, label toggling, table rendering, and plugin CRUD import { showToast } from "./chat/chat-toast.js" +import { + humanizeName, truncateDescription, + openViewModal, createActionCard +} from './workspace/view-utils.js'; // Fetch merged plugin settings from backend given type and current settings export async function fetchAndMergePluginSettings(pluginType, currentSettings = {}) { @@ -60,8 +64,7 @@ export function escapeHtml(str) { } // Render plugins table (parameterized for tbody selector and button handlers) -export function renderPluginsTable({plugins, tbodySelector, onEdit, onDelete, ensureTable = true, isAdmin = false}) { - console.log('Rendering plugins table with %d plugins', plugins.length); +export function renderPluginsTable({plugins, tbodySelector, onEdit, onDelete, onView, ensureTable = true, isAdmin = false}) { // Optionally ensure the table is present before rendering if (ensureTable) { ensurePluginsTableInRoot(); @@ -75,29 +78,33 @@ export function renderPluginsTable({plugins, tbodySelector, onEdit, onDelete, en plugins.forEach(plugin => { const tr = document.createElement('tr'); const safeName = escapeHtml(plugin.name); - const safeDisplayName = escapeHtml(plugin.display_name || plugin.name); - const safeDesc = escapeHtml(plugin.description || 'No description available'); + const displayName = humanizeName(plugin.display_name || plugin.name); + const safeDisplayName = escapeHtml(displayName); + const description = plugin.description || 'No description available'; + const truncatedDesc = escapeHtml(truncateDescription(description, 90)); let actionButtons = ''; let globalBadge = plugin.is_global ? ' Global' : ''; - // Show action buttons for: - // - Admin context: all actions (global and personal) - // - User context: only personal actions (not global) + // View button always shown + let viewButton = ``; + + // Edit/Delete buttons based on context + let editDeleteButtons = ''; if (isAdmin || !plugin.is_global) { - actionButtons = ` -
+ editDeleteButtons = ` -
- `; + `; } + actionButtons = `
${viewButton}${editDeleteButtons}
`; tr.innerHTML = ` - ${safeDisplayName}${globalBadge} - ${safeDesc} + ${safeDisplayName}${globalBadge} + ${truncatedDesc} ${actionButtons} `; tbody.appendChild(tr); @@ -109,6 +116,34 @@ export function renderPluginsTable({plugins, tbodySelector, onEdit, onDelete, en tbody.querySelectorAll('.delete-plugin-btn').forEach(btn => { btn.onclick = () => onDelete(btn.getAttribute('data-plugin-name')); }); + tbody.querySelectorAll('.view-plugin-btn').forEach(btn => { + btn.onclick = () => { + if (onView) { + onView(btn.getAttribute('data-plugin-name')); + } + }; + }); +} + +// Render plugins grid (card-based view) +export function renderPluginsGrid({plugins, containerSelector, onEdit, onDelete, onView, isAdmin = false}) { + const container = document.querySelector(containerSelector); + if (!container) return; + container.innerHTML = ''; + if (!plugins.length) { + container.innerHTML = '
No actions found.
'; + return; + } + plugins.forEach(plugin => { + const card = createActionCard(plugin, { + onView: (p) => { if (onView) onView(p.name); }, + onEdit: (p) => onEdit(p.name), + onDelete: (p) => onDelete(p.name), + canManage: isAdmin || !plugin.is_global, + isAdmin + }); + container.appendChild(card); + }); } // Toggle auth fields and labels (parameterized for DOM elements) diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js index 89076076..aa5b4e01 100644 --- a/application/single_app/static/js/plugin_modal_stepper.js +++ b/application/single_app/static/js/plugin_modal_stepper.js @@ -1,6 +1,10 @@ // plugin_modal_stepper.js // Multi-step modal functionality for action/plugin creation import { showToast } from "./chat/chat-toast.js"; +import { getTypeIcon } from "./workspace/view-utils.js"; + +// Action types hidden from the creation UI (backend plugins remain intact) +const HIDDEN_ACTION_TYPES = ['sql_schema', 'ui_test', 'queue_storage', 'blob_storage', 'embedding_model']; export class PluginModalStepper { @@ -129,6 +133,12 @@ export class PluginModalStepper { document.getElementById('sql-auth-type').addEventListener('change', () => this.handleSqlAuthTypeChange()); + // Test SQL connection button + const testConnBtn = document.getElementById('sql-test-connection-btn'); + if (testConnBtn) { + testConnBtn.addEventListener('click', () => this.testSqlConnection()); + } + // Set up display name to generated name conversion this.setupNameGeneration(); @@ -193,6 +203,8 @@ export class PluginModalStepper { if (!res.ok) throw new Error('Failed to load action types'); this.availableTypes = await res.json(); + // Hide deprecated/internal action types from the creation UI + this.availableTypes = this.availableTypes.filter(t => !HIDDEN_ACTION_TYPES.includes(t.type)); // Sort action types alphabetically by display name this.availableTypes.sort((a, b) => { const nameA = (a.display || a.displayName || a.type || a.name || '').toLowerCase(); @@ -271,10 +283,15 @@ export class PluginModalStepper { description.substring(0, maxLength) + '...' : description; const needsTruncation = description.length > maxLength; + const iconClass = getTypeIcon(type.type || type.name); + col.innerHTML = `
-
${this.escapeHtml(displayName)}
+
+ +
${this.escapeHtml(displayName)}
+

${this.escapeHtml(truncatedDescription)} ${needsTruncation ? ` @@ -538,43 +555,52 @@ export class PluginModalStepper { } if (stepNumber === 4) { - // Load additional settings schema for selected type - let options = {forceReload: true}; - this.getAdditionalSettingsSchema(this.selectedType, options); + const isSqlType = this.selectedType === 'sql_query' || this.selectedType === 'sql_schema'; const additionalFieldsDiv = document.getElementById('plugin-additional-fields-div'); - if (additionalFieldsDiv) { - // Only clear and rebuild if type changes - if (this.selectedType !== this.lastAdditionalFieldsType) { - additionalFieldsDiv.innerHTML = ''; - additionalFieldsDiv.classList.remove('d-none'); - if (this.selectedType) { - this.getAdditionalSettingsSchema(this.selectedType) - .then(schema => { - if (schema) { - this.buildAdditionalFieldsUI(schema, additionalFieldsDiv); - try { - if (this.isEditMode && this.originalPlugin && this.originalPlugin.additionalFields) { - this.populateDynamicAdditionalFields(this.originalPlugin.additionalFields); + + // For SQL types, hide additional fields entirely since Step 3 covers all SQL config + if (isSqlType && additionalFieldsDiv) { + additionalFieldsDiv.innerHTML = ''; + additionalFieldsDiv.classList.add('d-none'); + this.lastAdditionalFieldsType = this.selectedType; + } else { + // Load additional settings schema for selected type + let options = {forceReload: true}; + this.getAdditionalSettingsSchema(this.selectedType, options); + if (additionalFieldsDiv) { + // Only clear and rebuild if type changes + if (this.selectedType !== this.lastAdditionalFieldsType) { + additionalFieldsDiv.innerHTML = ''; + additionalFieldsDiv.classList.remove('d-none'); + if (this.selectedType) { + this.getAdditionalSettingsSchema(this.selectedType) + .then(schema => { + if (schema) { + this.buildAdditionalFieldsUI(schema, additionalFieldsDiv); + try { + if (this.isEditMode && this.originalPlugin && this.originalPlugin.additionalFields) { + this.populateDynamicAdditionalFields(this.originalPlugin.additionalFields); + } + } catch (error) { + console.error('Error populating dynamic additional fields:', error); } - } catch (error) { - console.error('Error populating dynamic additional fields:', error); + } else { + console.log('No additional settings schema found'); + additionalFieldsDiv.classList.add('d-none'); } - } else { - console.log('No additional settings schema found'); + }) + .catch(error => { + console.error(`Error fetching additional settings schema for type: ${this.selectedType} -- ${error}`); additionalFieldsDiv.classList.add('d-none'); - } - }) - .catch(error => { - console.error(`Error fetching additional settings schema for type: ${this.selectedType} -- ${error}`); - additionalFieldsDiv.classList.add('d-none'); - }); - } else { - console.warn('No plugin type selected'); - additionalFieldsDiv.classList.add('d-none'); + }); + } else { + console.warn('No plugin type selected'); + additionalFieldsDiv.classList.add('d-none'); + } + this.lastAdditionalFieldsType = this.selectedType; } - this.lastAdditionalFieldsType = this.selectedType; + // Otherwise, preserve user data and do not redraw } - // Otherwise, preserve user data and do not redraw } if (!this.isEditMode) { @@ -1230,6 +1256,80 @@ export class PluginModalStepper { this.updateSqlAuthInfo(); } + async testSqlConnection() { + const btn = document.getElementById('sql-test-connection-btn'); + const resultDiv = document.getElementById('sql-test-connection-result'); + const alertDiv = document.getElementById('sql-test-connection-alert'); + if (!btn || !resultDiv || !alertDiv) return; + + // Collect current SQL config from Step 3 + const databaseType = document.querySelector('input[name="sql-database-type"]:checked')?.value; + const connectionMethod = document.querySelector('input[name="sql-connection-method"]:checked')?.value || 'parameters'; + const authType = document.getElementById('sql-auth-type')?.value || 'username_password'; + + if (!databaseType) { + resultDiv.classList.remove('d-none'); + alertDiv.className = 'alert alert-warning mb-0 py-2 px-3 small'; + alertDiv.textContent = 'Please select a database type first.'; + return; + } + + const payload = { + database_type: databaseType, + connection_method: connectionMethod, + auth_type: authType + }; + + if (connectionMethod === 'connection_string') { + payload.connection_string = document.getElementById('sql-connection-string')?.value?.trim() || ''; + } else { + payload.server = document.getElementById('sql-server')?.value?.trim() || ''; + payload.database = document.getElementById('sql-database')?.value?.trim() || ''; + payload.port = document.getElementById('sql-port')?.value?.trim() || ''; + if (databaseType === 'sqlserver' || databaseType === 'azure_sql') { + payload.driver = document.getElementById('sql-driver')?.value || ''; + } + } + + if (authType === 'username_password') { + payload.username = document.getElementById('sql-username')?.value?.trim() || ''; + payload.password = document.getElementById('sql-password')?.value?.trim() || ''; + } + + payload.timeout = parseInt(document.getElementById('sql-timeout')?.value) || 10; + + // Show loading state + const originalText = btn.innerHTML; + btn.innerHTML = 'Testing...'; + btn.disabled = true; + resultDiv.classList.add('d-none'); + + try { + const response = await fetch('/api/plugins/test-sql-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 = '' + (data.message || 'Connection successful!'); + } else { + alertDiv.className = 'alert alert-danger mb-0 py-2 px-3 small'; + alertDiv.innerHTML = '' + (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: ' + (error.message || 'Network error'); + } finally { + btn.innerHTML = originalText; + btn.disabled = false; + } + } + updateSqlConnectionExamples() { const selectedType = document.querySelector('input[name="sql-database-type"]:checked')?.value; const examplesDiv = document.getElementById('sql-connection-examples'); @@ -1720,12 +1820,17 @@ export class PluginModalStepper { // Collect additional fields from the dynamic UI and MERGE with existing additionalFields // This preserves OpenAPI spec content and other auto-populated fields - try { - const dynamicFields = this.collectAdditionalFields(); - // Merge dynamicFields into additionalFields (preserving existing values) - additionalFields = { ...additionalFields, ...dynamicFields }; - } catch (e) { - throw new Error('Invalid additional fields input'); + // For SQL types, Step 3 already provides all necessary config — skip dynamic field merge + // to prevent empty Step 4 fields from overwriting populated Step 3 values + const isSqlType = this.selectedType === 'sql_query' || this.selectedType === 'sql_schema'; + if (!isSqlType) { + try { + const dynamicFields = this.collectAdditionalFields(); + // Merge dynamicFields into additionalFields (preserving existing values) + additionalFields = { ...additionalFields, ...dynamicFields }; + } catch (e) { + throw new Error('Invalid additional fields input'); + } } let metadata = {}; @@ -2106,6 +2211,7 @@ export class PluginModalStepper { populateAdvancedSummary() { const advancedSection = document.getElementById('summary-advanced-section'); + const isSqlType = this.selectedType === 'sql_query' || this.selectedType === 'sql_schema'; // Check if there's any metadata or additional fields const metadata = document.getElementById('plugin-metadata').value.trim(); @@ -2123,9 +2229,33 @@ export class PluginModalStepper { hasMetadata = metadata.length > 0 && metadata !== '{}'; } - // DRY: Use private helper to collect additional fields - let additionalFieldsObj = this.collectAdditionalFields(); - hasAdditionalFields = Object.keys(additionalFieldsObj).length > 0; + // For SQL types, additional fields are already shown in the SQL Database Configuration + // summary section, so skip showing them again in Advanced to avoid redundancy + if (!isSqlType) { + // DRY: Use private helper to collect additional fields + let additionalFieldsObj = this.collectAdditionalFields(); + hasAdditionalFields = Object.keys(additionalFieldsObj).length > 0; + + // Show/hide additional fields preview + const additionalFieldsPreview = document.getElementById('summary-additional-fields-preview'); + if (hasAdditionalFields) { + let previewContent = ''; + if (typeof additionalFieldsObj === 'object' && additionalFieldsObj !== null) { + previewContent = JSON.stringify(additionalFieldsObj, null, 2); + } else { + previewContent = ''; + } + document.getElementById('summary-additional-fields-content').textContent = previewContent; + additionalFieldsPreview.style.display = ''; + } else { + additionalFieldsPreview.style.display = 'none'; + } + } else { + // Hide additional fields for SQL types + const additionalFieldsPreview = document.getElementById('summary-additional-fields-preview'); + if (additionalFieldsPreview) additionalFieldsPreview.style.display = 'none'; + hasAdditionalFields = false; + } // Update has metadata/additional fields indicators document.getElementById('summary-has-metadata').textContent = hasMetadata ? 'Yes' : 'No'; @@ -2140,21 +2270,6 @@ export class PluginModalStepper { metadataPreview.style.display = 'none'; } - // Show/hide additional fields preview - const additionalFieldsPreview = document.getElementById('summary-additional-fields-preview'); - if (hasAdditionalFields) { - let previewContent = ''; - if (typeof additionalFieldsObj === 'object' && additionalFieldsObj !== null) { - previewContent = JSON.stringify(additionalFieldsObj, null, 2); - } else { - previewContent = ''; - } - document.getElementById('summary-additional-fields-content').textContent = previewContent; - additionalFieldsPreview.style.display = ''; - } else { - additionalFieldsPreview.style.display = 'none'; - } - // Show advanced section if there's any advanced content if (hasMetadata || hasAdditionalFields) { advancedSection.style.display = ''; diff --git a/application/single_app/static/js/workspace/group_agents.js b/application/single_app/static/js/workspace/group_agents.js index f97dbd07..608f029e 100644 --- a/application/single_app/static/js/workspace/group_agents.js +++ b/application/single_app/static/js/workspace/group_agents.js @@ -4,16 +4,23 @@ import { showToast } from "../chat/chat-toast.js"; import * as agentsCommon from "../agents_common.js"; import { AgentModalStepper } from "../agent_modal_stepper.js"; +import { + humanizeName, truncateDescription, escapeHtml as escapeHtmlUtil, + setupViewToggle, switchViewContainers, openViewModal, createAgentCard +} from './view-utils.js'; const tableBody = document.getElementById("group-agents-table-body"); const errorContainer = document.getElementById("group-agents-error"); const searchInput = document.getElementById("group-agents-search"); const createButton = document.getElementById("create-group-agent-btn"); const permissionWarning = document.getElementById("group-agents-permission-warning"); +const agentsListView = document.getElementById("group-agents-list-view"); +const agentsGridView = document.getElementById("group-agents-grid-view"); let agents = []; let filteredAgents = []; let agentStepper = null; +let currentViewMode = 'list'; let currentContext = window.groupWorkspaceContext || { activeGroupId: null, activeGroupName: "", @@ -21,14 +28,7 @@ let currentContext = window.groupWorkspaceContext || { }; function escapeHtml(value) { - if (!value) return ""; - return value.replace(/[&<>"']/g, (char) => ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'" - }[char] || char)); + return escapeHtmlUtil(value); } function canManageAgents() { @@ -46,6 +46,7 @@ function groupAllowsModifications() { } function truncateName(name, maxLength = 18) { + // Kept for backward compat; prefer humanizeName for display if (!name || name.length <= maxLength) return name || ""; return `${name.substring(0, maxLength)}…`; } @@ -114,29 +115,61 @@ function renderAgentsTable(list) { list.forEach((agent) => { const tr = document.createElement("tr"); - const displayName = truncateName(agent.display_name || agent.displayName || agent.name || ""); - const description = escapeHtml(agent.description || "No description available."); - - let actionsHtml = ""; + const rawName = agent.display_name || agent.displayName || agent.name || ""; + const displayName = humanizeName(rawName); + const fullDesc = agent.description || "No description available."; + const shortDesc = truncateDescription(fullDesc, 90); + + let actionsHtml = ` + + `; if (canManage) { - actionsHtml = ` - - `; } tr.innerHTML = ` - ${escapeHtml(displayName)} - ${description} + ${escapeHtml(displayName)} + ${escapeHtml(shortDesc)} ${actionsHtml}`; tableBody.appendChild(tr); }); } +function renderAgentsGrid(list) { + if (!agentsGridView) return; + agentsGridView.innerHTML = ''; + + if (!list.length) { + agentsGridView.innerHTML = '

No group agents found.
'; + return; + } + + const canManage = canManageAgents() && groupAllowsModifications(); + list.forEach(agent => { + const col = createAgentCard(agent, { + onChat: a => chatWithGroupAgent(a.name || a), + onView: a => openGroupAgentViewModal(a), + onEdit: canManage ? a => { + const found = agents.find(x => x.id === (a.id || a.name || a) || x.name === (a.name || a)); + openAgentModal(found || null); + } : null, + onDelete: canManage ? a => deleteGroupAgent(a.id || a.name || a) : null + }); + agentsGridView.appendChild(col); + }); +} + function filterAgents(term) { if (!term) { filteredAgents = agents.slice(); @@ -149,6 +182,23 @@ function filterAgents(term) { }); } renderAgentsTable(filteredAgents); + renderAgentsGrid(filteredAgents); +} + +// Open the view modal for a group agent with Chat/Edit/Delete actions +function openGroupAgentViewModal(agent) { + const canManage = canManageAgents() && groupAllowsModifications(); + const callbacks = { + onChat: (a) => chatWithGroupAgent(a.name) + }; + if (canManage) { + callbacks.onEdit = (a) => { + const found = agents.find(x => x.id === a.id || x.name === a.name); + openAgentModal(found || a); + }; + callbacks.onDelete = (a) => deleteGroupAgent(a.id || a.name); + } + openViewModal(agent, 'agent', callbacks); } function overrideAgentStepper(stepper) { @@ -343,7 +393,57 @@ async function fetchGroupAgents() { } } +async function chatWithGroupAgent(agentName) { + try { + const agent = agents.find(a => a.name === agentName); + if (!agent) { + throw new Error("Agent not found"); + } + + const payloadData = { + selected_agent: { + name: agentName, + display_name: agent.display_name || agent.displayName || agentName, + is_global: !!agent.is_global, + is_group: true, + group_id: currentContext.activeGroupId, + group_name: currentContext.activeGroupName + } + }; + + const resp = await fetch("/api/user/settings/selected_agent", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payloadData) + }); + + if (!resp.ok) { + throw new Error("Failed to select agent"); + } + + window.location.href = "/chats"; + } catch (err) { + console.error("Error selecting group agent for chat:", err); + showToast("Error selecting agent for chat. Please try again.", "danger"); + } +} + function handleTableClick(event) { + const viewBtn = event.target.closest(".view-group-agent-btn"); + if (viewBtn) { + const agentName = viewBtn.dataset.agentName; + const agent = agents.find(a => a.name === agentName); + if (agent) openGroupAgentViewModal(agent); + return; + } + + const chatBtn = event.target.closest(".chat-group-agent-btn"); + if (chatBtn) { + const agentName = chatBtn.dataset.agentName; + chatWithGroupAgent(agentName); + return; + } + const editBtn = event.target.closest(".edit-group-agent-btn"); if (editBtn) { const agentId = editBtn.dataset.agentId; @@ -384,6 +484,11 @@ function initialize() { updatePermissionUI(); bindEventHandlers(); + setupViewToggle('groupAgents', 'groupAgentsViewPreference', (mode) => { + currentViewMode = mode; + switchViewContainers(mode, agentsListView, agentsGridView); + }); + if (document.getElementById("group-agents-tab-btn")?.classList.contains("active")) { fetchGroupAgents(); } diff --git a/application/single_app/static/js/workspace/group_plugins.js b/application/single_app/static/js/workspace/group_plugins.js index 60a7f42e..8acdf5bd 100644 --- a/application/single_app/static/js/workspace/group_plugins.js +++ b/application/single_app/static/js/workspace/group_plugins.js @@ -3,6 +3,10 @@ import { ensurePluginsTableInRoot, validatePluginManifest } from "../plugin_common.js"; import { showToast } from "../chat/chat-toast.js"; +import { + humanizeName, truncateDescription, escapeHtml as escapeHtmlUtil, + setupViewToggle, switchViewContainers, openViewModal, createActionCard +} from './view-utils.js'; const root = document.getElementById("group-plugins-root"); const permissionWarning = document.getElementById("group-plugins-permission-warning"); @@ -11,6 +15,7 @@ let plugins = []; let filteredPlugins = []; let templateReady = false; let listenersBound = false; +let currentViewMode = 'list'; let currentContext = window.groupWorkspaceContext || { activeGroupId: null, activeGroupName: "", @@ -18,14 +23,7 @@ let currentContext = window.groupWorkspaceContext || { }; function escapeHtml(value) { - if (!value) return ""; - return value.replace(/[&<>"']/g, (char) => ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'" - }[char] || char)); + return escapeHtmlUtil(value); } function canManagePlugins() { @@ -66,6 +64,14 @@ function bindRootEvents() { }); root.addEventListener("click", async (event) => { + const viewBtn = event.target.closest(".view-group-plugin-btn"); + if (viewBtn) { + const pluginId = viewBtn.dataset.pluginId; + const plugin = plugins.find(x => x.id === pluginId || x.name === pluginId); + if (plugin) openGroupPluginViewModal(plugin); + return; + } + const createBtn = event.target.closest("#create-group-plugin-btn"); if (createBtn) { event.preventDefault(); @@ -148,23 +154,28 @@ function renderPluginsTable(list) { const canManage = canManagePlugins() && groupAllowsModifications(); list.forEach((plugin) => { const tr = document.createElement("tr"); - const displayName = plugin.displayName || plugin.display_name || plugin.name || ""; - const description = plugin.description || "No description available."; + const rawName = plugin.displayName || plugin.display_name || plugin.name || ""; + const displayName = humanizeName(rawName); + const fullDesc = plugin.description || "No description available."; + const shortDesc = truncateDescription(fullDesc, 90); const isGlobal = Boolean(plugin.is_global); - let actionsHtml = ""; + // View button always visible + let actionsHtml = ` + `; + if (canManage && !isGlobal) { - actionsHtml = ` -
- - -
`; + actionsHtml += ` + + `; } else if (canManage && isGlobal) { - actionsHtml = "Managed globally"; + actionsHtml += `Managed globally`; } const titleHtml = isGlobal @@ -172,14 +183,36 @@ function renderPluginsTable(list) { : escapeHtml(displayName); tr.innerHTML = ` - ${titleHtml} - ${escapeHtml(description)} + ${titleHtml} + ${escapeHtml(shortDesc)} ${actionsHtml}`; tbody.appendChild(tr); }); } +function renderPluginsGrid(list) { + const gridView = document.getElementById('group-plugins-grid-view'); + if (!gridView) return; + gridView.innerHTML = ''; + + if (!list.length) { + gridView.innerHTML = '
No group actions found.
'; + return; + } + + const canManage = canManagePlugins() && groupAllowsModifications(); + list.forEach(plugin => { + const isGlobal = Boolean(plugin.is_global); + const col = createActionCard(plugin, { + onView: p => openGroupPluginViewModal(p), + onEdit: (canManage && !isGlobal) ? p => openPluginModal(p.id || p.name) : null, + onDelete: (canManage && !isGlobal) ? p => deleteGroupPlugin(p.id || p.name) : null + }); + gridView.appendChild(col); + }); +} + function filterPlugins(term) { if (!term) { filteredPlugins = plugins.slice(); @@ -192,6 +225,19 @@ function filterPlugins(term) { }); } renderPluginsTable(filteredPlugins); + renderPluginsGrid(filteredPlugins); +} + +// Open the view modal for a group action with Edit/Delete actions +function openGroupPluginViewModal(plugin) { + const canManage = canManagePlugins() && groupAllowsModifications(); + const isGlobal = Boolean(plugin.is_global); + const callbacks = {}; + if (canManage && !isGlobal) { + callbacks.onEdit = (p) => openPluginModal(p.id || p.name); + callbacks.onDelete = (p) => deleteGroupPlugin(p.id || p.name); + } + openViewModal(plugin, 'action', callbacks); } async function fetchGroupPlugins() { @@ -220,7 +266,17 @@ async function fetchGroupPlugins() { filteredPlugins = plugins.slice(); renderPluginsTable(filteredPlugins); + renderPluginsGrid(filteredPlugins); updatePermissionUI(); + + // Set up view toggle (only once after template is in DOM) + setupViewToggle('groupPlugins', 'groupPluginsViewPreference', (mode) => { + currentViewMode = mode; + switchViewContainers(mode, + document.getElementById('group-plugins-list-view'), + document.getElementById('group-plugins-grid-view') + ); + }); } catch (error) { console.error("Error loading group actions:", error); renderError(error.message || "Unable to load group actions."); diff --git a/application/single_app/static/js/workspace/view-utils.js b/application/single_app/static/js/workspace/view-utils.js new file mode 100644 index 00000000..3b78bc15 --- /dev/null +++ b/application/single_app/static/js/workspace/view-utils.js @@ -0,0 +1,523 @@ +// view-utils.js +// Shared utilities for list/grid view toggle, name humanization, and view modal +// Used by personal and group agents/actions workspace modules + +/** + * Convert a technical name to a human-readable display name. + * Handles underscores, camelCase, PascalCase, and consecutive uppercase. + * Examples: + * "sql_query" → "Sql Query" + * "myAgentName" → "My Agent Name" + * "OpenAPIPlugin" → "Open API Plugin" + * "log_analytics" → "Log Analytics" + */ +export function humanizeName(name) { + if (!name) return ""; + // Replace underscores and hyphens with spaces + let result = name.replace(/[_-]/g, " "); + // Insert space before uppercase letters that follow lowercase letters (camelCase) + result = result.replace(/([a-z])([A-Z])/g, "$1 $2"); + // Insert space between consecutive uppercase followed by lowercase (e.g., "APIPlugin" → "API Plugin") + result = result.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2"); + // Capitalize first letter of each word + result = result.replace(/\b\w/g, (c) => c.toUpperCase()); + // Collapse multiple spaces + result = result.replace(/\s+/g, " ").trim(); + return result; +} + +/** + * Truncate a description string to maxLen characters, appending "…" if truncated. + */ +export function truncateDescription(text, maxLen = 100) { + if (!text) return ""; + if (text.length <= maxLen) return text; + return text.substring(0, maxLen).trimEnd() + "…"; +} + +/** + * Escape HTML entities to prevent XSS. + */ +export function escapeHtml(str) { + if (!str) return ""; + return str.replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]) + ); +} + +/** + * Get an appropriate Bootstrap icon class for an action/plugin type. + */ +export function getTypeIcon(type) { + if (!type) return "bi-lightning-charge"; + const t = type.toLowerCase(); + if (t.includes("sql")) return "bi-database"; + if (t.includes("openapi")) return "bi-globe"; + if (t.includes("log_analytics")) return "bi-graph-up"; + if (t.includes("msgraph")) return "bi-microsoft"; + if (t.includes("databricks")) return "bi-bricks"; + 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"; + if (t.includes("queue")) return "bi-inbox"; + if (t.includes("embedding")) return "bi-vector-pen"; + if (t.includes("fact_memory")) return "bi-brain"; + if (t.includes("math")) return "bi-calculator"; + if (t.includes("text")) return "bi-fonts"; + if (t.includes("time")) return "bi-clock"; + return "bi-lightning-charge"; +} + +/** + * Create the HTML string for a list/grid view toggle button group. + * @param {string} prefix - Unique prefix for element IDs (e.g., "agents", "plugins", "group-agents") + * @returns {string} HTML string + */ +export function createViewToggleHtml(prefix) { + return ` +
+ + + + +
`; +} + +/** + * Set up view toggle event listeners and restore saved preference. + * @param {string} prefix - Unique prefix matching createViewToggleHtml + * @param {string} storageKey - localStorage key for persistence + * @param {function} onSwitch - Callback receiving 'list' or 'grid' + */ +export function setupViewToggle(prefix, storageKey, onSwitch) { + const listRadio = document.getElementById(`${prefix}-view-list`); + const gridRadio = document.getElementById(`${prefix}-view-grid`); + if (!listRadio || !gridRadio) return; + + listRadio.addEventListener("change", () => { + if (listRadio.checked) { + localStorage.setItem(storageKey, "list"); + onSwitch("list"); + } + }); + + gridRadio.addEventListener("change", () => { + if (gridRadio.checked) { + localStorage.setItem(storageKey, "grid"); + onSwitch("grid"); + } + }); + + // Restore saved preference + const saved = localStorage.getItem(storageKey); + if (saved === "grid") { + gridRadio.checked = true; + listRadio.checked = false; + onSwitch("grid"); + } else { + onSwitch("list"); + } +} + +/** + * Toggle visibility of list and grid containers. + * @param {string} mode - 'list' or 'grid' + * @param {HTMLElement} listContainer - The list/table container element + * @param {HTMLElement} gridContainer - The grid container element + */ +export function switchViewContainers(mode, listContainer, gridContainer) { + if (listContainer) { + listContainer.classList.toggle("d-none", mode !== "list"); + } + if (gridContainer) { + gridContainer.classList.toggle("d-none", mode !== "grid"); + } +} + +// ============================================================================ +// VIEW MODAL — Lightweight read-only detail view +// ============================================================================ + +/** + * Open a read-only view modal for an agent or action. + * @param {object} item - The agent or action data object + * @param {'agent'|'action'} type - What kind of item this is + * @param {object} [callbacks] - Optional action callbacks { onChat, onEdit, onDelete } + */ +export function openViewModal(item, type, callbacks = {}) { + const modalEl = document.getElementById("item-view-modal"); + if (!modalEl) return; + + const titleEl = modalEl.querySelector(".modal-title"); + const bodyEl = modalEl.querySelector(".modal-body"); + const footerEl = modalEl.querySelector(".modal-footer"); + if (!titleEl || !bodyEl || !footerEl) return; + + if (type === "agent") { + titleEl.textContent = "Agent Details"; + bodyEl.innerHTML = buildAgentViewHtml(item); + } else { + titleEl.textContent = "Action Details"; + bodyEl.innerHTML = buildActionViewHtml(item); + } + + // Build footer buttons dynamically + footerEl.innerHTML = ''; + const { onChat, onEdit, onDelete } = callbacks; + + if (onChat && typeof onChat === 'function') { + const chatBtn = document.createElement('button'); + chatBtn.type = 'button'; + chatBtn.className = 'btn btn-primary'; + chatBtn.innerHTML = 'Chat'; + chatBtn.addEventListener('click', () => { + bootstrap.Modal.getInstance(modalEl)?.hide(); + onChat(item); + }); + footerEl.appendChild(chatBtn); + } + + if (onEdit && typeof onEdit === 'function') { + const editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'btn btn-outline-secondary'; + editBtn.innerHTML = 'Edit'; + editBtn.addEventListener('click', () => { + bootstrap.Modal.getInstance(modalEl)?.hide(); + onEdit(item); + }); + footerEl.appendChild(editBtn); + } + + if (onDelete && typeof onDelete === 'function') { + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'btn btn-outline-danger'; + delBtn.innerHTML = 'Delete'; + delBtn.addEventListener('click', () => { + bootstrap.Modal.getInstance(modalEl)?.hide(); + onDelete(item); + }); + footerEl.appendChild(delBtn); + } + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'btn btn-secondary'; + closeBtn.textContent = 'Close'; + closeBtn.setAttribute('data-bs-dismiss', 'modal'); + footerEl.appendChild(closeBtn); + + const modal = new bootstrap.Modal(modalEl); + modal.show(); +} + +function buildAgentViewHtml(agent) { + const displayName = escapeHtml(agent.display_name || agent.displayName || agent.name || ""); + const name = escapeHtml(agent.name || ""); + const description = escapeHtml(agent.description || "No description available."); + const model = escapeHtml(agent.azure_openai_gpt_deployment || agent.model || "Default"); + const agentType = agent.agent_type === "aifoundry" ? "Azure AI Foundry" : "Local (Semantic Kernel)"; + const rawInstructions = agent.instructions || "No instructions defined."; + // Render instructions as Markdown (marked + DOMPurify are loaded globally in base.html) + const renderedInstructions = (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') + ? DOMPurify.sanitize(marked.parse(rawInstructions)) + : escapeHtml(rawInstructions); + const isGlobal = agent.is_global; + const scopeBadge = isGlobal + ? 'Global' + : 'Personal'; + + return ` +
+
+ Basic Information +
+
+
+
+ + ${displayName} +
+
+ + ${name} +
+
+ + ${scopeBadge} +
+
+ + ${escapeHtml(agentType)} +
+
+ + ${description} +
+
+
+
+
+
+ Model Configuration +
+
+
+
+ + ${model} +
+
+
+
+
+
+ Instructions +
+
+
+${renderedInstructions} +
+
+
`; +} + +function buildActionViewHtml(action) { + const displayName = escapeHtml(action.display_name || action.displayName || action.name || ""); + const name = escapeHtml(action.name || ""); + const description = escapeHtml(action.description || "No description available."); + const type = escapeHtml(action.type || "unknown"); + const typeIcon = getTypeIcon(action.type); + const authType = escapeHtml(formatAuthType(action.auth?.type || action.auth_type || "")); + const endpoint = escapeHtml(action.endpoint || action.base_url || ""); + const isGlobal = action.is_global; + const scopeBadge = isGlobal + ? 'Global' + : 'Personal'; + + let configHtml = ""; + if (endpoint) { + configHtml = ` +
+
+ Configuration +
+
+
+
+ + ${endpoint} +
+
+ + ${authType || "None"} +
+
+
+
`; + } + + return ` +
+
+ Basic Information +
+
+
+
+ + ${displayName} +
+
+ + ${name} +
+
+ + ${humanizeName(type)} +
+
+ + ${scopeBadge} +
+
+ + ${description} +
+
+
+
+ ${configHtml}`; +} + +function formatAuthType(type) { + if (!type) return ""; + const map = { + "key": "API Key", + "identity": "Managed Identity", + "user": "User (Delegated)", + "servicePrincipal": "Service Principal", + "connection_string": "Connection String", + "basic": "Basic Auth", + "username_password": "Username / Password", + "NoAuth": "No Authentication" + }; + return map[type] || type; +} + +// ============================================================================ +// GRID CARD RENDERERS +// ============================================================================ + +/** + * Create a grid card element for an agent. + * @param {object} agent - Agent data object + * @param {object} options - { onChat, onView, onEdit, onDelete, canManage, isGroup } + * @returns {HTMLElement} + */ +export function createAgentCard(agent, options = {}) { + const { onChat, onView, onEdit, onDelete, canManage = false, isGroup = false } = options; + const col = document.createElement("div"); + col.className = "col-sm-6 col-md-4 col-lg-3"; + + const displayName = humanizeName(agent.display_name || agent.displayName || agent.name || ""); + const description = agent.description || "No description available."; + const isGlobal = agent.is_global; + + let badgeHtml = ""; + if (isGlobal) { + badgeHtml = 'Global'; + } + + let buttonsHtml = ` + + `; + + if (canManage && !isGlobal) { + buttonsHtml += ` + + `; + } + + col.innerHTML = ` +
+
+
+ +
+
${escapeHtml(displayName)}${badgeHtml}
+

${escapeHtml(truncateDescription(description, 120))}

+
+ ${buttonsHtml} +
+
+
`; + + // Bind button events + const chatBtn = col.querySelector(".item-card-chat-btn"); + const viewBtn = col.querySelector(".item-card-view-btn"); + const editBtn = col.querySelector(".item-card-edit-btn"); + const deleteBtn = col.querySelector(".item-card-delete-btn"); + + if (chatBtn && onChat) chatBtn.addEventListener("click", (e) => { e.stopPropagation(); onChat(agent); }); + if (viewBtn && onView) viewBtn.addEventListener("click", (e) => { e.stopPropagation(); onView(agent); }); + if (editBtn && onEdit) editBtn.addEventListener("click", (e) => { e.stopPropagation(); onEdit(agent); }); + if (deleteBtn && onDelete) deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); onDelete(agent); }); + + // Clicking anywhere on the card opens the detail view + const cardEl = col.querySelector(".item-card"); + if (cardEl && onView) { + cardEl.style.cursor = "pointer"; + cardEl.addEventListener("click", () => onView(agent)); + } + + return col; +} + +/** + * Create a grid card element for an action/plugin. + * @param {object} plugin - Action/plugin data object + * @param {object} options - { onView, onEdit, onDelete, canManage, isAdmin } + * @returns {HTMLElement} + */ +export function createActionCard(plugin, options = {}) { + const { onView, onEdit, onDelete, canManage = true, isAdmin = false } = options; + const col = document.createElement("div"); + col.className = "col-sm-6 col-md-4 col-lg-3"; + + const displayName = humanizeName(plugin.display_name || plugin.displayName || plugin.name || ""); + const description = plugin.description || "No description available."; + const type = plugin.type || ""; + const typeIcon = getTypeIcon(type); + const isGlobal = plugin.is_global; + + let badgeHtml = ""; + if (isGlobal) { + badgeHtml = 'Global'; + } + + const typeBadge = type + ? `${escapeHtml(humanizeName(type))}` + : ""; + + let buttonsHtml = ` + `; + + if ((isAdmin || (canManage && !isGlobal))) { + buttonsHtml += ` + + `; + } + + col.innerHTML = ` +
+
+
+ +
+
${escapeHtml(displayName)}${badgeHtml}
+
${typeBadge}
+

${escapeHtml(truncateDescription(description, 120))}

+
+ ${buttonsHtml} +
+
+
`; + + // Bind button events + const viewBtn = col.querySelector(".item-card-view-btn"); + const editBtn = col.querySelector(".item-card-edit-btn"); + const deleteBtn = col.querySelector(".item-card-delete-btn"); + + if (viewBtn && onView) viewBtn.addEventListener("click", (e) => { e.stopPropagation(); onView(plugin); }); + if (editBtn && onEdit) editBtn.addEventListener("click", (e) => { e.stopPropagation(); onEdit(plugin); }); + if (deleteBtn && onDelete) deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); onDelete(plugin); }); + + // Clicking anywhere on the card opens the detail view + const cardEl = col.querySelector(".item-card"); + if (cardEl && onView) { + cardEl.style.cursor = "pointer"; + cardEl.addEventListener("click", () => onView(plugin)); + } + + return col; +} diff --git a/application/single_app/static/js/workspace/workspace_agents.js b/application/single_app/static/js/workspace/workspace_agents.js index a0839b25..623be234 100644 --- a/application/single_app/static/js/workspace/workspace_agents.js +++ b/application/single_app/static/js/workspace/workspace_agents.js @@ -4,14 +4,22 @@ import { showToast } from "../chat/chat-toast.js"; import * as agentsCommon from '../agents_common.js'; import { AgentModalStepper } from '../agent_modal_stepper.js'; +import { + humanizeName, truncateDescription, escapeHtml, + setupViewToggle, switchViewContainers, + openViewModal, createAgentCard +} from './view-utils.js'; // --- DOM Elements & Globals --- const agentsTbody = document.getElementById('agents-table-body'); const agentsErrorDiv = document.getElementById('workspace-agents-error'); const createAgentBtn = document.getElementById('create-agent-btn'); const agentsSearchInput = document.getElementById('agents-search'); +const agentsListView = document.getElementById('agents-list-view'); +const agentsGridView = document.getElementById('agents-grid-view'); let agents = []; let filteredAgents = []; +let currentViewMode = 'list'; // --- Function Definitions --- @@ -43,104 +51,87 @@ function filterAgents(searchTerm) { }); } renderAgentsTable(filteredAgents); + renderAgentsGrid(filteredAgents); } -// --- Helper Functions --- - -function truncateDisplayName(displayName, maxLength = 12) { - if (!displayName || displayName.length <= maxLength) { - return displayName; +// Open the view modal for an agent with Chat/Edit/Delete actions in the footer +function openAgentViewModal(agent) { + const callbacks = { + onChat: (a) => chatWithAgent(a.name), + onDelete: !agent.is_global ? (a) => { if (confirm(`Delete agent '${a.name}'?`)) deleteAgent(a.name); } : null + }; + if (!agent.is_global) { + callbacks.onEdit = (a) => openAgentModal(a); } - return displayName.substring(0, maxLength) + '...'; + openViewModal(agent, 'agent', callbacks); } +// --- Rendering Functions --- function renderAgentsTable(agentsList) { if (!agentsTbody) return; agentsTbody.innerHTML = ''; if (!agentsList.length) { const tr = document.createElement('tr'); - tr.innerHTML = 'No agents found.'; + tr.innerHTML = 'No agents found.'; agentsTbody.appendChild(tr); return; } - // Fetch selected_agent from user settings (async) - fetch('/api/user/settings').then(res => { - if (!res.ok) throw new Error('Failed to load user settings'); - return res.json(); - }).then(settings => { - let selectedAgentObj = settings.selected_agent; - if (!selectedAgentObj && settings.settings && settings.settings.selected_agent) { - selectedAgentObj = settings.settings.selected_agent; - } - let selectedAgentName = typeof selectedAgentObj === 'object' ? selectedAgentObj.name : selectedAgentObj; - agentsTbody.innerHTML = ''; - for (const agent of agentsList) { - const tr = document.createElement('tr'); - - // Create action buttons - let actionButtons = ``; - - if (!agent.is_global) { - actionButtons += ` - - - `; - } - - const truncatedDisplayName = truncateDisplayName(agent.display_name || agent.name || ''); - - tr.innerHTML = ` - - ${truncatedDisplayName} - ${agent.is_global ? ' Global' : ''} - - ${agent.description || 'No description available'} - ${actionButtons} - `; - agentsTbody.appendChild(tr); - } - }).catch(e => { - renderError('Could not load agent settings: ' + e.message); - // Fallback: render table without settings - agentsTbody.innerHTML = ''; - for (const agent of agentsList) { - const tr = document.createElement('tr'); - - // Create action buttons - let actionButtons = ` + `; - - if (!agent.is_global) { - actionButtons += ` - - - `; - } - - const truncatedDisplayName = truncateDisplayName(agent.display_name || agent.name || ''); - - tr.innerHTML = ` - - ${truncatedDisplayName} - ${agent.is_global ? ' Global' : ''} - - ${agent.description || 'No description available'} - ${actionButtons} - `; - agentsTbody.appendChild(tr); + + if (!isGlobal) { + actionButtons += ` + + `; } - }); + + tr.innerHTML = ` + + ${escapeHtml(displayName)} + ${isGlobal ? ' Global' : ''} + + ${escapeHtml(truncatedDesc)} + ${actionButtons} + `; + agentsTbody.appendChild(tr); + } +} + +function renderAgentsGrid(agentsList) { + if (!agentsGridView) return; + agentsGridView.innerHTML = ''; + if (!agentsList.length) { + agentsGridView.innerHTML = '
No agents found.
'; + return; + } + + for (const agent of agentsList) { + const card = createAgentCard(agent, { + onChat: (a) => chatWithAgent(a.name), + onView: (a) => openAgentViewModal(a), + onEdit: (a) => openAgentModal(a), + onDelete: (a) => { if (confirm(`Delete agent '${a.name}'?`)) deleteAgent(a.name); }, + canManage: !agent.is_global + }); + agentsGridView.appendChild(card); + } } async function fetchAgents() { @@ -151,6 +142,7 @@ async function fetchAgents() { agents = await res.json(); filteredAgents = agents; // Initialize filtered list renderAgentsTable(filteredAgents); + renderAgentsGrid(filteredAgents); } catch (e) { renderError(e.message); } @@ -177,17 +169,14 @@ function attachAgentTableEvents() { } agentsTbody.addEventListener('click', function (e) { - console.log('Agent table clicked, target:', e.target); - // Find the button element (could be the target or a parent) const editBtn = e.target.closest('.edit-agent-btn'); const deleteBtn = e.target.closest('.delete-agent-btn'); const chatBtn = e.target.closest('.chat-agent-btn'); + const viewBtn = e.target.closest('.view-agent-btn'); if (editBtn) { - console.log('Edit agent button clicked, dataset:', editBtn.dataset); const agent = agents.find(a => a.name === editBtn.dataset.name); - console.log('Found agent:', agent); openAgentModal(agent); } @@ -201,33 +190,27 @@ function attachAgentTableEvents() { const agentName = chatBtn.dataset.name; chatWithAgent(agentName); } + + if (viewBtn) { + const agent = agents.find(a => a.name === viewBtn.dataset.name); + if (agent) openAgentViewModal(agent); + } }); } async function chatWithAgent(agentName) { try { - console.log('DEBUG: chatWithAgent called with agentName:', agentName); - console.log('DEBUG: Available agents:', agents); - - // Find the agent to get its is_global status const agent = agents.find(a => a.name === agentName); - console.log('DEBUG: Found agent:', agent); - if (!agent) { throw new Error('Agent not found'); } - console.log('DEBUG: Agent is_global flag:', agent.is_global); - console.log('DEBUG: !!agent.is_global:', !!agent.is_global); - - // Set the selected agent with proper is_global flag const payloadData = { selected_agent: { name: agentName, is_global: !!agent.is_global } }; - console.log('DEBUG: Sending payload:', payloadData); const resp = await fetch('/api/user/settings/selected_agent', { method: 'POST', @@ -239,9 +222,6 @@ async function chatWithAgent(agentName) { throw new Error('Failed to select agent'); } - console.log('DEBUG: Agent selection saved successfully'); - - // Navigate to chat page window.location.href = '/chats'; } catch (err) { console.error('Error selecting agent for chat:', err); @@ -353,6 +333,17 @@ async function deleteAgent(name) { function initializeWorkspaceAgentUI() { window.agentModalStepper = new AgentModalStepper(false); attachAgentTableEvents(); + + // Set up view toggle + setupViewToggle('agents', 'agentsViewPreference', (mode) => { + currentViewMode = mode; + switchViewContainers(mode, agentsListView, agentsGridView); + // Re-render grid if switching to grid and we have data + if (mode === 'grid' && filteredAgents.length) { + renderAgentsGrid(filteredAgents); + } + }); + fetchAgents(); } diff --git a/application/single_app/static/js/workspace/workspace_plugins.js b/application/single_app/static/js/workspace/workspace_plugins.js index 30fef0d5..84f1eb46 100644 --- a/application/single_app/static/js/workspace/workspace_plugins.js +++ b/application/single_app/static/js/workspace/workspace_plugins.js @@ -1,10 +1,14 @@ // workspace_plugins.js (refactored to use plugin_common.js and new multi-step modal) -import { renderPluginsTable, ensurePluginsTableInRoot, validatePluginManifest } from '../plugin_common.js'; +import { renderPluginsTable, renderPluginsGrid, ensurePluginsTableInRoot, validatePluginManifest } from '../plugin_common.js'; import { showToast } from "../chat/chat-toast.js" +import { + setupViewToggle, switchViewContainers, openViewModal +} from './view-utils.js'; const root = document.getElementById('workspace-plugins-root'); let plugins = []; let filteredPlugins = []; +let currentViewMode = 'list'; function renderLoading() { root.innerHTML = `
Loading...
`; @@ -14,6 +18,22 @@ function renderError(msg) { root.innerHTML = `
${msg}
`; } +function getViewHandlers() { + return { + onEdit: name => openPluginModal(plugins.find(p => p.name === name)), + onDelete: name => deletePlugin(name), + onView: name => { + const plugin = plugins.find(p => p.name === name); + if (plugin) { + openViewModal(plugin, 'action', { + onEdit: (item) => openPluginModal(item), + onDelete: (item) => deletePlugin(item.name) + }); + } + } + }; +} + function filterPlugins(searchTerm) { if (!searchTerm || !searchTerm.trim()) { filteredPlugins = plugins; @@ -26,14 +46,18 @@ function filterPlugins(searchTerm) { }); } - // Ensure table template is in place ensurePluginsTableInRoot(); + const handlers = getViewHandlers(); renderPluginsTable({ plugins: filteredPlugins, tbodySelector: '#plugins-table-body', - onEdit: name => openPluginModal(plugins.find(p => p.name === name)), - onDelete: name => deletePlugin(name) + ...handlers + }); + renderPluginsGrid({ + plugins: filteredPlugins, + containerSelector: '#plugins-grid-view', + ...handlers }); } @@ -47,12 +71,26 @@ async function fetchPlugins() { // Ensure table template is in place ensurePluginsTableInRoot(); + const handlers = getViewHandlers(); renderPluginsTable({ plugins: filteredPlugins, tbodySelector: '#plugins-table-body', - onEdit: name => openPluginModal(plugins.find(p => p.name === name)), - onDelete: name => deletePlugin(name) + ...handlers + }); + renderPluginsGrid({ + plugins: filteredPlugins, + containerSelector: '#plugins-grid-view', + ...handlers + }); + + // Set up view toggle (only once after template is in DOM) + setupViewToggle('plugins', 'pluginsViewPreference', (mode) => { + currentViewMode = mode; + switchViewContainers(mode, + document.getElementById('plugins-list-view'), + document.getElementById('plugins-grid-view') + ); }); // Set up the create action button diff --git a/application/single_app/static/json/schemas/sql_query.definition.json b/application/single_app/static/json/schemas/sql_query.definition.json index d38a41a8..6903c22a 100644 --- a/application/single_app/static/json/schemas/sql_query.definition.json +++ b/application/single_app/static/json/schemas/sql_query.definition.json @@ -1,6 +1,9 @@ { "$schema": "./plugin.definition.schema.json", "allowedAuthTypes": [ + "user", + "identity", + "servicePrincipal", "connection_string" ] } diff --git a/application/single_app/static/json/schemas/sql_query_plugin.additional_settings.schema.json b/application/single_app/static/json/schemas/sql_query_plugin.additional_settings.schema.json index 9e4f6d34..f7f46ebd 100644 --- a/application/single_app/static/json/schemas/sql_query_plugin.additional_settings.schema.json +++ b/application/single_app/static/json/schemas/sql_query_plugin.additional_settings.schema.json @@ -3,13 +3,13 @@ "title": "SQL Query Plugin Additional Settings", "type": "object", "properties": { - "connection_string__Secret": { + "connection_string": { "type": "string", "description": "Database connection string. Required if server/database not provided." }, "database_type": { "type": "string", - "enum": ["sqlserver", "postgresql", "mysql", "sqlite", "azure_sql", "azuresql"], + "enum": ["sqlserver", "postgresql", "mysql", "sqlite", "azure_sql"], "description": "Type of database engine." }, "server": { @@ -24,7 +24,7 @@ "type": "string", "description": "Username for authentication." }, - "password__Secret": { + "password": { "type": "string", "description": "Password for authentication." }, @@ -50,6 +50,6 @@ "description": "Query timeout in seconds." } }, - "required": ["database_type", "database"], + "required": ["database_type"], "additionalProperties": false } diff --git a/application/single_app/static/json/schemas/sql_schema.definition.json b/application/single_app/static/json/schemas/sql_schema.definition.json index d38a41a8..6903c22a 100644 --- a/application/single_app/static/json/schemas/sql_schema.definition.json +++ b/application/single_app/static/json/schemas/sql_schema.definition.json @@ -1,6 +1,9 @@ { "$schema": "./plugin.definition.schema.json", "allowedAuthTypes": [ + "user", + "identity", + "servicePrincipal", "connection_string" ] } diff --git a/application/single_app/static/json/schemas/sql_schema_plugin.additional_settings.schema.json b/application/single_app/static/json/schemas/sql_schema_plugin.additional_settings.schema.json index e97c7b4b..29fb6b3f 100644 --- a/application/single_app/static/json/schemas/sql_schema_plugin.additional_settings.schema.json +++ b/application/single_app/static/json/schemas/sql_schema_plugin.additional_settings.schema.json @@ -3,13 +3,13 @@ "title": "SQL Schema Plugin Additional Settings", "type": "object", "properties": { - "connection_string__Secret": { + "connection_string": { "type": "string", "description": "Database connection string. Required if server/database not provided." }, "database_type": { "type": "string", - "enum": ["sqlserver", "postgresql", "mysql", "sqlite", "azure_sql", "azuresql"], + "enum": ["sqlserver", "postgresql", "mysql", "sqlite", "azure_sql"], "description": "Type of database engine." }, "server": { @@ -24,7 +24,7 @@ "type": "string", "description": "Username for authentication." }, - "password__Secret": { + "password": { "type": "string", "description": "Password for authentication." }, @@ -33,6 +33,6 @@ "description": "ODBC or DB driver name." } }, - "required": ["database_type", "database"], + "required": ["database_type"], "additionalProperties": false } diff --git a/application/single_app/templates/_agent_examples_modal.html b/application/single_app/templates/_agent_examples_modal.html index 52f95cdc..398e930c 100644 --- a/application/single_app/templates/_agent_examples_modal.html +++ b/application/single_app/templates/_agent_examples_modal.html @@ -92,7 +92,7 @@
-

+          
@@ -427,7 +427,12 @@
+ + +
+
+ +
+ + +
+
+
+ +
+
Advanced
+

Advanced settings are typically not required. Expand below if you need to customize metadata or additional fields.

- - -
Optional metadata for this action.
+
-
- - -
Additional configuration fields specific to this action type.
+
+
+ + +
Optional metadata for this action.
+
+
+ + +
Additional configuration fields specific to this action type.
+
@@ -777,6 +802,15 @@
background-color: #f8f9fa; } +/* Advanced toggle chevron animation */ +#plugin-advanced-toggle-icon { + transition: transform 0.3s ease; +} +#plugin-advanced-collapse.show ~ .mb-3 #plugin-advanced-toggle-icon, +[aria-expanded="true"] #plugin-advanced-toggle-icon { + transform: rotate(180deg); +} + .sql-connection-config, .sql-auth-config { background-color: white; diff --git a/application/single_app/templates/group_workspaces.html b/application/single_app/templates/group_workspaces.html index e14fe8a3..62937fc3 100644 --- a/application/single_app/templates/group_workspaces.html +++ b/application/single_app/templates/group_workspaces.html @@ -763,33 +763,42 @@

Group Workspace

You do not have permission to manage group agents.
-
+
+
+ + + + +
- - - - - - - - - - - - - -
Display NameDescriptionActions
-
- Loading... -
- Select a group to load agents. -
+
+ + + + + + + + + + + + + +
Display NameDescriptionActions
+
+ Loading... +
+ Select a group to load agents. +
+
+
@@ -813,33 +822,42 @@

Group Workspace

-
+
+
+ + + + +
- - - - - - - - - - - - - -
Display NameDescriptionActions
-
- Loading... -
- Select a group to load actions. -
+
+ + + + + + + + + + + + + +
Display NameDescriptionActions
+
+ Loading... +
+ Select a group to load actions. +
+
+
@@ -851,6 +869,22 @@

Group Workspace

+ + + +
+ + + + +
- - - - - - - -
Display NameDescriptionActions
-
Loading...
- Loading agents... -
+ +
+ + + + + + + +
Display NameDescriptionActions
+
Loading...
+ Loading agents... +
+
+ +
@@ -730,16 +741,27 @@

Personal Workspace

+
+ + + + +
- - - - - -
Display NameDescriptionActions
+ +
+ + + + + +
Display NameDescriptionActions
+
+ +
@@ -754,6 +776,24 @@

Personal Workspace

+ + +