498 lines
19 KiB
TypeScript
498 lines
19 KiB
TypeScript
|
|
import { useState } from 'react'
|
||
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
|
|
import { Bell, Plus, Trash2, Check, AlertTriangle, Info, AlertCircle } from 'lucide-react'
|
||
|
|
import { alertsApi, alertRulesApi } from '../api/client'
|
||
|
|
import type { AlertSeverity, AlertRule, CreateAlertRuleRequest } from '../api/types'
|
||
|
|
import { cn } from '../lib/utils'
|
||
|
|
|
||
|
|
function getSeverityStyle(severity: AlertSeverity) {
|
||
|
|
return {
|
||
|
|
Critical: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||
|
|
Warning: 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30',
|
||
|
|
Info: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
|
||
|
|
}[severity]
|
||
|
|
}
|
||
|
|
|
||
|
|
function getSeverityIcon(severity: AlertSeverity) {
|
||
|
|
return {
|
||
|
|
Critical: <AlertCircle size={14} />,
|
||
|
|
Warning: <AlertTriangle size={14} />,
|
||
|
|
Info: <Info size={14} />,
|
||
|
|
}[severity]
|
||
|
|
}
|
||
|
|
|
||
|
|
const metricPathOptions = [
|
||
|
|
{ value: 'cpu_usage_percent', label: 'CPU-Auslastung (%)' },
|
||
|
|
{ value: 'memory_usage_percent', label: 'RAM-Auslastung (%)' },
|
||
|
|
]
|
||
|
|
|
||
|
|
const operatorOptions = [
|
||
|
|
{ value: '>', label: '>' },
|
||
|
|
{ value: '>=', label: '>=' },
|
||
|
|
{ value: '<', label: '<' },
|
||
|
|
{ value: '<=', label: '<=' },
|
||
|
|
{ value: '==', label: '==' },
|
||
|
|
]
|
||
|
|
|
||
|
|
const severityOptions = [
|
||
|
|
{ value: 'Info' as AlertSeverity, label: 'Info' },
|
||
|
|
{ value: 'Warning' as AlertSeverity, label: 'Warning' },
|
||
|
|
{ value: 'Critical' as AlertSeverity, label: 'Critical' },
|
||
|
|
]
|
||
|
|
|
||
|
|
interface CreateRuleForm {
|
||
|
|
name: string
|
||
|
|
metricPath: string
|
||
|
|
operator: string
|
||
|
|
threshold: string
|
||
|
|
severity: AlertSeverity
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function AlertsPage() {
|
||
|
|
const [activeTab, setActiveTab] = useState<'alerts' | 'rules'>('alerts')
|
||
|
|
const [alertFilter, setAlertFilter] = useState<'all' | 'unacknowledged' | 'acknowledged'>('unacknowledged')
|
||
|
|
const [showCreateRuleModal, setShowCreateRuleModal] = useState(false)
|
||
|
|
const [formData, setFormData] = useState<CreateRuleForm>({
|
||
|
|
name: '',
|
||
|
|
metricPath: 'cpu_usage_percent',
|
||
|
|
operator: '>',
|
||
|
|
threshold: '',
|
||
|
|
severity: 'Warning',
|
||
|
|
})
|
||
|
|
|
||
|
|
const queryClient = useQueryClient()
|
||
|
|
|
||
|
|
// Queries
|
||
|
|
const alertsQuery = useQuery({
|
||
|
|
queryKey: ['alerts'],
|
||
|
|
queryFn: () => alertsApi.list(),
|
||
|
|
})
|
||
|
|
|
||
|
|
const rulesQuery = useQuery({
|
||
|
|
queryKey: ['alert-rules'],
|
||
|
|
queryFn: () => alertRulesApi.list(),
|
||
|
|
})
|
||
|
|
|
||
|
|
// Mutations
|
||
|
|
const acknowledgeMutation = useMutation({
|
||
|
|
mutationFn: (id: number) => alertsApi.acknowledge(id),
|
||
|
|
onSuccess: () => {
|
||
|
|
queryClient.invalidateQueries({ queryKey: ['alerts'] })
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
const createRuleMutation = useMutation({
|
||
|
|
mutationFn: (data: CreateAlertRuleRequest) => alertRulesApi.create(data),
|
||
|
|
onSuccess: () => {
|
||
|
|
queryClient.invalidateQueries({ queryKey: ['alert-rules'] })
|
||
|
|
setShowCreateRuleModal(false)
|
||
|
|
setFormData({
|
||
|
|
name: '',
|
||
|
|
metricPath: 'cpu_usage_percent',
|
||
|
|
operator: '>',
|
||
|
|
threshold: '',
|
||
|
|
severity: 'Warning',
|
||
|
|
})
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
const updateRuleMutation = useMutation({
|
||
|
|
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
|
||
|
|
alertRulesApi.update(id, { enabled }),
|
||
|
|
onSuccess: () => {
|
||
|
|
queryClient.invalidateQueries({ queryKey: ['alert-rules'] })
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
const deleteRuleMutation = useMutation({
|
||
|
|
mutationFn: (id: number) => alertRulesApi.delete(id),
|
||
|
|
onSuccess: () => {
|
||
|
|
queryClient.invalidateQueries({ queryKey: ['alert-rules'] })
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
// Filter alerts
|
||
|
|
const filteredAlerts = alertsQuery.data?.filter((alert) => {
|
||
|
|
if (alertFilter === 'acknowledged') return alert.acknowledged
|
||
|
|
if (alertFilter === 'unacknowledged') return !alert.acknowledged
|
||
|
|
return true
|
||
|
|
}) || []
|
||
|
|
|
||
|
|
// Handlers
|
||
|
|
const handleCreateRule = async () => {
|
||
|
|
if (!formData.name.trim() || !formData.threshold) {
|
||
|
|
alert('Bitte alle erforderlichen Felder ausfüllen')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await createRuleMutation.mutateAsync({
|
||
|
|
name: formData.name,
|
||
|
|
metricPath: formData.metricPath,
|
||
|
|
operator: formData.operator,
|
||
|
|
threshold: parseFloat(formData.threshold),
|
||
|
|
severity: formData.severity,
|
||
|
|
})
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Fehler beim Erstellen der Regel:', err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleDeleteRule = (id: number) => {
|
||
|
|
if (window.confirm('Möchten Sie diese Regel wirklich löschen?')) {
|
||
|
|
deleteRuleMutation.mutate(id)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleToggleRuleEnabled = (rule: AlertRule) => {
|
||
|
|
updateRuleMutation.mutate({ id: rule.id, enabled: !rule.enabled })
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="p-6 max-w-7xl mx-auto">
|
||
|
|
{/* Header */}
|
||
|
|
<div className="flex items-center gap-3 mb-6">
|
||
|
|
<Bell size={24} className="text-primary" />
|
||
|
|
<h1 className="text-3xl font-bold text-foreground">Alerts</h1>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Tabs */}
|
||
|
|
<div className="flex gap-2 border-b border-border mb-6">
|
||
|
|
<button
|
||
|
|
onClick={() => setActiveTab('alerts')}
|
||
|
|
className={cn(
|
||
|
|
'px-4 py-2 font-medium transition-colors',
|
||
|
|
activeTab === 'alerts'
|
||
|
|
? 'border-b-2 border-primary text-primary'
|
||
|
|
: 'text-muted-foreground hover:text-foreground',
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
Aktive Alerts
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={() => setActiveTab('rules')}
|
||
|
|
className={cn(
|
||
|
|
'px-4 py-2 font-medium transition-colors',
|
||
|
|
activeTab === 'rules'
|
||
|
|
? 'border-b-2 border-primary text-primary'
|
||
|
|
: 'text-muted-foreground hover:text-foreground',
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
Regeln
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Tab 1: Aktive Alerts */}
|
||
|
|
{activeTab === 'alerts' && (
|
||
|
|
<div className="space-y-4">
|
||
|
|
{/* Filter */}
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<button
|
||
|
|
onClick={() => setAlertFilter('all')}
|
||
|
|
className={cn(
|
||
|
|
'px-3 py-1 rounded text-sm transition-colors',
|
||
|
|
alertFilter === 'all'
|
||
|
|
? 'bg-primary text-primary-foreground'
|
||
|
|
: 'bg-accent text-foreground hover:bg-accent/80',
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
Alle
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={() => setAlertFilter('unacknowledged')}
|
||
|
|
className={cn(
|
||
|
|
'px-3 py-1 rounded text-sm transition-colors',
|
||
|
|
alertFilter === 'unacknowledged'
|
||
|
|
? 'bg-primary text-primary-foreground'
|
||
|
|
: 'bg-accent text-foreground hover:bg-accent/80',
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
Unbestätigt
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={() => setAlertFilter('acknowledged')}
|
||
|
|
className={cn(
|
||
|
|
'px-3 py-1 rounded text-sm transition-colors',
|
||
|
|
alertFilter === 'acknowledged'
|
||
|
|
? 'bg-primary text-primary-foreground'
|
||
|
|
: 'bg-accent text-foreground hover:bg-accent/80',
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
Bestätigt
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Loading / Error */}
|
||
|
|
{alertsQuery.isLoading && <p className="text-muted-foreground">Lädt...</p>}
|
||
|
|
{alertsQuery.isError && (
|
||
|
|
<p className="text-red-400">Fehler beim Laden der Alerts</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Empty State */}
|
||
|
|
{alertsQuery.isSuccess && filteredAlerts.length === 0 && (
|
||
|
|
<p className="text-muted-foreground text-center py-8">
|
||
|
|
Keine aktiven Alerts
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Table */}
|
||
|
|
{alertsQuery.isSuccess && filteredAlerts.length > 0 && (
|
||
|
|
<div className="overflow-x-auto border border-border rounded-lg">
|
||
|
|
<table className="w-full text-sm">
|
||
|
|
<thead>
|
||
|
|
<tr className="bg-accent border-b border-border">
|
||
|
|
<th className="px-4 py-2 text-left font-medium">Erstellt</th>
|
||
|
|
<th className="px-4 py-2 text-left font-medium">Agent</th>
|
||
|
|
<th className="px-4 py-2 text-left font-medium">Regel</th>
|
||
|
|
<th className="px-4 py-2 text-left font-medium">Nachricht</th>
|
||
|
|
<th className="px-4 py-2 text-left font-medium">Schweregrad</th>
|
||
|
|
<th className="px-4 py-2 text-left font-medium">Aktionen</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{filteredAlerts.map((alert) => (
|
||
|
|
<tr key={alert.id} className="border-b border-border hover:bg-accent/50">
|
||
|
|
<td className="px-4 py-2 text-xs text-muted-foreground">
|
||
|
|
{new Date(alert.createdAt).toLocaleString('de-DE')}
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-2">
|
||
|
|
<div>
|
||
|
|
<p className="font-medium">{alert.agentHostname}</p>
|
||
|
|
<p className="text-xs text-muted-foreground">{alert.agentId}</p>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-2">{alert.ruleName}</td>
|
||
|
|
<td className="px-4 py-2 text-xs">{alert.message}</td>
|
||
|
|
<td className="px-4 py-2">
|
||
|
|
<div className={cn('flex items-center gap-1 px-2 py-1 rounded w-fit text-xs font-medium', getSeverityStyle(alert.severity))}>
|
||
|
|
{getSeverityIcon(alert.severity)}
|
||
|
|
{alert.severity}
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-2">
|
||
|
|
<button
|
||
|
|
onClick={() => acknowledgeMutation.mutate(alert.id)}
|
||
|
|
disabled={alert.acknowledged || acknowledgeMutation.isPending}
|
||
|
|
className={cn(
|
||
|
|
'flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors',
|
||
|
|
alert.acknowledged
|
||
|
|
? 'bg-green-500/20 text-green-400 cursor-default'
|
||
|
|
: 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 disabled:opacity-50',
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<Check size={12} />
|
||
|
|
{alert.acknowledged ? 'Bestätigt' : 'Bestätigen'}
|
||
|
|
</button>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Tab 2: Regeln */}
|
||
|
|
{activeTab === 'rules' && (
|
||
|
|
<div className="space-y-4">
|
||
|
|
{/* Create Button */}
|
||
|
|
<button
|
||
|
|
onClick={() => setShowCreateRuleModal(true)}
|
||
|
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||
|
|
>
|
||
|
|
<Plus size={16} />
|
||
|
|
Neue Regel
|
||
|
|
</button>
|
||
|
|
|
||
|
|
{/* Loading / Error */}
|
||
|
|
{rulesQuery.isLoading && <p className="text-muted-foreground">Lädt...</p>}
|
||
|
|
{rulesQuery.isError && (
|
||
|
|
<p className="text-red-400">Fehler beim Laden der Regeln</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Empty State */}
|
||
|
|
{rulesQuery.isSuccess && rulesQuery.data.length === 0 && (
|
||
|
|
<p className="text-muted-foreground text-center py-8">
|
||
|
|
Keine Regeln vorhanden
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Table */}
|
||
|
|
{rulesQuery.isSuccess && rulesQuery.data.length > 0 && (
|
||
|
|
<div className="overflow-x-auto border border-border rounded-lg">
|
||
|
|
<table className="w-full text-sm">
|
||
|
|
<thead>
|
||
|
|
<tr className="bg-accent border-b border-border">
|
||
|
|
<th className="px-4 py-2 text-left font-medium">Name</th>
|
||
|
|
<th className="px-4 py-2 text-left font-medium">Metrik</th>
|
||
|
|
<th className="px-4 py-2 text-left font-medium">Bedingung</th>
|
||
|
|
<th className="px-4 py-2 text-left font-medium">Schweregrad</th>
|
||
|
|
<th className="px-4 py-2 text-center font-medium">Aktiv</th>
|
||
|
|
<th className="px-4 py-2 text-left font-medium">Aktionen</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{rulesQuery.data.map((rule) => {
|
||
|
|
const metricLabel = metricPathOptions.find(
|
||
|
|
(opt) => opt.value === rule.metricPath,
|
||
|
|
)?.label || rule.metricPath
|
||
|
|
return (
|
||
|
|
<tr key={rule.id} className="border-b border-border hover:bg-accent/50">
|
||
|
|
<td className="px-4 py-2 font-medium">{rule.name}</td>
|
||
|
|
<td className="px-4 py-2 text-xs">{metricLabel}</td>
|
||
|
|
<td className="px-4 py-2 text-xs font-mono">
|
||
|
|
{metricLabel} {rule.operator} {rule.threshold}
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-2">
|
||
|
|
<div className={cn('flex items-center gap-1 px-2 py-1 rounded w-fit text-xs font-medium', getSeverityStyle(rule.severity))}>
|
||
|
|
{getSeverityIcon(rule.severity)}
|
||
|
|
{rule.severity}
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-2 text-center">
|
||
|
|
<button
|
||
|
|
onClick={() => handleToggleRuleEnabled(rule)}
|
||
|
|
disabled={updateRuleMutation.isPending}
|
||
|
|
className={cn(
|
||
|
|
'px-2 py-1 rounded text-xs font-medium transition-colors',
|
||
|
|
rule.enabled
|
||
|
|
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
|
||
|
|
: 'bg-red-500/20 text-red-400 hover:bg-red-500/30',
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{rule.enabled ? 'Ja' : 'Nein'}
|
||
|
|
</button>
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-2">
|
||
|
|
<button
|
||
|
|
onClick={() => handleDeleteRule(rule.id)}
|
||
|
|
disabled={deleteRuleMutation.isPending}
|
||
|
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50"
|
||
|
|
>
|
||
|
|
<Trash2 size={12} />
|
||
|
|
Löschen
|
||
|
|
</button>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Create Rule Modal */}
|
||
|
|
{showCreateRuleModal && (
|
||
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||
|
|
<div className="bg-card border border-border rounded-lg p-6 max-w-md w-full">
|
||
|
|
<h2 className="text-xl font-bold mb-4">Neue Regel erstellen</h2>
|
||
|
|
|
||
|
|
<div className="space-y-4">
|
||
|
|
{/* Name */}
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium mb-1">
|
||
|
|
Name <span className="text-red-400">*</span>
|
||
|
|
</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={formData.name}
|
||
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||
|
|
className="w-full px-3 py-2 bg-background border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||
|
|
placeholder="z.B. CPU über 90%"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* MetricPath */}
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium mb-1">
|
||
|
|
Metrik <span className="text-red-400">*</span>
|
||
|
|
</label>
|
||
|
|
<select
|
||
|
|
value={formData.metricPath}
|
||
|
|
onChange={(e) => setFormData({ ...formData, metricPath: e.target.value })}
|
||
|
|
className="w-full px-3 py-2 bg-background border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||
|
|
>
|
||
|
|
{metricPathOptions.map((opt) => (
|
||
|
|
<option key={opt.value} value={opt.value}>
|
||
|
|
{opt.label}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Operator */}
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium mb-1">
|
||
|
|
Operator <span className="text-red-400">*</span>
|
||
|
|
</label>
|
||
|
|
<select
|
||
|
|
value={formData.operator}
|
||
|
|
onChange={(e) => setFormData({ ...formData, operator: e.target.value })}
|
||
|
|
className="w-full px-3 py-2 bg-background border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||
|
|
>
|
||
|
|
{operatorOptions.map((opt) => (
|
||
|
|
<option key={opt.value} value={opt.value}>
|
||
|
|
{opt.label}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Threshold */}
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium mb-1">
|
||
|
|
Schwellenwert <span className="text-red-400">*</span>
|
||
|
|
</label>
|
||
|
|
<input
|
||
|
|
type="number"
|
||
|
|
value={formData.threshold}
|
||
|
|
onChange={(e) => setFormData({ ...formData, threshold: e.target.value })}
|
||
|
|
className="w-full px-3 py-2 bg-background border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||
|
|
placeholder="z.B. 90"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Severity */}
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium mb-1">
|
||
|
|
Schweregrad <span className="text-red-400">*</span>
|
||
|
|
</label>
|
||
|
|
<select
|
||
|
|
value={formData.severity}
|
||
|
|
onChange={(e) => setFormData({ ...formData, severity: e.target.value as AlertSeverity })}
|
||
|
|
className="w-full px-3 py-2 bg-background border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||
|
|
>
|
||
|
|
{severityOptions.map((opt) => (
|
||
|
|
<option key={opt.value} value={opt.value}>
|
||
|
|
{opt.label}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Buttons */}
|
||
|
|
<div className="flex gap-3 mt-6">
|
||
|
|
<button
|
||
|
|
onClick={() => setShowCreateRuleModal(false)}
|
||
|
|
className="flex-1 px-4 py-2 bg-accent text-foreground rounded-md hover:bg-accent/80 transition-colors"
|
||
|
|
>
|
||
|
|
Abbrechen
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={handleCreateRule}
|
||
|
|
disabled={createRuleMutation.isPending}
|
||
|
|
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||
|
|
>
|
||
|
|
{createRuleMutation.isPending ? 'Wird erstellt...' : 'Erstellen'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|