diff --git a/aws/lambda/README.md b/aws/lambda/README.md index 48d0fc376f..5334bb19df 100644 --- a/aws/lambda/README.md +++ b/aws/lambda/README.md @@ -105,7 +105,7 @@ go to [pytorch-gha-infra](https://github.com/pytorch-labs/pytorch-gha-infra) - Update the release-tag and add your zip file name in [runners/common/Terrafile](https://github.com/pytorch-labs/pytorch-gha-infra/blob/main/runners/common/Terrafile) - During the deploy process, the workflow will download your file based on the Terrafile. - If you need clichouse account permission, you need ask pytorch dev infra teammate to create a clichouse role for your lambda. - - you need to add the clickhouse role secret to the repo secret, `bunnylol oss pytorch-labs/pytorch-gha-infra` and update it in settings-> secrets. + - you need to add the clickhouse role secret to the repo secret, `bunnylol oss pytorch-labs/pytorch-gha-infra` and update it in settings-> secrets. ### Deploy the lambda Once the pr is submitted, go to [Runners Do Terraform Release (apply)](https://github.com/pytorch-labs/pytorch-gha-infra/actions/workflows/runners-on-dispatch-release.yml), and click Run workflow. diff --git a/torchci/.env.example b/torchci/.env.example index 4edcc995b2..ae2a7c102e 100644 --- a/torchci/.env.example +++ b/torchci/.env.example @@ -28,3 +28,7 @@ OPENSEARCH_PASSWORD= CLICKHOUSE_HUD_USER_URL= CLICKHOUSE_HUD_USER_USERNAME= CLICKHOUSE_HUD_USER_PASSWORD= + +# Lambda function URL and auth token for Grafana MCP (TorchDash) +GRAFANA_MCP_LAMBDA_URL= +GRAFANA_MCP_AUTH_TOKEN= diff --git a/torchci/components/AISpinner.tsx b/torchci/components/AISpinner.tsx new file mode 100644 index 0000000000..51b4a0fbc2 --- /dev/null +++ b/torchci/components/AISpinner.tsx @@ -0,0 +1,201 @@ +import { keyframes } from "@emotion/react"; +import styled from "@emotion/styled"; +import { useTheme } from "@mui/material"; +import React from "react"; + +// Define animations for the cubes and sparkles +const pulse = keyframes` + 0% { transform: scale(0.8); opacity: 0.3; } + 50% { transform: scale(1); opacity: 1; } + 100% { transform: scale(0.8); opacity: 0.3; } +`; + +const float = keyframes` + 0% { transform: translateY(0) rotate(0); opacity: 0.2; } + 50% { transform: translateY(-10px) rotate(45deg); opacity: 1; } + 100% { transform: translateY(-20px) rotate(90deg); opacity: 0; } +`; + +const sparkle = keyframes` + 0% { transform: scale(0); opacity: 0; } + 50% { transform: scale(1); opacity: 1; } + 100% { transform: scale(0); opacity: 0; } +`; + +const Container = styled.div` + position: relative; + width: 70px; + height: 70px; + display: flex; + justify-content: center; + align-items: center; + margin-right: 6px; // Ensures the sparkles don't overlap with text +`; + +const CubeContainer = styled.div` + position: relative; + width: 65px; + height: 65px; + transform-style: preserve-3d; + transform: rotateX(45deg) rotateZ(45deg); +`; + +const Cube = styled.div<{ delay: number; color: string; size: string }>` + position: absolute; + width: ${(props) => props.size}; + height: ${(props) => props.size}; + background: ${(props) => props.color}; + opacity: 0.8; + border-radius: 2px; + animation: ${pulse} 1.8s ease-in-out infinite; + animation-delay: ${(props) => props.delay}s; + box-shadow: 0 0 10px rgba(255, 255, 255, 0.3); +`; + +const Sparkle = styled.div<{ + delay: number; + color: string; + size: string; + top: string; + left: string; +}>` + position: absolute; + width: ${(props) => props.size}; + height: ${(props) => props.size}; + top: ${(props) => props.top}; + left: ${(props) => props.left}; + background: ${(props) => props.color}; + border-radius: 50%; + opacity: 0.8; + animation: ${sparkle} 2s ease-in-out infinite; + animation-delay: ${(props) => props.delay}s; + box-shadow: 0 0 8px ${(props) => props.color}; +`; + +const FloatingParticle = styled.div<{ + delay: number; + color: string; + size: string; + top: string; + left: string; +}>` + position: absolute; + width: ${(props) => props.size}; + height: ${(props) => props.size}; + top: ${(props) => props.top}; + left: ${(props) => props.left}; + background: ${(props) => props.color}; + opacity: 0; + animation: ${float} 3s ease-in-out infinite; + animation-delay: ${(props) => props.delay}s; +`; + +const AISpinner: React.FC = () => { + const theme = useTheme(); + const primaryColor = theme.palette.primary.main; + const secondaryColor = theme.palette.secondary.main; + + // Position for the 4 cubes in a grid - more square now + const cubes = [ + { top: "0px", left: "0px", delay: 0, size: "28px" }, + { top: "0px", left: "32px", delay: 0.3, size: "28px" }, + { top: "32px", left: "0px", delay: 0.6, size: "28px" }, + { top: "32px", left: "32px", delay: 0.9, size: "28px" }, + ]; + + // Create sparkles + const sparkles = [ + { + top: "-10px", + left: "20px", + delay: 0.2, + size: "6px", + color: primaryColor, + }, + { + top: "20px", + left: "-10px", + delay: 0.7, + size: "5px", + color: secondaryColor, + }, + { top: "50px", left: "20px", delay: 0.4, size: "7px", color: primaryColor }, + { + top: "20px", + left: "65px", + delay: 0.9, + size: "5px", + color: secondaryColor, + }, + { top: "70px", left: "60px", delay: 1.2, size: "4px", color: primaryColor }, + { + top: "-15px", + left: "50px", + delay: 1.5, + size: "5px", + color: secondaryColor, + }, + ]; + + // Floating particles + const particles = [ + { top: "20px", left: "5px", delay: 0.2, size: "5px", color: primaryColor }, + { + top: "15px", + left: "60px", + delay: 0.8, + size: "4px", + color: secondaryColor, + }, + { top: "55px", left: "55px", delay: 1.3, size: "6px", color: primaryColor }, + { + top: "40px", + left: "-5px", + delay: 1.7, + size: "3px", + color: secondaryColor, + }, + ]; + + return ( + + + {cubes.map((cube, index) => ( + + ))} + + + {/* Add sparkles */} + {sparkles.map((spark, index) => ( + + ))} + + {/* Add floating particles */} + {particles.map((particle, index) => ( + + ))} + + ); +}; + +export default AISpinner; diff --git a/torchci/components/McpQueryPage.tsx b/torchci/components/McpQueryPage.tsx new file mode 100644 index 0000000000..a4c39fd525 --- /dev/null +++ b/torchci/components/McpQueryPage.tsx @@ -0,0 +1,897 @@ +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import { + Box, + Button, + TextField, + Tooltip, + Typography, + useTheme, +} from "@mui/material"; +import { useSession } from "next-auth/react"; +import { useEffect, useRef, useState } from "react"; +import AISpinner from "./AISpinner"; +import { GrafanaEmbed } from "./McpQueryPage/GrafanaEmbed"; +import { + useAnimatedCounter, + useAutoScroll, + useThinkingMessages, + useTokenCalculator, +} from "./McpQueryPage/hooks"; +import { + ChunkMetadata, + LoaderWrapper, + McpQueryPageContainer, + QuerySection, + ResponseText, + ResultsSection, + ScrollToBottomButton, +} from "./McpQueryPage/styles"; +import { TodoList } from "./McpQueryPage/TodoList"; +import { ToolUse } from "./McpQueryPage/ToolUse"; +import { MessageWrapper, ParsedContent } from "./McpQueryPage/types"; +import { + extractGrafanaLinks, + formatElapsedTime, + formatTokenCount, + renderTextWithLinks, +} from "./McpQueryPage/utils"; + +export const McpQueryPage = () => { + const session = useSession(); + const theme = useTheme(); + + const [query, setQuery] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [response, setResponse] = useState(""); + const [parsedResponses, setParsedResponses] = useState([]); + const [expandedTools, setExpandedTools] = useState>( + {} + ); + const [allToolsExpanded, setAllToolsExpanded] = useState(false); + const [typingSpeed] = useState(10); + const [thinkingMessageIndex, setThinkingMessageIndex] = useState(0); + const [startTime, setStartTime] = useState(null); + const [elapsedTime, setElapsedTime] = useState(0); // in seconds + const [totalTokens, setTotalTokens] = useState(0); // track total tokens for display + const [completedTokens, setCompletedTokens] = useState(0); // final token count after completion + const [completedTime, setCompletedTime] = useState(0); // final time after completion + const [error, setError] = useState(""); + const [debugVisible, setDebugVisible] = useState(false); + + const fetchControllerRef = useRef(null); + + const thinkingMessages = useThinkingMessages(); + const displayedTokens = useAnimatedCounter(totalTokens); + const calculateTotalTokens = useTokenCalculator(); + const { showScrollButton, scrollToBottomAndEnable, resetAutoScroll } = + useAutoScroll(isLoading, parsedResponses); + + const handleQueryChange = (event: React.ChangeEvent) => { + setQuery(event.target.value); + }; + + // Rotate through thinking messages every 6 seconds + useEffect(() => { + if (!isLoading) return; + + const interval = setInterval(() => { + setThinkingMessageIndex((prev) => (prev + 1) % thinkingMessages.length); + }, 6000); + + return () => clearInterval(interval); + }, [isLoading, thinkingMessages.length]); + + // Also update message when new data comes in + useEffect(() => { + if (isLoading && parsedResponses.length > 0) { + setThinkingMessageIndex((prev) => (prev + 1) % thinkingMessages.length); + } + }, [parsedResponses.length, isLoading, thinkingMessages.length]); + + // Timer effect to update elapsed time + useEffect(() => { + if (!isLoading || !startTime) return; + + const timer = setInterval(() => { + const now = Date.now(); + const elapsed = Math.floor((now - startTime) / 1000); + setElapsedTime(elapsed); + }, 1000); + + return () => clearInterval(timer); + }, [isLoading, startTime]); + + // Handle typewriter effect for text content + useEffect(() => { + const animatingItems = parsedResponses.filter( + (item) => item.type === "text" && item.isAnimating + ); + + if (animatingItems.length === 0) return; + + const itemIndex = parsedResponses.findIndex((item) => item.isAnimating); + if (itemIndex === -1) return; + + const item = parsedResponses[itemIndex]; + const fullText = item.content; + const currentText = item.displayedContent || ""; + + if (currentText.length >= fullText.length) { + setParsedResponses((prev) => { + const updated = [...prev]; + updated[itemIndex].isAnimating = false; + updated[itemIndex].displayedContent = fullText; + return updated; + }); + return; + } + + const timer = setTimeout(() => { + setParsedResponses((prev) => { + const updated = [...prev]; + updated[itemIndex].displayedContent = fullText.substring( + 0, + (updated[itemIndex].displayedContent || "").length + 1 + ); + return updated; + }); + }, typingSpeed); + + return () => clearTimeout(timer); + }, [parsedResponses, typingSpeed]); + + // Calculate total tokens whenever parsedResponses changes + useEffect(() => { + if (parsedResponses.length > 0) { + const total = calculateTotalTokens(parsedResponses); + if (isFinite(total) && total >= 0 && total !== totalTokens) { + setTotalTokens(total); + } + } + }, [parsedResponses, calculateTotalTokens, totalTokens]); + + // Clean up on component unmount + useEffect(() => { + return () => { + if (fetchControllerRef.current) { + fetchControllerRef.current.abort(); + fetchControllerRef.current = null; + } + }; + }, []); + + // Parse response JSON and extract content + const parseJsonLine = (line: string) => { + try { + if (!line.trim()) return; + + setResponse((prev) => prev + line + "\n"); + const json = JSON.parse(line) as MessageWrapper; + + // Process timing data from result messages + if ( + json.type === "result" && + json.subtype === "success" && + json.duration_ms + ) { + const durationSec = Math.round(json.duration_ms / 1000); + setElapsedTime(durationSec); + } + + // Handle different response types + if (json.type === "assistant" && json.message?.content) { + json.message.content.forEach((item) => { + if (item.type === "text" && "text" in item) { + const textContent = item.text || ""; + const grafanaLinks = extractGrafanaLinks(textContent); + + setParsedResponses((prev) => { + const now = Date.now(); + const outputTokens = json.message?.usage?.output_tokens || 0; + + return [ + ...prev, + { + type: "text", + content: textContent, + displayedContent: "", + isAnimating: true, + grafanaLinks: + grafanaLinks.length > 0 ? grafanaLinks : undefined, + timestamp: now, + outputTokens: outputTokens, + }, + ]; + }); + } else if ( + item.type === "tool_use" && + "name" in item && + "input" in item + ) { + // Special handling for Todo tools + if (item.name === "TodoWrite" || item.name === "TodoRead") { + if (item.name === "TodoWrite" && "todos" in item.input) { + setParsedResponses((prev) => { + const now = Date.now(); + const todos = item.input.todos; + + const todoListIndex = prev.findIndex( + (response) => response.type === "todo_list" + ); + + const updated = [...prev]; + + if (todoListIndex !== -1) { + updated[todoListIndex] = { + ...updated[todoListIndex], + todoItems: todos, + timestamp: now, + }; + } else { + updated.push({ + type: "todo_list", + content: "Todo List", + todoItems: todos, + timestamp: now, + }); + } + + updated.push({ + type: "tool_use", + content: "", + toolName: item.name, + toolInput: item.input, + timestamp: now, + outputTokens: json.message?.usage?.output_tokens || 0, + toolUseId: "id" in item ? item.id : undefined, + }); + + return updated; + }); + } else { + setParsedResponses((prev) => { + const now = Date.now(); + return [ + ...prev, + { + type: "tool_use", + content: "", + toolName: item.name, + toolInput: item.input, + timestamp: now, + outputTokens: json.message?.usage?.output_tokens || 0, + toolUseId: "id" in item ? item.id : undefined, + }, + ]; + }); + } + } else { + setParsedResponses((prev) => { + const now = Date.now(); + const outputTokens = json.message?.usage?.output_tokens || 0; + + return [ + ...prev, + { + type: "tool_use", + content: "", + toolName: item.name, + toolInput: item.input, + timestamp: now, + outputTokens: outputTokens, + toolUseId: "id" in item ? item.id : undefined, + }, + ]; + }); + } + } + }); + } else if (json.type === "user" && json.message?.content) { + json.message.content.forEach((item) => { + if (item.type === "tool_result" && item.tool_use_id) { + setParsedResponses((prev) => { + const updated = [...prev]; + const toolUseIndex = updated.findIndex( + (response) => + response.type === "tool_use" && + response.toolUseId === item.tool_use_id + ); + + if (toolUseIndex !== -1) { + const toolName = updated[toolUseIndex].toolName; + + if (toolName === "TodoWrite" || toolName === "TodoRead") { + try { + const toolInput = updated[toolUseIndex].toolInput; + + if ( + toolName === "TodoWrite" && + toolInput && + "todos" in toolInput + ) { + const todos = toolInput.todos; + const todoListIndex = updated.findIndex( + (response) => response.type === "todo_list" + ); + + if (todoListIndex !== -1) { + updated[todoListIndex] = { + ...updated[todoListIndex], + todoItems: todos, + timestamp: Date.now(), + }; + } else { + updated.push({ + type: "todo_list", + content: "Todo List", + todoItems: todos, + timestamp: Date.now(), + }); + } + } else if (toolName === "TodoRead") { + try { + const resultContent = item.content?.[0]?.text || ""; + if (resultContent.includes('"todos":')) { + const todoData = JSON.parse(resultContent); + if ( + todoData && + todoData.todos && + Array.isArray(todoData.todos) + ) { + const todoListIndex = updated.findIndex( + (response) => response.type === "todo_list" + ); + + if (todoListIndex !== -1) { + updated[todoListIndex] = { + ...updated[todoListIndex], + todoItems: todoData.todos, + timestamp: Date.now(), + }; + } else { + updated.push({ + type: "todo_list", + content: "Todo List", + todoItems: todoData.todos, + timestamp: Date.now(), + }); + } + } + } + } catch (e) { + console.error("Failed to parse TodoRead result:", e); + } + } + + updated.splice(toolUseIndex, 1); + } catch (err) { + console.error("Failed to process todo data:", err); + updated[toolUseIndex] = { + ...updated[toolUseIndex], + toolResult: + item.content?.[0]?.text || "No result content", + }; + } + } else { + updated[toolUseIndex] = { + ...updated[toolUseIndex], + toolResult: item.content?.[0]?.text || "No result content", + }; + } + } + + return updated; + }); + } + }); + } else if (json.type === "content_block_delta") { + if (json.delta?.type === "text" && json.delta.text) { + setParsedResponses((prev) => { + const now = Date.now(); + + if (prev.length > 0 && prev[prev.length - 1].type === "text") { + const updated = [...prev]; + updated[updated.length - 1].content += json.delta?.text || ""; + updated[updated.length - 1].isAnimating = true; + + const fullContent = updated[updated.length - 1].content; + updated[updated.length - 1].grafanaLinks = + extractGrafanaLinks(fullContent); + + const tokenIncrement = 1; + const currentTokens = + updated[updated.length - 1].outputTokens || 0; + updated[updated.length - 1].outputTokens = + currentTokens + tokenIncrement; + updated[updated.length - 1].timestamp = now; + + return updated; + } else { + const textContent = json.delta?.text || ""; + + return [ + ...prev, + { + type: "text", + content: textContent, + displayedContent: "", + isAnimating: true, + grafanaLinks: extractGrafanaLinks(textContent), + timestamp: now, + outputTokens: json.usage?.output_tokens || 0, + }, + ]; + } + }); + } + } else if (json.error) { + setError(`Error: ${json.error}`); + } + } catch (err) { + console.log("Failed to parse:", line); + } + }; + + const cancelRequest = () => { + if (fetchControllerRef.current) { + fetchControllerRef.current.abort(); + fetchControllerRef.current = null; + setIsLoading(false); + } + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!query.trim()) { + setError("Query cannot be empty"); + return; + } + + cancelRequest(); + + setIsLoading(true); + setResponse(""); + setParsedResponses([]); + setError(""); + setAllToolsExpanded(false); + resetAutoScroll(); + + const now = Date.now(); + setStartTime(now); + setElapsedTime(0); + setTotalTokens(0); + setCompletedTokens(0); + setCompletedTime(0); + + fetchControllerRef.current = new AbortController(); + + try { + const response = await fetch("/api/grafana_mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Requested-With": "XMLHttpRequest", + }, + body: JSON.stringify({ query }), + signal: fetchControllerRef.current.signal, + cache: "no-store", + // @ts-ignore + duplex: "half", + }); + + if (!response.ok) { + const errorText = await response.text(); + + if (response.status === 401) { + throw new Error( + "Authentication required. Please sign in to continue." + ); + } else if (response.status === 403) { + throw new Error( + "Access denied. You need write permissions to pytorch/pytorch repository to use this tool." + ); + } else { + throw new Error(errorText || `HTTP error: ${response.status}`); + } + } + + if (!response.body) { + throw new Error("Response body is null"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + if (fetchControllerRef.current === null) break; + + const { done, value } = await reader.read(); + + if (done) { + if (buffer.trim()) { + parseJsonLine(buffer.trim()); + } + + setTimeout(() => { + const finalTokens = calculateTotalTokens(parsedResponses); + setCompletedTime(elapsedTime); + setCompletedTokens(finalTokens); + setTotalTokens(finalTokens); + setIsLoading(false); + }, 500); + + break; + } + + const text = decoder.decode(value, { stream: true }); + buffer += text; + + const lines = buffer.split("\n"); + + for (let i = 0; i < lines.length - 1; i++) { + if (lines[i].trim()) { + parseJsonLine(lines[i].trim()); + } + } + + buffer = lines[lines.length - 1]; + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + setError("Request cancelled"); + } else { + console.error("Fetch error:", err); + setError(`Error: ${err instanceof Error ? err.message : String(err)}`); + } + setIsLoading(false); + } + }; + + // Authentication check + if (session.status === "loading") { + return ( + + + + + Checking authentication... + + + + ); + } + + if ( + session.status === "unauthenticated" || + !session.data?.user || + !(session.data as any)?.accessToken + ) { + return ( + + + + Authentication Required + + + You must be logged in with write permissions to pytorch/pytorch to + access this tool. + + + Please sign in to continue. + + + + ); + } + + const renderContent = () => { + if (parsedResponses.length === 0) { + if (isLoading) { + return ( + + + + + {thinkingMessages[thinkingMessageIndex]} + + + Running for {formatElapsedTime(elapsedTime)} •{" "} + {formatTokenCount(displayedTokens)} tokens + + + + ); + } + return ( + + Run a query to see results here. + + ); + } + + return ( +
+ {parsedResponses + .filter((item) => item.type !== "todo_list") + .map((item, index) => ( +
+ {item.type === "text" ? ( + <> + + {renderTextWithLinks( + (item.displayedContent !== undefined + ? item.displayedContent + : item.content + )?.trim() || "", + item.isAnimating + )} + + + {!item.isAnimating && ( + + {item.timestamp && + index > 0 && + parsedResponses[index - 1].timestamp + ? `Generated in ${( + (item.timestamp - + (parsedResponses[index - 1].timestamp || 0)) / + 1000 + ).toFixed(2)}s` + : item.timestamp && startTime + ? `Generated in ${( + (item.timestamp - (startTime || 0)) / + 1000 + ).toFixed(2)}s` + : ""} + {item.outputTokens + ? ` • ${formatTokenCount(item.outputTokens)} tokens` + : ""} + + )} + + {item.grafanaLinks && item.grafanaLinks.length > 0 && ( + + {item.grafanaLinks.map((link, i) => ( + + ))} + + )} + + ) : item.type === "tool_use" && item.toolName ? ( + + setExpandedTools((prev) => ({ + ...prev, + [index]: !prev[index], + })) + } + /> + ) : null} +
+ ))} + + {parsedResponses + .filter((item) => item.type === "todo_list") + .map((item, index) => ( +
+ {item.todoItems && ( + + )} +
+ ))} + + {isLoading ? ( + + + + + {thinkingMessages[thinkingMessageIndex]} + + + Running for {formatElapsedTime(elapsedTime)} •{" "} + {formatTokenCount(displayedTokens)} tokens + + + + ) : ( + completedTokens > 0 && ( + + + Completed in {formatElapsedTime(completedTime)} • Total:{" "} + {formatTokenCount(completedTokens)} tokens + + + ) + )} +
+ ); + }; + + return ( + + {showScrollButton && ( + + + + + + )} + + + PyTorch Grafana Agent + + + What timeseries should we create for you? + + + + + { + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + e.preventDefault(); + if (!isLoading && query.trim()) { + handleSubmit(e); + } + } + }} + /> + + + + {isLoading && ( + + )} + + + + + + + + + Results + {parsedResponses.length > 0 && + parsedResponses.some((item) => item.type === "tool_use") && ( + + )} + + + {error && ( + + {error} + + )} + + {renderContent()} + + {debugVisible && ( + + Debug: Raw Response +
+              {response || "(No data yet)"}
+            
+
+ )} +
+
+ ); +}; diff --git a/torchci/components/McpQueryPage/GrafanaEmbed.tsx b/torchci/components/McpQueryPage/GrafanaEmbed.tsx new file mode 100644 index 0000000000..d94dda2020 --- /dev/null +++ b/torchci/components/McpQueryPage/GrafanaEmbed.tsx @@ -0,0 +1,46 @@ +import { Box, Button, Typography } from "@mui/material"; +import React from "react"; +import { useDarkMode } from "../../lib/DarkModeContext"; +import { ChartHeader, GrafanaChartContainer } from "./styles"; + +interface GrafanaEmbedProps { + dashboardId: string; +} + +export const GrafanaEmbed: React.FC = ({ dashboardId }) => { + const { themeMode, darkMode } = useDarkMode(); + + let chartTheme = "light"; + if (themeMode === "system") { + chartTheme = darkMode ? "dark" : "light"; + } else { + chartTheme = themeMode; + } + + const dashboardUrl = `https://disz2yd9jqnwc.cloudfront.net/public-dashboards/${dashboardId}?theme=${chartTheme}`; + + return ( + + + Grafana Dashboard + + + +