Окна «Создать пользователя» и «Создать OIDC-клиент» по доске с проверкой полей; баннер безопасности всегда виден; кнопки действий без иконок; подписи иконок приложений как на доске; поиск в таблице passkeys. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
156 lines
7.2 KiB
TypeScript
156 lines
7.2 KiB
TypeScript
import { useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import { KpiStrip } from '../kpi-strip'
|
|
import { Button } from '@authportal/ui/components/button'
|
|
import { Input } from '@authportal/ui/components/input'
|
|
import { Switch } from '@authportal/ui/components/switch'
|
|
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
|
import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PreviewSheet, SettingRow, StatusBadge, TypedConfirm } from '../bits'
|
|
import { ActivityFeed } from '../canvas-blocks'
|
|
import { kpi } from '../kpi'
|
|
import { AUDIT_FEED, AUDIT_ROWS, type AuditRow } from '../mock'
|
|
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
|
|
|
|
const mono = (v: string) => <span className="font-mono text-[12.5px] whitespace-nowrap">{v}</span>
|
|
|
|
const columns: GridColumn<AuditRow>[] = [
|
|
{ id: 'action', title: 'Событие', size: 340, sortValue: (r) => r.action, cell: (r) => <TwoLineCell dot={r.tone} title={<span className="font-mono text-[13px]">{r.action}</span>} sub={r.summary} /> },
|
|
{ id: 'status', title: 'Статус', size: 120, cell: (r) => <StatusBadge tone={r.tone}>{r.label}</StatusBadge> },
|
|
{ id: 'src', title: 'Источник', size: 160, sortValue: (r) => r.source, cell: (r) => mono(r.source) },
|
|
{ id: 'who', title: 'Кто', size: 120, cell: (r) => mono(r.who) },
|
|
{ id: 'ip', title: 'IP', size: 140, cell: (r) => mono(r.ip) },
|
|
{ id: 'when', title: 'Когда', size: 140, cell: (r) => mono(r.when) },
|
|
]
|
|
|
|
const VIEWS = [
|
|
{ label: 'Все', match: () => true },
|
|
{ label: 'Warning и выше', match: (r: AuditRow) => r.tone === 'wn' || r.tone === 'er' },
|
|
{ label: 'Critical', match: (r: AuditRow) => r.tone === 'er' },
|
|
]
|
|
|
|
export function AuditPage() {
|
|
const [entry, setEntry] = useState<AuditRow | null>(null)
|
|
const [purgeOpen, setPurgeOpen] = useState(false)
|
|
const [tab, setTab] = useState('feed')
|
|
const [retention, setRetention] = useState(90)
|
|
|
|
return (
|
|
<>
|
|
<PageHead
|
|
title="Изменения"
|
|
sub="Журнал действий администраторов и приложений · портал, EvoFirewall, EvoBGP, MikrotikManager и другие через ingest"
|
|
actions={
|
|
<Button variant="outline" onClick={() => setPurgeOpen(true)}>
|
|
Очистить старые записи…
|
|
</Button>
|
|
}
|
|
/>
|
|
<KpiStrip
|
|
aria-label="Показатели"
|
|
items={[
|
|
kpi('all', 'Событий', '148', { badge: 'за 7 дней' }),
|
|
kpi('acc', 'Изменений прав', '22', { tone: 'ok', badge: 'user.access_update' }),
|
|
kpi('crit', 'Критичных', '3', { tone: 'er', badge: 'critical' }),
|
|
kpi('src', 'Источников', '6', { badge: 'портал +5 приложений' }),
|
|
]}
|
|
/>
|
|
<Tabs value={tab} onValueChange={setTab} className="min-w-0">
|
|
<PageTabsList>
|
|
<PageTab value="feed" label="Лента" />
|
|
<PageTab value="table" label="Таблица" count={148} />
|
|
<PageTab value="retention" label="Ротация" />
|
|
</PageTabsList>
|
|
<TabsContent value="feed" className="min-w-0">
|
|
<ActivityFeed
|
|
title="Лента изменений"
|
|
description="Последние события · иконка отражает тип, цвет — важность"
|
|
items={AUDIT_FEED}
|
|
action={
|
|
<Button variant="outline" size="sm" onClick={() => setTab('table')}>
|
|
Весь журнал
|
|
</Button>
|
|
}
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent value="table" className="min-w-0">
|
|
<PreviewSectionGrid
|
|
title="Журнал изменений"
|
|
description="Поиск по событию, пользователю и IP · виды по важности"
|
|
rows={AUDIT_ROWS}
|
|
columns={columns}
|
|
getRowId={(r) => r.id}
|
|
searchText={(r) => `${r.action} ${r.summary} ${r.who} ${r.ip}`}
|
|
views={VIEWS}
|
|
footer="Показано 8 из 148"
|
|
onRowClick={setEntry}
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent value="retention">
|
|
<FormFrame title="Ротация журнала" description="Сколько хранить записи и как очищать" badge={`${retention} дней`}>
|
|
<SettingRow title="Срок хранения" description="Записи старше будут удаляться автоматически">
|
|
<Input
|
|
key={retention}
|
|
defaultValue={`${retention} дней`}
|
|
aria-label="Срок хранения"
|
|
className="w-40 max-w-full font-mono"
|
|
onBlur={(e) => {
|
|
const n = Number.parseInt(e.target.value, 10)
|
|
if (Number.isFinite(n) && n >= 7 && n <= 3650 && n !== retention) {
|
|
setRetention(n)
|
|
toast.success(`Срок хранения: ${n} дн. (макет)`)
|
|
}
|
|
}}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow title="Автоматическая очистка" description="Раз в сутки в 03:00">
|
|
<Switch defaultChecked aria-label="Автоматическая очистка" />
|
|
</SettingRow>
|
|
<SettingRow title="Записей в журнале" description="Из них старше срока хранения — 212">
|
|
<StatusBadge tone="mu">1 482</StatusBadge>
|
|
</SettingRow>
|
|
</FormFrame>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<PreviewSheet
|
|
open={entry !== null}
|
|
onOpenChange={(o) => !o && setEntry(null)}
|
|
title="Событие журнала"
|
|
description="Детали записи"
|
|
footer={<Button variant="outline" onClick={() => setEntry(null)}>Закрыть</Button>}
|
|
>
|
|
{entry ? (
|
|
<>
|
|
<KvList items={[['Событие', `${entry.action} · ${entry.summary}`]]} />
|
|
<div>
|
|
<StatusBadge tone={entry.tone}>{entry.label}</StatusBadge>
|
|
</div>
|
|
<KvList
|
|
items={[
|
|
['Источник', entry.source],
|
|
['Кто', `${entry.who}@shnt.top · ${entry.ip}`],
|
|
]}
|
|
/>
|
|
<CodeBlock label="Подробности">
|
|
{'target_type: user\ntarget_id: 7c1e…a3\nbefore: { disabled: false }\nafter: { disabled: true }'}
|
|
</CodeBlock>
|
|
</>
|
|
) : null}
|
|
</PreviewSheet>
|
|
|
|
<TypedConfirm
|
|
open={purgeOpen}
|
|
onOpenChange={setPurgeOpen}
|
|
title="Очистить старые записи?"
|
|
description={`Будут безвозвратно удалены 212 записей старше ${retention} дней. Само действие попадёт в журнал как audit.purge.`}
|
|
expected="очистить"
|
|
confirmLabel="Очистить"
|
|
onConfirm={() => {
|
|
setPurgeOpen(false)
|
|
toast.success('Очищено 212 записей (макет)')
|
|
}}
|
|
/>
|
|
</>
|
|
)
|
|
}
|