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
|
const rest = entries.length - maxKeys
|
||||||
return rest > 0 ? `${head} (+${rest})` : head
|
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,
|
auditActionBadgeVariant,
|
||||||
auditActionLabel,
|
auditActionLabel,
|
||||||
auditEntityLabel,
|
auditEntityLabel,
|
||||||
|
auditEntityTitle,
|
||||||
diffFieldEntries,
|
diffFieldEntries,
|
||||||
|
type AuditEntityLookup,
|
||||||
} from '@/components/domain/audit-labels'
|
} from '@/components/domain/audit-labels'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
@@ -47,6 +49,7 @@ export interface AuditRow {
|
|||||||
|
|
||||||
interface AuditTimelineProps {
|
interface AuditTimelineProps {
|
||||||
rows: AuditRow[]
|
rows: AuditRow[]
|
||||||
|
entityLookup?: AuditEntityLookup
|
||||||
}
|
}
|
||||||
|
|
||||||
function actionIcon(action: string) {
|
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' })
|
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') {
|
if (entity === 'vps') {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
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 }} />}
|
render={<Link to="/vps/$vpsId" params={{ vpsId: entityId }} />}
|
||||||
>
|
>
|
||||||
{entityId}
|
{title}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <span className="font-mono text-xs">{entityId}</span>
|
return (
|
||||||
|
<span className="truncate text-sm font-medium" title={entityId}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventRow({
|
function EventRow({
|
||||||
@@ -99,15 +115,18 @@ function EventRow({
|
|||||||
step,
|
step,
|
||||||
isLast,
|
isLast,
|
||||||
defaultOpen,
|
defaultOpen,
|
||||||
|
entityLookup,
|
||||||
}: {
|
}: {
|
||||||
row: AuditRow
|
row: AuditRow
|
||||||
step: number
|
step: number
|
||||||
isLast: boolean
|
isLast: boolean
|
||||||
defaultOpen: boolean
|
defaultOpen: boolean
|
||||||
|
entityLookup?: AuditEntityLookup
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(defaultOpen)
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
const fieldCount = diffFieldEntries(row.diff).length
|
const fieldCount = diffFieldEntries(row.diff).length
|
||||||
const actor = row.actorUserId?.trim() || 'система'
|
const actor = row.actorUserId?.trim() || 'система'
|
||||||
|
const title = auditEntityTitle(row.entity, row.entityId, row.diff, entityLookup)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TimelineItem step={step} className={cn('ms-10', isLast ? 'pb-0' : 'pb-6')}>
|
<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 />
|
<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 truncate text-sm font-medium">{actor}</span>
|
||||||
<span className="text-muted-foreground text-xs">·</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 ? (
|
{fieldCount > 0 ? (
|
||||||
<Badge variant="outline" size="xs">
|
<Badge variant="outline" size="xs">
|
||||||
{fieldCount} {fieldCount === 1 ? 'поле' : 'полей'}
|
{fieldCount} {fieldCount === 1 ? 'поле' : 'полей'}
|
||||||
@@ -157,11 +176,19 @@ function EventRow({
|
|||||||
<dd className="text-sm font-medium">{auditEntityLabel(row.entity)}</dd>
|
<dd className="text-sm font-medium">{auditEntityLabel(row.entity)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-col gap-0.5">
|
<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">
|
<dd className="min-w-0">
|
||||||
<EntityIdLink entity={row.entity} entityId={row.entityId} />
|
<EntityTargetLink entity={row.entity} entityId={row.entityId} title={title} />
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</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">
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<dt className="text-muted-foreground text-xs">Актор</dt>
|
<dt className="text-muted-foreground text-xs">Актор</dt>
|
||||||
<dd className="truncate text-sm font-medium">{actor}</dd>
|
<dd className="truncate text-sm font-medium">{actor}</dd>
|
||||||
@@ -187,7 +214,7 @@ function EventRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Day-grouped audit timeline — DNA solution-users-6. */
|
/** Day-grouped audit timeline — DNA solution-users-6. */
|
||||||
export function AuditTimeline({ rows }: AuditTimelineProps) {
|
export function AuditTimeline({ rows, entityLookup }: AuditTimelineProps) {
|
||||||
const days = useMemo(() => {
|
const days = useMemo(() => {
|
||||||
const map = new Map<string, { key: string; label: string; events: AuditRow[] }>()
|
const map = new Map<string, { key: string; label: string; events: AuditRow[] }>()
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -217,6 +244,7 @@ export function AuditTimeline({ rows }: AuditTimelineProps) {
|
|||||||
step={index + 1}
|
step={index + 1}
|
||||||
isLast={index === day.events.length - 1}
|
isLast={index === day.events.length - 1}
|
||||||
defaultOpen={dayIndex === 0 && index < 2}
|
defaultOpen={dayIndex === 0 && index < 2}
|
||||||
|
entityLookup={entityLookup}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Timeline>
|
</Timeline>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { HistoryIcon, UserRoundIcon } from 'lucide-react'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
@@ -17,7 +18,9 @@ import {
|
|||||||
auditActionBadgeVariant,
|
auditActionBadgeVariant,
|
||||||
auditActionLabel,
|
auditActionLabel,
|
||||||
auditEntityLabel,
|
auditEntityLabel,
|
||||||
|
auditEntityTitle,
|
||||||
diffPreview,
|
diffPreview,
|
||||||
|
type AuditEntityLookup,
|
||||||
} from '@/components/domain/audit-labels'
|
} from '@/components/domain/audit-labels'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
@@ -40,6 +43,8 @@ const ACTION_FILTERS: { value: AuditActionFilter; label: string }[] = [
|
|||||||
|
|
||||||
export const Route = createFileRoute('/_auth/audit')({
|
export const Route = createFileRoute('/_auth/audit')({
|
||||||
validateSearch: (search) => auditSearchSchema.parse(search),
|
validateSearch: (search) => auditSearchSchema.parse(search),
|
||||||
|
loader: ({ context: { queryClient } }) =>
|
||||||
|
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||||
component: AuditPage,
|
component: AuditPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -53,6 +58,23 @@ function AuditPage() {
|
|||||||
queryKey: ['audit'],
|
queryKey: ['audit'],
|
||||||
queryFn: () => api.fetchAuditLog(200) as Promise<AuditRow[]>,
|
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 filtered = useMemo(() => {
|
||||||
const rows = data ?? []
|
const rows = data ?? []
|
||||||
@@ -123,19 +145,28 @@ function AuditPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'entityId',
|
key: 'entityId',
|
||||||
header: 'ID',
|
header: 'Объект',
|
||||||
cell: (r) =>
|
sortValue: (r) => auditEntityTitle(r.entity, r.entityId, r.diff, entityLookup),
|
||||||
r.entity === 'vps' ? (
|
cell: (r) => {
|
||||||
<Button
|
const title = auditEntityTitle(r.entity, r.entityId, r.diff, entityLookup)
|
||||||
variant="link"
|
if (r.entity === 'vps') {
|
||||||
className="h-auto p-0 font-mono text-xs"
|
return (
|
||||||
render={<Link to="/vps/$vpsId" params={{ vpsId: r.entityId }} />}
|
<Button
|
||||||
>
|
variant="link"
|
||||||
{r.entityId}
|
className="h-auto max-w-full truncate p-0 text-sm font-medium"
|
||||||
</Button>
|
title={r.entityId}
|
||||||
) : (
|
render={<Link to="/vps/$vpsId" params={{ vpsId: r.entityId }} />}
|
||||||
<span className="font-mono text-xs">{r.entityId}</span>
|
>
|
||||||
),
|
{title}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="truncate text-sm font-medium" title={r.entityId}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actor',
|
key: 'actor',
|
||||||
@@ -218,7 +249,7 @@ function AuditPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AuditTimeline rows={filtered} />
|
return <AuditTimeline rows={filtered} entityLookup={entityLookup} />
|
||||||
}}
|
}}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
Reference in New Issue
Block a user