From 7cf447609b0ec36a515776ca74e0cd1fc83bf6e4 Mon Sep 17 00:00:00 2001 From: Kyrylo Shmidt Date: Thu, 17 Jul 2025 15:29:21 +0200 Subject: [PATCH 1/6] Support custom MCP server icons --- openapi-config.ts | 3 + package-lock.json | 42 ++++- package.json | 1 + .../AgentFlowChart/MCPServerIcon/index.tsx | 17 +- .../AgentFlowChart/MCPServerIcon/styles.ts | 8 + .../AgentFlowChart/MCPServerIcon/types.ts | 10 +- .../MCPServersContainer/index.tsx | 4 +- .../MCPServersToolbar/index.tsx | 7 +- .../MCPServersToolbar/styles.ts | 2 +- .../MCPServerDialog/ToolsStep/index.tsx | 172 +++++++++++++++++- .../MCPServerDialog/ToolsStep/styles.ts | 103 ++++++++++- .../MCPServerDialog/ToolsStep/types.ts | 20 +- .../MCPServerDialog/index.tsx | 13 +- .../Agentic/IncidentTemplate/index.tsx | 3 +- .../Agentic/common/PromptInput/index.tsx | 5 +- .../common/icons/12px/DownloadIcon.tsx | 19 +- src/components/common/icons/32px/PageIcon.tsx | 25 +++ src/hooks/useFormDataRequest.ts | 72 ++++++++ src/redux/services/types.ts | 15 ++ 19 files changed, 505 insertions(+), 36 deletions(-) create mode 100644 src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServerIcon/styles.ts create mode 100644 src/components/common/icons/32px/PageIcon.tsx create mode 100644 src/hooks/useFormDataRequest.ts diff --git a/openapi-config.ts b/openapi-config.ts index 8c2d5455d..30bd4a978 100644 --- a/openapi-config.ts +++ b/openapi-config.ts @@ -35,10 +35,13 @@ const config: ConfigFile = { })), filterEndpoints: [ /About/, + /Agentic/, + /AI/, /Assets/, /Authentication/, /CodeAnalytics/, /Dashboard/, + /Diagnostic/, /Environments/, /Errors/, /Graphs/, diff --git a/package-lock.json b/package-lock.json index d2e5b0c0b..a31276aa2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "react": "^18.2.0", "react-cool-dimensions": "^3.0.1", "react-dom": "^18.2.0", + "react-dropzone": "^14.3.8", "react-error-boundary": "^5.0.0", "react-helmet": "^6.1.0", "react-hook-form": "^7.48.2", @@ -6929,6 +6930,15 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -10621,6 +10631,18 @@ "node": ">=16" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -17246,6 +17268,23 @@ "react": "^18.3.1" } }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-error-boundary": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-5.0.0.tgz", @@ -20316,8 +20355,7 @@ "node_modules/tslib": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", - "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", - "dev": true + "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==" }, "node_modules/turbo-stream": { "version": "2.4.0", diff --git a/package.json b/package.json index b8a7a64a8..743f484db 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "react": "^18.2.0", "react-cool-dimensions": "^3.0.1", "react-dom": "^18.2.0", + "react-dropzone": "^14.3.8", "react-error-boundary": "^5.0.0", "react-helmet": "^6.1.0", "react-hook-form": "^7.48.2", diff --git a/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServerIcon/index.tsx b/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServerIcon/index.tsx index ebf103aff..7c861ab92 100644 --- a/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServerIcon/index.tsx +++ b/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServerIcon/index.tsx @@ -3,21 +3,24 @@ import { MCPLogoIcon } from "../../../../common/icons/24px/MCPLogoIcon"; import { KubernetesLogoIcon } from "../../../../common/icons/25px/KubernetesLogoIcon"; import { PostgresLogoIcon } from "../../../../common/icons/25px/PostgresLogoIcon"; import { GitHubLogoIcon } from "../../../../common/icons/28px/GitHubLogoIcon"; +import * as s from "./styles"; import type { MCPServerIconProps } from "./types"; export const DEFAULT_SIZE = 12; // in pixels export const MCPServerIcon = ({ - type, - isCustom, - isActive, - size = DEFAULT_SIZE + size = DEFAULT_SIZE, + server }: MCPServerIconProps) => { - if (isCustom) { + if (server.icon) { + return ; + } + + if (server.isEditable) { return ; } - switch (type) { + switch (server.name) { case "github": return ; case "postgres": @@ -29,7 +32,7 @@ export const MCPServerIcon = ({ ); default: diff --git a/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServerIcon/styles.ts b/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServerIcon/styles.ts new file mode 100644 index 000000000..bd0405c28 --- /dev/null +++ b/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServerIcon/styles.ts @@ -0,0 +1,8 @@ +import styled from "styled-components"; +import type { CustomImageProps } from "./types"; + +export const CustomImage = styled.img` + width: ${({ $size }) => $size}px; + height: ${({ $size }) => $size}px; + object-fit: contain; +`; diff --git a/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServerIcon/types.ts b/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServerIcon/types.ts index d505a963d..d533ac4c9 100644 --- a/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServerIcon/types.ts +++ b/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServerIcon/types.ts @@ -1,6 +1,10 @@ +import type { ExtendedAgentMCPServer } from "../types"; + export interface MCPServerIconProps { - type: string; - isActive?: boolean; - isCustom?: boolean; + server: ExtendedAgentMCPServer; size?: number; } + +export interface CustomImageProps { + $size: number; +} diff --git a/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServersContainer/index.tsx b/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServersContainer/index.tsx index e381116a9..cb82f1734 100644 --- a/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServersContainer/index.tsx +++ b/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServersContainer/index.tsx @@ -20,9 +20,7 @@ export const MCPServersContainer = ({ servers }: MCPServersContainerProps) => { diff --git a/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServersToolbar/index.tsx b/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServersToolbar/index.tsx index bd5f1f56c..dc677b808 100644 --- a/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServersToolbar/index.tsx +++ b/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServersToolbar/index.tsx @@ -99,12 +99,7 @@ export const MCPServersToolbar = ({
- +
diff --git a/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServersToolbar/styles.ts b/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServersToolbar/styles.ts index c3a6efd1a..e57c7cadf 100644 --- a/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServersToolbar/styles.ts +++ b/src/components/Agentic/IncidentDetails/AgentFlowChart/MCPServersToolbar/styles.ts @@ -22,7 +22,7 @@ export const MCPServerIconContainer = styled.div` display: flex; align-items: center; justify-content: center; - cursor: ${({ $isEditable }) => ($isEditable ? "pointer" : "default")}; + cursor: ${({ $isEditable }) => ($isEditable ? "pointer" : "auto")}; `; export const KebabMenuButton = styled.button` diff --git a/src/components/Agentic/IncidentTemplate/MCPServerDialog/ToolsStep/index.tsx b/src/components/Agentic/IncidentTemplate/MCPServerDialog/ToolsStep/index.tsx index 65b89928c..143d4be2c 100644 --- a/src/components/Agentic/IncidentTemplate/MCPServerDialog/ToolsStep/index.tsx +++ b/src/components/Agentic/IncidentTemplate/MCPServerDialog/ToolsStep/index.tsx @@ -1,12 +1,25 @@ +import axios from "axios"; import { useState, type ChangeEvent } from "react"; +import { useDropzone } from "react-dropzone"; +import { useFormDataRequest } from "../../../../../hooks/useFormDataRequest"; +import type { MCPServerIcon } from "../../../../../redux/services/types"; +import { isString } from "../../../../../typeGuards/isString"; import { sendUserActionTrackingEvent } from "../../../../../utils/actions/sendUserActionTrackingEvent"; +import { roundTo } from "../../../../../utils/roundTo"; +import { DownloadIcon } from "../../../../common/icons/12px/DownloadIcon"; +import { TrashBinIcon } from "../../../../common/icons/16px/TrashBinIcon"; +import { PageIcon } from "../../../../common/icons/32px/PageIcon"; +import { Direction } from "../../../../common/icons/types"; import { NewButton } from "../../../../common/v3/NewButton"; +import { Tooltip } from "../../../../common/v3/Tooltip"; import { SearchInput } from "../../../common/SearchInput"; import { trackingEvents } from "../../../tracking"; import { Footer } from "../Footer"; import * as s from "./styles"; import type { ToolsStepProps } from "./types"; +const MAX_ICON_FILE_SIZE = 1024 * 1024; // in bytes + export const ToolsStep = ({ onCancel, onSave, @@ -14,12 +27,98 @@ export const ToolsStep = ({ selectedTools: initialSelectedTools = [], isLoading, instructions = "", + icon, error }: ToolsStepProps) => { const [instructionsTextAreaValue, setInstructionsTextAreaValue] = useState(instructions); const [selectedTools, setSelectedTools] = useState(initialSelectedTools); const [searchInputValue, setSearchInputValue] = useState(""); + const [dropzoneError, setDropzoneError] = useState(); + const [iconId, setIconId] = useState(null); + const [fileToUpload, setFileToUpload] = useState(null); + const { + send: upload, + progress, + isSending, + abort + } = useFormDataRequest({ + url: `${ + isString(window.digmaApiProxyPrefix) + ? window.digmaApiProxyPrefix + : "/api/" + }mcp/icon`, + withCredentials: true, + onSuccess: (response) => { + setIconId(response.id); + setDropzoneError(undefined); + }, + onError: (error) => { + const errorMessage = axios.isAxiosError(error) + ? error.response + ? String(error.response.data) + : error.message + : error instanceof Error + ? error.message + : "Unknown error"; + setDropzoneError(`Failed to upload icon: ${errorMessage}`); + } + }); + + const fileDetails = icon + ? { + name: icon.fileName, + size: icon.fileSize, + type: icon.fileName.split(".")[1] ?? "" + } + : fileToUpload + ? { + name: fileToUpload.name, + size: fileToUpload.size, + type: fileToUpload.type.split("/")[1] + } + : null; + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + accept: { + "image/jpeg": [".jpg", ".jpeg"], + "image/png": [".png"] + }, + disabled: Boolean(fileDetails), + multiple: false, + maxSize: MAX_ICON_FILE_SIZE, + onDrop: (acceptedFiles, rejectedFiles) => { + if (rejectedFiles.length > 0) { + const rejection = rejectedFiles[0]; + if (rejection.errors.some((error) => error.code === "too-many-files")) { + setDropzoneError("Too many files. Only one file is allowed."); + return; + } + + if ( + rejection.errors.some((error) => error.code === "file-invalid-type") + ) { + setDropzoneError("Invalid file type. Only JPG and PNG are allowed."); + return; + } + + if (rejection.errors.some((error) => error.code === "file-too-large")) { + setDropzoneError("File too large. Maximum size is 1MB."); + } + } else { + setDropzoneError(undefined); + + if (acceptedFiles.length > 0) { + const file = acceptedFiles[0]; + setFileToUpload(file); + const formData = new FormData(); + formData.append("icon_image", file); + + void upload(formData); + } + } + } + }); const handleInstructionsTextAreaChange = ( e: ChangeEvent @@ -31,7 +130,7 @@ export const ToolsStep = ({ sendUserActionTrackingEvent( trackingEvents.INCIDENT_TEMPLATE_EDIT_MCP_DIALOG_SAVE_BUTTON_CLICKED ); - onSave(selectedTools, instructionsTextAreaValue); + onSave(selectedTools, instructionsTextAreaValue, iconId); }; const handleCancelButtonClick = () => { @@ -69,6 +168,23 @@ export const ToolsStep = ({ const isSaveButtonEnabled = selectedTools.length > 0 && !isLoading; + const handleRemoveFile = () => { + if (isSending) { + abort(); + } + setFileToUpload(null); + setIconId(null); + setDropzoneError(undefined); + }; + + const formattedFileSize = fileDetails + ? fileDetails.size >= MAX_ICON_FILE_SIZE + ? `${roundTo(fileDetails.size / 1024 / 1024, 0)} MB` + : `${roundTo(fileDetails.size / 1024, 0)} KB` + : ""; + + const footerError = dropzoneError ?? error; + return ( @@ -106,9 +222,61 @@ export const ToolsStep = ({ "Describe any specific instructions on how this MCP should be used" } /> + + + + {fileDetails ? ( + <> + + + {fileDetails.type} + + + + {fileDetails.name} + + + {formattedFileSize}{" "} + {isSending || progress > 0 ? `– ${progress}% uploaded` : ""} + + + {fileDetails && ( + + + + )} + + ) : ( + <> + + + + + + Click to upload logo or + drag and drop + + PNG or JPG (max. 500x500px) + + + )} + +