Add Google Chat webhook to notification channels

- Added Google Chat  as a new channel type option in the Notification Channel
- Add Google Chat tab to notifications
- Fix: Ensure Google Chat and Email save record to PB
This commit is contained in:
Tola Leng
2025-08-05 21:35:56 +07:00
parent 96bf456338
commit 99d67deb10
4 changed files with 210 additions and 40 deletions
@@ -1,4 +1,3 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { import {
Dialog, Dialog,
@@ -38,7 +37,7 @@ interface NotificationChannelDialogProps {
const baseSchema = z.object({ const baseSchema = z.object({
notify_name: z.string().min(1, "Name is required"), notify_name: z.string().min(1, "Name is required"),
notification_type: z.enum(["telegram", "discord", "slack", "signal", "email"]), notification_type: z.enum(["telegram", "discord", "slack", "signal", "google_chat", "email", "webhook"]),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
service_id: z.string().default("global"), service_id: z.string().default("global"),
template_id: z.string().optional(), template_id: z.string().optional(),
@@ -65,6 +64,11 @@ const signalSchema = baseSchema.extend({
signal_number: z.string().min(1, "Signal number is required"), signal_number: z.string().min(1, "Signal number is required"),
}); });
const googleChatSchema = baseSchema.extend({
notification_type: z.literal("google_chat"),
google_chat_webhook_url: z.string().url("Must be a valid URL"),
});
const emailSchema = baseSchema.extend({ const emailSchema = baseSchema.extend({
notification_type: z.literal("email"), notification_type: z.literal("email"),
email_address: z.string().email("Valid email is required"), email_address: z.string().email("Valid email is required"),
@@ -90,6 +94,7 @@ const formSchema = z.discriminatedUnion("notification_type", [
discordSchema, discordSchema,
slackSchema, slackSchema,
signalSchema, signalSchema,
googleChatSchema,
emailSchema, emailSchema,
webhookSchema webhookSchema
]); ]);
@@ -101,6 +106,7 @@ const notificationTypeOptions = [
{ value: "discord", label: "Discord", description: "Send notifications to Discord webhook" }, { value: "discord", label: "Discord", description: "Send notifications to Discord webhook" },
{ value: "slack", label: "Slack", description: "Send notifications to Slack webhook" }, { value: "slack", label: "Slack", description: "Send notifications to Slack webhook" },
{ value: "signal", label: "Signal", description: "Send notifications via Signal" }, { value: "signal", label: "Signal", description: "Send notifications via Signal" },
{ value: "google_chat", label: "Google Chat", description: "Send notifications to Google Chat webhook" },
{ value: "email", label: "Email", description: "Send notifications via email" }, { value: "email", label: "Email", description: "Send notifications via email" },
{ value: "webhook", label: "Webhook", description: "Send notifications to custom webhook" }, { value: "webhook", label: "Webhook", description: "Send notifications to custom webhook" },
]; ];
@@ -236,7 +242,7 @@ export const NotificationChannelDialog = ({
await webhookService.createWebhook(webhookData); await webhookService.createWebhook(webhookData);
} }
} else { } else {
// Handle other notification types with existing service // Handle all other notification types including Google Chat and Email
const configData = { const configData = {
...values, ...values,
service_id: values.service_id || "global", service_id: values.service_id || "global",
@@ -248,7 +254,14 @@ export const NotificationChannelDialog = ({
await alertConfigService.createAlertConfiguration(configData as any); await alertConfigService.createAlertConfiguration(configData as any);
} }
} }
onClose(true); // Close with refresh onClose(true); // Close with refresh
} catch (error) {
toast({
title: "Error",
description: "Failed to save notification channel",
variant: "destructive"
});
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -310,7 +323,6 @@ export const NotificationChannelDialog = ({
)} )}
/> />
{/* Show different fields based on notification type */}
{notificationType === "telegram" && ( {notificationType === "telegram" && (
<> <>
<FormField <FormField
@@ -405,6 +417,25 @@ export const NotificationChannelDialog = ({
/> />
)} )}
{notificationType === "google_chat" && (
<FormField
control={form.control}
name="google_chat_webhook_url"
render={({ field }) => (
<FormItem>
<FormLabel>Google Chat Webhook URL</FormLabel>
<FormControl>
<Input placeholder="https://chat.googleapis.com/v1/spaces/..." {...field} />
</FormControl>
<FormDescription>
Google Chat webhook URL from your Google Chat space
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{notificationType === "email" && ( {notificationType === "email" && (
<> <>
<FormField <FormField
@@ -597,7 +628,6 @@ export const NotificationChannelDialog = ({
)} )}
/> />
{/* Payload Template Section */}
<div className="space-y-4"> <div className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
@@ -13,9 +13,17 @@ import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { alertConfigService } from "@/services/alertConfigService"; import { alertConfigService } from "@/services/alertConfigService";
import { pb } from "@/lib/pocketbase";
interface CombinedChannel extends Partial<AlertConfiguration> {
isWebhook?: boolean;
url?: string;
method?: string;
description?: string;
}
interface NotificationChannelListProps { interface NotificationChannelListProps {
channels: AlertConfiguration[]; channels: CombinedChannel[];
onEdit: (config: AlertConfiguration) => void; onEdit: (config: AlertConfiguration) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
} }
@@ -25,25 +33,63 @@ export const NotificationChannelList = ({
onEdit, onEdit,
onDelete onDelete
}: NotificationChannelListProps) => { }: NotificationChannelListProps) => {
const toggleEnabled = async (config: AlertConfiguration) => { const toggleEnabled = async (config: CombinedChannel) => {
if (!config.id) return; if (!config.id) return;
if (config.isWebhook) {
// Handle webhook toggle
try {
const newEnabled = config.enabled ? "off" : "on";
await pb.collection('webhook').update(config.id, {
enabled: newEnabled
});
// Trigger refresh by calling onEdit with empty config
onEdit({} as AlertConfiguration);
} catch (error) {
console.error("Error updating webhook:", error);
}
} else {
// Handle alert config toggle
await alertConfigService.updateAlertConfiguration(config.id, { await alertConfigService.updateAlertConfiguration(config.id, {
enabled: !config.enabled enabled: !config.enabled
}); });
// The parent component will refresh the list // The parent component will refresh the list
onEdit(config); onEdit(config as AlertConfiguration);
}
}; };
const getChannelTypeLabel = (type: string) => { const getChannelTypeLabel = (type: string | undefined) => {
switch(type) { switch(type) {
case "telegram": return "Telegram"; case "telegram": return "Telegram";
case "discord": return "Discord"; case "discord": return "Discord";
case "slack": return "Slack"; case "slack": return "Slack";
case "signal": return "Signal"; case "signal": return "Signal";
case "google_chat": return "Google Chat";
case "email": return "Email"; case "email": return "Email";
default: return type; case "webhook": return "Webhook";
default: return type || "Unknown";
}
};
const getChannelDetails = (config: CombinedChannel) => {
if (config.isWebhook) {
return `${config.method || 'POST'} ${config.url || ''}`;
}
switch(config.notification_type) {
case "telegram":
return config.telegram_chat_id || '';
case "discord":
case "slack":
case "google_chat":
return config.discord_webhook_url || config.slack_webhook_url || config.google_chat_webhook_url || '';
case "signal":
return config.signal_number || '';
case "email":
return config.email_address || '';
default:
return '';
} }
}; };
@@ -66,6 +112,7 @@ export const NotificationChannelList = ({
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Type</TableHead> <TableHead>Type</TableHead>
<TableHead>Details</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Created</TableHead> <TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
@@ -78,11 +125,14 @@ export const NotificationChannelList = ({
<TableCell> <TableCell>
<Badge variant="outline">{getChannelTypeLabel(channel.notification_type)}</Badge> <Badge variant="outline">{getChannelTypeLabel(channel.notification_type)}</Badge>
</TableCell> </TableCell>
<TableCell className="max-w-xs truncate text-sm text-muted-foreground">
{getChannelDetails(channel)}
</TableCell>
<TableCell> <TableCell>
<Switch <Switch
checked={ checked={
typeof channel.enabled === 'string' typeof channel.enabled === 'string'
? channel.enabled === "true" ? channel.enabled === "true" || channel.enabled === "on"
: !!channel.enabled : !!channel.enabled
} }
onCheckedChange={() => toggleEnabled(channel)} onCheckedChange={() => toggleEnabled(channel)}
@@ -96,7 +146,8 @@ export const NotificationChannelList = ({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onEdit(channel)} onClick={() => onEdit(channel as AlertConfiguration)}
disabled={channel.isWebhook} // Disable edit for webhooks for now
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
@@ -5,28 +5,47 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Plus, Loader2 } from "lucide-react"; import { Plus, Loader2 } from "lucide-react";
import { AlertConfiguration, alertConfigService } from "@/services/alertConfigService"; import { AlertConfiguration, alertConfigService } from "@/services/alertConfigService";
import { WebhookConfiguration, webhookService } from "@/services/webhookService";
import { NotificationChannelDialog } from "./NotificationChannelDialog"; import { NotificationChannelDialog } from "./NotificationChannelDialog";
import { NotificationChannelList } from "./NotificationChannelList"; import { NotificationChannelList } from "./NotificationChannelList";
import { pb } from "@/lib/pocketbase";
interface CombinedChannel extends Partial<AlertConfiguration> {
isWebhook?: boolean;
url?: string;
method?: string;
description?: string;
}
const NotificationSettings = () => { const NotificationSettings = () => {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]); const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
const [webhookConfigs, setWebhookConfigs] = useState<WebhookConfiguration[]>([]);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [currentTab, setCurrentTab] = useState<string>("all"); const [currentTab, setCurrentTab] = useState<string>("all");
const [editingConfig, setEditingConfig] = useState<AlertConfiguration | null>(null); const [editingConfig, setEditingConfig] = useState<AlertConfiguration | null>(null);
const fetchAlertConfigurations = async () => { const fetchNotificationChannels = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
// Fetch alert configurations
const configs = await alertConfigService.getAlertConfigurations(); const configs = await alertConfigService.getAlertConfigurations();
setAlertConfigs(configs); setAlertConfigs(configs);
// Fetch webhooks
try {
const webhookResponse = await pb.collection('webhook').getList(1, 50);
setWebhookConfigs(webhookResponse.items as WebhookConfiguration[]);
} catch (webhookError) {
setWebhookConfigs([]);
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
fetchAlertConfigurations(); fetchNotificationChannels();
}, []); }, []);
const handleAddNew = () => { const handleAddNew = () => {
@@ -40,22 +59,66 @@ const NotificationSettings = () => {
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
// Check if it's a webhook first
const isWebhook = webhookConfigs.find(w => w.id === id);
if (isWebhook) {
// Handle webhook deletion
if (confirm("Are you sure you want to delete this webhook?")) {
try {
await pb.collection('webhook').delete(id);
fetchNotificationChannels();
} catch (error) {
console.error("Error deleting webhook:", error);
}
}
} else {
// Handle alert config deletion
const success = await alertConfigService.deleteAlertConfiguration(id); const success = await alertConfigService.deleteAlertConfiguration(id);
if (success) { if (success) {
fetchAlertConfigurations(); fetchNotificationChannels();
}
} }
}; };
const handleDialogClose = (refreshList: boolean) => { const handleDialogClose = (refreshList: boolean) => {
setDialogOpen(false); setDialogOpen(false);
if (refreshList) { if (refreshList) {
fetchAlertConfigurations(); fetchNotificationChannels();
} }
}; };
const getCombinedChannels = (): CombinedChannel[] => {
const combined: CombinedChannel[] = [];
// Add alert configurations
alertConfigs.forEach(config => {
combined.push(config);
});
// Add webhooks as notification channels
webhookConfigs.forEach(webhook => {
combined.push({
id: webhook.id,
notify_name: webhook.name,
notification_type: "webhook" as const,
enabled: webhook.enabled === "on",
created: webhook.created,
updated: webhook.updated,
isWebhook: true,
url: webhook.url,
method: webhook.method,
description: webhook.description
});
});
return combined;
};
const getFilteredConfigs = () => { const getFilteredConfigs = () => {
if (currentTab === "all") return alertConfigs; const combined = getCombinedChannels();
return alertConfigs.filter(config => config.notification_type === currentTab); if (currentTab === "all") return combined;
return combined.filter(config => config.notification_type === currentTab);
}; };
return ( return (
@@ -86,6 +149,7 @@ const NotificationSettings = () => {
<TabsTrigger value="discord">Discord</TabsTrigger> <TabsTrigger value="discord">Discord</TabsTrigger>
<TabsTrigger value="slack">Slack</TabsTrigger> <TabsTrigger value="slack">Slack</TabsTrigger>
<TabsTrigger value="signal">Signal</TabsTrigger> <TabsTrigger value="signal">Signal</TabsTrigger>
<TabsTrigger value="google_chat">Google Chat</TabsTrigger>
<TabsTrigger value="email">Email</TabsTrigger> <TabsTrigger value="email">Email</TabsTrigger>
<TabsTrigger value="webhook">Webhook</TabsTrigger> <TabsTrigger value="webhook">Webhook</TabsTrigger>
</TabsList> </TabsList>
+39 -14
View File
@@ -7,7 +7,7 @@ export interface AlertConfiguration {
collectionId?: string; collectionId?: string;
collectionName?: string; collectionName?: string;
service_id: string; service_id: string;
notification_type: "telegram" | "discord" | "signal" | "slack" | "email"; notification_type: "telegram" | "discord" | "slack" | "signal" | "google_chat" | "email" | "webhook";
telegram_chat_id?: string; telegram_chat_id?: string;
discord_webhook_url?: string; discord_webhook_url?: string;
signal_number?: string; signal_number?: string;
@@ -15,6 +15,7 @@ export interface AlertConfiguration {
bot_token?: string; bot_token?: string;
template_id?: string; template_id?: string;
slack_webhook_url?: string; slack_webhook_url?: string;
google_chat_webhook_url?: string;
enabled: boolean; enabled: boolean;
created?: string; created?: string;
updated?: string; updated?: string;
@@ -29,13 +30,10 @@ export interface AlertConfiguration {
export const alertConfigService = { export const alertConfigService = {
async getAlertConfigurations(): Promise<AlertConfiguration[]> { async getAlertConfigurations(): Promise<AlertConfiguration[]> {
// console.info("Fetching alert configurations");
try { try {
const response = await pb.collection('alert_configurations').getList(1, 50); const response = await pb.collection('alert_configurations').getList(1, 50);
// console.info("Alert configurations response:", response);
return response.items as AlertConfiguration[]; return response.items as AlertConfiguration[];
} catch (error) { } catch (error) {
// console.error("Error fetching alert configurations:", error);
toast({ toast({
title: "Error", title: "Error",
description: "Failed to load notification settings", description: "Failed to load notification settings",
@@ -46,17 +44,50 @@ export const alertConfigService = {
}, },
async createAlertConfiguration(config: Omit<AlertConfiguration, 'id' | 'collectionId' | 'collectionName' | 'created' | 'updated'>): Promise<AlertConfiguration | null> { async createAlertConfiguration(config: Omit<AlertConfiguration, 'id' | 'collectionId' | 'collectionName' | 'created' | 'updated'>): Promise<AlertConfiguration | null> {
// console.info("Creating alert configuration:", config);
try { try {
const result = await pb.collection('alert_configurations').create(config); // Build the configuration object with proper field mapping
// console.info("Alert configuration created:", result); const cleanConfig: any = {
service_id: config.service_id || "global",
notification_type: config.notification_type,
notify_name: config.notify_name,
enabled: config.enabled,
template_id: config.template_id || "",
};
// Add type-specific fields based on notification type
if (config.notification_type === "telegram") {
cleanConfig.telegram_chat_id = config.telegram_chat_id || "";
cleanConfig.bot_token = config.bot_token || "";
} else if (config.notification_type === "discord") {
cleanConfig.discord_webhook_url = config.discord_webhook_url || "";
} else if (config.notification_type === "slack") {
cleanConfig.slack_webhook_url = config.slack_webhook_url || "";
} else if (config.notification_type === "signal") {
cleanConfig.signal_number = config.signal_number || "";
} else if (config.notification_type === "google_chat") {
cleanConfig.google_chat_webhook_url = config.google_chat_webhook_url || "";
} else if (config.notification_type === "email") {
cleanConfig.email_address = config.email_address || "";
cleanConfig.email_sender_name = config.email_sender_name || "";
cleanConfig.smtp_server = config.smtp_server || "";
cleanConfig.smtp_port = config.smtp_port || "";
}
const result = await pb.collection('alert_configurations').create(cleanConfig);
toast({ toast({
title: "Success", title: "Success",
description: "Notification channel created successfully", description: "Notification channel created successfully",
}); });
return result as AlertConfiguration; return result as AlertConfiguration;
} catch (error) { } catch (error) {
// console.error("Error creating alert configuration:", error); // Try to get more details from the error
if (error && typeof error === 'object') {
}
toast({ toast({
title: "Error", title: "Error",
description: "Failed to create notification channel", description: "Failed to create notification channel",
@@ -67,17 +98,14 @@ export const alertConfigService = {
}, },
async updateAlertConfiguration(id: string, config: Partial<AlertConfiguration>): Promise<AlertConfiguration | null> { async updateAlertConfiguration(id: string, config: Partial<AlertConfiguration>): Promise<AlertConfiguration | null> {
// console.info(`Updating alert configuration ${id}:`, config);
try { try {
const result = await pb.collection('alert_configurations').update(id, config); const result = await pb.collection('alert_configurations').update(id, config);
// console.info("Alert configuration updated:", result);
toast({ toast({
title: "Success", title: "Success",
description: "Notification channel updated successfully", description: "Notification channel updated successfully",
}); });
return result as AlertConfiguration; return result as AlertConfiguration;
} catch (error) { } catch (error) {
// console.error("Error updating alert configuration:", error);
toast({ toast({
title: "Error", title: "Error",
description: "Failed to update notification channel", description: "Failed to update notification channel",
@@ -88,17 +116,14 @@ export const alertConfigService = {
}, },
async deleteAlertConfiguration(id: string): Promise<boolean> { async deleteAlertConfiguration(id: string): Promise<boolean> {
// console.info(`Deleting alert configuration ${id}`);
try { try {
await pb.collection('alert_configurations').delete(id); await pb.collection('alert_configurations').delete(id);
// console.info("Alert configuration deleted");
toast({ toast({
title: "Success", title: "Success",
description: "Notification channel removed", description: "Notification channel removed",
}); });
return true; return true;
} catch (error) { } catch (error) {
// console.error("Error deleting alert configuration:", error);
toast({ toast({
title: "Error", title: "Error",
description: "Failed to remove notification channel", description: "Failed to remove notification channel",