feat(audit): гибрид timeline и таблицы журнала изменений
Docker / build (push) Failing after 18s

Лента в стиле solution-users-6 с раскрываемым diff; переключение на ResourcePage.
Исправлено: монитор считает ошибку синка только по последней записи аккаунта.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 17:57:43 +07:00
co-authored by Cursor
parent a02daba69b
commit c849dd5ff8
14 changed files with 1696 additions and 58 deletions
@@ -0,0 +1,356 @@
"use client"
import * as React from "react"
import { Badge } from "@/components/reui/badge"
import {
Frame,
FrameHeader,
FramePanel,
} from "@/components/reui/frame"
import {
Timeline,
TimelineContent,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from "@/components/reui/timeline"
import { toast } from "sonner"
import { cn } from "@cfdm/ui/lib/utils"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@cfdm/ui/components/avatar"
import { Button } from "@cfdm/ui/components/button"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@cfdm/ui/components/collapsible"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@cfdm/ui/components/empty"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@cfdm/ui/components/select"
import { Tabs, TabsList, TabsTrigger } from "@cfdm/ui/components/tabs"
import {
AUDIT_DAYS,
FILTER_OPTIONS,
RANGE_OPTIONS,
severityDotClass,
severityLabel,
severityVariant,
type AuditEvent,
type EventType,
} from "./data"
import { ChevronRightIcon, CopyIcon, CalendarIcon, DownloadIcon, FilterIcon } from "lucide-react"
const TOTAL_EVENTS = AUDIT_DAYS.reduce((sum, day) => sum + day.events.length, 0)
function copyValue(value: string) {
if (typeof navigator !== "undefined" && navigator.clipboard) {
void navigator.clipboard.writeText(value).catch(() => undefined)
}
}
// ── Single audit event row (reuses timeline-1 Collapsible-in-Frame grammar) ──
function EventRow({
event,
isLast,
step,
defaultOpen,
}: {
event: AuditEvent
isLast: boolean
step: number
defaultOpen: boolean
}) {
const [open, setOpen] = React.useState(defaultOpen)
return (
<TimelineItem step={step} className={cn("ms-10", isLast ? "pb-0" : "pb-6")}>
<TimelineHeader className="flex min-w-0 items-center justify-between gap-2.5">
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.5rem)] group-data-[orientation=vertical]/timeline:translate-y-7" />
<div className="flex flex-wrap items-center gap-2">
<TimelineTitle className="text-sm font-semibold">
{event.action}
</TimelineTitle>
<Badge variant={severityVariant[event.severity]} className="gap-1.5">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
severityDotClass[event.severity]
)}
aria-hidden="true"
/>
{severityLabel[event.severity]}
</Badge>
<span className="text-muted-foreground text-xs">{event.time}</span>
</div>
<TimelineIndicator className="border-border bg-background text-muted-foreground flex size-6 items-center justify-center border shadow-xs group-data-[orientation=vertical]/timeline:-left-7 [&_svg]:size-3.5">
{event.icon}
</TimelineIndicator>
</TimelineHeader>
<TimelineContent className="mt-2">
<Frame stacked dense spacing="sm">
<Collapsible
open={open}
onOpenChange={(nextOpen) => setOpen(nextOpen)}
className="group/collapsible"
>
<CollapsibleTrigger
type="button"
className="flex w-full"
aria-label={`Toggle ${event.action} details`}
>
<FrameHeader className="flex grow flex-row items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Avatar className="size-5">
<AvatarImage
src={event.actor.avatar}
alt={event.actor.name}
/>
<AvatarFallback className="text-[10px]">
{event.actor.initials}
</AvatarFallback>
</Avatar>
<span className="text-muted-foreground min-w-0 truncate text-sm font-medium">
{event.actor.name}, {event.label}
</span>
</div>
<ChevronRightIcon className="text-muted-foreground size-4 shrink-0 transition-transform duration-200 group-data-open/collapsible:rotate-90" aria-hidden="true" />
</FrameHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<FramePanel className="space-y-3">
<dl className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
<DetailRow label="Target">
<span className="text-foreground truncate font-medium">
{event.target}
</span>
</DetailRow>
<DetailRow label="Actor">
<span className="text-foreground truncate font-medium">
{event.actor.email}
</span>
</DetailRow>
<DetailRow label="Source IP">
<span className="text-foreground inline-flex min-w-0 items-center gap-2 font-medium tabular-nums">
<span className="truncate">{event.ip}</span>
<span className="text-muted-foreground truncate">
{event.location}
</span>
</span>
</DetailRow>
<DetailRow label="Session">
<span className="text-foreground truncate font-mono text-xs">
{event.detail.sessionId}
</span>
</DetailRow>
</dl>
<p className="text-muted-foreground text-xs leading-5">
{event.detail.reason}
</p>
<div className="flex flex-wrap items-center justify-between gap-2.5 border-t pt-2.5">
<Badge variant="outline" className="gap-1.5 font-mono">
{event.ref}
</Badge>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
copyValue(event.ref)
toast.success("Reference copied", {
description: `${event.ref} is on your clipboard.`,
})
}}
>
<CopyIcon className="opacity-60" aria-hidden="true" />
Copy reference
</Button>
</div>
</FramePanel>
</CollapsibleContent>
</Collapsible>
</Frame>
</TimelineContent>
</TimelineItem>
)
}
function DetailRow({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div className="flex min-w-0 flex-col gap-0.5">
<dt className="text-muted-foreground text-xs">{label}</dt>
<dd className="flex min-w-0 items-center text-sm">{children}</dd>
</div>
)
}
export function AuditLogTimeline() {
const [filter, setFilter] = React.useState<string[]>(["All"])
const [range, setRange] = React.useState("24h")
const activeFilter = (filter[0] ?? "All") as EventType | "All"
const visibleDays = React.useMemo(() => {
if (activeFilter === "All") return AUDIT_DAYS
return AUDIT_DAYS.map((day) => ({
...day,
events: day.events.filter((event) => event.type === activeFilter),
})).filter((day) => day.events.length > 0)
}, [activeFilter])
const visibleCount = visibleDays.reduce(
(sum, day) => sum + day.events.length,
0
)
const handleExport = () => {
toast.success("Export ready", {
description: `${visibleCount} events queued as CSV. Link valid for 24 hours.`,
})
}
return (
<section
className="mx-auto w-full max-w-2xl"
aria-labelledby="audit-log-title"
>
{/* ── Content header (title + filter chips + range + export) ── */}
<div className="mb-6 flex flex-col gap-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="min-w-0 space-y-1">
<h1
id="audit-log-title"
className="text-xl font-semibold tracking-tight"
>
Audit Log
</h1>
<p className="text-muted-foreground text-sm leading-5">
{TOTAL_EVENTS} events in Acme Cloud workspace
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Select
value={range}
onValueChange={(value) => value && setRange(value)}
items={RANGE_OPTIONS}
>
<SelectTrigger size="sm" className="w-40">
<CalendarIcon className="text-muted-foreground size-4" aria-hidden="true" />
<SelectValue />
</SelectTrigger>
<SelectContent align="end" alignItemWithTrigger={false}>
<SelectGroup>
{RANGE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button size="sm" type="button" onClick={handleExport}>
<DownloadIcon aria-hidden="true" />
<span className="hidden sm:block">Export CSV</span>
</Button>
</div>
</div>
<Tabs
value={activeFilter}
onValueChange={(value) => value && setFilter([value])}
>
<TabsList
variant="line"
aria-label="Filter by event type"
className="h-10! w-full justify-start gap-6 overflow-x-auto border-b"
>
{FILTER_OPTIONS.map((option) => (
<TabsTrigger
key={option.value}
value={option.value}
className="px-1 text-sm after:-bottom-px!"
>
{option.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
{visibleDays.length === 0 ? (
<Empty className="min-h-[280px] border-0 bg-transparent">
<EmptyHeader>
<EmptyMedia variant="icon">
<FilterIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>No {activeFilter} events</EmptyTitle>
<EmptyDescription>
No {activeFilter} events in the last 24 hours. Try another type or
widen the range.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button
type="button"
variant="outline"
onClick={() => setFilter(["All"])}
>
Clear filter
</Button>
</EmptyContent>
</Empty>
) : (
<div className="space-y-8">
{visibleDays.map((day) => (
<div key={day.id} className="space-y-4">
<h2 className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
{day.date}
</h2>
<Timeline>
{day.events.map((event, index) => (
<EventRow
key={event.id}
event={event}
step={index + 1}
isLast={index === day.events.length - 1}
defaultOpen={day.id === 1 && index < 2}
/>
))}
</Timeline>
</div>
))}
</div>
)}
</section>
)
}
@@ -0,0 +1,407 @@
import type { BadgeProps } from "@/components/reui/badge"
import { CircleCheckIcon, TriangleAlertIcon, ArrowLeftRightIcon, MailIcon, ShieldCheckIcon, UsersIcon, RefreshCwIcon, LogOutIcon, KeyRoundIcon, DatabaseIcon } from "lucide-react"
// ── Audit log world (Acme Cloud workspace) ──
// Severity drives the timeline indicator + the inline severity badge. Event
// type drives the filter chips. Each event carries an actor (avatar + email +
// IP), a target, and an expandable detail block (session id, reason).
export type EventSeverity = "info" | "notice" | "critical"
export type EventType = "Auth" | "Roles" | "SSO/SCIM" | "Sessions" | "API"
export type AuditActor = {
name: string
email: string
avatar: string
initials: string
}
export type AuditEvent = {
id: string
ref: string
type: EventType
action: string
label: string
target: string
severity: EventSeverity
time: string
actor: AuditActor
ip: string
location: string
icon: React.ReactNode
detail: { sessionId: string; reason: string }
}
export type AuditDay = {
id: number
date: string
events: AuditEvent[]
}
export type FilterOption = { value: EventType | "All"; label: string }
export type RangeOption = { value: string; label: string }
// ── Filter chips (event-type) ──
export const FILTER_OPTIONS: FilterOption[] = [
{ value: "All", label: "All" },
{ value: "Auth", label: "Auth" },
{ value: "Roles", label: "Roles" },
{ value: "SSO/SCIM", label: "SSO/SCIM" },
{ value: "Sessions", label: "Sessions" },
{ value: "API", label: "API" },
]
// ── Date-range select ──
export const RANGE_OPTIONS: RangeOption[] = [
{ value: "24h", label: "Last 24 hours" },
{ value: "7d", label: "Last 7 days" },
{ value: "30d", label: "Last 30 days" },
{ value: "90d", label: "Last 90 days" },
]
// ── Severity → badge variant + indicator dot ──
export const severityVariant: Record<EventSeverity, BadgeProps["variant"]> = {
info: "success-outline",
notice: "warning-outline",
critical: "destructive-outline",
}
export const severityLabel: Record<EventSeverity, string> = {
info: "Info",
notice: "Notice",
critical: "Critical",
}
export const severityDotClass: Record<EventSeverity, string> = {
info: "bg-success",
notice: "bg-warning",
critical: "bg-destructive",
}
const MIRA: AuditActor = {
name: "Mira Stone",
email: "[email protected]",
avatar:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
initials: "MS",
}
const LEO: AuditActor = {
name: "Leo Grant",
email: "[email protected]",
avatar:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
initials: "LG",
}
const SANA: AuditActor = {
name: "Sana Qureshi",
email: "[email protected]",
avatar:
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
initials: "SQ",
}
const SARAH: AuditActor = {
name: "Sarah Chen",
email: "[email protected]",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
initials: "SC",
}
const DAVID: AuditActor = {
name: "David Kim",
email: "[email protected]",
avatar:
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
initials: "DK",
}
const KENJI: AuditActor = {
name: "Kenji Tan",
email: "[email protected]",
avatar:
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
initials: "KT",
}
const OMAR: AuditActor = {
name: "Omar Haddad",
email: "[email protected]",
avatar:
"https://images.unsplash.com/photo-1507591064344-4c6ce005b128?w=96&h=96&dpr=2&q=80",
initials: "OH",
}
const NORA: AuditActor = {
name: "Nora Vale",
email: "[email protected]",
avatar:
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
initials: "NV",
}
const authIcon = (
<CircleCheckIcon className="size-3.5" aria-hidden="true" />
)
const authFailIcon = (
<TriangleAlertIcon className="size-3.5" aria-hidden="true" />
)
const roleIcon = (
<ArrowLeftRightIcon className="size-3.5" aria-hidden="true" />
)
const inviteIcon = (
<MailIcon className="size-3.5" aria-hidden="true" />
)
const ssoIcon = (
<ShieldCheckIcon className="size-3.5" aria-hidden="true" />
)
const scimIcon = (
<UsersIcon className="size-3.5" aria-hidden="true" />
)
const mfaIcon = (
<RefreshCwIcon className="size-3.5" aria-hidden="true" />
)
const sessionIcon = (
<LogOutIcon className="size-3.5" aria-hidden="true" />
)
const apiIcon = (
<KeyRoundIcon className="size-3.5" aria-hidden="true" />
)
const exportIcon = (
<DatabaseIcon className="size-3.5" aria-hidden="true" />
)
// ── Audit events grouped by day (newest first) ──
export const AUDIT_DAYS: AuditDay[] = [
{
id: 1,
date: "Today, Jun 17",
events: [
{
id: "e1",
ref: "evt_9f3a21c8",
type: "Auth",
action: "Login failed",
label: "Password rejected",
target: "[email protected]",
severity: "critical",
time: "2:14 PM",
actor: KENJI,
ip: "192.0.2.51",
location: "Berlin",
icon: authFailIcon,
detail: {
sessionId: "sess_b71e0d44",
reason: "3 failed attempts in 5 minutes, account temporarily locked",
},
},
{
id: "e2",
ref: "evt_71b0a9d2",
type: "Roles",
action: "Role changed",
label: "Member to Admin",
target: "Sana Qureshi",
severity: "notice",
time: "1:02 PM",
actor: LEO,
ip: "192.0.2.14",
location: "San Francisco",
icon: roleIcon,
detail: {
sessionId: "sess_c98a2f10",
reason: "Promotion approved by Mira Stone, scope raised to Write",
},
},
{
id: "e3",
ref: "evt_4c2d80ae",
type: "Sessions",
action: "Session revoked",
label: "Chrome on Windows",
target: "David Kim",
severity: "notice",
time: "11:48 AM",
actor: SARAH,
ip: "192.0.2.22",
location: "Seattle",
icon: sessionIcon,
detail: {
sessionId: "sess_5d1c6b09",
reason: "Revoked from a stale device, last active 14 days ago",
},
},
{
id: "e4",
ref: "evt_2a6f13bb",
type: "Auth",
action: "Login success",
label: "SSO via Okta",
target: "[email protected]",
severity: "info",
time: "9:05 AM",
actor: MIRA,
ip: "192.0.2.14",
location: "San Francisco",
icon: authIcon,
detail: {
sessionId: "sess_a02d7e58",
reason: "Passkey verified, session valid for 12 hours",
},
},
],
},
{
id: 2,
date: "Yesterday, Jun 16",
events: [
{
id: "e5",
ref: "evt_88e1c5f0",
type: "API",
action: "API key created",
label: "Production, ci-deploy",
target: "key_3f9a...c712",
severity: "notice",
time: "6:21 PM",
actor: DAVID,
ip: "192.0.2.31",
location: "Seattle",
icon: apiIcon,
detail: {
sessionId: "sess_7b40e1aa",
reason: "Scopes: deployments:write, logs:read, expires in 90 days",
},
},
{
id: "e6",
ref: "evt_15d7a3e9",
type: "SSO/SCIM",
action: "SSO config changed",
label: "Okta to Microsoft Entra ID",
target: "Acme Cloud workspace",
severity: "critical",
time: "4:37 PM",
actor: MIRA,
ip: "192.0.2.14",
location: "San Francisco",
icon: ssoIcon,
detail: {
sessionId: "sess_e21f9c03",
reason: "Default identity provider switched, 68 members affected",
},
},
{
id: "e7",
ref: "evt_6b094d27",
type: "SSO/SCIM",
action: "SCIM provision",
label: "4 members imported",
target: "Engineering team",
severity: "info",
time: "4:30 PM",
actor: LEO,
ip: "192.0.2.14",
location: "San Francisco",
icon: scimIcon,
detail: {
sessionId: "sess_d4c7b210",
reason: "JIT provisioning from Entra ID, 80 of 80 seats reconciled",
},
},
{
id: "e8",
ref: "evt_33a8e0c1",
type: "Auth",
action: "MFA reset",
label: "Authenticator re-enrolled",
target: "Omar Haddad",
severity: "notice",
time: "2:10 PM",
actor: SARAH,
ip: "192.0.2.22",
location: "Seattle",
icon: mfaIcon,
detail: {
sessionId: "sess_9f0b2d6e",
reason: "Lost device reported, TOTP factor reset by admin",
},
},
{
id: "e9",
ref: "evt_07c4f2a5",
type: "Roles",
action: "Member invited",
label: "Guest, Support Agent",
target: "[email protected]",
severity: "info",
time: "10:55 AM",
actor: MIRA,
ip: "192.0.2.14",
location: "San Francisco",
icon: inviteIcon,
detail: {
sessionId: "sess_1ab39e7c",
reason: "Invite expires in 7 days, scope set to Read",
},
},
],
},
{
id: 3,
date: "Jun 15",
events: [
{
id: "e10",
ref: "evt_5e2b9114",
type: "API",
action: "Data export",
label: "Audit log, CSV",
target: "8,420 events",
severity: "notice",
time: "5:42 PM",
actor: OMAR,
ip: "192.0.2.40",
location: "Toronto",
icon: exportIcon,
detail: {
sessionId: "sess_4c8d1f93",
reason: "Export covered 90 days, download link valid for 24 hours",
},
},
{
id: "e11",
ref: "evt_9012ad6f",
type: "Sessions",
action: "Session revoked",
label: "Safari on iOS",
target: "Nora Vale",
severity: "info",
time: "3:18 PM",
actor: NORA,
ip: "192.0.2.47",
location: "Austin",
icon: sessionIcon,
detail: {
sessionId: "sess_2f7a0c61",
reason: "Signed out of all other devices from account settings",
},
},
{
id: "e12",
ref: "evt_a4f60b38",
type: "Auth",
action: "Login success",
label: "Password, 2FA passed",
target: "[email protected]",
severity: "info",
time: "8:47 AM",
actor: SANA,
ip: "192.0.2.33",
location: "London",
icon: authIcon,
detail: {
sessionId: "sess_88be4d02",
reason: "Security key verified, new device added to trusted list",
},
},
],
},
]
@@ -0,0 +1,9 @@
import { AuditLogTimeline } from "./components/audit-log-timeline"
export function Page() {
return (
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-12">
<AuditLogTimeline />
</div>
)
}
@@ -0,0 +1,27 @@
import { formatDiffValue, diffFieldEntries } from '@/components/domain/audit-labels'
interface AuditDiffProps {
diff: Record<string, unknown> | null | undefined
className?: string
}
/** Поля diff как список ключ → значение (не raw JSON). */
export function AuditDiff({ diff, className }: AuditDiffProps) {
const entries = diffFieldEntries(diff)
if (entries.length === 0) {
return <p className="text-muted-foreground text-xs">Нет деталей изменений</p>
}
return (
<dl className={className ?? 'grid grid-cols-1 gap-2.5 sm:grid-cols-2'}>
{entries.map(([key, value]) => (
<div key={key} className="flex min-w-0 flex-col gap-0.5">
<dt className="text-muted-foreground font-mono text-xs">{key}</dt>
<dd className="text-foreground min-w-0 truncate text-sm font-medium" title={formatDiffValue(value)}>
{formatDiffValue(value)}
</dd>
</div>
))}
</dl>
)
}
@@ -0,0 +1,65 @@
export const AUDIT_ENTITY_LABELS: Record<string, string> = {
vps: 'VPS',
payment: 'Платёж',
providerAccount: 'Аккаунт',
provider: 'Хостер',
settings: 'Настройки',
balanceLedger: 'Баланс',
serverProject: 'Проект',
}
export const ACTION_LABELS: Record<string, string> = {
create: 'Создание',
update: 'Изменение',
delete: 'Удаление',
}
export type AuditAction = 'create' | 'update' | 'delete' | string
export type AuditActionBadgeVariant =
| 'success-light'
| 'info-light'
| 'destructive-light'
| 'outline'
export function auditEntityLabel(entity: string): string {
return AUDIT_ENTITY_LABELS[entity] ?? entity
}
export function auditActionLabel(action: string): string {
return ACTION_LABELS[action] ?? action
}
export function auditActionBadgeVariant(action: string): AuditActionBadgeVariant {
if (action === 'create') return 'success-light'
if (action === 'update') return 'info-light'
if (action === 'delete') return 'destructive-light'
return 'outline'
}
export function formatDiffValue(value: unknown): string {
if (value == null) return '—'
if (typeof value === 'string') return value || '—'
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
export function diffFieldEntries(diff: Record<string, unknown> | null | undefined) {
if (!diff) return []
return Object.entries(diff)
}
export function diffPreview(diff: Record<string, unknown> | null | undefined, maxKeys = 3): string {
const entries = diffFieldEntries(diff)
if (entries.length === 0) return '—'
const head = entries
.slice(0, maxKeys)
.map(([key, value]) => `${key}: ${formatDiffValue(value)}`)
.join(', ')
const rest = entries.length - maxKeys
return rest > 0 ? `${head} (+${rest})` : head
}
@@ -0,0 +1,227 @@
import { useMemo, useState } from 'react'
import { Link } from '@tanstack/react-router'
import {
ChevronRightIcon,
HistoryIcon,
PencilIcon,
PlusIcon,
Trash2Icon,
UserRoundIcon,
} from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { Frame, FrameHeader, FramePanel } from '@/components/reui/frame'
import {
Timeline,
TimelineContent,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { AuditDiff } from '@/components/domain/audit-diff'
import {
auditActionBadgeVariant,
auditActionLabel,
auditEntityLabel,
diffFieldEntries,
} from '@/components/domain/audit-labels'
import { cn } from '@cfdm/ui/lib/utils'
import { Button } from '@cfdm/ui/components/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@cfdm/ui/components/collapsible'
export interface AuditRow {
id: string
entity: string
entityId: string
action: string
diff: Record<string, unknown> | null
actorUserId?: string | null
createdAt: string
}
interface AuditTimelineProps {
rows: AuditRow[]
}
function actionIcon(action: string) {
if (action === 'create') return <PlusIcon aria-hidden />
if (action === 'delete') return <Trash2Icon aria-hidden />
if (action === 'update') return <PencilIcon aria-hidden />
return <HistoryIcon aria-hidden />
}
function dayKey(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function dayLabel(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleDateString('ru-RU', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
function timeLabel(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return '—'
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function EntityIdLink({ entity, entityId }: { entity: string; entityId: string }) {
if (entity === 'vps') {
return (
<Button
variant="link"
className="h-auto p-0 font-mono text-xs"
render={<Link to="/vps/$vpsId" params={{ vpsId: entityId }} />}
>
{entityId}
</Button>
)
}
return <span className="font-mono text-xs">{entityId}</span>
}
function EventRow({
row,
step,
isLast,
defaultOpen,
}: {
row: AuditRow
step: number
isLast: boolean
defaultOpen: boolean
}) {
const [open, setOpen] = useState(defaultOpen)
const fieldCount = diffFieldEntries(row.diff).length
const actor = row.actorUserId?.trim() || 'система'
return (
<TimelineItem step={step} className={cn('ms-10', isLast ? 'pb-0' : 'pb-6')}>
<TimelineHeader className="flex min-w-0 items-center justify-between gap-2.5">
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.5rem)] group-data-[orientation=vertical]/timeline:translate-y-7" />
<div className="flex min-w-0 flex-wrap items-center gap-2">
<TimelineTitle className="text-sm font-semibold">
{auditActionLabel(row.action)}
</TimelineTitle>
<Badge variant={auditActionBadgeVariant(row.action)} size="sm">
{auditEntityLabel(row.entity)}
</Badge>
<span className="text-muted-foreground text-xs tabular-nums">{timeLabel(row.createdAt)}</span>
</div>
<TimelineIndicator className="border-border bg-background text-muted-foreground flex size-6 items-center justify-center border shadow-xs group-data-[orientation=vertical]/timeline:-left-7 [&_svg]:size-3.5">
{actionIcon(row.action)}
</TimelineIndicator>
</TimelineHeader>
<TimelineContent className="mt-2">
<Frame stacked dense spacing="sm">
<Collapsible open={open} onOpenChange={setOpen} className="group/collapsible">
<CollapsibleTrigger type="button" className="flex w-full" aria-label="Детали изменений">
<FrameHeader className="flex grow flex-row items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<UserRoundIcon className="text-muted-foreground size-3.5 shrink-0" aria-hidden />
<span className="text-muted-foreground truncate text-sm font-medium">{actor}</span>
<span className="text-muted-foreground text-xs">·</span>
<EntityIdLink entity={row.entity} entityId={row.entityId} />
{fieldCount > 0 ? (
<Badge variant="outline" size="xs">
{fieldCount} {fieldCount === 1 ? 'поле' : 'полей'}
</Badge>
) : null}
</div>
<ChevronRightIcon
className="text-muted-foreground size-4 shrink-0 transition-transform duration-200 group-data-open/collapsible:rotate-90"
aria-hidden
/>
</FrameHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<FramePanel className="flex flex-col gap-3">
<dl className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
<div className="flex min-w-0 flex-col gap-0.5">
<dt className="text-muted-foreground text-xs">Сущность</dt>
<dd className="text-sm font-medium">{auditEntityLabel(row.entity)}</dd>
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<dt className="text-muted-foreground text-xs">ID</dt>
<dd className="min-w-0">
<EntityIdLink entity={row.entity} entityId={row.entityId} />
</dd>
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<dt className="text-muted-foreground text-xs">Актор</dt>
<dd className="truncate text-sm font-medium">{actor}</dd>
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<dt className="text-muted-foreground text-xs">Время</dt>
<dd className="text-sm font-medium tabular-nums">
{new Date(row.createdAt).toLocaleString('ru-RU')}
</dd>
</div>
</dl>
<div className="border-border flex flex-col gap-2 border-t pt-2.5">
<span className="text-muted-foreground text-xs font-medium">Изменения</span>
<AuditDiff diff={row.diff} />
</div>
</FramePanel>
</CollapsibleContent>
</Collapsible>
</Frame>
</TimelineContent>
</TimelineItem>
)
}
/** Day-grouped audit timeline — DNA solution-users-6. */
export function AuditTimeline({ rows }: AuditTimelineProps) {
const days = useMemo(() => {
const map = new Map<string, { key: string; label: string; events: AuditRow[] }>()
for (const row of rows) {
const key = dayKey(row.createdAt)
const existing = map.get(key)
if (existing) {
existing.events.push(row)
} else {
map.set(key, { key, label: dayLabel(row.createdAt), events: [row] })
}
}
return Array.from(map.values())
}, [rows])
return (
<div className="flex flex-col gap-8">
{days.map((day, dayIndex) => (
<div key={day.key} className="flex flex-col gap-4">
<h2 className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
{day.label}
</h2>
<Timeline>
{day.events.map((event, index) => (
<EventRow
key={event.id}
row={event}
step={index + 1}
isLast={index === day.events.length - 1}
defaultOpen={dayIndex === 0 && index < 2}
/>
))}
</Timeline>
</div>
))}
</div>
)
}
@@ -0,0 +1,35 @@
import { HistoryIcon, TableIcon } from 'lucide-react'
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
export type AuditViewMode = 'timeline' | 'table'
interface AuditViewToggleProps {
view: AuditViewMode
onViewChange: (view: AuditViewMode) => void
}
export function AuditViewToggle({ view, onViewChange }: AuditViewToggleProps) {
return (
<ToggleGroup
variant="outline"
size="sm"
spacing={0}
value={[view]}
onValueChange={(next) => {
const selected = next[0]
if (selected === 'timeline' || selected === 'table') onViewChange(selected)
}}
aria-label="Вид журнала"
>
<ToggleGroupItem value="timeline" aria-label="Лента">
<HistoryIcon data-icon="inline-start" />
Лента
</ToggleGroupItem>
<ToggleGroupItem value="table" aria-label="Таблица">
<TableIcon data-icon="inline-start" />
Таблица
</ToggleGroupItem>
</ToggleGroup>
)
}
@@ -72,6 +72,12 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
)
}
type SyncStatusRow = {
accountId?: string
status?: string | null
ok?: boolean
}
function isStaleSync(lastAt: string | null | undefined): boolean {
if (!lastAt) return true
const ts = new Date(lastAt).getTime()
@@ -79,13 +85,31 @@ function isStaleSync(lastAt: string | null | undefined): boolean {
return Date.now() - ts > 24 * 60 * 60 * 1000
}
function isSyncFailureStatus(row: SyncStatusRow): boolean {
const status = String(row.status ?? '').toLowerCase()
return status === 'failed' || status === 'error' || row.ok === false
}
/** Последний синк на аккаунт (журнал desc по startedAt); старые fail после OK игнорируются. */
function countCurrentSyncFailures(rows: SyncStatusRow[]): number {
const seen = new Set<string>()
let failed = 0
for (const row of rows) {
const accountId = row.accountId
if (!accountId || seen.has(accountId)) continue
seen.add(accountId)
if (isSyncFailureStatus(row)) failed += 1
}
return failed
}
/** Live system monitor popover (app-shell pattern, VPS Tracker API data). */
export function SystemMonitorPopover() {
const statsQ = useQuery({ ...dashboardStatsQueryOptions(), refetchInterval: 30_000 })
const snapQ = useQuery({ ...snapshotQueryOptions(), refetchInterval: 30_000 })
const syncQ = useQuery({
queryKey: ['sync', 'status'],
queryFn: () => api.fetchSyncStatus() as Promise<Array<{ status?: string; ok?: boolean }>>,
queryFn: () => api.fetchSyncStatus() as Promise<SyncStatusRow[]>,
refetchInterval: 30_000,
})
const notifyQ = useQuery({
@@ -106,12 +130,8 @@ export function SystemMonitorPopover() {
const lowBalance = (stats?.lowBalanceAccountCount ?? 0) > 0
const staleSync =
(stats?.staleSyncAccountCount ?? 0) > 0 || isStaleSync(stats?.lastGlobalSyncAt)
const recentSyncFailed = (syncQ.data ?? []).some(
(row) =>
String(row.status ?? '').toLowerCase() === 'failed' ||
String(row.status ?? '').toLowerCase() === 'error' ||
row.ok === false,
)
const failedSyncCount = countCurrentSyncFailures(syncQ.data ?? [])
const recentSyncFailed = failedSyncCount > 0
const syncAlert = staleSync || recentSyncFailed
const failedNotifications = (notifyQ.data ?? []).filter(
(n) => String(n.status ?? '').toLowerCase() === 'failed',
@@ -123,7 +143,7 @@ export function SystemMonitorPopover() {
{
id: 'sync',
label: 'Синк',
value: syncAlert ? '!' : 'OK',
value: recentSyncFailed ? String(failedSyncCount) : syncAlert ? '!' : 'OK',
unit: '',
percent: syncAlert ? 35 : 100,
icon: <RefreshCw aria-hidden />,
@@ -164,7 +184,7 @@ export function SystemMonitorPopover() {
alert: downCount > 0,
},
],
[downCount, issuesCount, lowBalance, recentSyncFailed, runwayDays, runwayLow, syncAlert],
[downCount, failedSyncCount, issuesCount, lowBalance, recentSyncFailed, runwayDays, runwayLow, syncAlert],
)
const spiking = metrics.some((m) => m.alert) || !apiOk || failedNotifications > 0
+256
View File
@@ -0,0 +1,256 @@
import { createContext, useCallback, useContext, useState } from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@cfdm/ui/lib/utils"
// Types
type TimelineContextValue = {
activeStep: number
setActiveStep: (step: number) => void
}
// Context
const TimelineContext = createContext<TimelineContextValue | undefined>(
undefined
)
const useTimeline = () => {
const context = useContext(TimelineContext)
if (!context) {
throw new Error("useTimeline must be used within a Timeline")
}
return context
}
// Components
interface TimelineProps extends useRender.ComponentProps<"div"> {
defaultValue?: number
value?: number
onValueChange?: (value: number) => void
orientation?: "horizontal" | "vertical"
}
function Timeline({
defaultValue = 1,
value,
onValueChange,
orientation = "vertical",
className,
render,
children,
...props
}: TimelineProps) {
const [activeStep, setInternalStep] = useState(defaultValue)
const setActiveStep = useCallback(
(step: number) => {
if (value === undefined) {
setInternalStep(step)
}
onValueChange?.(step)
},
[value, onValueChange]
)
const currentStep = value ?? activeStep
const defaultProps = {
className: cn(
"group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
className
),
"data-orientation": orientation,
"data-slot": "timeline",
children,
}
return (
<TimelineContext.Provider
value={{ activeStep: currentStep, setActiveStep }}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</TimelineContext.Provider>
)
}
// TimelineContent
function TimelineContent({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cn("text-muted-foreground text-sm", className),
"data-slot": "timeline-content",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineDate
type TimelineDateProps = useRender.ComponentProps<"time">
function TimelineDate({
className,
render,
children,
...props
}: TimelineDateProps) {
const defaultProps = {
className: cn(
"mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4",
className
),
"data-slot": "timeline-date",
children,
}
return useRender({
defaultTagName: "time",
render,
props: mergeProps<"time">(defaultProps, props),
})
}
// TimelineHeader
function TimelineHeader({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cn(className),
"data-slot": "timeline-header",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineIndicator
type TimelineIndicatorProps = useRender.ComponentProps<"div">
function TimelineIndicator({
className,
children,
render,
...props
}: TimelineIndicatorProps) {
const defaultProps = {
"aria-hidden": true,
className: cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary",
className
),
"data-slot": "timeline-indicator",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineItem
interface TimelineItemProps extends useRender.ComponentProps<"div"> {
step: number
}
function TimelineItem({
step,
className,
render,
children,
...props
}: TimelineItemProps) {
const { activeStep } = useTimeline()
const defaultProps = {
className: cn(
"group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-6 has-[+[data-completed]]:**:data-[slot=timeline-separator]:bg-primary",
className
),
"data-completed": step <= activeStep || undefined,
"data-slot": "timeline-item",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineSeparator
function TimelineSeparator({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
"aria-hidden": true,
className: cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5",
className
),
"data-slot": "timeline-separator",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineTitle
function TimelineTitle({
className,
render,
children,
...props
}: useRender.ComponentProps<"h3">) {
const defaultProps = {
className: cn("font-medium text-sm", className),
"data-slot": "timeline-title",
children,
}
return useRender({
defaultTagName: "h3",
render,
props: mergeProps<"h3">(defaultProps, props),
})
}
export {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
}
+1
View File
@@ -392,6 +392,7 @@ export const api = {
entityId: string
action: string
diff: Record<string, unknown> | null
actorUserId?: string | null
createdAt: string
}>>(`/api/audit?limit=${limit}`),
}
+150 -49
View File
@@ -1,6 +1,8 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMemo } from 'react'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { HistoryIcon } from 'lucide-react'
import { HistoryIcon, UserRoundIcon } from 'lucide-react'
import { z } from 'zod'
import { api } from '@/lib/api-client'
import { PageShell } from '@/components/page-shell'
@@ -8,49 +10,91 @@ import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import type { DataGridColumn } from '@/components/data-grid-types'
import { AuditTimeline, type AuditRow } from '@/components/domain/audit-timeline'
import { AuditViewToggle, type AuditViewMode } from '@/components/domain/audit-view-toggle'
import {
ACTION_LABELS,
auditActionBadgeVariant,
auditActionLabel,
auditEntityLabel,
diffPreview,
} from '@/components/domain/audit-labels'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { Badge } from '@/components/reui/badge'
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
import { TableSkeleton } from '@/components/skeletons'
const AUDIT_ENTITY_LABELS: Record<string, string> = {
vps: 'VPS',
payment: 'Платёж',
providerAccount: 'Аккаунт',
provider: 'Хостер',
settings: 'Настройки',
balanceLedger: 'Баланс',
serverProject: 'Проект',
}
const auditSearchSchema = z.object({
view: z.enum(['timeline', 'table']).optional(),
action: z.enum(['all', 'create', 'update', 'delete']).optional(),
})
function auditEntityLabel(entity: string): string {
return AUDIT_ENTITY_LABELS[entity] ?? entity
}
type AuditActionFilter = 'all' | 'create' | 'update' | 'delete'
interface AuditRow {
id: string
entity: string
entityId: string
action: string
diff: Record<string, unknown> | null
createdAt: string
}
const ACTION_LABELS: Record<string, string> = {
create: 'Создание',
update: 'Изменение',
delete: 'Удаление',
}
const ACTION_FILTERS: { value: AuditActionFilter; label: string }[] = [
{ value: 'all', label: 'Все' },
{ value: 'create', label: ACTION_LABELS.create },
{ value: 'update', label: ACTION_LABELS.update },
{ value: 'delete', label: ACTION_LABELS.delete },
]
export const Route = createFileRoute('/_auth/audit')({
validateSearch: (search) => auditSearchSchema.parse(search),
component: AuditPage,
})
function AuditPage() {
const navigate = useNavigate({ from: Route.fullPath })
const search = Route.useSearch()
const view: AuditViewMode = search.view === 'table' ? 'table' : 'timeline'
const actionFilter: AuditActionFilter = search.action ?? 'all'
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['audit'],
queryFn: () => api.fetchAuditLog(200),
queryFn: () => api.fetchAuditLog(200) as Promise<AuditRow[]>,
})
const filtered = useMemo(() => {
const rows = data ?? []
if (actionFilter === 'all') return rows
return rows.filter((r) => r.action === actionFilter)
}, [actionFilter, data])
const actionCounts = useMemo(() => {
const rows = data ?? []
const counts: Record<AuditActionFilter, number> = {
all: rows.length,
create: 0,
update: 0,
delete: 0,
}
for (const r of rows) {
if (r.action === 'create' || r.action === 'update' || r.action === 'delete') {
counts[r.action] += 1
}
}
return counts
}, [data])
const setView = (next: AuditViewMode) => {
void navigate({
search: (prev) => ({
...prev,
view: next === 'timeline' ? undefined : next,
}),
})
}
const setActionFilter = (next: string) => {
if (next !== 'all' && next !== 'create' && next !== 'update' && next !== 'delete') return
void navigate({
search: (prev) => ({
...prev,
action: next === 'all' ? undefined : next,
}),
})
}
const columns: DataGridColumn<AuditRow>[] = [
{
key: 'createdAt',
@@ -58,40 +102,57 @@ function AuditPage() {
icon: HistoryIcon,
sortValue: (r) => r.createdAt,
cell: (r) => (
<span className="tabular-nums text-muted-foreground">
<span className="text-muted-foreground tabular-nums">
{new Date(r.createdAt).toLocaleString('ru-RU')}
</span>
),
},
{
key: 'action',
header: 'Действие',
cell: (r) => (
<Badge variant={auditActionBadgeVariant(r.action)} size="sm">
{auditActionLabel(r.action)}
</Badge>
),
},
{
key: 'entity',
header: 'Сущность',
cell: (r) => <Badge variant="outline">{auditEntityLabel(r.entity)}</Badge>,
},
{
key: 'action',
header: 'Действие',
cell: (r) => ACTION_LABELS[r.action] ?? r.action,
},
{
key: 'entityId',
header: 'ID',
cell: (r) =>
r.entity === 'vps' ? (
<Button variant="link" className="h-auto p-0" render={<Link to="/vps/$vpsId" params={{ vpsId: r.entityId }} />}>
<Button
variant="link"
className="h-auto p-0 font-mono text-xs"
render={<Link to="/vps/$vpsId" params={{ vpsId: r.entityId }} />}
>
{r.entityId}
</Button>
) : (
<span className="font-mono text-xs">{r.entityId}</span>
),
},
{
key: 'actor',
header: 'Актор',
icon: UserRoundIcon,
sortValue: (r) => r.actorUserId ?? '',
cell: (r) => (
<span className="text-muted-foreground text-sm">{r.actorUserId?.trim() || 'система'}</span>
),
},
{
key: 'diff',
header: 'Изменения',
sortable: false,
cell: (r) => (
<span className="max-w-md truncate text-xs text-muted-foreground">
{r.diff ? JSON.stringify(r.diff) : '—'}
<span className="text-muted-foreground max-w-md truncate text-xs" title={diffPreview(r.diff, 8)}>
{diffPreview(r.diff)}
</span>
),
},
@@ -99,7 +160,29 @@ function AuditPage() {
return (
<PageShell>
<PageHeader title="Журнал изменений" description="История ручных правок через API" />
<PageHeader
title="Журнал изменений"
description="История ручных правок через API"
actions={<AuditViewToggle view={view} onViewChange={setView} />}
/>
<Tabs value={actionFilter} onValueChange={setActionFilter}>
<TabsList variant="line" aria-label="Фильтр по действию" className="gap-5">
{ACTION_FILTERS.map((option) => (
<TabsTrigger
key={option.value}
value={option.value}
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3"
>
<span>{option.label}</span>
<span className="bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 text-[10px] tabular-nums">
{actionCounts[option.value]}
</span>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<QueryState
data={data}
isLoading={isLoading}
@@ -109,16 +192,34 @@ function AuditPage() {
skeleton={<TableSkeleton />}
empty={!data?.length}
emptyTitle="Записей нет"
emptyDescription="Изменения VPS появятся здесь после CRUD-операций"
emptyDescription="Изменения появятся здесь после CRUD-операций"
>
{(rows) => (
<ResourcePage
columns={columnDefFromDataGrid(columns)}
data={rows as AuditRow[]}
getRowId={(r) => r.id}
pageSize={25}
/>
)}
{() => {
if (filtered.length === 0) {
return (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-12 text-center text-sm">
<p>Нет записей для фильтра «{ACTION_LABELS[actionFilter] ?? actionFilter}»</p>
<Button type="button" variant="outline" size="sm" onClick={() => setActionFilter('all')}>
Сбросить фильтр
</Button>
</div>
)
}
if (view === 'table') {
return (
<ResourcePage
columns={columnDefFromDataGrid(columns)}
data={filtered}
getRowId={(r) => r.id}
pageSize={25}
dense
/>
)
}
return <AuditTimeline rows={filtered} />
}}
</QueryState>
</PageShell>
)
+2
View File
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
import { toggleVariants } from "@cfdm/ui/components/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }
+43
View File
@@ -0,0 +1,43 @@
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@cfdm/ui/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }