import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, } from 'react' import { ClipboardDocumentIcon, HashtagIcon, KeyIcon, MagnifyingGlassIcon, PlusIcon, } from '@heroicons/react/20/solid' import Button from '../../../components/Button' import Avatar from '../../../components/Avatar' import Checkbox from '../../../components/Checkbox' import LoadingSpinner from '../../../components/LoadingSpinner' import Switch from '../../../components/Switch' import { useNotifications } from '../../../components/Notifications' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' type AdminChannel = { id: string name: string slug: string sourceName: string image: string allUsers: boolean userIds: string[] webhookEnabled: boolean tokenConfigured: boolean webhookTokenUpdatedAt?: string | null webhookPath: string } type AdminChannelUser = { id: string username: string displayName: string email: string unit: string avatar: string } type ChannelDraft = { id?: string name: string slug: string sourceName: string image: string allUsers: boolean userIds: string[] webhookEnabled: boolean tokenConfigured: boolean webhookTokenUpdatedAt?: string | null webhookPath: string } const inputClassName = 'block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500' const emptyDraft: ChannelDraft = { name: '', slug: '', sourceName: '', image: '', allUsers: true, userIds: [], webhookEnabled: true, tokenConfigured: false, webhookPath: '', } async function readApiError(response: Response, fallback: string) { const data = await response.json().catch(() => null) return data?.error ?? fallback } function userDisplayName(user: AdminChannelUser) { return user.displayName || user.username || user.email } function channelToDraft(channel: AdminChannel): ChannelDraft { return { ...channel, userIds: channel.userIds ?? [], } } export default function ChannelsAdministration() { const { showToast, showErrorToast } = useNotifications() const [channels, setChannels] = useState([]) const [users, setUsers] = useState([]) const [draft, setDraft] = useState(emptyDraft) const [zabbixScript, setZabbixScript] = useState('') const [newToken, setNewToken] = useState('') const [memberSearch, setMemberSearch] = useState('') const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) const [isRotatingToken, setIsRotatingToken] = useState(false) const imageInputRef = useRef(null) const webhookUrl = useMemo(() => { const path = draft.webhookPath || `/webhooks/channels/${draft.slug}` try { return new URL(path, API_URL).toString() } catch { return `${API_URL.replace(/\/$/, '')}${path}` } }, [draft.slug, draft.webhookPath]) const filteredUsers = useMemo(() => { const query = memberSearch.trim().toLowerCase() if (!query) { return users } return users.filter((user) => [userDisplayName(user), user.username, user.email, user.unit] .filter(Boolean) .join(' ') .toLowerCase() .includes(query), ) }, [memberSearch, users]) const loadChannels = useCallback(async (preferredId?: string) => { setIsLoading(true) try { const response = await fetch(`${API_URL}/admin/channels`, { credentials: 'include', }) if (!response.ok) { throw new Error( await readApiError(response, 'Channels konnten nicht geladen werden'), ) } const data = await response.json() const nextChannels = (data.channels ?? []) as AdminChannel[] setChannels(nextChannels) setUsers(data.users ?? []) setZabbixScript(data.zabbixScript ?? '') const selected = nextChannels.find((channel) => channel.id === preferredId) ?? nextChannels[0] setDraft(selected ? channelToDraft(selected) : emptyDraft) } catch (error) { showErrorToast({ title: 'Channels konnten nicht geladen werden', error, fallback: 'Channels konnten nicht geladen werden', }) } finally { setIsLoading(false) } }, [showErrorToast]) useEffect(() => { const timeoutId = window.setTimeout(() => { void loadChannels() }, 0) return () => window.clearTimeout(timeoutId) }, [loadChannels]) function selectChannel(channel: AdminChannel) { setDraft(channelToDraft(channel)) setNewToken('') setMemberSearch('') } function createChannel() { setDraft(emptyDraft) setNewToken('') setMemberSearch('') } function updateDraft( key: Key, value: ChannelDraft[Key], ) { setDraft((current) => ({ ...current, [key]: value })) } function toggleUser(userId: string, checked: boolean) { setDraft((current) => ({ ...current, userIds: checked ? [...new Set([...current.userIds, userId])] : current.userIds.filter((id) => id !== userId), })) } function setAllMembers(checked: boolean) { const filteredIds = filteredUsers.map((user) => user.id) setDraft((current) => { if (checked) { return { ...current, userIds: [...new Set([...current.userIds, ...filteredIds])], } } const filteredSet = new Set(filteredIds) return { ...current, userIds: current.userIds.filter((id) => !filteredSet.has(id)), } }) } function handleImageFileChange(event: ChangeEvent) { const file = event.target.files?.[0] event.target.value = '' if (!file) { return } if (!file.type.startsWith('image/')) { showToast({ title: 'Ungültige Bilddatei', message: 'Bitte wähle eine gültige Bilddatei aus.', variant: 'error', }) return } if (file.size > 2 * 1024 * 1024) { showToast({ title: 'Channel-Bild zu groß', message: 'Das Channel-Bild darf maximal 2 MB groß sein.', variant: 'error', }) return } const reader = new FileReader() reader.onload = () => { if (typeof reader.result === 'string') { updateDraft('image', reader.result) } } reader.readAsDataURL(file) } async function saveChannel(event: FormEvent) { event.preventDefault() setIsSaving(true) try { const response = await fetch( draft.id ? `${API_URL}/admin/channels/${draft.id}` : `${API_URL}/admin/channels`, { method: draft.id ? 'PUT' : 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: draft.name, slug: draft.slug, sourceName: draft.sourceName, image: draft.image, allUsers: draft.allUsers, userIds: draft.userIds, webhookEnabled: draft.webhookEnabled, }), }, ) if (!response.ok) { throw new Error( await readApiError(response, 'Channel konnte nicht gespeichert werden'), ) } const data = await response.json() await loadChannels(data.id ?? draft.id) showToast({ title: 'Channel gespeichert', message: 'Mitglieder und Webhook-Einstellungen wurden aktualisiert.', variant: 'success', }) } catch (error) { showErrorToast({ title: 'Channel konnte nicht gespeichert werden', error, fallback: 'Channel konnte nicht gespeichert werden', }) } finally { setIsSaving(false) } } async function rotateToken() { if (!draft.id) { return } setIsRotatingToken(true) try { const response = await fetch( `${API_URL}/admin/channels/${draft.id}/token`, { method: 'POST', credentials: 'include', }, ) if (!response.ok) { throw new Error( await readApiError(response, 'Webhook-Token konnte nicht erzeugt werden'), ) } const data = await response.json() setNewToken(data.token ?? '') setDraft((current) => ({ ...current, tokenConfigured: true })) showToast({ title: 'Webhook-Token erzeugt', message: 'Das Token wird nur jetzt vollständig angezeigt.', variant: 'success', autoClose: false, }) await loadChannels(draft.id) setNewToken(data.token ?? '') } catch (error) { showErrorToast({ title: 'Webhook-Token konnte nicht erzeugt werden', error, fallback: 'Webhook-Token konnte nicht erzeugt werden', }) } finally { setIsRotatingToken(false) } } async function copyText(value: string, label: string) { try { await navigator.clipboard.writeText(value) showToast({ title: `${label} kopiert`, message: 'Der Wert liegt jetzt in der Zwischenablage.', variant: 'success', }) } catch (error) { showErrorToast({ title: `${label} konnte nicht kopiert werden`, error, fallback: 'Die Zwischenablage ist nicht verfügbar.', }) } } if (isLoading) { return (
) } const selectedMemberCount = draft.userIds.length const filteredAllSelected = filteredUsers.length > 0 && filteredUsers.every((user) => draft.userIds.includes(user.id)) const filteredSomeSelected = !filteredAllSelected && filteredUsers.some((user) => draft.userIds.includes(user.id)) return (

{draft.id ? 'Channel bearbeiten' : 'Channel anlegen'}

Channel-Bild

{draft.image && ( )}

Optional: PNG, JPG, WEBP oder GIF bis maximal 2 MB.

updateDraft('webhookEnabled', event.target.checked) } label="Webhook aktiviert" description="Nur aktivierte Channels nehmen externe Nachrichten an." /> updateDraft('allUsers', event.target.checked)} label="Für alle Benutzer" description="Neue Benutzer werden automatisch Mitglied dieses Channels." />
{!draft.allUsers && (

Channelmitglieder

{selectedMemberCount} von {users.length} ausgewählt
setMemberSearch(event.target.value)} placeholder="Mitglieder suchen…" className="block w-full rounded-md bg-white py-1.5 pr-3 pl-9 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500" />
setAllMembers(event.target.checked)} label={ memberSearch.trim() ? 'Alle Treffer auswählen' : 'Alle auswählen' } labelClassName="font-medium" /> {selectedMemberCount > 0 && ( )}
{filteredUsers.length === 0 ? (

Keine Benutzer gefunden.

) : ( filteredUsers.map((user) => { const checked = draft.userIds.includes(user.id) return ( toggleUser(user.id, event.target.checked) } wrapperClassName={`items-center rounded-lg px-2 py-2 transition ${ checked ? 'bg-indigo-50 dark:bg-indigo-500/10' : 'hover:bg-gray-50 dark:hover:bg-white/5' }`} label={ {userDisplayName(user)} {user.unit || user.email} } /> ) }) )}
)}
{draft.id && (

Webhook für Zabbix 7.2.15

Verwende den Medientyp „Webhook“. Das Bearer-Token wird nur nach dem Erzeugen vollständig angezeigt.

  1. Unter „Alerts > Media types“ einen Medientyp vom Typ „Webhook“ anlegen.
  2. Die Parameter und das JavaScript unten übernehmen und den Medientyp über „Test“ prüfen.
  3. Den Medientyp einem Zabbix-Benutzer als Medium zuweisen. Bei „Send to“ genügt beispielsweise „teg-channel“.
  4. Diesen Benutzer in der gewünschten Action unter „Send to users“ als Empfänger auswählen.
void copyText(webhookUrl, 'URL')} /> {newToken && ( void copyText(newToken, 'Token')} /> )}

Parameter im Zabbix-Medientyp

{[ ['URL', webhookUrl], ['Token', newToken || ''], ['To', '{ALERT.SENDTO}'], ['Subject', '{ALERT.SUBJECT}'], ['Message', '{ALERT.MESSAGE}'], ['Host', '{HOST.NAME}'], ['Severity', '{EVENT.SEVERITY}'], ['Status', '{EVENT.STATUS}'], ['EventId', '{EVENT.ID}'], ['EventUrl', '{TRIGGER.URL}'], ].map(([name, value]) => (
{name} {value}
))}
)}
) } function CopyField({ label, value, onCopy, }: { label: string value: string onCopy: () => void }) { return (
) }