feat(integrations): add NextJS LLM/Grafana integration + enable dashboard embedding
This commit is contained in:
@@ -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 {};
|
||||||
@@ -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<Dashboard> {
|
||||||
|
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<DashboardMeta[]> {
|
||||||
|
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<PrometheusResult> {
|
||||||
|
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<string, string>;
|
||||||
|
values: [number, string][];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get LLM-specific metrics
|
||||||
|
*/
|
||||||
|
export async function getLLMMetrics(): Promise<LLMMetrics> {
|
||||||
|
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<string, number>;
|
||||||
|
memoryByPod: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, number> {
|
||||||
|
const out: Record<string, number> = {};
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
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<Message[]>(
|
||||||
|
systemPrompt ? [{ role: 'system', content: systemPrompt }] : []
|
||||||
|
);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(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<string, number>;
|
||||||
|
memoryByPod: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metrics hook with auto-refresh
|
||||||
|
*/
|
||||||
|
export function useLLMMetrics(refreshInterval = 30000) {
|
||||||
|
const [metrics, setMetrics] = useState<LLMMetrics | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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=<service-account-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 (
|
||||||
|
* <div>
|
||||||
|
* {messages.map((m, i) => <p key={i}>{m.role}: {m.content}</p>)}
|
||||||
|
* <button onClick={() => send('Hello')} disabled={isLoading}>
|
||||||
|
* Send
|
||||||
|
* </button>
|
||||||
|
* </div>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* function MetricsDashboard() {
|
||||||
|
* const { metrics, isLoading } = useLLMMetrics(30000);
|
||||||
|
* const { iframeProps } = useGrafanaEmbed('llm-frontend', 2);
|
||||||
|
*
|
||||||
|
* if (isLoading) return <p>Loading...</p>;
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <div>
|
||||||
|
* <p>Pods ready: {metrics?.podsReady}</p>
|
||||||
|
* {iframeProps && <iframe {...iframeProps} />}
|
||||||
|
* </div>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
* AVAILABLE MODELS
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
*
|
||||||
|
* | model | Engine | Notes |
|
||||||
|
* |--------------------|---------|--------------------------------|
|
||||||
|
* | reasoning | vLLM | Qwen3-32B, best for complex |
|
||||||
|
* | ornith:35b | Ollama | General purpose |
|
||||||
|
* | qwen2.5:3b-instruct| Ollama | Fast, smaller tasks |
|
||||||
|
*
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
* GRAFANA EMBEDDING (optional, requires config change)
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
*
|
||||||
|
* To enable iframe embedding, add to grafana.ini:
|
||||||
|
* security:
|
||||||
|
* allow_embedding: true
|
||||||
|
*
|
||||||
|
* Or via Helm values (k8s/infra/logging/grafana-values.yaml):
|
||||||
|
* grafana.ini:
|
||||||
|
* security:
|
||||||
|
* allow_embedding: true
|
||||||
|
*
|
||||||
|
* Panel embed URLs follow pattern:
|
||||||
|
* https://grafana.riotpiao.com/d-solo/llm-frontend?panelId=2&from=now-6h&to=now
|
||||||
|
*
|
||||||
|
* Dashboard UID for LLM: llm-frontend
|
||||||
|
* Panel IDs: 2 (pods ready), 11 (CPU), 12 (Memory), 13 (Restarts)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export all modules
|
||||||
|
export * from './llm-client';
|
||||||
|
export * from './grafana-client';
|
||||||
|
export * from './hooks';
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* LLM Client for api.riotpiao.com
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { chat, streamChat } from './llm-client';
|
||||||
|
*
|
||||||
|
* // Non-streaming
|
||||||
|
* const response = await chat({ model: 'reasoning', messages: [...] });
|
||||||
|
*
|
||||||
|
* // Streaming
|
||||||
|
* for await (const chunk of streamChat({ model: 'reasoning', messages: [...] })) {
|
||||||
|
* process.stdout.write(chunk);
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Model = 'reasoning' | 'ornith:35b' | 'qwen2.5:3b-instruct';
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
role: 'system' | 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatRequest {
|
||||||
|
model: Model;
|
||||||
|
messages: Message[];
|
||||||
|
max_tokens?: number;
|
||||||
|
temperature?: number;
|
||||||
|
stream?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatResponse {
|
||||||
|
id: string;
|
||||||
|
object: string;
|
||||||
|
created: number;
|
||||||
|
model: string;
|
||||||
|
choices: {
|
||||||
|
index: number;
|
||||||
|
message: Message;
|
||||||
|
finish_reason: string;
|
||||||
|
}[];
|
||||||
|
usage?: {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const LLM_BASE_URL = process.env.LLM_BASE_URL || 'https://api.riotpiao.com/v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-streaming chat completion
|
||||||
|
*/
|
||||||
|
export async function chat(request: ChatRequest): Promise<ChatResponse> {
|
||||||
|
const response = await fetch(`${LLM_BASE_URL}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...request, stream: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`LLM API error: ${response.status} ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming chat completion - yields content chunks
|
||||||
|
*/
|
||||||
|
export async function* streamChat(request: ChatRequest): AsyncGenerator<string> {
|
||||||
|
const response = await fetch(`${LLM_BASE_URL}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...request, stream: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`LLM API error: ${response.status} ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) throw new Error('No response body');
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = line.slice(6);
|
||||||
|
if (data === '[DONE]') return;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
const content = parsed.choices?.[0]?.delta?.content;
|
||||||
|
if (content) yield content;
|
||||||
|
} catch {
|
||||||
|
// Skip malformed chunks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook compatible streaming (returns ReadableStream for Response)
|
||||||
|
*/
|
||||||
|
export function createStreamResponse(request: ChatRequest): Response {
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
try {
|
||||||
|
for await (const chunk of streamChat(request)) {
|
||||||
|
controller.enqueue(new TextEncoder().encode(chunk));
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
} catch (err) {
|
||||||
|
controller.error(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -44,6 +44,10 @@ grafana.ini:
|
|||||||
server:
|
server:
|
||||||
root_url: https://grafana.riotpiao.com
|
root_url: https://grafana.riotpiao.com
|
||||||
|
|
||||||
|
# Allow embedding dashboards in iframes (NextJS integration)
|
||||||
|
security:
|
||||||
|
allow_embedding: true
|
||||||
|
|
||||||
# No anonymous read access — every user must log in via Authentik SSO.
|
# No anonymous read access — every user must log in via Authentik SSO.
|
||||||
auth.anonymous:
|
auth.anonymous:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
Reference in New Issue
Block a user