Files
breakpilot-compliance/admin-compliance/app/sdk/audit-timeline/_components/CIDHistoryModal.tsx
T
Benjamin Admin 299375e486 feat(dsms): version chain history + diff endpoint + Audit Timeline UI
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>
2026-05-22 10:10:07 +02:00

208 lines
8.8 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
interface HistoryEntry {
cid: string
version: string | null
document_type: string | null
document_id: string | null
parent_cid: string | null
created_at: string | null
checksum: string | null
}
interface DiffResponse {
kind: 'text' | 'binary'
cid_a: string
cid_b: string
metadata_diff: Record<string, { old: unknown; new: unknown }>
diff?: string
added_lines?: number
removed_lines?: number
note?: string
}
interface Props {
cid: string
onClose: () => void
}
function shorten(cid: string): string {
if (cid.length <= 14) return cid
return cid.slice(0, 8) + '…' + cid.slice(-6)
}
export default function CIDHistoryModal({ cid, onClose }: Props) {
const [history, setHistory] = useState<HistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [diffPair, setDiffPair] = useState<{ a: string; b: string } | null>(null)
const [diff, setDiff] = useState<DiffResponse | null>(null)
const [diffLoading, setDiffLoading] = useState(false)
useEffect(() => {
let cancel = false
setLoading(true)
setError(null)
fetch(`/api/sdk/v1/dsms/documents/${encodeURIComponent(cid)}/history`)
.then(async (r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const json = await r.json()
if (!cancel) setHistory(json.history || [])
})
.catch((e) => {
if (!cancel) setError(e?.message || 'Fehler beim Laden')
})
.finally(() => {
if (!cancel) setLoading(false)
})
return () => {
cancel = true
}
}, [cid])
async function loadDiff(a: string, b: string) {
setDiffPair({ a, b })
setDiff(null)
setDiffLoading(true)
try {
const res = await fetch(
`/api/sdk/v1/dsms/documents/${encodeURIComponent(a)}/diff/${encodeURIComponent(b)}`
)
if (res.ok) {
const json = (await res.json()) as DiffResponse
setDiff(json)
} else {
setDiff({ kind: 'binary', cid_a: a, cid_b: b, metadata_diff: {}, note: `HTTP ${res.status}` })
}
} finally {
setDiffLoading(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<div
className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col bg-white dark:bg-gray-800 rounded-xl shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700">
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">DSMS-Versionsverlauf</h2>
<code className="text-[10px] font-mono text-gray-500 dark:text-gray-400">{shorten(cid)}</code>
</div>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 dark:text-gray-400 text-sm">
Schliessen
</button>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-4">
{loading && <div className="text-sm text-gray-500">Verlauf wird geladen</div>}
{error && <div className="text-sm text-red-600 dark:text-red-400">{error}</div>}
{!loading && !error && history.length === 0 && (
<div className="text-sm text-gray-500 italic">
Kein Versionsverlauf gefunden. Diese CID hat keine parent_cid-Kette.
</div>
)}
{!loading && !error && history.length > 0 && (
<>
<div className="text-xs text-gray-500 dark:text-gray-400">
{history.length} Version{history.length > 1 ? 'en' : ''} in der Kette (neueste oben).
</div>
<ol className="relative border-l-2 border-emerald-500/40 pl-4 space-y-3">
{history.map((entry, idx) => {
const next = history[idx + 1]
return (
<li key={entry.cid} className="relative">
<div className="absolute -left-[1.4rem] top-1.5 w-3 h-3 rounded-full bg-emerald-500 ring-2 ring-white dark:ring-gray-800" />
<div className="bg-gray-50 dark:bg-gray-900/40 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Version {entry.version || '?'} {idx === 0 && <span className="ml-2 text-[10px] text-emerald-600 font-semibold">AKTUELL</span>}
</div>
<code className="text-[10px] font-mono text-gray-500 dark:text-gray-400 break-all">{entry.cid}</code>
</div>
{next && (
<button
onClick={() => loadDiff(next.cid, entry.cid)}
className="shrink-0 text-[11px] text-purple-600 hover:text-purple-800 dark:text-purple-400 hover:underline"
title="Aenderungen zur Vorversion anzeigen"
>
Diff zu V{next.version || '?'}
</button>
)}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400 flex flex-wrap gap-x-3 gap-y-0.5">
{entry.document_type && <span>Typ: {entry.document_type}</span>}
{entry.document_id && <span>Dok-ID: {entry.document_id}</span>}
{entry.created_at && <span>{new Date(entry.created_at).toLocaleString('de-DE')}</span>}
</div>
{entry.checksum && (
<div className="mt-1 text-[10px] text-gray-400 font-mono">SHA-256: {entry.checksum.slice(0, 16)}</div>
)}
</div>
</li>
)
})}
</ol>
</>
)}
{diffPair && (
<div className="mt-4 border-t border-gray-200 dark:border-gray-700 pt-4 space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold text-gray-900 dark:text-white">
Diff: {shorten(diffPair.a)} {shorten(diffPair.b)}
</h3>
<button onClick={() => { setDiff(null); setDiffPair(null) }} className="text-[11px] text-gray-500 hover:text-gray-700">
Schliessen
</button>
</div>
{diffLoading && <div className="text-xs text-gray-500">Diff wird geladen</div>}
{!diffLoading && diff && (
<>
{Object.keys(diff.metadata_diff || {}).length > 0 && (
<div className="text-xs">
<div className="font-medium text-gray-700 dark:text-gray-300 mb-1">Metadaten-Aenderungen</div>
<table className="w-full">
<tbody>
{Object.entries(diff.metadata_diff).map(([field, { old, new: nv }]) => (
<tr key={field} className="border-b border-gray-100 dark:border-gray-800">
<td className="py-0.5 pr-2 font-mono text-[10px] text-gray-500">{field}</td>
<td className="py-0.5 pr-2 text-red-600 dark:text-red-400 line-through">{JSON.stringify(old)}</td>
<td className="py-0.5 text-green-700 dark:text-green-400">{JSON.stringify(nv)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{diff.kind === 'text' && diff.diff && (
<>
<div className="text-[11px] text-gray-500">
{diff.added_lines ?? 0} Zeilen hinzu, {diff.removed_lines ?? 0} entfernt
</div>
<pre className="text-[10px] font-mono whitespace-pre-wrap bg-gray-900 text-gray-100 p-3 rounded max-h-64 overflow-y-auto">
{diff.diff}
</pre>
</>
)}
{diff.kind === 'binary' && (
<div className="text-xs text-amber-700 dark:text-amber-400 italic">
{diff.note || 'Binaere Datei — kein Text-Diff verfuegbar.'}
</div>
)}
</>
)}
</div>
)}
</div>
</div>
</div>
)
}