feat: activity screen in the SPA, route /activity

This commit is contained in:
fsylvain committed 2026-10-07 01:48:21 +02:00
1 parent e37a46a027
commit 35db442cd3
47 files changed
+7582 -11

No files matched your search

+305
View File
@@ -0,0 +1,305 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale } from '@/i18n'
import {
activeChips,
bodyDeltaLabel,
changeValue,
clearChip,
defaultFilters,
exportQuery,
fieldLabel,
initials,
listQuery,
summaryQuery,
targetLink,
type ActivityFilters,
} from '../activity'
import { dayLabel, formatDateRange, groupByDay, whenLabel } from '../activityTime'
import { barHeights, stripCards } from '../activitySummary'
import { summaryDefault, summaryNone, summaryZero } from '@/views/__tests__/activityFixtures'
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(() => {
localStorage.clear()
initializeLocale()
})
function filters(overrides: Partial<ActivityFilters> = {}): ActivityFilters {
return { ...defaultFilters(), ...overrides }
}
function entries(query: string) {
return Object.fromEntries(new URLSearchParams(query))
}
describe('Queries', () => {
it('always sends range, page and size and nothing else by default', () => {
expect(entries(listQuery(filters(), 1, 25))).toEqual({ range: '7d', page: '1', size: '25' })
})
it('adds q, actor, category, outcome and security only when set', () => {
const query = listQuery(
filters({
q: 'spring',
actor: 'user:3',
category: 'auth',
outcome: 'failed',
security: true,
}),
2,
50,
)
expect(entries(query)).toEqual({
q: 'spring',
actor: 'user:3',
category: 'auth',
outcome: 'failed',
security: 'true',
range: '7d',
page: '2',
size: '50',
})
})
it('sends from and to only with the custom range', () => {
expect(
entries(
listQuery(filters({ range: '30d', from: '2026-09-01', to: '2026-09-30' }), 1, 25),
),
).toEqual({ range: '30d', page: '1', size: '25' })
expect(
entries(
listQuery(
filters({ range: 'custom', from: '2026-09-01', to: '2026-09-30' }),
1,
25,
),
),
).toEqual({ range: 'custom', from: '2026-09-01', to: '2026-09-30', page: '1', size: '25' })
})
it('never sends an actor for a regular user', () => {
expect(entries(listQuery(filters({ actor: 'system' }), 1, 25, false)).actor).toBeUndefined()
})
it('keeps the summary to the date range and the export to the list filters', () => {
const all = filters({
q: 'x',
category: 'media',
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
})
expect(entries(summaryQuery(all))).toEqual({
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
})
expect(entries(exportQuery(all))).toEqual({
q: 'x',
category: 'media',
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
})
})
})
describe('Chips', () => {
it('lists the active filters in order and never the default range', () => {
expect(activeChips(filters())).toEqual([])
expect(
activeChips(
filters({
q: 'spring',
actor: 'user:3',
actorLabel: 'Marta Alves',
category: 'auth',
outcome: 'failed',
security: true,
range: '30d',
}),
).map((chip) => chip.label),
).toEqual([
'Search: spring',
'Person: Marta Alves',
'Category: Authentication',
'Outcome: Failed',
'Security events',
'Dates: 30 days',
])
})
it('words custom ranges', () => {
const range = (from: string, to: string) =>
activeChips(filters({ range: 'custom', from, to }))[0]!.label
expect(range('2026-09-01', '2026-09-30')).toBe('Dates: 1 – 30 Sep 2026')
expect(range('2026-09-28', '2026-10-03')).toBe('Dates: 28 Sep – 3 Oct 2026')
expect(range('2025-12-28', '2026-01-03')).toBe('Dates: 28 Dec 2025 – 3 Jan 2026')
expect(range('2026-09-05', '2026-09-05')).toBe('Dates: 5 Sep 2026')
})
it('words custom ranges in French', () => {
setLocale('fr')
expect(formatDateRange('2026-10-01', '2026-10-30')).toBe('1 – 30 oct. 2026')
})
it('resets one filter at a time', () => {
const all = filters({ q: 'a', actor: 'system', actorLabel: 'System', range: '30d' })
expect(clearChip(all, 'q').q).toBe('')
expect(clearChip(all, 'actor')).toMatchObject({ actor: '', actorLabel: '' })
expect(clearChip(all, 'range').range).toBe('7d')
})
})
describe('Times', () => {
const now = new Date(2026, 9, 6, 15, 0, 0)
it('labels days as Today, Yesterday or the date', () => {
expect(dayLabel(new Date(2026, 9, 6, 9, 0).toISOString(), now)).toBe('Today')
expect(dayLabel(new Date(2026, 9, 5, 23, 59).toISOString(), now)).toBe('Yesterday')
expect(dayLabel(new Date(2026, 9, 3, 14, 20).toISOString(), now)).toBe('3 Oct 2026')
setLocale('fr')
expect(dayLabel(new Date(2026, 9, 6, 9, 0).toISOString(), now)).toBe('Aujourd’hui')
expect(dayLabel(new Date(2026, 9, 3, 14, 20).toISOString(), now)).toBe('3 oct. 2026')
})
it('reads relative within today and as a clock time before', () => {
expect(whenLabel(new Date(2026, 9, 6, 14, 58).toISOString(), now)).toBe('2 min ago')
expect(whenLabel(new Date(2026, 9, 6, 14, 31).toISOString(), now)).toBe('29 min ago')
expect(whenLabel(new Date(2026, 9, 6, 14, 0).toISOString(), now)).toBe('1 h ago')
expect(whenLabel(new Date(2026, 9, 5, 18, 20).toISOString(), now)).toBe('18:20')
expect(whenLabel(new Date(2026, 9, 6, 14, 59, 50).toISOString(), now)).toBe('1 min ago')
})
it('groups consecutive items of the same local day in API order', () => {
const groups = groupByDay(
[
{ created_at: new Date(2026, 9, 6, 12).toISOString(), id: 1 },
{ created_at: new Date(2026, 9, 6, 9).toISOString(), id: 2 },
{ created_at: new Date(2026, 9, 5, 20).toISOString(), id: 3 },
],
now,
)
expect(groups.map((group) => [group.label, group.items.map((entry) => entry.id)])).toEqual([
['Today', [1, 2]],
['Yesterday', [3]],
])
})
})
describe('Labels', () => {
it('takes the first letters of the first two words', () => {
expect(initials('Marta Alves')).toBe('MA')
expect(initials('inès duarte jr')).toBe('ID')
expect(initials('Madonna')).toBe('M')
})
it('links each target type, and nothing for a deleted or unknown one', () => {
const link = (type: string, exists = true) =>
targetLink({ type, id: '8', name: 'x', exists })
expect(link('article')).toBe('/article/8/edit')
expect(link('image')).toBe('/media')
expect(link('user')).toBe('/users')
expect(link('invite')).toBe('/users')
expect(link('api_key')).toBe('/integration/api-keys')
expect(link('release')).toBeNull()
expect(link('article', false)).toBeNull()
expect(targetLink(null)).toBeNull()
})
it('labels fields and values, keeping unknown codes', () => {
expect(fieldLabel('title')).toBe('Title')
expect(fieldLabel('online')).toBe('Publish state')
expect(fieldLabel('mystery')).toBe('mystery')
expect(changeValue('online', 'false')).toBe('Draft')
expect(changeValue('online', 'true')).toBe('Published')
expect(changeValue('title', 'true')).toBe('true')
setLocale('fr')
expect(fieldLabel('filename')).toBe('Nom du fichier')
expect(changeValue('online', 'true')).toBe('Publié')
})
it('words body deltas with sign and singular', () => {
expect(bodyDeltaLabel(312)).toBe('Body edited (+312 characters)')
expect(bodyDeltaLabel(-48)).toBe('Body edited (-48 characters)')
expect(bodyDeltaLabel(1)).toBe('Body edited (+1 character)')
})
})
describe('Summary strip', () => {
it('derives the four cards from the summary', () => {
const cards = stripCards(summaryDefault, '7d')
expect(cards.map((card) => [card.label, card.value, card.subline])).toEqual([
['Total actions', '1,284', '12% vs previous 7 days'],
['Active people', '9', '7 people, 2 API keys'],
['Failed actions', '6', '0.5% of all actions'],
['Security events', '11', '11 flagged'],
])
expect(cards.map((card) => card.tone)).toEqual(['neutral', 'neutral', 'error', 'warning'])
expect(cards[0]!.icon).toBe('i-lucide-trending-up')
})
it('words the comparison for each range', () => {
const subline = (range: 'today' | '7d' | '30d' | 'custom') =>
stripCards(summaryDefault, range)[0]!.subline
expect(subline('today')).toBe('12% vs previous day')
expect(subline('30d')).toBe('12% vs previous 30 days')
expect(subline('custom')).toBe('12% vs previous period')
})
it('goes calm on zero and reads No data yet on an empty site', () => {
const quiet = stripCards(summaryZero, '7d')
expect(quiet[0]!.icon).toBe('i-lucide-trending-down')
expect(quiet.slice(2).map((card) => [card.tone, card.subline, card.icon])).toEqual([
['calm', 'Nothing failed', 'i-lucide-circle-check'],
['calm', 'Nothing to flag', 'i-lucide-circle-check'],
])
const none = stripCards(summaryNone, '7d')
expect(none.map((card) => card.subline)).toEqual([
'No data yet',
'No data yet',
'Nothing failed',
'Nothing to flag',
])
})
it('words singulars, the French forms and the flag count', () => {
const single = stripCards(
{ ...summaryDefault, active_people: { users: 1, api_keys: 1 }, security: 1 },
'7d',
)
expect(single[1]!.subline).toBe('1 person, 1 API key')
expect(single[3]!.subline).toBe('1 flagged')
setLocale('fr')
expect(stripCards(summaryDefault, '7d')[3]!.subline).toBe('11 signalés')
expect(stripCards({ ...summaryDefault, security: 1 }, '7d')[3]!.subline).toBe('1 signalé')
expect(
stripCards({ ...summaryDefault, total: 40, previous_total: 0 }, '7d')[0]!.subline,
).toBe('Aucune donnée antérieure')
expect(
stripCards({ ...summaryDefault, total: 40, previous_total: 40 }, '7d')[0]!.subline,
).toBe('Aucun changement')
})
it('sizes bars from the peak with a 2 px minimum', () => {
expect(barHeights([4, 6, 5, 8, 7, 9, 6], 20)).toEqual([9, 13, 11, 18, 16, 20, 13])
expect(barHeights([0, 0, 0, 0, 0, 0, 0], 20)).toEqual([2, 2, 2, 2, 2, 2, 2])
expect(barHeights([0, 1, 0, 0, 0, 0, 100], 16)).toEqual([2, 2, 2, 2, 2, 2, 16])
})
})
@@ -0,0 +1,240 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale, t } from '@/i18n'
import { messages, type MessageKey } from '@/i18n/messages'
import type { ActivityItem } from '@/lib/activity'
import {
SENTENCE_ACTIONS,
composeSentence,
sentenceTemplates,
sentenceText,
} from '../activitySentence'
function item(overrides: Partial<ActivityItem>): ActivityItem {
return {
id: 1,
created_at: '2026-10-03T12:00:00Z',
action: 'article.publish',
category: 'articles',
actor: { type: 'user', id: 3, name: 'Marta Alves' },
target: { type: 'article', id: '8', name: 'Spring release', exists: true },
outcome: 'success',
failure_reason: null,
security: false,
source: 'web',
ip: null,
user_agent: null,
params: {},
changes: [],
...overrides,
}
}
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(() => {
localStorage.clear()
initializeLocale()
})
describe('Sentences: English and French come from the same code and params', () => {
const cases: [string, Partial<ActivityItem>, string, string][] = [
[
'article.publish',
{},
'Published the article Spring release',
'Article publié : Spring release',
],
[
'article.publish failed',
{ outcome: 'failed' },
'Failed to publish the article Spring release',
"Échec de la publication de l'article Spring release",
],
[
'media.purge',
{ action: 'media.purge', target: null, params: { merged: 3, articles_updated: 5 } },
'Purged duplicate images: 3 merged, 5 articles updated',
'Doublons purgés : 3 fusionnées, 5 articles mis à jour',
],
[
'media.purge with failed groups',
{
action: 'media.purge',
target: null,
params: { merged: 3, articles_updated: 1, failed_groups: 1 },
},
'Purged duplicate images: 3 merged, 1 article updated, 1 group failed',
'Doublons purgés : 3 fusionnées, 1 article mis à jour, 1 groupe en échec',
],
[
'system.media_purge with several failed groups',
{
action: 'system.media_purge',
target: null,
params: { merged: 10, articles_updated: 12, failed_groups: 3 },
},
'Ran the scheduled duplicate purge: 10 merged, 12 articles updated, 3 groups failed',
'Purge automatique des doublons : 10 fusionnées, 12 articles mis à jour, 3 groupes en échec',
],
[
'system.activity_cleanup singular',
{ action: 'system.activity_cleanup', target: null, params: { removed: 1 } },
'Removed 1 old event from the activity log',
"1 ancien événement supprimé du journal d'activité",
],
[
'system.activity_cleanup plural',
{ action: 'system.activity_cleanup', target: null, params: { removed: 1284 } },
'Removed 1,284 old events from the activity log',
"1 284 anciens événements supprimés du journal d'activité",
],
[
'system.activity_retention_changed',
{
action: 'system.activity_retention_changed',
target: null,
params: { from: 90, to: 0 },
},
'Changed the activity retention from 90 days to Forever',
"Durée de conservation de l'activité modifiée : 90 jours → Toujours",
],
[
'system.restart_requested update',
{
action: 'system.restart_requested',
target: { type: 'release', id: 'v1.4.0', name: 'v1.4.0', exists: true },
params: { kind: 'update', tag: 'v1.4.0' },
},
'Restarted to apply the update v1.4.0',
'Redémarrage pour appliquer la mise à jour v1.4.0',
],
[
'system.restart_requested restart',
{ action: 'system.restart_requested', target: null, params: { kind: 'restart' } },
'Requested a restart',
'Redémarrage demandé',
],
[
'media.upload failed without a target',
{ action: 'media.upload', target: null, outcome: 'failed' },
'Failed to upload the image',
"Échec du téléversement de l'image",
],
[
'article.publish without a target',
{ target: null },
'Published the article',
'Article publié',
],
[
'auth.login_failed uses the actor name',
{
action: 'auth.login_failed',
target: null,
outcome: 'failed',
actor: { type: 'anonymous', id: null, name: 'alex@example.com' },
},
'Failed login attempt for alex@example.com',
'Échec de connexion pour alex@example.com',
],
[
'auth.login failed renders its success sentence',
{ action: 'auth.login', target: null, outcome: 'failed' },
'Logged in',
'Connexion',
],
[
'unknown action code',
{ action: 'article.archive' },
'article.archive',
'article.archive',
],
]
it.each(cases)('%s', (_, overrides, english, french) => {
const subject = item(overrides)
expect(sentenceText(subject)).toBe(english)
setLocale('fr')
expect(sentenceText(subject)).toBe(french)
})
it('inserts the target name untranslated as the semibold last segment', () => {
const segments = composeSentence(item({}))
expect(segments).toEqual([
{ text: 'Published the article ', strong: false },
{ text: 'Spring release', strong: true },
])
setLocale('fr')
expect(composeSentence(item({}))).toEqual([
{ text: 'Article publié : ', strong: false },
{ text: 'Spring release', strong: true },
])
})
it('keeps a target name that looks like a template slot', () => {
const subject = item({
target: { type: 'article', id: '1', name: '{0} {1}', exists: true },
})
expect(sentenceText(subject)).toBe('Published the article {0} {1}')
})
})
describe('The catalogue is complete in both languages', () => {
const templateKeys = SENTENCE_ACTIONS.flatMap((action) => {
const template = sentenceTemplates(action)!
return [template.success, ...(template.failed ? [template.failed] : [])]
})
it.each(Array.from(new Set(templateKeys)))('has a French entry for %s', (key) => {
expect(messages).toHaveProperty([key as MessageKey])
expect(messages[key as MessageKey]).not.toBe('')
expect(messages[key as MessageKey]).not.toBe(key)
})
it('covers every action the producers record', () => {
expect(SENTENCE_ACTIONS.sort()).toEqual(
[
'article.create',
'article.edit',
'article.publish',
'article.unpublish',
'article.delete',
'media.upload',
'media.rename',
'media.delete',
'media.merge',
'media.purge',
'system.media_purge',
'user.register',
'user.verify',
'user.update_profile',
'invite.create',
'invite.cancel',
'invite.renew',
'invite.accept',
'api_key.create',
'api_key.revoke',
'auth.login',
'auth.login_failed',
'auth.logout',
'auth.password_reset_requested',
'auth.password_reset_completed',
'system.update_checked',
'system.update_downloaded',
'system.restart_requested',
'system.activity_retention_changed',
'system.activity_cleanup',
].sort(),
)
})
it('formats a template with placeholders through t', () => {
expect(t('Revoked API key {0}', ['Mobile app'])).toBe('Revoked API key Mobile app')
})
})
+350
View File
@@ -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])
}
+209
View File
@@ -0,0 +1,209 @@
import { formatNumber, t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import type { ActivityItem } from '@/lib/activity'
export interface SentenceSegment {
text: string
strong: boolean
}
interface Template {
success: MessageKey
failed?: MessageKey
}
const TARGET_SLOT = '\u0000'
const DROPPED_SLOT = new RegExp(`[\\s\\u00a0]*:?[\\s\\u00a0]*${TARGET_SLOT}`)
const PURGE_ACTIONS = ['media.purge', 'system.media_purge']
const CATALOGUE: Record<string, Template> = {
'article.create': {
success: 'Created the article {0}',
failed: 'Failed to create the article {0}',
},
'article.edit': { success: 'Edited the article {0}', failed: 'Failed to edit the article {0}' },
'article.publish': {
success: 'Published the article {0}',
failed: 'Failed to publish the article {0}',
},
'article.unpublish': {
success: 'Unpublished the article {0}',
failed: 'Failed to unpublish the article {0}',
},
'article.delete': {
success: 'Deleted the article {0}',
failed: 'Failed to delete the article {0}',
},
'media.upload': {
success: 'Uploaded the image {0}',
failed: 'Failed to upload the image {0}',
},
'media.rename': {
success: 'Renamed the image {0}',
failed: 'Failed to rename the image {0}',
},
'media.delete': { success: 'Deleted the image {0}', failed: 'Failed to delete the image {0}' },
'media.merge': {
success: 'Merged duplicates into the image {0}',
failed: 'Failed to merge duplicates into the image {0}',
},
'media.purge': {
success: 'Purged duplicate images: {0}',
failed: 'Failed to purge duplicate images',
},
'system.media_purge': {
success: 'Ran the scheduled duplicate purge: {0}',
failed: 'Failed to run the scheduled duplicate purge',
},
'user.register': {
success: 'Registered the account {0}',
failed: 'Failed to register the account {0}',
},
'user.verify': {
success: 'Verified the account {0}',
failed: 'Failed to verify the account {0}',
},
'user.update_profile': {
success: 'Updated the profile of {0}',
failed: 'Failed to update the profile of {0}',
},
'invite.create': { success: 'Invited {0}', failed: 'Failed to invite {0}' },
'invite.cancel': {
success: 'Cancelled the invite for {0}',
failed: 'Failed to cancel the invite for {0}',
},
'invite.renew': {
success: 'Renewed the invite for {0}',
failed: 'Failed to renew the invite for {0}',
},
'invite.accept': {
success: 'Accepted the invite for {0}',
failed: 'Failed to accept the invite for {0}',
},
'api_key.create': {
success: 'Created API key {0}',
failed: 'Failed to create API key {0}',
},
'api_key.revoke': {
success: 'Revoked API key {0}',
failed: 'Failed to revoke API key {0}',
},
'auth.login': { success: 'Logged in' },
'auth.login_failed': {
success: 'Failed login attempt for {0}',
failed: 'Failed login attempt for {0}',
},
'auth.logout': { success: 'Logged out' },
'auth.password_reset_requested': { success: 'Requested a password reset' },
'auth.password_reset_completed': { success: 'Reset the password' },
'system.update_checked': {
success: 'Checked for updates',
failed: 'Failed to check for updates',
},
'system.update_downloaded': {
success: 'Downloaded the update {0}',
failed: 'Failed to download the update {0}',
},
'system.restart_requested': { success: 'Requested a restart', failed: 'Failed to restart' },
'system.activity_retention_changed': {
success: 'Changed the activity retention from {0} to {1}',
},
'system.activity_cleanup': { success: 'Removed {0} old events from the activity log' },
}
function count(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
function purgeSummary(params: Record<string, unknown>) {
const articles = count(params.articles_updated)
const failedGroups = count(params.failed_groups)
const parts = [
t('{0} merged', [formatNumber(count(params.merged))]),
articles === 1
? t('1 article updated')
: t('{0} articles updated', [formatNumber(articles)]),
]
if (failedGroups > 0) {
parts.push(
failedGroups === 1
? t('1 group failed')
: t('{0} groups failed', [formatNumber(failedGroups)]),
)
}
return parts.join(', ')
}
function retentionDays(value: unknown) {
const days = count(value)
return days === 0 ? t('Forever') : t('{0} days', [formatNumber(days)])
}
function resolveTemplate(item: ActivityItem): MessageKey | null {
const template = CATALOGUE[item.action]
if (!template) return null
if (item.action === 'system.restart_requested' && item.outcome === 'success') {
return item.params.kind === 'update'
? 'Restarted to apply the update {0}'
: 'Requested a restart'
}
if (item.outcome === 'failed') return template.failed ?? template.success
return template.success
}
function slotValues(item: ActivityItem, template: MessageKey) {
if (PURGE_ACTIONS.includes(item.action) && item.outcome === 'success') {
return [purgeSummary(item.params)]
}
if (item.action === 'system.activity_retention_changed') {
return [retentionDays(item.params.from), retentionDays(item.params.to)]
}
if (item.action === 'system.activity_cleanup') {
return [formatNumber(count(item.params.removed))]
}
return template.includes('{0}') ? [TARGET_SLOT] : []
}
function targetName(item: ActivityItem) {
if (item.action === 'auth.login_failed') return item.actor.name
return item.target?.name ?? ''
}
function usesTargetSlot(values: string[]) {
return values.length === 1 && values[0] === TARGET_SLOT
}
export function composeSentence(item: ActivityItem): SentenceSegment[] {
const template = resolveTemplate(item)
if (template === null) return [{ text: item.action, strong: false }]
if (item.action === 'system.activity_cleanup' && count(item.params.removed) === 1) {
return [{ text: t('Removed 1 old event from the activity log'), strong: false }]
}
const values = slotValues(item, template)
const name = targetName(item)
const rendered = t(template, values)
if (!usesTargetSlot(values)) return [{ text: rendered, strong: false }]
if (!name) return [{ text: rendered.replace(DROPPED_SLOT, ''), strong: false }]
const [before, after] = rendered.split(TARGET_SLOT)
const segments: SentenceSegment[] = [
{ text: before ?? '', strong: false },
{ text: name, strong: true },
]
if (after) segments.push({ text: after, strong: false })
return segments.filter((segment) => segment.text !== '')
}
export function sentenceText(item: ActivityItem) {
return composeSentence(item)
.map((segment) => segment.text)
.join('')
}
export const SENTENCE_ACTIONS = Object.keys(CATALOGUE)
export function sentenceTemplates(action: string) {
return CATALOGUE[action]
}
+109
View File
@@ -0,0 +1,109 @@
import { formatNumber, t } from '@/i18n'
import type { ActivitySummary, RangeKey } from '@/lib/activity'
export type StripKey = 'total' | 'active' | 'failed' | 'security'
export type StripTone = 'neutral' | 'error' | 'warning' | 'calm'
export interface StripCard {
key: StripKey
label: string
value: string
subline: string
icon: string | null
tone: StripTone
series: number[]
}
export const STRIP_KEYS: StripKey[] = ['total', 'active', 'failed', 'security']
export const BAR_COUNT = 7
export const MIN_BAR_HEIGHT = 2
function percent(part: number, whole: number) {
return Math.round((Math.abs(part - whole) / whole) * 100)
}
function totalSubline(summary: ActivitySummary, range: RangeKey) {
if (summary.total === 0) return { text: t('No data yet'), icon: null }
if (summary.previous_total === 0) return { text: t('No earlier data'), icon: null }
if (summary.total === summary.previous_total) return { text: t('No change'), icon: null }
const value = formatNumber(percent(summary.total, summary.previous_total))
const text =
range === 'today'
? t('{0}% vs previous day', [value])
: range === '30d'
? t('{0}% vs previous 30 days', [value])
: range === 'custom'
? t('{0}% vs previous period', [value])
: t('{0}% vs previous 7 days', [value])
const icon =
summary.total >= summary.previous_total ? 'i-lucide-trending-up' : 'i-lucide-trending-down'
return { text, icon }
}
function peopleSubline(summary: ActivitySummary) {
if (summary.total === 0) return t('No data yet')
const { users, api_keys: keys } = summary.active_people
const people = users === 1 ? t('1 person') : t('{0} people', [formatNumber(users)])
const apiKeys = keys === 1 ? t('1 API key') : t('{0} API keys', [formatNumber(keys)])
return `${people}, ${apiKeys}`
}
function failedSubline(summary: ActivitySummary) {
const ratio = (summary.failed / summary.total) * 100
return t('{0}% of all actions', [
formatNumber(ratio, { minimumFractionDigits: 1, maximumFractionDigits: 1 }),
])
}
function flaggedSubline(count: number) {
return count === 1 ? t('1 flagged') : t('{0} flagged', [formatNumber(count)])
}
export function stripCards(summary: ActivitySummary, range: RangeKey): StripCard[] {
const total = totalSubline(summary, range)
const failedCalm = summary.failed === 0
const securityCalm = summary.security === 0
return [
{
key: 'total',
label: t('Total actions'),
value: formatNumber(summary.total),
subline: total.text,
icon: total.icon,
tone: 'neutral',
series: summary.series.total,
},
{
key: 'active',
label: t('Active people'),
value: formatNumber(summary.active_people.users + summary.active_people.api_keys),
subline: peopleSubline(summary),
icon: null,
tone: 'neutral',
series: summary.series.active,
},
{
key: 'failed',
label: t('Failed actions'),
value: formatNumber(summary.failed),
subline: failedCalm ? t('Nothing failed') : failedSubline(summary),
icon: failedCalm ? 'i-lucide-circle-check' : 'i-lucide-circle-x',
tone: failedCalm ? 'calm' : 'error',
series: summary.series.failed,
},
{
key: 'security',
label: t('Security events'),
value: formatNumber(summary.security),
subline: securityCalm ? t('Nothing to flag') : flaggedSubline(summary.security),
icon: securityCalm ? 'i-lucide-circle-check' : 'i-lucide-shield-alert',
tone: securityCalm ? 'calm' : 'warning',
series: summary.series.security,
},
]
}
export function barHeights(series: number[], height: number) {
const peak = Math.max(...series, 1)
return series.map((value) => Math.max(MIN_BAR_HEIGHT, Math.round((value / peak) * height)))
}
+110
View File
@@ -0,0 +1,110 @@
import { locale, t } from '@/i18n'
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const MINUTE_MS = 60 * 1000
const HOUR_MS = 60 * MINUTE_MS
const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})$/
export interface DayGroup<T> {
key: string
label: string
items: T[]
}
function pad(value: number) {
return String(value).padStart(2, '0')
}
function monthShort(date: Date, timeZone?: 'UTC') {
if (locale.value === 'fr') {
return new Intl.DateTimeFormat('fr', { month: 'short', timeZone }).format(date)
}
return MONTHS[timeZone ? date.getUTCMonth() : date.getMonth()]
}
export function formatAbsoluteDay(date: Date, timeZone?: 'UTC') {
const day = timeZone ? date.getUTCDate() : date.getDate()
const year = timeZone ? date.getUTCFullYear() : date.getFullYear()
return `${day} ${monthShort(date, timeZone)} ${year}`
}
function clock(date: Date, withSeconds: boolean, utc: boolean) {
const hours = utc ? date.getUTCHours() : date.getHours()
const minutes = utc ? date.getUTCMinutes() : date.getMinutes()
const seconds = utc ? date.getUTCSeconds() : date.getSeconds()
const base = `${pad(hours)}:${pad(minutes)}`
return withSeconds ? `${base}:${pad(seconds)}` : base
}
export function localDayKey(date: Date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function startOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
export function dayLabel(iso: string, now: Date = new Date()) {
const date = new Date(iso)
const today = startOfDay(now)
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1)
const key = localDayKey(date)
if (key === localDayKey(today)) return t('Today')
if (key === localDayKey(yesterday)) return t('Yesterday')
return formatAbsoluteDay(date)
}
export function whenLabel(iso: string, now: Date = new Date()) {
const date = new Date(iso)
if (localDayKey(date) !== localDayKey(now)) return clock(date, false, false)
const elapsed = Math.max(0, now.getTime() - date.getTime())
if (elapsed < HOUR_MS) {
return t('{0} min ago', [Math.max(1, Math.floor(elapsed / MINUTE_MS))])
}
return t('{0} h ago', [Math.floor(elapsed / HOUR_MS)])
}
export function fullLocalTime(iso: string) {
const date = new Date(iso)
return `${formatAbsoluteDay(date)}, ${clock(date, true, false)}`
}
export function fullUtcTime(iso: string) {
const date = new Date(iso)
return `${formatAbsoluteDay(date, 'UTC')}, ${clock(date, true, true)}`
}
export function groupByDay<T extends { created_at: string }>(
items: T[],
now: Date = new Date(),
): DayGroup<T>[] {
const groups: DayGroup<T>[] = []
for (const item of items) {
const key = localDayKey(new Date(item.created_at))
const last = groups[groups.length - 1]
if (last && last.key === key) {
last.items.push(item)
continue
}
groups.push({ key, label: dayLabel(item.created_at, now), items: [item] })
}
return groups
}
function parseIsoDay(value: string) {
const match = ISO_DAY.exec(value)
if (!match) return null
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 12)
}
export function formatDateRange(from: string, to: string) {
const start = parseIsoDay(from)
const end = parseIsoDay(to)
if (!start || !end) return ''
const sameYear = start.getFullYear() === end.getFullYear()
const sameMonth = sameYear && start.getMonth() === end.getMonth()
if (sameMonth && start.getDate() === end.getDate()) return formatAbsoluteDay(end)
if (sameMonth) return `${start.getDate()} – ${formatAbsoluteDay(end)}`
if (sameYear) return `${start.getDate()} ${monthShort(start)} – ${formatAbsoluteDay(end)}`
return `${formatAbsoluteDay(start)} – ${formatAbsoluteDay(end)}`
}
+15
View File
@@ -0,0 +1,15 @@
const SEGMENT_BUTTON =
'h-10.5 gap-1.5 px-3 text-sm ring-0 md:h-7.5 md:px-2.5 rounded-none whitespace-nowrap'
const SEGMENT_SELECTED =
'bg-secondary-200 font-medium text-primary-900 hover:bg-secondary-200 dark:bg-secondary-300 dark:hover:bg-secondary-300'
const SEGMENT_IDLE = 'bg-default font-normal text-toned'
export function segmentClass(selected: boolean, block = false) {
return [
SEGMENT_BUTTON,
selected ? SEGMENT_SELECTED : SEGMENT_IDLE,
block ? 'flex-1 justify-center' : '',
]
}
export const SEGMENT_GROUP = 'flex shrink-0 border border-accented'