re-uploading work
This commit is contained in:
585
web/src/pages/triggers/TriggersPage.tsx
Normal file
585
web/src/pages/triggers/TriggersPage.tsx
Normal file
@@ -0,0 +1,585 @@
|
||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
useTriggers,
|
||||
useTrigger,
|
||||
useDeleteTrigger,
|
||||
useEnableTrigger,
|
||||
useDisableTrigger,
|
||||
} from "@/hooks/useTriggers";
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Search,
|
||||
X,
|
||||
Plus,
|
||||
Copy,
|
||||
Check,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
export default function TriggersPage() {
|
||||
const { ref } = useParams<{ ref?: string }>();
|
||||
const { data, isLoading, error } = useTriggers({});
|
||||
const triggers = data?.data || [];
|
||||
const [collapsedPacks, setCollapsedPacks] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Filter triggers based on search query
|
||||
const filteredTriggers = useMemo(() => {
|
||||
if (!searchQuery.trim()) return triggers;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return triggers.filter((trigger: any) => {
|
||||
return (
|
||||
trigger.label?.toLowerCase().includes(query) ||
|
||||
trigger.ref?.toLowerCase().includes(query) ||
|
||||
trigger.description?.toLowerCase().includes(query) ||
|
||||
trigger.pack_ref?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [triggers, searchQuery]);
|
||||
|
||||
// Group filtered triggers by pack
|
||||
const triggersByPack = useMemo(() => {
|
||||
const grouped = new Map<string, any[]>();
|
||||
filteredTriggers.forEach((trigger: any) => {
|
||||
const packRef = trigger.pack_ref || "unknown";
|
||||
if (!grouped.has(packRef)) {
|
||||
grouped.set(packRef, []);
|
||||
}
|
||||
grouped.get(packRef)!.push(trigger);
|
||||
});
|
||||
// Sort packs alphabetically
|
||||
return new Map(
|
||||
[...grouped.entries()].sort((a, b) => a[0].localeCompare(b[0])),
|
||||
);
|
||||
}, [filteredTriggers]);
|
||||
|
||||
const togglePack = (packRef: string) => {
|
||||
setCollapsedPacks((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(packRef)) {
|
||||
next.delete(packRef);
|
||||
} else {
|
||||
next.add(packRef);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
<p>Error: {(error as Error).message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)]">
|
||||
{/* Left sidebar - Triggers List */}
|
||||
<div className="w-96 border-r border-gray-200 overflow-y-auto bg-gray-50">
|
||||
<div className="p-4 border-b border-gray-200 bg-white sticky top-0 z-10">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h1 className="text-2xl font-bold">Triggers</h1>
|
||||
<Link
|
||||
to="/triggers/create"
|
||||
className="inline-flex items-center px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Create Trigger
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{filteredTriggers.length} of {triggers.length} triggers
|
||||
</p>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mt-3 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search triggers..."
|
||||
className="block w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
{triggers.length === 0 ? (
|
||||
<div className="bg-white p-8 text-center rounded-lg shadow-sm m-2">
|
||||
<p className="text-gray-500">No triggers found</p>
|
||||
</div>
|
||||
) : filteredTriggers.length === 0 ? (
|
||||
<div className="bg-white p-8 text-center rounded-lg shadow-sm m-2">
|
||||
<p className="text-gray-500">No triggers match your search</p>
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="mt-2 text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Array.from(triggersByPack.entries()).map(
|
||||
([packRef, packTriggers]) => {
|
||||
const isCollapsed = collapsedPacks.has(packRef);
|
||||
return (
|
||||
<div
|
||||
key={packRef}
|
||||
className="bg-white rounded-lg shadow-sm overflow-hidden"
|
||||
>
|
||||
{/* Pack Header */}
|
||||
<button
|
||||
onClick={() => togglePack(packRef)}
|
||||
className="w-full px-3 py-2 flex items-center justify-between hover:bg-gray-50 transition-colors border-b border-gray-200"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-gray-900">
|
||||
{packRef}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
|
||||
{packTriggers.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Triggers List */}
|
||||
{!isCollapsed && (
|
||||
<div className="p-1">
|
||||
{packTriggers.map((trigger: any) => (
|
||||
<Link
|
||||
key={trigger.id}
|
||||
to={`/triggers/${trigger.ref}`}
|
||||
className={`block p-3 rounded transition-colors ${
|
||||
ref === trigger.ref
|
||||
? "bg-blue-50 border-2 border-blue-500"
|
||||
: "border-2 border-transparent hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium text-sm text-gray-900 truncate">
|
||||
{trigger.label}
|
||||
</div>
|
||||
<span
|
||||
className={`ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
trigger.enabled
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{trigger.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-gray-500 mt-1 truncate">
|
||||
{trigger.ref}
|
||||
</div>
|
||||
{trigger.description && (
|
||||
<div className="text-xs text-gray-400 mt-1 line-clamp-2">
|
||||
{trigger.description}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel - Trigger Detail or Empty State */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{ref ? (
|
||||
<TriggerDetail triggerRef={ref} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center text-gray-500">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">
|
||||
No trigger selected
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Select a trigger from the list to view its details
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TriggerDetail({ triggerRef }: { triggerRef: string }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: trigger, isLoading, error } = useTrigger(triggerRef);
|
||||
const { isAuthenticated } = useAuth();
|
||||
const deleteTrigger = useDeleteTrigger();
|
||||
const enableTrigger = useEnableTrigger();
|
||||
const disableTrigger = useDisableTrigger();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [isTogglingEnabled, setIsTogglingEnabled] = useState(false);
|
||||
const [copiedWebhookUrl, setCopiedWebhookUrl] = useState(false);
|
||||
|
||||
const handleToggleEnabled = async () => {
|
||||
if (!trigger?.data) return;
|
||||
|
||||
setIsTogglingEnabled(true);
|
||||
try {
|
||||
if (trigger.data.enabled) {
|
||||
await disableTrigger.mutateAsync(triggerRef);
|
||||
} else {
|
||||
await enableTrigger.mutateAsync(triggerRef);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle trigger enabled status:", err);
|
||||
} finally {
|
||||
setIsTogglingEnabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteTrigger.mutateAsync(triggerRef);
|
||||
window.location.href = "/triggers";
|
||||
} catch (err) {
|
||||
console.error("Failed to delete trigger:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const copyWebhookUrl = async () => {
|
||||
if (!trigger?.data?.webhook_key) return;
|
||||
|
||||
const apiBaseUrl =
|
||||
import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
const webhookUrl = `${apiBaseUrl}/webhooks/${trigger.data.webhook_key}`;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(webhookUrl);
|
||||
setCopiedWebhookUrl(true);
|
||||
setTimeout(() => setCopiedWebhookUrl(false), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy webhook URL:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !trigger) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
<p>Error: {error ? (error as Error).message : "Trigger not found"}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const paramSchema = trigger.data?.param_schema || {};
|
||||
const properties = paramSchema.properties || {};
|
||||
const requiredFields = paramSchema.required || [];
|
||||
const paramEntries = Object.entries(properties);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-3xl font-bold">
|
||||
<span className="text-gray-500">{trigger.data?.pack_ref}.</span>
|
||||
{trigger.data?.label}
|
||||
</h1>
|
||||
{/* Toggle Switch */}
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={trigger.data?.enabled || false}
|
||||
onChange={handleToggleEnabled}
|
||||
disabled={!isAuthenticated || isTogglingEnabled}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:bg-blue-600 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-disabled:opacity-50 peer-disabled:cursor-not-allowed"></div>
|
||||
<span className="ms-3 text-sm font-medium text-gray-900">
|
||||
{isTogglingEnabled ? (
|
||||
<span className="text-gray-400">Updating...</span>
|
||||
) : (
|
||||
<span
|
||||
className={
|
||||
trigger.data?.enabled ? "text-green-700" : "text-gray-700"
|
||||
}
|
||||
>
|
||||
{trigger.data?.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{/* Show edit and delete buttons for ad-hoc triggers (not from pack installation) */}
|
||||
{trigger.data?.is_adhoc && (
|
||||
<>
|
||||
<button
|
||||
onClick={() =>
|
||||
navigate(`/triggers/${encodeURIComponent(triggerRef)}/edit`)
|
||||
}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 inline-flex items-center gap-2"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={deleteTrigger.isPending}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md">
|
||||
<h3 className="text-xl font-bold mb-4">Confirm Delete</h3>
|
||||
<p className="mb-6">
|
||||
Are you sure you want to delete trigger{" "}
|
||||
<strong>
|
||||
{trigger.data?.pack_ref}.{trigger.data?.label}
|
||||
</strong>
|
||||
?
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Info Card */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Trigger Information</h2>
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Reference</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 font-mono">
|
||||
{trigger.data?.ref}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Label</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{trigger.data?.label}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Pack</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
<Link
|
||||
to={`/packs/${trigger.data?.pack_ref}`}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{trigger.data?.pack_ref}
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-gray-500">
|
||||
Description
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{trigger.data?.description || "No description provided"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Created</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{new Date(trigger.data?.created || "").toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Updated</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{new Date(trigger.data?.updated || "").toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{paramEntries.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">
|
||||
Parameters
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{paramEntries.map(([key, param]: [string, any]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="border border-gray-200 rounded p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono font-semibold text-sm">
|
||||
{key}
|
||||
</span>
|
||||
{requiredFields.includes(key) && (
|
||||
<span className="text-xs px-2 py-0.5 bg-red-100 text-red-700 rounded">
|
||||
Required
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs px-2 py-0.5 bg-gray-100 text-gray-700 rounded">
|
||||
{param?.type || "any"}
|
||||
</span>
|
||||
</div>
|
||||
{param?.description && (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{param.description}
|
||||
</p>
|
||||
)}
|
||||
{param?.default !== undefined && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Default:{" "}
|
||||
<code className="bg-gray-100 px-1 rounded">
|
||||
{JSON.stringify(param.default)}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Webhook URL (if enabled) */}
|
||||
{trigger.data?.webhook_enabled && trigger.data?.webhook_key && (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Webhook URL</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-gray-50 rounded border border-gray-200">
|
||||
<code className="text-xs break-all text-gray-700">
|
||||
{import.meta.env.VITE_API_BASE_URL ||
|
||||
window.location.origin}
|
||||
/webhooks/{trigger.data.webhook_key}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyWebhookUrl}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
{copiedWebhookUrl ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy URL
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500">
|
||||
Use this URL to send webhook events to this trigger.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
to={`/packs/${trigger.data?.pack_ref}`}
|
||||
className="block w-full px-4 py-2 text-sm text-center bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
View Pack
|
||||
</Link>
|
||||
<Link
|
||||
to={`/rules?trigger=${trigger.data?.ref}`}
|
||||
className="block w-full px-4 py-2 text-sm text-center bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
View Rules
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user