diff --git a/src/components/Agents/InstalAgentInstruction/useAgentsBaseURL.tsx b/src/components/Agents/InstalAgentInstruction/useAgentsBaseURL.tsx index 5d84eb6f3..ade762272 100644 --- a/src/components/Agents/InstalAgentInstruction/useAgentsBaseURL.tsx +++ b/src/components/Agents/InstalAgentInstruction/useAgentsBaseURL.tsx @@ -7,7 +7,8 @@ export function useAgentsBaseURL() { // if we are on the SaaS platform, we need to use the backend URL from the user // profile, not the current URL - const baseUrl = authSystem === "clerk" ? backendUrl : window.location.origin; + const baseUrl = + authSystem === "clerk" ? backendUrl : window.location.origin + "/api"; return baseUrl; } diff --git a/src/components/Authentication/Kratos/KratosUserProfileDropdown.tsx b/src/components/Authentication/Kratos/KratosUserProfileDropdown.tsx index 4c24c6d4d..1de9fbbc5 100644 --- a/src/components/Authentication/Kratos/KratosUserProfileDropdown.tsx +++ b/src/components/Authentication/Kratos/KratosUserProfileDropdown.tsx @@ -14,10 +14,12 @@ import KratosLogoutButton from "./KratosLogoutButton"; type UserProfileDropdownProps = { openKubeConfigModal: () => void; + openMcpTokenModal: () => void; }; export function KratosUserProfileDropdown({ - openKubeConfigModal + openKubeConfigModal, + openMcpTokenModal }: UserProfileDropdownProps) { const { user } = useUser(); const userNavigation = [{ name: "Your Profile", href: "/profile-settings" }]; @@ -75,6 +77,14 @@ export function KratosUserProfileDropdown({ Download kubeconfig + + + diff --git a/src/components/Tokens/Add/TokenDisplayModal.tsx b/src/components/Tokens/Add/TokenDisplayModal.tsx index f43498e38..01829d86a 100644 --- a/src/components/Tokens/Add/TokenDisplayModal.tsx +++ b/src/components/Tokens/Add/TokenDisplayModal.tsx @@ -2,22 +2,28 @@ import { useState } from "react"; import { FaCopy, FaEye, FaEyeSlash } from "react-icons/fa"; import { CreateTokenResponse } from "../../../api/services/tokens"; import { Button } from "../../../ui/Buttons/Button"; +import { JSONViewer } from "../../../ui/Code/JSONViewer"; import { Modal } from "../../../ui/Modal"; +import { Tab, Tabs } from "../../../ui/Tabs/Tabs"; import { toastSuccess } from "../../Toast/toast"; import { TokenFormValues } from "./CreateTokenForm"; +import { useAgentsBaseURL } from "../../../components/Agents/InstalAgentInstruction/useAgentsBaseURL"; +import CodeBlock from "@flanksource-ui/ui/Code/CodeBlock"; type Props = { isOpen: boolean; onClose: () => void; tokenResponse: CreateTokenResponse; formValues?: TokenFormValues; + isMcp?: boolean; }; export default function TokenDisplayModal({ isOpen, onClose, tokenResponse, - formValues + formValues, + isMcp = false }: Props) { const [showToken, setShowToken] = useState(false); @@ -77,6 +83,15 @@ export default function TokenDisplayModal({ + {isMcp && ( +
+

+ MCP Client Setup: +

+ +
+ )} +

