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,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')
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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'
|
||||
Reference in new issue
Block a user