Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions frontend/src/features/alerts/components/alert-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,25 @@ export function AlertRow({
<td className={`${TD} relative w-[6px] p-0`}>
<span className={cn('absolute inset-y-0 left-0 w-[3px]', sev.bar)} title={t(`alerts.severity.${sk}`)} aria-hidden />
</td>
<td className={TD}>
<button
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
<td
className={cn(TD, 'cursor-pointer relative')}
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
>
<span
className={cn(
'flex h-4 w-4 items-center justify-center rounded border',
checked ? 'border-primary bg-primary' : 'border-input'
)}
>
{checked && <span className="h-2 w-2 rounded-sm bg-primary-foreground" />}
</button>
</span>

<span className='p-8 absolute -top-0.5 -translate-y-0.5'>
</span>

</td>
<td className={TD}>
<button
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { useEffect, useMemo, useState } from 'react'
import { AlertTriangle, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll'
import { AlertsFilterBar } from '@/features/alerts/components/alerts-filter-bar'
import { useAlertsList } from '@/features/alerts/hooks/use-alerts-list'
import { useAlertTagCatalog } from '@/features/alerts/hooks/use-alert-tag-catalog'
import { FILTER_OPS } from '@/features/alerts/lib/alert-meta'
import type { Alert, CustomFilter, FilterType } from '@/features/alerts/types/alert.types'
import { IncidentAlertRow } from './incident-alert-row'
import { IncidentAlertsPickerHeader } from './incident-alerts-picker-header'

const COLS = 5

export function CreateIncidentStepAlerts({
selected,
onToggle,
onToggleAll,
onAlertsChange,
}: {
selected: Set<string>
onToggle: (id: string) => void
onToggleAll: (page: Alert[]) => void
onAlertsChange: (alerts: Alert[]) => void
}) {
const { t } = useTranslation()
const [customFilters, setCustomFilters] = useState<CustomFilter[]>([])
const [page, setPage] = useState(0)
const pageSize = 50

const filters = useMemo<FilterType[]>(() => {
const f: FilterType[] = [{ field: 'parentId', operator: 'IS', value: '' }]
for (const cf of customFilters) {
const needsValue = FILTER_OPS.find((o) => o.id === cf.operator)?.needsValue ?? true
f.push({ field: cf.field, operator: cf.operator, value: needsValue ? cf.value : undefined })
}
return f
}, [customFilters])

const { alerts, total, hasMore, loading, error, refresh } = useAlertsList(page, pageSize, filters)
const { tagCatalog } = useAlertTagCatalog(() => {})

useEffect(() => { onAlertsChange(alerts) }, [alerts, onAlertsChange])

const allChecked = alerts.length > 0 && alerts.every((a) => selected.has(a.id))

return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0">
<AlertsFilterBar
filters={customFilters}
onAdd={(cf) => { setCustomFilters((c) => [...c, cf]); setPage(0) }}
onUpdate={(i, cf) => { setCustomFilters((c) => c.map((f, idx) => (idx === i ? cf : f))); setPage(0) }}
onRemove={(i) => { setCustomFilters((c) => c.filter((_, idx) => idx !== i)); setPage(0) }}
onClear={() => { setCustomFilters([]); setPage(0) }}
/>
</div>

<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card">
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-full border-collapse">
<IncidentAlertsPickerHeader allChecked={allChecked} onTogglePage={() => onToggleAll(alerts)} />
<tbody>
{loading && alerts.length === 0 ? (
<tr>
<td colSpan={COLS} className="px-6 py-16 text-center text-sm text-muted-foreground">
<Loader2 className="mx-auto h-4 w-4 animate-spin" /> {t('alerts.list.loading')}
</td>
</tr>
) : error ? (
<tr>
<td colSpan={COLS} className="px-6 py-16 text-center text-sm">
<AlertTriangle size={16} className="mr-1 inline text-amber-500" />
{t('alerts.list.loadError')}
<button onClick={refresh} className="ml-2 text-primary hover:underline">
{t('alerts.list.retry')}
</button>
</td>
</tr>
) : alerts.length === 0 ? (
<tr>
<td colSpan={COLS} className="px-6 py-16 text-center text-sm text-muted-foreground">
{t('alerts.list.empty')}
</td>
</tr>
) : (
alerts.map((a) => (
<IncidentAlertRow
key={a.id}
alert={a}
tagCatalog={tagCatalog}
checked={selected.has(a.id)}
onToggle={() => onToggle(a.id)}
/>
))
)}
</tbody>
</table>
{alerts.length > 0 && (
<InfiniteScrollSentinel
onReach={() => setPage((p) => p + 1)}
hasMore={hasMore}
loading={loading}
endLabel={t('common.allLoaded', { count: total })}
/>
)}
</div>
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useTranslation } from 'react-i18next'
import { Input } from '@/shared/components/ui/input'

export function CreateIncidentStepDetails({
name,
description,
onChangeName,
onChangeDescription,
}: {
name: string
description: string
onChangeName: (v: string) => void
onChangeDescription: (v: string) => void
}) {
const { t } = useTranslation()
return (
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium">
{t('incidents.create.details.nameLabel')}
<span className="ml-0.5 text-destructive">*</span>
</label>
<Input
type="text"
value={name}
onChange={(e) => onChangeName(e.target.value)}
placeholder={t('incidents.create.details.namePlaceholder')}
required
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">
{t('incidents.create.details.descriptionLabel')}
</label>
<textarea
value={description}
onChange={(e) => onChangeDescription(e.target.value)}
placeholder={t('incidents.create.details.descriptionPlaceholder')}
rows={4}
className="flex w-full rounded-md border border-input bg-background/40 px-3 py-2 text-sm placeholder:text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50 resize-none"
/>
</div>
</div>
)
}
156 changes: 156 additions & 0 deletions frontend/src/features/incidents/components/create-incident-wizard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { useCallback, useMemo, useState } from 'react'
import { ArrowLeft, ArrowRight, Loader2, X } from 'lucide-react'
import { toast } from 'sonner'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/components/ui/button'
import type { Alert } from '@/features/alerts/types/alert.types'
import { STATUS_BY_VALUE, STATUS_VALUE } from '@/features/alerts/types/alert.types'
import { incidentsHttpService, IncidentsHttpError } from '../services/incidents-http.service'
import type { AlertLinkItem, CreateIncidentInput } from '../types/incident.types'
import { CreateIncidentStepDetails } from './create-incident-step-details'
import { CreateIncidentStepAlerts } from './create-incident-step-alerts'

export function CreateIncidentWizard({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const { t } = useTranslation()
const [step, setStep] = useState<1 | 2>(1)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [alertsById, setAlertsById] = useState<Map<string, Alert>>(new Map())
const [busy, setBusy] = useState(false)

const onAlertsChange = useCallback((alerts: Alert[]) => {
setAlertsById((prev) => {
const next = new Map(prev)
alerts.forEach((a) => next.set(a.id, a))
return next
})
}, [])

const toggle = (id: string) =>
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})

const toggleAll = (page: Alert[]) =>
setSelected((prev) => {
const next = new Set(prev)
const allOn = page.length > 0 && page.every((a) => next.has(a.id))
if (allOn) page.forEach((a) => next.delete(a.id))
else page.forEach((a) => next.add(a.id))
return next
})

const alertList = useMemo<AlertLinkItem[]>(
() =>
[...selected]
.map((id) => alertsById.get(id))
.filter((a): a is Alert => !!a)
.map((a) => ({
alertId: a.id,
alertName: a.name || a.id,
alertSeverity: a.severity ?? 'low',
alertStatus: STATUS_VALUE[STATUS_BY_VALUE[a.status ?? ''] ?? 'open'],
})),
[selected, alertsById],
)

const trimmedName = name.trim()
const canProceed = trimmedName.length > 0
const canSubmit = canProceed && selected.size > 0 && !busy

const payload = useMemo<CreateIncidentInput>(
() => ({
incidentName: trimmedName,
incidentDescription: description.trim() || undefined,
alertList,
}),
[trimmedName, description, alertList],
)

const submit = async () => {
if (!canSubmit) return
setBusy(true)
try {
await incidentsHttpService.create(payload)
toast.success(t('incidents.create.success'))
onCreated()
} catch (e) {
toast.error(e instanceof IncidentsHttpError ? e.message : t('incidents.create.error'))
setBusy(false)
}
}

return (
<div className="flex h-full min-h-0 w-full flex-col px-6 pb-6 pt-3">
<header className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-3">
<h1 className="text-base font-semibold">{t('incidents.create.title')}</h1>
<span className="text-xs text-muted-foreground">
{t('incidents.create.stepIndicator', { current: step, total: 2 })}
</span>
</div>
<button
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={t('common.actions.close')}
>
<X size={16} />
</button>
</header>

<div className="mt-4 flex min-h-0 flex-1 flex-col">
{step === 1 ? (
<div className="mx-auto w-full max-w-xl">
<CreateIncidentStepDetails
name={name}
description={description}
onChangeName={setName}
onChangeDescription={setDescription}
/>
</div>
) : (
<CreateIncidentStepAlerts
selected={selected}
onToggle={toggle}
onToggleAll={toggleAll}
onAlertsChange={onAlertsChange}
/>
)}
</div>

<footer className="mt-4 flex shrink-0 items-center justify-between border-t border-border pt-3">
<div className="text-xs text-muted-foreground">
{step === 2 && selected.size > 0 && t('incidents.create.selectedCount', { count: selected.size })}
</div>
<div className="flex items-center gap-2">
{step === 1 ? (
<>
<Button variant="ghost" size="sm" onClick={onClose}>
{t('common.actions.cancel')}
</Button>
<Button size="sm" disabled={!canProceed} onClick={() => setStep(2)}>
{t('common.actions.next')}
<ArrowRight size={14} className="ml-1.5" />
</Button>
</>
) : (
<>
<Button variant="ghost" size="sm" onClick={() => setStep(1)} disabled={busy}>
<ArrowLeft size={14} className="mr-1.5" />
{t('common.actions.back')}
</Button>
<Button size="sm" disabled={!canSubmit} onClick={submit}>
{busy && <Loader2 size={14} className="mr-1.5 animate-spin" />}
{t('incidents.create.submit')}
</Button>
</>
)}
</div>
</footer>
</div>
)
}
Loading
Loading