299375e486
DSMS Stufe 3 — making the parent_cid chain useful end-to-end.
Gateway (dsms-gateway):
- /api/v1/documents/{cid}/history alias added next to the legacy
/documents/{cid}/history (history endpoint itself was already there,
just under an inconsistent prefix).
- NEW /api/v1/documents/{cid_a}/diff/{cid_b}: fetches both packages from
IPFS, computes a metadata diff (per-field old/new), and renders a
unified text diff for utf-8 payloads. Binary payloads return only
metadata diff with a "binary — compare via rendered export" note.
- 4 new pytest cases (mocking ipfs_cat): text diff, binary fallback,
fetch error, history chain depth — all green.
Frontend (admin-compliance):
- CIDHistoryModal: lazy-loads /dsms/documents/:cid/history, renders the
version chain as a vertical timeline, marks the AKTUELL entry, and
per-step exposes a "Diff zu V<n>" button that loads + renders the diff
inline (metadata table + unified text diff in a monospace panel).
- AuditTimelinePage: existing CID badge now sits next to a "Verlauf
anzeigen" link that opens the modal. Handles both Python's plain-CID
audit values and the Go techfile flow's JSON envelope {cid, filename,
size} via extractCID() helper.
This makes "show me how this CE-Akte changed between V2 and V3"
self-service in the UI instead of a curl-against-IPFS workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
148 lines
6.4 KiB
TypeScript
148 lines
6.4 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { useAuditTimeline, type AuditEntry } from './_hooks/useAuditTimeline'
|
|
import CIDHistoryModal from './_components/CIDHistoryModal'
|
|
|
|
const ENTITY_LABELS: Record<string, string> = {
|
|
evidence: 'Nachweis', control: 'Control', document: 'Dokument',
|
|
dsfa: 'DSFA', vvt: 'VVT', tom: 'TOM', policy: 'Richtlinie',
|
|
dsms_archive: 'DSMS-Archiv', risk: 'Risiko',
|
|
}
|
|
|
|
const ACTION_COLORS: Record<string, string> = {
|
|
create: 'bg-green-500', update: 'bg-blue-500', delete: 'bg-red-500',
|
|
approve: 'bg-purple-500', archive: 'bg-emerald-500', review: 'bg-yellow-500',
|
|
sign: 'bg-indigo-500', reject: 'bg-red-400',
|
|
}
|
|
|
|
const FILTER_OPTIONS = ['all', 'evidence', 'dsms_archive', 'control', 'document', 'dsfa', 'vvt', 'tom']
|
|
|
|
// new_value may be a plain CID (from Python evidence flow) or a JSON envelope
|
|
// {"cid":"X","filename":"...","size":"..."} (from the Go IACE tech-file flow).
|
|
function extractCID(value: string): string {
|
|
const trimmed = value.trim()
|
|
if (trimmed.startsWith('{')) {
|
|
try {
|
|
const parsed = JSON.parse(trimmed)
|
|
if (typeof parsed.cid === 'string') return parsed.cid
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
export default function AuditTimelinePage() {
|
|
const { entries, loading, filter, setFilter } = useAuditTimeline()
|
|
const [historyCID, setHistoryCID] = useState<string | null>(null)
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Audit Timeline</h1>
|
|
<p className="text-sm text-gray-500 mt-1">Chronologische Compliance-Historie mit DSMS-Nachweisen</p>
|
|
</div>
|
|
|
|
{/* Filter */}
|
|
<div className="flex gap-2 flex-wrap">
|
|
{FILTER_OPTIONS.map((f) => (
|
|
<button
|
|
key={f}
|
|
onClick={() => setFilter(f)}
|
|
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
|
filter === f
|
|
? 'bg-purple-600 text-white'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'
|
|
}`}
|
|
>
|
|
{f === 'all' ? 'Alle' : ENTITY_LABELS[f] || f}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-32">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
|
|
</div>
|
|
) : entries.length === 0 ? (
|
|
<div className="text-center py-16 text-gray-500">
|
|
Keine Eintraege gefunden. Compliance-Aktionen werden automatisch protokolliert.
|
|
</div>
|
|
) : (
|
|
<div className="relative">
|
|
{/* Timeline line */}
|
|
<div className="absolute left-6 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
|
|
|
|
<div className="space-y-4">
|
|
{entries.map((entry) => (
|
|
<TimelineEntry key={entry.id} entry={entry} onShowHistory={setHistoryCID} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{historyCID && <CIDHistoryModal cid={historyCID} onClose={() => setHistoryCID(null)} />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TimelineEntry({ entry, onShowHistory }: { entry: AuditEntry; onShowHistory: (cid: string) => void }) {
|
|
const dotColor = ACTION_COLORS[entry.action] || 'bg-gray-400'
|
|
const isCID = entry.field_changed === 'dsms_cid' || entry.action === 'archive'
|
|
const date = new Date(entry.performed_at)
|
|
|
|
return (
|
|
<div className="relative flex gap-4 pl-3">
|
|
{/* Dot */}
|
|
<div className={`relative z-10 w-3 h-3 rounded-full mt-1.5 flex-shrink-0 ring-4 ring-white dark:ring-gray-900 ${dotColor}`} />
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 min-w-0">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-sm font-medium text-gray-900 dark:text-white">{entry.entity_name}</span>
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
|
{ENTITY_LABELS[entry.entity_type] || entry.entity_type}
|
|
</span>
|
|
<span className={`px-2 py-0.5 rounded text-[10px] font-medium text-white ${dotColor}`}>
|
|
{entry.action}
|
|
</span>
|
|
</div>
|
|
{entry.change_summary && (
|
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{entry.change_summary}</p>
|
|
)}
|
|
{isCID && entry.new_value && (
|
|
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
|
<svg className="w-3.5 h-3.5 text-emerald-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
</svg>
|
|
<code className="text-[10px] bg-emerald-50 text-emerald-700 px-2 py-0.5 rounded font-mono dark:bg-emerald-900/30 dark:text-emerald-300">
|
|
{entry.new_value.length > 20 ? entry.new_value.slice(0, 8) + '...' + entry.new_value.slice(-6) : entry.new_value}
|
|
</code>
|
|
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
if (entry.new_value) onShowHistory(extractCID(entry.new_value))
|
|
}}
|
|
className="text-[10px] text-purple-600 hover:text-purple-800 dark:text-purple-400 underline-offset-2 hover:underline"
|
|
title="DSMS-Versionsverlauf und Diff zur Vorversion anzeigen"
|
|
>
|
|
Verlauf anzeigen
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="text-right flex-shrink-0">
|
|
<div className="text-xs text-gray-400">{date.toLocaleDateString('de-DE')}</div>
|
|
<div className="text-[10px] text-gray-300">{date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</div>
|
|
<div className="text-[10px] text-gray-300 mt-0.5">{entry.performed_by}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|