feat(audit): показывать человекочитаемые имена VPS в журнале
Docker / build (push) Failing after 19s

Вместо сырого ID — dns/ip (и name для хостера/аккаунта/проекта); id остаётся в title и деталях.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 18:10:43 +07:00
co-authored by Cursor
parent c849dd5ff8
commit 72f9597ede
3 changed files with 122 additions and 22 deletions
@@ -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>
+45 -14
View File
@@ -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>