diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 87ca4477..baed796f 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-tooltip": "^1.2.7", "@reduxjs/toolkit": "^2.8.2", "@tailwindcss/vite": "^4.1.11", + "@types/three": "^0.184.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "fastest-levenshtein": "^1.0.16", @@ -38,7 +39,8 @@ "rxjs": "^7.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", - "tailwindcss": "^4.1.11" + "tailwindcss": "^4.1.11", + "three": "^0.184.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", diff --git a/apps/frontend/src/components/ai-copilot/AICopilot.tsx b/apps/frontend/src/components/ai-copilot/AICopilot.tsx new file mode 100644 index 00000000..2997a900 --- /dev/null +++ b/apps/frontend/src/components/ai-copilot/AICopilot.tsx @@ -0,0 +1,597 @@ +import { useEffect, useState } from "react" +import { useSelector } from "react-redux" +import { useParams } from "react-router" +import { + BrainCircuit, Activity, Heart, AlertTriangle, CheckCircle, + Clock, Search, Save, Loader2, Terminal, Shield, TrendingUp, + Zap, MessageSquare, Lightbulb +} from "lucide-react" +import { formatBytes } from "@common/src/bytes-conversion" +import { calculateHitRatio } from "@common/src/cache-hit-ratio" +import { AppHeader } from "../ui/app-header" +import RouteContainer from "../ui/route-container" +import { ParticleWave } from "../ui/particle-wave" +import { selectData } from "@/state/valkey-features/info/infoSelectors" +import { useAppDispatch } from "@/hooks/hooks" +import { updateData } from "@/state/valkey-features/info/infoSlice" +import { selectConnectionDetails } from "@/state/valkey-features/connection/connectionSelectors" +import { analyzeDatabase, type AnalysisResult } from "@/services/analysis-engine" +import { interpretQuery, getSuggestions, type CommandResult } from "@/services/command-engine" +import { + saveAnalysis, retrieveAnalyses, searchSimilarAnalyses, + saveCommandInteraction, type BreethEdge, type AnalysisRecord, +} from "@/services/breeth" + +export function AICopilot() { + const dispatch = useAppDispatch() + const { id, clusterId } = useParams() + const connectionDetails = useSelector(selectConnectionDetails(id!)) + const infoData = (useSelector(selectData(id!)) || {}) as unknown as Record + + // Analysis state + const [analysis, setAnalysis] = useState(null) + const [isAnalyzing, setIsAnalyzing] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [saved, setSaved] = useState(false) + + // Command state + const [commandInput, setCommandInput] = useState("") + const [commandResult, setCommandResult] = useState(null) + const [isInterpreting, setIsInterpreting] = useState(false) + const [commandError, setCommandError] = useState(null) + const [showDebug, setShowDebug] = useState(false) + + // Breeth memory state + const [breethMemories, setBreethMemories] = useState([]) + const [similarIncidents, setSimilarIncidents] = useState([]) + const [isLoadingMemories, setIsLoadingMemories] = useState(false) + const [isSearchingSimilar, setIsSearchingSimilar] = useState(false) + const [breethError, setBreethError] = useState(null) + + // Fetch metrics + useEffect(() => { + dispatch(updateData({ + connectionId: id!, + clusterId: clusterId ?? "", + address: { host: connectionDetails.host, port: connectionDetails.port }, + })) + }, [id, clusterId, dispatch, connectionDetails.host, connectionDetails.port]) + + // Load Breeth memory on mount + useEffect(() => { + loadBreethHistory() + }, []) + + const loadBreethHistory = async () => { + setIsLoadingMemories(true) + setBreethError(null) + try { + const edges = await retrieveAnalyses() + setBreethMemories(edges) + } catch (err) { + console.error("Breeth load failed:", err) + setBreethError(err instanceof Error ? err.message : "Failed to load history") + } finally { + setIsLoadingMemories(false) + } + } + + // ── Actions ───────────────────────────────────────────────────────────────── + + const handleAnalyze = async () => { + setIsAnalyzing(true) + setSaved(false) + setSimilarIncidents([]) + + // Run analysis + await new Promise((r) => setTimeout(r, 600)) + const result = analyzeDatabase(infoData) + setAnalysis(result) + setIsAnalyzing(false) + + // Auto-search similar incidents + if (result.issues.length > 0) { + setIsSearchingSimilar(true) + try { + const edges = await searchSimilarAnalyses(result.issues.join(" ")) + setSimilarIncidents(edges) + } catch (err) { + console.error("Similar search failed:", err) + } finally { + setIsSearchingSimilar(false) + } + } + } + + const handleSave = async () => { + if (!analysis) return + setIsSaving(true) + try { + const hits = Number(infoData.keyspace_hits) || 0 + const misses = Number(infoData.keyspace_misses) || 0 + const record: AnalysisRecord = { + timestamp: new Date().toISOString(), + healthScore: analysis.healthScore, + rootCause: analysis.rootCause, + riskAssessment: analysis.riskAssessment, + issues: analysis.issues, + recommendations: analysis.recommendations, + optimizations: analysis.optimizations, + metricsSnapshot: { + used_memory: infoData.used_memory, + connected_clients: infoData.connected_clients, + hitRatio: calculateHitRatio(hits, misses), + keyspace_hits: hits, + keyspace_misses: misses, + total_commands_processed: infoData.total_commands_processed, + }, + } + await saveAnalysis(record) + setSaved(true) + setBreethError(null) + await loadBreethHistory() + } catch (err) { + console.error("Save failed:", err) + setBreethError(err instanceof Error ? err.message : "Failed to save analysis") + } finally { + setIsSaving(false) + } + } + + const handleCommand = async (input?: string) => { + const query = input || commandInput + if (!query.trim()) return + + setIsInterpreting(true) + setCommandError(null) + setCommandResult(null) + try { + const result = await interpretQuery(query) + setCommandResult(result) + + // Persist the interaction to Breeth memory (best-effort). + if (result.isSafe && result.generatedCommand) { + try { + await saveCommandInteraction(query, result.generatedCommand, result.explanation) + } catch (err) { + console.error("Command save failed:", err) + } + } + } catch (err) { + console.error("Interpret failed:", err) + setCommandError(err instanceof Error ? err.message : "Failed to interpret query") + } finally { + setIsInterpreting(false) + } + } + + // ── Computed Metrics ──────────────────────────────────────────────────────── + + const usedMemory = Number(infoData.used_memory) || 0 + const totalCommands = Number(infoData.total_commands_processed) || 0 + const hits = Number(infoData.keyspace_hits) || 0 + const misses = Number(infoData.keyspace_misses) || 0 + const hitRatio = calculateHitRatio(hits, misses) + const connectedClients = Number(infoData.connected_clients) || 0 + const uptimeSeconds = Number(infoData.uptime_in_seconds) || 0 + const uptimeFormatted = uptimeSeconds > 86400 + ? `${Math.floor(uptimeSeconds / 86400)}d ${Math.floor((uptimeSeconds % 86400) / 3600)}h` + : uptimeSeconds > 3600 + ? `${Math.floor(uptimeSeconds / 3600)}h ${Math.floor((uptimeSeconds % 3600) / 60)}m` + : `${Math.floor(uptimeSeconds / 60)}m` + + return ( + + + } title="AI Copilot" /> + +
+ {/* ── Metrics Bar ──────────────────────────────────────────────────── */} +
+

Current Database Health

+
+ + + + + +
+
+ + {/* ── Action Bar ───────────────────────────────────────────────────── */} +
+ + {analysis && ( + + )} +
+ + {/* ── Analysis Results ──────────────────────────────────────────────── */} + {analysis && ( + <> + {/* Health Score + Breakdown (full width) */} +
+
+
+ +
+

Health Score

+

+ {analysis.healthScore}/100 +

+
+ + Confidence {analysis.confidence}% + + + {new Date(analysis.timestamp).toLocaleTimeString()} +
+
+
+ + {/* Breakdown bars */} +
+

Score Breakdown

+ {analysis.breakdown.map((cat) => ( +
+ {cat.label} +
+
+
+ + +{cat.earned}/{cat.max} + +
+ ))} +
+ {analysis.breakdown.map((cat) => ( +

+ {cat.label}: {cat.reason} +

+ ))} +
+
+
+
+ +
+ {/* Root Cause */} +
+
+ +

Root Cause

+
+

{analysis.rootCause}

+
+ + {/* Risk Assessment */} +
+
+ +

Risk Assessment

+
+

{analysis.riskAssessment}

+
+ + {/* Optimization Opportunities */} +
+
+ +

Optimizations

+
+
    + {analysis.optimizations.map((opt, i) => ( +
  • • {opt}
  • + ))} +
+
+ + {/* Issues */} + {analysis.issues.length > 0 && ( +
+
+ +

Issues ({analysis.issues.length})

+
+
    + {analysis.issues.map((issue, i) => ( +
  • • {issue}
  • + ))} +
+
+ )} + + {/* Recommendations */} +
+
+ +

Recommendations

+
+
    + {analysis.recommendations.map((rec, i) => ( +
  • • {rec}
  • + ))} +
+
+
+ + )} + + {/* ── Empty State ──────────────────────────────────────────────────── */} + {!analysis && !isAnalyzing && ( +
+ +

No analysis yet

+

Click "Analyze Database" to get health insights.

+
+ )} + + {/* ── Why This Recommendation (Demo Mode) ──────────────────────────── */} + {analysis && similarIncidents.length > 0 && ( +
+

+ + Why This Recommendation? +

+
+
+
+

Current Metrics

+

+ Memory: {formatBytes(usedMemory)} | Hit Ratio: {hitRatio} | Clients: {connectedClients} +

+
+
+

Previous Similar Investigation (Breeth Memory)

+

{similarIncidents[0].fact}

+
+
+

Reasoning

+

+ Based on the current {analysis.rootCause.toLowerCase()} and a similar past incident, + the system recommends: {analysis.recommendations[0]} +

+
+
+
+
+ )} + + {isSearchingSimilar && ( +
+ + Searching Breeth memory for similar incidents... +
+ )} + + {/* ── Natural Language Commands ─────────────────────────────────────── */} +
+
+

+ + Ask Valkey +

+ +
+
+
+ setCommandInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleCommand()} + placeholder="e.g., Which keys use the most memory? Find keys without TTL..." + disabled={isInterpreting} + className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-tw-dark-border + rounded-lg bg-white dark:bg-tw-dark-primary dark:text-white + focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50" + /> + +
+ + {/* Suggestions */} +
+ {getSuggestions().slice(0, 6).map((suggestion) => ( + + ))} +
+ + {/* Error state */} + {commandError && ( +
+
+ +

{commandError}

+
+
+ )} + + {/* Command Result */} + {commandResult && !commandError && ( +
+ {/* Interpreted intent header */} +
+ Detected Intent + + {commandResult.intent} + + + confidence {(commandResult.confidence * 100).toFixed(0)}% + + + {commandResult.source} + +
+ + {commandResult.intent === "UNKNOWN" || !commandResult.isSafe ? ( +
+ +

{commandResult.explanation}

+
+ ) : ( + <> +
+

+ Generated Command +

+
+                        {commandResult.generatedCommand}
+                      
+
+
+

+ Explanation +

+

+ {commandResult.explanation} +

+
+ + )} + + {/* Debug Mode panel */} + {showDebug && ( +
+

Debug Mode

+
+
User Query: {commandResult.query}
+
Detected Intent: {commandResult.intent}
+
Confidence: {commandResult.confidence}
+
Source: {commandResult.source}
+
Generated Command: {commandResult.generatedCommand || "(none)"}
+ {commandResult.parseError && ( +
+ Parse Note: {commandResult.parseError} +
+ )} +
+ {commandResult.rawResponse && ( +
+

Raw LLM Response:

+
+                          {commandResult.rawResponse}
+                        
+
+ )} +
+ )} +
+ )} +
+
+ + {/* ── Breeth Memory Panel ──────────────────────────────────────────── */} + {breethError && ( +
+ + Breeth Memory: {breethError} +
+ )} + {(breethMemories.length > 0 || isLoadingMemories) && ( +
+

+ + Past Investigations (Breeth Memory) +

+ {isLoadingMemories ? ( +
+ + Loading from Breeth... +
+ ) : ( +
+ {breethMemories.map((edge) => ( +
+ +

{edge.fact}

+
+ ))} +
+ )} +
+ )} + + {/* ── Similar Incidents ─────────────────────────────────────────────── */} + {similarIncidents.length > 1 && ( +
+

+ + Similar Incidents +

+
+ {similarIncidents.slice(1).map((edge) => ( +
+

{edge.fact}

+
+ ))} +
+
+ )} +
+
+ ) +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function MetricCard({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ) +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function getScoreColor(score: number) { + if (score >= 80) return "text-green-500" + if (score >= 60) return "text-yellow-500" + return "text-red-500" +} + +function getScoreBg(score: number) { + if (score >= 80) return "bg-green-500/10 border-green-500/30" + if (score >= 60) return "bg-yellow-500/10 border-yellow-500/30" + return "bg-red-500/10 border-red-500/30" +} + +function barColor(status: "good" | "warn" | "critical" | "unknown") { + switch (status) { + case "good": return "bg-green-500" + case "warn": return "bg-yellow-500" + case "critical": return "bg-red-500" + default: return "bg-gray-400" + } +} diff --git a/apps/frontend/src/components/ai-copilot/README.md b/apps/frontend/src/components/ai-copilot/README.md new file mode 100644 index 00000000..9e69f880 --- /dev/null +++ b/apps/frontend/src/components/ai-copilot/README.md @@ -0,0 +1,89 @@ +# AI Copilot + +An AI-assisted operations panel for Valkey Admin. It analyzes live database +metrics, converts natural language into safe Valkey commands, and remembers +past investigations using [Breeth](https://www.thebreeth.com) as a persistent +memory layer. + +## Features + +1. **AI Performance Analyzer** — Health Score, Root Cause, Risk Assessment, + Recommendations, and Optimization Opportunities derived from live metrics. +2. **Natural Language Commands ("Ask Valkey")** — type plain-English requests; + the command engine generates safe, read-only Valkey commands. Destructive + operations (DEL, FLUSHALL, CONFIG SET, etc.) are blocked. +3. **Breeth Memory** — analyses are saved to Breeth and recalled across + sessions. Includes "Past Investigations" and "Similar Incidents". + +## Architecture + +``` +Browser (React) + └── services/breeth.ts → calls local backend only + │ + ▼ +Valkey Admin Backend (Express) + └── /api/ai-copilot/save-analysis (POST) + └── /api/ai-copilot/history (GET) + └── /api/ai-copilot/search-similar (POST) + │ (adds Authorization: Bearer ) + ▼ +Breeth API (https://api.thebreeth.com/v1/*) +``` + +The Breeth API key is **never** exposed to the browser. It lives only in the +server process via the `BREETH_API_KEY` environment variable. + +## Setup + +The AI Copilot memory features require a Breeth API key. + +1. Mint a key in the Breeth dashboard: **API Keys → New key**. +2. Provide it to the server via the `BREETH_API_KEY` environment variable. + +### Local (node) + +```bash +# from repo root +export BREETH_API_KEY=ck_live_xxx # Windows (PowerShell): $env:BREETH_API_KEY="ck_live_xxx" +node apps/server/dist/index.js +``` + +### Docker Compose + +```bash +# Pass the key from your shell environment (compose reads ${BREETH_API_KEY}) +export BREETH_API_KEY=ck_live_xxx +docker compose -f docker/docker-compose.yml up --build -d +``` + +Or create a `.env` file next to `docker-compose.yml`: + +``` +BREETH_API_KEY=ck_live_xxx +``` + +See `apps/server/.env.example` for reference. + +## Behavior without a key + +If `BREETH_API_KEY` is not set, the three `/api/ai-copilot/*` endpoints return +**HTTP 500** with a clear message: + +```json +{ "ok": false, "error": "Breeth integration is not configured. Set the BREETH_API_KEY environment variable on the server." } +``` + +The Performance Analyzer and Natural Language Commands still work locally; only +the memory persistence/retrieval features require the key. + +## Files + +| File | Purpose | +|------|---------| +| `apps/frontend/src/components/ai-copilot/AICopilot.tsx` | Main page component | +| `apps/frontend/src/services/analysis-engine.ts` | Health analysis logic | +| `apps/frontend/src/services/command-engine.ts` | NLP → command + safety layer | +| `apps/frontend/src/services/breeth.ts` | Frontend client for backend endpoints | +| `apps/server/src/index.ts` | Backend `/api/ai-copilot/*` endpoints | +| `apps/server/.env.example` | Documents the required `BREETH_API_KEY` | diff --git a/apps/frontend/src/components/ui/app-sidebar.tsx b/apps/frontend/src/components/ui/app-sidebar.tsx index c66b5fe6..341b58f0 100644 --- a/apps/frontend/src/components/ui/app-sidebar.tsx +++ b/apps/frontend/src/components/ui/app-sidebar.tsx @@ -9,7 +9,8 @@ import { Github, KeyRound, Network, - Activity + Activity, + BrainCircuit } from "lucide-react" import { Link, useLocation, useParams } from "react-router" import { useState } from "react" @@ -73,6 +74,11 @@ export function AppSidebar() { title: "Activity", icon: Activity, }, + { + to: (clusterId ? `/${clusterId}/${id}/ai-copilot` : `/${id}/ai-copilot`), + title: "AI Copilot", + icon: BrainCircuit, + }, { to: (clusterId ? `/${clusterId}/${id}/sendcommand` : `/${id}/sendcommand`), title: "Send Command", icon: SquareTerminal, diff --git a/apps/frontend/src/components/ui/bg-pattern.tsx b/apps/frontend/src/components/ui/bg-pattern.tsx new file mode 100644 index 00000000..970d5210 --- /dev/null +++ b/apps/frontend/src/components/ui/bg-pattern.tsx @@ -0,0 +1,82 @@ +import React from "react" +import { cn } from "@/lib/utils" + +type BGVariantType = "dots" | "diagonal-stripes" | "grid" | "horizontal-lines" | "vertical-lines" | "checkerboard" + +type BGMaskType = + | "fade-center" + | "fade-edges" + | "fade-top" + | "fade-bottom" + | "fade-left" + | "fade-right" + | "fade-x" + | "fade-y" + | "none" + +type BGPatternProps = React.ComponentProps<"div"> & { + variant?: BGVariantType + mask?: BGMaskType + size?: number + fill?: string +} + +const maskClasses: Record = { + "fade-edges": "[mask-image:radial-gradient(ellipse_at_center,var(--background),transparent)]", + "fade-center": "[mask-image:radial-gradient(ellipse_at_center,transparent,var(--background))]", + "fade-top": "[mask-image:linear-gradient(to_bottom,transparent,var(--background))]", + "fade-bottom": "[mask-image:linear-gradient(to_bottom,var(--background),transparent)]", + "fade-left": "[mask-image:linear-gradient(to_right,transparent,var(--background))]", + "fade-right": "[mask-image:linear-gradient(to_right,var(--background),transparent)]", + "fade-x": "[mask-image:linear-gradient(to_right,transparent,var(--background),transparent)]", + "fade-y": "[mask-image:linear-gradient(to_bottom,transparent,var(--background),transparent)]", + none: "", +} + +function getBgImage(variant: BGVariantType, fill: string, size: number) { + switch (variant) { + case "dots": + return `radial-gradient(${fill} 1px, transparent 1px)` + case "grid": + return `linear-gradient(to right, ${fill} 1px, transparent 1px), linear-gradient(to bottom, ${fill} 1px, transparent 1px)` + case "diagonal-stripes": + return `repeating-linear-gradient(45deg, ${fill}, ${fill} 1px, transparent 1px, transparent ${size}px)` + case "horizontal-lines": + return `linear-gradient(to bottom, ${fill} 1px, transparent 1px)` + case "vertical-lines": + return `linear-gradient(to right, ${fill} 1px, transparent 1px)` + case "checkerboard": + return `linear-gradient(45deg, ${fill} 25%, transparent 25%), linear-gradient(-45deg, ${fill} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${fill} 75%), linear-gradient(-45deg, transparent 75%, ${fill} 75%)` + default: + return undefined + } +} + +const BGPattern = ({ + variant = "grid", + mask = "none", + size = 24, + fill = "#252525", + className, + style, + ...props +}: BGPatternProps) => { + const bgSize = `${size}px ${size}px` + const backgroundImage = getBgImage(variant, fill, size) + + return ( +
+ ) +} + +BGPattern.displayName = "BGPattern" + +export { BGPattern } diff --git a/apps/frontend/src/components/ui/particle-wave.tsx b/apps/frontend/src/components/ui/particle-wave.tsx new file mode 100644 index 00000000..49f55de4 --- /dev/null +++ b/apps/frontend/src/components/ui/particle-wave.tsx @@ -0,0 +1,164 @@ +import React, { useRef, useEffect } from "react" +import * as THREE from "three" + +interface ParticleWaveProps { + className?: string +} + +/** + * Animated Three.js particle-wave background. + * + * Renders with a transparent clear color so it sits behind page content as a + * subtle backdrop. Particle color adapts to the current (light/dark) theme. + * Sized to its parent container rather than the full window. + */ +const ParticleWave: React.FC = ({ className = "" }) => { + const canvasRef = useRef(null) + const sceneRef = useRef<{ + scene: THREE.Scene + camera: THREE.PerspectiveCamera + renderer: THREE.WebGLRenderer + particles: THREE.Points + particleMaterial: THREE.ShaderMaterial + animationId: number | null + } | null>(null) + + const getCurrentTheme = () => + document.documentElement.classList.contains("dark") ? "dark" : "light" + + const getParticleColor = (theme: string) => + theme === "dark" + ? new THREE.Vector3(0.45, 0.5, 0.95) // soft indigo for dark theme + : new THREE.Vector3(0.3, 0.35, 0.7) // muted indigo for light theme + + const particleVertex = ` + attribute float scale; + uniform float uTime; + void main() { + vec3 p = position; + float s = scale; + p.y += (sin(p.x + uTime) * 0.5) + (cos(p.y + uTime) * 0.1) * 2.0; + p.x += (sin(p.y + uTime) * 0.5); + s += (sin(p.x + uTime) * 0.5) + (cos(p.y + uTime) * 0.1) * 2.0; + vec4 mvPosition = modelViewMatrix * vec4(p, 1.0); + gl_PointSize = s * 12.0 * (1.0 / -mvPosition.z); + gl_Position = projectionMatrix * mvPosition; + } + ` + + const particleFragment = ` + uniform vec3 uColor; + void main() { + gl_FragColor = vec4(uColor, 0.35); + } + ` + + const sizeToParent = (canvas: HTMLCanvasElement) => { + const parent = canvas.parentElement + const w = parent?.clientWidth || window.innerWidth + const h = parent?.clientHeight || window.innerHeight + return { w, h } + } + + const initScene = () => { + if (!canvasRef.current) return + const canvas = canvasRef.current + const { w, h } = sizeToParent(canvas) + const aspectRatio = w / h + + const camera = new THREE.PerspectiveCamera(75, aspectRatio, 0.01, 1000) + camera.position.set(0, 6, 5) + + const scene = new THREE.Scene() + + const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }) + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) + renderer.setSize(w, h, false) + renderer.setClearColor(0x000000, 0) // transparent — show page behind + + const gap = 0.3 + const amountX = 200 + const amountY = 200 + const particleNum = amountX * amountY + const particlePositions = new Float32Array(particleNum * 3) + const particleScales = new Float32Array(particleNum) + let i = 0 + let j = 0 + for (let ix = 0; ix < amountX; ix++) { + for (let iy = 0; iy < amountY; iy++) { + particlePositions[i] = ix * gap - (amountX * gap) / 2 + particlePositions[i + 1] = 0 + particlePositions[i + 2] = iy * gap - (amountX * gap) / 2 + particleScales[j] = 1 + i += 3 + j++ + } + } + const particleGeometry = new THREE.BufferGeometry() + particleGeometry.setAttribute("position", new THREE.BufferAttribute(particlePositions, 3)) + particleGeometry.setAttribute("scale", new THREE.BufferAttribute(particleScales, 1)) + + const particleMaterial = new THREE.ShaderMaterial({ + transparent: true, + vertexShader: particleVertex, + fragmentShader: particleFragment, + uniforms: { + uTime: { value: 0 }, + uColor: { value: getParticleColor(getCurrentTheme()) }, + }, + }) + + const particles = new THREE.Points(particleGeometry, particleMaterial) + scene.add(particles) + + sceneRef.current = { scene, camera, renderer, particles, particleMaterial, animationId: null } + } + + const animate = () => { + if (!sceneRef.current) return + const { scene, camera, renderer, particleMaterial } = sceneRef.current + particleMaterial.uniforms.uTime.value += 0.03 + particleMaterial.uniforms.uColor.value = getParticleColor(getCurrentTheme()) + camera.lookAt(scene.position) + renderer.render(scene, camera) + sceneRef.current.animationId = requestAnimationFrame(animate) + } + + const handleResize = () => { + if (!sceneRef.current || !canvasRef.current) return + const { camera, renderer } = sceneRef.current + const { w, h } = sizeToParent(canvasRef.current) + camera.aspect = w / h + camera.updateProjectionMatrix() + renderer.setSize(w, h, false) + } + + useEffect(() => { + initScene() + animate() + window.addEventListener("resize", handleResize) + + return () => { + if (sceneRef.current?.animationId) cancelAnimationFrame(sceneRef.current.animationId) + window.removeEventListener("resize", handleResize) + if (sceneRef.current) { + const { scene, renderer, particles } = sceneRef.current + scene.remove(particles) + particles.geometry?.dispose() + if (Array.isArray(particles.material)) particles.material.forEach((m) => m.dispose()) + else particles.material.dispose() + renderer.dispose() + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + + ) +} + +export { ParticleWave } diff --git a/apps/frontend/src/css/index.css b/apps/frontend/src/css/index.css index d664f627..1563bb69 100644 --- a/apps/frontend/src/css/index.css +++ b/apps/frontend/src/css/index.css @@ -198,4 +198,32 @@ animation-name: linearProgress; animation-timing-function: linear; animation-fill-mode: forwards; -} \ No newline at end of file +} + + +/* ── AI Copilot Utilities ──────────────────────────────────────────────────── */ + +.section-title { + @apply text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3; +} + +.card { + @apply border border-gray-200 dark:border-tw-dark-border rounded-lg p-4; +} + +.btn-primary { + @apply px-5 py-2.5 bg-primary text-white rounded-lg font-medium + hover:bg-primary/90 transition-colors disabled:opacity-50 + disabled:cursor-not-allowed flex items-center gap-2 text-sm; +} + +.btn-success { + @apply px-5 py-2.5 bg-emerald-600 text-white rounded-lg font-medium + hover:bg-emerald-700 transition-colors disabled:opacity-50 + disabled:cursor-not-allowed flex items-center gap-2 text-sm; +} + +.empty-state { + @apply border border-dashed border-gray-300 dark:border-tw-dark-border + rounded-lg p-8 text-center text-gray-500 dark:text-gray-400; +} diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index cae4c173..66f8ebbe 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -15,6 +15,7 @@ import { WebSocketReconnect } from "./components/WebSocketReconnect.tsx" import { ValkeyReconnect } from "./components/ValkeyReconnect.tsx" import { SendCommand } from "@/components/send-command/SendCommand.tsx" import { ActivityView } from "@/components/activity-view/ActivityView.tsx" +import { AICopilot } from "@/components/ai-copilot/AICopilot.tsx" import { Connection } from "@/components/connection/Connection.tsx" import "./css/index.css" @@ -48,6 +49,7 @@ const AppWithHistory = () => { } path="/:clusterId/:id/browse" /> } path="/:clusterId/:id/cluster-topology" /> } path="/:clusterId/:id/activity" /> + } path="/:clusterId/:id/ai-copilot" /> } path="/:clusterId/:id/settings" /> @@ -58,6 +60,7 @@ const AppWithHistory = () => { } path="/:id/sendcommand" /> } path="/:id/browse" /> } path="/:id/activity" /> + } path="/:id/ai-copilot" /> } path="/:id/settings" /> } path="/:id/learnmore" /> diff --git a/apps/frontend/src/services/analysis-engine.ts b/apps/frontend/src/services/analysis-engine.ts new file mode 100644 index 00000000..92a79b29 --- /dev/null +++ b/apps/frontend/src/services/analysis-engine.ts @@ -0,0 +1,355 @@ +/** + * Analysis Engine — data-driven health analyzer for Valkey metrics. + * + * The health score is computed as a weighted sum of independent metric + * categories. Each category earns a fraction of its max weight based on the + * real metric value, so the score and narrative move continuously as metrics + * change. Every category also emits the reasoning used to derive its points. + * + * Weights (sum to 100): + * Cache Efficiency 25 + * Memory 20 + * Client Health 22 + * Command Throughput 18 + * Data Lifecycle 15 + */ + +export interface ScoreCategory { + /** Display name, e.g. "Cache Efficiency" */ + label: string + /** Points earned (0..max), rounded */ + earned: number + /** Maximum points this category can contribute */ + max: number + /** Human-readable reason for the points earned */ + reason: string + /** Severity used for UI coloring */ + status: "good" | "warn" | "critical" | "unknown" +} + +export interface AnalysisResult { + healthScore: number + /** Per-category contribution to the score */ + breakdown: ScoreCategory[] + rootCause: string + riskAssessment: string + issues: string[] + recommendations: string[] + optimizations: string[] + /** 0..100 — how much of the analysis was backed by real data */ + confidence: number + /** ISO timestamp of when the analysis ran */ + timestamp: string +} + +interface MetricsData { + [key: string]: unknown +} + +const num = (v: unknown): number => { + const n = Number(v) + return Number.isFinite(n) ? n : 0 +} + +const has = (v: unknown): boolean => v !== null && v !== undefined && v !== "" + +/** + * Map a normalized 0..1 quality value to points out of `max`. + */ +const pts = (quality: number, max: number): number => + Math.round(Math.max(0, Math.min(1, quality)) * max) + +export function analyzeDatabase(data: MetricsData): AnalysisResult { + const issues: string[] = [] + const recommendations: string[] = [] + const optimizations: string[] = [] + const rootCauseCandidates: { severity: number; text: string }[] = [] + const riskCandidates: { severity: number; text: string }[] = [] + const breakdown: ScoreCategory[] = [] + + // Track data availability for the confidence indicator. + let availableSignals = 0 + const totalSignals = 7 + + // ── 1. Cache Efficiency (max 25) ──────────────────────────────────────────── + const hits = num(data.keyspace_hits) + const misses = num(data.keyspace_misses) + const totalReads = hits + misses + { + const max = 25 + if (totalReads > 0) { + availableSignals++ + const hitRatio = (hits / totalReads) * 100 + // quality scales linearly: 0% ratio → 0 pts, 90%+ ratio → full pts + const quality = hitRatio / 90 + const earned = pts(quality, max) + let status: ScoreCategory["status"] = "good" + let reason = `Hit ratio ${hitRatio.toFixed(1)}% across ${totalReads.toLocaleString()} reads` + + if (hitRatio < 20) { + status = "critical" + issues.push(`Critical: cache hit ratio is ${hitRatio.toFixed(1)}% (target ≥ 80%)`) + recommendations.push("Add TTLs and pre-warm hot keys — most reads are missing cache") + optimizations.push("Adopt a read-through caching pattern for hot paths") + rootCauseCandidates.push({ severity: 90 - hitRatio, text: `Cache hit ratio of ${hitRatio.toFixed(1)}% means the cache is rarely serving reads, forcing expensive backing-store lookups.` }) + riskCandidates.push({ severity: 90 - hitRatio, text: "Read latency climbs and backing store load grows as cache misses dominate." }) + } else if (hitRatio < 50) { + status = "warn" + issues.push(`Warning: cache hit ratio is ${hitRatio.toFixed(1)}% (target ≥ 80%)`) + recommendations.push("Increase key retention / TTLs to raise the hit ratio") + optimizations.push("Profile most-missed key prefixes and pre-load them") + rootCauseCandidates.push({ severity: 90 - hitRatio, text: `A ${hitRatio.toFixed(1)}% hit ratio indicates moderate cache inefficiency.` }) + riskCandidates.push({ severity: 60, text: "Performance degrades further under increased load." }) + } else if (hitRatio < 80) { + status = "warn" + optimizations.push(`Hit ratio ${hitRatio.toFixed(1)}% — pre-warming could push it past 80%`) + } else { + optimizations.push(`Strong hit ratio (${hitRatio.toFixed(1)}%) — cache is doing its job`) + } + breakdown.push({ label: "Cache Efficiency", earned, max, reason, status }) + } else { + // No read traffic — can't judge cache. Award neutral-favorable but flag unknown. + breakdown.push({ + label: "Cache Efficiency", + earned: Math.round(max * 0.6), + max, + reason: "No read traffic yet — cache efficiency unproven", + status: "unknown", + }) + optimizations.push("Generate read traffic to evaluate cache efficiency") + } + } + + // ── 2. Memory (max 20) ────────────────────────────────────────────────────── + const usedMemory = num(data.used_memory) + const maxMemory = num(data.maxmemory) + const totalSystemMemory = num(data.total_system_memory) + const memoryLimit = maxMemory > 0 ? maxMemory : totalSystemMemory + { + const max = 20 + if (usedMemory > 0 && memoryLimit > 0) { + availableSignals++ + const memPct = (usedMemory / memoryLimit) * 100 + // quality: ≤60% util → full pts, 100% util → 0 pts + const quality = (100 - memPct) / 40 + const earned = pts(quality, max) + let status: ScoreCategory["status"] = "good" + const reason = `Using ${(usedMemory / 1048576).toFixed(1)} MB of ${(memoryLimit / 1048576).toFixed(0)} MB (${memPct.toFixed(0)}%)` + + if (memPct > 90) { + status = "critical" + issues.push(`Critical: memory at ${memPct.toFixed(0)}% of limit`) + recommendations.push("Raise maxmemory or evict stale data immediately") + recommendations.push("Audit large keys with MEMORY USAGE ") + rootCauseCandidates.push({ severity: memPct, text: `Memory utilization is ${memPct.toFixed(0)}%, leaving little headroom before evictions or write rejection.` }) + riskCandidates.push({ severity: memPct, text: "Out-of-memory risk — the server may start rejecting writes." }) + } else if (memPct > 75) { + status = "warn" + issues.push(`Warning: memory at ${memPct.toFixed(0)}% of limit`) + recommendations.push("Set an eviction policy (e.g. allkeys-lru) as a safety net") + optimizations.push("Track memory growth trend to forecast capacity") + riskCandidates.push({ severity: memPct - 30, text: "Traffic spikes could push memory into the eviction zone." }) + } else { + optimizations.push(`Memory healthy at ${memPct.toFixed(0)}% — comfortable headroom`) + } + breakdown.push({ label: "Memory", earned, max, reason, status }) + } else { + breakdown.push({ + label: "Memory", + earned: Math.round(max * 0.6), + max, + reason: "Memory limit not reported — utilization unknown", + status: "unknown", + }) + } + } + + // ── 3. Client Health (max 22) ──────────────────────────────────────────────── + const connectedClients = num(data.connected_clients) + const rejectedConnections = num(data.rejected_connections) + const blockedClients = num(data.blocked_clients) + { + const max = 22 + if (has(data.connected_clients)) { + availableSignals++ + let quality = 1 + let status: ScoreCategory["status"] = "good" + const reasonParts = [`${connectedClients} connected`] + + // Connected clients pressure + if (connectedClients > 500) { + quality -= 0.5 + status = "critical" + issues.push(`Critical: ${connectedClients} connected clients`) + recommendations.push("Introduce connection pooling (50–100 conns per app instance)") + rootCauseCandidates.push({ severity: 70, text: `${connectedClients} concurrent client connections consume significant per-connection memory and file descriptors.` }) + riskCandidates.push({ severity: 70, text: "File-descriptor exhaustion can cause new connections to fail." }) + } else if (connectedClients > 100) { + quality -= 0.25 + status = "warn" + recommendations.push("Consider connection pooling to reduce client count") + } + + // Rejected connections + if (rejectedConnections > 0) { + availableSignals += 0 // counted within client health + quality -= 0.35 + status = status === "good" ? "warn" : status + issues.push(`Warning: ${rejectedConnections} rejected connections`) + recommendations.push("Raise the maxclients limit — connections are being refused") + rootCauseCandidates.push({ severity: 80, text: `${rejectedConnections} connections were rejected, indicating the maxclients ceiling was hit.` }) + riskCandidates.push({ severity: 80, text: "Application requests fail outright when connections are rejected." }) + reasonParts.push(`${rejectedConnections} rejected`) + } + + // Blocked clients + if (blockedClients > 10) { + quality -= 0.15 + status = status === "good" ? "warn" : status + issues.push(`Warning: ${blockedClients} blocked clients`) + optimizations.push("Review BLPOP/BRPOP usage — many clients are blocking") + reasonParts.push(`${blockedClients} blocked`) + } + + if (status === "good") { + optimizations.push(`Client load healthy (${connectedClients} connections)`) + } + + breakdown.push({ + label: "Client Health", + earned: pts(quality, max), + max, + reason: reasonParts.join(", "), + status, + }) + } else { + breakdown.push({ label: "Client Health", earned: Math.round(max * 0.6), max, reason: "Client metrics unavailable", status: "unknown" }) + } + } + + // ── 4. Command Throughput (max 18) ──────────────────────────────────────────── + const totalCommands = num(data.total_commands_processed) + const totalErrors = num(data.total_error_replies) + { + const max = 18 + if (totalCommands > 0) { + availableSignals++ + const errorRate = totalCommands > 0 ? (totalErrors / totalCommands) * 100 : 0 + // quality is driven by error rate: 0% errors → full, ≥5% errors → 0 + const quality = 1 - errorRate / 5 + const earned = pts(quality, max) + let status: ScoreCategory["status"] = "good" + const reason = `${totalCommands.toLocaleString()} commands processed, ${errorRate.toFixed(2)}% error replies` + + if (errorRate > 5) { + status = "critical" + issues.push(`Critical: ${errorRate.toFixed(1)}% of commands returned errors`) + recommendations.push("Inspect application command usage — high error-reply rate") + rootCauseCandidates.push({ severity: 60 + errorRate, text: `An error-reply rate of ${errorRate.toFixed(1)}% suggests malformed commands or misuse.` }) + riskCandidates.push({ severity: 50, text: "Elevated error rate signals client bugs or schema drift." }) + } else if (errorRate > 1) { + status = "warn" + optimizations.push(`Error-reply rate ${errorRate.toFixed(2)}% — worth investigating`) + } else { + optimizations.push(`Throughput healthy — ${totalCommands.toLocaleString()} commands, minimal errors`) + } + breakdown.push({ label: "Command Throughput", earned, max, reason, status }) + } else { + breakdown.push({ label: "Command Throughput", earned: Math.round(max * 0.6), max, reason: "No command traffic recorded yet", status: "unknown" }) + } + } + + // ── 5. Data Lifecycle (max 15) — evictions + key hygiene ────────────────────── + const evictedKeys = num(data.evicted_keys) + const expiredKeys = num(data.expired_keys) + const keysCount = num(data.keys_count) + const bytesPerKey = num(data.bytes_per_key) + { + const max = 15 + if (has(data.evicted_keys) || keysCount > 0) { + availableSignals++ + let quality = 1 + let status: ScoreCategory["status"] = "good" + const reasonParts: string[] = [] + + if (evictedKeys > 1000) { + quality -= 0.6 + status = "critical" + issues.push(`Critical: ${evictedKeys.toLocaleString()} keys evicted`) + recommendations.push("Increase memory or add explicit TTLs — heavy eviction in progress") + rootCauseCandidates.push({ severity: 75, text: `${evictedKeys.toLocaleString()} evicted keys show the dataset exceeds available memory.` }) + riskCandidates.push({ severity: 65, text: "Eviction silently drops data that applications may expect to exist." }) + reasonParts.push(`${evictedKeys.toLocaleString()} evicted`) + } else if (evictedKeys > 0) { + quality -= 0.25 + status = "warn" + issues.push(`Notice: ${evictedKeys.toLocaleString()} keys evicted`) + optimizations.push("Confirm the eviction policy matches your access pattern") + reasonParts.push(`${evictedKeys.toLocaleString()} evicted`) + } + + if (keysCount > 0) reasonParts.push(`${keysCount.toLocaleString()} keys`) + if (expiredKeys > 0) reasonParts.push(`${expiredKeys.toLocaleString()} expired`) + + // Key hygiene heuristics + if (bytesPerKey > 10240 && keysCount > 100) { + quality -= 0.15 + status = status === "good" ? "warn" : status + optimizations.push(`Avg key size ${(bytesPerKey / 1024).toFixed(1)} KB — audit large hashes/sorted sets`) + } + if (keysCount > 1_000_000) { + optimizations.push("Over 1M keys — consider namespacing and periodic cleanup") + } + // Low expiry activity with a sizable keyspace hints at missing TTLs + if (keysCount > 1000 && expiredKeys === 0 && evictedKeys === 0) { + optimizations.push("No keys are expiring — many keys may lack TTLs (run: find keys without TTL)") + } + + if (status === "good") optimizations.push("Data lifecycle healthy — no eviction pressure") + + breakdown.push({ + label: "Data Lifecycle", + earned: pts(quality, max), + max, + reason: reasonParts.length ? reasonParts.join(", ") : "No eviction or expiry pressure", + status, + }) + } else { + breakdown.push({ label: "Data Lifecycle", earned: Math.round(max * 0.6), max, reason: "Keyspace metrics unavailable", status: "unknown" }) + } + } + + // ── Synthesize score ────────────────────────────────────────────────────────── + const healthScore = breakdown.reduce((sum, c) => sum + c.earned, 0) + + // Root cause = highest-severity contributor, else healthy message. + rootCauseCandidates.sort((a, b) => b.severity - a.severity) + riskCandidates.sort((a, b) => b.severity - a.severity) + + const rootCause = rootCauseCandidates.length > 0 + ? rootCauseCandidates[0].text + : `Score ${healthScore}/100 — all measured categories are within healthy ranges. ` + + `Leading contributors: ${[...breakdown].sort((a, b) => b.earned - a.earned).slice(0, 2).map((c) => `${c.label} (${c.earned}/${c.max})`).join(", ")}.` + + const riskAssessment = riskCandidates.length > 0 + ? riskCandidates[0].text + : "Low risk — the database is operating within normal parameters across all measured signals." + + if (issues.length === 0) recommendations.push("No action required — keep monitoring trends") + if (optimizations.length === 0) optimizations.push("No optimization opportunities identified") + + // Confidence reflects how much of the analysis used real data. + const confidence = Math.round((availableSignals / totalSignals) * 100) + + return { + healthScore, + breakdown, + rootCause, + riskAssessment, + issues, + recommendations: Array.from(new Set(recommendations)), + optimizations: Array.from(new Set(optimizations)), + confidence, + timestamp: new Date().toISOString(), + } +} diff --git a/apps/frontend/src/services/breeth.ts b/apps/frontend/src/services/breeth.ts new file mode 100644 index 00000000..4875b1b4 --- /dev/null +++ b/apps/frontend/src/services/breeth.ts @@ -0,0 +1,120 @@ +/** + * Breeth Service — frontend client for the AI Copilot backend endpoints. + * + * All Breeth API calls go through the Valkey Admin backend at /api/ai-copilot/*. + * The API key is stored server-side only. + * + * Architecture: + * Browser → /api/ai-copilot/* → Backend → https://api.thebreeth.com/v1/* + */ + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface AnalysisRecord { + timestamp: string + healthScore: number + rootCause: string + riskAssessment: string + issues: string[] + recommendations: string[] + optimizations: string[] + metricsSnapshot: Record + query?: string +} + +export interface BreethEdge { + edge_uuid: string + source_node: string + target_node: string + fact: string + name: string + intent_meta?: { + edge_kind?: string + cognitive_pattern?: string + why_connected?: string + } +} + +interface SaveResponse { + ok: boolean + episode_name?: string + extracted?: { entities: number; edges: number } + error?: string +} + +interface SearchResponse { + ok: boolean + edges: BreethEdge[] + error?: string +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Save a health analysis to Breeth via the backend. + */ +export async function saveAnalysis(record: AnalysisRecord): Promise { + const response = await fetch("/api/ai-copilot/save-analysis", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(record), + }) + + const data: SaveResponse = await response.json() + if (!data.ok) { + throw new Error(data.error || "Failed to save analysis") + } + return data +} + +/** + * Retrieve analysis history from Breeth via the backend. + */ +export async function retrieveAnalyses(): Promise { + const response = await fetch("/api/ai-copilot/history") + const data: SearchResponse = await response.json() + if (!data.ok) { + throw new Error(data.error || "Failed to load history") + } + return data.edges +} + +/** + * Search for similar incidents via the backend. + */ +export async function searchSimilarAnalyses(query: string, limit = 5): Promise { + const response = await fetch("/api/ai-copilot/search-similar", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query, limit }), + }) + + const data: SearchResponse = await response.json() + if (!data.ok) { + throw new Error(data.error || "Failed to search similar incidents") + } + return data.edges +} + +/** + * Save a natural language command interaction via the backend. + */ +export async function saveCommandInteraction( + question: string, + generatedCommand: string, + result: string, +): Promise { + const record: AnalysisRecord = { + timestamp: new Date().toISOString(), + healthScore: 0, + rootCause: "", + riskAssessment: "", + issues: [], + recommendations: [], + optimizations: [], + metricsSnapshot: {}, + query: `User asked: "${question}". Generated: ${generatedCommand}. Result: ${result.slice(0, 300)}`, + } + + return saveAnalysis(record) +} diff --git a/apps/frontend/src/services/command-engine.ts b/apps/frontend/src/services/command-engine.ts new file mode 100644 index 00000000..d29a4051 --- /dev/null +++ b/apps/frontend/src/services/command-engine.ts @@ -0,0 +1,73 @@ +/** + * Command Engine — frontend client for the backend intent detector. + * + * Natural language is sent to /api/ai-copilot/interpret, which uses Gemini + * (when GEMINI_API_KEY is configured) or a deterministic local classifier. + * The backend never silently falls back to a generic INFO command — when it + * can't determine intent it returns UNKNOWN with debug details. + */ + +export interface CommandResult { + query: string + intent: string + confidence: number + generatedCommand: string + explanation: string + isSafe: boolean + // ── Debug fields ── + source: "groq" | "local" | "blocked" + rawResponse: string | null + parseError: string | null +} + +interface InterpretResponse extends CommandResult { + ok: boolean + error?: string +} + +/** + * Send a natural-language query to the backend intent detector. + */ +export async function interpretQuery(input: string): Promise { + const query = input.trim() + + const response = await fetch("/api/ai-copilot/interpret", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query }), + }) + + const data: InterpretResponse = await response.json() + + if (!data.ok) { + throw new Error(data.error || "Intent detection failed") + } + + return { + query: data.query, + intent: data.intent, + confidence: data.confidence, + generatedCommand: data.generatedCommand, + explanation: data.explanation, + isSafe: data.isSafe, + source: data.source, + rawResponse: data.rawResponse, + parseError: data.parseError, + } +} + +/** + * Suggested queries for the input field. + */ +export function getSuggestions(): string[] { + return [ + "Show top memory consuming keys", + "Which keys use the most memory?", + "Find keys without TTL", + "Show active clients", + "Explain cache performance", + "Show database statistics", + "Show slow queries", + "What is the replication status?", + ] +} diff --git a/apps/server/.env.example b/apps/server/.env.example new file mode 100644 index 00000000..7ff83c6b --- /dev/null +++ b/apps/server/.env.example @@ -0,0 +1,18 @@ +# Valkey Admin — Server Environment Variables + +# ── AI Copilot (Breeth memory integration) ────────────────────────────────── +# API key for the Breeth memory service (https://www.thebreeth.com). +# Required for the AI Copilot "Save to Memory", "Past Investigations", +# and "Similar Incidents" features. Without it, those endpoints return HTTP 500. +# Mint a key in the Breeth dashboard: API Keys → New key. +BREETH_API_KEY=your_key_here + +# ── AI Copilot ("Ask Valkey" intent detection) ────────────────────────────── +# Groq API key. When set, natural-language queries are classified by Groq +# (strict JSON intent via the OpenAI-compatible API). Without it, a +# deterministic local keyword classifier is used instead. +# Get a key at https://console.groq.com/keys. +GROQ_API_KEY=your_groq_key_here + +# Optional: override the Groq model (default: openai/gpt-oss-120b) +# GROQ_MODEL=openai/gpt-oss-120b diff --git a/apps/server/src/ai-intent.ts b/apps/server/src/ai-intent.ts new file mode 100644 index 00000000..2b38c250 --- /dev/null +++ b/apps/server/src/ai-intent.ts @@ -0,0 +1,328 @@ +/** + * AI Intent Detection for "Ask Valkey". + * + * Converts a natural-language query into a structured intent, then maps that + * intent to a safe, read-only Valkey command. + * + * Two detection paths: + * 1. Gemini (when GEMINI_API_KEY is set) — strict JSON output, validated. + * 2. Local keyword classifier — deterministic fallback, always available. + * + * The result always carries debug fields so the UI can show exactly what + * happened. We never silently fall back to a generic INFO command. + */ + +// ─── Intent catalog ─────────────────────────────────────────────────────────── + +export const INTENTS = { + TOP_MEMORY_KEYS: { + command: "MEMORY DOCTOR", + explanation: "Runs memory diagnostics to surface large-key and fragmentation issues.", + }, + MEMORY_USAGE: { + command: "INFO memory", + explanation: "Shows memory usage: used, peak, fragmentation ratio, and allocator stats.", + }, + KEYS_WITHOUT_TTL: { + command: "SCAN 0 COUNT 100", + explanation: "Scans keys incrementally. For each, TTL returning -1 means no expiry is set.", + }, + KEY_COUNT: { + command: "DBSIZE", + explanation: "Returns the total number of keys in the current database.", + }, + ACTIVE_CLIENTS: { + command: "CLIENT LIST", + explanation: "Lists connected clients with address, age, idle time, and current command.", + }, + CACHE_PERFORMANCE: { + command: "INFO stats", + explanation: "Shows keyspace_hits and keyspace_misses. Hit ratio = hits / (hits + misses); target ≥ 80%.", + }, + DATABASE_STATS: { + command: "INFO", + explanation: "Comprehensive server info: memory, CPU, clients, persistence, and keyspace stats.", + }, + SLOW_QUERIES: { + command: "SLOWLOG GET 10", + explanation: "Returns the 10 most recent slow commands above the slowlog threshold.", + }, + REPLICATION_STATUS: { + command: "INFO replication", + explanation: "Shows replication role, connected replicas, and replication offset.", + }, + PERSISTENCE_STATUS: { + command: "INFO persistence", + explanation: "Shows RDB/AOF status: last save time and changes since last dump.", + }, + SERVER_UPTIME: { + command: "INFO server", + explanation: "Shows server uptime, version, mode, and process id.", + }, +} as const + +export type IntentName = keyof typeof INTENTS + +export interface IntentResult { + query: string + intent: IntentName | "UNKNOWN" + confidence: number + explanation: string + generatedCommand: string + isSafe: boolean + // ── Debug fields ── + source: "groq" | "local" | "blocked" + rawResponse: string | null + parseError: string | null +} + +// ─── Safety layer ─────────────────────────────────────────────────────────── + +const DESTRUCTIVE = /\b(delete|del|remove|flush(all|db)?|drop|destroy|wipe|shutdown|config\s+set|rename|migrate|restore|swapdb)\b/i + +function destructiveResult(query: string): IntentResult { + return { + query, + intent: "UNKNOWN", + confidence: 1, + explanation: "Destructive operations are blocked in AI Copilot. Use the Send Command page with caution.", + generatedCommand: "", + isSafe: false, + source: "blocked", + rawResponse: null, + parseError: null, + } +} + +// ─── Local keyword classifier (deterministic fallback) ──────────────────────── + +const KEYWORDS: Record = { + TOP_MEMORY_KEYS: [/top\s*memory/i, /largest\s*key/i, /big(gest)?\s*key/i, /most\s*memory/i, /memory[-\s]*consum/i, /heaviest\s*key/i], + MEMORY_USAGE: [/memory\s*(usage|stats|info|consumption)/i, /how\s*much\s*memory/i, /ram\s*usage/i, /used\s*memory/i], + KEYS_WITHOUT_TTL: [/without\s*ttl/i, /no\s*ttl/i, /missing\s*ttl/i, /never\s*expir/i, /not?\s*expir/i, /persistent\s*key/i], + KEY_COUNT: [/how\s*many\s*keys/i, /key\s*count/i, /number\s*of\s*keys/i, /total\s*keys/i, /dbsize/i], + ACTIVE_CLIENTS: [/active\s*client/i, /connected\s*client/i, /show\s*client/i, /list\s*client/i, /who.*connected/i, /current\s*connection/i], + CACHE_PERFORMANCE: [/cache\s*(perf|hit|miss|ratio|effic)/i, /hit\s*ratio/i, /explain.*cache/i, /cache\s*performance/i], + DATABASE_STATS: [/database\s*stat/i, /db\s*stat/i, /server\s*stat/i, /overview/i, /general\s*info/i, /show.*stats/i], + SLOW_QUERIES: [/slow\s*(log|quer|command)/i, /slowest/i, /laggy\s*command/i], + REPLICATION_STATUS: [/replicat/i, /replica/i, /\bslave\b/i, /\bmaster\b/i], + PERSISTENCE_STATUS: [/persist/i, /\brdb\b/i, /\baof\b/i, /backup/i, /snapshot/i], + SERVER_UPTIME: [/uptime/i, /how\s*long.*running/i, /server\s*version/i], +} + +function classifyLocally(query: string): { intent: IntentName | "UNKNOWN"; confidence: number } { + const scores: { intent: IntentName; score: number }[] = [] + for (const [intent, patterns] of Object.entries(KEYWORDS) as [IntentName, RegExp[]][]) { + let score = 0 + for (const re of patterns) if (re.test(query)) score++ + if (score > 0) scores.push({ intent, score }) + } + if (scores.length === 0) return { intent: "UNKNOWN", confidence: 0 } + scores.sort((a, b) => b.score - a.score) + // Confidence: more matched patterns = higher confidence, capped. + const confidence = Math.min(0.6 + scores[0].score * 0.15, 0.95) + return { intent: scores[0].intent, confidence } +} + +// ─── Groq path (OpenAI-compatible) ──────────────────────────────────────────── + +const GROQ_MODEL = process.env.GROQ_MODEL || "openai/gpt-oss-120b" +const GROQ_URL = "https://api.groq.com/openai/v1/chat/completions" + +function buildPrompt(query: string): string { + const intentList = Object.keys(INTENTS).join(", ") + return [ + "You are an intent classifier for a Valkey (Redis-compatible) database admin tool.", + `Classify the user's request into EXACTLY ONE of these intents: ${intentList}.`, + 'If none fit, use "UNKNOWN".', + "", + "Respond with STRICT JSON ONLY. No markdown, no code fences, no prose.", + "Schema:", + '{ "intent": "", "confidence": , "explanation": "" }', + "", + `User request: "${query}"`, + ].join("\n") +} + +interface LlmParsed { + intent: string + confidence: number + explanation: string +} + +function extractJson(text: string): string { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i) + const candidate = fenced ? fenced[1] : text + const brace = candidate.match(/\{[\s\S]*\}/) + return (brace ? brace[0] : candidate).trim() +} + +async function classifyWithGroq( + query: string, + apiKey: string, +): Promise<{ parsed: LlmParsed | null; raw: string; parseError: string | null }> { + const body = { + model: GROQ_MODEL, + messages: [ + { role: "system", content: "You output strict JSON only, matching the requested schema." }, + { role: "user", content: buildPrompt(query) }, + ], + response_format: { type: "json_object" }, + temperature: 0, + } + + const response = await fetch(GROQ_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + }) + + const raw = await response.text() + if (!response.ok) { + return { parsed: null, raw, parseError: `Groq HTTP ${response.status}` } + } + + // Unwrap the OpenAI-compatible envelope → choices[0].message.content + let modelText = raw + try { + const envelope = JSON.parse(raw) as { + choices?: { message?: { content?: string } }[] + } + modelText = envelope.choices?.[0]?.message?.content ?? raw + } catch { + // raw wasn't the expected envelope; fall through with raw text + } + + try { + const parsed = JSON.parse(extractJson(modelText)) as LlmParsed + if (typeof parsed.intent !== "string") { + return { parsed: null, raw: modelText, parseError: "Missing 'intent' field in LLM JSON" } + } + return { parsed, raw: modelText, parseError: null } + } catch (err) { + return { parsed: null, raw: modelText, parseError: err instanceof Error ? err.message : "JSON parse failed" } + } +} + +// ─── Public entry ───────────────────────────────────────────────────────────── + +/** + * Returns the configured model name (for health/debug output). + */ +export function getModel(): string { + return GROQ_MODEL +} + +/** + * Health check: is GROQ_API_KEY configured, and is the API reachable? + * Never returns or logs the key itself. + */ +export async function llmHealth(): Promise<{ + configured: boolean + reachable: boolean + model: string + status?: number + error?: string +}> { + const apiKey = process.env.GROQ_API_KEY + if (!apiKey) { + return { configured: false, reachable: false, model: GROQ_MODEL, error: "GROQ_API_KEY not set" } + } + try { + const response = await fetch(GROQ_URL, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, + body: JSON.stringify({ + model: GROQ_MODEL, + messages: [{ role: "user", content: 'Reply with valid json {"ok":true} only.' }], + response_format: { type: "json_object" }, + temperature: 0, + }), + }) + if (!response.ok) { + const body = await response.text() + return { configured: true, reachable: false, model: GROQ_MODEL, status: response.status, error: body.slice(0, 300) } + } + return { configured: true, reachable: true, model: GROQ_MODEL, status: response.status } + } catch (err) { + return { configured: true, reachable: false, model: GROQ_MODEL, error: err instanceof Error ? err.message : "request failed" } + } +} + +export async function detectIntent(query: string): Promise { + const trimmed = query.trim() + + if (DESTRUCTIVE.test(trimmed)) { + return destructiveResult(trimmed) + } + + const apiKey = process.env.GROQ_API_KEY + let rawResponse: string | null = null + let parseError: string | null = null + + // 1. Try Groq if configured. + if (apiKey) { + try { + const { parsed, raw, parseError: pErr } = await classifyWithGroq(trimmed, apiKey) + rawResponse = raw + parseError = pErr + + if (parsed && parsed.intent in INTENTS) { + const intent = parsed.intent as IntentName + const meta = INTENTS[intent] + return { + query: trimmed, + intent, + confidence: typeof parsed.confidence === "number" ? parsed.confidence : 0.9, + explanation: parsed.explanation || meta.explanation, + generatedCommand: meta.command, + isSafe: true, + source: "groq", + rawResponse, + parseError: null, + } + } + if (parsed && parsed.intent === "UNKNOWN") { + // Model explicitly said unknown — fall through to local, but keep raw. + parseError = parseError ?? "LLM returned UNKNOWN" + } + } catch (err) { + parseError = err instanceof Error ? err.message : "Groq request failed" + } + } else { + parseError = "GROQ_API_KEY not configured — using local classifier" + } + + // 2. Deterministic local classifier. + const { intent, confidence } = classifyLocally(trimmed) + if (intent !== "UNKNOWN") { + const meta = INTENTS[intent] + return { + query: trimmed, + intent, + confidence, + explanation: meta.explanation, + generatedCommand: meta.command, + isSafe: true, + source: "local", + rawResponse, + parseError, + } + } + + // 3. True unknown — DO NOT silently run INFO. Report it. + return { + query: trimmed, + intent: "UNKNOWN", + confidence: 0, + explanation: "Could not determine intent. Try: 'show top memory keys', 'find keys without TTL', 'show active clients', or 'explain cache performance'.", + generatedCommand: "", + isSafe: true, + source: rawResponse ? "groq" : "local", + rawResponse, + parseError, + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 6cc0b2db..d6cbd40f 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -40,6 +40,7 @@ import { updateClusterNodeRegistry } from "./metrics-orchestrator" import { isAllowedWebSocketOrigin } from "./websocket-origin" +import { detectIntent, llmHealth, getModel } from "./ai-intent" import type { Request, Response } from "express" interface AliveWebSocket extends WebSocket { @@ -80,6 +81,169 @@ app.use(express.json()) const metricsRouter = createMetricsOrchestratorRouter() app.use("/orchestrator", metricsRouter) +// ── AI Copilot Backend Endpoints (Breeth integration) ──────────────────────── +const BREETH_API_URL = "https://api.thebreeth.com" +const BREETH_API_KEY = process.env.BREETH_API_KEY +const BREETH_GROUP_ID = "valkey-admin-copilot" + +// Guard: every AI Copilot route requires the server-side Breeth API key. +function requireBreethKey(res: Response): boolean { + if (!BREETH_API_KEY) { + console.error("BREETH_API_KEY is not set. AI Copilot memory features are disabled.") + res.status(500).json({ + ok: false, + error: "Breeth integration is not configured. Set the BREETH_API_KEY environment variable on the server.", + }) + return false + } + return true +} + +async function breethPost(path: string, body: Record): Promise> { + const maxAttempts = 3 + let lastErr: unknown + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 10000) + try { + const response = await fetch(`${BREETH_API_URL}${path}`, { + method: "POST", + headers: { + "Authorization": `Bearer ${BREETH_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: controller.signal, + }) + clearTimeout(timeout) + if (!response.ok) { + const err = await response.json().catch(() => ({ error: response.statusText })) as Record + throw new Error(err.message || err.error || `Breeth API ${response.status}`) + } + return response.json() as Promise> + } catch (err) { + clearTimeout(timeout) + lastErr = err + const message = err instanceof Error ? err.message : String(err) + // Retry only on transient network/timeout errors, not on API errors. + const transient = message === "fetch failed" || message.includes("aborted") || message.includes("timeout") || message.includes("ECONNRESET") || message.includes("ENOTFOUND") + if (!transient || attempt === maxAttempts) break + console.warn(`Breeth ${path} attempt ${attempt} failed (${message}); retrying...`) + await new Promise((r) => setTimeout(r, 300 * attempt)) + } + } + + const message = lastErr instanceof Error ? lastErr.message : String(lastErr) + throw new Error( + message === "fetch failed" + ? "Could not reach Breeth (network error). Please retry." + : message, + ) +} + +// Save an analysis to Breeth +app.post("/api/ai-copilot/save-analysis", async (req: Request, res: Response) => { + if (!requireBreethKey(res)) return + try { + const { timestamp, healthScore, rootCause, riskAssessment, issues, recommendations, optimizations, metricsSnapshot, query } = req.body + + const content = [ + `Valkey health analysis at ${timestamp}.`, + `Health Score: ${healthScore}/100.`, + `Root Cause: ${rootCause}`, + `Risk: ${riskAssessment}`, + issues?.length > 0 ? `Issues: ${issues.join(". ")}.` : "No issues detected.", + `Recommendations: ${recommendations?.join(". ")}.`, + `Optimizations: ${optimizations?.join(". ")}.`, + metricsSnapshot ? `Memory: ${metricsSnapshot.used_memory ?? "unknown"} bytes. Clients: ${metricsSnapshot.connected_clients ?? "unknown"}. Hit ratio: ${metricsSnapshot.hitRatio ?? "unknown"}.` : "", + query ? `User query: ${query}` : "", + ].filter(Boolean).join(" ") + + const data = await breethPost("/v1/episodes", { + content, + group_id: BREETH_GROUP_ID, + source_description: "valkey-admin-ai-copilot", + extract_intent: true, + }) + + res.json({ ok: true, episode_name: data.episode_name, extracted: data.extracted }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Failed to save analysis" + console.error("AI Copilot save-analysis error:", message) + res.status(502).json({ ok: false, error: message }) + } +}) + +// Retrieve analysis history from Breeth +app.get("/api/ai-copilot/history", async (_req: Request, res: Response) => { + if (!requireBreethKey(res)) return + try { + const data = await breethPost("/v1/search", { + query: "Valkey health analysis score root cause recommendations", + group_id: BREETH_GROUP_ID, + limit: 10, + }) + const edges = Array.isArray(data.edges) ? data.edges : [] + res.json({ ok: true, edges }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Failed to load history" + console.error("AI Copilot history error:", message) + res.status(502).json({ ok: false, edges: [], error: message }) + } +}) + +// Search for similar incidents +app.post("/api/ai-copilot/search-similar", async (req: Request, res: Response) => { + if (!requireBreethKey(res)) return + try { + const { query, limit } = req.body + if (!query) { + res.status(400).json({ ok: false, edges: [], error: "query is required" }) + return + } + const data = await breethPost("/v1/search", { + query, + group_id: BREETH_GROUP_ID, + limit: limit || 5, + }) + const edges = Array.isArray(data.edges) ? data.edges : [] + res.json({ ok: true, edges }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Failed to search" + console.error("AI Copilot search-similar error:", message) + res.status(502).json({ ok: false, edges: [], error: message }) + } +}) + +// Natural-language → intent → safe command ("Ask Valkey") +app.post("/api/ai-copilot/interpret", async (req: Request, res: Response) => { + try { + const { query } = req.body + if (!query || typeof query !== "string") { + res.status(400).json({ ok: false, error: "query is required" }) + return + } + const result = await detectIntent(query) + console.log( + `[AskValkey] query="${result.query}" → intent=${result.intent} ` + + `confidence=${result.confidence} source=${result.source}` + + (result.parseError ? ` parseError="${result.parseError}"` : ""), + ) + res.json({ ok: true, ...result }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Intent detection failed" + console.error("AI Copilot interpret error:", message) + res.status(500).json({ ok: false, error: message }) + } +}) + +// LLM health check — confirms key presence and API reachability (never returns the key) +app.get("/api/ai-copilot/llm-health", async (_req: Request, res: Response) => { + const health = await llmHealth() + res.json(health) +}) + // Fallback to index.html for SPA routing app.get("*", (_req: Request, res: Response) => { res.sendFile(path.join(frontendDist, "index.html")) @@ -141,6 +305,9 @@ async function updateRegistryforK8() { server.listen(port, () => { console.log(`Server running at http://localhost:${port}`) + // AI Copilot startup health check (never logs the actual keys). + console.log(`[AICopilot] BREETH_API_KEY ${process.env.BREETH_API_KEY ? "configured" : "MISSING"}`) + console.log(`[AICopilot] GROQ_API_KEY ${process.env.GROQ_API_KEY ? "configured" : "MISSING"} (model: ${getModel()})`) if (process.send) { // Check if process.send is available (i.e., if forked) process.send({ type: "websocket-ready" }) // Send a ready message to the parent process } diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 00a73f18..2e47ba0e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -8,3 +8,23 @@ services: environment: NODE_ENV: production KEY_VALUE_SIZE_LIMIT_BYTES: 2048 + VALKEY_HOST: valkey + VALKEY_PORT: 6379 + VALKEY_TLS: "false" + VALKEY_ENDPOINT_TYPE: node + # AI Copilot — Breeth memory integration. Set in your shell or a .env file. + BREETH_API_KEY: ${BREETH_API_KEY} + # AI Copilot — Groq intent detection for "Ask Valkey". + GROQ_API_KEY: ${GROQ_API_KEY} + networks: + - default + + valkey: + image: valkey/valkey:latest + ports: + - "6379:6379" + networks: + - default + +networks: + default: diff --git a/package-lock.json b/package-lock.json index 18de7cb5..7bb418f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,10 @@ "name": "valkey-admin", "version": "1.0.1", "workspaces": [ + "common", "apps/frontend", "apps/server", - "apps/metrics", - "common" + "apps/metrics" ], "dependencies": { "@types/express": "^5.0.6" @@ -48,6 +48,7 @@ "@radix-ui/react-tooltip": "^1.2.7", "@reduxjs/toolkit": "^2.8.2", "@tailwindcss/vite": "^4.1.11", + "@types/three": "^0.184.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "fastest-levenshtein": "^1.0.16", @@ -62,7 +63,8 @@ "rxjs": "^7.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", - "tailwindcss": "^4.1.11" + "tailwindcss": "^4.1.11", + "three": "^0.184.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -1302,6 +1304,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", @@ -4986,6 +4994,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -5337,6 +5351,26 @@ "@types/node": "*" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -5351,6 +5385,12 @@ "license": "MIT", "optional": true }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -9490,7 +9530,6 @@ "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "dev": true, "license": "MIT" }, "node_modules/file-entry-cache": { @@ -11826,6 +11865,12 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -14659,6 +14704,12 @@ "node": ">=0.8" } }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",