Files
RenewCMS/web/src/lib/activity.ts
T

416 lines
9.9 KiB
TypeScript

import { formatNumber, t } from '@/i18n'
import { formatDateRange } from '@/lib/activityTime'
export const ACTIVITY_CATEGORIES = [
'articles',
'media',
'users',
'api_keys',
'auth',
'system',
] as const
export type ActivityCategory = (typeof ACTIVITY_CATEGORIES)[number]
export type ActivityOutcome = 'success' | 'failed'
export type OutcomeFilter = 'all' | ActivityOutcome
export type ActorType = 'user' | 'api_key' | 'system' | 'anonymous'
export type ActivitySource = 'web' | 'api_key' | 'system'
export type RangeKey = 'today' | '7d' | '30d' | 'custom'
export const PAGE_SIZES = [25, 50, 100] as const
export const DEFAULT_PAGE_SIZE = 25
export const DEFAULT_RANGE: RangeKey = '7d'
export const RANGE_KEYS: RangeKey[] = ['today', '7d', '30d', 'custom']
export const OUTCOME_FILTERS: OutcomeFilter[] = ['all', 'success', 'failed']
export interface ActivityActor {
type: ActorType
id: number | null
name: string
}
export interface ActivityTarget {
type: string
id: string
name: string
exists: boolean
}
export type ActivityChange =
| { field: string; kind: 'text'; before: string; after: string }
| { field: string; kind: 'body'; delta: number }
export interface ActivityItem {
id: number
created_at: string
action: string
category: ActivityCategory
actor: ActivityActor
target: ActivityTarget | null
outcome: ActivityOutcome
failure_reason: string | null
security: boolean
source: ActivitySource
ip: string | null
user_agent: string | null
params: Record<string, unknown>
changes: ActivityChange[]
}
export interface ActivityPage {
items: ActivityItem[]
total: number
page: number
size: number
}
export interface ActivitySummary {
total: number
previous_total: number
active_people: { users: number; api_keys: number }
failed: number
security: number
series: {
total: number[]
active: number[]
failed: number[]
security: number[]
}
}
export interface ActivityFilters {
q: string
actor: string
actorLabel: string
category: ActivityCategory | ''
outcome: OutcomeFilter
security: boolean
range: RangeKey
from: string
to: string
}
export type ChipKey = 'q' | 'actor' | 'category' | 'outcome' | 'security' | 'range'
export interface ActivityChip {
key: ChipKey
label: string
}
export function defaultFilters(): ActivityFilters {
return {
q: '',
actor: '',
actorLabel: '',
category: '',
outcome: 'all',
security: false,
range: DEFAULT_RANGE,
from: '',
to: '',
}
}
export function summaryRangeChanged(a: ActivityFilters, b: ActivityFilters) {
return a.range !== b.range || a.from !== b.from || a.to !== b.to
}
function rangeParams(range: ExportRange, from: string, to: string) {
const params = new URLSearchParams({ range })
if (range === 'custom') {
params.set('from', from)
params.set('to', to)
}
return params
}
function filterParams(filters: ActivityFilters) {
const params = new URLSearchParams()
if (filters.q) params.set('q', filters.q)
if (filters.actor) params.set('actor', filters.actor)
if (filters.category) params.set('category', filters.category)
if (filters.outcome !== 'all') params.set('outcome', filters.outcome)
if (filters.security) params.set('security', 'true')
rangeParams(filters.range, filters.from, filters.to).forEach((value, key) =>
params.set(key, value),
)
return params
}
export function listQuery(filters: ActivityFilters, page: number, size: number, withActor = true) {
const params = filterParams(withActor ? filters : { ...filters, actor: '' })
params.set('page', String(page))
params.set('size', String(size))
return params.toString()
}
export function exportQuery(filters: ActivityFilters) {
return filterParams(filters).toString()
}
export const OWNERS_ONLY_COPY = 'Only owners and admins can export all activity.'
export const NOTHING_TO_EXPORT_COPY = 'Nothing to export. No events match these filters.'
export type ExportRange = 'all' | RangeKey
export const EXPORT_RANGE_KEYS: ExportRange[] = ['all', 'today', '7d', '30d', 'custom']
export function exportRangeLabel(range: ExportRange) {
return range === 'all' ? t('All time') : rangeLabel(range)
}
export function exportAllQuery(range: ExportRange, from: string, to: string) {
return rangeParams(range, from, to).toString()
}
export function isExportRangeValid(range: ExportRange, from: string, to: string) {
return range !== 'custom' || (from !== '' && to !== '' && from <= to)
}
function localDateStamp() {
const now = new Date()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${now.getFullYear()}-${month}-${day}`
}
export function currentViewFileName() {
return `activity-${localDateStamp()}.csv`
}
export function allActivityFileName() {
return `activity-all-${localDateStamp()}.csv`
}
export function readBlobText(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(reader.error)
reader.readAsText(blob)
})
}
export function isHeaderOnlyCsv(text: string) {
return text.trim().split(/\r?\n/).length <= 1
}
export function summaryQuery(filters: ActivityFilters) {
return rangeParams(filters.range, filters.from, filters.to).toString()
}
export function categoryLabel(category: ActivityCategory) {
switch (category) {
case 'articles':
return t('Articles')
case 'media':
return t('Media')
case 'users':
return t('Users and invites')
case 'api_keys':
return t('API keys')
case 'auth':
return t('Authentication')
case 'system':
return t('System')
}
}
export function categoryIcon(category: ActivityCategory) {
switch (category) {
case 'articles':
return 'i-lucide-file-text'
case 'media':
return 'i-lucide-image'
case 'users':
return 'i-lucide-users'
case 'api_keys':
return 'i-lucide-key-round'
case 'auth':
return 'i-lucide-shield-check'
case 'system':
return 'i-lucide-server-cog'
}
}
export function outcomeFilterLabel(outcome: OutcomeFilter) {
switch (outcome) {
case 'all':
return t('All')
case 'success':
return t('Succeeded')
case 'failed':
return t('Failed')
}
}
export function outcomeOptions() {
return OUTCOME_FILTERS.map((value) => ({
value,
label: outcomeFilterLabel(value),
}))
}
export function outcomeBadge(outcome: ActivityOutcome) {
return outcome === 'success'
? { tone: 'ok' as const, label: t('Succeeded') }
: { tone: 'fail' as const, label: t('Failed') }
}
export function rowSurface(selected: boolean, security: boolean) {
return [selected ? 'bg-elevated' : 'bg-default', security ? 'shadow-flag' : '']
}
export function rangeLabel(range: RangeKey) {
switch (range) {
case 'today':
return t('Today')
case '7d':
return t('7 days')
case '30d':
return t('30 days')
case 'custom':
return t('Custom')
}
}
export function activeChips(filters: ActivityFilters): ActivityChip[] {
const chips: ActivityChip[] = []
if (filters.q) chips.push({ key: 'q', label: t('Search: {0}', [filters.q]) })
if (filters.actor)
chips.push({
key: 'actor',
label: t('Person: {0}', [filters.actorLabel]),
})
if (filters.category)
chips.push({
key: 'category',
label: t('Category: {0}', [categoryLabel(filters.category)]),
})
if (filters.outcome !== 'all')
chips.push({
key: 'outcome',
label: t('Outcome: {0}', [outcomeFilterLabel(filters.outcome)]),
})
if (filters.security)
chips.push({
key: 'security',
label: t('Type: {0}', [t('Security events')]),
})
if (filters.range !== DEFAULT_RANGE) {
const value =
filters.range === 'custom'
? formatDateRange(filters.from, filters.to)
: rangeLabel(filters.range)
chips.push({ key: 'range', label: t('Dates: {0}', [value]) })
}
return chips
}
export function clearChip(filters: ActivityFilters, key: ChipKey): ActivityFilters {
const defaults = defaultFilters()
switch (key) {
case 'q':
return { ...filters, q: '' }
case 'actor':
return { ...filters, actor: '', actorLabel: '' }
case 'category':
return { ...filters, category: '' }
case 'outcome':
return { ...filters, outcome: 'all' }
case 'security':
return { ...filters, security: false }
case 'range':
return { ...filters, range: defaults.range, from: '', to: '' }
}
}
export function initials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => Array.from(word)[0])
.join('')
.toUpperCase()
}
export function sourceLabel(source: ActivitySource) {
switch (source) {
case 'web':
return t('Web app')
case 'api_key':
return t('API key')
case 'system':
return t('System task')
}
}
export function targetTypeLabel(type: string) {
switch (type) {
case 'article':
return t('Article')
case 'image':
return t('Image')
case 'user':
return t('User')
case 'invite':
return t('Invite')
case 'api_key':
return t('API key')
case 'release':
return t('Release')
default:
return type
}
}
export function targetLink(target: ActivityTarget | null) {
if (!target?.exists) return null
switch (target.type) {
case 'article':
return `/article/${target.id}/edit`
case 'image':
return '/media'
case 'user':
case 'invite':
return '/users'
case 'api_key':
return '/integration/api-keys'
default:
return null
}
}
export function fieldLabel(field: string) {
switch (field) {
case 'title':
return t('Title')
case 'online':
return t('Publish state')
case 'body':
return t('Body')
case 'filename':
return t('File name')
case 'display_name':
return t('Display name')
default:
return field
}
}
export function changeValue(field: string, value: string) {
if (field !== 'online') return value
if (value === 'false') return t('Draft')
if (value === 'true') return t('Published')
return value
}
export function bodyDeltaLabel(delta: number) {
const sign = delta < 0 ? '-' : '+'
const amount = `${sign}${formatNumber(Math.abs(delta))}`
return Math.abs(delta) === 1
? t('Body edited ({0} character)', [amount])
: t('Body edited ({0} characters)', [amount])
}