Skip to content

Commit 173cbbc

Browse files
Backlog/v12 home chat (#2335)
* fix[frontend](home): refactored components on home page * fix[frontend](home): make agent responses be on the chat hero home page
1 parent fb6ec4c commit 173cbbc

19 files changed

Lines changed: 774 additions & 717 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Link } from 'react-router-dom'
2+
import { type LucideIcon } from 'lucide-react'
3+
4+
export function CardHeader({
5+
title,
6+
icon: Icon,
7+
iconClass,
8+
action,
9+
}: {
10+
title: string
11+
icon?: LucideIcon
12+
iconClass?: string
13+
action?: { label: string; href: string }
14+
}) {
15+
return (
16+
<div className="flex items-center justify-between px-6 pt-5 pb-4">
17+
<h3 className="flex items-center gap-2 text-sm font-semibold">
18+
{Icon && <Icon size={16} strokeWidth={1.75} className={iconClass ?? 'text-muted-foreground'} />}
19+
{title}
20+
</h3>
21+
{action && (
22+
<Link to={action.href} className="text-xs font-medium text-primary hover:underline">
23+
{action.label}
24+
</Link>
25+
)}
26+
</div>
27+
)
28+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { useState } from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import { AtSign, Paperclip, Sparkles } from 'lucide-react'
4+
import { useSocAi } from '@/features/soc-ai/SocAiProvider'
5+
import { useSocAiConfigured } from '@/features/soc-ai/lib/useSocAiConfig'
6+
import { IconBtn } from './IconBtn'
7+
8+
const SUGGESTION_KEYS = ['threatHunt', 'observableTriage', 'writeRule', 'systemStatus'] as const
9+
10+
export function ChatHero() {
11+
const { t } = useTranslation()
12+
const configured = useSocAiConfigured()
13+
const { submit } = useSocAi()
14+
const [value, setValue] = useState('')
15+
16+
// Hidden entirely until SOC-AI has a provider configured — same gate the
17+
// floating composer/panel use, so we never show a dead chat box.
18+
if (!configured) return null
19+
20+
const send = () => {
21+
const text = value.trim()
22+
if (!text) return
23+
submit(text, { openPanel: false, scope: 'home' })
24+
setValue('')
25+
}
26+
27+
return (
28+
<section className="flex flex-col items-center">
29+
<h2 className="mb-4 text-center text-base font-medium text-foreground">{t('home.hero.title')}</h2>
30+
<div className="relative w-full max-w-3xl">
31+
<div
32+
aria-hidden
33+
className="absolute -inset-px rounded-xl bg-[conic-gradient(from_180deg_at_50%_50%,#1a8cff_0deg,#0ea5e9_120deg,#2563eb_240deg,#1a8cff_360deg)] opacity-50 blur-[1px]"
34+
/>
35+
<div className="relative rounded-xl bg-card p-4">
36+
<textarea
37+
rows={3}
38+
value={value}
39+
onChange={(e) => setValue(e.target.value)}
40+
onKeyDown={(e) => {
41+
if (e.key === 'Enter' && !e.shiftKey) {
42+
e.preventDefault()
43+
send()
44+
}
45+
}}
46+
placeholder={t('home.hero.placeholder')}
47+
className="w-full resize-none border-0 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
48+
/>
49+
<div className="mt-2 flex items-center justify-between">
50+
<div className="flex items-center gap-2 text-muted-foreground">
51+
<IconBtn label={t('home.hero.mention')}><AtSign size={16} strokeWidth={1.75} /></IconBtn>
52+
<IconBtn label={t('home.hero.attach')}><Paperclip size={16} strokeWidth={1.75} /></IconBtn>
53+
</div>
54+
<button
55+
onClick={send}
56+
disabled={!value.trim()}
57+
className="flex h-7 w-7 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-sm hover:opacity-90 disabled:opacity-40"
58+
aria-label={t('home.hero.send')}
59+
>
60+
<Sparkles size={14} strokeWidth={2} />
61+
</button>
62+
</div>
63+
</div>
64+
</div>
65+
<div className="mt-4 flex flex-wrap justify-center gap-2">
66+
{SUGGESTION_KEYS.map((key) => {
67+
const label = t(`home.hero.suggestions.${key}`)
68+
return (
69+
<button
70+
key={key}
71+
onClick={() => submit(label, { openPanel: false, scope: 'home' })}
72+
className="rounded-full border border-border bg-card px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
73+
>
74+
{label}
75+
</button>
76+
)
77+
})}
78+
</div>
79+
</section>
80+
)
81+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useTranslation } from 'react-i18next'
2+
import { BadgeCheck } from 'lucide-react'
3+
import { cn } from '@/shared/lib/utils'
4+
import { useComplianceScores } from '../hooks/use-overview'
5+
import { CardHeader } from './CardHeader'
6+
import { EmptyState } from './EmptyState'
7+
8+
export function ComplianceCard() {
9+
const { t } = useTranslation()
10+
const { items, isLoading } = useComplianceScores()
11+
return (
12+
<div className="h-full rounded-xl border border-border bg-card">
13+
<CardHeader title={t('home.compliance.title')} icon={BadgeCheck} iconClass="text-emerald-500" action={{ label: t('home.compliance.reports'), href: '/compliance' }} />
14+
<div className="px-6 pb-6">
15+
{isLoading ? (
16+
<div className="h-32 animate-pulse rounded-lg bg-muted" />
17+
) : items.length === 0 ? (
18+
<EmptyState text={t('home.compliance.empty')} />
19+
) : (
20+
<>
21+
<div
22+
className="grid items-end gap-3 h-32"
23+
style={{ gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))` }}
24+
>
25+
{items.map((c) => (
26+
<div key={c.key} className="flex h-full flex-col items-center justify-end">
27+
<div className="mb-1 font-mono text-[10px] tabular-nums">{c.score}%</div>
28+
<div
29+
className={cn(
30+
'w-full rounded-t-md',
31+
c.score >= 90
32+
? 'bg-gradient-to-t from-emerald-600 to-emerald-400'
33+
: c.score >= 80
34+
? 'bg-gradient-to-t from-sky-600 to-sky-400'
35+
: c.score >= 50
36+
? 'bg-gradient-to-t from-amber-600 to-amber-400'
37+
: 'bg-gradient-to-t from-red-600 to-red-400',
38+
)}
39+
style={{ height: `${Math.max(c.score, 2)}%` }}
40+
/>
41+
</div>
42+
))}
43+
</div>
44+
<div
45+
className="mt-2 grid gap-3 text-center text-[10px] uppercase tracking-wider text-muted-foreground"
46+
style={{ gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))` }}
47+
>
48+
{items.map((c) => (
49+
<div key={c.key} className="truncate" title={c.name}>
50+
{c.name}
51+
</div>
52+
))}
53+
</div>
54+
</>
55+
)}
56+
</div>
57+
</div>
58+
)
59+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useTranslation } from 'react-i18next'
2+
import { Database, TrendingUp } from 'lucide-react'
3+
import { useDatasourcesOverview } from '../hooks/use-overview'
4+
import { fmtCount, spanLabel } from './helpers'
5+
import { CardHeader } from './CardHeader'
6+
import { EventsChart } from './EventsChart'
7+
8+
export function DatasourcesCard() {
9+
const { t } = useTranslation()
10+
const ds = useDatasourcesOverview()
11+
return (
12+
<div className="h-full rounded-xl border border-border bg-card">
13+
<CardHeader title={t('home.datasources.title')} icon={Database} action={{ label: t('home.datasources.viewAll'), href: '/datasources' }} />
14+
<div className="flex flex-wrap items-end gap-x-10 gap-y-4 px-6 pt-2">
15+
<div>
16+
{ds.isLoading ? (
17+
<div className="h-12 w-20 animate-pulse rounded bg-muted" />
18+
) : (
19+
<div className="text-5xl font-semibold leading-none tracking-tight text-emerald-500 tabular-nums dark:text-emerald-400">
20+
{fmtCount(ds.activeSources)}
21+
</div>
22+
)}
23+
<div className="mt-2 text-xs text-muted-foreground">
24+
<span className="text-foreground">{t('home.datasources.ofTotal', { n: fmtCount(ds.total) })}</span>{' '}
25+
{t('home.datasources.datasources')}
26+
<br />
27+
{t('home.datasources.sendingData')}
28+
</div>
29+
</div>
30+
<div>
31+
<div className="flex items-baseline gap-2">
32+
{ds.isLoading ? (
33+
<div className="h-12 w-28 animate-pulse rounded bg-muted" />
34+
) : (
35+
<span className="text-5xl font-semibold leading-none tracking-tight tabular-nums">
36+
{fmtCount(ds.events)}
37+
</span>
38+
)}
39+
<TrendingUp size={20} strokeWidth={2} className="text-emerald-500 dark:text-emerald-400" />
40+
</div>
41+
<div className="mt-2 text-xs text-muted-foreground">
42+
{t('home.datasources.totalEvents')}
43+
<br />
44+
{spanLabel(t, ds.from, ds.to)}
45+
</div>
46+
</div>
47+
</div>
48+
<div className="px-2 pb-2 pt-4">
49+
<EventsChart points={ds.points} loading={ds.isLoading} />
50+
</div>
51+
</div>
52+
)
53+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function EmptyState({ text }: { text: string }) {
2+
return <div className="py-4 text-center text-xs text-muted-foreground">{text}</div>
3+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { useTranslation } from 'react-i18next'
2+
import { fmtCount, shortTime } from './helpers'
3+
4+
export function EventsChart({ points, loading }: { points: { t: string; count: number }[]; loading: boolean }) {
5+
const { t } = useTranslation()
6+
const w = 700
7+
const h = 180
8+
const pl = 44
9+
const pr = 16
10+
const pt = 10
11+
const pb = 24
12+
const innerW = w - pl - pr
13+
const innerH = h - pt - pb
14+
15+
if (loading) {
16+
return <div className="mx-4 h-44 animate-pulse rounded-lg bg-muted" />
17+
}
18+
if (points.length < 2) {
19+
return (
20+
<div className="flex h-44 items-center justify-center text-xs text-muted-foreground">
21+
{t('home.datasources.noData')}
22+
</div>
23+
)
24+
}
25+
26+
const counts = points.map((p) => p.count)
27+
const max = Math.max(...counts, 1)
28+
const xs = points.map((_, i) => pl + (i * innerW) / (points.length - 1))
29+
const ys = points.map((v) => pt + innerH - (v.count / max) * innerH)
30+
const linePath = points.map((_, i) => `${i === 0 ? 'M' : 'L'} ${xs[i]} ${ys[i]}`).join(' ')
31+
const areaPath = `${linePath} L ${xs[xs.length - 1]} ${pt + innerH} L ${xs[0]} ${pt + innerH} Z`
32+
33+
// Two reference gridlines (half + full scale) with compact labels.
34+
const grids = [max / 2, max]
35+
// A handful of evenly-spaced x labels so the axis stays readable.
36+
const labelEvery = Math.max(1, Math.ceil(points.length / 6))
37+
38+
return (
39+
<svg viewBox={`0 0 ${w} ${h}`} className="h-44 w-full" preserveAspectRatio="none">
40+
<defs>
41+
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
42+
<line x1="0" y1="0" x2="0" y2="6" className="stroke-foreground/5" strokeWidth="1" />
43+
</pattern>
44+
<linearGradient id="lineGrad" x1="0" y1="0" x2="1" y2="0">
45+
<stop offset="0" stopColor="#22d3ee" />
46+
<stop offset="1" stopColor="#1a8cff" />
47+
</linearGradient>
48+
</defs>
49+
<rect x={pl} y={pt} width={innerW} height={innerH} fill="url(#hatch)" />
50+
<g className="fill-muted-foreground" fontSize="10">
51+
{grids.map((v) => (
52+
<text key={v} x={pl - 6} y={pt + innerH - (v / max) * innerH + 3} textAnchor="end">
53+
{fmtCount(Math.round(v))}
54+
</text>
55+
))}
56+
</g>
57+
{grids.map((v) => (
58+
<line
59+
key={v}
60+
x1={pl}
61+
x2={pl + innerW}
62+
y1={pt + innerH - (v / max) * innerH}
63+
y2={pt + innerH - (v / max) * innerH}
64+
className="stroke-border/40"
65+
strokeDasharray="2 4"
66+
/>
67+
))}
68+
<path d={areaPath} className="fill-primary/10" />
69+
<path d={linePath} fill="none" stroke="url(#lineGrad)" strokeWidth="2" strokeLinejoin="round" />
70+
<g className="fill-muted-foreground" fontSize="10">
71+
{points.map((p, i) =>
72+
i % labelEvery === 0 ? (
73+
<text key={i} x={xs[i]} y={h - 6} textAnchor="middle">
74+
{shortTime(p.t)}
75+
</text>
76+
) : null,
77+
)}
78+
</g>
79+
</svg>
80+
)
81+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { useTranslation } from 'react-i18next'
2+
import { Trash2 } from 'lucide-react'
3+
import { useSocAi } from '@/features/soc-ai/SocAiProvider'
4+
import { MessageRow } from '@/features/soc-ai/components/MessageRow'
5+
6+
export function HomeChatTranscript() {
7+
const { t } = useTranslation()
8+
const { homeMessages, clear } = useSocAi()
9+
10+
return (
11+
<section className="mt-8">
12+
<div className="flex justify-end">
13+
<button
14+
type="button"
15+
onClick={() => clear('home')}
16+
className="inline-flex items-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:border-border hover:bg-muted hover:text-foreground"
17+
>
18+
<Trash2 size={13} />
19+
{t('socAi.chat.clear')}
20+
</button>
21+
</div>
22+
<div className="mt-4 flex flex-col gap-4 text-[13.5px] leading-relaxed">
23+
{homeMessages.map((m) => (
24+
<MessageRow key={m.id} message={m} />
25+
))}
26+
</div>
27+
</section>
28+
)
29+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export function IconBtn({ children, label }: { children: React.ReactNode; label: string }) {
2+
return (
3+
<button
4+
aria-label={label}
5+
title={label}
6+
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted hover:text-foreground"
7+
>
8+
{children}
9+
</button>
10+
)
11+
}

0 commit comments

Comments
 (0)