rmm2/frontend/src/pages/Customers.jsx
cynfo3000 4023a14fe1 feat: Agent zeigt Warnung wenn er nicht den Kunden-Key nutzt
- last_api_key Spalte in agents, wird bei Heartbeat gesetzt
- GetAgentsByCustomer liefert last_api_key mit
- Frontend: rot + Warnsymbol wenn Key abweicht vom Kunden-Agent-Key
- Grau wenn noch kein Heartbeat mit Key-Info
2026-03-10 00:23:59 +01:00

302 lines
13 KiB
JavaScript

import { useEffect, useState } from 'react'
import api from '../api/client'
import { copyToClipboard } from '../utils/clipboard'
import { Plus, Trash2, Edit2, X, Check, ChevronDown, ChevronRight, Copy, Key, Monitor, AlertTriangle } from 'lucide-react'
const PERM_LABELS = {
agent: { label: 'Agent', color: 'text-blue-400 bg-blue-900/30 border-blue-700/50' },
read: { label: 'Read-only', color: 'text-green-400 bg-green-900/30 border-green-700/50' },
write: { label: 'Write', color: 'text-yellow-400 bg-yellow-900/30 border-yellow-700/50' },
admin: { label: 'Admin', color: 'text-orange-400 bg-orange-900/30 border-orange-700/50' },
}
export default function Customers() {
const [customers, setCustomers] = useState([])
const [loading, setLoading] = useState(true)
const [showAdd, setShowAdd] = useState(false)
const [editId, setEditId] = useState(null)
const [form, setForm] = useState({ number: '', name: '' })
const [expandedId, setExpandedId] = useState(null)
const reload = () => {
api.getCustomers().then((c) => setCustomers(c || [])).finally(() => setLoading(false))
}
useEffect(() => { reload() }, [])
const handleAdd = async () => {
if (!form.number || !form.name) return
await api.createCustomer(form.number, form.name)
setForm({ number: '', name: '' })
setShowAdd(false)
reload()
}
const handleUpdate = async (id) => {
await api.updateCustomer(id, form.number, form.name)
setEditId(null)
reload()
}
const handleDelete = async (id) => {
if (!confirm('Kunde wirklich loeschen?')) return
await api.deleteCustomer(id)
reload()
}
const toggleExpand = (id) => {
setExpandedId(prev => prev === id ? null : id)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white">Kunden</h1>
<button
onClick={() => { setShowAdd(true); setForm({ number: '', name: '' }) }}
className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white transition-colors"
>
<Plus className="w-4 h-4" />
Neuer Kunde
</button>
</div>
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800 text-left text-gray-500">
<th className="px-4 py-2 font-medium w-6"></th>
<th className="px-4 py-2 font-medium">Nummer</th>
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium hidden md:table-cell">Erstellt</th>
<th className="px-4 py-2 font-medium w-24"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{showAdd && (
<tr>
<td></td>
<td className="px-4 py-2">
<input
type="text"
placeholder="K05116"
value={form.number}
onChange={(e) => setForm({ ...form, number: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
autoFocus
/>
</td>
<td className="px-4 py-2">
<input
type="text"
placeholder="Kundenname"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
/>
</td>
<td className="hidden md:table-cell"></td>
<td className="px-4 py-2">
<div className="flex gap-1">
<button onClick={handleAdd} className="text-green-400 hover:text-green-300">
<Check className="w-4 h-4" />
</button>
<button onClick={() => setShowAdd(false)} className="text-gray-500 hover:text-gray-300">
<X className="w-4 h-4" />
</button>
</div>
</td>
</tr>
)}
{customers.map((c) => (
<>
{editId === c.id ? (
<tr key={`edit-${c.id}`} className="bg-gray-800/30">
<td></td>
<td className="px-4 py-2">
<input
type="text"
value={form.number}
onChange={(e) => setForm({ ...form, number: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
/>
</td>
<td className="px-4 py-2">
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
/>
</td>
<td className="hidden md:table-cell"></td>
<td className="px-4 py-2">
<div className="flex gap-1">
<button onClick={() => handleUpdate(c.id)} className="text-green-400 hover:text-green-300">
<Check className="w-4 h-4" />
</button>
<button onClick={() => setEditId(null)} className="text-gray-500 hover:text-gray-300">
<X className="w-4 h-4" />
</button>
</div>
</td>
</tr>
) : (
<tr
key={`row-${c.id}`}
className={`hover:bg-gray-800/50 cursor-pointer transition-colors ${expandedId === c.id ? 'bg-gray-800/30' : ''}`}
onClick={() => toggleExpand(c.id)}
>
<td className="px-3 py-2.5 text-gray-600">
{expandedId === c.id
? <ChevronDown className="w-3.5 h-3.5" />
: <ChevronRight className="w-3.5 h-3.5" />
}
</td>
<td className="px-4 py-2.5 text-orange-400 font-mono">{c.number}</td>
<td className="px-4 py-2.5 text-white">{c.name}</td>
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">
{new Date(c.created_at).toLocaleDateString('de-DE')}
</td>
<td className="px-4 py-2.5" onClick={(e) => e.stopPropagation()}>
<div className="flex gap-1">
<button
onClick={() => { setEditId(c.id); setForm({ number: c.number, name: c.name }) }}
className="text-gray-500 hover:text-gray-300 p-1 rounded hover:bg-gray-700"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(c.id)}
className="text-gray-500 hover:text-red-400 p-1 rounded hover:bg-gray-700"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
)}
{expandedId === c.id && (
<tr key={`expand-${c.id}`}>
<td colSpan={5} className="px-0 py-0">
<CustomerKeyPanel customerId={c.id} />
</td>
</tr>
)}
</>
))}
</tbody>
</table>
{!loading && customers.length === 0 && !showAdd && (
<div className="text-center py-8 text-gray-500">Keine Kunden angelegt</div>
)}
</div>
</div>
)
}
function CustomerKeyPanel({ customerId }) {
const [keys, setKeys] = useState(null)
const [agents, setAgents] = useState(null)
const [copied, setCopied] = useState(null)
useEffect(() => {
api.getCustomerAPIKeys(customerId).then(setKeys)
api.getCustomerAgents(customerId).then(setAgents)
}, [customerId])
const copyKey = (key, id) => {
copyToClipboard(key)
setCopied(id)
setTimeout(() => setCopied(null), 2000)
}
const loading = keys === null || agents === null
if (loading) {
return <div className="px-8 py-3 text-gray-500 text-xs bg-gray-800/20 border-t border-gray-800">Laden...</div>
}
// Agent-Key fuer diesen Kunden ermitteln (zum Abgleich welcher Agent ihn nutzt)
const agentKey = keys.find(k => k.permissions === 'agent')
return (
<div className="bg-gray-800/20 border-t border-gray-800 px-8 py-3 grid grid-cols-1 md:grid-cols-2 gap-x-10 gap-y-3">
{/* Agents */}
<div className="space-y-1.5">
<div className="flex items-center gap-2 mb-2">
<Monitor className="w-3.5 h-3.5 text-gray-500" />
<span className="text-xs text-gray-500 font-medium">Agents ({agents.length})</span>
</div>
{agents.length === 0 ? (
<div className="text-xs text-gray-600">Keine Agents zugeordnet</div>
) : (
agents.map((a) => {
const wrongKey = agentKey && a.last_api_key && a.last_api_key !== agentKey.key
const unknownKey = agentKey && !a.last_api_key
return (
<div key={a.id} className={`flex items-center gap-2.5 rounded px-1.5 py-0.5 -mx-1.5 ${wrongKey ? 'bg-red-900/20' : ''}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
a.status === 'online' ? 'bg-green-400' :
a.status === 'stale' ? 'bg-yellow-400' : 'bg-gray-600'
}`} />
<span className={`text-xs flex-1 min-w-0 truncate ${wrongKey ? 'text-red-300' : 'text-gray-300'}`}>
{a.name}
</span>
<span className="text-[10px] text-gray-600 font-mono flex-shrink-0">{a.agent_version || '—'}</span>
{wrongKey && (
<span title={`Falscher Key: ${a.last_api_key?.substring(0,8)}...`}>
<AlertTriangle className="w-3 h-3 text-red-400 flex-shrink-0" />
</span>
)}
{unknownKey && (
<span title="Noch kein Heartbeat mit Key-Info">
<AlertTriangle className="w-3 h-3 text-gray-600 flex-shrink-0" />
</span>
)}
</div>
)
})
)}
</div>
{/* API-Keys */}
<div className="space-y-1.5">
<div className="flex items-center gap-2 mb-2">
<Key className="w-3.5 h-3.5 text-gray-500" />
<span className="text-xs text-gray-500 font-medium">API-Keys</span>
</div>
{keys.length === 0 ? (
<div className="text-xs text-gray-600">Keine Keys</div>
) : (
keys.map((k) => {
const perm = PERM_LABELS[k.permissions] || PERM_LABELS.read
return (
<div key={k.id} className="flex items-center gap-2.5">
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded border leading-none w-16 text-center flex-shrink-0 ${perm.color}`}>
{perm.label}
</span>
<code className="text-xs text-gray-500 font-mono flex-1 min-w-0 truncate">
{k.key.substring(0, 8)}...{k.key.substring(k.key.length - 4)}
</code>
<button
onClick={(e) => { e.stopPropagation(); copyKey(k.key, k.id) }}
className="text-gray-600 hover:text-orange-400 transition-colors flex-shrink-0"
title="Key kopieren"
>
{copied === k.id
? <Check className="w-3.5 h-3.5 text-green-400" />
: <Copy className="w-3.5 h-3.5" />
}
</button>
</div>
)
})
)}
</div>
</div>
)
}