From c849dd5ff8b2ef37690546847abd879c2b53f119 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 21 Jul 2026 17:57:43 +0700 Subject: [PATCH] =?UTF-8?q?feat(audit):=20=D0=B3=D0=B8=D0=B1=D1=80=D0=B8?= =?UTF-8?q?=D0=B4=20timeline=20=D0=B8=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=86=D1=8B=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=D0=B0=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Лента в стиле solution-users-6 с раскрываемым diff; переключение на ResourcePage. Исправлено: монитор считает ошибку синка только по последней записи аккаунта. Co-authored-by: Cursor --- .../components/audit-log-timeline.tsx | 356 +++++++++++++++ .../solution-users-6/components/data.tsx | 407 ++++++++++++++++++ .../blocks/solution-users-6/page.tsx | 9 + apps/web/src/components/domain/audit-diff.tsx | 27 ++ .../web/src/components/domain/audit-labels.ts | 65 +++ .../src/components/domain/audit-timeline.tsx | 227 ++++++++++ .../components/domain/audit-view-toggle.tsx | 35 ++ .../layout/system-monitor-popover.tsx | 38 +- apps/web/src/components/reui/timeline.tsx | 256 +++++++++++ apps/web/src/lib/api-client.ts | 1 + apps/web/src/routes/_auth/audit.tsx | 199 ++++++--- packages/ui/src/components/avatar.tsx | 2 + packages/ui/src/components/toggle-group.tsx | 89 ++++ packages/ui/src/components/toggle.tsx | 43 ++ 14 files changed, 1696 insertions(+), 58 deletions(-) create mode 100644 apps/web/src/components/blocks/solution-users-6/components/audit-log-timeline.tsx create mode 100644 apps/web/src/components/blocks/solution-users-6/components/data.tsx create mode 100644 apps/web/src/components/blocks/solution-users-6/page.tsx create mode 100644 apps/web/src/components/domain/audit-diff.tsx create mode 100644 apps/web/src/components/domain/audit-labels.ts create mode 100644 apps/web/src/components/domain/audit-timeline.tsx create mode 100644 apps/web/src/components/domain/audit-view-toggle.tsx create mode 100644 apps/web/src/components/reui/timeline.tsx create mode 100644 packages/ui/src/components/toggle-group.tsx create mode 100644 packages/ui/src/components/toggle.tsx diff --git a/apps/web/src/components/blocks/solution-users-6/components/audit-log-timeline.tsx b/apps/web/src/components/blocks/solution-users-6/components/audit-log-timeline.tsx new file mode 100644 index 0000000..a0b64c2 --- /dev/null +++ b/apps/web/src/components/blocks/solution-users-6/components/audit-log-timeline.tsx @@ -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 ( + + + +
+ + {event.action} + + + + {event.time} +
+ + {event.icon} + +
+ + + + setOpen(nextOpen)} + className="group/collapsible" + > + + +
+ + + + {event.actor.initials} + + + + {event.actor.name}, {event.label} + +
+
+
+ + + +
+ + + {event.target} + + + + + {event.actor.email} + + + + + {event.ip} + + {event.location} + + + + + + {event.detail.sessionId} + + +
+ +

+ {event.detail.reason} +

+ +
+ + {event.ref} + + +
+
+
+
+ +
+
+ ) +} + +function DetailRow({ + label, + children, +}: { + label: string + children: React.ReactNode +}) { + return ( +
+
{label}
+
{children}
+
+ ) +} + +export function AuditLogTimeline() { + const [filter, setFilter] = React.useState(["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 ( +
+ {/* ── Content header (title + filter chips + range + export) ── */} +
+
+
+

+ Audit Log +

+

+ {TOTAL_EVENTS} events in Acme Cloud workspace +

+
+ +
+ + + +
+
+ + value && setFilter([value])} + > + + {FILTER_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +
+ + {visibleDays.length === 0 ? ( + + + + + No {activeFilter} events + + No {activeFilter} events in the last 24 hours. Try another type or + widen the range. + + + + + + + ) : ( +
+ {visibleDays.map((day) => ( +
+

+ {day.date} +

+ + {day.events.map((event, index) => ( + + ))} + +
+ ))} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/blocks/solution-users-6/components/data.tsx b/apps/web/src/components/blocks/solution-users-6/components/data.tsx new file mode 100644 index 0000000..4daf019 --- /dev/null +++ b/apps/web/src/components/blocks/solution-users-6/components/data.tsx @@ -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 = { + info: "success-outline", + notice: "warning-outline", + critical: "destructive-outline", +} + +export const severityLabel: Record = { + info: "Info", + notice: "Notice", + critical: "Critical", +} + +export const severityDotClass: Record = { + 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 = ( +