315 lines
13 KiB
TypeScript
315 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import Link from 'next/link';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import {
|
|
ArrowLeft,
|
|
Plus,
|
|
Trash2,
|
|
Send,
|
|
Bell,
|
|
MessageSquare,
|
|
Globe,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Loader2,
|
|
Pencil,
|
|
} from 'lucide-react';
|
|
|
|
interface Channel {
|
|
id: number;
|
|
name: string;
|
|
channel_type: string;
|
|
config: Record<string, any>;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
const CHANNEL_TYPES = [
|
|
{ value: 'teams', label: 'Microsoft Teams', icon: MessageSquare, color: 'bg-indigo-100 text-indigo-700', fields: [
|
|
{ key: 'webhook_url', label: 'Webhook URL', type: 'url', placeholder: 'https://...webhook.office.com/...' },
|
|
]},
|
|
{ value: 'telegram', label: 'Telegram', icon: Send, color: 'bg-blue-100 text-blue-700', fields: [
|
|
{ key: 'bot_token', label: 'Bot Token', type: 'password', placeholder: '123456:ABC-DEF...' },
|
|
{ key: 'chat_id', label: 'Chat ID', type: 'text', placeholder: '-1001234567890' },
|
|
{ key: 'parse_mode', label: 'Parse Mode', type: 'select', options: ['HTML', 'Markdown', 'MarkdownV2'] },
|
|
]},
|
|
{ value: 'ntfy', label: 'ntfy', icon: Bell, color: 'bg-green-100 text-green-700', fields: [
|
|
{ key: 'server_url', label: 'Server URL', type: 'url', placeholder: 'https://ntfy.sh' },
|
|
{ key: 'topic', label: 'Topic', type: 'text', placeholder: 'pulse-alerts' },
|
|
{ key: 'auth_token', label: 'Auth Token (optional)', type: 'password', placeholder: 'tk_...' },
|
|
{ key: 'default_priority', label: 'Default Priority', type: 'select', options: ['min', 'low', 'default', 'high', 'urgent'] },
|
|
]},
|
|
{ value: 'webhook', label: 'Generic Webhook', icon: Globe, color: 'bg-gray-100 text-gray-700', fields: [
|
|
{ key: 'url', label: 'URL', type: 'url', placeholder: 'https://...' },
|
|
{ key: 'method', label: 'Method', type: 'select', options: ['POST', 'PUT', 'PATCH'] },
|
|
]},
|
|
];
|
|
|
|
export default function ChannelsPage() {
|
|
const [channels, setChannels] = useState<Channel[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
const [formType, setFormType] = useState('teams');
|
|
const [formName, setFormName] = useState('');
|
|
const [formConfig, setFormConfig] = useState<Record<string, any>>({});
|
|
const [testStatus, setTestStatus] = useState<Record<number, 'idle' | 'testing' | 'success' | 'error'>>({});
|
|
|
|
useEffect(() => { loadChannels(); }, []);
|
|
|
|
const loadChannels = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const res = await fetch('/api/notification-channels');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setChannels(data.data || []);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load channels:', err);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const saveChannel = async () => {
|
|
if (!formName) return;
|
|
try {
|
|
const payload = { name: formName, channel_type: formType, config: formConfig, is_active: true };
|
|
|
|
if (editingId) {
|
|
await fetch(`/api/notification-channels/${editingId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
} else {
|
|
await fetch('/api/notification-channels', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
resetForm();
|
|
loadChannels();
|
|
} catch (err) {
|
|
console.error('Failed to save channel:', err);
|
|
}
|
|
};
|
|
|
|
const deleteChannel = async (id: number) => {
|
|
if (!confirm('Delete this notification channel?')) return;
|
|
try {
|
|
await fetch(`/api/notification-channels/${id}`, { method: 'DELETE' });
|
|
setChannels(prev => prev.filter(c => c.id !== id));
|
|
} catch (err) {
|
|
console.error('Failed to delete channel:', err);
|
|
}
|
|
};
|
|
|
|
const toggleChannel = async (id: number, active: boolean) => {
|
|
try {
|
|
await fetch(`/api/notification-channels/${id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ is_active: active }),
|
|
});
|
|
setChannels(prev => prev.map(c => c.id === id ? { ...c, is_active: active } : c));
|
|
} catch (err) {
|
|
console.error('Failed to toggle channel:', err);
|
|
}
|
|
};
|
|
|
|
const testChannel = async (id: number) => {
|
|
setTestStatus(prev => ({ ...prev, [id]: 'testing' }));
|
|
try {
|
|
const res = await fetch(`/api/notification-channels/${id}/test`, { method: 'POST' });
|
|
const data = await res.json();
|
|
setTestStatus(prev => ({ ...prev, [id]: data.success ? 'success' : 'error' }));
|
|
setTimeout(() => setTestStatus(prev => ({ ...prev, [id]: 'idle' })), 3000);
|
|
} catch {
|
|
setTestStatus(prev => ({ ...prev, [id]: 'error' }));
|
|
setTimeout(() => setTestStatus(prev => ({ ...prev, [id]: 'idle' })), 3000);
|
|
}
|
|
};
|
|
|
|
const editChannel = (channel: Channel) => {
|
|
setEditingId(channel.id);
|
|
setFormType(channel.channel_type);
|
|
setFormName(channel.name);
|
|
setFormConfig(channel.config);
|
|
setShowCreate(true);
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setShowCreate(false);
|
|
setEditingId(null);
|
|
setFormType('teams');
|
|
setFormName('');
|
|
setFormConfig({});
|
|
};
|
|
|
|
const typeDef = CHANNEL_TYPES.find(t => t.value === formType);
|
|
|
|
return (
|
|
<div className="container mx-auto p-6 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Link href="/admin/workflow">
|
|
<Button variant="ghost" size="icon"><ArrowLeft className="h-4 w-4" /></Button>
|
|
</Link>
|
|
<div>
|
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
|
<Bell className="h-6 w-6" /> Notification Channels
|
|
</h1>
|
|
<p className="text-muted-foreground text-sm">Configure Teams, Telegram, ntfy, and webhook destinations</p>
|
|
</div>
|
|
</div>
|
|
<Button onClick={() => { resetForm(); setShowCreate(true); }}>
|
|
<Plus className="h-4 w-4 mr-2" /> New Channel
|
|
</Button>
|
|
</div>
|
|
|
|
{showCreate && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">{editingId ? 'Edit Channel' : 'Create Channel'}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-sm font-medium">Channel Name</label>
|
|
<input
|
|
className="w-full mt-1 px-3 py-2 border rounded-md bg-background"
|
|
placeholder="e.g., NOC Teams Channel"
|
|
value={formName}
|
|
onChange={e => setFormName(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium">Type</label>
|
|
<select
|
|
className="w-full mt-1 px-3 py-2 border rounded-md bg-background"
|
|
value={formType}
|
|
onChange={e => { setFormType(e.target.value); setFormConfig({}); }}
|
|
disabled={!!editingId}
|
|
>
|
|
{CHANNEL_TYPES.map(t => (
|
|
<option key={t.value} value={t.value}>{t.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{typeDef && (
|
|
<div className="space-y-3">
|
|
{typeDef.fields.map(field => (
|
|
<div key={field.key}>
|
|
<label className="text-sm font-medium">{field.label}</label>
|
|
{field.type === 'select' ? (
|
|
<select
|
|
className="w-full mt-1 px-3 py-2 border rounded-md bg-background"
|
|
value={formConfig[field.key] || (field.options?.[0] || '')}
|
|
onChange={e => setFormConfig(prev => ({ ...prev, [field.key]: e.target.value }))}
|
|
>
|
|
{field.options?.map((opt: string) => (
|
|
<option key={opt} value={opt}>{opt}</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input
|
|
className="w-full mt-1 px-3 py-2 border rounded-md bg-background"
|
|
type={field.type}
|
|
placeholder={field.placeholder}
|
|
value={formConfig[field.key] || ''}
|
|
onChange={e => setFormConfig(prev => ({ ...prev, [field.key]: e.target.value }))}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2">
|
|
<Button onClick={saveChannel} disabled={!formName}>{editingId ? 'Update' : 'Create'}</Button>
|
|
<Button variant="outline" onClick={resetForm}>Cancel</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<div className="text-center py-12 text-muted-foreground">Loading channels...</div>
|
|
) : channels.length === 0 && !showCreate ? (
|
|
<Card>
|
|
<CardContent className="py-12 text-center text-muted-foreground">
|
|
<Bell className="h-12 w-12 mx-auto mb-4 opacity-30" />
|
|
<p>No notification channels configured yet.</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{channels.map(channel => {
|
|
const cType = CHANNEL_TYPES.find(t => t.value === channel.channel_type);
|
|
const Icon = cType?.icon || Globe;
|
|
const status = testStatus[channel.id] || 'idle';
|
|
|
|
return (
|
|
<Card key={channel.id} className={!channel.is_active ? 'opacity-60' : ''}>
|
|
<CardContent className="py-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Switch
|
|
checked={channel.is_active}
|
|
onCheckedChange={(checked) => toggleChannel(channel.id, checked)}
|
|
/>
|
|
<Icon className="h-5 w-5 text-muted-foreground" />
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">{channel.name}</span>
|
|
<Badge className={cType?.color || ''}>{cType?.label || channel.channel_type}</Badge>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{channel.channel_type === 'teams' && channel.config.webhook_url && `URL: ${channel.config.webhook_url.substring(0, 50)}...`}
|
|
{channel.channel_type === 'telegram' && `Chat: ${channel.config.chat_id || 'not set'}`}
|
|
{channel.channel_type === 'ntfy' && `Topic: ${channel.config.topic || 'not set'} @ ${channel.config.server_url || 'ntfy.sh'}`}
|
|
{channel.channel_type === 'webhook' && `${channel.config.method || 'POST'} ${channel.config.url || 'not set'}`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => testChannel(channel.id)}
|
|
disabled={status === 'testing'}
|
|
>
|
|
{status === 'testing' && <Loader2 className="h-3 w-3 mr-1 animate-spin" />}
|
|
{status === 'success' && <CheckCircle2 className="h-3 w-3 mr-1 text-green-500" />}
|
|
{status === 'error' && <XCircle className="h-3 w-3 mr-1 text-red-500" />}
|
|
{status === 'idle' && <Send className="h-3 w-3 mr-1" />}
|
|
Test
|
|
</Button>
|
|
<Button variant="ghost" size="icon" onClick={() => editChannel(channel)}>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" onClick={() => deleteChannel(channel.id)}>
|
|
<Trash2 className="h-4 w-4 text-red-500" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|