re-uploading work

This commit is contained in:
2026-02-04 17:46:30 -06:00
commit 3b14c65998
1388 changed files with 381262 additions and 0 deletions

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