teg/frontend/src/pages/administration/channels/ChannelsAdministration.tsx
2026-07-03 16:56:20 +02:00

765 lines
25 KiB
TypeScript

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<AdminChannel[]>([])
const [users, setUsers] = useState<AdminChannelUser[]>([])
const [draft, setDraft] = useState<ChannelDraft>(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<HTMLInputElement | null>(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 extends keyof ChannelDraft>(
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<HTMLInputElement>) {
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<HTMLFormElement>) {
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 (
<div className="rounded-xl bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<LoadingSpinner label="Channels werden geladen..." center />
</div>
)
}
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 (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[19rem_minmax(0,1fr)]">
<aside className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<div className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-white/10">
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
Channels
</h2>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Schreibgeschützte Integrationen
</p>
</div>
<Button
type="button"
size="sm"
color="indigo"
leadingIcon={<PlusIcon className="size-4" />}
onClick={createChannel}
>
Neu
</Button>
</div>
<div className="space-y-1 p-2">
{channels.map((channel) => (
<button
key={channel.id}
type="button"
onClick={() => selectChannel(channel)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm ${
draft.id === channel.id
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5'
}`}
>
{channel.image ? (
<Avatar
src={channel.image}
name={channel.name}
size={9}
shape="circle"
/>
) : (
<HashtagIcon className="size-5 shrink-0" />
)}
<span className="min-w-0">
<span className="block truncate font-medium">{channel.name}</span>
<span className="block truncate text-xs opacity-70">
/{channel.slug}
</span>
</span>
</button>
))}
</div>
</aside>
<div className="space-y-6">
<form
onSubmit={saveChannel}
className="rounded-xl bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
>
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
{draft.id ? 'Channel bearbeiten' : 'Channel anlegen'}
</h2>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<label className="text-sm font-medium text-gray-900 dark:text-white">
Channelname
<input
required
value={draft.name}
onChange={(event) => updateDraft('name', event.target.value)}
placeholder="Zabbix"
className={`${inputClassName} mt-2`}
/>
</label>
<label className="text-sm font-medium text-gray-900 dark:text-white">
Webhook-Slug
<input
required
value={draft.slug}
onChange={(event) => updateDraft('slug', event.target.value)}
placeholder="zabbix"
pattern="[a-z0-9]+(?:-[a-z0-9]+)*"
className={`${inputClassName} mt-2`}
/>
</label>
<label className="text-sm font-medium text-gray-900 dark:text-white sm:col-span-2">
Angezeigter Absender
<input
value={draft.sourceName}
onChange={(event) => updateDraft('sourceName', event.target.value)}
placeholder={draft.name || 'Zabbix'}
className={`${inputClassName} mt-2`}
/>
</label>
<div className="sm:col-span-2">
<p className="text-sm font-medium text-gray-900 dark:text-white">
Channel-Bild
</p>
<div className="mt-2 flex items-center gap-4">
<Avatar
src={draft.image}
name={draft.name || 'Channel'}
size={14}
shape="circle"
/>
<div>
<input
ref={imageInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="sr-only"
onChange={handleImageFileChange}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
onClick={() => imageInputRef.current?.click()}
>
Bild hochladen
</Button>
{draft.image && (
<Button
type="button"
variant="secondary"
color="red"
size="sm"
onClick={() => updateDraft('image', '')}
>
Entfernen
</Button>
)}
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Optional: PNG, JPG, WEBP oder GIF bis maximal 2 MB.
</p>
</div>
</div>
</div>
</div>
<div className="mt-5 space-y-4 border-t border-gray-200 pt-5 dark:border-white/10">
<Switch
checked={draft.webhookEnabled}
onChange={(event) =>
updateDraft('webhookEnabled', event.target.checked)
}
label="Webhook aktiviert"
description="Nur aktivierte Channels nehmen externe Nachrichten an."
/>
<Switch
checked={draft.allUsers}
onChange={(event) => updateDraft('allUsers', event.target.checked)}
label="Für alle Benutzer"
description="Neue Benutzer werden automatisch Mitglied dieses Channels."
/>
</div>
{!draft.allUsers && (
<div className="mt-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
Channelmitglieder
</h3>
<span className="text-xs text-gray-500 dark:text-gray-400">
{selectedMemberCount} von {users.length} ausgewählt
</span>
</div>
<div className="relative mt-3">
<MagnifyingGlassIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400" />
<input
type="search"
value={memberSearch}
onChange={(event) => 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"
/>
</div>
<div className="mt-3 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<div className="flex items-center justify-between gap-3 border-b border-gray-200 bg-gray-50 px-3 py-2 dark:border-white/10 dark:bg-white/5">
<Checkbox
checked={filteredAllSelected}
indeterminate={filteredSomeSelected}
disabled={filteredUsers.length === 0}
onChange={(event) => setAllMembers(event.target.checked)}
label={
memberSearch.trim()
? 'Alle Treffer auswählen'
: 'Alle auswählen'
}
labelClassName="font-medium"
/>
{selectedMemberCount > 0 && (
<button
type="button"
onClick={() => updateDraft('userIds', [])}
className="shrink-0 text-xs font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
>
Auswahl zurücksetzen
</button>
)}
</div>
<div className="max-h-72 space-y-0.5 overflow-y-auto p-2">
{filteredUsers.length === 0 ? (
<p className="px-2 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
Keine Benutzer gefunden.
</p>
) : (
filteredUsers.map((user) => {
const checked = draft.userIds.includes(user.id)
return (
<Checkbox
key={user.id}
checked={checked}
onChange={(event) =>
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={
<span className="flex min-w-0 items-center gap-3">
<Avatar
src={user.avatar}
name={userDisplayName(user)}
size={8}
className="shrink-0"
/>
<span className="min-w-0">
<span className="block truncate font-medium text-gray-900 dark:text-white">
{userDisplayName(user)}
</span>
<span className="block truncate text-xs text-gray-500 dark:text-gray-400">
{user.unit || user.email}
</span>
</span>
</span>
}
/>
)
})
)}
</div>
</div>
</div>
)}
<div className="mt-6 flex justify-end">
<Button type="submit" color="indigo" isLoading={isSaving}>
Channel speichern
</Button>
</div>
</form>
{draft.id && (
<section className="rounded-xl bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="flex items-center gap-2 text-base font-semibold text-gray-900 dark:text-white">
<KeyIcon className="size-5 text-indigo-500" />
Webhook für Zabbix 7.2.15
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Verwende den Medientyp Webhook. Das Bearer-Token wird nur nach
dem Erzeugen vollständig angezeigt.
</p>
</div>
<Button
type="button"
variant="secondary"
color="indigo"
isLoading={isRotatingToken}
onClick={() => void rotateToken()}
>
{draft.tokenConfigured ? 'Token erneuern' : 'Token erzeugen'}
</Button>
</div>
<div className="mt-5 space-y-4">
<ol className="list-decimal space-y-1.5 pl-5 text-sm text-gray-600 dark:text-gray-300">
<li>
Unter Alerts &gt; Media types einen Medientyp vom Typ
Webhook anlegen.
</li>
<li>
Die Parameter und das JavaScript unten übernehmen und den
Medientyp über Test prüfen.
</li>
<li>
Den Medientyp einem Zabbix-Benutzer als Medium zuweisen. Bei
Send to genügt beispielsweise teg-channel.
</li>
<li>
Diesen Benutzer in der gewünschten Action unter Send to
users als Empfänger auswählen.
</li>
</ol>
<CopyField
label="URL"
value={webhookUrl}
onCopy={() => void copyText(webhookUrl, 'URL')}
/>
{newToken && (
<CopyField
label="Token"
value={newToken}
onCopy={() => void copyText(newToken, 'Token')}
/>
)}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
Parameter im Zabbix-Medientyp
</h3>
<div className="mt-2 overflow-hidden rounded-lg border border-gray-200 text-sm dark:border-white/10">
{[
['URL', webhookUrl],
['Token', newToken || '<erzeugtes Token>'],
['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]) => (
<div
key={name}
className="grid grid-cols-[7rem_minmax(0,1fr)] border-b border-gray-200 last:border-b-0 dark:border-white/10"
>
<span className="bg-gray-50 px-3 py-2 font-medium text-gray-700 dark:bg-white/5 dark:text-gray-300">
{name}
</span>
<code className="break-all px-3 py-2 text-gray-600 dark:text-gray-300">
{value}
</code>
</div>
))}
</div>
</div>
</div>
</section>
)}
</div>
</div>
)
}
function CopyField({
label,
value,
onCopy,
}: {
label: string
value: string
onCopy: () => void
}) {
return (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white">
{label}
</label>
<div className="mt-2 flex gap-2">
<input readOnly value={value} className={inputClassName} />
<Button
type="button"
variant="secondary"
color="gray"
leadingIcon={<ClipboardDocumentIcon className="size-4" />}
onClick={onCopy}
>
Kopieren
</Button>
</div>
</div>
)
}