feat(control-center): adapt AuditPage styling and token integration
This commit is contained in:
parent
b8739a5096
commit
3922e577f3
1 changed files with 168 additions and 74 deletions
|
|
@ -4,8 +4,12 @@ import {
|
||||||
Search,
|
Search,
|
||||||
Download,
|
Download,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
ChevronRight,
|
||||||
|
X,
|
||||||
|
Hash,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||||
|
import { RU_AUDIT_ACTION, RU_ROLES } from '../locales/ru';
|
||||||
|
|
||||||
export const AuditPage: React.FC = () => {
|
export const AuditPage: React.FC = () => {
|
||||||
const auditLog = useKccStore((state) => state.auditLog);
|
const auditLog = useKccStore((state) => state.auditLog);
|
||||||
|
|
@ -15,11 +19,13 @@ export const AuditPage: React.FC = () => {
|
||||||
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
||||||
|
|
||||||
const filtered = auditLog.filter((item) => {
|
const filtered = auditLog.filter((item) => {
|
||||||
|
const actionRu = RU_AUDIT_ACTION[item.action] || '';
|
||||||
const matchSearch =
|
const matchSearch =
|
||||||
!search ||
|
!search ||
|
||||||
item.operatorName.toLowerCase().includes(search.toLowerCase()) ||
|
item.operatorName.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
item.reason.toLowerCase().includes(search.toLowerCase()) ||
|
item.reason.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
item.action.toLowerCase().includes(search.toLowerCase()) ||
|
item.action.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
actionRu.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
item.correlationId.toLowerCase().includes(search.toLowerCase());
|
item.correlationId.toLowerCase().includes(search.toLowerCase());
|
||||||
return matchSearch;
|
return matchSearch;
|
||||||
});
|
});
|
||||||
|
|
@ -37,15 +43,15 @@ export const AuditPage: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-krona-border pb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
|
<h1 className="text-lg md:text-xl font-bold text-krona-primary flex items-center gap-2">
|
||||||
<FileCheck2 className="w-5 h-5 text-amber-400" />
|
<FileCheck2 className="w-5 h-5 text-amber-500 shrink-0" />
|
||||||
<span>Неизменяемый журнал аудита действий операторов</span>
|
<span>Неизменяемый журнал аудита действий</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-zinc-400 mt-0.5">
|
<p className="text-xs text-krona-secondary mt-0.5">
|
||||||
Криптографическая цепочка событий (SHA-256 Hash Chain). Записи защищены от редактирования и удаления.
|
Криптографическая цепочка событий (SHA-256 Hash Chain). Записи защищены от редактирования и удаления.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -55,59 +61,127 @@ export const AuditPage: React.FC = () => {
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!permissions.canExportAudit}
|
disabled={!permissions.canExportAudit}
|
||||||
onClick={handleExportJson}
|
onClick={handleExportJson}
|
||||||
className="px-3 py-1.5 rounded-lg bg-zinc-900 border border-zinc-700 hover:bg-zinc-800 text-zinc-200 text-xs font-semibold flex items-center gap-1.5 transition-colors disabled:opacity-40"
|
className="min-h-[44px] px-4 py-2.5 rounded-lg bg-krona-surface border border-krona-border hover:bg-krona-workspace text-krona-primary text-xs font-semibold flex items-center justify-center gap-2 transition-colors disabled:opacity-40 shadow-xs"
|
||||||
title="Экспорт неизменяемого журнала для регулятора/аудитора"
|
title="Экспорт неизменяемого журнала для регулятора/аудитора"
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5" />
|
<Download className="w-4 h-4" />
|
||||||
<span>Экспорт JSON</span>
|
<span>Экспорт JSON</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Verification Badge */}
|
{/* Verification Badge */}
|
||||||
<div className="rounded-xl border border-emerald-500/40 bg-emerald-950/20 p-3.5 flex items-center justify-between text-xs text-emerald-300">
|
<div className="rounded-xl border border-emerald-500/40 bg-emerald-500/10 p-3.5 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs text-emerald-600 dark:text-emerald-300">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
<ShieldCheck className="w-5 h-5 text-emerald-500 shrink-0" />
|
||||||
<span>Целостность журнала аудита подтверждена: <strong>{auditLog.length} событий</strong> в цепочке хешей.</span>
|
<span>
|
||||||
|
Целостность журнала подтверждена: <strong>{auditLog.length} событий</strong> в цепочке хешей.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-[11px] text-emerald-400/80">WORM (Write Once, Read Many)</span>
|
<span className="font-mono text-[11px] text-emerald-600/80 dark:text-emerald-400/80">
|
||||||
|
WORM (Write Once, Read Many)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative max-w-md">
|
<div className="relative max-w-md">
|
||||||
<Search className="w-4 h-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
<Search className="w-4 h-4 text-krona-secondary absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Поиск по оператору, действию, correlation ID или обоснованию..."
|
placeholder="Поиск по оператору, действию или обоснованию..."
|
||||||
className="w-full bg-[#0b1d16] border border-zinc-800 rounded-lg pl-9 pr-3 py-2 text-xs text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500 transition-colors"
|
className="w-full bg-krona-workspace border border-krona-border rounded-lg pl-9 pr-3 py-2.5 text-xs text-krona-primary placeholder:text-krona-secondary/60 focus:outline-hidden focus:border-amber-500 transition-colors min-h-[44px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Audit Log Table */}
|
{/* Mobile Card View (block lg:hidden) */}
|
||||||
<div className="rounded-xl border border-zinc-800 bg-[#091a13] overflow-hidden shadow-xl">
|
<div className="block lg:hidden space-y-3">
|
||||||
|
{filtered.map((item) => {
|
||||||
|
const actionLabel = RU_AUDIT_ACTION[item.action] || item.action;
|
||||||
|
const roleLabel = RU_ROLES[item.operatorRole]?.shortLabel || item.operatorRole;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.eventId}
|
||||||
|
onClick={() => setSelectedEventId(item.eventId)}
|
||||||
|
className="p-4 rounded-xl border border-krona-border bg-krona-surface space-y-3 cursor-pointer shadow-xs hover:border-amber-500/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start gap-2 border-b border-krona-border pb-2.5">
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-sm text-krona-primary">{actionLabel}</div>
|
||||||
|
<div className="text-[11px] text-krona-secondary font-mono mt-0.5">
|
||||||
|
{item.operatorName} • <span className="text-amber-500 font-semibold">{roleLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-[11px] text-krona-secondary font-mono">
|
||||||
|
{new Date(item.occurredAt).toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-krona-secondary space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-krona-secondary/70">Объект:</span>
|
||||||
|
<span className="font-medium text-krona-primary">
|
||||||
|
{item.target.displayName || item.target.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="line-clamp-2 text-krona-primary/90 bg-krona-workspace p-2 rounded-lg border border-krona-border">
|
||||||
|
{item.reason}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-krona-border flex items-center justify-between text-[11px] text-krona-secondary">
|
||||||
|
<span className="font-mono text-xs text-krona-secondary flex items-center gap-1">
|
||||||
|
<Hash className="w-3 h-3 text-amber-500" />
|
||||||
|
{item.hash.slice(0, 8)}...{item.hash.slice(-4)}
|
||||||
|
</span>
|
||||||
|
<span className="text-amber-500 font-medium inline-flex items-center gap-0.5">
|
||||||
|
Подробнее <ChevronRight className="w-3.5 h-3.5" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="p-8 rounded-xl border border-krona-border bg-krona-surface text-center text-xs text-krona-secondary">
|
||||||
|
Записи аудита не найдены
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Audit Log Table (hidden lg:block) */}
|
||||||
|
<div className="hidden lg:block rounded-xl border border-krona-border bg-krona-surface overflow-hidden shadow-xs">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-xs border-collapse">
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-800 bg-zinc-950/70 text-zinc-400 font-semibold uppercase tracking-wider text-[11px]">
|
<tr className="border-b border-krona-border bg-krona-workspace text-krona-secondary font-semibold uppercase tracking-wider text-[11px]">
|
||||||
<th className="py-3 px-4">Время (UTC)</th>
|
<th className="py-3 px-4">Время</th>
|
||||||
<th className="py-3 px-3">Оператор / Роль</th>
|
<th className="py-3 px-3">Оператор / Роль</th>
|
||||||
<th className="py-3 px-3 font-mono">Действие</th>
|
<th className="py-3 px-3">Действие</th>
|
||||||
<th className="py-3 px-3">Целевой объект</th>
|
<th className="py-3 px-3">Целевой объект</th>
|
||||||
<th className="py-3 px-3">Обоснование (Reason)</th>
|
<th className="py-3 px-3">Обоснование</th>
|
||||||
<th className="py-3 px-3 font-mono">Correlation ID</th>
|
<th className="py-3 px-3 font-mono">Correlation ID</th>
|
||||||
<th className="py-3 px-4 text-right font-mono">SHA-256 Hash</th>
|
<th className="py-3 px-4 text-right font-mono">SHA-256 Hash</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-800/60">
|
<tbody className="divide-y divide-krona-border/60">
|
||||||
{filtered.map((item) => (
|
{filtered.map((item) => {
|
||||||
|
const actionLabel = RU_AUDIT_ACTION[item.action] || item.action;
|
||||||
|
const roleLabel = RU_ROLES[item.operatorRole]?.label || item.operatorRole;
|
||||||
|
|
||||||
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={item.eventId}
|
key={item.eventId}
|
||||||
onClick={() => setSelectedEventId(item.eventId)}
|
onClick={() => setSelectedEventId(item.eventId)}
|
||||||
className="hover:bg-zinc-800/40 cursor-pointer transition-colors"
|
className="hover:bg-krona-workspace/50 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<td className="py-3 px-4 font-mono text-zinc-400 text-[11px] whitespace-nowrap">
|
<td className="py-3 px-4 font-mono text-krona-secondary text-[11px] whitespace-nowrap">
|
||||||
{new Date(item.occurredAt).toLocaleString('ru-RU', {
|
{new Date(item.occurredAt).toLocaleString('ru-RU', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
|
|
@ -117,27 +191,35 @@ export const AuditPage: React.FC = () => {
|
||||||
})}
|
})}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3">
|
<td className="py-3 px-3">
|
||||||
<div className="font-bold text-zinc-200">{item.operatorName}</div>
|
<div className="font-bold text-krona-primary">{item.operatorName}</div>
|
||||||
<div className="text-[10px] text-amber-400/80 font-mono uppercase">{item.operatorRole}</div>
|
<div className="text-[10px] text-amber-500 font-medium">{roleLabel}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3 font-mono text-amber-300 text-[11px]">{item.action}</td>
|
<td className="py-3 px-3">
|
||||||
<td className="py-3 px-3 text-zinc-300">
|
<div className="font-semibold text-krona-primary text-xs">{actionLabel}</div>
|
||||||
<span className="font-semibold">{item.target.displayName || item.target.id}</span>
|
<div className="font-mono text-[10px] text-krona-secondary">{item.action}</div>
|
||||||
<span className="text-[10px] text-zinc-500 block font-mono">type: {item.target.type}</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3 text-zinc-200 max-w-xs truncate" title={item.reason}>
|
<td className="py-3 px-3 text-krona-secondary">
|
||||||
|
<span className="font-semibold text-krona-primary">
|
||||||
|
{item.target.displayName || item.target.id}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-krona-secondary block font-mono">
|
||||||
|
тип: {item.target.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3 text-krona-primary max-w-xs truncate" title={item.reason}>
|
||||||
{item.reason}
|
{item.reason}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3 font-mono text-zinc-400 text-[11px]">{item.correlationId}</td>
|
<td className="py-3 px-3 font-mono text-krona-secondary text-[11px]">{item.correlationId}</td>
|
||||||
<td className="py-3 px-4 text-right font-mono text-zinc-500 text-[10px]" title={item.hash}>
|
<td className="py-3 px-4 text-right font-mono text-krona-secondary text-[10px]" title={item.hash}>
|
||||||
{item.hash.slice(0, 10)}...{item.hash.slice(-6)}
|
{item.hash.slice(0, 10)}...{item.hash.slice(-6)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="py-8 text-center text-zinc-500">
|
<td colSpan={7} className="py-8 text-center text-krona-secondary">
|
||||||
Записи аудита не найдены
|
Записи аудита не найдены
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -150,38 +232,49 @@ export const AuditPage: React.FC = () => {
|
||||||
{/* Selected event modal / details */}
|
{/* Selected event modal / details */}
|
||||||
{selectedEvent && (
|
{selectedEvent && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs p-4 animate-in fade-in"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
>
|
>
|
||||||
<div className="w-full max-w-xl rounded-xl border border-zinc-700 bg-[#0d1e17] p-6 text-zinc-200 space-y-4 shadow-2xl">
|
<div className="w-full max-w-xl rounded-xl border border-krona-border bg-krona-surface p-5 md:p-6 text-krona-primary space-y-4 shadow-2xl">
|
||||||
<div className="flex justify-between items-start border-b border-zinc-800 pb-3">
|
<div className="flex justify-between items-start border-b border-krona-border pb-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-mono text-amber-400">{selectedEvent.eventId}</div>
|
<div className="text-xs font-mono text-amber-500">{selectedEvent.eventId}</div>
|
||||||
<h3 className="text-base font-bold text-zinc-100">{selectedEvent.action}</h3>
|
<h3 className="text-base font-bold text-krona-primary">
|
||||||
|
{RU_AUDIT_ACTION[selectedEvent.action] || selectedEvent.action}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedEventId(null)}
|
onClick={() => setSelectedEventId(null)}
|
||||||
className="text-zinc-400 hover:text-zinc-200 text-xs px-2 py-1 rounded-md bg-zinc-800"
|
className="min-w-[44px] min-h-[44px] flex items-center justify-center text-krona-secondary hover:text-krona-primary rounded-lg hover:bg-krona-workspace transition-colors"
|
||||||
|
aria-label="Закрыть"
|
||||||
>
|
>
|
||||||
Закрыть
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 text-xs">
|
<div className="space-y-3 text-xs">
|
||||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-1.5">
|
<div className="p-3.5 rounded-lg bg-krona-workspace border border-krona-border space-y-2">
|
||||||
<div className="text-zinc-400">Обоснование действия:</div>
|
<div className="flex justify-between text-krona-secondary">
|
||||||
<div className="font-medium text-zinc-100">{selectedEvent.reason}</div>
|
<span>Оператор:</span>
|
||||||
<div className="text-zinc-400 text-[11px] pt-1 border-t border-zinc-850">
|
<span className="font-semibold text-krona-primary">
|
||||||
Влияние: <span className="text-amber-300">{selectedEvent.impactSummary}</span>
|
{selectedEvent.operatorName} ({RU_ROLES[selectedEvent.operatorRole]?.label || selectedEvent.operatorRole})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-krona-secondary">Обоснование действия:</div>
|
||||||
|
<div className="font-medium text-krona-primary p-2 bg-krona-surface rounded-md border border-krona-border">
|
||||||
|
{selectedEvent.reason}
|
||||||
|
</div>
|
||||||
|
<div className="text-krona-secondary text-[11px] pt-1 border-t border-krona-border">
|
||||||
|
Влияние: <span className="text-amber-500 font-medium">{selectedEvent.impactSummary}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-1 font-mono text-[11px]">
|
<div className="p-3.5 rounded-lg bg-krona-workspace border border-krona-border space-y-1.5 font-mono text-[11px]">
|
||||||
<div className="text-zinc-400">Previous Hash (N-1):</div>
|
<div className="text-krona-secondary">Предыдущий хеш (Previous Hash N-1):</div>
|
||||||
<div className="text-zinc-500 break-all">{selectedEvent.previousHash}</div>
|
<div className="text-krona-secondary/80 break-all text-[10px]">{selectedEvent.previousHash}</div>
|
||||||
<div className="text-zinc-400 pt-1">Current Hash (SHA-256):</div>
|
<div className="text-krona-secondary pt-1">Текущий хеш (SHA-256):</div>
|
||||||
<div className="text-emerald-400 break-all font-bold">{selectedEvent.hash}</div>
|
<div className="text-emerald-600 dark:text-emerald-400 break-all font-bold text-[10px]">{selectedEvent.hash}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -190,3 +283,4 @@ export const AuditPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue