Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
597 changes: 597 additions & 0 deletions apps/frontend/src/components/ai-copilot/AICopilot.tsx

Large diffs are not rendered by default.

89 changes: 89 additions & 0 deletions apps/frontend/src/components/ai-copilot/README.md
Original file line number Diff line number Diff line change
@@ -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_KEY>)
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` |
8 changes: 7 additions & 1 deletion apps/frontend/src/components/ui/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
82 changes: 82 additions & 0 deletions apps/frontend/src/components/ui/bg-pattern.tsx
Original file line number Diff line number Diff line change
@@ -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<BGMaskType, string> = {
"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 (
<div
className={cn("absolute inset-0 z-[-10] size-full", maskClasses[mask], className)}
style={{
backgroundImage,
backgroundSize: bgSize,
...style,
}}
{...props}
/>
)
}

BGPattern.displayName = "BGPattern"

export { BGPattern }
164 changes: 164 additions & 0 deletions apps/frontend/src/components/ui/particle-wave.tsx
Original file line number Diff line number Diff line change
@@ -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<ParticleWaveProps> = ({ className = "" }) => {
const canvasRef = useRef<HTMLCanvasElement>(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 (
<canvas
ref={canvasRef}
className={`absolute inset-0 z-[-10] block size-full ${className}`}
/>
)
}

export { ParticleWave }
Loading