Files
ClusterCanvas/webui/src/logsFiltering.ts
T
Squid 463aa9a7a3 Add activity logs, sidebar badges, and logout with working alert colors.
Record auth audits and surface them in Activity, poll sidebar counts without resetting idle, and add a profile logout path plus theme red/green/blue tokens so failure badges render.
2026-07-18 21:43:44 +02:00

307 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string | undefined | null>,
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<NodeAuditEvent>,
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<NodeAuditEvent>,
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<AuthAuditEvent>,
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<AuthAuditEvent>,
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<T>(
items: ReadonlyArray<T>,
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}`
}