|
|
|
@@ -0,0 +1,185 @@
|
|
|
|
|
import { useState, useEffect, useMemo } from 'react'
|
|
|
|
|
import { ClipboardList, Search, ChevronDown } from 'lucide-react'
|
|
|
|
|
import api from '../api/client'
|
|
|
|
|
|
|
|
|
|
const ACTION_COLORS = {
|
|
|
|
|
login: 'bg-blue-500/20 text-blue-400',
|
|
|
|
|
'tunnel.open': 'bg-green-500/20 text-green-400',
|
|
|
|
|
'tunnel.close': 'bg-green-500/20 text-green-400',
|
|
|
|
|
'backup.create': 'bg-yellow-500/20 text-yellow-400',
|
|
|
|
|
'backup.download': 'bg-yellow-500/20 text-yellow-400',
|
|
|
|
|
'update.check': 'bg-orange-500/20 text-orange-400',
|
|
|
|
|
'update.run': 'bg-orange-500/20 text-orange-400',
|
|
|
|
|
'update.major': 'bg-orange-500/20 text-orange-400',
|
|
|
|
|
reboot: 'bg-orange-500/20 text-orange-400',
|
|
|
|
|
'agent.delete': 'bg-red-500/20 text-red-400',
|
|
|
|
|
'agent.create': 'bg-emerald-500/20 text-emerald-400',
|
|
|
|
|
'wireguard.add': 'bg-purple-500/20 text-purple-400',
|
|
|
|
|
'wireguard.delete': 'bg-red-500/20 text-red-400',
|
|
|
|
|
'firmware.upload': 'bg-cyan-500/20 text-cyan-400',
|
|
|
|
|
'firmware.update_request': 'bg-orange-500/20 text-orange-400',
|
|
|
|
|
'firmware.update_all': 'bg-orange-500/20 text-orange-400',
|
|
|
|
|
exec: 'bg-gray-500/20 text-gray-400',
|
|
|
|
|
'customer.create': 'bg-emerald-500/20 text-emerald-400',
|
|
|
|
|
'customer.delete': 'bg-red-500/20 text-red-400',
|
|
|
|
|
'user.create': 'bg-emerald-500/20 text-emerald-400',
|
|
|
|
|
'user.delete': 'bg-red-500/20 text-red-400',
|
|
|
|
|
'password.change': 'bg-amber-500/20 text-amber-400',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ALL_ACTIONS = Object.keys(ACTION_COLORS)
|
|
|
|
|
|
|
|
|
|
function timeAgo(dateStr) {
|
|
|
|
|
const now = new Date()
|
|
|
|
|
const date = new Date(dateStr)
|
|
|
|
|
const seconds = Math.floor((now - date) / 1000)
|
|
|
|
|
if (seconds < 60) return 'gerade eben'
|
|
|
|
|
const minutes = Math.floor(seconds / 60)
|
|
|
|
|
if (minutes < 60) return `vor ${minutes} Min`
|
|
|
|
|
const hours = Math.floor(minutes / 60)
|
|
|
|
|
if (hours < 24) return `vor ${hours} Std`
|
|
|
|
|
const days = Math.floor(hours / 24)
|
|
|
|
|
return `vor ${days} Tag${days > 1 ? 'en' : ''}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function AuditLog() {
|
|
|
|
|
const [entries, setEntries] = useState([])
|
|
|
|
|
const [total, setTotal] = useState(0)
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
const [search, setSearch] = useState('')
|
|
|
|
|
const [actionFilter, setActionFilter] = useState('')
|
|
|
|
|
const [offset, setOffset] = useState(0)
|
|
|
|
|
const LIMIT = 100
|
|
|
|
|
|
|
|
|
|
const load = async (reset = false) => {
|
|
|
|
|
try {
|
|
|
|
|
setLoading(true)
|
|
|
|
|
const newOffset = reset ? 0 : offset
|
|
|
|
|
const res = await api.getAuditLogs({ limit: LIMIT, offset: newOffset, action: actionFilter })
|
|
|
|
|
if (reset) {
|
|
|
|
|
setEntries(res.data)
|
|
|
|
|
setOffset(LIMIT)
|
|
|
|
|
} else {
|
|
|
|
|
setEntries(prev => [...prev, ...res.data])
|
|
|
|
|
setOffset(prev => prev + LIMIT)
|
|
|
|
|
}
|
|
|
|
|
setTotal(res.total)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Audit-Log laden fehlgeschlagen:', e)
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
load(true)
|
|
|
|
|
}, [actionFilter])
|
|
|
|
|
|
|
|
|
|
const filtered = useMemo(() => {
|
|
|
|
|
if (!search) return entries
|
|
|
|
|
const q = search.toLowerCase()
|
|
|
|
|
return entries.filter(e =>
|
|
|
|
|
e.user_name.toLowerCase().includes(q) ||
|
|
|
|
|
e.action.toLowerCase().includes(q) ||
|
|
|
|
|
(e.target_name || '').toLowerCase().includes(q) ||
|
|
|
|
|
(e.target_id || '').toLowerCase().includes(q) ||
|
|
|
|
|
(e.details || '').toLowerCase().includes(q) ||
|
|
|
|
|
(e.source_ip || '').toLowerCase().includes(q)
|
|
|
|
|
)
|
|
|
|
|
}, [entries, search])
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center gap-3 mb-4">
|
|
|
|
|
<ClipboardList className="w-5 h-5 text-orange-500" />
|
|
|
|
|
<h1 className="text-lg font-bold text-white">Audit-Log</h1>
|
|
|
|
|
<span className="text-xs text-gray-500">{total} Einträge</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Filter */}
|
|
|
|
|
<div className="flex flex-col sm:flex-row gap-2 mb-4">
|
|
|
|
|
<div className="relative flex-1">
|
|
|
|
|
<Search className="absolute left-2.5 top-2 w-4 h-4 text-gray-500" />
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
placeholder="Suchen..."
|
|
|
|
|
value={search}
|
|
|
|
|
onChange={e => setSearch(e.target.value)}
|
|
|
|
|
className="w-full pl-8 pr-3 py-1.5 bg-gray-800 border border-gray-700 rounded text-xs text-gray-200 placeholder-gray-500 focus:outline-none focus:border-orange-500"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<select
|
|
|
|
|
value={actionFilter}
|
|
|
|
|
onChange={e => setActionFilter(e.target.value)}
|
|
|
|
|
className="appearance-none pl-3 pr-8 py-1.5 bg-gray-800 border border-gray-700 rounded text-xs text-gray-200 focus:outline-none focus:border-orange-500"
|
|
|
|
|
>
|
|
|
|
|
<option value="">Alle Aktionen</option>
|
|
|
|
|
{ALL_ACTIONS.map(a => (
|
|
|
|
|
<option key={a} value={a}>{a}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
<ChevronDown className="absolute right-2 top-2 w-3 h-3 text-gray-500 pointer-events-none" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Table */}
|
|
|
|
|
<div className="overflow-x-auto rounded border border-gray-800">
|
|
|
|
|
<table className="w-full text-xs">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr className="bg-gray-900 text-gray-400 border-b border-gray-800">
|
|
|
|
|
<th className="px-3 py-2 text-left font-medium">Zeitpunkt</th>
|
|
|
|
|
<th className="px-3 py-2 text-left font-medium">Benutzer</th>
|
|
|
|
|
<th className="px-3 py-2 text-left font-medium">Aktion</th>
|
|
|
|
|
<th className="px-3 py-2 text-left font-medium">Ziel</th>
|
|
|
|
|
<th className="px-3 py-2 text-left font-medium">Details</th>
|
|
|
|
|
<th className="px-3 py-2 text-left font-medium">IP</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="divide-y divide-gray-800/50">
|
|
|
|
|
{filtered.map(e => (
|
|
|
|
|
<tr key={e.id} className="hover:bg-gray-800/30">
|
|
|
|
|
<td className="px-3 py-1.5 text-gray-400 whitespace-nowrap" title={new Date(e.timestamp).toLocaleString('de-DE')}>
|
|
|
|
|
{timeAgo(e.timestamp)}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-3 py-1.5 text-gray-300">{e.user_name}</td>
|
|
|
|
|
<td className="px-3 py-1.5">
|
|
|
|
|
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium ${ACTION_COLORS[e.action] || 'bg-gray-500/20 text-gray-400'}`}>
|
|
|
|
|
{e.action}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-3 py-1.5 text-gray-400 max-w-[200px] truncate" title={`${e.target_type || ''} ${e.target_name || e.target_id || ''}`}>
|
|
|
|
|
{e.target_name || e.target_id || '-'}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-3 py-1.5 text-gray-500 max-w-[250px] truncate" title={e.details}>
|
|
|
|
|
{e.details || '-'}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-3 py-1.5 text-gray-500 whitespace-nowrap">{e.source_ip || '-'}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
{filtered.length === 0 && !loading && (
|
|
|
|
|
<tr>
|
|
|
|
|
<td colSpan={6} className="px-3 py-6 text-center text-gray-500">
|
|
|
|
|
Keine Einträge
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Load more */}
|
|
|
|
|
{entries.length < total && (
|
|
|
|
|
<div className="mt-3 text-center">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => load(false)}
|
|
|
|
|
disabled={loading}
|
|
|
|
|
className="px-4 py-1.5 bg-gray-800 hover:bg-gray-700 text-gray-300 text-xs rounded border border-gray-700 disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
{loading ? 'Laden...' : `Mehr laden (${entries.length}/${total})`}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|