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
50 changes: 33 additions & 17 deletions frontend/src/features/alerting-rules/components/table.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react'
import type { TFunction } from 'i18next'
import { Crosshair, Lock } from 'lucide-react'
import { ResizableTableHeader } from '@/shared/components/ui/resizable-table-header'
import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns'
import { cn } from '@/shared/lib/utils'
import type { CorrelationRule } from '../services/alerting-rules-http.service'
import { impactKey } from '../lib/impact-key'
Expand All @@ -11,30 +13,44 @@ import { Toggle } from './toggle'

const TH = 'whitespace-nowrap px-3 py-2.5 text-left align-middle font-medium'
const TD = 'whitespace-nowrap px-3 py-2.5 align-middle'
const ALERTING_RULES_TABLE_COLS = [48, 360, 220, 160, 180, 160, 90, 90]
const IMPACT_TONE: Record<string, string> = { high: 'text-red-500', medium: 'text-amber-500', low: 'text-sky-500', none: 'text-muted-foreground' }

export function Table({ rules, selected, onToggleSelected, onSelectAll, onOpen, onToggle, t, footer }: { rules: CorrelationRule[]; selected: Set<string>; onToggleSelected: (relPath: string) => void; onSelectAll: (checked: boolean) => void; onOpen: (r: CorrelationRule) => void; onToggle: (r: CorrelationRule, next: boolean) => void; t: TFunction; footer?: ReactNode }) {
const allChecked = rules.length > 0 && rules.every((r) => selected.has(r.relPath))
const someChecked = !allChecked && rules.some((r) => selected.has(r.relPath))
const alertingRulesLabelMins = colMins([
t('alertingRules.table.name'),
t('alertingRules.table.dataTypes'),
t('alertingRules.table.category'),
t('alertingRules.table.technique'),
t('alertingRules.table.adversary'),
t('alertingRules.table.impact'),
t('alertingRules.table.active'),
])
const { widths, startDrag } = useResizableColumns(ALERTING_RULES_TABLE_COLS, {
min: [48, ...alertingRulesLabelMins],
storageKey: 'alerting-rules-table-columns',
})
return (
<div className="mt-4 min-h-0 flex-1 overflow-auto rounded-xl border border-border">
<table className="min-w-full border-collapse">
<thead className="sticky top-0 z-10 bg-muted/90 text-[10px] uppercase tracking-wider text-muted-foreground">
<tr className="border-b border-border">
<th className={`${TH} text-center`}>
<div className="flex justify-center">
<SelectAllCheckbox checked={allChecked} indeterminate={someChecked} onChange={onSelectAll} label={t('alertingRules.table.selectAll')} />
</div>
</th>
<th className={TH}>{t('alertingRules.table.name')}</th>
<th className={TH}>{t('alertingRules.table.dataTypes')}</th>
<th className={TH}>{t('alertingRules.table.category')}</th>
<th className={TH}>{t('alertingRules.table.technique')}</th>
<th className={TH}>{t('alertingRules.table.adversary')}</th>
<th className={`${TH} text-center`}>{t('alertingRules.table.impact')}</th>
<th className={`${TH} text-center`}>{t('alertingRules.table.active')}</th>
</tr>
</thead>
<table className="min-w-full border-collapse table-fixed">
<ResizableTableHeader
cells={[
{ content: <div className="flex justify-center"><SelectAllCheckbox checked={allChecked} indeterminate={someChecked} onChange={onSelectAll} label={t('alertingRules.table.selectAll')} /></div>, className: `${TH} text-center` },
{ content: t('alertingRules.table.name'), className: TH },
{ content: t('alertingRules.table.dataTypes'), className: TH },
{ content: t('alertingRules.table.category'), className: TH },
{ content: t('alertingRules.table.technique'), className: TH },
{ content: t('alertingRules.table.adversary'), className: TH },
{ content: t('alertingRules.table.impact'), className: `${TH} text-center` },
{ content: t('alertingRules.table.active'), className: `${TH} text-center` },
]}
widths={widths}
startDrag={startDrag}
className="sticky top-0 z-10 bg-muted/90 text-[10px] uppercase tracking-wider text-muted-foreground"
rowClassName="border-b border-border"
/>
<tbody>
{rules.map((r) => {
const dts = (r.dataTypes ?? []).filter((d) => d.included).map((d) => d.dataType)
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/features/alerts/components/alert-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,16 @@ export function AlertRow({
onCreateRule={() => onCreateRule(a)}
/>
</td>
<td className={`${TD} font-mono mx-auto text-[11px] text-muted-foreground`} title={a.technique}>
{a.technique || '—'}
<td className={`${TD} overflow-hidden font-mono text-[11px] text-muted-foreground`} title={a.technique}>
<span className="block truncate">{a.technique || '—'}</span>
</td>
<td className={TD}>
<EndpointMini ep={a.target} />
</td>
<td className={TD}>
<EndpointMini ep={a.adversary} accent />
<div className="block truncate">
<EndpointMini ep={a.adversary} accent />
</div>
</td>
<td className={`${TD} text-center`}>
<span
Expand Down
58 changes: 38 additions & 20 deletions frontend/src/features/alerts/components/alerts-table-header.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,47 @@
import { useTranslation } from 'react-i18next'
import { ResizableTableHeader } from '@/shared/components/ui/resizable-table-header'
import type { ColSize, useResizableColumns } from '@/shared/hooks/useResizableColumns'

const TH = 'whitespace-nowrap px-3 py-2.5 text-left align-middle font-medium'
export const ALERTS_TABLE_COLS = [6, 36, 38, 38, 360, 130, 180, 160, 160, 90, 90, 160]
// Icon/checkbox tracks (indices 0-3) keep their exact width as min; label
// tracks get a floor sized to keep both the uppercase heading and the row
// content (alert name, mitre code, endpoint pill, timestamp) legible.
export const ALERTS_TABLE_MINS = [6, 36, 38, 38, 220, 120, 160, 140, 140, 120, 120, 130]

export function AlertsTableHeader({ allChecked, onTogglePage }: { allChecked: boolean; onTogglePage: () => void }) {
export function AlertsTableHeader({
allChecked,
widths,
startDrag,
onTogglePage,
}: {
allChecked: boolean
widths: ColSize[]
startDrag: ReturnType<typeof useResizableColumns>['startDrag']
onTogglePage: () => void
}) {
const { t } = useTranslation()
return (
<thead className="sticky top-0 z-10 bg-muted/90 text-[10px] uppercase tracking-wider text-muted-foreground">
<tr className="border-b border-border">
<th className={`${TH} w-[6px] p-0`} aria-hidden />
<th className={`${TH} w-px`}>
<button onClick={onTogglePage} className="flex h-4 w-4 items-center justify-center rounded border border-input">
{allChecked && <span className="h-2 w-2 rounded-sm bg-primary" />}
</button>
</th>
<th className={`${TH} text-center`} colSpan={2}>{t('alerts.table.actions')}</th>
<th className={TH}>{t('alerts.table.alert')}</th>
<th className={TH}>{t('alerts.table.status')}</th>
<th className={TH}>{t('alerts.table.technique')}</th>
<th className={TH}>{t('alerts.table.source')}</th>
<th className={TH}>{t('alerts.table.adversary')}</th>
<th className={`${TH} text-center`}>{t('alerts.table.severity')}</th>
<th className={`${TH} text-center`}>{t('alerts.table.echoes')}</th>
<th className={`${TH} text-center`}>{t('alerts.table.time')}</th>
</tr>
</thead>
<ResizableTableHeader
cells={[
{ content: null, className: `${TH} w-[6px] p-0` },
{ content: <button onClick={onTogglePage} className="flex h-4 w-4 items-center justify-center rounded border border-input">{allChecked && <span className="h-2 w-2 rounded-sm bg-primary" />}</button>, className: `${TH} w-px` },
{ content: t('alerts.table.actions'), className: `${TH} text-center` },
{ content: null, className: `${TH} text-center` },
{ content: t('alerts.table.alert'), className: TH },
{ content: t('alerts.table.status'), className: TH },
{ content: t('alerts.table.technique'), className: TH },
{ content: t('alerts.table.source'), className: TH },
{ content: t('alerts.table.adversary'), className: TH },
{ content: t('alerts.table.severity'), className: `${TH} text-center` },
{ content: t('alerts.table.echoes'), className: `${TH} text-center` },
{ content: t('alerts.table.time'), className: `${TH} text-center` },
]}
widths={widths}
startDrag={startDrag}
className="sticky top-0 z-10 bg-muted/90 text-[10px] uppercase tracking-wider text-muted-foreground"
rowClassName="border-b border-border"
/>
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import { Tag as TagIcon } from 'lucide-react'
import type { TaggingRule } from '../types/tagging-rule.types'
import { TAGGING_RULES_TABLE_COLS } from './tagging-rules-table'

export function TaggingRulesTableRow({
rule,
tableCols,
onOpen,
}: {
rule: TaggingRule
tableCols: string
onOpen: (rule: TaggingRule) => void
}) {
const tags = rule.tags ?? []
return (
<div
onClick={() => onOpen(rule)}
className="grid cursor-pointer items-center gap-3 border-b border-border/60 px-4 py-3 text-sm last:border-b-0 hover:bg-muted/30"
style={{ gridTemplateColumns: TAGGING_RULES_TABLE_COLS }}
className="grid w-max min-w-full cursor-pointer items-center gap-3 border-b border-border/60 px-4 py-3 text-sm last:border-b-0 hover:bg-muted/30"
style={{ gridTemplateColumns: tableCols }}
>
<div className="min-w-0">
<div className="truncate font-medium">{rule.name}</div>
Expand Down
28 changes: 17 additions & 11 deletions frontend/src/features/alerts/components/tagging-rules-table.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useTranslation } from 'react-i18next'
import { ResizableGridHeader } from '@/shared/components/ui/resizable-grid-header'
import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns'
import type { TaggingRule } from '../types/tagging-rule.types'
import { TaggingRulesTableRow } from './tagging-rules-table-row'

export const TAGGING_RULES_TABLE_COLS = '1.6fr 0.9fr 90px'
export const TAGGING_RULES_TABLE_COLS = ['1.6fr', '0.9fr', 90]

export function TaggingRulesTable({
rules,
Expand All @@ -12,18 +14,22 @@ export function TaggingRulesTable({
onOpen: (rule: TaggingRule) => void
}) {
const { t } = useTranslation()
const taggingHeaders = [t('taggingRules.table.rule'), t('taggingRules.table.tags'), t('taggingRules.table.conditions')]
const { template: tableCols, startDrag } = useResizableColumns(TAGGING_RULES_TABLE_COLS, {
min: colMins(taggingHeaders),
storageKey: 'tagging-rules-table-columns',
})
return (
<div className="mt-4 min-h-0 flex-1 overflow-y-auto rounded-xl border border-border">
<div
className="grid items-center gap-3 border-b border-border bg-muted/30 px-4 py-2.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground"
style={{ gridTemplateColumns: TAGGING_RULES_TABLE_COLS }}
>
<div>{t('taggingRules.table.rule')}</div>
<div>{t('taggingRules.table.tags')}</div>
<div className="text-center">{t('taggingRules.table.conditions')}</div>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-x-auto overflow-y-auto rounded-xl border border-border">
<ResizableGridHeader
headers={taggingHeaders}
tableCols={tableCols}
startDrag={startDrag}
className="bg-muted/30 py-2.5 font-medium"
cellClassName="last:text-center"
/>
{rules.map((rule) => (
<TaggingRulesTableRow key={rule.id} rule={rule} onOpen={onOpen} />
<TaggingRulesTableRow key={rule.id} rule={rule} tableCols={tableCols} onOpen={onOpen} />
))}
</div>
)
Expand Down
25 changes: 22 additions & 3 deletions frontend/src/features/alerts/pages/AlertsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Button } from '@/shared/components/ui/button'
import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll'
import { ConfirmDialog } from '@/shared/components/ui/confirm-dialog'
import { presetRange, type TimeRange, resolveRange } from '@/shared/components/ui/time-range-picker'
import { useResizableColumns } from '@/shared/hooks/useResizableColumns'
import { FILTER_OPS, TS } from '../lib/alert-meta'
import { alertToRuleConditions } from '../lib/tagging-rule-meta'
import {
Expand All @@ -30,7 +31,7 @@ import { AlertsStatusTabs } from '../components/alerts-status-tabs'
import { AlertsVolumeCard } from '../components/alerts-volume-card'
import { AlertsBreakdownCard } from '../components/alerts-breakdown-card'
import { AlertsBulkBar } from '../components/alerts-bulk-bar'
import { AlertsTableHeader, ALERTS_TABLE_COLUMN_COUNT } from '../components/alerts-table-header'
import { AlertsTableHeader, ALERTS_TABLE_COLS, ALERTS_TABLE_COLUMN_COUNT, ALERTS_TABLE_MINS } from '../components/alerts-table-header'
import { AlertRow } from '../components/alert-row'
import { EchoesTimeline } from '../components/echoes-timeline'
import { AlertDrawer } from '../components/alert-drawer'
Expand Down Expand Up @@ -58,6 +59,14 @@ export function AlertsPage() {
const [selected, setSelected] = useState<Set<string>>(new Set())
const [expandedEchoes, setExpandedEchoes] = useState<Set<string>>(new Set())
const [openAlert, setOpenAlert] = useState<Alert | null>(null)
const { widths: alertTableWidths, startDrag: startAlertTableDrag } = useResizableColumns(ALERTS_TABLE_COLS, {
min: ALERTS_TABLE_MINS,
storageKey: 'alerts-table-columns',
})
const alertTableWidth = alertTableWidths.reduce<number>(
(total, width) => total + (typeof width === 'number' ? width : 0),
0,
)
const [incidentTargets, setIncidentTargets] = useState<Alert[] | null>(null)
// Tagging-rule drawer is rendered here so the tag editor / rule button don't
// have to bounce through the tagging-rules page.
Expand Down Expand Up @@ -333,8 +342,18 @@ export function AlertsPage() {
}}
>
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-full border-collapse">
<AlertsTableHeader allChecked={allChecked} onTogglePage={togglePage} />
<table className="border-collapse table-fixed" style={{ minWidth: '100%', width: `${alertTableWidth}px` }}>
<colgroup>
{alertTableWidths.map((width, index) => (
<col key={index} style={{ width: typeof width === 'number' ? `${width}px` : width }} />
))}
</colgroup>
<AlertsTableHeader
allChecked={allChecked}
widths={alertTableWidths}
startDrag={startAlertTableDrag}
onTogglePage={togglePage}
/>
<tbody>
{loading && alerts.length === 0 ? (
<tr>
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/features/api-keys/components/KeyRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { ApiKey } from '../types/api-key.types'
import { IconAction } from './IconAction'
import { StatusBadge } from './StatusBadge'

export const COLS = '1.4fr 1.2fr 110px 110px 110px 100px 110px'
export const API_KEY_TABLE_COLS = ['1.4fr', '1.2fr', 110, 110, 110, 100, 110]

function formatDate(iso: string): string {
const d = new Date(iso)
Expand All @@ -14,20 +14,22 @@ function formatDate(iso: string): string {

export function KeyRow({
apiKey: k,
tableCols,
onEdit,
onRotate,
onDelete,
}: {
apiKey: ApiKey
tableCols: string
onEdit: () => void
onRotate: () => void
onDelete: () => void
}) {
const { t } = useTranslation()
return (
<div
className="grid items-center gap-3 border-b border-border px-4 py-3 text-xs last:border-b-0 hover:bg-muted/30"
style={{ gridTemplateColumns: COLS }}
className="grid w-max min-w-full items-center gap-3 border-b border-border px-4 py-3 text-xs last:border-b-0 hover:bg-muted/30"
style={{ gridTemplateColumns: tableCols }}
>
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
Expand Down
35 changes: 25 additions & 10 deletions frontend/src/features/api-keys/pages/ApiKeysPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import { cn } from '@/shared/lib/utils'
import { Button } from '@/shared/components/ui/button'
import { Input } from '@/shared/components/ui/input'
import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll'
import { ColumnResizeHandle } from '@/shared/components/ui/column-resize-handle'
import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns'
import { useBilling } from '@/features/billing'
import { EnterpriseGate } from '@/shared/components/EnterpriseGate'
import { apiKeysHttpService } from '../services/api-keys-http.service'
import type { ApiKey, ApiKeyPageInfo } from '../types/api-key.types'
import { COLS, KeyRow } from '../components/KeyRow'
import { API_KEY_TABLE_COLS, KeyRow } from '../components/KeyRow'
import { UpsertDialog } from '../components/UpsertDialog'
import { ConfirmDialog } from '../components/ConfirmDialog'
import { RevealModal } from '../components/RevealModal'
Expand All @@ -39,6 +41,19 @@ export function ApiKeysPage() {
const [dialog, setDialog] = useState<DialogState>(null)
const [confirm, setConfirm] = useState<ConfirmState>(null)
const [revealed, setRevealed] = useState<{ name: string; token: string } | null>(null)
const apiKeyHeaders = [
t('apiKeys.col.name'),
t('apiKeys.col.allowedIps'),
t('apiKeys.col.created'),
t('apiKeys.col.lastRotated'),
t('apiKeys.col.expires'),
t('apiKeys.col.status'),
t('apiKeys.col.actions'),
]
const { template: tableCols, startDrag } = useResizableColumns(API_KEY_TABLE_COLS, {
min: colMins(apiKeyHeaders),
storageKey: 'api-keys-table-columns',
})

const load = useCallback(async () => {
setLoading(true)
Expand Down Expand Up @@ -129,18 +144,17 @@ export function ApiKeysPage() {
</Button>
</div>

<div className="mt-3 overflow-hidden rounded-xl border border-border bg-card">
<div className="mt-3 overflow-x-auto overflow-y-hidden rounded-xl border border-border bg-card">
<div
className="grid items-center gap-3 border-b border-border bg-muted/40 px-4 py-2 text-[10px] uppercase tracking-wider text-muted-foreground"
style={{ gridTemplateColumns: COLS }}
style={{ gridTemplateColumns: tableCols }}
>
<div>{t('apiKeys.col.name')}</div>
<div>{t('apiKeys.col.allowedIps')}</div>
<div>{t('apiKeys.col.created')}</div>
<div>{t('apiKeys.col.lastRotated')}</div>
<div>{t('apiKeys.col.expires')}</div>
<div>{t('apiKeys.col.status')}</div>
<div className="text-right">{t('apiKeys.col.actions')}</div>
{apiKeyHeaders.map((header, index, headers) => (
<div key={index} data-resizable-col className="relative min-w-0 pr-2 last:pr-0 last:text-right">
{header}
{index < headers.length - 1 && <ColumnResizeHandle onMouseDown={startDrag(index)} />}
</div>
))}
</div>

{loading && (!keys || keys.length === 0) && (
Expand Down Expand Up @@ -173,6 +187,7 @@ export function ApiKeysPage() {
<KeyRow
key={k.id}
apiKey={k}
tableCols={tableCols}
onEdit={() => setDialog({ mode: 'edit', key: k })}
onRotate={() => setConfirm({ kind: 'rotate', key: k })}
onDelete={() => setConfirm({ kind: 'delete', key: k })}
Expand Down
Loading
Loading