765 lines
25 KiB
TypeScript
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 > 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>
|
|
)
|
|
}
|