import type { AuthAuditEvent, NodeAuditAction, NodeAuditEvent, NodeKind, } from './api/client' export type DateRangePreset = 'all' | 'today' | '7d' | '30d' | 'custom' export type SortDirection = 'asc' | 'desc' export type NodeLogsSortKey = | 'at' | 'action' | 'actor' | 'node' | 'kind' | 'detail' export type AuthLogsSortKey = | 'at' | 'action' | 'actor' | 'target' | 'outcome' | 'category' | 'detail' export type AuthOutcomeFilter = 'all' | 'failure' | 'success' export type AuthCategoryFilter = 'all' | AuthAuditEvent['category'] export type AuthActionQuickFilter = 'all' | 'login_failures' | string function normalizeSearchText(value: string): string { return value.trim().toLowerCase() } export function eventMatchesSearch( fields: ReadonlyArray, query: string, ): boolean { const normalizedQuery = normalizeSearchText(query) if (normalizedQuery === '') { return true } return fields.some((field) => normalizeSearchText(field ?? '').includes(normalizedQuery), ) } export function startOfLocalDay(reference: Date = new Date()): Date { return new Date( reference.getFullYear(), reference.getMonth(), reference.getDate(), ) } export function resolveDateRangeBounds( preset: DateRangePreset, customFrom: string, customTo: string, now: Date = new Date(), ): { from: Date | null; to: Date | null } { if (preset === 'all') { return { from: null, to: null } } if (preset === 'today') { return { from: startOfLocalDay(now), to: null } } if (preset === '7d') { const from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) return { from, to: null } } if (preset === '30d') { const from = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) return { from, to: null } } const fromParsed = customFrom ? Date.parse(customFrom) : Number.NaN const toParsed = customTo ? Date.parse(customTo) : Number.NaN return { from: Number.isNaN(fromParsed) ? null : new Date(fromParsed), to: Number.isNaN(toParsed) ? null : new Date(toParsed), } } export function isWithinDateRange( at: string, from: Date | null, to: Date | null, ): boolean { const timestamp = Date.parse(at) if (Number.isNaN(timestamp)) { return false } if (from !== null && timestamp < from.getTime()) { return false } if (to !== null && timestamp > to.getTime()) { return false } return true } function compareStrings(left: string, right: string): number { return left.localeCompare(right, undefined, { sensitivity: 'base' }) } function compareTimestamps(left: string, right: string): number { const leftTime = Date.parse(left) const rightTime = Date.parse(right) if (Number.isNaN(leftTime) && Number.isNaN(rightTime)) { return 0 } if (Number.isNaN(leftTime)) { return 1 } if (Number.isNaN(rightTime)) { return -1 } return leftTime - rightTime } function applySortDirection(comparison: number, direction: SortDirection): number { return direction === 'asc' ? comparison : -comparison } export function filterNodeEvents( events: ReadonlyArray, options: { search: string kind: 'all' | NodeKind action: 'all' | NodeAuditAction dateFrom: Date | null dateTo: Date | null }, ): NodeAuditEvent[] { return events.filter((event) => { if (options.kind !== 'all' && event.node_kind !== options.kind) { return false } if (options.action !== 'all' && event.action !== options.action) { return false } if (!isWithinDateRange(event.at, options.dateFrom, options.dateTo)) { return false } return eventMatchesSearch( [ event.at, event.action, event.actor, event.node_name, event.node_id, event.node_kind, event.detail, ], options.search, ) }) } export function sortNodeEvents( events: ReadonlyArray, sortKey: NodeLogsSortKey, direction: SortDirection, ): NodeAuditEvent[] { const sorted = [...events] sorted.sort((left, right) => { let comparison = 0 switch (sortKey) { case 'at': comparison = compareTimestamps(left.at, right.at) break case 'action': comparison = compareStrings(left.action, right.action) break case 'actor': comparison = compareStrings(left.actor, right.actor) break case 'node': comparison = compareStrings(left.node_name, right.node_name) break case 'kind': comparison = compareStrings(left.node_kind, right.node_kind) break case 'detail': comparison = compareStrings(left.detail ?? '', right.detail ?? '') break } return applySortDirection(comparison, direction) }) return sorted } export function filterAuthEvents( events: ReadonlyArray, options: { search: string outcome: AuthOutcomeFilter category: AuthCategoryFilter actionFilter: AuthActionQuickFilter dateFrom: Date | null dateTo: Date | null }, ): AuthAuditEvent[] { return events.filter((event) => { if (options.outcome !== 'all' && event.outcome !== options.outcome) { return false } if (options.category !== 'all' && event.category !== options.category) { return false } if (options.actionFilter === 'login_failures') { if (event.action !== 'login' || event.outcome !== 'failure') { return false } } else if ( options.actionFilter !== 'all' && event.action !== options.actionFilter ) { return false } if (!isWithinDateRange(event.at, options.dateFrom, options.dateTo)) { return false } return eventMatchesSearch( [ event.at, event.action, event.outcome, event.category, event.actor, event.target, event.detail, ], options.search, ) }) } export function sortAuthEvents( events: ReadonlyArray, sortKey: AuthLogsSortKey, direction: SortDirection, ): AuthAuditEvent[] { const sorted = [...events] sorted.sort((left, right) => { let comparison = 0 switch (sortKey) { case 'at': comparison = compareTimestamps(left.at, right.at) break case 'action': comparison = compareStrings(left.action, right.action) break case 'actor': comparison = compareStrings(left.actor, right.actor) break case 'target': comparison = compareStrings(left.target ?? '', right.target ?? '') break case 'outcome': comparison = compareStrings(left.outcome, right.outcome) break case 'category': comparison = compareStrings(left.category, right.category) break case 'detail': comparison = compareStrings(left.detail ?? '', right.detail ?? '') break } return applySortDirection(comparison, direction) }) return sorted } export function paginateItems( items: ReadonlyArray, page: number, pageSize: number, ): { pageItems: T[]; totalPages: number; safePage: number } { const totalPages = Math.max(1, Math.ceil(items.length / pageSize)) const safePage = Math.min(Math.max(1, page), totalPages) const startIndex = (safePage - 1) * pageSize return { pageItems: items.slice(startIndex, startIndex + pageSize), totalPages, safePage, } } export function pageRangeLabel( totalCount: number, page: number, pageSize: number, ): string { if (totalCount === 0) { return 'Showing 0 of 0' } const start = (page - 1) * pageSize + 1 const end = Math.min(page * pageSize, totalCount) return `Showing ${start}–${end} of ${totalCount}` }