416 lines
9.9 KiB
TypeScript
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])
|
|
}
|