|
1 | | -import { useEffect, useState } from 'react' |
2 | | -import { ChevronDown, ExternalLink, Flame, UserPlus, X } from 'lucide-react' |
| 1 | +import { useState } from 'react' |
| 2 | +import { Flame, X } from 'lucide-react' |
3 | 3 | import { useTranslation } from 'react-i18next' |
4 | | -import type { TFunction } from 'i18next' |
5 | 4 | import { cn } from '@/shared/lib/utils' |
6 | 5 | import { Button } from '@/shared/components/ui/button' |
7 | | -import { usersHttpService } from '@/features/team/services/team-http.service' |
8 | | -import type { UserListItem } from '@/features/team/types/team.types' |
9 | | -import { SEV_META, ST_META, TS, absTime, flagEmoji, relativeTime, riskOf, sevKey, statusKey } from '../lib/alert-meta' |
| 6 | +import { SEV_META, ST_META, TS, absTime, riskOf, sevKey, statusKey } from '../lib/alert-meta' |
10 | 7 | import { combineUserNote, isAiNote, parseAiNote, userNotePart } from '../lib/ai-note' |
11 | | -import { STATUS_BY_INT, type Alert, type AlertTag, type Side } from '../types/alert.types' |
| 8 | +import { type Alert, type AlertTag } from '../types/alert.types' |
12 | 9 | import { useRelatedLogs } from '../hooks/use-related-logs' |
13 | | -import { Menu, Row, Section, TagChip } from './ui-primitives' |
| 10 | +import { Section } from './section' |
| 11 | +import { Row } from './row' |
| 12 | +import { TagChip } from './tag-chip' |
14 | 13 | import { AlertTagEditor } from './alert-tag-editor' |
15 | 14 | import { AlertAiAssessment } from './alert-ai-assessment' |
16 | 15 | import { AlertRelatedEvents } from './alert-related-events' |
17 | 16 | import { StatusChangeMenu } from './status-change-menu' |
| 17 | +import { TechniqueValue } from './technique-value' |
| 18 | +import { AssigneeMenu } from './assignee-menu' |
| 19 | +import { PartyCard } from './party-card' |
| 20 | +import { HistoryTab } from './history-tab' |
18 | 21 |
|
19 | 22 | type Tab = 'summary' | 'parties' | 'events' | 'history' |
20 | 23 |
|
@@ -257,190 +260,3 @@ export function AlertDrawer({ |
257 | 260 | </div> |
258 | 261 | ) |
259 | 262 | } |
260 | | - |
261 | | -// Renders the MITRE technique as a link to attack.mitre.org when it carries a |
262 | | -// technique id (e.g. "T1110 - Brute Force" or "T1059.001 - PowerShell"). |
263 | | -function TechniqueValue({ technique }: { technique?: string }) { |
264 | | - if (!technique) return <span className="font-mono">—</span> |
265 | | - const m = technique.match(/T\d{4}(?:\.\d{3})?/i) |
266 | | - if (!m) return <span className="font-mono">{technique}</span> |
267 | | - const id = m[0].toUpperCase() |
268 | | - const path = id.replace('.', '/') |
269 | | - return ( |
270 | | - <a |
271 | | - href={`https://attack.mitre.org/techniques/${path}`} |
272 | | - target="_blank" |
273 | | - rel="noopener noreferrer" |
274 | | - className="inline-flex items-center gap-1 font-mono text-primary hover:underline" |
275 | | - > |
276 | | - {technique} |
277 | | - <ExternalLink size={11} className="shrink-0" /> |
278 | | - </a> |
279 | | - ) |
280 | | -} |
281 | | - |
282 | | -// Assign / reassign / clear an alert's owner. Loads the user list lazily on first |
283 | | -// open so the dropdown isn't fetched for every alert row. |
284 | | -function AssigneeMenu({ current, onAssign }: { current?: string; onAssign: (assignee: string) => void }) { |
285 | | - const { t } = useTranslation() |
286 | | - const [users, setUsers] = useState<UserListItem[] | null>(null) |
287 | | - const [loaded, setLoaded] = useState(false) |
288 | | - |
289 | | - useEffect(() => { |
290 | | - if (!loaded) return |
291 | | - let cancelled = false |
292 | | - usersHttpService |
293 | | - .list({ page_size: 200 }) |
294 | | - .then((r) => { |
295 | | - if (!cancelled) setUsers(r.data ?? []) |
296 | | - }) |
297 | | - .catch(() => { |
298 | | - if (!cancelled) setUsers([]) |
299 | | - }) |
300 | | - return () => { |
301 | | - cancelled = true |
302 | | - } |
303 | | - }, [loaded]) |
304 | | - |
305 | | - const label = (u: UserListItem) => |
306 | | - [u.first_name, u.last_name].filter(Boolean).join(' ') || u.login |
307 | | - |
308 | | - return ( |
309 | | - <div onMouseEnter={() => setLoaded(true)} onFocusCapture={() => setLoaded(true)} onClickCapture={() => setLoaded(true)}> |
310 | | - <Menu |
311 | | - trigger={ |
312 | | - <> |
313 | | - <UserPlus size={13} className="mr-1.5" /> |
314 | | - {current ? current : t('alerts.drawer.assign')} |
315 | | - <ChevronDown size={12} className="ml-1" /> |
316 | | - </> |
317 | | - } |
318 | | - > |
319 | | - {users == null ? ( |
320 | | - <div className="px-3 py-2 text-xs text-muted-foreground">{t('alerts.drawer.loadingUsers')}</div> |
321 | | - ) : ( |
322 | | - <> |
323 | | - {users.map((u) => ( |
324 | | - <button |
325 | | - key={u.id} |
326 | | - onClick={() => onAssign(label(u))} |
327 | | - className={cn( |
328 | | - 'block w-full px-3 py-1.5 text-left text-sm hover:bg-muted', |
329 | | - current === label(u) && 'font-semibold text-primary' |
330 | | - )} |
331 | | - > |
332 | | - {label(u)} |
333 | | - </button> |
334 | | - ))} |
335 | | - {current && ( |
336 | | - <button |
337 | | - onClick={() => onAssign('')} |
338 | | - className="block w-full border-t border-border px-3 py-1.5 text-left text-sm text-muted-foreground hover:bg-muted" |
339 | | - > |
340 | | - {t('alerts.drawer.unassign')} |
341 | | - </button> |
342 | | - )} |
343 | | - </> |
344 | | - )} |
345 | | - </Menu> |
346 | | - </div> |
347 | | - ) |
348 | | -} |
349 | | - |
350 | | -function PartyCard({ title, ep, accent }: { title: string; ep?: Side; accent?: boolean }) { |
351 | | - const { t } = useTranslation() |
352 | | - return ( |
353 | | - <div className={cn('rounded-lg border bg-card p-4', accent ? 'border-red-500/30' : 'border-border')}> |
354 | | - <h4 className="mb-3 text-sm font-semibold">{title}</h4> |
355 | | - {!ep ? ( |
356 | | - <p className="text-xs text-muted-foreground">{t('alerts.party.noData')}</p> |
357 | | - ) : ( |
358 | | - <dl className="grid grid-cols-[80px_1fr] gap-y-2 text-xs"> |
359 | | - {ep.ip && <Row k={t('alerts.party.ip')}><span className="font-mono">{ep.ip}</span></Row>} |
360 | | - {ep.host && <Row k={t('alerts.party.host')}><span className="font-mono">{ep.host}</span></Row>} |
361 | | - {ep.user && <Row k={t('alerts.party.user')}><span className="font-mono">{ep.user}</span></Row>} |
362 | | - {ep.domain && <Row k={t('alerts.party.domain')}><span className="font-mono">{ep.domain}</span></Row>} |
363 | | - {ep.geolocation?.country && ( |
364 | | - <Row k={t('alerts.party.country')}> |
365 | | - {flagEmoji(ep.geolocation.countryCode)} {ep.geolocation.country} |
366 | | - {ep.geolocation.city ? ` · ${ep.geolocation.city}` : ''} |
367 | | - </Row> |
368 | | - )} |
369 | | - </dl> |
370 | | - )} |
371 | | - </div> |
372 | | - ) |
373 | | -} |
374 | | - |
375 | | -function HistoryTab({ alert: a }: { alert: Alert }) { |
376 | | - const { t } = useTranslation() |
377 | | - const history = a.history ?? [] |
378 | | - if (history.length === 0) return <p className="text-xs text-muted-foreground">{t('alerts.history.empty')}</p> |
379 | | - return ( |
380 | | - <div className="space-y-2"> |
381 | | - {history |
382 | | - .slice() |
383 | | - .reverse() |
384 | | - .map((h, i) => { |
385 | | - const detail = historyDetail(h, t) |
386 | | - return ( |
387 | | - <div key={i} className="rounded-md border border-border bg-card px-3 py-2 text-xs"> |
388 | | - <div className="flex items-center justify-between gap-2"> |
389 | | - <span className="font-medium">{actionLabel(h.action, t)}</span> |
390 | | - <span className="font-mono text-[10px] text-muted-foreground">{relativeTime(h.timestamp)}</span> |
391 | | - </div> |
392 | | - {(detail || h.user) && ( |
393 | | - <div className="mt-0.5 text-muted-foreground"> |
394 | | - {detail} |
395 | | - {h.user && ( |
396 | | - <span> |
397 | | - {detail ? ' · ' : ''} |
398 | | - {t('alerts.history.by', { user: h.user })} |
399 | | - </span> |
400 | | - )} |
401 | | - </div> |
402 | | - )} |
403 | | - </div> |
404 | | - ) |
405 | | - })} |
406 | | - </div> |
407 | | - ) |
408 | | -} |
409 | | - |
410 | | -const ACTION_KEYS = ['UPDATE_STATUS', 'UPDATE_TAGS', 'UPDATE_NOTES', 'UPDATE_SOLUTION', 'MARK_AS_INCIDENT'] |
411 | | -function actionLabel(a: string | undefined, t: TFunction) { |
412 | | - if (a && ACTION_KEYS.includes(a)) return t(`alerts.history.actions.${a}`) |
413 | | - return (a ?? 'change').replace(/_/g, ' ').toLowerCase() |
414 | | -} |
415 | | - |
416 | | -// A clean one-line detail — never dumps the raw AI assessment or the newValue JSON. |
417 | | -function historyDetail(h: { message?: string; newValue?: string }, t: TFunction): string { |
418 | | - const msg = (h.message ?? '').trim() |
419 | | - if (msg && !isAiNote(msg) && !msg.startsWith('{')) return msg |
420 | | - try { |
421 | | - const v = JSON.parse(h.newValue || '{}') as Record<string, unknown> |
422 | | - const parts: string[] = [] |
423 | | - if (typeof v.status === 'number') { |
424 | | - const stKey = STATUS_BY_INT[v.status] |
425 | | - parts.push( |
426 | | - t('alerts.history.detail.statusTo', { status: stKey ? t(`alerts.status.${stKey}`) : String(v.status) }) |
427 | | - ) |
428 | | - } |
429 | | - if (v.tags != null) |
430 | | - parts.push( |
431 | | - t('alerts.history.detail.tags', { |
432 | | - tags: Array.isArray(v.tags) ? (v.tags as string[]).join(', ') : String(v.tags), |
433 | | - }) |
434 | | - ) |
435 | | - if (typeof v.statusObservation === 'string' && v.statusObservation) |
436 | | - parts.push( |
437 | | - isAiNote(v.statusObservation) |
438 | | - ? t('alerts.history.detail.aiAdded') |
439 | | - : t('alerts.history.detail.observationAdded') |
440 | | - ) |
441 | | - if (v.notes != null && !isAiNote(String(v.notes))) parts.push(t('alerts.history.detail.notesUpdated')) |
442 | | - return parts.join(' · ') |
443 | | - } catch { |
444 | | - return '' |
445 | | - } |
446 | | -} |
0 commit comments