From ce68f33608b2fe2880971a2d8f4e3de1cf798648 Mon Sep 17 00:00:00 2001 From: Ankan Saha Date: Mon, 27 Jul 2026 19:01:06 +0530 Subject: [PATCH 1/5] chore: remove Blue/Green Deployments feature from LandingPage component --- client/src/app/page.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/client/src/app/page.tsx b/client/src/app/page.tsx index 4e36b4b..cbe39cb 100644 --- a/client/src/app/page.tsx +++ b/client/src/app/page.tsx @@ -195,12 +195,6 @@ export default function LandingPage() { desc: 'Failover strategy ensures requests automatically retry healthy origins if primary fails or returns 5xx errors.', features: ['Ordered retry logic', 'Health monitoring', 'Zero config needed'], }, - { - icon: 'Activity', - title: 'Blue/Green Deployments', - desc: 'Use weighted strategies to gradually shift traffic from old to new infrastructure with fine-grained control.', - features: ['Gradual rollouts', 'Instant rollback', 'A/B testing'], - }, { icon: 'Link', title: 'Stateful Workloads', From 33f0dadf89c98c58b2b250abecea0e3c9c1750e1 Mon Sep 17 00:00:00 2001 From: Ankan Saha Date: Mon, 27 Jul 2026 21:35:04 +0530 Subject: [PATCH 2/5] feat(ai): implement AI run auditing and logging services - Add audit service to record AI run details including user ID, prompt, trace, outcome, duration, and errors. - Introduce logging service for AI runs to provide consistent console output during processing. - Create model provider service to manage API keys and model configurations for different providers. - Implement model router service to handle invoking models with fallback mechanisms and error classification. - Add quota service to manage provider and model exhaustion states using Redis. - Introduce rate limit service to control request pacing for models. - Create SSE service for streaming events to clients. - Develop tools service for managing load balancers with user confirmation for destructive actions. - Define AI types for better type safety and clarity in AI-related operations. - Update load balancer controllers and orchestrators to support new features and validations. - Enhance hostname service to check for conflicts with existing Cloudflare Worker routes. - Add new routes for AI operations in the application. - Extend Cloudflare client to retrieve worker routes for better conflict management. - Introduce resource locking utility to prevent race conditions during load balancer creation. --- AGENTS.md | 85 ++- client/src/app/dashboard/page.tsx | 91 +++- client/src/components/dashboard/AiBuilder.tsx | 496 ++++++++++++++++++ client/src/lib/aiStream.ts | 68 +++ client/src/lib/api.ts | 2 + client/src/types/api.ts | 31 ++ server/.env.example | 4 + server/package-lock.json | 289 ++++++---- server/package.json | 2 + .../__tests__/integration/cloudflare.test.ts | 1 + .../integration/loadbalancer.test.ts | 48 ++ server/src/__tests__/setup/redisMock.ts | 2 + server/src/__tests__/unit/ai/agent.test.ts | 184 +++++++ .../__tests__/unit/ai/model-router.test.ts | 190 +++++++ server/src/__tests__/unit/ai/quota.test.ts | 71 +++ .../src/__tests__/unit/ai/rate-limit.test.ts | 75 +++ server/src/__tests__/unit/ai/tools.test.ts | 143 +++++ server/src/__tests__/unit/hostname.test.ts | 114 ++++ .../src/__tests__/unit/resourceLock.test.ts | 76 +++ server/src/app.ts | 2 + server/src/controllers/aiController.ts | 84 +++ server/src/middleware/validation.ts | 2 +- .../validators/loadBalancerValidators.ts | 278 +++++----- server/src/models/AiRun.ts | 95 ++++ server/src/models/LoadBalancer.ts | 10 +- server/src/models/index.ts | 1 + server/src/modules/ai/config/models.ts | 46 ++ server/src/modules/ai/config/systemPrompt.ts | 57 ++ .../src/modules/ai/services/agent.service.ts | 248 +++++++++ .../src/modules/ai/services/audit.service.ts | 33 ++ server/src/modules/ai/services/log.service.ts | 24 + .../ai/services/model-provider.service.ts | 33 ++ .../ai/services/model-router.service.ts | 158 ++++++ .../src/modules/ai/services/quota.service.ts | 65 +++ .../modules/ai/services/rate-limit.service.ts | 40 ++ server/src/modules/ai/services/sse.service.ts | 42 ++ .../src/modules/ai/services/tools.service.ts | 273 ++++++++++ server/src/modules/ai/types/ai.types.ts | 58 ++ .../controllers/validate.controller.ts | 4 + .../orchestrators/create.orchestrator.ts | 24 +- .../orchestrators/update.orchestrator.ts | 1 + .../loadbalancer/services/hostname.service.ts | 39 +- server/src/routes/aiRoutes.ts | 12 + server/src/services/cloudflareClient.ts | 13 + server/src/types/http.ts | 19 + server/src/utils/resourceLock.ts | 54 ++ 46 files changed, 3430 insertions(+), 257 deletions(-) create mode 100644 client/src/components/dashboard/AiBuilder.tsx create mode 100644 client/src/lib/aiStream.ts create mode 100644 server/src/__tests__/unit/ai/agent.test.ts create mode 100644 server/src/__tests__/unit/ai/model-router.test.ts create mode 100644 server/src/__tests__/unit/ai/quota.test.ts create mode 100644 server/src/__tests__/unit/ai/rate-limit.test.ts create mode 100644 server/src/__tests__/unit/ai/tools.test.ts create mode 100644 server/src/__tests__/unit/hostname.test.ts create mode 100644 server/src/__tests__/unit/resourceLock.test.ts create mode 100644 server/src/controllers/aiController.ts create mode 100644 server/src/models/AiRun.ts create mode 100644 server/src/modules/ai/config/models.ts create mode 100644 server/src/modules/ai/config/systemPrompt.ts create mode 100644 server/src/modules/ai/services/agent.service.ts create mode 100644 server/src/modules/ai/services/audit.service.ts create mode 100644 server/src/modules/ai/services/log.service.ts create mode 100644 server/src/modules/ai/services/model-provider.service.ts create mode 100644 server/src/modules/ai/services/model-router.service.ts create mode 100644 server/src/modules/ai/services/quota.service.ts create mode 100644 server/src/modules/ai/services/rate-limit.service.ts create mode 100644 server/src/modules/ai/services/sse.service.ts create mode 100644 server/src/modules/ai/services/tools.service.ts create mode 100644 server/src/modules/ai/types/ai.types.ts create mode 100644 server/src/routes/aiRoutes.ts create mode 100644 server/src/utils/resourceLock.ts diff --git a/AGENTS.md b/AGENTS.md index 3bc489c..4323bd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ app/ # Next.js App Router pages components/ auth/ # AuthLayout, GoogleAuthButton - dashboard/ # LoadBalancerCard, SessionCard, Sidebar + dashboard/ # LoadBalancerCard, SessionCard, Sidebar, AiBuilder landing/ # FlowDiagram layout/ # ProtectedRoute loadbalancers/ # DeploymentExperience, LoadBalancerVisualization, PauseModal @@ -65,6 +65,7 @@ contexts/ lib/ api.ts # Singleton ApiClient (Axios) — calls backend directly + aiStream.ts # fetch-based SSE reader for POST /api/ai/generate firebase.ts # Firebase app init cloudRegions.ts # Cloudflare region list geoData.ts # Geo targeting data @@ -103,18 +104,21 @@ models/ User.ts # IUser Mongoose schema LoadBalancer.ts # ILoadBalancer Mongoose schema Session.ts # ISession Mongoose schema + AiRun.ts # IAiRun — audit trail for AI provisioning runs -routes/ # Flat route handlers (auth, cloudflare, user) +routes/ # Flat route handlers (auth, cloudflare, user, ai) authRoutes.ts cloudflareRoutes.ts loadBalancerRoutes.ts # Legacy — active routes are in modules/ userRoutes.ts + aiRoutes.ts -controllers/ # Flat controllers (auth, cloudflare, user) +controllers/ # Flat controllers (auth, cloudflare, user, ai) authController.ts cloudflareController.ts loadBalancerController.ts # Legacy userController.ts + aiController.ts # generateWithAi (SSE) modules/ # Domain-module pattern (preferred) loadbalancer/ @@ -153,6 +157,22 @@ modules/ # Domain-module pattern (preferred) controllers/ list.controller.ts script.controller.ts + ai/ # Natural-language provisioning agent (LangChain.js) + # routes + controllers live in the flat folders above + config/ + models.ts # MISTRAL_MODELS + FREE_MODELS + MODEL_LADDER + systemPrompt.ts # guardrails — service scope only, no chat + services/ + model-provider.service.ts # ladder entry → ChatOpenAI (both providers are OpenAI-compatible) + quota.service.ts # global cooldowns — 24h provider, 60s model + rate-limit.service.ts # per-model rps pacing (falls through, never queues) + model-router.service.ts # walk ladder, skip open breakers, fall through on failure + tools.service.ts # LB tools, bound to the JWT userId + agent.service.ts # tool-calling loop, emits SSE events + audit.service.ts # persists the AiRun record + sse.service.ts # SSE frame writer + types/ + ai.types.ts services/ # Cross-cutting infra services cloudflareClient.ts # Cloudflare REST API wrapper @@ -174,6 +194,7 @@ services/ # Cross-cutting infra services maintenance.js utils/ + resourceLock.ts # Redis SET NX mutex — serialises same-name concurrent creates encryption.ts # AES-256-GCM encrypt/decrypt password.ts # bcrypt hash/compare jwt.ts # JWT generate/verify @@ -312,6 +333,46 @@ Stores deployment history snapshots (Worker JS + config) per load balancer actio | GET | `/` | cursor-paginated list (filter: all/active/inactive) | | GET | `/:id/script` | raw Worker JS for a session | +### AI (`/api/ai`) +| Method | Path | Notes | +|---|---|---| +| POST | `/generate` | `{ prompt }` → SSE stream of the agent run. 3 per 15 min. | + +**SSE events:** `run_start`, `model_switch`, `status`, `tool_start`, `tool_result`, `done`, `error`. + +**Tools:** `list_zones`, `list_load_balancers`, `create_load_balancer`, `update_load_balancer`, +`delete_load_balancer`, `pause_load_balancer`, `resume_load_balancer`. + +Only `create_load_balancer` performs work — it calls `createLoadBalancerOrchestrator`, so rollback +and cancellation behave exactly as on the HTTP route. The four destructive tools **never touch +Cloudflare**: they resolve and authorise the target, then return a `PendingAction` that ends the run +with `outcome: 'pending'`. The client renders it as a confirmation panel and, on confirm, calls the +ordinary REST routes (`DELETE /:id`, `POST /:id/pause`, …). Nothing irreversible happens inside an +agent run, and the server never has to pause mid-request. + +**Failure handling:** a tool that fails twice stops the run; a 409 conflict stops it on the first +attempt — the agent must never rename or re-target to work around a conflict. Either way a final +model call with `RCA_PROMPT` (no tools bound) writes the root-cause paragraph shown to the user. + +**Model ladder:** the OpenRouter free tier first (`openrouter/free` last within it), then all +Mistral models best-first — the free quota is capped per day, so it is spent before the metered one. +Failures are classified, and only quota exhaustion is shared with other users: + +| Disposition | Trigger | Effect | +|---|---|---| +| `provider-exhausted` | OpenRouter 429 matching `DAILY_QUOTA_PATTERN` | 24h Redis cooldown, **global** | +| `model-exhausted` | Mistral 429 | 60s Redis cooldown on that model, **global** | +| `provider-dead` | 401/403 | provider skipped for **this run only** | +| `transient` | anything else, incl. burst 429 | model skipped for **this run only** | + +Mistral entries carry their published `rps`; `tryConsume` paces them in Redis and the router moves +down the ladder rather than waiting. Both providers are reached through `ChatOpenAI` with a +different `baseURL`. + +**Concurrency:** runs are fully independent — separate HTTP calls, separate `trace`, separate tool +instances. They share only the one server-wide API key per provider, so they share that upstream +quota. + ### User (`/api/user`) | Method | Path | Notes | |---|---|---| @@ -348,6 +409,14 @@ All Cloudflare API calls are **server-only** (never from client). - Account > Workers KV Storage > Edit - Zone > Zone > Read +**Hostname conflict detection:** `assertHostnameAvailable` checks two independent bindings — +`GET /accounts/{id}/workers/domains` (Custom Domains) and `GET /zones/{id}/workers/routes` (Worker +Routes). A Custom Domain silently takes precedence over a route covering the same hostname, so +checking only the former would move live traffic off whatever Worker the route points at. Route +patterns are matched with `routePatternCoversHostname` (`*` = zero or more chars, path ignored); +routes with no `script` attached are bypass rules and are not conflicts. The route check needs a +`zoneId` and is skipped when the caller has none. + **Key CF operations:** - `PUT /accounts/{id}/workers/scripts/{name}` — deploy Worker - `PUT /accounts/{id}/workers/domains` — attach hostname @@ -368,7 +437,7 @@ Client → POST /api/loadbalancers 3. ensureWorkerNameAvailability (CF API check) 4. generateWorkerCode (from strategy template) 5. deployWorker → CF API - 6. assertHostnameAvailable (CF API check) + 6. assertHostnameAvailable (CF API check — Custom Domains AND Worker Routes) 7. attachDomainToWorker → CF API → returns workerUrl 8. LoadBalancer.create() → MongoDB 9. createSession() → MongoDB (non-blocking, failure ignored) @@ -376,6 +445,12 @@ Client → POST /api/loadbalancers ← { success, data, message } ``` +Step 2 first claims `lb:create:{accountId}:{scriptName}` via `acquireLock`, released in a +`finally`. Cloudflare's script PUT is an upsert and `scriptName` is globally unique in Mongo, so +without the lock two concurrent creates of the same name overwrite each other's Worker and the +loser's rollback deletes the winner's. The rollback is gated on holding that lock for the same +reason. Redis being unreachable fails open — the create proceeds unlocked. + On any failure after Worker deploy: full rollback (delete Worker + DB record). --- @@ -413,6 +488,8 @@ CORS_ORIGIN=http://localhost:3000 FIREBASE_PROJECT_ID= FIREBASE_CLIENT_EMAIL= FIREBASE_PRIVATE_KEY= +MISTRAL_API_KEY= # AI provisioning — at least one of these two +OPENROUTER_API_KEY= # enables POST /api/ai/generate, else it returns 503 ``` --- diff --git a/client/src/app/dashboard/page.tsx b/client/src/app/dashboard/page.tsx index 6222c7e..1d3b90e 100644 --- a/client/src/app/dashboard/page.tsx +++ b/client/src/app/dashboard/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import { api } from '@/lib/api'; @@ -10,6 +10,8 @@ import { Icons } from '@/components/shared/Icons'; import { ConfirmModal } from '@/components/ui/Modal'; import { PauseModal } from '@/components/loadbalancers/PauseModal'; import { DeploymentOverlay, DeploymentSuccessModal } from '@/components/loadbalancers/DeploymentExperience'; +import { AiPromptCard, AiProgressOverlay, applyAiEvent, initialAiRunState, type AiRunState } from '@/components/dashboard/AiBuilder'; +import { streamAiGeneration } from '@/lib/aiStream'; import type { LoadBalancer, LoadBalancerAnalytics } from '@/types/api'; import toast from 'react-hot-toast'; @@ -28,6 +30,14 @@ export default function DashboardPage() { const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; lb: LoadBalancer | null }>({ isOpen: false, lb: null }); const [deleteSuccess, setDeleteSuccess] = useState<{ name: string; fullDomain: string } | null>(null); + // AI provisioning state + const [aiPrompt, setAiPrompt] = useState(''); + const [aiRun, setAiRun] = useState(null); + const [actionPending, setActionPending] = useState(false); + const aiRunRef = useRef(null); + const aiAbortRef = useRef(null); + aiRunRef.current = aiRun; + // Search & filter state const [searchValue, setSearchValue] = useState(''); const [searchDebounce, setSearchDebounce] = useState(''); @@ -103,6 +113,68 @@ export default function DashboardPage() { fetchAnalytics(); }, [fetchLoadBalancers, fetchAnalytics]); + const runAiGeneration = useCallback(async () => { + const prompt = aiPrompt.trim(); + if (!prompt) return; + + const controller = new AbortController(); + aiAbortRef.current = controller; + setAiRun(initialAiRunState); + + try { + await streamAiGeneration(prompt, { + signal: controller.signal, + onEvent: (event) => setAiRun((current) => applyAiEvent(current ?? initialAiRunState, event)), + }); + } catch (error: any) { + // Aborting closes the request, which the server treats as a cancellation and rolls back. + const cancelled = controller.signal.aborted; + setAiRun((current) => { + const base = current ?? initialAiRunState; + return cancelled + ? { ...base, phase: 'done', outcome: 'failure', message: 'You cancelled the run. The step in progress was rolled back; anything already finished was kept.' } + : applyAiEvent(base, { name: 'error', payload: { message: error.message || 'AI generation failed' } }); + }); + } finally { + aiAbortRef.current = null; + } + }, [aiPrompt]); + + const cancelAiGeneration = useCallback(() => aiAbortRef.current?.abort(), []); + + // The agent only ever prepares a destructive step. Running it is a plain REST call from here, + // through the same endpoints the dashboard buttons already use. + const confirmAiAction = useCallback(async () => { + const pending = aiRunRef.current?.pendingAction; + if (!pending) return; + + const { action, loadBalancerId, payload } = pending; + setActionPending(true); + try { + if (action === 'delete') await api.deleteLoadBalancer(loadBalancerId); + else if (action === 'pause') await api.pauseLoadBalancer(loadBalancerId, (payload?.mode as 'release-domain' | 'keep-domain') ?? 'keep-domain'); + else if (action === 'resume') await api.resumeLoadBalancer(loadBalancerId); + else await api.updateLoadBalancer(loadBalancerId, payload ?? {}); + + toast.success(`${pending.name} updated`); + setAiPrompt(''); + setAiRun(null); + refreshAll(); + } catch (error: any) { + toast.error(error.message || 'Action failed'); + } finally { + setActionPending(false); + } + }, [refreshAll]); + + // A failed run can still have deployed something before it broke, so always resync. + const closeAiOverlay = useCallback((clearPrompt: boolean) => { + aiAbortRef.current?.abort(); + if (clearPrompt) setAiPrompt(''); + setAiRun(null); + refreshAll(); + }, [refreshAll]); + const handleNav = (id: string) => { if (id === 'settings') router.push('/settings'); else if (id === 'sessions') router.push('/sessions'); @@ -222,6 +294,13 @@ export default function DashboardPage() { } />
+ + {!hasBalancers && !loading ? ( router.push('/loadbalancers/create')} /> ) : ( @@ -320,6 +399,16 @@ export default function DashboardPage() {
+ closeAiOverlay(aiRun?.outcome === 'success')} + onRetry={() => closeAiOverlay(false)} + /> + setPauseModal({ isOpen: false, lb: null })} diff --git a/client/src/components/dashboard/AiBuilder.tsx b/client/src/components/dashboard/AiBuilder.tsx new file mode 100644 index 0000000..314090c --- /dev/null +++ b/client/src/components/dashboard/AiBuilder.tsx @@ -0,0 +1,496 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Icons } from '@/components/shared/Icons'; +import type { AiEvent, AiOutcome, AiStep, LoadBalancer, PendingAction } from '@/types/api'; + +const MAX_PROMPT_LENGTH = 2000; + +const TOOL_LABELS: Record = { + list_zones: 'Reading your Cloudflare zones', + list_load_balancers: 'Reading your load balancers', + create_load_balancer: 'Deploying the load balancer', + update_load_balancer: 'Preparing the configuration change', + delete_load_balancer: 'Preparing the deletion', + pause_load_balancer: 'Preparing the pause', + resume_load_balancer: 'Preparing the resume', +}; + +const ACTION_VERBS: Record = { + delete: 'Delete it', + pause: 'Pause it', + resume: 'Resume it', + update: 'Apply it', +}; + +const labelFor = (tool: string) => TOOL_LABELS[tool] ?? tool.replace(/_/g, ' '); + +export interface AiRunState { + phase: 'running' | 'done'; + runId: string | null; + progress: number; + statusMessage: string; + steps: AiStep[]; + modelNote: string | null; + pendingAction: PendingAction | null; + outcome: AiOutcome | null; + message: string; + loadBalancers: LoadBalancer[]; +} + +export const initialAiRunState: AiRunState = { + phase: 'running', + runId: null, + progress: 4, + statusMessage: 'Connecting to the provisioning agent', + steps: [], + modelNote: null, + pendingAction: null, + outcome: null, + message: '', + loadBalancers: [], +}; + +/** Single place mapping the server's SSE contract onto what the overlay renders. */ +export function applyAiEvent(state: AiRunState, event: AiEvent): AiRunState { + switch (event.name) { + case 'run_start': + return { ...state, runId: event.payload.runId }; + + case 'model_switch': + return { ...state, modelNote: `Falling back to ${event.payload.to}` }; + + case 'status': + return { ...state, statusMessage: event.payload.message, progress: event.payload.progress }; + + case 'tool_start': + return { + ...state, + statusMessage: labelFor(event.payload.name), + steps: [...state.steps, { label: labelFor(event.payload.name), state: 'running' }], + }; + + case 'tool_result': + return { ...state, steps: closeLastStep(state.steps, event.payload.ok, event.payload.summary) }; + + case 'done': + return { + ...state, + phase: 'done', + progress: 100, + outcome: event.payload.outcome, + message: event.payload.message, + loadBalancers: event.payload.loadBalancers ?? [], + pendingAction: event.payload.pendingAction ?? null, + }; + + case 'error': + return { + ...state, + phase: 'done', + outcome: 'failure', + message: event.payload.message, + steps: closeLastStep(state.steps, false, event.payload.message), + }; + + default: + return state; + } +} + +/** Reveals text one character at a time; jumps to the end if the text changes mid-run. */ +function useTypewriter(text: string, charsPerTick = 2, tickMs = 16): string { + const [shown, setShown] = useState(''); + + useEffect(() => { + if (!text) { + setShown(''); + return; + } + + let index = 0; + const timer = window.setInterval(() => { + index = Math.min(index + charsPerTick, text.length); + setShown(text.slice(0, index)); + if (index >= text.length) window.clearInterval(timer); + }, tickMs); + + return () => window.clearInterval(timer); + }, [text, charsPerTick, tickMs]); + + return shown; +} + +function closeLastStep(steps: AiStep[], ok: boolean, detail: string): AiStep[] { + const lastRunning = steps.map((s) => s.state).lastIndexOf('running'); + if (lastRunning === -1) return steps; + const next = [...steps]; + next[lastRunning] = { ...next[lastRunning], state: ok ? 'ok' : 'failed', detail }; + return next; +} + +// --- Prompt card --- + +interface AiPromptCardProps { + value: string; + onChange: (value: string) => void; + onSubmit: () => void; + disabled?: boolean; +} + +export function AiPromptCard({ value, onChange, onSubmit, disabled }: AiPromptCardProps) { + const textareaRef = useRef(null); + + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${Math.min(el.scrollHeight, 220)}px`; + }, [value]); + + const canSubmit = !disabled && value.trim().length > 0; + + return ( +
+
+ + Describe it, we deploy it +
+ +