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