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 = ` -
${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 = ` -
Advanced settings are typically not required. Expand below if you need to customize metadata or additional fields.
| Display Name | -Description | -Actions | -
|---|---|---|
|
-
- Loading...
-
- Select a group to load agents.
- |
- ||
| Display Name | +Description | +Actions | +
|---|---|---|
|
+
+ Loading...
+
+ Select a group to load agents.
+ |
+ ||
| Display Name | -Description | -Actions | -
|---|---|---|
|
-
- Loading...
-
- Select a group to load actions.
- |
- ||
| Display Name | +Description | +Actions | +
|---|---|---|
|
+
+ Loading...
+
+ Select a group to load actions.
+ |
+ ||
| Display Name | Description | Actions |
|---|---|---|
|
- Loading...
- Loading agents...
- |
- ||
| Display Name | Description | Actions |
|---|---|---|
|
+ Loading...
+ Loading agents...
+ |
+ ||
| Display Name | Description | Actions |
|---|
| Display Name | Description | Actions |
|---|