From 8e52b672330779972b0efeb47eca680506e8d459 Mon Sep 17 00:00:00 2001 From: singret <100959986+singret@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:02:30 +0000 Subject: [PATCH 1/5] [NER-41] feat: Neuri Analysis panel and Run Investigation button on incident detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NeuriPanel component inline in the activity tab (below AI Summary), following the same pattern as AISummaryPanel: - Checks GET /api/v1/settings/neuri on mount; shows "not configured" message if webhook_url is empty - Loads any existing result immediately on mount (no button click needed if investigation already ran) - "Run Investigation" button → POST /api/v1/neuri/investigate (proxy, secret stays server-side); shows spinner while triggering - Polls GET /api/v1/neuri/result?incident_id= every 5s until result arrives; times out after 90s with actionable error message - Result card: top hypothesis badge (colour-coded by type), confidence %, summary text, ranked hypotheses with progress bars; collapse/expand when more than 3 hypotheses - frontend/src/api/neuri.ts: getNeuriSettings, triggerNeuriInvestigation, getNeuriResults - Neuri types added to api/types.ts: NeuriSettingsResponse, NeuriResult, RankedHypothesis, NeuriResultListResponse, NeuriTriggerResponse Closes NER-41 --- frontend/src/api/neuri.ts | 14 + frontend/src/api/types.ts | 32 +++ .../src/components/incidents/NeuriPanel.tsx | 247 ++++++++++++++++++ frontend/src/pages/IncidentDetailPage.tsx | 2 + 4 files changed, 295 insertions(+) create mode 100644 frontend/src/api/neuri.ts create mode 100644 frontend/src/components/incidents/NeuriPanel.tsx diff --git a/frontend/src/api/neuri.ts b/frontend/src/api/neuri.ts new file mode 100644 index 00000000..bda4a5fe --- /dev/null +++ b/frontend/src/api/neuri.ts @@ -0,0 +1,14 @@ +import { apiClient } from './client' +import type { NeuriSettingsResponse, NeuriResultListResponse, NeuriTriggerResponse } from './types' + +export async function getNeuriSettings(): Promise { + return apiClient.get('/api/v1/settings/neuri') +} + +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 a6c15902..7234c9b3 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -52,6 +52,38 @@ 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 RankedHypothesis { + type: string + confidence: 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 00000000..5b7bc204 --- /dev/null +++ b/frontend/src/components/incidents/NeuriPanel.tsx @@ -0,0 +1,247 @@ +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 +} + +export function NeuriPanel({ incidentId }: 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) => { if (r.results.length > 0) setResult(r.results[0] ?? null) }) + .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) { + setResult(r.results[0] ?? null) + 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. +
+ )} + + {/* 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.confidence * 100)}% + + {hypothesisLabel(h.type)} + +
+ ))} + {ranked.length > 3 && ( + + )} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/IncidentDetailPage.tsx b/frontend/src/pages/IncidentDetailPage.tsx index dca4d35e..b8344a9a 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' @@ -238,6 +239,7 @@ export function IncidentDetailPage() { lastActivityAt={lastActivityAt} onSummaryGenerated={refetch} /> + Date: Wed, 1 Jul 2026 08:19:18 +0000 Subject: [PATCH 2/5] feat(settings): add Neuri configuration section to System Settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admins can now configure Neuri directly from Settings → System without using curl. Fields: webhook URL, Regen base URL, webhook secret (masked, show/hide toggle). Configured state shows the secret hint and a Remove button. Secret field is optional on update — leave blank to keep the existing secret. --- frontend/src/api/neuri.ts | 6 +- frontend/src/api/types.ts | 6 + frontend/src/pages/SystemSettingsPage.tsx | 142 +++++++++++++++++++++- 3 files changed, 152 insertions(+), 2 deletions(-) diff --git a/frontend/src/api/neuri.ts b/frontend/src/api/neuri.ts index bda4a5fe..49d349f7 100644 --- a/frontend/src/api/neuri.ts +++ b/frontend/src/api/neuri.ts @@ -1,10 +1,14 @@ import { apiClient } from './client' -import type { NeuriSettingsResponse, NeuriResultListResponse, NeuriTriggerResponse } from './types' +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 }) } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 7234c9b3..41275c99 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -60,6 +60,12 @@ export interface NeuriSettingsResponse { webhook_secret_hint?: string } +export interface NeuriSettingsRequest { + webhook_url?: string + regen_base_url?: string + webhook_secret?: string +} + export interface RankedHypothesis { type: string confidence: number diff --git a/frontend/src/pages/SystemSettingsPage.tsx b/frontend/src/pages/SystemSettingsPage.tsx index 484ddf90..dc2639a3 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 ────────────────────────────────────────────────────── */}
From 8b32e547d36a88126f862c6c9428dea9d523345c Mon Sep 17 00:00:00 2001 From: singret <100959986+singret@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:55:04 +0000 Subject: [PATCH 3/5] fix(neuri): accept null top_hypothesis and zero confidence in result DTO Remove binding:"required" from TopHypothesis (can be null when coverage_complete=false) and drop required from Confidence (0.0 is valid when Neuri cannot determine a root cause). Both fields failing validation caused every partial investigation callback to return 400, leaving the UI stuck on the 90s timeout. Co-Authored-By: Claude Sonnet 4.6 --- backend/internal/api/handlers/dto/neuri_result.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/internal/api/handlers/dto/neuri_result.go b/backend/internal/api/handlers/dto/neuri_result.go index d5170956..8877da6f 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"` } From 3220d2bf38d6e7b04bfe3f2d2a311ab729376a5d Mon Sep 17 00:00:00 2001 From: singret <100959986+singret@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:07:22 +0000 Subject: [PATCH 4/5] fix(neuri): correct webhook payload key, propagate incident labels, restructure investigation UI [NER-41] Two integration bugs against Neuri's real API, found via live testing: - Trigger payload used incident_id, but Neuri's webhook expects id. - affected_services was always sent empty; incident labels were never populated on the incident model, so there was no data to derive it from. Frontend: RankedHypothesis.confidence renamed to score, matching Neuri's actual API field name. NeuriPanel moves out of a persistent Activity-tab strip into its own "Investigation" tab, with a compact NeuriActivityCard summary left on the Activity tab linking into it. --- backend/internal/api/handlers/neuri.go | 19 ++++--- backend/internal/services/incident_service.go | 7 ++- frontend/src/api/types.ts | 2 +- .../src/components/incidents/NeuriPanel.tsx | 26 +++++++--- frontend/src/pages/IncidentDetailPage.tsx | 50 +++++++++++++++++-- frontend/vite.config.ts | 1 + 6 files changed, 85 insertions(+), 20 deletions(-) diff --git a/backend/internal/api/handlers/neuri.go b/backend/internal/api/handlers/neuri.go index 5761a807..bd0fc0df 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 caf94e89..4864d7fa 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/types.ts b/frontend/src/api/types.ts index 41275c99..c569524e 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -68,7 +68,7 @@ export interface NeuriSettingsRequest { export interface RankedHypothesis { type: string - confidence: number + score: number } export interface NeuriResult { diff --git a/frontend/src/components/incidents/NeuriPanel.tsx b/frontend/src/components/incidents/NeuriPanel.tsx index 5b7bc204..21ec8b40 100644 --- a/frontend/src/components/incidents/NeuriPanel.tsx +++ b/frontend/src/components/incidents/NeuriPanel.tsx @@ -28,9 +28,10 @@ function hypothesisBadge(type: string): string { interface NeuriPanelProps { incidentId: string + onResultLoaded?: (result: NeuriResult | null) => void } -export function NeuriPanel({ incidentId }: NeuriPanelProps) { +export function NeuriPanel({ incidentId, onResultLoaded }: NeuriPanelProps) { const [configured, setConfigured] = useState(null) const [result, setResult] = useState(null) const [triggering, setTriggering] = useState(false) @@ -49,7 +50,11 @@ export function NeuriPanel({ incidentId }: NeuriPanelProps) { .catch(() => setConfigured(false)) getNeuriResults(incidentId) - .then((r) => { if (r.results.length > 0) setResult(r.results[0] ?? null) }) + .then((r) => { + const latest = r.results[0] ?? null + setResult(latest) + onResultLoaded?.(latest) + }) .catch(() => {}) }, [incidentId]) @@ -67,7 +72,9 @@ export function NeuriPanel({ incidentId }: NeuriPanelProps) { try { const r = await getNeuriResults(incidentId) if (r.results.length > 0) { - setResult(r.results[0] ?? null) + const latest = r.results[0] ?? null + setResult(latest) + onResultLoaded?.(latest) stopPolling() } } catch { @@ -101,7 +108,7 @@ export function NeuriPanel({ incidentId }: NeuriPanelProps) { if (configured === null) return null return ( -
+
{/* Header row */}
@@ -131,9 +138,12 @@ export function NeuriPanel({ incidentId }: NeuriPanelProps) { )}
@@ -220,10 +230,10 @@ function ResultCard({
- {Math.round(h.confidence * 100)}% + {Math.round(h.score * 100)}% {hypothesisLabel(h.type)} diff --git a/frontend/src/pages/IncidentDetailPage.tsx b/frontend/src/pages/IncidentDetailPage.tsx index b8344a9a..c96f3151 100644 --- a/frontend/src/pages/IncidentDetailPage.tsx +++ b/frontend/src/pages/IncidentDetailPage.tsx @@ -17,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)', @@ -48,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) @@ -74,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) @@ -221,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} />
@@ -231,7 +241,6 @@ export function IncidentDetailPage() {
{activeTab === 'activity' && (
- {/* AI Summary at the top of the activity feed */} - + {neuriResult && setActiveTab('investigation')} />}
)} + {activeTab === 'investigation' && ( + + )} {activeTab === 'alerts' && ( )} @@ -307,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/vite.config.ts b/frontend/vite.config.ts index 13c9f04c..b8d5644b 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', From f8ad97a1ad0756db10569ce8144d6710c484715d Mon Sep 17 00:00:00 2001 From: singret <100959986+singret@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:12:07 +0000 Subject: [PATCH 5/5] feat(neuri): add fluidify.ai/neuri Pilot link to not-configured state [NER-41] Surfaced only when Neuri isn't configured yet -- the moment someone encounters the feature without having set it up, and the natural place for a "learn more / join the pilot" CTA. --- frontend/src/components/incidents/NeuriPanel.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/incidents/NeuriPanel.tsx b/frontend/src/components/incidents/NeuriPanel.tsx index 21ec8b40..38bfc9de 100644 --- a/frontend/src/components/incidents/NeuriPanel.tsx +++ b/frontend/src/components/incidents/NeuriPanel.tsx @@ -152,7 +152,17 @@ export function NeuriPanel({ incidentId, onResultLoaded }: NeuriPanelProps) { {!configured && (
- Neuri not configured. Set the webhook URL in Settings → System → Neuri. + + Neuri not configured. Set the webhook URL in Settings → System → Neuri, or{' '} + + learn more about the Neuri Pilot + . +
)}