diff --git a/backend/internal/api/handlers/dto/neuri_result.go b/backend/internal/api/handlers/dto/neuri_result.go index d517095..8877da6 100644 --- a/backend/internal/api/handlers/dto/neuri_result.go +++ b/backend/internal/api/handlers/dto/neuri_result.go @@ -5,8 +5,8 @@ import "encoding/json" type NeuriResultRequest struct { IncidentID string `json:"incident_id" binding:"required"` InvestigationRunID string `json:"investigation_run_id" binding:"required"` - TopHypothesis string `json:"top_hypothesis" binding:"required"` - Confidence float64 `json:"confidence" binding:"required,min=0,max=1"` + TopHypothesis string `json:"top_hypothesis"` + Confidence float64 `json:"confidence" binding:"min=0,max=1"` Summary string `json:"summary" binding:"required"` RankedHypotheses json.RawMessage `json:"ranked_hypotheses"` } diff --git a/backend/internal/api/handlers/neuri.go b/backend/internal/api/handlers/neuri.go index 5761a80..bd0fc0d 100644 --- a/backend/internal/api/handlers/neuri.go +++ b/backend/internal/api/handlers/neuri.go @@ -154,13 +154,20 @@ func TriggerNeuriInvestigation( callbackURL := regenBaseURL + "/api/v1/neuri/result" + affectedServices := []string{} + if svc, ok := incident.Labels["service"]; ok { + if svcStr, ok := svc.(string); ok && svcStr != "" { + affectedServices = []string{svcStr} + } + } + payload := map[string]interface{}{ - "incident_id": incident.ID.String(), - "title": incident.Title, - "started_at": incident.TriggeredAt.UTC().Format(time.RFC3339), - "severity": incident.Severity, - "affected_services": []string{}, - "callback_url": callbackURL, + "id": incident.ID.String(), + "title": incident.Title, + "started_at": incident.TriggeredAt.UTC().Format(time.RFC3339), + "severity": incident.Severity, + "affected_services": affectedServices, + "callback_url": callbackURL, } body, _ := json.Marshal(payload) diff --git a/backend/internal/services/incident_service.go b/backend/internal/services/incident_service.go index caf94e8..4864d7f 100644 --- a/backend/internal/services/incident_service.go +++ b/backend/internal/services/incident_service.go @@ -271,6 +271,11 @@ func (s *incidentService) CreateIncidentFromAlert(alert *models.Alert, aiEnabled slug := generateSlug(alert.Title) // Create incident object + incidentLabels := make(models.JSONB) + for k, v := range alert.Labels { + incidentLabels[k] = v + } + incident := &models.Incident{ ID: uuid.New(), Title: alert.Title, @@ -281,7 +286,7 @@ func (s *incidentService) CreateIncidentFromAlert(alert *models.Alert, aiEnabled CreatedByType: "system", CreatedByID: "alertmanager", TriggeredAt: time.Now(), - Labels: make(models.JSONB), + Labels: incidentLabels, CustomFields: make(models.JSONB), AIEnabled: aiEnabled, } diff --git a/frontend/src/api/neuri.ts b/frontend/src/api/neuri.ts new file mode 100644 index 0000000..49d349f --- /dev/null +++ b/frontend/src/api/neuri.ts @@ -0,0 +1,18 @@ +import { apiClient } from './client' +import type { NeuriSettingsResponse, NeuriSettingsRequest, NeuriResultListResponse, NeuriTriggerResponse } from './types' + +export async function getNeuriSettings(): Promise { + return apiClient.get('/api/v1/settings/neuri') +} + +export async function updateNeuriSettings(req: NeuriSettingsRequest): Promise { + return apiClient.patch('/api/v1/settings/neuri', req) +} + +export async function triggerNeuriInvestigation(incidentId: string): Promise { + return apiClient.post('/api/v1/neuri/investigate', { incident_id: incidentId }) +} + +export async function getNeuriResults(incidentId: string): Promise { + return apiClient.get(`/api/v1/neuri/result?incident_id=${incidentId}`) +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index a6c1590..c569524 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -52,6 +52,44 @@ export interface AISettingsResponse { enabled: boolean } +// Neuri AI analysis types (NER-41) +export interface NeuriSettingsResponse { + webhook_url: string + regen_base_url: string + webhook_secret_set: boolean + webhook_secret_hint?: string +} + +export interface NeuriSettingsRequest { + webhook_url?: string + regen_base_url?: string + webhook_secret?: string +} + +export interface RankedHypothesis { + type: string + score: number +} + +export interface NeuriResult { + id: string + incident_id: string + investigation_run_id: string + top_hypothesis: string + confidence: number + summary: string + ranked_hypotheses: RankedHypothesis[] + created_at: string +} + +export interface NeuriResultListResponse { + results: NeuriResult[] +} + +export interface NeuriTriggerResponse { + status: string +} + export interface TeamsSettingsResponse { enabled: boolean } diff --git a/frontend/src/components/incidents/NeuriPanel.tsx b/frontend/src/components/incidents/NeuriPanel.tsx new file mode 100644 index 0000000..38bfc9d --- /dev/null +++ b/frontend/src/components/incidents/NeuriPanel.tsx @@ -0,0 +1,267 @@ +import { useState, useEffect, useRef } from 'react' +import { Microscope, AlertCircle, Loader2, ChevronDown, ChevronUp } from 'lucide-react' +import { getNeuriSettings, triggerNeuriInvestigation, getNeuriResults } from '../../api/neuri' +import type { NeuriResult, RankedHypothesis } from '../../api/types' + +const POLL_INTERVAL_MS = 5000 +const TIMEOUT_MS = 90000 + +const HYPOTHESIS_COLORS: Record = { + CODE_CHANGE: 'bg-blue-100 text-blue-800', + CONFIG_CHANGE: 'bg-orange-100 text-orange-800', + DEPENDENCY_FAILURE: 'bg-red-100 text-red-800', + RESOURCE_EXHAUSTION: 'bg-amber-100 text-amber-800', + TRAFFIC_ANOMALY: 'bg-purple-100 text-purple-800', + INFRASTRUCTURE_FAILURE: 'bg-red-100 text-red-800', + DATA_ISSUE: 'bg-teal-100 text-teal-800', + CAPACITY_LIMIT: 'bg-orange-100 text-orange-800', + UNKNOWN: 'bg-gray-100 text-gray-600', +} + +function hypothesisLabel(type: string): string { + return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) +} + +function hypothesisBadge(type: string): string { + return HYPOTHESIS_COLORS[type] ?? 'bg-gray-100 text-gray-600' +} + +interface NeuriPanelProps { + incidentId: string + onResultLoaded?: (result: NeuriResult | null) => void +} + +export function NeuriPanel({ incidentId, onResultLoaded }: NeuriPanelProps) { + const [configured, setConfigured] = useState(null) + const [result, setResult] = useState(null) + const [triggering, setTriggering] = useState(false) + const [polling, setPolling] = useState(false) + const [timedOut, setTimedOut] = useState(false) + const [error, setError] = useState(null) + const [showAllHypotheses, setShowAllHypotheses] = useState(false) + + const pollRef = useRef | null>(null) + const timeoutRef = useRef | null>(null) + + // Check if Neuri is configured and load any existing result on mount + useEffect(() => { + getNeuriSettings() + .then((s) => setConfigured(!!s.webhook_url)) + .catch(() => setConfigured(false)) + + getNeuriResults(incidentId) + .then((r) => { + const latest = r.results[0] ?? null + setResult(latest) + onResultLoaded?.(latest) + }) + .catch(() => {}) + }, [incidentId]) + + function stopPolling() { + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } + if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null } + setPolling(false) + } + + function startPolling() { + setPolling(true) + setTimedOut(false) + + pollRef.current = setInterval(async () => { + try { + const r = await getNeuriResults(incidentId) + if (r.results.length > 0) { + const latest = r.results[0] ?? null + setResult(latest) + onResultLoaded?.(latest) + stopPolling() + } + } catch { + // keep polling — transient errors shouldn't stop us + } + }, POLL_INTERVAL_MS) + + timeoutRef.current = setTimeout(() => { + stopPolling() + setTimedOut(true) + }, TIMEOUT_MS) + } + + useEffect(() => () => stopPolling(), []) + + async function handleTrigger() { + setTriggering(true) + setError(null) + setTimedOut(false) + try { + await triggerNeuriInvestigation(incidentId) + startPolling() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to start investigation') + } finally { + setTriggering(false) + } + } + + // Still loading config — render nothing to avoid layout shift + if (configured === null) return null + + return ( +
+ {/* Header row */} +
+
+ + Neuri Analysis + {polling && ( + + Investigating… + + )} +
+ + {configured && !result && !polling && ( + + )} + + {configured && result && !polling && ( + + )} +
+ + {/* Not configured */} + {!configured && ( +
+ + + Neuri not configured. Set the webhook URL in Settings → System → Neuri, or{' '} + + learn more about the Neuri Pilot + . + +
+ )} + + {/* Error */} + {error && ( +
+ + {error} +
+ )} + + {/* Timeout */} + {timedOut && !result && ( +
+ + Investigation timed out (90s). Neuri may still be running — refresh in a moment or re-run. +
+ )} + + {/* Polling skeleton */} + {polling && !result && ( +
+
+
+
+
+ )} + + {/* Idle — no result yet */} + {configured && !result && !polling && !error && !timedOut && ( +

+ Run an AI root cause investigation on this incident using Neuri. +

+ )} + + {/* Result card */} + {result && setShowAllHypotheses(v => !v)} />} +
+ ) +} + +function ResultCard({ + result, + showAll, + onToggle, +}: { + result: NeuriResult + showAll: boolean + onToggle: () => void +}) { + const ranked: RankedHypothesis[] = Array.isArray(result.ranked_hypotheses) ? result.ranked_hypotheses : [] + const visible = showAll ? ranked : ranked.slice(0, 3) + + return ( +
+ {/* Primary finding */} +
+ + {hypothesisLabel(result.top_hypothesis)} + + + {Math.round(result.confidence * 100)}% confidence + +
+ + {/* Summary */} +

{result.summary}

+ + {/* Ranked list */} + {ranked.length > 0 && ( +
+

All hypotheses

+ {visible.map((h) => ( +
+
+
+
+ {Math.round(h.score * 100)}% + + {hypothesisLabel(h.type)} + +
+ ))} + {ranked.length > 3 && ( + + )} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/IncidentDetailPage.tsx b/frontend/src/pages/IncidentDetailPage.tsx index dca4d35..c96f315 100644 --- a/frontend/src/pages/IncidentDetailPage.tsx +++ b/frontend/src/pages/IncidentDetailPage.tsx @@ -9,6 +9,7 @@ import { SeverityDropdown } from '../components/incidents/SeverityDropdown' import { AddTimelineEntry } from '../components/incidents/AddTimelineEntry' import { GroupedAlerts } from '../components/incidents/GroupedAlerts' import { AISummaryPanel } from '../components/incidents/AISummaryPanel' +import { NeuriPanel } from '../components/incidents/NeuriPanel' import { PostMortemPanel } from '../components/incidents/PostMortemPanel' import { AttachmentsPanel } from '../components/incidents/AttachmentsPanel' import { ToastContainer, useToast } from '../components/ui/Toast' @@ -16,10 +17,12 @@ import { GeneralError } from '../components/ui/ErrorState' import { Button } from '../components/ui/Button' import { Badge } from '../components/ui/Badge' import { listEscalationPolicies } from '../api/escalation' +import { getPostMortem } from '../api/postmortems' +import { getNeuriResults } from '../api/neuri' import { apiClient } from '../api/client' -import type { EscalationPolicy } from '../api/types' +import type { EscalationPolicy, NeuriResult } from '../api/types' -type TabType = 'activity' | 'alerts' | 'postmortem' | 'attachments' +type TabType = 'activity' | 'alerts' | 'investigation' | 'postmortem' | 'attachments' const SEVERITY_TINT: Record = { critical: 'rgba(220,38,38,0.045)', @@ -47,6 +50,7 @@ export function IncidentDetailPage() { const { id } = useParams<{ id: string }>() const [activeTab, setActiveTab] = useState('activity') const [hasPostMortem, setHasPostMortem] = useState(false) + const [neuriResult, setNeuriResult] = useState(null) const [attachmentCount, setAttachmentCount] = useState(0) const { toasts, dismissToast, success, error: showError } = useToast() const [showEscalateModal, setShowEscalateModal] = useState(false) @@ -73,6 +77,12 @@ export function IncidentDetailPage() { listEscalationPolicies().then(r => setEscalatePolicies(r.data)).catch(() => {}) }, []) + useEffect(() => { + if (!id) return + getPostMortem(id).then(pm => setHasPostMortem(!!pm)).catch(() => {}) + getNeuriResults(id).then(r => setNeuriResult(r.results[0] ?? null)).catch(() => {}) + }, [id]) + async function handleEscalate() { if (!selectedPolicyId || !id) return setEscalating(true) @@ -220,6 +230,7 @@ export function IncidentDetailPage() {
setActiveTab('activity')} label="Activity" count={incident.timeline.length} /> setActiveTab('alerts')} label="Alerts" count={incident.alerts.length} /> + setActiveTab('investigation')} label="Neuri" count={neuriResult ? 1 : 0} /> setActiveTab('postmortem')} label="Post-Mortem" count={hasPostMortem ? 1 : 0} /> setActiveTab('attachments')} label="Attachments" count={attachmentCount} />
@@ -230,7 +241,6 @@ export function IncidentDetailPage() {
{activeTab === 'activity' && (
- {/* AI Summary at the top of the activity feed */} + {neuriResult && setActiveTab('investigation')} />}
)} + {activeTab === 'investigation' && ( + + )} {activeTab === 'alerts' && ( )} @@ -305,6 +319,36 @@ export function IncidentDetailPage() { // ── Subcomponents ───────────────────────────────────────────────────────────── +const HYPOTHESIS_COLORS: Record = { + CODE_CHANGE: 'bg-blue-100 text-blue-800', + CONFIG_CHANGE: 'bg-orange-100 text-orange-800', + DEPENDENCY_FAILURE: 'bg-red-100 text-red-800', + RESOURCE_EXHAUSTION: 'bg-amber-100 text-amber-800', + TRAFFIC_ANOMALY: 'bg-purple-100 text-purple-800', + INFRASTRUCTURE_FAILURE: 'bg-red-100 text-red-800', + DATA_ISSUE: 'bg-teal-100 text-teal-800', + CAPACITY_LIMIT: 'bg-orange-100 text-orange-800', + UNKNOWN: 'bg-gray-100 text-gray-600', +} + +function NeuriActivityCard({ result, onViewFull }: { result: NeuriResult; onViewFull: () => void }) { + const label = result.top_hypothesis.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) + const badge = HYPOTHESIS_COLORS[result.top_hypothesis] ?? 'bg-gray-100 text-gray-600' + return ( +
+
+
+ Neuri Investigation + {label} + {Math.round(result.confidence * 100)}% confidence +
+ +
+

{result.summary}

+
+ ) +} + function TabButton({ active, onClick, label, count }: { active: boolean; onClick: () => void; label: string; count: number }) { diff --git a/frontend/src/pages/SystemSettingsPage.tsx b/frontend/src/pages/SystemSettingsPage.tsx index 484ddf9..dc2639a 100644 --- a/frontend/src/pages/SystemSettingsPage.tsx +++ b/frontend/src/pages/SystemSettingsPage.tsx @@ -12,6 +12,7 @@ import { AIProvider, SystemSettings, } from '../api/settings' +import { getNeuriSettings, updateNeuriSettings } from '../api/neuri' const COMMON_TIMEZONES = [ 'UTC', @@ -67,6 +68,16 @@ export function SystemSettingsPage() { const [savingAI, setSavingAI] = useState(false) const [aiSaved, setAISaved] = useState(false) + // Neuri + const [neuriConfigured, setNeuriConfigured] = useState(false) + const [neuriHint, setNeuriHint] = useState('') + const [neuriWebhookURL, setNeuriWebhookURL] = useState('') + const [neuriRegenBaseURL, setNeuriRegenBaseURL] = useState('') + const [neuriSecret, setNeuriSecret] = useState('') + const [showNeuriSecret, setShowNeuriSecret] = useState(false) + const [savingNeuri, setSavingNeuri] = useState(false) + const [neuriSaved, setNeuriSaved] = useState(false) + // Telemetry const [savingTelemetry, setSavingTelemetry] = useState(false) @@ -84,13 +95,17 @@ export function SystemSettingsPage() { async function load() { setLoading(true) try { - const data = await getSystemSettings() + const [data, neuri] = await Promise.all([getSystemSettings(), getNeuriSettings()]) setSettings(data) setInstanceName(data.instance_name || '') setTimezone(data.timezone || 'UTC') setAIProvider((data.ai_provider as AIProvider) || 'openai') setOllamaURL(data.ollama_base_url || '') setOllamaModel(data.ollama_model || '') + setNeuriConfigured(neuri.webhook_secret_set) + setNeuriHint(neuri.webhook_secret_hint ?? '') + setNeuriWebhookURL(neuri.webhook_url || '') + setNeuriRegenBaseURL(neuri.regen_base_url || '') setError('') } catch { setError('Failed to load system settings') @@ -180,6 +195,39 @@ export function SystemSettingsPage() { } } + async function handleSaveNeuri() { + setSavingNeuri(true) + setNeuriSaved(false) + try { + await updateNeuriSettings({ + webhook_url: neuriWebhookURL.trim(), + regen_base_url: neuriRegenBaseURL.trim(), + ...(neuriSecret.trim() ? { webhook_secret: neuriSecret.trim() } : {}), + }) + setNeuriSecret('') + setNeuriSaved(true) + await load() + setTimeout(() => setNeuriSaved(false), 3000) + } catch { + setError('Failed to save Neuri settings') + } finally { + setSavingNeuri(false) + } + } + + async function handleClearNeuri() { + setSavingNeuri(true) + try { + await updateNeuriSettings({ webhook_url: '', regen_base_url: '', webhook_secret: '' }) + setNeuriSecret('') + await load() + } catch { + setError('Failed to remove Neuri configuration') + } finally { + setSavingNeuri(false) + } + } + async function handleToggleTelemetry(enabled: boolean) { setSavingTelemetry(true) try { @@ -426,6 +474,98 @@ export function SystemSettingsPage() {
+ {/* ── Neuri ────────────────────────────────────────────────────────── */} +
+
+

Neuri (AI Root Cause Analysis)

+

+ Connect to a Neuri instance to run automated root-cause investigations on incidents. +

+
+ + {neuriConfigured ? ( +
+
+ + Neuri configured — secret ends in {neuriHint.slice(-4)} +
+ +
+ ) : ( +
+ + Neuri not configured — root-cause investigation is disabled. +
+ )} + +
+
+ + setNeuriWebhookURL(e.target.value)} + placeholder="http://neuri:8001/investigate" + className="w-full px-3 py-2 rounded-lg border border-border-primary bg-surface-secondary text-text-primary text-sm font-mono focus:outline-none focus:ring-2 focus:ring-brand-primary" + /> +

URL of your Neuri service's investigation endpoint.

+
+ +
+ + setNeuriRegenBaseURL(e.target.value)} + placeholder="https://regen.example.com" + className="w-full px-3 py-2 rounded-lg border border-border-primary bg-surface-secondary text-text-primary text-sm font-mono focus:outline-none focus:ring-2 focus:ring-brand-primary" + /> +

+ The URL Neuri uses to POST results back to Regen. Must be reachable from the Neuri service. +

+
+ +
+ +
+ setNeuriSecret(e.target.value)} + placeholder={neuriConfigured ? `Current: ${neuriHint}` : 'Shared secret for signing requests'} + className="w-full px-3 py-2 pr-10 rounded-lg border border-border-primary bg-surface-secondary text-text-primary text-sm font-mono focus:outline-none focus:ring-2 focus:ring-brand-primary" + /> + +
+

+ {neuriConfigured ? 'Leave blank to keep the current secret.' : 'Set the same value in your Neuri config.'} +

+
+
+ +
+ + {neuriSaved && ( + + Saved + + )} +
+
+ {/* ── Telemetry ────────────────────────────────────────────────────── */}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 13c9f04..b8d5644 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ plugins: [react()], server: { port: 3000, + allowedHosts: 'all', proxy: { '/api': { target: process.env.VITE_API_URL || 'http://localhost:8080',