diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index 54550e9cf..9ef3cddc8 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -54,6 +54,8 @@ from mcpgateway.common.models import LogLevel from mcpgateway.config import settings from mcpgateway.db import get_db, GlobalConfig, ObservabilitySavedQuery, ObservabilitySpan, ObservabilityTrace +from mcpgateway.db import Prompt as DbPrompt +from mcpgateway.db import Resource as DbResource from mcpgateway.db import Tool as DbTool from mcpgateway.db import utc_now from mcpgateway.middleware.rbac import get_current_user_with_permissions, require_permission @@ -1038,14 +1040,36 @@ async def admin_add_server(request: Request, db: Session = Depends(get_db), user except json.JSONDecodeError: LOGGER.warning("Failed to parse allToolIds JSON, falling back to checked tools") + # Handle "Select All" for resources + associated_resources_list = form.getlist("associatedResources") + if form.get("selectAllResources") == "true": + all_resource_ids_json = str(form.get("allResourceIds", "[]")) + try: + all_resource_ids = json.loads(all_resource_ids_json) + associated_resources_list = all_resource_ids + LOGGER.info(f"Select All resources enabled: {len(all_resource_ids)} resources selected") + except json.JSONDecodeError: + LOGGER.warning("Failed to parse allResourceIds JSON, falling back to checked resources") + + # Handle "Select All" for prompts + associated_prompts_list = form.getlist("associatedPrompts") + if form.get("selectAllPrompts") == "true": + all_prompt_ids_json = str(form.get("allPromptIds", "[]")) + try: + all_prompt_ids = json.loads(all_prompt_ids_json) + associated_prompts_list = all_prompt_ids + LOGGER.info(f"Select All prompts enabled: {len(all_prompt_ids)} prompts selected") + except json.JSONDecodeError: + LOGGER.warning("Failed to parse allPromptIds JSON, falling back to checked prompts") + server = ServerCreate( id=form.get("id") or None, name=form.get("name"), description=form.get("description"), icon=form.get("icon"), associated_tools=",".join(str(x) for x in associated_tools_list), - associated_resources=",".join(str(x) for x in form.getlist("associatedResources")), - associated_prompts=",".join(str(x) for x in form.getlist("associatedPrompts")), + associated_resources=",".join(str(x) for x in associated_resources_list), + associated_prompts=",".join(str(x) for x in associated_prompts_list), tags=tags, visibility=visibility, ) @@ -1242,14 +1266,36 @@ async def admin_edit_server( except json.JSONDecodeError: LOGGER.warning("Failed to parse allToolIds JSON, falling back to checked tools") + # Handle "Select All" for resources + associated_resources_list = form.getlist("associatedResources") + if form.get("selectAllResources") == "true": + all_resource_ids_json = str(form.get("allResourceIds", "[]")) + try: + all_resource_ids = json.loads(all_resource_ids_json) + associated_resources_list = all_resource_ids + LOGGER.info(f"Select All resources enabled for edit: {len(all_resource_ids)} resources selected") + except json.JSONDecodeError: + LOGGER.warning("Failed to parse allResourceIds JSON, falling back to checked resources") + + # Handle "Select All" for prompts + associated_prompts_list = form.getlist("associatedPrompts") + if form.get("selectAllPrompts") == "true": + all_prompt_ids_json = str(form.get("allPromptIds", "[]")) + try: + all_prompt_ids = json.loads(all_prompt_ids_json) + associated_prompts_list = all_prompt_ids + LOGGER.info(f"Select All prompts enabled for edit: {len(all_prompt_ids)} prompts selected") + except json.JSONDecodeError: + LOGGER.warning("Failed to parse allPromptIds JSON, falling back to checked prompts") + server = ServerUpdate( id=form.get("id"), name=form.get("name"), description=form.get("description"), icon=form.get("icon"), associated_tools=",".join(str(x) for x in associated_tools_list), - associated_resources=",".join(str(x) for x in form.getlist("associatedResources")), - associated_prompts=",".join(str(x) for x in form.getlist("associatedPrompts")), + associated_resources=",".join(str(x) for x in associated_resources_list), + associated_prompts=",".join(str(x) for x in associated_prompts_list), tags=tags, visibility=visibility, team_id=team_id, @@ -5147,6 +5193,430 @@ async def admin_search_tools( return {"tools": tools, "count": len(tools)} +@admin_router.get("/prompts/partial", response_class=HTMLResponse) +async def admin_prompts_partial_html( + request: Request, + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1), + include_inactive: bool = False, + render: Optional[str] = Query(None), + db: Session = Depends(get_db), + user=Depends(get_current_user_with_permissions), +): + """Return paginated prompts HTML partials for the admin UI. + + This HTMX endpoint returns only the partial HTML used by the admin UI for + prompts. It supports three render modes: + + - default: full table partial (rows + controls) + - ``render="controls"``: return only pagination controls + - ``render="selector"``: return selector items for infinite scroll + + Args: + request (Request): FastAPI request object used by the template engine. + page (int): Page number (1-indexed). + per_page (int): Number of items per page (bounded by settings). + include_inactive (bool): If True, include inactive prompts in results. + render (Optional[str]): Render mode; one of None, "controls", "selector". + db (Session): Database session (dependency-injected). + user: Authenticated user object from dependency injection. + + Returns: + Union[HTMLResponse, TemplateResponse]: A rendered template response + containing either the table partial, pagination controls, or selector + items depending on ``render``. The response contains JSON-serializable + encoded prompt data when templates expect it. + """ + # Normalize per_page within configured bounds + per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size)) + + user_email = get_user_email(user) + + # Team scoping + team_service = TeamManagementService(db) + user_teams = await team_service.get_user_teams(user_email) + team_ids = [t.id for t in user_teams] + + # Build base query + query = select(DbPrompt) + if not include_inactive: + query = query.where(DbPrompt.is_active.is_(True)) + + # Access conditions: owner, team, public + access_conditions = [DbPrompt.owner_email == user_email] + if team_ids: + access_conditions.append(and_(DbPrompt.team_id.in_(team_ids), DbPrompt.visibility.in_(["team", "public"]))) + access_conditions.append(DbPrompt.visibility == "public") + + query = query.where(or_(*access_conditions)) + + # Count total items + count_query = select(func.count()).select_from(DbPrompt).where(or_(*access_conditions)) # pylint: disable=not-callable + if not include_inactive: + count_query = count_query.where(DbPrompt.is_active.is_(True)) + + total_items = db.scalar(count_query) or 0 + + # Apply pagination ordering and limits + offset = (page - 1) * per_page + query = query.order_by(DbPrompt.name, DbPrompt.id).offset(offset).limit(per_page) + + prompts_db = list(db.scalars(query).all()) + + # Convert to schemas using PromptService + local_prompt_service = PromptService() + prompts_data = [] + for p in prompts_db: + try: + prompt_dict = await local_prompt_service.get_prompt_details(db, p.id, include_inactive=include_inactive) + if prompt_dict: + prompts_data.append(prompt_dict) + except Exception as e: + LOGGER.warning(f"Failed to convert prompt {p.id} to schema: {e}") + continue + + data = jsonable_encoder(prompts_data) + + # Build pagination metadata + pagination = PaginationMeta( + page=page, + per_page=per_page, + total_items=total_items, + total_pages=math.ceil(total_items / per_page) if per_page > 0 else 0, + has_next=page < math.ceil(total_items / per_page) if per_page > 0 else False, + has_prev=page > 1, + ) + + base_url = f"{settings.app_root_path}/admin/prompts/partial" + links = generate_pagination_links( + base_url=base_url, + page=page, + per_page=per_page, + total_pages=pagination.total_pages, + query_params={"include_inactive": "true"} if include_inactive else {}, + ) + + if render == "controls": + return request.app.state.templates.TemplateResponse( + "pagination_controls.html", + { + "request": request, + "pagination": pagination.model_dump(), + "base_url": base_url, + "hx_target": "#prompts-table-body", + "hx_indicator": "#prompts-loading", + "query_params": {"include_inactive": "true"} if include_inactive else {}, + "root_path": request.scope.get("root_path", ""), + }, + ) + + if render == "selector": + return request.app.state.templates.TemplateResponse( + "prompts_selector_items.html", + { + "request": request, + "data": data, + "pagination": pagination.model_dump(), + "root_path": request.scope.get("root_path", ""), + }, + ) + + return request.app.state.templates.TemplateResponse( + "prompts_partial.html", + { + "request": request, + "data": data, + "pagination": pagination.model_dump(), + "links": links.model_dump() if links else None, + "root_path": request.scope.get("root_path", ""), + "include_inactive": include_inactive, + }, + ) + + +@admin_router.get("/resources/partial", response_class=HTMLResponse) +async def admin_resources_partial_html( + request: Request, + page: int = Query(1, ge=1, description="Page number (1-indexed)"), + per_page: int = Query(50, ge=1, le=500, description="Items per page"), + include_inactive: bool = False, + render: Optional[str] = Query(None, description="Render mode: 'controls' for pagination controls only"), + db: Session = Depends(get_db), + user=Depends(get_current_user_with_permissions), +): + """Return HTML partial for paginated resources list (HTMX endpoint). + + This endpoint mirrors the behavior of the tools and prompts partial + endpoints. It returns a template fragment suitable for HTMX-based + pagination/infinite-scroll within the admin UI. + + Args: + request (Request): FastAPI request object used by the template engine. + page (int): Page number (1-indexed). + per_page (int): Number of items per page (bounded by settings). + include_inactive (bool): If True, include inactive resources in results. + render (Optional[str]): Render mode; when set to "controls" returns only + pagination controls. Other supported value: "selector" for selector + items used by infinite scroll selectors. + db (Session): Database session (dependency-injected). + user: Authenticated user object from dependency injection. + + Returns: + Union[HTMLResponse, TemplateResponse]: Rendered template response with the + resources partial (rows + controls), pagination controls only, or selector + items depending on the ``render`` parameter. + """ + LOGGER.debug(f"User {get_user_email(user)} requested resources HTML partial (page={page}, per_page={per_page}, render={render})") + + # Normalize per_page + per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size)) + + user_email = get_user_email(user) + + # Team scoping + team_service = TeamManagementService(db) + user_teams = await team_service.get_user_teams(user_email) + team_ids = [t.id for t in user_teams] + + # Build base query + query = select(DbResource) + + # Apply active/inactive filter + if not include_inactive: + query = query.where(DbResource.is_active.is_(True)) + + # Access conditions: owner, team, public + access_conditions = [DbResource.owner_email == user_email] + if team_ids: + access_conditions.append(and_(DbResource.team_id.in_(team_ids), DbResource.visibility.in_(["team", "public"]))) + access_conditions.append(DbResource.visibility == "public") + + query = query.where(or_(*access_conditions)) + + # Count total items + count_query = select(func.count()).select_from(DbResource).where(or_(*access_conditions)) # pylint: disable=not-callable + if not include_inactive: + count_query = count_query.where(DbResource.is_active.is_(True)) + + total_items = db.scalar(count_query) or 0 + + # Apply pagination ordering and limits + offset = (page - 1) * per_page + query = query.order_by(DbResource.name, DbResource.id).offset(offset).limit(per_page) + + resources_db = list(db.scalars(query).all()) + + # Convert to schemas using ResourceService + local_resource_service = ResourceService() + resources_data = [] + for r in resources_db: + try: + resources_data.append(local_resource_service._convert_resource_to_read(r)) # pylint: disable=protected-access + except Exception as e: + LOGGER.warning(f"Failed to convert resource {getattr(r, 'id', '')} to schema: {e}") + continue + + data = jsonable_encoder(resources_data) + + # Build pagination metadata + pagination = PaginationMeta( + page=page, + per_page=per_page, + total_items=total_items, + total_pages=math.ceil(total_items / per_page) if per_page > 0 else 0, + has_next=page < math.ceil(total_items / per_page) if per_page > 0 else False, + has_prev=page > 1, + ) + + base_url = f"{settings.app_root_path}/admin/resources/partial" + links = generate_pagination_links( + base_url=base_url, + page=page, + per_page=per_page, + total_pages=pagination.total_pages, + query_params={"include_inactive": "true"} if include_inactive else {}, + ) + + if render == "controls": + return request.app.state.templates.TemplateResponse( + "pagination_controls.html", + { + "request": request, + "pagination": pagination.model_dump(), + "base_url": base_url, + "hx_target": "#resources-table-body", + "hx_indicator": "#resources-loading", + "query_params": {"include_inactive": "true"} if include_inactive else {}, + "root_path": request.scope.get("root_path", ""), + }, + ) + + if render == "selector": + return request.app.state.templates.TemplateResponse( + "resources_selector_items.html", + { + "request": request, + "data": data, + "pagination": pagination.model_dump(), + "root_path": request.scope.get("root_path", ""), + }, + ) + + return request.app.state.templates.TemplateResponse( + "resources_partial.html", + { + "request": request, + "data": data, + "pagination": pagination.model_dump(), + "links": links.model_dump() if links else None, + "root_path": request.scope.get("root_path", ""), + "include_inactive": include_inactive, + }, + ) + + +@admin_router.get("/prompts/ids", response_class=JSONResponse) +async def admin_get_all_prompt_ids( + include_inactive: bool = False, + db: Session = Depends(get_db), + user=Depends(get_current_user_with_permissions), +): + """Return all prompt IDs accessible to the current user (select-all helper). + + This endpoint is used by UI "Select All" helpers to fetch only the IDs + of prompts the requesting user can access (owner, team, or public). + + Args: + include_inactive (bool): When True include prompts that are inactive. + db (Session): Database session (injected dependency). + user: Authenticated user object from dependency injection. + + Returns: + dict: A dictionary containing two keys: + - "prompt_ids": List[str] of accessible prompt IDs. + - "count": int number of IDs returned. + """ + user_email = get_user_email(user) + team_service = TeamManagementService(db) + user_teams = await team_service.get_user_teams(user_email) + team_ids = [t.id for t in user_teams] + + query = select(DbPrompt.id) + if not include_inactive: + query = query.where(DbPrompt.is_active.is_(True)) + + access_conditions = [DbPrompt.owner_email == user_email, DbPrompt.visibility == "public"] + if team_ids: + access_conditions.append(and_(DbPrompt.team_id.in_(team_ids), DbPrompt.visibility.in_(["team", "public"]))) + + query = query.where(or_(*access_conditions)) + prompt_ids = [row[0] for row in db.execute(query).all()] + return {"prompt_ids": prompt_ids, "count": len(prompt_ids)} + + +@admin_router.get("/resources/ids", response_class=JSONResponse) +async def admin_get_all_resource_ids( + include_inactive: bool = False, + db: Session = Depends(get_db), + user=Depends(get_current_user_with_permissions), +): + """Return all resource IDs accessible to the current user (select-all helper). + + This endpoint is used by UI "Select All" helpers to fetch only the IDs + of resources the requesting user can access (owner, team, or public). + + Args: + include_inactive (bool): Whether to include inactive resources in the results. + db (Session): Database session dependency. + user: Authenticated user object from dependency injection. + + Returns: + dict: A dictionary containing two keys: + - "resource_ids": List[str] of accessible resource IDs. + - "count": int number of IDs returned. + """ + user_email = get_user_email(user) + team_service = TeamManagementService(db) + user_teams = await team_service.get_user_teams(user_email) + team_ids = [t.id for t in user_teams] + + query = select(DbResource.id) + if not include_inactive: + query = query.where(DbResource.is_active.is_(True)) + + access_conditions = [DbResource.owner_email == user_email, DbResource.visibility == "public"] + if team_ids: + access_conditions.append(and_(DbResource.team_id.in_(team_ids), DbResource.visibility.in_(["team", "public"]))) + + query = query.where(or_(*access_conditions)) + resource_ids = [row[0] for row in db.execute(query).all()] + return {"resource_ids": resource_ids, "count": len(resource_ids)} + + +@admin_router.get("/prompts/search", response_class=JSONResponse) +async def admin_search_prompts( + q: str = Query("", description="Search query"), + include_inactive: bool = False, + limit: int = Query(100, ge=1, le=1000), + db: Session = Depends(get_db), + user=Depends(get_current_user_with_permissions), +): + """Search prompts by name or description for selector search. + + Performs a case-insensitive search over prompt names and descriptions + and returns a limited list of matching prompts suitable for selector + UIs (id, name, description). + + Args: + q (str): Search query string. + include_inactive (bool): When True include prompts that are inactive. + limit (int): Maximum number of results to return (bounded by the query parameter). + db (Session): Database session (injected dependency). + user: Authenticated user object from dependency injection. + + Returns: + dict: A dictionary containing: + - "prompts": List[dict] where each dict has keys "id", "name", "description". + - "count": int number of matched prompts returned. + """ + user_email = get_user_email(user) + search_query = q.strip().lower() + if not search_query: + return {"prompts": [], "count": 0} + + team_service = TeamManagementService(db) + user_teams = await team_service.get_user_teams(user_email) + team_ids = [t.id for t in user_teams] + + query = select(DbPrompt.id, DbPrompt.name, DbPrompt.description) + if not include_inactive: + query = query.where(DbPrompt.is_active.is_(True)) + + access_conditions = [DbPrompt.owner_email == user_email, DbPrompt.visibility == "public"] + if team_ids: + access_conditions.append(and_(DbPrompt.team_id.in_(team_ids), DbPrompt.visibility.in_(["team", "public"]))) + + query = query.where(or_(*access_conditions)) + + search_conditions = [func.lower(DbPrompt.name).contains(search_query), func.lower(coalesce(DbPrompt.description, "")).contains(search_query)] + query = query.where(or_(*search_conditions)) + + query = query.order_by( + case( + (func.lower(DbPrompt.name).startswith(search_query), 1), + else_=2, + ), + func.lower(DbPrompt.name), + ).limit(limit) + + results = db.execute(query).all() + prompts = [] + for row in results: + prompts.append({"id": row.id, "name": row.name, "description": row.description}) + + return {"prompts": prompts, "count": len(prompts)} + + @admin_router.get("/tools/{tool_id}", response_model=ToolRead) async def admin_get_tool(tool_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]: """ diff --git a/mcpgateway/services/prompt_service.py b/mcpgateway/services/prompt_service.py index 8cb936b05..456ed2461 100644 --- a/mcpgateway/services/prompt_service.py +++ b/mcpgateway/services/prompt_service.py @@ -1110,7 +1110,6 @@ async def get_prompt_details(self, db: Session, prompt_id: Union[int, str], incl >>> result == prompt_dict True """ - logger.info(f"prompt_id:::{prompt_id}") prompt = db.get(DbPrompt, prompt_id) if not prompt: raise PromptNotFoundError(f"Prompt not found: {prompt_id}") diff --git a/mcpgateway/static/admin.js b/mcpgateway/static/admin.js index fc33990d5..fefd09f6e 100644 --- a/mcpgateway/static/admin.js +++ b/mcpgateway/static/admin.js @@ -6618,7 +6618,29 @@ function initResourceSelect( 'input[type="checkbox"]', ); const checked = Array.from(checkboxes).filter((cb) => cb.checked); - const count = checked.length; + // const count = checked.length; + + // Select All handling + const selectAllInput = container.querySelector( + 'input[name="selectAllResources"]', + ); + const allIdsInput = container.querySelector( + 'input[name="allResourceIds"]', + ); + + let count = checked.length; + if ( + selectAllInput && + selectAllInput.value === "true" && + allIdsInput + ) { + try { + const allIds = JSON.parse(allIdsInput.value); + count = allIds.length; + } catch (e) { + console.error("Error parsing allResourceIds:", e); + } + } // Rebuild pills safely - show first 3, then summarize the rest pillsBox.innerHTML = ""; @@ -6665,6 +6687,21 @@ function initResourceSelect( 'input[type="checkbox"]', ); checkboxes.forEach((cb) => (cb.checked = false)); + + // Remove any select-all hidden inputs + const selectAllInput = container.querySelector( + 'input[name="selectAllResources"]', + ); + if (selectAllInput) { + selectAllInput.remove(); + } + const allIdsInput = container.querySelector( + 'input[name="allResourceIds"]', + ); + if (allIdsInput) { + allIdsInput.remove(); + } + update(); }); } @@ -6675,12 +6712,63 @@ function initResourceSelect( newSelectBtn.dataset.listenerAttached = "true"; selectBtn.parentNode.replaceChild(newSelectBtn, selectBtn); - newSelectBtn.addEventListener("click", () => { - const checkboxes = container.querySelectorAll( - 'input[type="checkbox"]', - ); - checkboxes.forEach((cb) => (cb.checked = true)); - update(); + newSelectBtn.addEventListener("click", async () => { + const originalText = newSelectBtn.textContent; + newSelectBtn.disabled = true; + newSelectBtn.textContent = "Selecting all resources..."; + + try { + const resp = await fetch( + `${window.ROOT_PATH}/admin/resources/ids`, + ); + if (!resp.ok) { + throw new Error("Failed to fetch resource IDs"); + } + const data = await resp.json(); + const allIds = data.resource_ids || []; + + // Check all currently loaded checkboxes + const loadedCheckboxes = container.querySelectorAll( + 'input[type="checkbox"]', + ); + loadedCheckboxes.forEach((cb) => (cb.checked = true)); + + // Add hidden select-all flag + let selectAllInput = container.querySelector( + 'input[name="selectAllResources"]', + ); + if (!selectAllInput) { + selectAllInput = document.createElement("input"); + selectAllInput.type = "hidden"; + selectAllInput.name = "selectAllResources"; + container.appendChild(selectAllInput); + } + selectAllInput.value = "true"; + + // Store IDs as JSON for backend handling + let allIdsInput = container.querySelector( + 'input[name="allResourceIds"]', + ); + if (!allIdsInput) { + allIdsInput = document.createElement("input"); + allIdsInput.type = "hidden"; + allIdsInput.name = "allResourceIds"; + container.appendChild(allIdsInput); + } + allIdsInput.value = JSON.stringify(allIds); + + update(); + + newSelectBtn.textContent = `✓ All ${allIds.length} resources selected`; + setTimeout(() => { + newSelectBtn.textContent = originalText; + }, 2000); + } catch (error) { + console.error("Error selecting all resources:", error); + alert("Failed to select all resources. Please try again."); + } finally { + newSelectBtn.disabled = false; + } }); } @@ -6691,6 +6779,35 @@ function initResourceSelect( container.dataset.changeListenerAttached = "true"; container.addEventListener("change", (e) => { if (e.target.type === "checkbox") { + // If Select All mode is active, update the stored IDs array + const selectAllInput = container.querySelector( + 'input[name="selectAllResources"]', + ); + const allIdsInput = container.querySelector( + 'input[name="allResourceIds"]', + ); + + if ( + selectAllInput && + selectAllInput.value === "true" && + allIdsInput + ) { + try { + let allIds = JSON.parse(allIdsInput.value); + const id = e.target.value; + if (e.target.checked) { + if (!allIds.includes(id)) { + allIds.push(id); + } + } else { + allIds = allIds.filter((x) => x !== id); + } + allIdsInput.value = JSON.stringify(allIds); + } catch (err) { + console.error("Error updating allResourceIds:", err); + } + } + update(); } }); @@ -6727,7 +6844,28 @@ function initPromptSelect( 'input[type="checkbox"]', ); const checked = Array.from(checkboxes).filter((cb) => cb.checked); - const count = checked.length; + + // Determine count: if Select All mode is active, use the stored allPromptIds + const selectAllInput = container.querySelector( + 'input[name="selectAllPrompts"]', + ); + const allIdsInput = container.querySelector( + 'input[name="allPromptIds"]', + ); + + let count = checked.length; + if ( + selectAllInput && + selectAllInput.value === "true" && + allIdsInput + ) { + try { + const allIds = JSON.parse(allIdsInput.value); + count = allIds.length; + } catch (e) { + console.error("Error parsing allPromptIds:", e); + } + } // Rebuild pills safely - show first 3, then summarize the rest pillsBox.innerHTML = ""; @@ -6774,6 +6912,21 @@ function initPromptSelect( 'input[type="checkbox"]', ); checkboxes.forEach((cb) => (cb.checked = false)); + + // Remove any select-all hidden inputs + const selectAllInput = container.querySelector( + 'input[name="selectAllPrompts"]', + ); + if (selectAllInput) { + selectAllInput.remove(); + } + const allIdsInput = container.querySelector( + 'input[name="allPromptIds"]', + ); + if (allIdsInput) { + allIdsInput.remove(); + } + update(); }); } @@ -6783,13 +6936,63 @@ function initPromptSelect( const newSelectBtn = selectBtn.cloneNode(true); newSelectBtn.dataset.listenerAttached = "true"; selectBtn.parentNode.replaceChild(newSelectBtn, selectBtn); + newSelectBtn.addEventListener("click", async () => { + const originalText = newSelectBtn.textContent; + newSelectBtn.disabled = true; + newSelectBtn.textContent = "Selecting all prompts..."; - newSelectBtn.addEventListener("click", () => { - const checkboxes = container.querySelectorAll( - 'input[type="checkbox"]', - ); - checkboxes.forEach((cb) => (cb.checked = true)); - update(); + try { + const resp = await fetch( + `${window.ROOT_PATH}/admin/prompts/ids`, + ); + if (!resp.ok) { + throw new Error("Failed to fetch prompt IDs"); + } + const data = await resp.json(); + const allIds = data.prompt_ids || []; + + // Check all currently loaded checkboxes + const loadedCheckboxes = container.querySelectorAll( + 'input[type="checkbox"]', + ); + loadedCheckboxes.forEach((cb) => (cb.checked = true)); + + // Add hidden select-all flag + let selectAllInput = container.querySelector( + 'input[name="selectAllPrompts"]', + ); + if (!selectAllInput) { + selectAllInput = document.createElement("input"); + selectAllInput.type = "hidden"; + selectAllInput.name = "selectAllPrompts"; + container.appendChild(selectAllInput); + } + selectAllInput.value = "true"; + + // Store IDs as JSON for backend handling + let allIdsInput = container.querySelector( + 'input[name="allPromptIds"]', + ); + if (!allIdsInput) { + allIdsInput = document.createElement("input"); + allIdsInput.type = "hidden"; + allIdsInput.name = "allPromptIds"; + container.appendChild(allIdsInput); + } + allIdsInput.value = JSON.stringify(allIds); + + update(); + + newSelectBtn.textContent = `✓ All ${allIds.length} prompts selected`; + setTimeout(() => { + newSelectBtn.textContent = originalText; + }, 2000); + } catch (error) { + console.error("Error selecting all prompts:", error); + alert("Failed to select all prompts. Please try again."); + } finally { + newSelectBtn.disabled = false; + } }); } @@ -6800,6 +7003,35 @@ function initPromptSelect( container.dataset.changeListenerAttached = "true"; container.addEventListener("change", (e) => { if (e.target.type === "checkbox") { + // If Select All mode is active, update the stored IDs array + const selectAllInput = container.querySelector( + 'input[name="selectAllPrompts"]', + ); + const allIdsInput = container.querySelector( + 'input[name="allPromptIds"]', + ); + + if ( + selectAllInput && + selectAllInput.value === "true" && + allIdsInput + ) { + try { + let allIds = JSON.parse(allIdsInput.value); + const id = e.target.value; + if (e.target.checked) { + if (!allIds.includes(id)) { + allIds.push(id); + } + } else { + allIds = allIds.filter((x) => x !== id); + } + allIdsInput.value = JSON.stringify(allIds); + } catch (err) { + console.error("Error updating allPromptIds:", err); + } + } + update(); } }); @@ -6816,13 +7048,110 @@ function toggleInactiveItems(type) { return; } - const url = new URL(window.location); + // Update URL in address bar (no navigation) so state is reflected + try { + const urlObj = new URL(window.location); + if (checkbox.checked) { + urlObj.searchParams.set("include_inactive", "true"); + } else { + urlObj.searchParams.delete("include_inactive"); + } + // Use replaceState to avoid adding history entries for every toggle + window.history.replaceState({}, document.title, urlObj.toString()); + } catch (e) { + // ignore (shouldn't happen) + } + + // Try to find the HTMX container that loads this entity's partial + // Prefer an element with hx-get containing the admin partial endpoint + const selector = `[hx-get*="/admin/${type}/partial"]`; + let container = document.querySelector(selector); + + // Fallback to conventional id naming used in templates + if (!container) { + const fallbackId = + type === "tools" ? "tools-table" : `${type}-list-container`; + container = document.getElementById(fallbackId); + } + + if (!container) { + // If we couldn't find a container, fallback to full-page reload + const fallbackUrl = new URL(window.location); + if (checkbox.checked) { + fallbackUrl.searchParams.set("include_inactive", "true"); + } else { + fallbackUrl.searchParams.delete("include_inactive"); + } + window.location = fallbackUrl; + return; + } + + // Build request URL based on the hx-get attribute or container id + const base = + container.getAttribute("hx-get") || + container.getAttribute("data-hx-get") || + ""; + let reqUrl; + try { + if (base) { + // base may already include query params; construct URL and set include_inactive/page + reqUrl = new URL(base, window.location.origin); + // reset to page 1 when toggling + reqUrl.searchParams.set("page", "1"); + if (checkbox.checked) { + reqUrl.searchParams.set("include_inactive", "true"); + } else { + reqUrl.searchParams.delete("include_inactive"); + } + } else { + // construct from known pattern + const root = window.ROOT_PATH || ""; + reqUrl = new URL( + `${root}/admin/${type}/partial?page=1&per_page=50`, + window.location.origin, + ); + if (checkbox.checked) { + reqUrl.searchParams.set("include_inactive", "true"); + } + } + } catch (e) { + // fallback to full reload + const fallbackUrl2 = new URL(window.location); + if (checkbox.checked) { + fallbackUrl2.searchParams.set("include_inactive", "true"); + } else { + fallbackUrl2.searchParams.delete("include_inactive"); + } + window.location = fallbackUrl2; + return; + } + + // Determine indicator selector + const indicator = + container.getAttribute("hx-indicator") || `#${type}-loading`; + + // Use HTMX to reload only the container (outerHTML swap) + if (window.htmx && typeof window.htmx.ajax === "function") { + try { + window.htmx.ajax("GET", reqUrl.toString(), { + target: container, + swap: "outerHTML", + indicator, + }); + return; + } catch (e) { + // fall through to full reload + } + } + + // Last resort: reload page with param + const finalUrl = new URL(window.location); if (checkbox.checked) { - url.searchParams.set("include_inactive", "true"); + finalUrl.searchParams.set("include_inactive", "true"); } else { - url.searchParams.delete("include_inactive"); + finalUrl.searchParams.delete("include_inactive"); } - window.location = url; + window.location = finalUrl; } function handleToggleSubmit(event, type) { @@ -10565,17 +10894,18 @@ function setupSelectorSearch() { }); } - // Prompts search + // Prompts search (server-side) const searchPrompts = safeGetElement("searchPrompts", true); if (searchPrompts) { + let promptSearchTimeout; searchPrompts.addEventListener("input", function () { - filterSelectorItems( - this.value, - "#associatedPrompts", - ".prompt-item", - "noPromptsMessage", - "searchPromptsQuery", - ); + const searchTerm = this.value; + if (promptSearchTimeout) { + clearTimeout(promptSearchTimeout); + } + promptSearchTimeout = setTimeout(() => { + serverSidePromptSearch(searchTerm); + }, 300); }); } } @@ -17548,6 +17878,131 @@ function updateToolMapping(container) { }); } +/** + * Perform server-side search for prompts and update the prompt list + */ +async function serverSidePromptSearch(searchTerm) { + const container = document.getElementById("associatedPrompts"); + const noResultsMessage = safeGetElement("noPromptsMessage", true); + const searchQuerySpan = safeGetElement("searchPromptsQuery", true); + + if (!container) { + console.error("associatedPrompts container not found"); + return; + } + + // Show loading state + container.innerHTML = ` +
+ + + + +

Searching prompts...

+
+ `; + + if (searchTerm.trim() === "") { + // If search term is empty, reload the default prompt selector + try { + const response = await fetch( + `${window.ROOT_PATH}/admin/prompts/partial?page=1&per_page=50&render=selector`, + ); + if (response.ok) { + const html = await response.text(); + container.innerHTML = html; + + // Hide no results message + if (noResultsMessage) { + noResultsMessage.style.display = "none"; + } + + // Initialize prompt mapping if needed + initPromptSelect( + "associatedPrompts", + "selectedPromptsPills", + "selectedPromptsWarning", + 6, + "selectAllPromptsBtn", + "clearAllPromptsBtn", + ); + } else { + container.innerHTML = + '
Failed to load prompts
'; + } + } catch (error) { + console.error("Error loading prompts:", error); + container.innerHTML = + '
Error loading prompts
'; + } + return; + } + + try { + const response = await fetch( + `${window.ROOT_PATH}/admin/prompts/search?q=${encodeURIComponent(searchTerm)}&limit=100`, + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.prompts && data.prompts.length > 0) { + let searchResultsHtml = ""; + data.prompts.forEach((prompt) => { + const displayName = prompt.name || prompt.id; + searchResultsHtml += ` + + `; + }); + + container.innerHTML = searchResultsHtml; + + // Initialize prompt select mapping + initPromptSelect( + "associatedPrompts", + "selectedPromptsPills", + "selectedPromptsWarning", + 6, + "selectAllPromptsBtn", + "clearAllPromptsBtn", + ); + + if (noResultsMessage) { + noResultsMessage.style.display = "none"; + } + } else { + container.innerHTML = ""; + if (noResultsMessage) { + if (searchQuerySpan) { + searchQuerySpan.textContent = searchTerm; + } + noResultsMessage.style.display = "block"; + } + } + } catch (error) { + console.error("Error searching prompts:", error); + container.innerHTML = + '
Error searching prompts
'; + if (noResultsMessage) { + noResultsMessage.style.display = "none"; + } + } +} + // Add CSS for streaming indicator animation const style = document.createElement("style"); style.textContent = ` diff --git a/mcpgateway/templates/admin.html b/mcpgateway/templates/admin.html index d4e8b247f..174a83d30 100644 --- a/mcpgateway/templates/admin.html +++ b/mcpgateway/templates/admin.html @@ -360,6 +360,8 @@ + + @@ -2120,30 +2122,19 @@

- {% for resource in resources %} - - {% endfor %} - + +
+ + + + + Loading resources... +

- - - - - - - - - - - - - - - - - - {% for resource in resources %} - - - - - - - - - - - - - - {% endfor %} - -
- ID - - URI - - Name - - Description - - MIME Type - - Tags - - Owner - - Team - - Visibility - - Status - - Actions -
- {{ resource.id }} - - {{ resource.uri }} - - {{ resource.name }} - - {{ resource.description or "N/A" }} - - {{ resource.mimeType or "N/A" }} - - {% if resource.tags %} {% for tag in resource.tags %} - {{ tag }} - {% endfor %} {% else %} - None - {% endif %} - - {% if resource.ownerEmail %} - {{ resource.ownerEmail }} - {% else %} - N/A - {% endif %} - - {% if resource.team %} - - {{ resource.team.replace(' ', '
')|safe }} -
- {% else %} - N/A - {% endif %} -
- {% if resource.visibility == 'private' %} - Private - {% elif resource.visibility == 'team' %} - Team - {% elif resource.visibility == 'public' %} - Public - {% else %} - N/A - {% endif %} - - - {{ "Active" if resource.isActive else "Inactive" }} - - -
- - - - - -
- {% if resource.isActive %} -
- - -
- {% else %} -
- - -
- {% endif %} -
- -
-
-
-
+ +
+ +
+ +
+ +
@@ -3700,236 +3468,42 @@

MCP Prompts

- - - - - - - - - - - - - - - - - {% for prompt in prompts %} - - - - - - - - - - - - - {% endfor %} - -
- ID - - Name - - Description - - Arguments - - Tags - - Owner - - Team - - Visibility - - Status - - Actions -
- {{ prompt.id }} - - {{ prompt.name }} - - {{ prompt.description }} - - {% for arg in prompt.arguments %}{{ arg.name }}{% if not - loop.last %}, {% endif %}{% endfor %} - - {% if prompt.tags %} {% for tag in prompt.tags %} - {{ tag }} - {% endfor %} {% else %} - None - {% endif %} - - {% if prompt.ownerEmail %} - {{ prompt.ownerEmail }} - {% else %} - N/A - {% endif %} - - {% if prompt.team %} - - {{ prompt.team.replace(' ', '
')|safe }} -
- {% else %} - N/A - {% endif %} -
- {% if prompt.visibility == 'private' %} - Private - {% elif prompt.visibility == 'team' %} - Team - {% elif prompt.visibility == 'public' %} - Public - {% else %} - N/A - {% endif %} - - - {{ "Active" if prompt.isActive else "Inactive" }} - - -
- - + +
+ +
+ + + + + Loading prompts... +
+
- - - + + - -
- {% if prompt.isActive %} -
- - -
- {% else %} -
- - -
- {% endif %} -
- -
-
-
-
+ + +
+ +

Add New Prompt diff --git a/mcpgateway/templates/prompts_partial.html b/mcpgateway/templates/prompts_partial.html new file mode 100644 index 000000000..b03086fef --- /dev/null +++ b/mcpgateway/templates/prompts_partial.html @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + +{% for prompt in data %} + + + + + + + + + + + +{% endfor %} + +
S. No.NameDescriptionTagsOwnerTeamVisibilityStatusActions
{{ (pagination.page - 1) * pagination.per_page + loop.index }}{{ prompt.name }}{% set clean_desc = (prompt.description or "") | replace('\n', ' ') | replace('\r', ' ') %} {% set refactor_desc = clean_desc | striptags | trim | escape %} {% if refactor_desc | length is greaterthan 220 %} {{ refactor_desc[:400] + "..." }} {% else %} {{ refactor_desc }} {% endif %}{% if prompt.tags %} {% for tag in prompt.tags %}{{ tag }}{% endfor %} {% else %}None{% endif %}{{ prompt.owner_email }}{% if prompt.team %}{{ prompt.team }}{% else %}None{% endif %}{% if prompt.visibility == 'public' %}🌍 Public{% elif prompt.visibility == 'team' %}👥 Team{% else %}🔒 Private{% endif %}
{% set enabled = prompt.is_active %}{% if enabled %}Active{% else %}Inactive{% endif %}
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+ {% set base_url = root_path + '/admin/prompts/partial' %} + {% set hx_target = '#prompts-table' %} + {% set hx_indicator = '#prompts-loading' %} + {% set query_params = {'include_inactive': include_inactive} %} + {% include 'pagination_controls.html' %} +
diff --git a/mcpgateway/templates/prompts_selector_items.html b/mcpgateway/templates/prompts_selector_items.html new file mode 100644 index 000000000..bfdbfe395 --- /dev/null +++ b/mcpgateway/templates/prompts_selector_items.html @@ -0,0 +1,38 @@ + + +{% for prompt in data %} + +{% endfor %} + +{% if pagination.has_next %} + +
+ + + + + + Loading more prompts... + +
+ +{% endif %} diff --git a/mcpgateway/templates/resources_partial.html b/mcpgateway/templates/resources_partial.html new file mode 100644 index 000000000..5d013f0c1 --- /dev/null +++ b/mcpgateway/templates/resources_partial.html @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + {% for resource in data %} + + + + + + + + + + + + + + {% endfor %} + +
IDURINameDescriptionMIME TypeTagsOwnerTeamVisibilityStatusActions
{{ resource.id }}{{ resource.uri }}{{ resource.name }}{{ resource.description or 'N/A' }}{{ resource.mimeType or 'N/A' }}{% if resource.tags %}{% for tag in resource.tags %}{{ tag }}{% endfor %}{% else %}None{% endif %}{% if resource.ownerEmail %}{{ resource.ownerEmail }}{% else %}N/A{% endif %}{% if resource.team %}{{ resource.team.replace(' ', '
')|safe }}
{% else %}N/A{% endif %}
{% if resource.visibility == 'private' %}Private{% elif resource.visibility == 'team' %}Team{% elif resource.visibility == 'public' %}Public{% else %}N/A{% endif %}{{ 'Active' if resource.isActive else 'Inactive' }} +
+ + +
+ {% if resource.isActive %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+ +
+
+
+
+ + +
+ {% set base_url = root_path + '/admin/resources/partial' %} + {% set hx_target = '#resources-table' %} + {% set hx_indicator = '#resources-loading' %} + {% set query_params = {'include_inactive': include_inactive} %} + {% include 'pagination_controls.html' %} +
diff --git a/mcpgateway/templates/resources_selector_items.html b/mcpgateway/templates/resources_selector_items.html new file mode 100644 index 000000000..413c1a58f --- /dev/null +++ b/mcpgateway/templates/resources_selector_items.html @@ -0,0 +1,37 @@ + + +{% for resource in data %} + +{% endfor %} + +{% if pagination.has_next %} + +
+ + + + + + Loading more resources... + +
+{% endif %}