feat(audit): показывать человекочитаемые имена VPS в журнале
Docker / build (push) Failing after 19s
Docker / build (push) Failing after 19s
Вместо сырого ID — dns/ip (и name для хостера/аккаунта/проекта); id остаётся в title и деталях. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -63,3 +63,44 @@ export function diffPreview(diff: Record<string, unknown> | null | undefined, ma
|
||||
const rest = entries.length - maxKeys
|
||||
return rest > 0 ? `${head} (+${rest})` : head
|
||||
}
|
||||
|
||||
export type AuditEntityLookup = {
|
||||
vps?: Map<string, { dns?: string | null; ip?: string | null }>
|
||||
provider?: Map<string, { name?: string | null }>
|
||||
providerAccount?: Map<string, { name?: string | null }>
|
||||
serverProject?: Map<string, { name?: string | null }>
|
||||
}
|
||||
|
||||
function strFromDiff(diff: Record<string, unknown> | null | undefined, key: string): string {
|
||||
const v = diff?.[key]
|
||||
return typeof v === 'string' && v.trim() ? v.trim() : ''
|
||||
}
|
||||
|
||||
/** Человекочитаемое имя сущности: dns/ip для VPS, name для хостера/аккаунта/проекта. */
|
||||
export function auditEntityTitle(
|
||||
entity: string,
|
||||
entityId: string,
|
||||
diff?: Record<string, unknown> | null,
|
||||
lookup?: AuditEntityLookup,
|
||||
): string {
|
||||
if (entity === 'vps') {
|
||||
const row = lookup?.vps?.get(entityId)
|
||||
const fromSnap = (row?.dns || row?.ip || '').trim()
|
||||
if (fromSnap) return fromSnap
|
||||
const fromDiff = strFromDiff(diff, 'dns') || strFromDiff(diff, 'ip')
|
||||
if (fromDiff) return fromDiff
|
||||
}
|
||||
if (entity === 'provider') {
|
||||
const name = (lookup?.provider?.get(entityId)?.name || strFromDiff(diff, 'name')).trim()
|
||||
if (name) return name
|
||||
}
|
||||
if (entity === 'providerAccount') {
|
||||
const name = (lookup?.providerAccount?.get(entityId)?.name || strFromDiff(diff, 'name')).trim()
|
||||
if (name) return name
|
||||
}
|
||||
if (entity === 'serverProject') {
|
||||
const name = (lookup?.serverProject?.get(entityId)?.name || strFromDiff(diff, 'name')).trim()
|
||||
if (name) return name
|
||||
}
|
||||
return entityId
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ import {
|
||||
auditActionBadgeVariant,
|
||||
auditActionLabel,
|
||||
auditEntityLabel,
|
||||
auditEntityTitle,
|
||||
diffFieldEntries,
|
||||
type AuditEntityLookup,
|
||||
} from '@/components/domain/audit-labels'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
@@ -47,6 +49,7 @@ export interface AuditRow {
|
||||
|
||||
interface AuditTimelineProps {
|
||||
rows: AuditRow[]
|
||||
entityLookup?: AuditEntityLookup
|
||||
}
|
||||
|
||||
function actionIcon(action: string) {
|
||||
@@ -79,19 +82,32 @@ function timeLabel(iso: string): string {
|
||||
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
function EntityIdLink({ entity, entityId }: { entity: string; entityId: string }) {
|
||||
function EntityTargetLink({
|
||||
entity,
|
||||
entityId,
|
||||
title,
|
||||
}: {
|
||||
entity: string
|
||||
entityId: string
|
||||
title: string
|
||||
}) {
|
||||
if (entity === 'vps') {
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 font-mono text-xs"
|
||||
className="h-auto max-w-full truncate p-0 text-sm font-medium"
|
||||
title={entityId}
|
||||
render={<Link to="/vps/$vpsId" params={{ vpsId: entityId }} />}
|
||||
>
|
||||
{entityId}
|
||||
{title}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return <span className="font-mono text-xs">{entityId}</span>
|
||||
return (
|
||||
<span className="truncate text-sm font-medium" title={entityId}>
|
||||
{title}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({
|
||||
@@ -99,15 +115,18 @@ function EventRow({
|
||||
step,
|
||||
isLast,
|
||||
defaultOpen,
|
||||
entityLookup,
|
||||
}: {
|
||||
row: AuditRow
|
||||
step: number
|
||||
isLast: boolean
|
||||
defaultOpen: boolean
|
||||
entityLookup?: AuditEntityLookup
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
const fieldCount = diffFieldEntries(row.diff).length
|
||||
const actor = row.actorUserId?.trim() || 'система'
|
||||
const title = auditEntityTitle(row.entity, row.entityId, row.diff, entityLookup)
|
||||
|
||||
return (
|
||||
<TimelineItem step={step} className={cn('ms-10', isLast ? 'pb-0' : 'pb-6')}>
|
||||
@@ -136,7 +155,7 @@ function EventRow({
|
||||
<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} />
|
||||
<EntityTargetLink entity={row.entity} entityId={row.entityId} title={title} />
|
||||
{fieldCount > 0 ? (
|
||||
<Badge variant="outline" size="xs">
|
||||
{fieldCount} {fieldCount === 1 ? 'поле' : 'полей'}
|
||||
@@ -157,11 +176,19 @@ function EventRow({
|
||||
<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>
|
||||
<dt className="text-muted-foreground text-xs">Объект</dt>
|
||||
<dd className="min-w-0">
|
||||
<EntityIdLink entity={row.entity} entityId={row.entityId} />
|
||||
<EntityTargetLink entity={row.entity} entityId={row.entityId} title={title} />
|
||||
</dd>
|
||||
</div>
|
||||
{title !== row.entityId ? (
|
||||
<div className="flex min-w-0 flex-col gap-0.5 sm:col-span-2">
|
||||
<dt className="text-muted-foreground text-xs">ID</dt>
|
||||
<dd className="text-muted-foreground truncate font-mono text-xs" title={row.entityId}>
|
||||
{row.entityId}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<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>
|
||||
@@ -187,7 +214,7 @@ function EventRow({
|
||||
}
|
||||
|
||||
/** Day-grouped audit timeline — DNA solution-users-6. */
|
||||
export function AuditTimeline({ rows }: AuditTimelineProps) {
|
||||
export function AuditTimeline({ rows, entityLookup }: AuditTimelineProps) {
|
||||
const days = useMemo(() => {
|
||||
const map = new Map<string, { key: string; label: string; events: AuditRow[] }>()
|
||||
for (const row of rows) {
|
||||
@@ -217,6 +244,7 @@ export function AuditTimeline({ rows }: AuditTimelineProps) {
|
||||
step={index + 1}
|
||||
isLast={index === day.events.length - 1}
|
||||
defaultOpen={dayIndex === 0 && index < 2}
|
||||
entityLookup={entityLookup}
|
||||
/>
|
||||
))}
|
||||
</Timeline>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { HistoryIcon, UserRoundIcon } from 'lucide-react'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { api } from '@/lib/api-client'
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
@@ -17,7 +18,9 @@ import {
|
||||
auditActionBadgeVariant,
|
||||
auditActionLabel,
|
||||
auditEntityLabel,
|
||||
auditEntityTitle,
|
||||
diffPreview,
|
||||
type AuditEntityLookup,
|
||||
} from '@/components/domain/audit-labels'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
@@ -40,6 +43,8 @@ const ACTION_FILTERS: { value: AuditActionFilter; label: string }[] = [
|
||||
|
||||
export const Route = createFileRoute('/_auth/audit')({
|
||||
validateSearch: (search) => auditSearchSchema.parse(search),
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: AuditPage,
|
||||
})
|
||||
|
||||
@@ -53,6 +58,23 @@ function AuditPage() {
|
||||
queryKey: ['audit'],
|
||||
queryFn: () => api.fetchAuditLog(200) as Promise<AuditRow[]>,
|
||||
})
|
||||
const { data: snapshot } = useQuery(snapshotQueryOptions())
|
||||
|
||||
const entityLookup = useMemo<AuditEntityLookup>(() => {
|
||||
const vps = new Map(
|
||||
(snapshot?.vps ?? []).map((v) => [v.id, { dns: v.dns, ip: v.ip }] as const),
|
||||
)
|
||||
const provider = new Map(
|
||||
(snapshot?.providers ?? []).map((p) => [p.id, { name: p.name }] as const),
|
||||
)
|
||||
const providerAccount = new Map(
|
||||
(snapshot?.providerAccounts ?? []).map((a) => [a.id, { name: a.name }] as const),
|
||||
)
|
||||
const serverProject = new Map(
|
||||
(snapshot?.serverProjects ?? []).map((p) => [p.id, { name: p.name }] as const),
|
||||
)
|
||||
return { vps, provider, providerAccount, serverProject }
|
||||
}, [snapshot])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const rows = data ?? []
|
||||
@@ -123,19 +145,28 @@ function AuditPage() {
|
||||
},
|
||||
{
|
||||
key: 'entityId',
|
||||
header: 'ID',
|
||||
cell: (r) =>
|
||||
r.entity === 'vps' ? (
|
||||
<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>
|
||||
),
|
||||
header: 'Объект',
|
||||
sortValue: (r) => auditEntityTitle(r.entity, r.entityId, r.diff, entityLookup),
|
||||
cell: (r) => {
|
||||
const title = auditEntityTitle(r.entity, r.entityId, r.diff, entityLookup)
|
||||
if (r.entity === 'vps') {
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto max-w-full truncate p-0 text-sm font-medium"
|
||||
title={r.entityId}
|
||||
render={<Link to="/vps/$vpsId" params={{ vpsId: r.entityId }} />}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="truncate text-sm font-medium" title={r.entityId}>
|
||||
{title}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actor',
|
||||
@@ -218,7 +249,7 @@ function AuditPage() {
|
||||
)
|
||||
}
|
||||
|
||||
return <AuditTimeline rows={filtered} />
|
||||
return <AuditTimeline rows={filtered} entityLookup={entityLookup} />
|
||||
}}
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
|
||||
Reference in New Issue
Block a user