From 4af000c6ae11bd8aedc6f12aca25eedbcdb22684 Mon Sep 17 00:00:00 2001 From: rock Date: Mon, 31 Aug 2026 20:22:26 -0700 Subject: [PATCH] feat(integrations): add NextJS LLM/Grafana integration + enable dashboard embedding --- integrations/nextjs/api-routes.ts | 97 +++++++++++++ integrations/nextjs/grafana-client.ts | 180 ++++++++++++++++++++++++ integrations/nextjs/hooks.ts | 193 ++++++++++++++++++++++++++ integrations/nextjs/index.ts | 114 +++++++++++++++ integrations/nextjs/llm-client.ts | 131 +++++++++++++++++ k8s/infra/logging/grafana-values.yaml | 4 + 6 files changed, 719 insertions(+) create mode 100644 integrations/nextjs/api-routes.ts create mode 100644 integrations/nextjs/grafana-client.ts create mode 100644 integrations/nextjs/hooks.ts create mode 100644 integrations/nextjs/index.ts create mode 100644 integrations/nextjs/llm-client.ts diff --git a/integrations/nextjs/api-routes.ts b/integrations/nextjs/api-routes.ts new file mode 100644 index 0000000..73f7a79 --- /dev/null +++ b/integrations/nextjs/api-routes.ts @@ -0,0 +1,97 @@ +/** + * NextJS API Route Examples + * + * Copy these to your NextJS app's pages/api/ or app/api/ directory + */ + +// ═══════════════════════════════════════════════════════════════════════════════ +// pages/api/chat.ts (Pages Router) +// ═══════════════════════════════════════════════════════════════════════════════ +/* +import type { NextApiRequest, NextApiResponse } from 'next'; +import { chat, streamChat, type Message } from '@/lib/llm-client'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const { messages, model = 'reasoning', stream = false } = req.body; + + if (!messages || !Array.isArray(messages)) { + return res.status(400).json({ error: 'messages required' }); + } + + try { + if (stream) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + for await (const chunk of streamChat({ model, messages })) { + res.write(`data: ${JSON.stringify({ content: chunk })}\n\n`); + } + res.write('data: [DONE]\n\n'); + res.end(); + } else { + const response = await chat({ model, messages }); + res.json(response); + } + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : 'Unknown error' }); + } +} +*/ + +// ═══════════════════════════════════════════════════════════════════════════════ +// app/api/chat/route.ts (App Router) +// ═══════════════════════════════════════════════════════════════════════════════ +/* +import { NextRequest } from 'next/server'; +import { chat, createStreamResponse, type Model, type Message } from '@/lib/llm-client'; + +export async function POST(req: NextRequest) { + const { messages, model = 'reasoning', stream = false } = await req.json(); + + if (!messages || !Array.isArray(messages)) { + return Response.json({ error: 'messages required' }, { status: 400 }); + } + + if (stream) { + return createStreamResponse({ model, messages }); + } + + const response = await chat({ model, messages }); + return Response.json(response); +} +*/ + +// ═══════════════════════════════════════════════════════════════════════════════ +// app/api/metrics/route.ts (App Router - Grafana proxy) +// ═══════════════════════════════════════════════════════════════════════════════ +/* +import { NextRequest } from 'next/server'; +import { getLLMMetrics, getDashboard } from '@/lib/grafana-client'; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const type = searchParams.get('type') || 'metrics'; + + try { + if (type === 'dashboard') { + const dashboard = await getDashboard('llm-frontend'); + return Response.json(dashboard); + } + + const metrics = await getLLMMetrics(); + return Response.json(metrics); + } catch (err) { + return Response.json( + { error: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 } + ); + } +} +*/ + +export {}; diff --git a/integrations/nextjs/grafana-client.ts b/integrations/nextjs/grafana-client.ts new file mode 100644 index 0000000..0f51153 --- /dev/null +++ b/integrations/nextjs/grafana-client.ts @@ -0,0 +1,180 @@ +/** + * Grafana Dashboard API Client + * + * Fetches dashboard data and panel snapshots from Grafana. + * Requires service account token with Viewer role. + * + * Setup: + * 1. Grafana UI > Administration > Service Accounts > Add + * 2. Create token, set GRAFANA_API_TOKEN env var + * 3. Or use Grafana API directly: POST /api/serviceaccounts + */ + +const GRAFANA_URL = process.env.GRAFANA_URL || 'https://grafana.riotpiao.com'; +const GRAFANA_API_TOKEN = process.env.GRAFANA_API_TOKEN || ''; + +interface GrafanaHeaders { + 'Content-Type': string; + Authorization?: string; +} + +function headers(): GrafanaHeaders { + const h: GrafanaHeaders = { 'Content-Type': 'application/json' }; + if (GRAFANA_API_TOKEN) { + h.Authorization = `Bearer ${GRAFANA_API_TOKEN}`; + } + return h; +} + +export interface Dashboard { + uid: string; + title: string; + panels: Panel[]; +} + +export interface Panel { + id: number; + title: string; + type: string; + gridPos: { h: number; w: number; x: number; y: number }; +} + +export interface DashboardMeta { + uid: string; + title: string; + url: string; + slug: string; +} + +/** + * Get dashboard by UID + */ +export async function getDashboard(uid: string): Promise { + const response = await fetch(`${GRAFANA_URL}/api/dashboards/uid/${uid}`, { + headers: headers(), + }); + + if (!response.ok) { + throw new Error(`Grafana API error: ${response.status} ${await response.text()}`); + } + + const data = await response.json(); + return data.dashboard; +} + +/** + * List all dashboards + */ +export async function listDashboards(): Promise { + const response = await fetch(`${GRAFANA_URL}/api/search?type=dash-db`, { + headers: headers(), + }); + + if (!response.ok) { + throw new Error(`Grafana API error: ${response.status}`); + } + + return response.json(); +} + +/** + * Query Prometheus datasource directly via Grafana proxy + * Bypasses need for direct Prometheus access + */ +export async function queryPrometheus( + query: string, + start?: number, + end?: number, + step?: number +): Promise { + const params = new URLSearchParams({ + query, + start: String(start || Math.floor(Date.now() / 1000) - 3600), + end: String(end || Math.floor(Date.now() / 1000)), + step: String(step || 60), + }); + + const response = await fetch( + `${GRAFANA_URL}/api/datasources/proxy/uid/prometheus/api/v1/query_range?${params}`, + { headers: headers() } + ); + + if (!response.ok) { + throw new Error(`Prometheus query error: ${response.status}`); + } + + return response.json(); +} + +export interface PrometheusResult { + status: string; + data: { + resultType: string; + result: { + metric: Record; + values: [number, string][]; + }[]; + }; +} + +/** + * Get LLM-specific metrics + */ +export async function getLLMMetrics(): Promise { + const [podsReady, cpuUsage, memoryUsage] = await Promise.all([ + queryPrometheus('sum(kube_pod_status_ready{namespace="llm-serving",condition="true"})'), + queryPrometheus('sum(rate(container_cpu_usage_seconds_total{namespace="llm-serving"}[5m])) by (pod)'), + queryPrometheus('sum(container_memory_working_set_bytes{namespace="llm-serving"}) by (pod)'), + ]); + + return { + podsReady: extractLatestValue(podsReady), + cpuByPod: extractSeriesLatest(cpuUsage), + memoryByPod: extractSeriesLatest(memoryUsage), + }; +} + +export interface LLMMetrics { + podsReady: number; + cpuByPod: Record; + memoryByPod: Record; +} + +function extractLatestValue(result: PrometheusResult): number { + const values = result.data?.result?.[0]?.values; + if (!values || values.length === 0) return 0; + return parseFloat(values[values.length - 1][1]); +} + +function extractSeriesLatest(result: PrometheusResult): Record { + const out: Record = {}; + for (const series of result.data?.result || []) { + const pod = series.metric.pod || 'unknown'; + const values = series.values; + if (values && values.length > 0) { + out[pod] = parseFloat(values[values.length - 1][1]); + } + } + return out; +} + +/** + * Generate iframe embed URL for a panel + * Requires Grafana security.allow_embedding=true + */ +export function getPanelEmbedUrl( + dashboardUid: string, + panelId: number, + from = 'now-6h', + to = 'now', + refresh = '30s' +): string { + return `${GRAFANA_URL}/d-solo/${dashboardUid}?panelId=${panelId}&from=${from}&to=${to}&refresh=${refresh}`; +} + +/** + * Generate full dashboard URL + */ +export function getDashboardUrl(dashboardUid: string): string { + return `${GRAFANA_URL}/d/${dashboardUid}`; +} diff --git a/integrations/nextjs/hooks.ts b/integrations/nextjs/hooks.ts new file mode 100644 index 0000000..1ff24a3 --- /dev/null +++ b/integrations/nextjs/hooks.ts @@ -0,0 +1,193 @@ +/** + * React Hooks for LLM & Grafana Integration + * + * Usage: + * import { useChat, useLLMMetrics } from '@/lib/hooks'; + * + * function ChatComponent() { + * const { messages, send, isLoading } = useChat(); + * // ... + * } + */ + +import { useState, useCallback, useRef, useEffect } from 'react'; + +export interface Message { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export interface UseChatOptions { + model?: 'reasoning' | 'ornith:35b' | 'qwen2.5:3b-instruct'; + systemPrompt?: string; + onError?: (error: Error) => void; +} + +export interface UseChatReturn { + messages: Message[]; + send: (content: string) => Promise; + isLoading: boolean; + error: Error | null; + clear: () => void; +} + +/** + * Chat hook with streaming support + */ +export function useChat(options: UseChatOptions = {}): UseChatReturn { + const { model = 'reasoning', systemPrompt, onError } = options; + + const [messages, setMessages] = useState( + systemPrompt ? [{ role: 'system', content: systemPrompt }] : [] + ); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const send = useCallback( + async (content: string) => { + const userMessage: Message = { role: 'user', content }; + setMessages((prev) => [...prev, userMessage]); + setIsLoading(true); + setError(null); + + abortRef.current = new AbortController(); + + try { + const response = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model, + messages: [...messages, userMessage], + stream: true, + }), + signal: abortRef.current.signal, + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const reader = response.body?.getReader(); + if (!reader) throw new Error('No response body'); + + const decoder = new TextDecoder(); + let assistantContent = ''; + + // Add placeholder assistant message + setMessages((prev) => [...prev, { role: 'assistant', content: '' }]); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const text = decoder.decode(value, { stream: true }); + const lines = text.split('\n').filter((line) => line.startsWith('data: ')); + + for (const line of lines) { + const data = line.slice(6); + if (data === '[DONE]') break; + + try { + const parsed = JSON.parse(data); + if (parsed.content) { + assistantContent += parsed.content; + setMessages((prev) => { + const updated = [...prev]; + updated[updated.length - 1] = { + role: 'assistant', + content: assistantContent, + }; + return updated; + }); + } + } catch { + // Skip malformed chunks + } + } + } + } catch (err) { + if (err instanceof Error && err.name !== 'AbortError') { + setError(err); + onError?.(err); + } + } finally { + setIsLoading(false); + abortRef.current = null; + } + }, + [messages, model, onError] + ); + + const clear = useCallback(() => { + abortRef.current?.abort(); + setMessages(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []); + setError(null); + }, [systemPrompt]); + + return { messages, send, isLoading, error, clear }; +} + +export interface LLMMetrics { + podsReady: number; + cpuByPod: Record; + memoryByPod: Record; +} + +/** + * Metrics hook with auto-refresh + */ +export function useLLMMetrics(refreshInterval = 30000) { + const [metrics, setMetrics] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchMetrics = useCallback(async () => { + try { + const response = await fetch('/api/metrics'); + if (!response.ok) throw new Error(`API error: ${response.status}`); + const data = await response.json(); + setMetrics(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err : new Error('Unknown error')); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchMetrics(); + const interval = setInterval(fetchMetrics, refreshInterval); + return () => clearInterval(interval); + }, [fetchMetrics, refreshInterval]); + + return { metrics, isLoading, error, refresh: fetchMetrics }; +} + +/** + * Grafana iframe embed component props generator + */ +export function useGrafanaEmbed(dashboardUid: string, panelId?: number) { + const baseUrl = process.env.NEXT_PUBLIC_GRAFANA_URL || 'https://grafana.riotpiao.com'; + + const dashboardUrl = `${baseUrl}/d/${dashboardUid}`; + const embedUrl = panelId + ? `${baseUrl}/d-solo/${dashboardUid}?panelId=${panelId}&from=now-6h&to=now&refresh=30s` + : null; + + return { + dashboardUrl, + embedUrl, + iframeProps: embedUrl + ? { + src: embedUrl, + width: '100%', + height: '400', + frameBorder: 0, + style: { border: 'none' }, + } + : null, + }; +} diff --git a/integrations/nextjs/index.ts b/integrations/nextjs/index.ts new file mode 100644 index 0000000..b18cab1 --- /dev/null +++ b/integrations/nextjs/index.ts @@ -0,0 +1,114 @@ +/** + * NextJS Integration for Homelab LLM & Grafana + * + * ═══════════════════════════════════════════════════════════════════════════════ + * SETUP + * ═══════════════════════════════════════════════════════════════════════════════ + * + * 1. Copy files to your NextJS project: + * cp integrations/nextjs/*.ts your-nextjs-app/lib/ + * + * 2. Environment variables (.env.local): + * LLM_BASE_URL=https://api.riotpiao.com/v1 + * GRAFANA_URL=https://grafana.riotpiao.com + * GRAFANA_API_TOKEN= + * NEXT_PUBLIC_GRAFANA_URL=https://grafana.riotpiao.com + * + * 3. Create Grafana service account (for API access): + * - Grafana UI > Administration > Service Accounts + * - Create account with Viewer role + * - Generate token, save to GRAFANA_API_TOKEN + * + * 4. Copy API routes (see api-routes.ts for templates): + * - app/api/chat/route.ts (LLM chat endpoint) + * - app/api/metrics/route.ts (Grafana metrics proxy) + * + * ═══════════════════════════════════════════════════════════════════════════════ + * USAGE EXAMPLES + * ═══════════════════════════════════════════════════════════════════════════════ + * + * Server-side (API routes, server components): + * + * import { chat, streamChat } from '@/lib/llm-client'; + * import { getLLMMetrics, getDashboard } from '@/lib/grafana-client'; + * + * // Non-streaming chat + * const response = await chat({ + * model: 'reasoning', + * messages: [{ role: 'user', content: 'Hello' }], + * }); + * + * // Streaming chat + * for await (const chunk of streamChat({ model: 'reasoning', messages })) { + * process.stdout.write(chunk); + * } + * + * // Get LLM pod metrics + * const metrics = await getLLMMetrics(); + * console.log(`Pods ready: ${metrics.podsReady}`); + * + * Client-side (React components): + * + * import { useChat, useLLMMetrics, useGrafanaEmbed } from '@/lib/hooks'; + * + * function ChatUI() { + * const { messages, send, isLoading } = useChat({ model: 'reasoning' }); + * + * return ( + *
+ * {messages.map((m, i) =>

{m.role}: {m.content}

)} + * + *
+ * ); + * } + * + * function MetricsDashboard() { + * const { metrics, isLoading } = useLLMMetrics(30000); + * const { iframeProps } = useGrafanaEmbed('llm-frontend', 2); + * + * if (isLoading) return

Loading...

; + * + * return ( + *
+ *

Pods ready: {metrics?.podsReady}

+ * {iframeProps &&