Usage Instructions: @@ -115,3 +130,161 @@ export default function TokenDisplayModal({ ); } + +type McpSetupTabsProps = { + token: string; +}; + +function McpSetupTabs({ token }: McpSetupTabsProps) { + const [activeTab, setActiveTab] = useState("claude-desktop"); + + const basicAuth = `Basic ${Buffer.from(`token:${token}`).toString("base64")}`; + const baseUrl = useAgentsBaseURL() + "/mcp"; + + const mcpConfigs = { + "claude-desktop": { + label: "Claude Desktop", + config: `{ + "mcpServers": { + "mission-control": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-http", + "${baseUrl}" + ], + "env": { + "AUTHORIZATION": "${basicAuth}" + } + } + } +}` + }, + "claude-code": { + label: "Claude Code", + config: `{ + "name": "mission-control", + "type": "http", + "url": "${baseUrl}", + "headers": { + "Authorization": "${basicAuth}" + } +}` + }, + "vscode-copilot": { + label: "VS Code Copilot", + config: `{ + "github.copilot.mcp.servers": { + "mission-control": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-http", + "${baseUrl}" + ], + "env": { + "AUTHORIZATION": "${basicAuth}" + } + } + } +}` + }, + cline: { + label: "Cline", + config: `{ + "cline.mcpServers": { + "mission-control": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-http", + "${baseUrl}" + ], + "env": { + "AUTHORIZATION": "${basicAuth}" + } + } + } +}` + }, + continue: { + label: "Continue.dev", + config: `{ + "mcpServers": [ + { + "name": "mission-control", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-http", + "${baseUrl}" + ], + "env": { + "AUTHORIZATION": "${basicAuth}" + } + } + ] +}` + }, + zed: { + label: "Zed Editor", + config: `{ + "assistant": { + "mcp": { + "servers": { + "mission-control": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-http", + "${baseUrl}" + ], + "env": { + "AUTHORIZATION": "${basicAuth}" + } + } + } + } + } +}` + }, + direct: { + label: "Direct HTTP", + config: `curl -X POST ${baseUrl} \\ + -H "Authorization: ${basicAuth}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {} + }, + "id": 1 + }'` + } + }; + + return ( +
+ + {Object.entries(mcpConfigs).map(([key, { label, config }]) => ( + +
+ {key === "direct" ? ( + + ) : ( + + )} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/Tokens/TokenDetailsModal.tsx b/src/components/Tokens/TokenDetailsModal.tsx index 5e339d8a2..d00eb4cf3 100644 --- a/src/components/Tokens/TokenDetailsModal.tsx +++ b/src/components/Tokens/TokenDetailsModal.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { FaCircleNotch, FaTrash } from "react-icons/fa"; +import { Age } from "@flanksource-ui/ui/Age"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { deleteToken, Token } from "../../api/services/tokens"; import { Avatar } from "../../ui/Avatar"; @@ -36,7 +37,7 @@ export default function TokenDetailsModal({ }); const formattedCreatedAt = useMemo(() => { - return new Date(token.created_at).toLocaleString(); + return new Date(token.created_at).toISOString(); }, [token.created_at]); const handleDeleteToken = () => { @@ -78,7 +79,7 @@ export default function TokenDetailsModal({ -
{formattedCreatedAt}
+

diff --git a/src/components/Users/UserProfile.tsx b/src/components/Users/UserProfile.tsx index e61172b44..0fe94ccf4 100644 --- a/src/components/Users/UserProfile.tsx +++ b/src/components/Users/UserProfile.tsx @@ -1,14 +1,26 @@ import { OrganizationSwitcher, UserButton } from "@clerk/nextjs"; import { useState } from "react"; -import { IoMdDownload } from "react-icons/io"; +import { IoMdAirplane, IoMdDownload } from "react-icons/io"; +import { CreateTokenResponse } from "../../api/services/tokens"; import { KratosUserProfileDropdown } from "../Authentication/Kratos/KratosUserProfileDropdown"; import useDetermineAuthSystem from "../Authentication/useDetermineAuthSystem"; import AddKubeConfigModal from "../KubeConfig/AddKubeConfigModal"; +import CreateTokenForm, { + TokenFormValues +} from "../Tokens/Add/CreateTokenForm"; +import TokenDisplayModal from "../Tokens/Add/TokenDisplayModal"; export function UserProfileDropdown() { const authSystem = useDetermineAuthSystem(); const [isDownloadKubeConfigModalOpen, setIsDownloadKubeConfigModalOpen] = useState(false); + const [isMcpTokenModalOpen, setIsMcpTokenModalOpen] = useState(false); + const [isMcpTokenDisplayModalOpen, setIsMcpTokenDisplayModalOpen] = + useState(false); + const [mcpTokenResponse, setMcpTokenResponse] = + useState(); + const [mcpTokenFormValues, setMcpTokenFormValues] = + useState(); return ( <> @@ -27,18 +39,43 @@ export function UserProfileDropdown() { labelIcon={} onClick={() => setIsDownloadKubeConfigModalOpen(true)} /> + } + onClick={() => setIsMcpTokenModalOpen(true)} + /> ) : ( setIsDownloadKubeConfigModalOpen(true)} + openMcpTokenModal={() => setIsMcpTokenModalOpen(true)} /> )} setIsDownloadKubeConfigModalOpen(false)} /> + setIsMcpTokenModalOpen(false)} + onSuccess={(response, formValues) => { + setMcpTokenResponse(response); + setMcpTokenFormValues(formValues); + setIsMcpTokenModalOpen(false); + setIsMcpTokenDisplayModalOpen(true); + }} + /> + {mcpTokenResponse && ( + setIsMcpTokenDisplayModalOpen(false)} + tokenResponse={mcpTokenResponse} + formValues={mcpTokenFormValues} + isMcp={true} + /> + )} ); } diff --git a/src/ui/Code/CodeBlock.tsx b/src/ui/Code/CodeBlock.tsx index 3d20e8f13..89306ec0b 100644 --- a/src/ui/Code/CodeBlock.tsx +++ b/src/ui/Code/CodeBlock.tsx @@ -1,35 +1,114 @@ import clsx from "clsx"; +import Highlight, { + Language, + PrismTheme, + defaultProps +} from "prism-react-renderer"; import { FaCopy } from "react-icons/fa"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { Button } from "../Buttons/Button"; +import { lightTheme } from "./JSONViewerTheme"; type Props = { code: string; codeBlockClassName?: string; className?: string; + language?: Language; + theme?: PrismTheme; + showLineNumbers?: boolean; }; export default function CodeBlock({ code, codeBlockClassName = "whitespace-pre-wrap break-all", - className = "flex flex-col flex-1 text-sm text-left gap-2 bg-gray-800 text-white rounded-lg p-4 pl-6" + className = "flex flex-col flex-1 text-sm text-left gap-2 bg-gray-800 text-white rounded-lg p-4 pl-6", + language, + theme = lightTheme, + showLineNumbers = false }: Props) { const copyFn = useCopyToClipboard(); + // If no language is specified, use the original simple code block + if (!language) { + return ( +
+ +
{code}
+
+
+ ); + } + + // Use syntax highlighting when language is specified return ( -
- -
{code}
-
+
+ + {({ + className: highlightClassName, + style, + tokens, + getLineProps, + getTokenProps + }) => ( +
+            {tokens.map((line, i) => {
+              const { style: lineStyle, ...lineProps } = getLineProps({
+                line,
+                key: i
+              });
+              return (
+                
+ {showLineNumbers && ( + + {i + 1} + + )} + + {line.map((token, key) => ( + + ))} + +
+ ); + })} +
+ )} +
diff --git a/src/ui/HelmSnippet/CLISnippet.tsx b/src/ui/HelmSnippet/CLISnippet.tsx index 9a96f1832..d2aa8196d 100644 --- a/src/ui/HelmSnippet/CLISnippet.tsx +++ b/src/ui/HelmSnippet/CLISnippet.tsx @@ -74,7 +74,7 @@ export default function CLIInstallAgent({ data }: Props) { return (

Copy the following command to install the chart

- +
); } diff --git a/src/ui/HelmSnippet/__tests__/CLISnippet.unit.test.tsx b/src/ui/HelmSnippet/__tests__/CLISnippet.unit.test.tsx index 3c5cb921d..3b1645f57 100644 --- a/src/ui/HelmSnippet/__tests__/CLISnippet.unit.test.tsx +++ b/src/ui/HelmSnippet/__tests__/CLISnippet.unit.test.tsx @@ -11,19 +11,23 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({ describe("InstallAgentModal", () => { it("renders the Helm repository installation command", () => { render(); - expect( - screen.getByText("helm repo add flanksource", { - exact: false - }).textContent - ).toMatchSnapshot(); + const element = screen.getByText((content, element) => { + return ( + element?.tagName.toLowerCase() === "pre" && + element?.textContent?.includes("helm repo add flanksource") === true + ); + }); + expect(element.textContent).toMatchSnapshot(); }); it("renders the Helm repository installation command with kube command", () => { render(); - expect( - screen.getByText("helm repo add flanksource", { - exact: false - }).textContent - ).toMatchSnapshot(); + const element = screen.getByText((content, element) => { + return ( + element?.tagName.toLowerCase() === "pre" && + element?.textContent?.includes("helm repo add flanksource") === true + ); + }); + expect(element.textContent).toMatchSnapshot(); }); }); diff --git a/src/ui/HelmSnippet/__tests__/__snapshots__/CLISnippet.unit.test.tsx.snap b/src/ui/HelmSnippet/__tests__/__snapshots__/CLISnippet.unit.test.tsx.snap index 9b31d6e43..547ac8224 100644 --- a/src/ui/HelmSnippet/__tests__/__snapshots__/CLISnippet.unit.test.tsx.snap +++ b/src/ui/HelmSnippet/__tests__/__snapshots__/CLISnippet.unit.test.tsx.snap @@ -2,36 +2,13 @@ exports[`InstallAgentModal renders the Helm repository installation command 1`] = ` "helm repo add flanksource https://flanksource.github.io/charts - -helm install mission-control-agent flanksource/mission-control-agent -n "mission-control-agent" \\ - --set upstream.createSecret=true \\ - --set upstream.host=http://localhost:3000 \\ - --set upstream.username=token \\ - --set upstream.password=password \\ - --set upstream.agentName=test-new-agent-instructions \\ - --set pushTelemetry.enabled=true \\ - --set pushTelemetry.topologyName=incident-commander.demo.aws.flanksource.com-test-new-agent-instructions \\ - --create-namespace - +helm install mission-control-agent flanksource/mission-control-agent -n "mission-control-agent" \\ --set upstream.createSecret=true \\ --set upstream.host=http://localhost:3000 \\ --set upstream.username=token \\ --set upstream.password=password \\ --set upstream.agentName=test-new-agent-instructions \\ --set pushTelemetry.enabled=true \\ --set pushTelemetry.topologyName=incident-commander.demo.aws.flanksource.com-test-new-agent-instructions \\ --create-namespace " `; exports[`InstallAgentModal renders the Helm repository installation command with kube command 1`] = ` "helm repo add flanksource https://flanksource.github.io/charts - -helm install mission-control-agent flanksource/mission-control-agent -n "mission-control-agent" \\ - --set upstream.createSecret=true \\ - --set upstream.host=http://localhost:3000 \\ - --set upstream.username=token \\ - --set upstream.password=password \\ - --set upstream.agentName=test-new-agent-instructions \\ - --set pushTelemetry.enabled=true \\ - --set pushTelemetry.topologyName=incident-commander.demo.aws.flanksource.com-test-new-agent-instructions \\ - --create-namespace - - -helm install mission-control-kubernetes flanksource/mission-control-kubernetes -n "mission-control-agent" \\ - --set clusterName=test-new-agent-instructions2 \\ - --set scraper.schedule=@every 31m +helm install mission-control-agent flanksource/mission-control-agent -n "mission-control-agent" \\ --set upstream.createSecret=true \\ --set upstream.host=http://localhost:3000 \\ --set upstream.username=token \\ --set upstream.password=password \\ --set upstream.agentName=test-new-agent-instructions \\ --set pushTelemetry.enabled=true \\ --set pushTelemetry.topologyName=incident-commander.demo.aws.flanksource.com-test-new-agent-instructions \\ --create-namespace +helm install mission-control-kubernetes flanksource/mission-control-kubernetes -n "mission-control-agent" \\ --set clusterName=test-new-agent-instructions2 \\ --set scraper.schedule=@every 31m " `;