wulf-pulse/app/admin/workflow/channels/page.tsx

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>
);
}