diff --git a/frontend/src/features/home/components/CardHeader.tsx b/frontend/src/features/home/components/CardHeader.tsx
new file mode 100644
index 000000000..73618f791
--- /dev/null
+++ b/frontend/src/features/home/components/CardHeader.tsx
@@ -0,0 +1,28 @@
+import { Link } from 'react-router-dom'
+import { type LucideIcon } from 'lucide-react'
+
+export function CardHeader({
+ title,
+ icon: Icon,
+ iconClass,
+ action,
+}: {
+ title: string
+ icon?: LucideIcon
+ iconClass?: string
+ action?: { label: string; href: string }
+}) {
+ return (
+
+
+
+ {isLoading ? (
+
+ ) : items.length === 0 ? (
+
+ ) : (
+ <>
+
+ {items.map((c) => (
+
+
{c.score}%
+
= 90
+ ? 'bg-gradient-to-t from-emerald-600 to-emerald-400'
+ : c.score >= 80
+ ? 'bg-gradient-to-t from-sky-600 to-sky-400'
+ : c.score >= 50
+ ? 'bg-gradient-to-t from-amber-600 to-amber-400'
+ : 'bg-gradient-to-t from-red-600 to-red-400',
+ )}
+ style={{ height: `${Math.max(c.score, 2)}%` }}
+ />
+
+ ))}
+
+
+ {items.map((c) => (
+
+ {c.name}
+
+ ))}
+
+ >
+ )}
+
+
+ )
+}
diff --git a/frontend/src/features/home/components/DatasourcesCard.tsx b/frontend/src/features/home/components/DatasourcesCard.tsx
new file mode 100644
index 000000000..fbee21089
--- /dev/null
+++ b/frontend/src/features/home/components/DatasourcesCard.tsx
@@ -0,0 +1,53 @@
+import { useTranslation } from 'react-i18next'
+import { Database, TrendingUp } from 'lucide-react'
+import { useDatasourcesOverview } from '../hooks/use-overview'
+import { fmtCount, spanLabel } from './helpers'
+import { CardHeader } from './CardHeader'
+import { EventsChart } from './EventsChart'
+
+export function DatasourcesCard() {
+ const { t } = useTranslation()
+ const ds = useDatasourcesOverview()
+ return (
+
+
+
+
+ {ds.isLoading ? (
+
+ ) : (
+
+ {fmtCount(ds.activeSources)}
+
+ )}
+
+ {t('home.datasources.ofTotal', { n: fmtCount(ds.total) })}{' '}
+ {t('home.datasources.datasources')}
+
+ {t('home.datasources.sendingData')}
+
+
+
+
+ {ds.isLoading ? (
+
+ ) : (
+
+ {fmtCount(ds.events)}
+
+ )}
+
+
+
+ {t('home.datasources.totalEvents')}
+
+ {spanLabel(t, ds.from, ds.to)}
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/home/components/EmptyState.tsx b/frontend/src/features/home/components/EmptyState.tsx
new file mode 100644
index 000000000..ac60430c6
--- /dev/null
+++ b/frontend/src/features/home/components/EmptyState.tsx
@@ -0,0 +1,3 @@
+export function EmptyState({ text }: { text: string }) {
+ return
{text}
+}
diff --git a/frontend/src/features/home/components/EventsChart.tsx b/frontend/src/features/home/components/EventsChart.tsx
new file mode 100644
index 000000000..713c79b8f
--- /dev/null
+++ b/frontend/src/features/home/components/EventsChart.tsx
@@ -0,0 +1,81 @@
+import { useTranslation } from 'react-i18next'
+import { fmtCount, shortTime } from './helpers'
+
+export function EventsChart({ points, loading }: { points: { t: string; count: number }[]; loading: boolean }) {
+ const { t } = useTranslation()
+ const w = 700
+ const h = 180
+ const pl = 44
+ const pr = 16
+ const pt = 10
+ const pb = 24
+ const innerW = w - pl - pr
+ const innerH = h - pt - pb
+
+ if (loading) {
+ return
+ }
+ if (points.length < 2) {
+ return (
+
+ {t('home.datasources.noData')}
+
+ )
+ }
+
+ const counts = points.map((p) => p.count)
+ const max = Math.max(...counts, 1)
+ const xs = points.map((_, i) => pl + (i * innerW) / (points.length - 1))
+ const ys = points.map((v) => pt + innerH - (v.count / max) * innerH)
+ const linePath = points.map((_, i) => `${i === 0 ? 'M' : 'L'} ${xs[i]} ${ys[i]}`).join(' ')
+ const areaPath = `${linePath} L ${xs[xs.length - 1]} ${pt + innerH} L ${xs[0]} ${pt + innerH} Z`
+
+ // Two reference gridlines (half + full scale) with compact labels.
+ const grids = [max / 2, max]
+ // A handful of evenly-spaced x labels so the axis stays readable.
+ const labelEvery = Math.max(1, Math.ceil(points.length / 6))
+
+ return (
+
+ )
+}
diff --git a/frontend/src/features/home/components/HomeChatTranscript.tsx b/frontend/src/features/home/components/HomeChatTranscript.tsx
new file mode 100644
index 000000000..9c0cb48d8
--- /dev/null
+++ b/frontend/src/features/home/components/HomeChatTranscript.tsx
@@ -0,0 +1,29 @@
+import { useTranslation } from 'react-i18next'
+import { Trash2 } from 'lucide-react'
+import { useSocAi } from '@/features/soc-ai/SocAiProvider'
+import { MessageRow } from '@/features/soc-ai/components/MessageRow'
+
+export function HomeChatTranscript() {
+ const { t } = useTranslation()
+ const { homeMessages, clear } = useSocAi()
+
+ return (
+
+
+
+
+
+ {homeMessages.map((m) => (
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/features/home/components/IconBtn.tsx b/frontend/src/features/home/components/IconBtn.tsx
new file mode 100644
index 000000000..f2de4c2e0
--- /dev/null
+++ b/frontend/src/features/home/components/IconBtn.tsx
@@ -0,0 +1,11 @@
+export function IconBtn({ children, label }: { children: React.ReactNode; label: string }) {
+ return (
+
+ )
+}
diff --git a/frontend/src/features/home/components/KpiTile.tsx b/frontend/src/features/home/components/KpiTile.tsx
new file mode 100644
index 000000000..32961700f
--- /dev/null
+++ b/frontend/src/features/home/components/KpiTile.tsx
@@ -0,0 +1,51 @@
+import { Link } from 'react-router-dom'
+import { type LucideIcon } from 'lucide-react'
+import { Sparkline } from './Sparkline'
+
+export function KpiTile({
+ icon: Icon,
+ label,
+ value,
+ sublabel,
+ sparkline,
+ accent,
+ loading,
+ href,
+}: {
+ icon: LucideIcon
+ label: string
+ value: string
+ sublabel?: string
+ sparkline?: number[]
+ accent: string
+ loading?: boolean
+ href?: string
+}) {
+ const inner = (
+
+
+
+ {label}
+
+ {loading ? (
+
+ ) : (
+
{value}
+ )}
+
+ {sparkline && sparkline.length > 1 ? (
+
+ ) : sublabel ? (
+ {sublabel}
+ ) : null}
+
+
+ )
+ return href ? (
+
+ {inner}
+
+ ) : (
+ inner
+ )
+}
diff --git a/frontend/src/features/home/components/MitreTechniquesCard.tsx b/frontend/src/features/home/components/MitreTechniquesCard.tsx
new file mode 100644
index 000000000..70c3fb03a
--- /dev/null
+++ b/frontend/src/features/home/components/MitreTechniquesCard.tsx
@@ -0,0 +1,45 @@
+import { useTranslation } from 'react-i18next'
+import { Crosshair } from 'lucide-react'
+import { useTopTechniques } from '../hooks/use-overview'
+import { fmtCount } from './helpers'
+import { CardHeader } from './CardHeader'
+import { SkeletonRows } from './SkeletonRows'
+import { EmptyState } from './EmptyState'
+
+export function MitreTechniquesCard() {
+ const { t } = useTranslation()
+ const { items, isLoading } = useTopTechniques()
+ const max = Math.max(...items.map((m) => m.count), 1)
+ return (
+
+
+
+ {isLoading ? (
+
+ ) : items.length === 0 ? (
+
+ ) : (
+ items.map((m) => (
+
+
+
{fmtCount(m.count)}
+
+ ))
+ )}
+
+
+ )
+}
diff --git a/frontend/src/features/home/components/SkeletonRows.tsx b/frontend/src/features/home/components/SkeletonRows.tsx
new file mode 100644
index 000000000..f7e6214d2
--- /dev/null
+++ b/frontend/src/features/home/components/SkeletonRows.tsx
@@ -0,0 +1,9 @@
+export function SkeletonRows({ rows }: { rows: number }) {
+ return (
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/features/home/components/Sparkline.tsx b/frontend/src/features/home/components/Sparkline.tsx
new file mode 100644
index 000000000..74acdd261
--- /dev/null
+++ b/frontend/src/features/home/components/Sparkline.tsx
@@ -0,0 +1,19 @@
+import { cn } from '@/shared/lib/utils'
+
+export function Sparkline({ data, accent }: { data: number[]; accent: string }) {
+ const w = 200
+ const h = 36
+ const max = Math.max(...data)
+ const min = Math.min(...data)
+ const range = max - min || 1
+ const xs = data.map((_, i) => (i * w) / (data.length - 1))
+ const ys = data.map((v) => h - ((v - min) / range) * (h - 4) - 2)
+ const path = data.map((_, i) => `${i === 0 ? 'M' : 'L'} ${xs[i]} ${ys[i]}`).join(' ')
+ const area = `${path} L ${xs[xs.length - 1]} ${h} L ${xs[0]} ${h} Z`
+ return (
+
+ )
+}
diff --git a/frontend/src/features/home/components/SystemHealthCard.tsx b/frontend/src/features/home/components/SystemHealthCard.tsx
new file mode 100644
index 000000000..299fa956d
--- /dev/null
+++ b/frontend/src/features/home/components/SystemHealthCard.tsx
@@ -0,0 +1,48 @@
+import { useTranslation } from 'react-i18next'
+import { Activity } from 'lucide-react'
+import { cn } from '@/shared/lib/utils'
+import { useSystemHealth, type HealthStatus } from '../hooks/use-overview'
+import { CardHeader } from './CardHeader'
+import { SkeletonRows } from './SkeletonRows'
+
+const HEALTH_META: Record
= {
+ up: { dot: 'bg-emerald-500', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300' },
+ degraded: { dot: 'bg-amber-500', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-300' },
+ down: { dot: 'bg-red-500', badge: 'bg-red-500/15 text-red-500 dark:text-red-300' },
+ unknown: { dot: 'bg-muted-foreground/50', badge: 'bg-muted text-muted-foreground' },
+}
+
+export function SystemHealthCard() {
+ const { t } = useTranslation()
+ const { services, isLoading } = useSystemHealth()
+ return (
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+ services.map((p) => {
+ const meta = HEALTH_META[p.status]
+ return (
+
+
+
+ {p.name}
+
+
+ {p.detail}
+
+ {t(`about.status.${p.status}`)}
+
+
+
+ )
+ })
+ )}
+
+
+ )
+}
diff --git a/frontend/src/features/home/components/TopAssetsCard.tsx b/frontend/src/features/home/components/TopAssetsCard.tsx
new file mode 100644
index 000000000..5472a78cc
--- /dev/null
+++ b/frontend/src/features/home/components/TopAssetsCard.tsx
@@ -0,0 +1,51 @@
+import { useTranslation } from 'react-i18next'
+import { Server } from 'lucide-react'
+import { useTopAssets } from '../hooks/use-overview'
+import { fmtCount } from './helpers'
+import { CardHeader } from './CardHeader'
+import { SkeletonRows } from './SkeletonRows'
+import { EmptyState } from './EmptyState'
+
+export function TopAssetsCard() {
+ const { t } = useTranslation()
+ const { items, isLoading } = useTopAssets()
+ const max = Math.max(...items.map((a) => a.count), 1)
+ return (
+
+
+
+
{t('home.assets.asset')}
+
{t('home.assets.alerts')}
+
+
+ {isLoading ? (
+
+
+
+ ) : items.length === 0 ? (
+
+
+
+ ) : (
+ items.map((a) => (
+
+
+
{fmtCount(a.count)}
+
+ ))
+ )}
+
+
+ )
+}
diff --git a/frontend/src/features/home/components/helpers.ts b/frontend/src/features/home/components/helpers.ts
new file mode 100644
index 000000000..4294a3cc8
--- /dev/null
+++ b/frontend/src/features/home/components/helpers.ts
@@ -0,0 +1,26 @@
+import { useTranslation } from 'react-i18next'
+
+export type TFn = ReturnType['t']
+
+export function fmtCount(n: number): string {
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
+ return n.toLocaleString()
+}
+
+export function spanLabel(t: TFn, from?: string, to?: string): string {
+ if (!from || !to) return t('home.span.recent')
+ const ms = new Date(to).getTime() - new Date(from).getTime()
+ if (!Number.isFinite(ms) || ms <= 0) return t('home.span.recent')
+ const days = Math.round(ms / 86_400_000)
+ if (days >= 1) return t('home.span.days', { count: days })
+ const hours = Math.max(1, Math.round(ms / 3_600_000))
+ return t('home.span.hours', { n: hours })
+}
+
+export function shortTime(iso: string): string {
+ const d = new Date(iso)
+ if (Number.isNaN(d.getTime())) return ''
+ return d.toLocaleString(undefined, { month: 'short', day: 'numeric' })
+}
diff --git a/frontend/src/features/home/pages/HomePage.tsx b/frontend/src/features/home/pages/HomePage.tsx
index bed4381f6..ee225bc3e 100644
--- a/frontend/src/features/home/pages/HomePage.tsx
+++ b/frontend/src/features/home/pages/HomePage.tsx
@@ -1,50 +1,30 @@
-import { useState } from 'react'
-import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
-import {
- Activity,
- AlertTriangle,
- AtSign,
- BadgeCheck,
- Crosshair,
- Database,
- Flame,
- Paperclip,
- Server,
- ShieldAlert,
- Sparkles,
- TrendingUp,
- Workflow,
- type LucideIcon,
-} from 'lucide-react'
-import { cn } from '@/shared/lib/utils'
+import { AlertTriangle, Flame, ShieldAlert, Workflow } from 'lucide-react'
+import { useActivePlaybooks, useAlertKpis, useOpenIncidents } from '../hooks/use-overview'
+import { fmtCount } from '../components/helpers'
+import { ChatHero } from '../components/ChatHero'
+import { KpiTile } from '../components/KpiTile'
+import { DatasourcesCard } from '../components/DatasourcesCard'
+import { SystemHealthCard } from '../components/SystemHealthCard'
+import { MitreTechniquesCard } from '../components/MitreTechniquesCard'
+import { TopAssetsCard } from '../components/TopAssetsCard'
+import { ComplianceCard } from '../components/ComplianceCard'
import { useSocAi } from '@/features/soc-ai/SocAiProvider'
-import { useSocAiConfigured } from '@/features/soc-ai/lib/useSocAiConfig'
-import {
- useActivePlaybooks,
- useAlertKpis,
- useComplianceScores,
- useDatasourcesOverview,
- useOpenIncidents,
- useSystemHealth,
- useTopAssets,
- useTopTechniques,
- type HealthStatus,
-} from '../hooks/use-overview'
-
-type TFn = ReturnType['t']
+import { HomeChatTranscript } from '../components/HomeChatTranscript'
export function HomePage() {
const { t } = useTranslation()
const alerts = useAlertKpis()
const incidents = useOpenIncidents()
const playbooks = useActivePlaybooks()
+ const { homeMessages } = useSocAi()
return (
-
+ {homeMessages.length === 0 ? (
+
-
- )
-}
-
-/* ─── Number / time helpers ────────────────────────────────────────────────── */
-
-function fmtCount(n: number): string {
- if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
- return n.toLocaleString()
-}
-
-function spanLabel(t: TFn, from?: string, to?: string): string {
- if (!from || !to) return t('home.span.recent')
- const ms = new Date(to).getTime() - new Date(from).getTime()
- if (!Number.isFinite(ms) || ms <= 0) return t('home.span.recent')
- const days = Math.round(ms / 86_400_000)
- if (days >= 1) return t('home.span.days', { count: days })
- const hours = Math.max(1, Math.round(ms / 3_600_000))
- return t('home.span.hours', { n: hours })
-}
-
-/* ─── AI chat hero ─────────────────────────────────────────────────────────── */
-
-const SUGGESTION_KEYS = ['threatHunt', 'observableTriage', 'writeRule', 'systemStatus'] as const
-
-function ChatHero() {
- const { t } = useTranslation()
- const configured = useSocAiConfigured()
- const { submit } = useSocAi()
- const [value, setValue] = useState('')
-
- // Hidden entirely until SOC-AI has a provider configured — same gate the
- // floating composer/panel use, so we never show a dead chat box.
- if (!configured) return null
-
- const send = () => {
- const text = value.trim()
- if (!text) return
- submit(text)
- setValue('')
- }
-
- return (
-
- {t('home.hero.title')}
-
-
- {SUGGESTION_KEYS.map((key) => {
- const label = t(`home.hero.suggestions.${key}`)
- return (
-
- )
- })}
-
-
- )
-}
-
-function IconBtn({ children, label }: { children: React.ReactNode; label: string }) {
- return (
-
- )
-}
-
-/* ─── KPI tiles ────────────────────────────────────────────────────────────── */
-
-function KpiTile({
- icon: Icon,
- label,
- value,
- sublabel,
- sparkline,
- accent,
- loading,
- href,
-}: {
- icon: LucideIcon
- label: string
- value: string
- sublabel?: string
- sparkline?: number[]
- accent: string
- loading?: boolean
- href?: string
-}) {
- const inner = (
-
-
-
- {label}
-
- {loading ? (
-
) : (
-
{value}
- )}
-
- {sparkline && sparkline.length > 1 ? (
-
- ) : sublabel ? (
- {sublabel}
- ) : null}
-
-
- )
- return href ? (
-
- {inner}
-
- ) : (
- inner
- )
-}
-
-function Sparkline({ data, accent }: { data: number[]; accent: string }) {
- const w = 200
- const h = 36
- const max = Math.max(...data)
- const min = Math.min(...data)
- const range = max - min || 1
- const xs = data.map((_, i) => (i * w) / (data.length - 1))
- const ys = data.map((v) => h - ((v - min) / range) * (h - 4) - 2)
- const path = data.map((_, i) => `${i === 0 ? 'M' : 'L'} ${xs[i]} ${ys[i]}`).join(' ')
- const area = `${path} L ${xs[xs.length - 1]} ${h} L ${xs[0]} ${h} Z`
- return (
-
- )
-}
-
-/* ─── Datasources ──────────────────────────────────────────────────────────── */
-
-function DatasourcesCard() {
- const { t } = useTranslation()
- const ds = useDatasourcesOverview()
- return (
-
-
-
-
- {ds.isLoading ? (
-
- ) : (
-
- {fmtCount(ds.activeSources)}
-
- )}
-
- {t('home.datasources.ofTotal', { n: fmtCount(ds.total) })}{' '}
- {t('home.datasources.datasources')}
-
- {t('home.datasources.sendingData')}
-
-
-
-
- {ds.isLoading ? (
-
- ) : (
-
- {fmtCount(ds.events)}
-
- )}
-
-
-
- {t('home.datasources.totalEvents')}
-
- {spanLabel(t, ds.from, ds.to)}
-
-
-
-
-
-
-
- )
-}
-
-function EventsChart({ points, loading }: { points: { t: string; count: number }[]; loading: boolean }) {
- const { t } = useTranslation()
- const w = 700
- const h = 180
- const pl = 44
- const pr = 16
- const pt = 10
- const pb = 24
- const innerW = w - pl - pr
- const innerH = h - pt - pb
-
- if (loading) {
- return
- }
- if (points.length < 2) {
- return (
-
- {t('home.datasources.noData')}
-
- )
- }
-
- const counts = points.map((p) => p.count)
- const max = Math.max(...counts, 1)
- const xs = points.map((_, i) => pl + (i * innerW) / (points.length - 1))
- const ys = points.map((v) => pt + innerH - (v.count / max) * innerH)
- const linePath = points.map((_, i) => `${i === 0 ? 'M' : 'L'} ${xs[i]} ${ys[i]}`).join(' ')
- const areaPath = `${linePath} L ${xs[xs.length - 1]} ${pt + innerH} L ${xs[0]} ${pt + innerH} Z`
-
- // Two reference gridlines (half + full scale) with compact labels.
- const grids = [max / 2, max]
- // A handful of evenly-spaced x labels so the axis stays readable.
- const labelEvery = Math.max(1, Math.ceil(points.length / 6))
-
- return (
-
- )
-}
-
-function shortTime(iso: string): string {
- const d = new Date(iso)
- if (Number.isNaN(d.getTime())) return ''
- return d.toLocaleString(undefined, { month: 'short', day: 'numeric' })
-}
-
-/* ─── MITRE ATT&CK top techniques ──────────────────────────────────────────── */
-
-function MitreTechniquesCard() {
- const { t } = useTranslation()
- const { items, isLoading } = useTopTechniques()
- const max = Math.max(...items.map((m) => m.count), 1)
- return (
-
-
-
- {isLoading ? (
-
- ) : items.length === 0 ? (
-
- ) : (
- items.map((m) => (
-
-
-
{fmtCount(m.count)}
-
- ))
- )}
-
-
- )
-}
-
-/* ─── System health (real backend / OpenSearch / SOC-AI) ───────────────────── */
-
-const HEALTH_META: Record
= {
- up: { dot: 'bg-emerald-500', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300' },
- degraded: { dot: 'bg-amber-500', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-300' },
- down: { dot: 'bg-red-500', badge: 'bg-red-500/15 text-red-500 dark:text-red-300' },
- unknown: { dot: 'bg-muted-foreground/50', badge: 'bg-muted text-muted-foreground' },
-}
-
-function SystemHealthCard() {
- const { t } = useTranslation()
- const { services, isLoading } = useSystemHealth()
- return (
-
-
-
- {isLoading ? (
-
-
-
- ) : (
- services.map((p) => {
- const meta = HEALTH_META[p.status]
- return (
-
-
-
- {p.name}
-
-
- {p.detail}
-
- {t(`about.status.${p.status}`)}
-
-
-
- )
- })
- )}
-
-
- )
-}
-
-/* ─── Top targeted assets (by alert volume) ────────────────────────────────── */
-
-function TopAssetsCard() {
- const { t } = useTranslation()
- const { items, isLoading } = useTopAssets()
- const max = Math.max(...items.map((a) => a.count), 1)
- return (
-
-
-
-
{t('home.assets.asset')}
-
{t('home.assets.alerts')}
-
-
- {isLoading ? (
-
-
-
- ) : items.length === 0 ? (
-
-
-
- ) : (
- items.map((a) => (
-
-
-
{fmtCount(a.count)}
-
- ))
- )}
-
-
- )
-}
-
-/* ─── Compliance score (latest snapshot per framework) ─────────────────────── */
-
-function ComplianceCard() {
- const { t } = useTranslation()
- const { items, isLoading } = useComplianceScores()
- return (
-
-
-
- {isLoading ? (
-
- ) : items.length === 0 ? (
-
- ) : (
- <>
-
- {items.map((c) => (
-
-
{c.score}%
-
= 90
- ? 'bg-gradient-to-t from-emerald-600 to-emerald-400'
- : c.score >= 80
- ? 'bg-gradient-to-t from-sky-600 to-sky-400'
- : c.score >= 50
- ? 'bg-gradient-to-t from-amber-600 to-amber-400'
- : 'bg-gradient-to-t from-red-600 to-red-400',
- )}
- style={{ height: `${Math.max(c.score, 2)}%` }}
- />
-
- ))}
-
-
- {items.map((c) => (
-
- {c.name}
-
- ))}
-
- >
- )}
-
-
- )
-}
-
-/* ─── Shared bits ──────────────────────────────────────────────────────────── */
-
-function SkeletonRows({ rows }: { rows: number }) {
- return (
-
- {Array.from({ length: rows }).map((_, i) => (
-
- ))}
-
- )
-}
-
-function EmptyState({ text }: { text: string }) {
- return
{text}
-}
-
-function CardHeader({
- title,
- icon: Icon,
- iconClass,
- action,
-}: {
- title: string
- icon?: LucideIcon
- iconClass?: string
- action?: { label: string; href: string }
-}) {
- return (
-
-
- {Icon && }
- {title}
-
- {action && (
-
- {action.label}
-
+
)}
)
diff --git a/frontend/src/features/soc-ai/SocAiProvider.tsx b/frontend/src/features/soc-ai/SocAiProvider.tsx
index ead415b0b..78a46f72c 100644
--- a/frontend/src/features/soc-ai/SocAiProvider.tsx
+++ b/frontend/src/features/soc-ai/SocAiProvider.tsx
@@ -1,4 +1,4 @@
-import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'
+import { createContext, useCallback, useContext, useMemo, useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react'
import { useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { extractNavigation, streamChat, type NavAction } from './lib/chat-stream'
@@ -18,16 +18,19 @@ export interface SocAiMessage {
actions?: NavAction[]
}
+export type SocAiScope = 'panel' | 'home'
+
interface SocAiContextValue {
open: boolean
expanded: boolean
messages: SocAiMessage[]
+ homeMessages: SocAiMessage[]
openPanel: () => void
closePanel: () => void
togglePanel: () => void
toggleExpand: () => void
- submit: (text: string) => void
- clear: () => void
+ submit: (text: string, opts?: { openPanel?: boolean; scope?: SocAiScope }) => void
+ clear: (scope?: SocAiScope) => void
}
const SocAiContext = createContext
(null)
@@ -56,36 +59,43 @@ export function SocAiProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false)
const [expanded, setExpanded] = useState(false)
const [messages, setMessages] = useState([])
+ const [homeMessages, setHomeMessages] = useState([])
const idRef = useRef(0)
const nextId = () => ++idRef.current
const abortRef = useRef(null)
const location = useLocation()
const { t, i18n } = useTranslation()
+ const setters: Record>> = {
+ panel: setMessages,
+ home: setHomeMessages,
+ }
+
const openPanel = useCallback(() => setOpen(true), [])
const closePanel = useCallback(() => setOpen(false), [])
const togglePanel = useCallback(() => setOpen((v) => !v), [])
const toggleExpand = useCallback(() => setExpanded((v) => !v), [])
- const clear = useCallback(() => {
+ const clear = useCallback((scope: SocAiScope = 'panel') => {
abortRef.current?.abort()
- setMessages([])
+ setters[scope]([])
}, [])
- const patchMsg = useCallback((id: number, fn: (m: SocAiMessage) => SocAiMessage) => {
- setMessages((list) => list.map((m) => (m.id === id ? fn(m) : m)))
+ const patchMsg = useCallback((scope: SocAiScope, id: number, fn: (m: SocAiMessage) => SocAiMessage) => {
+ setters[scope]((list) => list.map((m) => (m.id === id ? fn(m) : m)))
}, [])
const submit = useCallback(
- (raw: string) => {
+ (raw: string, opts?: { openPanel?: boolean; scope?: SocAiScope }) => {
const text = raw.trim()
if (!text) return
- setOpen(true)
+ const scope: SocAiScope = opts?.scope ?? 'panel'
+ if (opts?.openPanel !== false) setOpen(true)
abortRef.current?.abort()
const ac = new AbortController()
abortRef.current = ac
const aiId = nextId()
- setMessages((m) => [
+ setters[scope]((m) => [
...m,
{ id: nextId(), role: 'user', text },
{ id: aiId, role: 'ai', text: '', pending: true, steps: [] },
@@ -97,7 +107,7 @@ export function SocAiProvider({ children }: { children: ReactNode }) {
streamChat(
{ task: text, page, lang },
(ev) => {
- patchMsg(aiId, (msg) => {
+ patchMsg(scope, aiId, (msg) => {
switch (ev.kind) {
case 'tool_call':
return { ...msg, steps: [...(msg.steps ?? []), { tool: ev.tool ?? 'tool', status: 'running' }] }
@@ -125,7 +135,7 @@ export function SocAiProvider({ children }: { children: ReactNode }) {
ac.signal,
).catch((err) => {
if (ac.signal.aborted) return
- patchMsg(aiId, (msg) => ({
+ patchMsg(scope, aiId, (msg) => ({
...msg,
text: err instanceof Error ? err.message : t('socAi.chat.errorUnreachable'),
error: true,
@@ -137,8 +147,8 @@ export function SocAiProvider({ children }: { children: ReactNode }) {
)
const value = useMemo(
- () => ({ open, expanded, messages, openPanel, closePanel, togglePanel, toggleExpand, submit, clear }),
- [open, expanded, messages, openPanel, closePanel, togglePanel, toggleExpand, submit, clear],
+ () => ({ open, expanded, messages, homeMessages, openPanel, closePanel, togglePanel, toggleExpand, submit, clear }),
+ [open, expanded, messages, homeMessages, openPanel, closePanel, togglePanel, toggleExpand, submit, clear],
)
return {children}
diff --git a/frontend/src/features/soc-ai/components/MessageRow.tsx b/frontend/src/features/soc-ai/components/MessageRow.tsx
new file mode 100644
index 000000000..19843e456
--- /dev/null
+++ b/frontend/src/features/soc-ai/components/MessageRow.tsx
@@ -0,0 +1,138 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useNavigate } from 'react-router-dom'
+import { AlertCircle, ArrowUpRight, Check, Copy, Loader2, Wrench, X } from 'lucide-react'
+import { cn } from '@/shared/lib/utils'
+import { useSocAi, type SocAiMessage, type ToolStep } from '../SocAiProvider'
+import type { NavAction } from '../lib/chat-stream'
+import { MarkdownMessage } from './MarkdownMessage'
+
+// Logical navigation destinations the agent may emit → app routes. Filters/time
+// travel in router state for the target page to apply (where supported).
+const DEST_ROUTE: Record = {
+ 'log-explorer': '/log-explorer',
+ alerts: '/threat-management/alerts',
+ incidents: '/threat-management/incidents',
+ adversaries: '/threat-management/adversaries',
+ compliance: '/compliance',
+ datasources: '/datasources',
+ dashboards: '/home',
+}
+
+export function MessageRow({ message }: { message: SocAiMessage }) {
+ if (message.role === 'user') {
+ return (
+
+ {message.text}
+
+ )
+ }
+
+ const hasSteps = (message.steps?.length ?? 0) > 0
+ return (
+
+ {hasSteps &&
}
+ {message.pending && !message.text ? (
+
+ ) : message.error ? (
+
+ ) : (
+ <>
+
+ {message.actions && message.actions.length > 0 &&
}
+
+ >
+ )}
+
+ )
+}
+
+function Steps({ steps }: { steps: ToolStep[] }) {
+ return (
+
+ {steps.map((s, i) => (
+
+ {s.status === 'running' ? (
+
+ ) : s.status === 'error' ? (
+
+ ) : (
+
+ )}
+
+ {s.tool}
+
+ ))}
+
+ )
+}
+
+function Actions({ actions }: { actions: NavAction[] }) {
+ const navigate = useNavigate()
+ const { closePanel } = useSocAi()
+
+ const go = (a: NavAction) => {
+ const route = DEST_ROUTE[a.destination]
+ if (!route) return
+ closePanel()
+ navigate(route, { state: { socaiFilters: a.filters ?? [], socaiTime: a.time } })
+ }
+
+ return (
+
+ {actions
+ .filter((a) => DEST_ROUTE[a.destination])
+ .map((a, i) => (
+
+ ))}
+
+ )
+}
+
+function TypingDots() {
+ return (
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ )
+}
+
+function CopyButton({ text }: { text: string }) {
+ const { t } = useTranslation()
+ const [copied, setCopied] = useState(false)
+ const copy = () => {
+ void navigator.clipboard?.writeText(text)
+ setCopied(true)
+ window.setTimeout(() => setCopied(false), 1500)
+ }
+ return (
+
+ )
+}
diff --git a/frontend/src/features/soc-ai/components/SocAiPanel.tsx b/frontend/src/features/soc-ai/components/SocAiPanel.tsx
index 4d039efa8..b79ff871c 100644
--- a/frontend/src/features/soc-ai/components/SocAiPanel.tsx
+++ b/frontend/src/features/soc-ai/components/SocAiPanel.tsx
@@ -1,23 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { useNavigate } from 'react-router-dom'
-import { AlertCircle, ArrowUp, ArrowUpRight, Check, Copy, Loader2, Maximize2, Minimize2, Sparkles, Trash2, Wrench, X } from 'lucide-react'
+import { ArrowUp, Maximize2, Minimize2, Sparkles, Trash2, X } from 'lucide-react'
import { cn } from '@/shared/lib/utils'
-import { useSocAi, type SocAiMessage, type ToolStep } from '../SocAiProvider'
-import type { NavAction } from '../lib/chat-stream'
-import { MarkdownMessage } from './MarkdownMessage'
-
-// Logical navigation destinations the agent may emit → app routes. Filters/time
-// travel in router state for the target page to apply (where supported).
-const DEST_ROUTE: Record = {
- 'log-explorer': '/log-explorer',
- alerts: '/threat-management/alerts',
- incidents: '/threat-management/incidents',
- adversaries: '/threat-management/adversaries',
- compliance: '/compliance',
- datasources: '/datasources',
- dashboards: '/home',
-}
+import { useSocAi } from '../SocAiProvider'
+import { MessageRow } from './MessageRow'
export function SocAiPanel() {
const { t } = useTranslation()
@@ -107,123 +93,6 @@ export function SocAiPanel() {
)
}
-function MessageRow({ message }: { message: SocAiMessage }) {
- if (message.role === 'user') {
- return (
-
- {message.text}
-
- )
- }
-
- const hasSteps = (message.steps?.length ?? 0) > 0
- return (
-
- {hasSteps &&
}
- {message.pending && !message.text ? (
-
- ) : message.error ? (
-
- ) : (
- <>
-
- {message.actions && message.actions.length > 0 &&
}
-
- >
- )}
-
- )
-}
-
-function Steps({ steps }: { steps: ToolStep[] }) {
- return (
-
- {steps.map((s, i) => (
-
- {s.status === 'running' ? (
-
- ) : s.status === 'error' ? (
-
- ) : (
-
- )}
-
- {s.tool}
-
- ))}
-
- )
-}
-
-function Actions({ actions }: { actions: NavAction[] }) {
- const navigate = useNavigate()
- const { closePanel } = useSocAi()
-
- const go = (a: NavAction) => {
- const route = DEST_ROUTE[a.destination]
- if (!route) return
- closePanel()
- navigate(route, { state: { socaiFilters: a.filters ?? [], socaiTime: a.time } })
- }
-
- return (
-
- {actions
- .filter((a) => DEST_ROUTE[a.destination])
- .map((a, i) => (
-
- ))}
-
- )
-}
-
-function TypingDots() {
- return (
-
- {[0, 1, 2].map((i) => (
-
- ))}
-
- )
-}
-
-function CopyButton({ text }: { text: string }) {
- const { t } = useTranslation()
- const [copied, setCopied] = useState(false)
- const copy = () => {
- void navigator.clipboard?.writeText(text)
- setCopied(true)
- window.setTimeout(() => setCopied(false), 1500)
- }
- return (
-
- )
-}
function IconBtn({ label, onClick, children }: { label: string; onClick: () => void; children: React.ReactNode }) {
return (