From 96b902f2da36c238f19b50e8ac18991dff60c908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Thu, 10 Sep 2026 07:39:39 -0600 Subject: [PATCH 1/4] feat[frontend](): added shared column resize --- .../components/ui/column-resize-handle.tsx | 25 ++++++ .../shared/hooks/useResizableColumns.test.ts | 35 +++++++++ .../src/shared/hooks/useResizableColumns.ts | 78 +++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 frontend/src/shared/components/ui/column-resize-handle.tsx create mode 100644 frontend/src/shared/hooks/useResizableColumns.test.ts create mode 100644 frontend/src/shared/hooks/useResizableColumns.ts diff --git a/frontend/src/shared/components/ui/column-resize-handle.tsx b/frontend/src/shared/components/ui/column-resize-handle.tsx new file mode 100644 index 000000000..cfefd74c4 --- /dev/null +++ b/frontend/src/shared/components/ui/column-resize-handle.tsx @@ -0,0 +1,25 @@ +import type { MouseEvent } from 'react' +import { cn } from '@/shared/lib/utils' + +interface Props { + onMouseDown: (e: MouseEvent) => void + className?: string +} + +export function ColumnResizeHandle({ onMouseDown, className }: Props) { + return ( + e.stopPropagation()} + className={cn( + 'absolute right-0 top-0 z-20 h-full w-2 cursor-col-resize select-none touch-none', + 'bg-transparent hover:bg-primary/40 active:bg-primary', + 'transition-colors', + className, + )} + aria-label="Resize column" + /> + ) +} diff --git a/frontend/src/shared/hooks/useResizableColumns.test.ts b/frontend/src/shared/hooks/useResizableColumns.test.ts new file mode 100644 index 000000000..4e7dc6dbd --- /dev/null +++ b/frontend/src/shared/hooks/useResizableColumns.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import { useResizableColumns } from './useResizableColumns' + +describe('useResizableColumns', () => { + test('initial template joins px numbers and passes strings through', () => { + const { result } = renderHook(() => useResizableColumns([32, '1fr', 120])) + expect(result.current.template).toBe('32px 1fr 120px') + }) + + test('drag updates the column width and rebuilds the template', () => { + const { result } = renderHook(() => useResizableColumns([100, '1fr', 60])) + act(() => { + result.current.setWidths((prev) => { + const next = prev.slice() + next[0] = 180 + return next + }) + }) + expect(result.current.widths[0]).toBe(180) + expect(result.current.template).toBe('180px 1fr 60px') + }) + + test('min clamp is enforced when consumers write below it via drag', () => { + // The clamp lives in the drag handler; sanity-check the default min is 40. + const { result } = renderHook(() => useResizableColumns([100])) + act(() => { + result.current.setWidths([10]) + }) + // setWidths trusts the caller; the drag handler applies min. This asserts + // that the hook doesn't silently rewrite direct sets. + expect(result.current.widths[0]).toBe(10) + expect(result.current.template).toBe('10px') + }) +}) diff --git a/frontend/src/shared/hooks/useResizableColumns.ts b/frontend/src/shared/hooks/useResizableColumns.ts new file mode 100644 index 000000000..5c82e317a --- /dev/null +++ b/frontend/src/shared/hooks/useResizableColumns.ts @@ -0,0 +1,78 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { MouseEvent as ReactMouseEvent } from 'react' + +export type ColSize = string | number + +interface Opts { + min?: number + storageKey?: string +} + +const readStored = (key: string, len: number): ColSize[] | null => { + if (typeof window === 'undefined') return null + try { + const raw = localStorage.getItem(key) + if (!raw) return null + const parsed = JSON.parse(raw) + if (Array.isArray(parsed) && parsed.length === len) return parsed as ColSize[] + } catch { /* ignore */ } + return null +} + +export function useResizableColumns(initial: ColSize[], opts: Opts = {}) { + const min = opts.min ?? 40 + const storageKey = opts.storageKey + const [widths, setWidths] = useState(() => + storageKey ? readStored(storageKey, initial.length) ?? initial : initial, + ) + const dragRef = useRef<{ index: number; startX: number; startW: number } | null>(null) + + useEffect(() => { + if (!storageKey || typeof window === 'undefined') return + try { localStorage.setItem(storageKey, JSON.stringify(widths)) } catch { /* ignore */ } + }, [widths, storageKey]) + + useEffect(() => { + const onMove = (e: globalThis.MouseEvent) => { + const d = dragRef.current + if (!d) return + e.preventDefault() + const w = Math.max(min, d.startW + (e.clientX - d.startX)) + setWidths((prev) => { + const next = prev.slice() + next[d.index] = w + return next + }) + } + const onUp = () => { + if (!dragRef.current) return + dragRef.current = null + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + return () => { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + }, [min]) + + const startDrag = useCallback( + (index: number) => (e: ReactMouseEvent) => { + e.preventDefault() + e.stopPropagation() + const handle = e.currentTarget as HTMLElement + const cell = handle.closest('[data-resizable-col]') as HTMLElement | null ?? handle.parentElement + const startW = cell?.getBoundingClientRect().width ?? min + dragRef.current = { index, startX: e.clientX, startW } + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + }, + [min], + ) + + const template = widths.map((w) => (typeof w === 'number' ? `${w}px` : w)).join(' ') + + return { widths, template, setWidths, startDrag } +} From 7190c0f77890f878cca508ed114c27fe19b1b88d Mon Sep 17 00:00:00 2001 From: Andres Aguilera Date: Thu, 10 Sep 2026 14:29:40 -0300 Subject: [PATCH 2/4] feat: implement resizable columns in various tables - Added resizable columns functionality to ParsingFiltersPage, ExecutionsView, FlowsPage, TeamPage, UserAuditorPage, and Threat Intel components. - Introduced ResizableTableHeader and ResizableGridHeader components for better table header management. - Updated useResizableColumns hook to support dynamic column resizing and storage. - Enhanced UI with ColumnResizeHandle for visual feedback during resizing. - Adjusted styles and layout for improved responsiveness and usability across affected components. --- .../alerting-rules/components/table.tsx | 41 ++++++++------ .../alerts/components/alerts-table-header.tsx | 54 ++++++++++++------- .../components/tagging-rules-table-row.tsx | 7 +-- .../alerts/components/tagging-rules-table.tsx | 27 ++++++---- .../src/features/alerts/pages/AlertsPage.tsx | 25 +++++++-- .../features/api-keys/components/KeyRow.tsx | 8 +-- .../features/api-keys/pages/ApiKeysPage.tsx | 34 ++++++++---- .../src/features/audit/pages/AuditPage.tsx | 39 ++++++++++---- .../components/renderers/TableRenderer.tsx | 22 ++++---- .../datasources/pages/DataSourcesPage.tsx | 44 ++++++++++----- .../create-incident-step-alerts.tsx | 2 +- .../incident-alerts-picker-header.tsx | 33 +++++++----- .../incidents/components/incidents-table.tsx | 37 ++++++++----- .../features/incidents/lib/incident-meta.ts | 2 +- .../components/LogExplorerView.tsx | 14 ++++- .../log-explorer/components/log-results.tsx | 51 ++++++++++++------ .../pages/ParsingFiltersPage.tsx | 33 ++++++++---- .../soar/components/ExecutionsView.tsx | 44 +++++++++------ .../src/features/soar/pages/FlowsPage.tsx | 44 ++++++++++----- frontend/src/features/team/pages/TeamPage.tsx | 40 +++++++++----- .../threat-intel/components/FeedRow.tsx | 9 ++-- .../threat-intel/components/FeedsHeader.tsx | 20 +++---- .../threat-intel/components/FeedsList.tsx | 17 ++++-- .../threat-intel/components/IocRow.tsx | 9 ++-- .../threat-intel/components/IocTable.tsx | 39 ++++++++------ .../user-auditor/pages/UserAuditorPage.tsx | 49 ++++++++++------- .../components/ui/column-resize-handle.tsx | 9 ++-- .../components/ui/resizable-grid-header.tsx | 37 +++++++++++++ .../components/ui/resizable-table-header.tsx | 50 +++++++++++++++++ .../src/shared/hooks/useResizableColumns.ts | 35 +++++------- 30 files changed, 592 insertions(+), 283 deletions(-) create mode 100644 frontend/src/shared/components/ui/resizable-grid-header.tsx create mode 100644 frontend/src/shared/components/ui/resizable-table-header.tsx diff --git a/frontend/src/features/alerting-rules/components/table.tsx b/frontend/src/features/alerting-rules/components/table.tsx index 15b55f95c..ecabcf85c 100644 --- a/frontend/src/features/alerting-rules/components/table.tsx +++ b/frontend/src/features/alerting-rules/components/table.tsx @@ -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 { 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' @@ -11,30 +13,35 @@ 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 = { 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; 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 { widths, startDrag } = useResizableColumns(ALERTING_RULES_TABLE_COLS, { + min: 48, + storageKey: 'alerting-rules-table-columns', + }) return (
- - - - - - - - - - - - - +
-
- -
-
{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')}
+ , 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" + /> {rules.map((r) => { const dts = (r.dataTypes ?? []).filter((d) => d.included).map((d) => d.dataType) diff --git a/frontend/src/features/alerts/components/alerts-table-header.tsx b/frontend/src/features/alerts/components/alerts-table-header.tsx index 9e5ca1f4f..27d94317c 100644 --- a/frontend/src/features/alerts/components/alerts-table-header.tsx +++ b/frontend/src/features/alerts/components/alerts-table-header.tsx @@ -1,29 +1,43 @@ 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] -export function AlertsTableHeader({ allChecked, onTogglePage }: { allChecked: boolean; onTogglePage: () => void }) { +export function AlertsTableHeader({ + allChecked, + widths, + startDrag, + onTogglePage, +}: { + allChecked: boolean + widths: ColSize[] + startDrag: ReturnType['startDrag'] + onTogglePage: () => void +}) { const { t } = useTranslation() return ( - - - - - - - - - - - - - - + {allChecked && }, 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" + /> ) } diff --git a/frontend/src/features/alerts/components/tagging-rules-table-row.tsx b/frontend/src/features/alerts/components/tagging-rules-table-row.tsx index 08405f517..3d4dfcd2a 100644 --- a/frontend/src/features/alerts/components/tagging-rules-table-row.tsx +++ b/frontend/src/features/alerts/components/tagging-rules-table-row.tsx @@ -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 (
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 }} >
{rule.name}
diff --git a/frontend/src/features/alerts/components/tagging-rules-table.tsx b/frontend/src/features/alerts/components/tagging-rules-table.tsx index 658132493..f8aecb94a 100644 --- a/frontend/src/features/alerts/components/tagging-rules-table.tsx +++ b/frontend/src/features/alerts/components/tagging-rules-table.tsx @@ -1,8 +1,10 @@ import { useTranslation } from 'react-i18next' +import { ResizableGridHeader } from '@/shared/components/ui/resizable-grid-header' +import { 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, @@ -12,18 +14,21 @@ export function TaggingRulesTable({ onOpen: (rule: TaggingRule) => void }) { const { t } = useTranslation() + const { template: tableCols, startDrag } = useResizableColumns(TAGGING_RULES_TABLE_COLS, { + min: 60, + storageKey: 'tagging-rules-table-columns', + }) return ( -
-
-
{t('taggingRules.table.rule')}
-
{t('taggingRules.table.tags')}
-
{t('taggingRules.table.conditions')}
-
+
+ {rules.map((rule) => ( - + ))}
) diff --git a/frontend/src/features/alerts/pages/AlertsPage.tsx b/frontend/src/features/alerts/pages/AlertsPage.tsx index 470630fd9..255ee5ac1 100644 --- a/frontend/src/features/alerts/pages/AlertsPage.tsx +++ b/frontend/src/features/alerts/pages/AlertsPage.tsx @@ -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 { @@ -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 } from '../components/alerts-table-header' import { AlertRow } from '../components/alert-row' import { EchoesTimeline } from '../components/echoes-timeline' import { AlertDrawer } from '../components/alert-drawer' @@ -58,6 +59,14 @@ export function AlertsPage() { const [selected, setSelected] = useState>(new Set()) const [expandedEchoes, setExpandedEchoes] = useState>(new Set()) const [openAlert, setOpenAlert] = useState(null) + const { widths: alertTableWidths, startDrag: startAlertTableDrag } = useResizableColumns(ALERTS_TABLE_COLS, { + min: 6, + storageKey: 'alerts-table-columns', + }) + const alertTableWidth = alertTableWidths.reduce( + (total, width) => total + (typeof width === 'number' ? width : 0), + 0, + ) const [incidentTargets, setIncidentTargets] = useState(null) // Tagging-rule drawer is rendered here so the tag editor / rule button don't // have to bounce through the tagging-rules page. @@ -333,8 +342,18 @@ export function AlertsPage() { }} >
-
- - - {t('alerts.table.actions')}{t('alerts.table.alert')}{t('alerts.table.status')}{t('alerts.table.technique')}{t('alerts.table.source')}{t('alerts.table.adversary')}{t('alerts.table.severity')}{t('alerts.table.echoes')}{t('alerts.table.time')}
- +
+ + {alertTableWidths.map((width, index) => ( + + ))} + + {loading && alerts.length === 0 ? ( diff --git a/frontend/src/features/api-keys/components/KeyRow.tsx b/frontend/src/features/api-keys/components/KeyRow.tsx index f00ab2443..49d82e296 100644 --- a/frontend/src/features/api-keys/components/KeyRow.tsx +++ b/frontend/src/features/api-keys/components/KeyRow.tsx @@ -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) @@ -14,11 +14,13 @@ function formatDate(iso: string): string { export function KeyRow({ apiKey: k, + tableCols, onEdit, onRotate, onDelete, }: { apiKey: ApiKey + tableCols: string onEdit: () => void onRotate: () => void onDelete: () => void @@ -26,8 +28,8 @@ export function KeyRow({ const { t } = useTranslation() return (
diff --git a/frontend/src/features/api-keys/pages/ApiKeysPage.tsx b/frontend/src/features/api-keys/pages/ApiKeysPage.tsx index d4563efae..a8dea30d0 100644 --- a/frontend/src/features/api-keys/pages/ApiKeysPage.tsx +++ b/frontend/src/features/api-keys/pages/ApiKeysPage.tsx @@ -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 { 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' @@ -39,6 +41,10 @@ export function ApiKeysPage() { const [dialog, setDialog] = useState(null) const [confirm, setConfirm] = useState(null) const [revealed, setRevealed] = useState<{ name: string; token: string } | null>(null) + const { template: tableCols, startDrag } = useResizableColumns(API_KEY_TABLE_COLS, { + min: 60, + storageKey: 'api-keys-table-columns', + }) const load = useCallback(async () => { setLoading(true) @@ -129,18 +135,25 @@ export function ApiKeysPage() {
-
+
-
{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')}
+ {[ + 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'), + ].map((header, index, headers) => ( +
+ {header} + {index < headers.length - 1 && } +
+ ))}
{loading && (!keys || keys.length === 0) && ( @@ -173,6 +186,7 @@ export function ApiKeysPage() { setDialog({ mode: 'edit', key: k })} onRotate={() => setConfirm({ kind: 'rotate', key: k })} onDelete={() => setConfirm({ kind: 'delete', key: k })} diff --git a/frontend/src/features/audit/pages/AuditPage.tsx b/frontend/src/features/audit/pages/AuditPage.tsx index f5a716fc8..594ef3820 100644 --- a/frontend/src/features/audit/pages/AuditPage.tsx +++ b/frontend/src/features/audit/pages/AuditPage.tsx @@ -17,11 +17,14 @@ import { useDateFormat } from '@/shared/lib/datetime' 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 { useResizableColumns } from '@/shared/hooks/useResizableColumns' import { auditHttpService } from '../services/audit-http.service' import { humanizeAction } from '../lib' import type { AuditListQuery, AuditLog } from '../types/audit.types' const DEFAULT_PAGE_SIZE = 50 +const AUDIT_TABLE_COLS = [180, 140, '1fr', 180, 90, 140, 60] /* ─── Page ─────────────────────────────────────────────────────────────── */ @@ -433,16 +436,31 @@ function TableCard({ }) { const { t } = useTranslation() const { formatDateTime } = useDateFormat() + const { template, startDrag } = useResizableColumns(AUDIT_TABLE_COLS, { + min: 60, + storageKey: 'audit-log-table-columns', + }) + const headers = [ + t('audit.table.timestamp'), + t('audit.table.actor'), + t('audit.table.action'), + t('audit.table.resource'), + t('audit.table.status'), + t('audit.table.ip'), + '', + ] return ( -
-
-
{t('audit.table.timestamp')}
-
{t('audit.table.actor')}
-
{t('audit.table.action')}
-
{t('audit.table.resource')}
-
{t('audit.table.status')}
-
{t('audit.table.ip')}
-
+
+
+ {headers.map((header, index) => ( +
+ {header} + {index < headers.length - 1 && } +
+ ))}
{loading && data.length === 0 ? (
@@ -459,7 +477,8 @@ function TableCard({
- - - {columns.map((c) => ( - - ))} - - + ({ content: c, className: 'px-3 py-2 font-medium' }))} + widths={widths} + startDrag={startDrag} + className="sticky top-0 bg-card" + rowClassName="border-b border-border text-left text-xs uppercase tracking-wide text-muted-foreground" + /> {rows.map((row, i) => ( diff --git a/frontend/src/features/datasources/pages/DataSourcesPage.tsx b/frontend/src/features/datasources/pages/DataSourcesPage.tsx index 572b672f3..d320c3fa2 100644 --- a/frontend/src/features/datasources/pages/DataSourcesPage.tsx +++ b/frontend/src/features/datasources/pages/DataSourcesPage.tsx @@ -31,6 +31,8 @@ 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 { useResizableColumns } from '@/shared/hooks/useResizableColumns' import { TimeRangePicker, presetRange, type TimeRange } from '@/shared/components/ui/time-range-picker' import { datasourcesHttpService as svc, @@ -135,6 +137,10 @@ export function DataSourcesPage() { const [range, setRange] = useState(() => presetRange('24h')) const [counts, setCounts] = useState | null>(null) const [openId, setOpenId] = useState(null) + const { template: listCols, startDrag } = useResizableColumns(LIST_COLS, { + min: 40, + storageKey: 'datasources-table-columns', + }) useEffect(() => { const h = setTimeout(() => { @@ -287,10 +293,10 @@ export function DataSourcesPage() { ) : sources.length === 0 ? ( {t('datasources.none')} ) : layout === 'list' ? ( -
- +
+ {sources.map((s) => ( - setOpenId(s.id)} onLabelClick={filterByLabel} /> + setOpenId(s.id)} onLabelClick={filterByLabel} /> ))}
) : ( @@ -536,32 +542,42 @@ function Toolbar({ /* ─── List ─────────────────────────────────────────────────────────────── */ -const LIST_COLS = '36px 1fr 150px 110px 120px 120px' +const LIST_COLS = [36, '1fr', 150, 110, 120, 120] -function ListHeader() { +function ListHeader({ tableCols, startDrag }: { tableCols: string; startDrag: ReturnType['startDrag'] }) { const { t } = useTranslation() + const headers = [ + '', + t('datasources.cols.source'), + t('datasources.cols.type'), + t('datasources.cols.status'), + t('datasources.cols.events24h'), + t('datasources.cols.lastSeen'), + ] return (
-
-
{t('datasources.cols.source')}
-
{t('datasources.cols.type')}
-
{t('datasources.cols.status')}
-
{t('datasources.cols.events24h')}
-
{t('datasources.cols.lastSeen')}
+ {headers.map((header, index) => ( +
+ {header} + {index < headers.length - 1 && } +
+ ))}
) } function SourceListRow({ source: s, + tableCols, events24h, onOpen, onLabelClick, }: { source: Datasource + tableCols: string events24h: number onOpen: () => void onLabelClick: (label: string) => void @@ -573,8 +589,8 @@ function SourceListRow({ return (
diff --git a/frontend/src/features/incidents/components/create-incident-step-alerts.tsx b/frontend/src/features/incidents/components/create-incident-step-alerts.tsx index e4681a394..75df73558 100644 --- a/frontend/src/features/incidents/components/create-incident-step-alerts.tsx +++ b/frontend/src/features/incidents/components/create-incident-step-alerts.tsx @@ -58,7 +58,7 @@ export function CreateIncidentStepAlerts({
-
- {c} -
+
onToggleAll(alerts)} /> {loading && alerts.length === 0 ? ( diff --git a/frontend/src/features/incidents/components/incident-alerts-picker-header.tsx b/frontend/src/features/incidents/components/incident-alerts-picker-header.tsx index a16a76faa..cb7e92af6 100644 --- a/frontend/src/features/incidents/components/incident-alerts-picker-header.tsx +++ b/frontend/src/features/incidents/components/incident-alerts-picker-header.tsx @@ -1,6 +1,9 @@ import { useTranslation } from 'react-i18next' +import { ResizableTableHeader } from '@/shared/components/ui/resizable-table-header' +import { useResizableColumns } from '@/shared/hooks/useResizableColumns' const TH = 'whitespace-nowrap px-3 py-2.5 text-left align-middle font-medium' +const INCIDENT_ALERTS_TABLE_COLS = [6, 36, 360, 90, 160] export function IncidentAlertsPickerHeader({ allChecked, @@ -10,19 +13,23 @@ export function IncidentAlertsPickerHeader({ onTogglePage: () => void }) { const { t } = useTranslation() + const { widths, startDrag } = useResizableColumns(INCIDENT_ALERTS_TABLE_COLS, { + min: 6, + storageKey: 'incident-alerts-picker-table-columns', + }) return ( - - - - - - - - + {allChecked && }, className: `${TH} w-px` }, + { content: t('alerts.table.alert'), className: TH }, + { content: t('alerts.table.severity'), 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" + /> ) } diff --git a/frontend/src/features/incidents/components/incidents-table.tsx b/frontend/src/features/incidents/components/incidents-table.tsx index 8b32b75d5..0487c3ec0 100644 --- a/frontend/src/features/incidents/components/incidents-table.tsx +++ b/frontend/src/features/incidents/components/incidents-table.tsx @@ -1,4 +1,6 @@ import { useTranslation } from 'react-i18next' +import { ResizableGridHeader } from '@/shared/components/ui/resizable-grid-header' +import { useResizableColumns } from '@/shared/hooks/useResizableColumns' import { cn } from '@/shared/lib/utils' import { useDateFormat } from '@/shared/lib/datetime' import { SEV_TONE, TABLE_COLS, sevKey } from '../lib/incident-meta' @@ -9,25 +11,32 @@ import { IncidentAssignee } from './incident-assignee' export function IncidentsTable({ incidents, onOpen }: { incidents: Incident[]; onOpen: (i: Incident) => void }) { const { t } = useTranslation() const df = useDateFormat() + const { template: tableCols, startDrag } = useResizableColumns(TABLE_COLS, { + min: 60, + storageKey: 'incidents-table-columns', + }) return ( -
-
-
{t('incidents.table.name')}
-
{t('incidents.table.status')}
-
{t('incidents.table.severity')}
-
{t('incidents.table.assignee')}
-
{t('incidents.table.alerts')}
-
{t('incidents.table.created')}
-
+
+ {incidents.map((i) => ( )} + {i < columns.length - 1 && resizeHandle(i + 3)}
)) )} @@ -121,6 +140,7 @@ function ResultRowImpl({ doc, columns, autoColumns = [], + tableCols, expanded, onToggle, onAdd, @@ -130,6 +150,7 @@ function ResultRowImpl({ doc: LogDocument columns: string[] autoColumns?: string[] + tableCols?: string expanded: boolean onToggle: (index: number) => void onAdd?: (f: FilterType) => void @@ -148,10 +169,10 @@ function ResultRowImpl({
onToggle(index)} className={cn( - 'grid cursor-pointer items-center gap-3 border-b border-border/40 px-4 py-1 text-xs leading-tight transition-colors last:border-b-0', + 'grid w-max min-w-full cursor-pointer items-center gap-3 border-b border-border/40 px-4 py-1 text-xs leading-tight transition-colors last:border-b-0', expanded ? 'bg-muted/30' : 'hover:bg-muted/20' )} - style={{ gridTemplateColumns: gridTemplate(columns, autoColumns) }} + style={{ gridTemplateColumns: tableCols ?? gridTemplate(columns, autoColumns) }} > diff --git a/frontend/src/features/parsing-filters/pages/ParsingFiltersPage.tsx b/frontend/src/features/parsing-filters/pages/ParsingFiltersPage.tsx index 0559db204..f6820f01a 100644 --- a/frontend/src/features/parsing-filters/pages/ParsingFiltersPage.tsx +++ b/frontend/src/features/parsing-filters/pages/ParsingFiltersPage.tsx @@ -7,6 +7,8 @@ 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 { ResizableTableHeader } from '@/shared/components/ui/resizable-table-header' +import { useResizableColumns } from '@/shared/hooks/useResizableColumns' import { pipelinesHttpService } from '@/features/data-processing/services/data-processing-http.service' import type { Pipeline } from '@/features/data-processing/types/data-processing.types' import { TestPlaygroundModal } from '@/features/playground/components/TestPlaygroundModal' @@ -19,6 +21,7 @@ const TABS: Tab[] = ['all', 'active', 'inactive', 'system', 'user'] 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 PARSING_FILTERS_TABLE_COLS = [320, 220, 120, 90, 48, 48] // The name the engine matches on: the file's base name without its extension. function pipelineIdentity(relPath: string): string { @@ -44,6 +47,10 @@ export function ParsingFiltersPage() { const [editing, setEditing] = useState<{ filter: Pipeline; creating: boolean } | null>(null) const [preparingNew, setPreparingNew] = useState(false) const [showTestModal, setShowTestModal] = useState(false) + const { widths, startDrag } = useResizableColumns(PARSING_FILTERS_TABLE_COLS, { + min: 48, + storageKey: 'parsing-filters-table-columns', + }) // Deep-link: ?dataType= pre-filters the list to that data type // (e.g. opened from an integration's "Filters" button). @@ -237,17 +244,21 @@ export function ParsingFiltersPage() {
-
- - - {t('alerts.table.alert')}{t('alerts.table.severity')}{t('alerts.table.time')}
- - - - - - - - +
{t('parsingFilters.cols.filter')}{t('parsingFilters.cols.dataTypes')}{t('parsingFilters.cols.type')}{t('parsingFilters.cols.active')} - -
+ {loading && items.length === 0 ? (
diff --git a/frontend/src/features/soar/components/ExecutionsView.tsx b/frontend/src/features/soar/components/ExecutionsView.tsx index b4cb124a6..24f4a5120 100644 --- a/frontend/src/features/soar/components/ExecutionsView.tsx +++ b/frontend/src/features/soar/components/ExecutionsView.tsx @@ -13,6 +13,8 @@ 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 { ResizableGridHeader } from "@/shared/components/ui/resizable-grid-header"; +import { useResizableColumns } from "@/shared/hooks/useResizableColumns"; import { presetRange, resolveRange, @@ -48,7 +50,7 @@ const STATUSES: (ExecutionStatus | "all")[] = [ ]; const ORIGINS: (ExecutionOrigin | "all")[] = ["all", "FLOW", "MANUAL"]; const COLS = - "90px 100px minmax(160px,1.2fr) minmax(180px,1.6fr) 120px 150px 60px"; + [90, 100, "minmax(160px,1.2fr)", "minmax(180px,1.6fr)", 120, 150, 60]; const STATUS_META: Record< ExecutionStatus, @@ -78,6 +80,10 @@ export function ExecutionsView() { // each node's position in the flow's DAG (its ancestor chain) in the Node // column — the flow itself carries no per-run state, only its shape. const [runFlows, setRunFlows] = useState>({}); + const { template: tableCols, startDrag } = useResizableColumns(COLS, { + min: 60, + storageKey: "soar-executions-table-columns", + }); const [page, setPage] = useState(0); const [pageSize] = useState(50); const [loading, setLoading] = useState(true); @@ -264,19 +270,22 @@ export function ExecutionsView() {
-
-
{t("soar.executions.cols.status")}
-
{t("soar.executions.cols.node")}
-
{t("soar.executions.cols.flow")}
-
{t("soar.executions.cols.command")}
-
{t("soar.executions.cols.agent")}
-
{t("soar.executions.cols.date")}
-
{t("soar.executions.cols.retries")}
-
-
+
+ {loading && items.length === 0 ? (
{" "} @@ -306,6 +315,7 @@ export function ExecutionsView() { key={e.id} e={e} flow={e.rulePath ? runFlows[e.rulePath] : undefined} + tableCols={tableCols} df={df} t={t} /> @@ -327,11 +337,13 @@ export function ExecutionsView() { function ExecutionRow({ e, flow, + tableCols, df, t, }: { e: Execution; flow?: Flow; + tableCols: string; df: ReturnType; t: ReturnType["t"]; }) { @@ -357,8 +369,8 @@ function ExecutionRow({ return (
>({}) + const { widths, startDrag } = useResizableColumns(FLOWS_TABLE_COLS, { + min: 60, + storageKey: 'soar-flows-table-columns', + }) + const flowsTableWidth = widths.reduce( + (total, width) => total + (typeof width === 'number' ? width : 0), + 0, + ) const openStartFrom = () => { setStarting(true) @@ -190,18 +201,27 @@ export function FlowsPage() {
- - - - - - - - - - - +
{t('soar.cols.flow')}{t('soar.cols.platform')}{t('soar.cols.conditions')}{t('soar.cols.commands')}{t('soar.cols.lastRun')}{t('soar.cols.active')} -
+ + {widths.map((width, index) => ( + + ))} + + {loading && items.length === 0 ? ( diff --git a/frontend/src/features/team/pages/TeamPage.tsx b/frontend/src/features/team/pages/TeamPage.tsx index b3e4fe200..25cfbb1e5 100644 --- a/frontend/src/features/team/pages/TeamPage.tsx +++ b/frontend/src/features/team/pages/TeamPage.tsx @@ -26,6 +26,8 @@ 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 { useResizableColumns } from '@/shared/hooks/useResizableColumns' import { SUPPORTED_LANGUAGES } from '@/shared/i18n' import { rolesHttpService, TeamHttpError, usersHttpService } from '../services/team-http.service' import type { @@ -40,6 +42,7 @@ import type { } from '../types/team.types' const PAGE_SIZE = 20 +const MEMBER_COLS = ['1.7fr', '1fr', 90, 130, 40] /* Roles & permissions arrive from the backend as English DB strings. Translate the * stable identifiers (role name, permission resource/action) with a fallback to the @@ -160,6 +163,10 @@ function MembersView({ roles }: { roles: Role[] }) { const [openId, setOpenId] = useState(null) const [inviteOpen, setInviteOpen] = useState(false) + const { template: memberCols, startDrag } = useResizableColumns(MEMBER_COLS, { + min: 40, + storageKey: 'team-members-table-columns', + }) // Debounce the search box, and reset to page 1 when the query changes. useEffect(() => { @@ -207,16 +214,23 @@ function MembersView({ roles }: { roles: Role[] }) { -
+
-
{t('team.members.colUser')}
-
{t('team.members.colRoles')}
-
{t('team.members.col2fa')}
-
{t('team.members.colStatus')}
-
+ {[ + t('team.members.colUser'), + t('team.members.colRoles'), + t('team.members.col2fa'), + t('team.members.colStatus'), + '', + ].map((header, index, headers) => ( +
+ {header} + {index < headers.length - 1 && } +
+ ))}
{loading && (!users || users.length === 0) && ( @@ -245,7 +259,7 @@ function MembersView({ roles }: { roles: Role[] }) { )} {users && users.length > 0 && - users.map((u) => setOpenId(u.id)} />)} + users.map((u) => setOpenId(u.id)} />)}
{users && users.length > 0 && ( @@ -280,18 +294,16 @@ function MembersView({ roles }: { roles: Role[] }) { ) } -const MEMBER_COLS = '1.7fr 1fr 90px 130px 40px' - -function MemberRow({ user: u, onOpen }: { user: UserListItem; onOpen: () => void }) { +function MemberRow({ user: u, tableCols, onOpen }: { user: UserListItem; tableCols: string; onOpen: () => void }) { const { t } = useTranslation() return (
diff --git a/frontend/src/features/threat-intel/components/FeedRow.tsx b/frontend/src/features/threat-intel/components/FeedRow.tsx index 5fa8f2a1f..faf965426 100644 --- a/frontend/src/features/threat-intel/components/FeedRow.tsx +++ b/frontend/src/features/threat-intel/components/FeedRow.tsx @@ -5,18 +5,17 @@ import { feedAccuracyMeta, feedTypeTone } from './utils/severity-style' interface FeedRowProps { feed: ThreatFeed + tableCols: string } -const FEED_COLS = '12px 1fr 160px 140px' - -export function FeedRow({ feed }: FeedRowProps) { +export function FeedRow({ feed, tableCols }: FeedRowProps) { const { t } = useTranslation() const acc = feedAccuracyMeta(feed.accuracy) return (
{feed.name}
diff --git a/frontend/src/features/threat-intel/components/FeedsHeader.tsx b/frontend/src/features/threat-intel/components/FeedsHeader.tsx index 00da0697a..d3382d951 100644 --- a/frontend/src/features/threat-intel/components/FeedsHeader.tsx +++ b/frontend/src/features/threat-intel/components/FeedsHeader.tsx @@ -1,18 +1,14 @@ import { useTranslation } from 'react-i18next' +import { ResizableGridHeader } from '@/shared/components/ui/resizable-grid-header' +import type { useResizableColumns } from '@/shared/hooks/useResizableColumns' -const FEED_COLS = '12px 1fr 160px 140px' - -export function FeedsHeader() { +export function FeedsHeader({ tableCols, startDrag }: { tableCols: string; startDrag: ReturnType['startDrag'] }) { const { t } = useTranslation() return ( -
-
-
{t('threatIntel.feeds.table.name')}
-
{t('threatIntel.feeds.table.type')}
-
{t('threatIntel.feeds.table.accuracy')}
-
+ ) } diff --git a/frontend/src/features/threat-intel/components/FeedsList.tsx b/frontend/src/features/threat-intel/components/FeedsList.tsx index ec8d8159a..3a9469164 100644 --- a/frontend/src/features/threat-intel/components/FeedsList.tsx +++ b/frontend/src/features/threat-intel/components/FeedsList.tsx @@ -1,18 +1,25 @@ import { useTranslation } from 'react-i18next' +import { useResizableColumns } from '@/shared/hooks/useResizableColumns' import { useTiFeeds } from '../hooks/use-ti-feeds' import { FeedRow } from './FeedRow' import { FeedsHeader } from './FeedsHeader' +const FEED_COLS = [12, '1fr', 160, 140] + export function FeedsList() { const { t } = useTranslation() const { data, isLoading } = useTiFeeds() + const { template: tableCols, startDrag } = useResizableColumns(FEED_COLS, { + min: 36, + storageKey: 'threat-intel-feed-table-columns', + }) if (data?.kind === 'not-configured') return null if (isLoading) { return ( -
- +
+ {Array.from({ length: 5 }).map((_, i) => (
- +
+ {feeds.map((feed) => ( - + ))}
) diff --git a/frontend/src/features/threat-intel/components/IocRow.tsx b/frontend/src/features/threat-intel/components/IocRow.tsx index e3cfc4108..b8daad2cb 100644 --- a/frontend/src/features/threat-intel/components/IocRow.tsx +++ b/frontend/src/features/threat-intel/components/IocRow.tsx @@ -7,12 +7,11 @@ import { absTimestamp } from './utils/time-format' interface IocRowProps { ioc: EntitySummary + tableCols: string onOpen: (id: string) => void } -const IOC_COLS = '4px 90px 1fr 130px 1fr 110px 36px' - -export function IocRow({ ioc, onOpen }: IocRowProps) { +export function IocRow({ ioc, tableCols, onOpen }: IocRowProps) { const { t } = useTranslation() const tone = reputationTone(ioc.reputation) const rep = REPUTATION_STYLE[tone] @@ -22,8 +21,8 @@ export function IocRow({ ioc, onOpen }: IocRowProps) { return (
onOpen(ioc.id)} - className="group grid cursor-pointer items-center gap-3 border-b border-border/60 px-4 py-2.5 text-xs hover:bg-muted/40 last:border-b-0" - style={{ gridTemplateColumns: IOC_COLS }} + className="group grid w-max min-w-full cursor-pointer items-center gap-3 border-b border-border/60 px-4 py-2.5 text-xs hover:bg-muted/40 last:border-b-0" + style={{ gridTemplateColumns: tableCols }} >
diff --git a/frontend/src/features/threat-intel/components/IocTable.tsx b/frontend/src/features/threat-intel/components/IocTable.tsx index 2b565c21b..72729aafe 100644 --- a/frontend/src/features/threat-intel/components/IocTable.tsx +++ b/frontend/src/features/threat-intel/components/IocTable.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' +import { ResizableGridHeader } from '@/shared/components/ui/resizable-grid-header' +import { useResizableColumns } from '@/shared/hooks/useResizableColumns' import type { EntitySummary } from '../domain/threat-intel.types' import { Pagination } from '@/shared/components/ui/pagination' import { IocRow } from './IocRow' @@ -17,7 +19,7 @@ interface IocTableProps { onLoadMore?: () => void } -const IOC_COLS = '4px 90px 1fr 130px 1fr 110px 36px' +const IOC_COLS = [4, 90, '1fr', 130, '1fr', 110, 36] export function IocTable({ iocs, @@ -34,6 +36,10 @@ export function IocTable({ const { t } = useTranslation() const scrollRef = useRef(null) const sentinelRef = useRef(null) + const { template: tableCols, startDrag } = useResizableColumns(IOC_COLS, { + min: 36, + storageKey: 'threat-intel-ioc-table-columns', + }) useEffect(() => { const node = sentinelRef.current @@ -52,21 +58,24 @@ export function IocTable({ return ( <>
-
-
-
{t('threatIntel.iocs.table.type')}
-
{t('threatIntel.iocs.table.indicator')}
-
{t('threatIntel.iocs.table.reputation')}
-
{t('threatIntel.iocs.table.tags')}
-
{t('threatIntel.iocs.table.lastSeen')}
-
-
-
+
+ {iocs.map((ioc) => ( - onOpen(ioc.id)} /> + onOpen(ioc.id)} /> ))} {!isLoading && iocs.length === 0 && (
{t('threatIntel.iocs.empty')}
diff --git a/frontend/src/features/user-auditor/pages/UserAuditorPage.tsx b/frontend/src/features/user-auditor/pages/UserAuditorPage.tsx index f54a97a1a..a6e3daf8c 100644 --- a/frontend/src/features/user-auditor/pages/UserAuditorPage.tsx +++ b/frontend/src/features/user-auditor/pages/UserAuditorPage.tsx @@ -27,6 +27,8 @@ 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 { useResizableColumns } from '@/shared/hooks/useResizableColumns' import { CustomFilterBar } from '@/shared/components/filters/CustomFilterBar' import type { CustomFilter, @@ -38,6 +40,7 @@ import type { ADUser, ADUserSource, ADUserStats, ADUserStatus } from '../types/a const SIZE = 50 const STALE_MS = 30 * 86_400_000 +const LIST_COLS = [32, '1fr', '1.3fr', 110, 110, 110, 90, 36] type ViewId = 'all' | ADUserSource const VIEW_IDS: ViewId[] = ['all', 'windows', 'linux'] @@ -105,6 +108,10 @@ export function UserAuditorPage() { const [error, setError] = useState(false) const [stats, setStats] = useState(null) const [openUser, setOpenUser] = useState(null) + const { template: listCols, startDrag } = useResizableColumns(LIST_COLS, { + min: 36, + storageKey: 'user-auditor-table-columns', + }) const filterFields: FilterFieldDef[] = [ { field: 'status', label: t('userAuditor.filterFields.status') }, @@ -238,12 +245,12 @@ export function UserAuditorPage() {
) : layout === 'list' ? ( -
- +
+ {loading && users.length === 0 ? ( ) : ( - users.map((u) => setOpenUser(u)} t={t} />) + users.map((u) => setOpenUser(u)} t={t} />) )} {!loading && users.length === 0 && (
@@ -483,22 +490,28 @@ function Toolbar({ /* ─── List ─────────────────────────────────────────────────────────────── */ -const LIST_COLS = '32px 1fr 1.3fr 110px 110px 110px 90px 36px' - -function ListHeader({ t }: { t: TFunction }) { +function ListHeader({ t, tableCols, startDrag }: { t: TFunction; tableCols: string; startDrag: ReturnType['startDrag'] }) { + const headers = [ + '', + t('userAuditor.list.account'), + t('userAuditor.list.identity'), + t('userAuditor.list.status'), + t('userAuditor.list.lastLogon'), + t('userAuditor.list.lastSeen'), + t('userAuditor.list.tenant'), + '', + ] return (
-
-
{t('userAuditor.list.account')}
-
{t('userAuditor.list.identity')}
-
{t('userAuditor.list.status')}
-
{t('userAuditor.list.lastLogon')}
-
{t('userAuditor.list.lastSeen')}
-
{t('userAuditor.list.tenant')}
-
+ {headers.map((header, index) => ( +
+ {header} + {index < headers.length - 1 && } +
+ ))}
) } @@ -511,14 +524,14 @@ function LoadingRows() { ) } -function UserListRow({ user, onOpen, t }: { user: ADUser; onOpen: () => void; t: TFunction }) { +function UserListRow({ user, tableCols, onOpen, t }: { user: ADUser; tableCols: string; onOpen: () => void; t: TFunction }) { const status = statusOf(user) const identity = accountIdentity(user) return (
diff --git a/frontend/src/shared/components/ui/column-resize-handle.tsx b/frontend/src/shared/components/ui/column-resize-handle.tsx index cfefd74c4..2f327f474 100644 --- a/frontend/src/shared/components/ui/column-resize-handle.tsx +++ b/frontend/src/shared/components/ui/column-resize-handle.tsx @@ -1,4 +1,5 @@ import type { MouseEvent } from 'react' +import { GripVertical } from 'lucide-react' import { cn } from '@/shared/lib/utils' interface Props { @@ -14,12 +15,14 @@ export function ColumnResizeHandle({ onMouseDown, className }: Props) { onMouseDown={onMouseDown} onClick={(e) => e.stopPropagation()} className={cn( - 'absolute right-0 top-0 z-20 h-full w-2 cursor-col-resize select-none touch-none', - 'bg-transparent hover:bg-primary/40 active:bg-primary', + 'group absolute right-0 top-0 z-20 flex h-full w-3 cursor-col-resize select-none items-center justify-center touch-none', + 'bg-transparent hover:bg-primary/20 active:bg-primary/30', 'transition-colors', className, )} aria-label="Resize column" - /> + > + + ) } diff --git a/frontend/src/shared/components/ui/resizable-grid-header.tsx b/frontend/src/shared/components/ui/resizable-grid-header.tsx new file mode 100644 index 000000000..42b8ee7cc --- /dev/null +++ b/frontend/src/shared/components/ui/resizable-grid-header.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react' +import type { useResizableColumns } from '@/shared/hooks/useResizableColumns' +import { cn } from '@/shared/lib/utils' +import { ColumnResizeHandle } from './column-resize-handle' + +interface ResizableGridHeaderProps { + headers: ReactNode[] + tableCols: string + startDrag: ReturnType['startDrag'] + className?: string + cellClassName?: string +} + +export function ResizableGridHeader({ + headers, + tableCols, + startDrag, + className, + cellClassName, +}: ResizableGridHeaderProps) { + return ( +
+ {headers.map((header, index) => ( +
+ {header} + {index < headers.length - 1 && } +
+ ))} +
+ ) +} diff --git a/frontend/src/shared/components/ui/resizable-table-header.tsx b/frontend/src/shared/components/ui/resizable-table-header.tsx new file mode 100644 index 000000000..5df916545 --- /dev/null +++ b/frontend/src/shared/components/ui/resizable-table-header.tsx @@ -0,0 +1,50 @@ +import type { CSSProperties, ReactNode } from 'react' +import type { ColSize, useResizableColumns } from '@/shared/hooks/useResizableColumns' +import { cn } from '@/shared/lib/utils' +import { ColumnResizeHandle } from './column-resize-handle' + +export interface ResizableTableHeaderCell { + content: ReactNode + className?: string +} + +interface ResizableTableHeaderProps { + cells: ResizableTableHeaderCell[] + widths: ColSize[] + startDrag: ReturnType['startDrag'] + className?: string + rowClassName?: string + cellClassName?: string +} + +const widthStyle = (width: ColSize | undefined): CSSProperties | undefined => { + if (width == null) return undefined + return { width: typeof width === 'number' ? `${width}px` : width } +} + +export function ResizableTableHeader({ + cells, + widths, + startDrag, + className, + rowClassName, + cellClassName, +}: ResizableTableHeaderProps) { + return ( +
+ + {cells.map((cell, index) => ( + + ))} + + + ) +} diff --git a/frontend/src/shared/hooks/useResizableColumns.ts b/frontend/src/shared/hooks/useResizableColumns.ts index 5c82e317a..5b882553e 100644 --- a/frontend/src/shared/hooks/useResizableColumns.ts +++ b/frontend/src/shared/hooks/useResizableColumns.ts @@ -8,29 +8,15 @@ interface Opts { storageKey?: string } -const readStored = (key: string, len: number): ColSize[] | null => { - if (typeof window === 'undefined') return null - try { - const raw = localStorage.getItem(key) - if (!raw) return null - const parsed = JSON.parse(raw) - if (Array.isArray(parsed) && parsed.length === len) return parsed as ColSize[] - } catch { /* ignore */ } - return null -} - export function useResizableColumns(initial: ColSize[], opts: Opts = {}) { const min = opts.min ?? 40 const storageKey = opts.storageKey - const [widths, setWidths] = useState(() => - storageKey ? readStored(storageKey, initial.length) ?? initial : initial, - ) - const dragRef = useRef<{ index: number; startX: number; startW: number } | null>(null) + const [widths, setWidths] = useState(initial) + const dragRef = useRef<{ index: number; startX: number; startW: number; measured: number[] } | null>(null) useEffect(() => { - if (!storageKey || typeof window === 'undefined') return - try { localStorage.setItem(storageKey, JSON.stringify(widths)) } catch { /* ignore */ } - }, [widths, storageKey]) + setWidths(initial) + }, [initial.length, storageKey]) useEffect(() => { const onMove = (e: globalThis.MouseEvent) => { @@ -39,7 +25,7 @@ export function useResizableColumns(initial: ColSize[], opts: Opts = {}) { e.preventDefault() const w = Math.max(min, d.startW + (e.clientX - d.startX)) setWidths((prev) => { - const next = prev.slice() + const next = d.measured.length === prev.length ? d.measured.slice() : prev.slice() next[d.index] = w return next }) @@ -64,8 +50,15 @@ export function useResizableColumns(initial: ColSize[], opts: Opts = {}) { e.stopPropagation() const handle = e.currentTarget as HTMLElement const cell = handle.closest('[data-resizable-col]') as HTMLElement | null ?? handle.parentElement - const startW = cell?.getBoundingClientRect().width ?? min - dragRef.current = { index, startX: e.clientX, startW } + const row = cell?.parentElement + const measured = row + ? Array.from(row.children) + .filter((child): child is HTMLElement => child instanceof HTMLElement && child.hasAttribute('data-resizable-col')) + .map((child) => Math.max(min, child.getBoundingClientRect().width)) + : [] + const startW = measured[index] ?? cell?.getBoundingClientRect().width ?? min + if (measured.length > 0) setWidths(measured) + dragRef.current = { index, startX: e.clientX, startW, measured } document.body.style.cursor = 'col-resize' document.body.style.userSelect = 'none' }, From f1595875a5dd2f5e623c1f38b0a5e71d19077503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Thu, 10 Sep 2026 15:01:55 -0600 Subject: [PATCH 3/4] feat(frontend): per-column min widths for resizable tables --- .../alerting-rules/components/table.tsx | 13 ++++- .../alerts/components/alerts-table-header.tsx | 3 ++ .../alerts/components/tagging-rules-table.tsx | 7 +-- .../src/features/alerts/pages/AlertsPage.tsx | 4 +- .../features/api-keys/pages/ApiKeysPage.tsx | 23 ++++----- .../src/features/audit/pages/AuditPage.tsx | 10 ++-- .../components/renderers/TableRenderer.tsx | 4 +- .../datasources/pages/DataSourcesPage.tsx | 11 ++++- .../incident-alerts-picker-header.tsx | 9 +++- .../incidents/components/incidents-table.tsx | 21 +++++---- .../components/LogExplorerView.tsx | 11 ++++- .../pages/ParsingFiltersPage.tsx | 20 +++++--- .../soar/components/ExecutionsView.tsx | 23 ++++----- .../src/features/soar/pages/FlowsPage.tsx | 25 ++++++---- frontend/src/features/team/pages/TeamPage.tsx | 20 ++++---- .../threat-intel/components/FeedsList.tsx | 9 +++- .../threat-intel/components/IocTable.tsx | 24 +++++----- .../user-auditor/pages/UserAuditorPage.tsx | 12 ++++- .../shared/hooks/useResizableColumns.test.ts | 14 ++++-- .../src/shared/hooks/useResizableColumns.ts | 47 ++++++++++++++++--- 20 files changed, 210 insertions(+), 100 deletions(-) diff --git a/frontend/src/features/alerting-rules/components/table.tsx b/frontend/src/features/alerting-rules/components/table.tsx index ecabcf85c..b0926d134 100644 --- a/frontend/src/features/alerting-rules/components/table.tsx +++ b/frontend/src/features/alerting-rules/components/table.tsx @@ -2,7 +2,7 @@ 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 { useResizableColumns } from '@/shared/hooks/useResizableColumns' +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' @@ -19,8 +19,17 @@ const IMPACT_TONE: Record = { high: 'text-red-500', medium: 'tex export function Table({ rules, selected, onToggleSelected, onSelectAll, onOpen, onToggle, t, footer }: { rules: CorrelationRule[]; selected: Set; 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, + min: [48, ...alertingRulesLabelMins], storageKey: 'alerting-rules-table-columns', }) return ( diff --git a/frontend/src/features/alerts/components/alerts-table-header.tsx b/frontend/src/features/alerts/components/alerts-table-header.tsx index 27d94317c..2907d597e 100644 --- a/frontend/src/features/alerts/components/alerts-table-header.tsx +++ b/frontend/src/features/alerts/components/alerts-table-header.tsx @@ -4,6 +4,9 @@ import type { ColSize, useResizableColumns } from '@/shared/hooks/useResizableCo 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 the uppercase heading readable at ~11px. +export const ALERTS_TABLE_MINS = [6, 36, 38, 38, 140, 90, 110, 90, 110, 90, 80, 100] export function AlertsTableHeader({ allChecked, diff --git a/frontend/src/features/alerts/components/tagging-rules-table.tsx b/frontend/src/features/alerts/components/tagging-rules-table.tsx index f8aecb94a..89d9a742a 100644 --- a/frontend/src/features/alerts/components/tagging-rules-table.tsx +++ b/frontend/src/features/alerts/components/tagging-rules-table.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next' import { ResizableGridHeader } from '@/shared/components/ui/resizable-grid-header' -import { useResizableColumns } from '@/shared/hooks/useResizableColumns' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import type { TaggingRule } from '../types/tagging-rule.types' import { TaggingRulesTableRow } from './tagging-rules-table-row' @@ -14,14 +14,15 @@ 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: 60, + min: colMins(taggingHeaders), storageKey: 'tagging-rules-table-columns', }) return (
>(new Set()) const [openAlert, setOpenAlert] = useState(null) const { widths: alertTableWidths, startDrag: startAlertTableDrag } = useResizableColumns(ALERTS_TABLE_COLS, { - min: 6, + min: ALERTS_TABLE_MINS, storageKey: 'alerts-table-columns', }) const alertTableWidth = alertTableWidths.reduce( diff --git a/frontend/src/features/api-keys/pages/ApiKeysPage.tsx b/frontend/src/features/api-keys/pages/ApiKeysPage.tsx index a8dea30d0..85c2adc8a 100644 --- a/frontend/src/features/api-keys/pages/ApiKeysPage.tsx +++ b/frontend/src/features/api-keys/pages/ApiKeysPage.tsx @@ -13,7 +13,7 @@ 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 { useResizableColumns } from '@/shared/hooks/useResizableColumns' +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' @@ -41,8 +41,17 @@ export function ApiKeysPage() { const [dialog, setDialog] = useState(null) const [confirm, setConfirm] = useState(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: 60, + min: colMins(apiKeyHeaders), storageKey: 'api-keys-table-columns', }) @@ -140,15 +149,7 @@ export function ApiKeysPage() { 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: tableCols }} > - {[ - 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'), - ].map((header, index, headers) => ( + {apiKeyHeaders.map((header, index, headers) => (
{header} {index < headers.length - 1 && } diff --git a/frontend/src/features/audit/pages/AuditPage.tsx b/frontend/src/features/audit/pages/AuditPage.tsx index 594ef3820..cad1eda3a 100644 --- a/frontend/src/features/audit/pages/AuditPage.tsx +++ b/frontend/src/features/audit/pages/AuditPage.tsx @@ -18,7 +18,7 @@ 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 { useResizableColumns } from '@/shared/hooks/useResizableColumns' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import { auditHttpService } from '../services/audit-http.service' import { humanizeAction } from '../lib' import type { AuditListQuery, AuditLog } from '../types/audit.types' @@ -436,10 +436,6 @@ function TableCard({ }) { const { t } = useTranslation() const { formatDateTime } = useDateFormat() - const { template, startDrag } = useResizableColumns(AUDIT_TABLE_COLS, { - min: 60, - storageKey: 'audit-log-table-columns', - }) const headers = [ t('audit.table.timestamp'), t('audit.table.actor'), @@ -449,6 +445,10 @@ function TableCard({ t('audit.table.ip'), '', ] + const { template, startDrag } = useResizableColumns(AUDIT_TABLE_COLS, { + min: colMins(headers), + storageKey: 'audit-log-table-columns', + }) return (
'minmax(120px, 1fr)'), { - min: 80, + min: colMins(columns), storageKey: `dashboard-table-columns:${columns.join('|')}`, }) diff --git a/frontend/src/features/datasources/pages/DataSourcesPage.tsx b/frontend/src/features/datasources/pages/DataSourcesPage.tsx index d320c3fa2..242ed7663 100644 --- a/frontend/src/features/datasources/pages/DataSourcesPage.tsx +++ b/frontend/src/features/datasources/pages/DataSourcesPage.tsx @@ -32,7 +32,7 @@ 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 { useResizableColumns } from '@/shared/hooks/useResizableColumns' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import { TimeRangePicker, presetRange, type TimeRange } from '@/shared/components/ui/time-range-picker' import { datasourcesHttpService as svc, @@ -137,8 +137,15 @@ export function DataSourcesPage() { const [range, setRange] = useState(() => presetRange('24h')) const [counts, setCounts] = useState | null>(null) const [openId, setOpenId] = useState(null) + const datasourcesLabelMins = colMins([ + t('datasources.cols.source'), + t('datasources.cols.type'), + t('datasources.cols.status'), + t('datasources.cols.events24h'), + t('datasources.cols.lastSeen'), + ]) const { template: listCols, startDrag } = useResizableColumns(LIST_COLS, { - min: 40, + min: [36, ...datasourcesLabelMins], storageKey: 'datasources-table-columns', }) diff --git a/frontend/src/features/incidents/components/incident-alerts-picker-header.tsx b/frontend/src/features/incidents/components/incident-alerts-picker-header.tsx index cb7e92af6..e18e98870 100644 --- a/frontend/src/features/incidents/components/incident-alerts-picker-header.tsx +++ b/frontend/src/features/incidents/components/incident-alerts-picker-header.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next' import { ResizableTableHeader } from '@/shared/components/ui/resizable-table-header' -import { useResizableColumns } from '@/shared/hooks/useResizableColumns' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' const TH = 'whitespace-nowrap px-3 py-2.5 text-left align-middle font-medium' const INCIDENT_ALERTS_TABLE_COLS = [6, 36, 360, 90, 160] @@ -13,8 +13,13 @@ export function IncidentAlertsPickerHeader({ onTogglePage: () => void }) { const { t } = useTranslation() + const pickerLabelMins = colMins([ + t('alerts.table.alert'), + t('alerts.table.severity'), + t('alerts.table.time'), + ]) const { widths, startDrag } = useResizableColumns(INCIDENT_ALERTS_TABLE_COLS, { - min: 6, + min: [6, 36, ...pickerLabelMins], storageKey: 'incident-alerts-picker-table-columns', }) return ( diff --git a/frontend/src/features/incidents/components/incidents-table.tsx b/frontend/src/features/incidents/components/incidents-table.tsx index 0487c3ec0..c08ee30a6 100644 --- a/frontend/src/features/incidents/components/incidents-table.tsx +++ b/frontend/src/features/incidents/components/incidents-table.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next' import { ResizableGridHeader } from '@/shared/components/ui/resizable-grid-header' -import { useResizableColumns } from '@/shared/hooks/useResizableColumns' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import { cn } from '@/shared/lib/utils' import { useDateFormat } from '@/shared/lib/datetime' import { SEV_TONE, TABLE_COLS, sevKey } from '../lib/incident-meta' @@ -11,21 +11,22 @@ import { IncidentAssignee } from './incident-assignee' export function IncidentsTable({ incidents, onOpen }: { incidents: Incident[]; onOpen: (i: Incident) => void }) { const { t } = useTranslation() const df = useDateFormat() + const incidentsHeaders = [ + t('incidents.table.name'), + t('incidents.table.status'), + t('incidents.table.severity'), + t('incidents.table.assignee'), + t('incidents.table.alerts'), + t('incidents.table.created'), + ] const { template: tableCols, startDrag } = useResizableColumns(TABLE_COLS, { - min: 60, + min: colMins(incidentsHeaders), storageKey: 'incidents-table-columns', }) return (
`log-explorer-table-columns:${columns.length > 0 ? columns.join('|') : `auto:${autoColumns.join('|')}`}`, [columns, autoColumns], ) + // Grid mins: three fixed leading tracks (row-actions, indicator, time), + // then either the user-picked field names (manual mode) or source + auto + // fields + a flex message column (default mode). Label-based floors keep + // header names from cropping when a column is dragged narrow. + const logGridMins = columns.length > 0 + ? [20, 3, 168, ...colMins(columns)] + : [20, 3, 168, 96, ...colMins(autoColumns), 96] const { template: tableCols, startDrag } = useResizableColumns(logGridColumnSizes(columns, autoColumns), { - min: 20, + min: logGridMins, storageKey: columnStorageKey, }) diff --git a/frontend/src/features/parsing-filters/pages/ParsingFiltersPage.tsx b/frontend/src/features/parsing-filters/pages/ParsingFiltersPage.tsx index f6820f01a..87eaa3b9f 100644 --- a/frontend/src/features/parsing-filters/pages/ParsingFiltersPage.tsx +++ b/frontend/src/features/parsing-filters/pages/ParsingFiltersPage.tsx @@ -8,7 +8,7 @@ import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll' import { ResizableTableHeader } from '@/shared/components/ui/resizable-table-header' -import { useResizableColumns } from '@/shared/hooks/useResizableColumns' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import { pipelinesHttpService } from '@/features/data-processing/services/data-processing-http.service' import type { Pipeline } from '@/features/data-processing/types/data-processing.types' import { TestPlaygroundModal } from '@/features/playground/components/TestPlaygroundModal' @@ -47,8 +47,16 @@ export function ParsingFiltersPage() { const [editing, setEditing] = useState<{ filter: Pipeline; creating: boolean } | null>(null) const [preparingNew, setPreparingNew] = useState(false) const [showTestModal, setShowTestModal] = useState(false) + const parsingFiltersHeaders = [ + t('parsingFilters.cols.filter'), + t('parsingFilters.cols.dataTypes'), + t('parsingFilters.cols.type'), + t('parsingFilters.cols.active'), + 48, + 48, + ] const { widths, startDrag } = useResizableColumns(PARSING_FILTERS_TABLE_COLS, { - min: 48, + min: colMins(parsingFiltersHeaders), storageKey: 'parsing-filters-table-columns', }) @@ -247,10 +255,10 @@ export function ParsingFiltersPage() {
{t('soar.loading')}
+ {cell.content} + {index < cells.length - 1 && } +
>({}); + const executionsHeaders = [ + t("soar.executions.cols.status"), + t("soar.executions.cols.node"), + t("soar.executions.cols.flow"), + t("soar.executions.cols.command"), + t("soar.executions.cols.agent"), + t("soar.executions.cols.date"), + t("soar.executions.cols.retries"), + ]; const { template: tableCols, startDrag } = useResizableColumns(COLS, { - min: 60, + min: colMins(executionsHeaders), storageKey: "soar-executions-table-columns", }); const [page, setPage] = useState(0); @@ -272,15 +281,7 @@ export function ExecutionsView() {
>({}) + const flowsHeaders = [ + t('soar.cols.flow'), + t('soar.cols.platform'), + t('soar.cols.conditions'), + t('soar.cols.commands'), + t('soar.cols.lastRun'), + t('soar.cols.active'), + '', + ] const { widths, startDrag } = useResizableColumns(FLOWS_TABLE_COLS, { - min: 60, + min: [...colMins(flowsHeaders.slice(0, -1)), 60], storageKey: 'soar-flows-table-columns', }) const flowsTableWidth = widths.reduce( @@ -209,12 +218,12 @@ export function FlowsPage() { (null) const [inviteOpen, setInviteOpen] = useState(false) + const memberHeaders = [ + t('team.members.colUser'), + t('team.members.colRoles'), + t('team.members.col2fa'), + t('team.members.colStatus'), + '', + ] + const memberMins = [...colMins(memberHeaders.slice(0, -1)), 40] const { template: memberCols, startDrag } = useResizableColumns(MEMBER_COLS, { - min: 40, + min: memberMins, storageKey: 'team-members-table-columns', }) @@ -219,13 +227,7 @@ function MembersView({ roles }: { roles: Role[] }) { className="grid w-max min-w-full 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: memberCols }} > - {[ - t('team.members.colUser'), - t('team.members.colRoles'), - t('team.members.col2fa'), - t('team.members.colStatus'), - '', - ].map((header, index, headers) => ( + {memberHeaders.map((header, index, headers) => (
{header} {index < headers.length - 1 && } diff --git a/frontend/src/features/threat-intel/components/FeedsList.tsx b/frontend/src/features/threat-intel/components/FeedsList.tsx index 3a9469164..9c7cd96e4 100644 --- a/frontend/src/features/threat-intel/components/FeedsList.tsx +++ b/frontend/src/features/threat-intel/components/FeedsList.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { useResizableColumns } from '@/shared/hooks/useResizableColumns' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import { useTiFeeds } from '../hooks/use-ti-feeds' import { FeedRow } from './FeedRow' import { FeedsHeader } from './FeedsHeader' @@ -9,8 +9,13 @@ const FEED_COLS = [12, '1fr', 160, 140] export function FeedsList() { const { t } = useTranslation() const { data, isLoading } = useTiFeeds() + const feedLabelMins = colMins([ + t('threatIntel.feeds.table.name'), + t('threatIntel.feeds.table.type'), + t('threatIntel.feeds.table.accuracy'), + ]) const { template: tableCols, startDrag } = useResizableColumns(FEED_COLS, { - min: 36, + min: [12, ...feedLabelMins], storageKey: 'threat-intel-feed-table-columns', }) diff --git a/frontend/src/features/threat-intel/components/IocTable.tsx b/frontend/src/features/threat-intel/components/IocTable.tsx index 72729aafe..ffa054fc4 100644 --- a/frontend/src/features/threat-intel/components/IocTable.tsx +++ b/frontend/src/features/threat-intel/components/IocTable.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { ResizableGridHeader } from '@/shared/components/ui/resizable-grid-header' -import { useResizableColumns } from '@/shared/hooks/useResizableColumns' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import type { EntitySummary } from '../domain/threat-intel.types' import { Pagination } from '@/shared/components/ui/pagination' import { IocRow } from './IocRow' @@ -36,8 +36,18 @@ export function IocTable({ const { t } = useTranslation() const scrollRef = useRef(null) const sentinelRef = useRef(null) + const iocHeaders = [ + '', + t('threatIntel.iocs.table.type'), + t('threatIntel.iocs.table.indicator'), + t('threatIntel.iocs.table.reputation'), + t('threatIntel.iocs.table.tags'), + t('threatIntel.iocs.table.lastSeen'), + '', + ] + const iocLabelMins = colMins(iocHeaders.slice(1, -1)) const { template: tableCols, startDrag } = useResizableColumns(IOC_COLS, { - min: 36, + min: [4, ...iocLabelMins, 36], storageKey: 'threat-intel-ioc-table-columns', }) @@ -60,15 +70,7 @@ export function IocTable({
(null) const [openUser, setOpenUser] = useState(null) + const listLabelMins = colMins([ + t('userAuditor.list.account'), + t('userAuditor.list.identity'), + t('userAuditor.list.status'), + t('userAuditor.list.lastLogon'), + t('userAuditor.list.lastSeen'), + t('userAuditor.list.tenant'), + ]) const { template: listCols, startDrag } = useResizableColumns(LIST_COLS, { - min: 36, + min: [32, ...listLabelMins, 36], storageKey: 'user-auditor-table-columns', }) diff --git a/frontend/src/shared/hooks/useResizableColumns.test.ts b/frontend/src/shared/hooks/useResizableColumns.test.ts index 4e7dc6dbd..e9d67756b 100644 --- a/frontend/src/shared/hooks/useResizableColumns.test.ts +++ b/frontend/src/shared/hooks/useResizableColumns.test.ts @@ -3,9 +3,9 @@ import { act, renderHook } from '@testing-library/react' import { useResizableColumns } from './useResizableColumns' describe('useResizableColumns', () => { - test('initial template joins px numbers and passes strings through', () => { + test('initial template keeps px tracks fixed and floors flex tracks with default min', () => { const { result } = renderHook(() => useResizableColumns([32, '1fr', 120])) - expect(result.current.template).toBe('32px 1fr 120px') + expect(result.current.template).toBe('32px minmax(40px, 1fr) 120px') }) test('drag updates the column width and rebuilds the template', () => { @@ -18,7 +18,7 @@ describe('useResizableColumns', () => { }) }) expect(result.current.widths[0]).toBe(180) - expect(result.current.template).toBe('180px 1fr 60px') + expect(result.current.template).toBe('180px minmax(40px, 1fr) 60px') }) test('min clamp is enforced when consumers write below it via drag', () => { @@ -32,4 +32,12 @@ describe('useResizableColumns', () => { expect(result.current.widths[0]).toBe(10) expect(result.current.template).toBe('10px') }) + + test('per-column min array is used to floor flex tracks in the template', () => { + const { result } = renderHook(() => + useResizableColumns([32, '1fr', '1fr'], { min: [32, 120, 80] }), + ) + expect(result.current.template).toBe('32px minmax(120px, 1fr) minmax(80px, 1fr)') + expect(result.current.mins).toEqual([32, 120, 80]) + }) }) diff --git a/frontend/src/shared/hooks/useResizableColumns.ts b/frontend/src/shared/hooks/useResizableColumns.ts index 5b882553e..4e22f7b56 100644 --- a/frontend/src/shared/hooks/useResizableColumns.ts +++ b/frontend/src/shared/hooks/useResizableColumns.ts @@ -4,12 +4,35 @@ import type { MouseEvent as ReactMouseEvent } from 'react' export type ColSize = string | number interface Opts { - min?: number + min?: number | number[] storageKey?: string } +const DEFAULT_MIN = 40 + +const minAt = (min: number | number[] | undefined, index: number): number => { + if (min == null) return DEFAULT_MIN + if (typeof min === 'number') return min + return min[index] ?? DEFAULT_MIN +} + +// Per-column min widths derived from the header label so a drag/resize can't +// crop the label. Pass a number in the labels array for icon/checkbox columns +// where no label exists — that number is used verbatim as the min. +// ponytail: 15px/char + 30px padding is a rough uppercase-heading approximation; +// tune the charPx/padding options if a specific font stack diverges. +export function colMins( + labels: Array, + opts: { floor?: number; charPx?: number; padding?: number } = {}, +): number[] { + const { floor = 60, charPx = 15, padding = 30 } = opts + return labels.map((l) => + typeof l === 'number' ? l : Math.max(floor, Math.round(l.length * charPx) + padding), + ) +} + export function useResizableColumns(initial: ColSize[], opts: Opts = {}) { - const min = opts.min ?? 40 + const min = opts.min const storageKey = opts.storageKey const [widths, setWidths] = useState(initial) const dragRef = useRef<{ index: number; startX: number; startW: number; measured: number[] } | null>(null) @@ -23,7 +46,7 @@ export function useResizableColumns(initial: ColSize[], opts: Opts = {}) { const d = dragRef.current if (!d) return e.preventDefault() - const w = Math.max(min, d.startW + (e.clientX - d.startX)) + const w = Math.max(minAt(min, d.index), d.startW + (e.clientX - d.startX)) setWidths((prev) => { const next = d.measured.length === prev.length ? d.measured.slice() : prev.slice() next[d.index] = w @@ -54,9 +77,9 @@ export function useResizableColumns(initial: ColSize[], opts: Opts = {}) { const measured = row ? Array.from(row.children) .filter((child): child is HTMLElement => child instanceof HTMLElement && child.hasAttribute('data-resizable-col')) - .map((child) => Math.max(min, child.getBoundingClientRect().width)) + .map((child, i) => Math.max(minAt(min, i), child.getBoundingClientRect().width)) : [] - const startW = measured[index] ?? cell?.getBoundingClientRect().width ?? min + const startW = measured[index] ?? cell?.getBoundingClientRect().width ?? minAt(min, index) if (measured.length > 0) setWidths(measured) dragRef.current = { index, startX: e.clientX, startW, measured } document.body.style.cursor = 'col-resize' @@ -65,7 +88,17 @@ export function useResizableColumns(initial: ColSize[], opts: Opts = {}) { [min], ) - const template = widths.map((w) => (typeof w === 'number' ? `${w}px` : w)).join(' ') + const mins = widths.map((_, i) => minAt(min, i)) + // ponytail: wrap simple fr tracks in minmax so a narrow viewport can't + // collapse labels below their per-column min. Fixed px tracks and strings + // that already declare their own function (e.g. `minmax(120px, 1fr)`) are + // passed through unchanged. + const template = widths + .map((w, i) => { + if (typeof w === 'number') return `${w}px` + return /^[\d.]+fr$/.test(w.trim()) ? `minmax(${mins[i]}px, ${w})` : w + }) + .join(' ') - return { widths, template, setWidths, startDrag } + return { widths, template, setWidths, startDrag, mins } } From 14704d3b79d844efcbabb6bbda424a77cf420dcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Thu, 10 Sep 2026 15:18:03 -0600 Subject: [PATCH 4/4] fix[frontend](alerts): improved alerts table min widths and not truncated texts --- frontend/src/features/alerts/components/alert-row.tsx | 8 +++++--- .../features/alerts/components/alerts-table-header.tsx | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/features/alerts/components/alert-row.tsx b/frontend/src/features/alerts/components/alert-row.tsx index b8031247f..1024a0ee3 100644 --- a/frontend/src/features/alerts/components/alert-row.tsx +++ b/frontend/src/features/alerts/components/alert-row.tsx @@ -130,14 +130,16 @@ export function AlertRow({ onCreateRule={() => onCreateRule(a)} /> -
- {a.technique || '—'} + + {a.technique || '—'} - +
+ +