Лента в стиле solution-users-6 с раскрываемым diff; переключение на ResourcePage. Исправлено: монитор считает ошибку синка только по последней записи аккаунта. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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: "mira.stone@acmecloud.com",
|
||||
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: "leo.grant@acmecloud.com",
|
||||
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: "sana.qureshi@acmecloud.com",
|
||||
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: "sarah.chen@acmecloud.com",
|
||||
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: "david.kim@acmecloud.com",
|
||||
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: "kenji.tan@acmecloud.com",
|
||||
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: "omar.haddad@acmecloud.com",
|
||||
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: "nora.vale@acmecloud.com",
|
||||
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: "kenji.tan@acmecloud.com",
|
||||
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: "mira.stone@acmecloud.com",
|
||||
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: "nora.vale@acmecloud.com",
|
||||
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: "sana.qureshi@acmecloud.com",
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}`),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user