feat: activity screen in the SPA, route /activity
This commit is contained in:
1 parent
e37a46a027
commit
35db442cd3
47 files changed
+7582
-11
No files matched your search
@@ -0,0 +1,350 @@
|
||||
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 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')
|
||||
params.set('range', filters.range)
|
||||
if (filters.range === 'custom') {
|
||||
params.set('from', filters.from)
|
||||
params.set('to', filters.to)
|
||||
}
|
||||
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 function summaryQuery(filters: ActivityFilters) {
|
||||
const params = new URLSearchParams({ range: filters.range })
|
||||
if (filters.range === 'custom') {
|
||||
params.set('from', filters.from)
|
||||
params.set('to', filters.to)
|
||||
}
|
||||
return params.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('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])
|
||||
}
|
||||
Reference in new issue
Block a user