re-uploading work
This commit is contained in:
717
web/src/pages/actions/ActionsPage.tsx
Normal file
717
web/src/pages/actions/ActionsPage.tsx
Normal file
@@ -0,0 +1,717 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useActions, useAction, useDeleteAction } from "@/hooks/useActions";
|
||||
import { useExecutions } from "@/hooks/useExecutions";
|
||||
import { useState, useMemo } from "react";
|
||||
import { ChevronDown, ChevronRight, Search, X, Play } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { OpenAPI } from "@/api";
|
||||
import ParamSchemaForm, {
|
||||
validateParamSchema,
|
||||
type ParamSchema,
|
||||
} from "@/components/common/ParamSchemaForm";
|
||||
import ErrorDisplay from "@/components/common/ErrorDisplay";
|
||||
|
||||
export default function ActionsPage() {
|
||||
const { ref } = useParams<{ ref?: string }>();
|
||||
const { data, isLoading, error } = useActions();
|
||||
const actions = data?.data || [];
|
||||
const [collapsedPacks, setCollapsedPacks] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Filter actions based on search query
|
||||
const filteredActions = useMemo(() => {
|
||||
if (!searchQuery.trim()) return actions;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return actions.filter((action: any) => {
|
||||
return (
|
||||
action.label?.toLowerCase().includes(query) ||
|
||||
action.ref?.toLowerCase().includes(query) ||
|
||||
action.description?.toLowerCase().includes(query) ||
|
||||
action.pack_ref?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [actions, searchQuery]);
|
||||
|
||||
// Group filtered actions by pack
|
||||
const actionsByPack = useMemo(() => {
|
||||
const grouped = new Map<string, any[]>();
|
||||
filteredActions.forEach((action: any) => {
|
||||
const packRef = action.pack_ref;
|
||||
if (!grouped.has(packRef)) {
|
||||
grouped.set(packRef, []);
|
||||
}
|
||||
grouped.get(packRef)!.push(action);
|
||||
});
|
||||
// Sort packs alphabetically
|
||||
return new Map(
|
||||
[...grouped.entries()].sort((a, b) => a[0].localeCompare(b[0])),
|
||||
);
|
||||
}, [filteredActions]);
|
||||
|
||||
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">
|
||||
<ErrorDisplay error={error} title="Failed to load actions" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)]">
|
||||
{/* Left sidebar - Actions 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">
|
||||
<h1 className="text-2xl font-bold">Actions</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{filteredActions.length} of {actions.length} actions
|
||||
</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 actions..."
|
||||
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">
|
||||
{actions.length === 0 ? (
|
||||
<div className="bg-white p-8 text-center rounded-lg shadow-sm m-2">
|
||||
<p className="text-gray-500">No actions found</p>
|
||||
</div>
|
||||
) : filteredActions.length === 0 ? (
|
||||
<div className="bg-white p-8 text-center rounded-lg shadow-sm m-2">
|
||||
<p className="text-gray-500">No actions 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(actionsByPack.entries()).map(
|
||||
([packRef, packActions]) => {
|
||||
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">
|
||||
{packActions.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Actions List */}
|
||||
{!isCollapsed && (
|
||||
<div className="p-1">
|
||||
{packActions.map((action: any) => (
|
||||
<Link
|
||||
key={action.id}
|
||||
to={`/actions/${action.ref}`}
|
||||
className={`block p-3 rounded transition-colors ${
|
||||
ref === action.ref
|
||||
? "bg-blue-50 border-2 border-blue-500"
|
||||
: "border-2 border-transparent hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm text-gray-900 truncate">
|
||||
{action.label}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-gray-500 mt-1 truncate">
|
||||
{action.ref}
|
||||
</div>
|
||||
{action.description && (
|
||||
<div className="text-xs text-gray-400 mt-1 line-clamp-2">
|
||||
{action.description}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel - Action Detail or Empty State */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{ref ? (
|
||||
<ActionDetail actionRef={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 action selected
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Select an action from the list to view its details
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionDetail({ actionRef }: { actionRef: string }) {
|
||||
const { data: action, isLoading, error } = useAction(actionRef);
|
||||
const { data: executionsData } = useExecutions({
|
||||
actionRef: actionRef,
|
||||
pageSize: 10,
|
||||
});
|
||||
const deleteAction = useDeleteAction();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showExecuteModal, setShowExecuteModal] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteAction.mutateAsync(actionRef);
|
||||
// Navigate back to actions list without selection
|
||||
window.location.href = "/actions";
|
||||
} catch (err) {
|
||||
console.error("Failed to delete action:", 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 || !action) {
|
||||
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 : "Action not found"}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const executions = executionsData?.data || [];
|
||||
const paramSchema = action.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">{action.data?.pack_ref}.</span>
|
||||
{action.data?.label}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowExecuteModal(true)}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 flex items-center gap-2"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
Execute
|
||||
</button>
|
||||
{/* Only show delete button for ad-hoc actions (not from pack installation) */}
|
||||
{action.data?.is_adhoc && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={deleteAction.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 action{" "}
|
||||
<strong>
|
||||
{action.data?.pack_ref}.{action.data?.label}
|
||||
</strong>
|
||||
? This will also delete all associated executions.
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Execute Action Modal */}
|
||||
{showExecuteModal && (
|
||||
<ExecuteActionModal
|
||||
action={action.data!}
|
||||
onClose={() => setShowExecuteModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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">Action 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">
|
||||
{action.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">
|
||||
{action.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/${action.data?.pack_ref}`}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{action.data?.pack_ref}
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">
|
||||
Entry Point
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 font-mono">
|
||||
{action.data?.entrypoint}
|
||||
</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">
|
||||
{action.data?.description || "No description provided"}
|
||||
</dd>
|
||||
</div>
|
||||
{action.data?.runtime && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Runtime</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
Runtime #{action.data.runtime}
|
||||
</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(action.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(action.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>
|
||||
)}
|
||||
{param?.enum && param.enum.length > 0 && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Values:{" "}
|
||||
{param.enum.map((v: any) => `"${v}"`).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action.data?.out_schema &&
|
||||
Object.keys(action.data.out_schema).length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">
|
||||
Output Schema
|
||||
</h3>
|
||||
<pre className="bg-gray-50 p-3 rounded text-xs overflow-x-auto">
|
||||
{JSON.stringify(action.data.out_schema, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Executions */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold">
|
||||
Recent Executions ({executions.length})
|
||||
</h2>
|
||||
<Link
|
||||
to={`/executions?action_ref=${action.data?.ref}`}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
View All →
|
||||
</Link>
|
||||
</div>
|
||||
{executions.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
No executions yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{executions.map((execution: any) => (
|
||||
<Link
|
||||
key={execution.id}
|
||||
to={`/executions/${execution.id}`}
|
||||
className="block p-3 border border-gray-200 rounded hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-mono text-gray-600">
|
||||
#{execution.id}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
execution.status === "Completed"
|
||||
? "bg-green-100 text-green-800"
|
||||
: execution.status === "Failed"
|
||||
? "bg-red-100 text-red-800"
|
||||
: execution.status === "Running"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{execution.status}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(execution.created).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Quick Stats */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Statistics</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">Total Executions</span>
|
||||
<span className="text-lg font-semibold">
|
||||
{executionsData?.pagination?.total_items || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">Recent</span>
|
||||
<span className="text-lg font-semibold">
|
||||
{executions.length}
|
||||
</span>
|
||||
</div>
|
||||
</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/${action.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?action=${action.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>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecuteActionModal({
|
||||
action,
|
||||
onClose,
|
||||
}: {
|
||||
action: any;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Initialize parameters with default values from schema
|
||||
const paramSchema: ParamSchema = (action.param_schema as ParamSchema) || {};
|
||||
|
||||
const [parameters, setParameters] = useState<Record<string, any>>({});
|
||||
const [paramErrors, setParamErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const executeAction = useMutation({
|
||||
mutationFn: async (params: Record<string, any>) => {
|
||||
// Get the token by calling the TOKEN function
|
||||
const token =
|
||||
typeof OpenAPI.TOKEN === "function"
|
||||
? await OpenAPI.TOKEN({} as any)
|
||||
: OpenAPI.TOKEN;
|
||||
|
||||
const response = await fetch(
|
||||
`${OpenAPI.BASE}/api/v1/executions/execute`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action_ref: action.ref,
|
||||
parameters: params,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || "Failed to execute action");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["executions"] });
|
||||
onClose();
|
||||
// Redirect to execution detail page
|
||||
if (data?.data?.id) {
|
||||
window.location.href = `/executions/${data.data.id}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors = validateParamSchema(paramSchema, parameters);
|
||||
setParamErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await executeAction.mutateAsync(parameters);
|
||||
} catch (err) {
|
||||
console.error("Failed to execute action:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-bold">Execute Action</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Action:{" "}
|
||||
<span className="font-mono text-gray-900">{action.ref}</span>
|
||||
</p>
|
||||
{action.description && (
|
||||
<p className="text-sm text-gray-600 mt-1">{action.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{executeAction.error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
|
||||
{(executeAction.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<ParamSchemaForm
|
||||
schema={paramSchema}
|
||||
values={parameters}
|
||||
onChange={setParameters}
|
||||
errors={paramErrors}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={executeAction.isPending}
|
||||
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={executeAction.isPending}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{executeAction.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||
Executing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
Execute
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
web/src/pages/auth/LoginPage.tsx
Normal file
126
web/src/pages/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [login, setLogin] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login: authLogin } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Check for redirect path from session storage (set by axios interceptor on 401)
|
||||
const redirectPath = sessionStorage.getItem("redirect_after_login");
|
||||
const from = redirectPath || (location.state as any)?.from?.pathname || "/";
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await authLogin({ login, password });
|
||||
|
||||
// Clear the redirect path from session storage
|
||||
sessionStorage.removeItem("redirect_after_login");
|
||||
|
||||
navigate(from, { replace: true });
|
||||
} catch (err: any) {
|
||||
console.error("Login error:", err);
|
||||
console.error("Full error object:", JSON.stringify(err, null, 2));
|
||||
if (err.response) {
|
||||
console.error("Response status:", err.response.status);
|
||||
console.error("Response data:", err.response.data);
|
||||
}
|
||||
const errorMessage =
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
"Login failed. Please check your credentials.";
|
||||
setError(errorMessage);
|
||||
// Don't navigate on error - stay on login page
|
||||
setIsLoading(false);
|
||||
return;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h1 className="text-center text-4xl font-bold text-gray-900">
|
||||
Attune
|
||||
</h1>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
</div>
|
||||
<form
|
||||
className="mt-8 space-y-6"
|
||||
onSubmit={handleSubmit}
|
||||
action="#"
|
||||
method="post"
|
||||
>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">{error}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="login" className="sr-only">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="login"
|
||||
name="login"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Username"
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
316
web/src/pages/dashboard/DashboardPage.tsx
Normal file
316
web/src/pages/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { usePacks } from "@/hooks/usePacks";
|
||||
import { useActions } from "@/hooks/useActions";
|
||||
import { useRules } from "@/hooks/useRules";
|
||||
import { useExecutions } from "@/hooks/useExecutions";
|
||||
import { useExecutionStream } from "@/hooks/useExecutionStream";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ExecutionStatus } from "@/api";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
// Fetch metrics data
|
||||
const { data: packsData, isLoading: packsLoading } = usePacks({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
});
|
||||
const { data: actionsData, isLoading: actionsLoading } = useActions({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
});
|
||||
const { data: rulesData, isLoading: rulesLoading } = useRules({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
enabled: true,
|
||||
});
|
||||
const { data: executionsData, isLoading: executionsLoading } = useExecutions({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
const { data: runningExecutions } = useExecutions({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
status: ExecutionStatus.RUNNING,
|
||||
});
|
||||
|
||||
// Subscribe to real-time execution updates
|
||||
// The hook automatically invalidates queries when updates arrive
|
||||
const { isConnected } = useExecutionStream();
|
||||
|
||||
// Calculate metrics
|
||||
const totalPacks = packsData?.pagination?.total_items || 0;
|
||||
const totalActions = actionsData?.pagination?.total_items || 0;
|
||||
const activeRules = rulesData?.pagination?.total_items || 0;
|
||||
const runningCount = runningExecutions?.pagination?.total_items || 0;
|
||||
|
||||
// Calculate status distribution
|
||||
const statusDistribution = useMemo(() => {
|
||||
if (!executionsData?.data) return {};
|
||||
|
||||
const distribution: Record<ExecutionStatus, number> = {
|
||||
[ExecutionStatus.REQUESTED]: 0,
|
||||
[ExecutionStatus.SCHEDULING]: 0,
|
||||
[ExecutionStatus.SCHEDULED]: 0,
|
||||
[ExecutionStatus.RUNNING]: 0,
|
||||
[ExecutionStatus.COMPLETED]: 0,
|
||||
[ExecutionStatus.FAILED]: 0,
|
||||
[ExecutionStatus.CANCELING]: 0,
|
||||
[ExecutionStatus.CANCELLED]: 0,
|
||||
[ExecutionStatus.TIMEOUT]: 0,
|
||||
[ExecutionStatus.ABANDONED]: 0,
|
||||
};
|
||||
|
||||
executionsData.data.forEach((execution) => {
|
||||
distribution[execution.status] =
|
||||
(distribution[execution.status] || 0) + 1;
|
||||
});
|
||||
|
||||
return distribution;
|
||||
}, [executionsData]);
|
||||
|
||||
// Calculate success rate
|
||||
const successRate = useMemo(() => {
|
||||
if (!executionsData?.data || executionsData.data.length === 0) return 0;
|
||||
|
||||
const completed = executionsData.data.filter(
|
||||
(e) =>
|
||||
e.status === ExecutionStatus.COMPLETED ||
|
||||
e.status === ExecutionStatus.FAILED ||
|
||||
e.status === ExecutionStatus.TIMEOUT,
|
||||
);
|
||||
|
||||
if (completed.length === 0) return 0;
|
||||
|
||||
const succeeded = completed.filter(
|
||||
(e) => e.status === ExecutionStatus.COMPLETED,
|
||||
).length;
|
||||
return Math.round((succeeded / completed.length) * 100);
|
||||
}, [executionsData]);
|
||||
|
||||
// Format timestamp
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
if (diff < 60000) return "just now";
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
// Get status color
|
||||
const getStatusColor = (status: ExecutionStatus) => {
|
||||
switch (status) {
|
||||
case ExecutionStatus.COMPLETED:
|
||||
return "text-green-600 bg-green-50";
|
||||
case ExecutionStatus.FAILED:
|
||||
case ExecutionStatus.TIMEOUT:
|
||||
return "text-red-600 bg-red-50";
|
||||
case ExecutionStatus.RUNNING:
|
||||
return "text-blue-600 bg-blue-50";
|
||||
case ExecutionStatus.REQUESTED:
|
||||
case ExecutionStatus.SCHEDULING:
|
||||
case ExecutionStatus.SCHEDULED:
|
||||
return "text-yellow-600 bg-yellow-50";
|
||||
case ExecutionStatus.CANCELLED:
|
||||
case ExecutionStatus.CANCELING:
|
||||
return "text-gray-600 bg-gray-50";
|
||||
case ExecutionStatus.ABANDONED:
|
||||
return "text-purple-600 bg-purple-50";
|
||||
default:
|
||||
return "text-gray-600 bg-gray-50";
|
||||
}
|
||||
};
|
||||
|
||||
// Get status display name
|
||||
const getStatusDisplay = (status: ExecutionStatus): string => {
|
||||
return status;
|
||||
};
|
||||
|
||||
const isLoading =
|
||||
packsLoading || actionsLoading || rulesLoading || executionsLoading;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<p className="text-gray-600">Welcome back, {user?.login || "User"}</p>
|
||||
{isConnected && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-green-600">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||
Live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<Link
|
||||
to="/packs"
|
||||
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-600">Total Packs</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">
|
||||
{isLoading ? "—" : totalPacks}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">View all packs →</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/rules"
|
||||
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-600">Active Rules</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">
|
||||
{isLoading ? "—" : activeRules}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Manage rules →</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/executions"
|
||||
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
Running Executions
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-blue-600 mt-1">
|
||||
{isLoading ? "—" : runningCount}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">View executions →</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/actions"
|
||||
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-600">Total Actions</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">
|
||||
{isLoading ? "—" : totalActions}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Browse actions →</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Status Overview & Recent Activity */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Status Distribution */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Execution Status
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<p className="text-gray-500 text-center py-8">Loading...</p>
|
||||
) : executionsData?.data && executionsData.data.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(statusDistribution).map(([status, count]) => {
|
||||
const countNum = typeof count === "number" ? count : 0;
|
||||
if (countNum === 0) return null;
|
||||
const percentage = executionsData?.data
|
||||
? Math.round((countNum / executionsData.data.length) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div key={status}>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-700">{status}</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{countNum}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${
|
||||
status === ExecutionStatus.COMPLETED
|
||||
? "bg-green-500"
|
||||
: status === ExecutionStatus.FAILED ||
|
||||
status === ExecutionStatus.TIMEOUT
|
||||
? "bg-red-500"
|
||||
: status === ExecutionStatus.RUNNING
|
||||
? "bg-blue-500"
|
||||
: "bg-gray-400"
|
||||
}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Success Rate */}
|
||||
<div className="pt-3 mt-3 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700">Success Rate</span>
|
||||
<span className="text-lg font-bold text-gray-900">
|
||||
{successRate}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No executions yet</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white rounded-lg shadow p-6 lg:col-span-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Recent Activity
|
||||
</h2>
|
||||
<Link
|
||||
to="/executions"
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-gray-500 text-center py-8">Loading...</p>
|
||||
) : executionsData?.data && executionsData.data.length > 0 ? (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{executionsData.data.map((execution) => (
|
||||
<Link
|
||||
key={execution.id}
|
||||
to={`/executions/${execution.id}`}
|
||||
className="block p-3 rounded-lg border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 truncate">
|
||||
{execution.action_ref}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${getStatusColor(
|
||||
execution.status,
|
||||
)}`}
|
||||
>
|
||||
{getStatusDisplay(execution.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
|
||||
<span>ID: {execution.id}</span>
|
||||
<span>•</span>
|
||||
<span>{formatTime(execution.created)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">No recent activity</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
381
web/src/pages/enforcements/EnforcementDetailPage.tsx
Normal file
381
web/src/pages/enforcements/EnforcementDetailPage.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useEnforcement } from "@/hooks/useEvents";
|
||||
import { EnforcementStatus, EnforcementCondition } from "@/api";
|
||||
|
||||
export default function EnforcementDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const enforcementId = id ? parseInt(id, 10) : 0;
|
||||
|
||||
const {
|
||||
data: enforcementData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useEnforcement(enforcementId);
|
||||
const enforcement = enforcementData?.data;
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const getStatusColor = (status: EnforcementStatus) => {
|
||||
switch (status) {
|
||||
case EnforcementStatus.PROCESSED:
|
||||
return "bg-green-100 text-green-800";
|
||||
case EnforcementStatus.DISABLED:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
case EnforcementStatus.CREATED:
|
||||
return "bg-blue-100 text-blue-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const getConditionColor = (condition: EnforcementCondition) => {
|
||||
switch (condition) {
|
||||
case EnforcementCondition.ALL:
|
||||
return "bg-purple-100 text-purple-800";
|
||||
case EnforcementCondition.ANY:
|
||||
return "bg-indigo-100 text-indigo-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !enforcement) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-red-900 mb-2">
|
||||
Failed to load enforcement
|
||||
</h3>
|
||||
<p className="text-red-700">
|
||||
{error instanceof Error ? error.message : "Enforcement not found"}
|
||||
</p>
|
||||
<Link
|
||||
to="/enforcements"
|
||||
className="inline-block mt-4 text-red-600 hover:text-red-800"
|
||||
>
|
||||
← Back to Enforcements
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to="/enforcements"
|
||||
className="text-sm text-blue-600 hover:text-blue-800 mb-2 inline-block"
|
||||
>
|
||||
← Back to Enforcements
|
||||
</Link>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Enforcement #{enforcement.id}
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">Rule: {enforcement.rule_ref}</p>
|
||||
<div className="flex items-center gap-4 mt-3">
|
||||
<span
|
||||
className={`px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(enforcement.status)}`}
|
||||
>
|
||||
{enforcement.status}
|
||||
</span>
|
||||
<span
|
||||
className={`px-3 py-1 text-sm font-semibold rounded-full ${getConditionColor(enforcement.condition)}`}
|
||||
>
|
||||
Condition: {enforcement.condition}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Main Info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Overview Card */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Overview</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<dl className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Rule</dt>
|
||||
<dd className="mt-1">
|
||||
{enforcement.rule ? (
|
||||
<Link
|
||||
to={`/rules/${enforcement.rule}`}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{enforcement.rule_ref}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-gray-900">
|
||||
{enforcement.rule_ref}
|
||||
</span>
|
||||
)}
|
||||
{enforcement.rule && (
|
||||
<span className="ml-2 text-sm text-gray-500">
|
||||
(ID: {enforcement.rule})
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Trigger</dt>
|
||||
<dd className="mt-1 text-gray-900">
|
||||
{enforcement.trigger_ref}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Event</dt>
|
||||
<dd className="mt-1">
|
||||
{enforcement.event ? (
|
||||
<Link
|
||||
to={`/events/${enforcement.event}`}
|
||||
className="text-blue-600 hover:text-blue-800 font-mono"
|
||||
>
|
||||
#{enforcement.event}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-gray-500">No event associated</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Status</dt>
|
||||
<dd className="mt-1">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(enforcement.status)}`}
|
||||
>
|
||||
{enforcement.status}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">
|
||||
Condition Type
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${getConditionColor(enforcement.condition)}`}
|
||||
>
|
||||
{enforcement.condition}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">
|
||||
Created At
|
||||
</dt>
|
||||
<dd className="mt-1 text-gray-900">
|
||||
{formatDate(enforcement.created)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conditions Card */}
|
||||
{enforcement.conditions &&
|
||||
Object.keys(enforcement.conditions).length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Rule Conditions
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Evaluation criteria that must be met for this enforcement
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<pre className="text-sm bg-gray-50 rounded-lg p-4 overflow-x-auto">
|
||||
{JSON.stringify(enforcement.conditions, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configuration Card */}
|
||||
{enforcement.config && Object.keys(enforcement.config).length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Enforcement Configuration
|
||||
</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<pre className="text-sm bg-gray-50 rounded-lg p-4 overflow-x-auto">
|
||||
{JSON.stringify(enforcement.config, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payload Card */}
|
||||
{enforcement.payload &&
|
||||
Object.keys(enforcement.payload).length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Payload
|
||||
</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<pre className="text-sm bg-gray-50 rounded-lg p-4 overflow-x-auto">
|
||||
{JSON.stringify(enforcement.payload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Quick Links and Info */}
|
||||
<div className="space-y-6">
|
||||
{/* Quick Links */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Quick Links
|
||||
</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-2">
|
||||
{enforcement.rule && (
|
||||
<Link
|
||||
to={`/rules/${enforcement.rule}`}
|
||||
className="block text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
→ View Rule: {enforcement.rule_ref}
|
||||
</Link>
|
||||
)}
|
||||
{enforcement.event && (
|
||||
<Link
|
||||
to={`/events/${enforcement.event}`}
|
||||
className="block text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
→ View Event #{enforcement.event}
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to={`/triggers/${enforcement.trigger_ref}`}
|
||||
className="block text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
→ View Trigger: {enforcement.trigger_ref}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/executions?enforcement=${enforcement.id}`}
|
||||
className="block text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
→ View Related Executions
|
||||
</Link>
|
||||
<Link
|
||||
to={`/enforcements?rule=${enforcement.rule}`}
|
||||
className="block text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
→ View Similar Enforcements
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Metadata</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-gray-500">Enforcement ID</dt>
|
||||
<dd className="text-gray-900 font-mono">{enforcement.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Rule ID</dt>
|
||||
<dd className="text-gray-900 font-mono">
|
||||
{enforcement.rule || "N/A"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Rule Reference</dt>
|
||||
<dd className="text-gray-900">{enforcement.rule_ref}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Event ID</dt>
|
||||
<dd className="text-gray-900 font-mono">
|
||||
{enforcement.event || "N/A"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Trigger Reference</dt>
|
||||
<dd className="text-gray-900">{enforcement.trigger_ref}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Created</dt>
|
||||
<dd className="text-gray-900">
|
||||
{formatDate(enforcement.created)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Information */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
About Enforcements
|
||||
</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<div className="text-sm text-gray-700 space-y-3">
|
||||
<p>
|
||||
An enforcement represents the activation of a rule in response
|
||||
to a trigger event. It tracks the conditions that were
|
||||
evaluated and the parameters used for execution.
|
||||
</p>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded p-3">
|
||||
<p className="font-medium text-blue-900 mb-1">
|
||||
Condition Type: {enforcement.condition}
|
||||
</p>
|
||||
<p className="text-xs text-blue-700">
|
||||
{enforcement.condition === EnforcementCondition.ALL
|
||||
? "All conditions must be satisfied for this enforcement to execute."
|
||||
: "Any condition can be satisfied for this enforcement to execute."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded p-3">
|
||||
<p className="font-medium text-gray-900 mb-1">
|
||||
Status: {enforcement.status}
|
||||
</p>
|
||||
<p className="text-xs text-gray-700">
|
||||
{enforcement.status === EnforcementStatus.CREATED &&
|
||||
"This enforcement has been created and is awaiting processing."}
|
||||
{enforcement.status === EnforcementStatus.PROCESSED &&
|
||||
"This enforcement has been processed and actions have been executed."}
|
||||
{enforcement.status === EnforcementStatus.DISABLED &&
|
||||
"This enforcement has been disabled and will not trigger actions."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
447
web/src/pages/enforcements/EnforcementsPage.tsx
Normal file
447
web/src/pages/enforcements/EnforcementsPage.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useEnforcements } from "@/hooks/useEvents";
|
||||
import { useEnforcementStream } from "@/hooks/useEnforcementStream";
|
||||
import { EnforcementStatus } from "@/api";
|
||||
import { useState, useMemo, memo, useCallback, useEffect } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import MultiSelect from "@/components/common/MultiSelect";
|
||||
|
||||
// Memoized filter input component to prevent re-render on WebSocket updates
|
||||
const FilterInput = memo(
|
||||
({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
FilterInput.displayName = "FilterInput";
|
||||
|
||||
// Status options moved outside component to prevent recreation
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: EnforcementStatus.CREATED, label: "Created" },
|
||||
{ value: EnforcementStatus.PROCESSED, label: "Processed" },
|
||||
{ value: EnforcementStatus.DISABLED, label: "Disabled" },
|
||||
];
|
||||
|
||||
export default function EnforcementsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
const [searchFilters, setSearchFilters] = useState({
|
||||
rule: "",
|
||||
trigger: "",
|
||||
event: "",
|
||||
});
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
|
||||
|
||||
// Debounced filter state for API calls
|
||||
const [debouncedFilters, setDebouncedFilters] = useState(searchFilters);
|
||||
const [debouncedStatuses, setDebouncedStatuses] = useState(selectedStatuses);
|
||||
|
||||
// Debounce filter changes (500ms delay)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedFilters(searchFilters);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchFilters]);
|
||||
|
||||
// Debounce status changes (300ms delay - shorter since it's a selection)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedStatuses(selectedStatuses);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [selectedStatuses]);
|
||||
|
||||
const queryParams = useMemo(() => {
|
||||
const params: any = { page, pageSize };
|
||||
if (debouncedFilters.trigger) params.triggerRef = debouncedFilters.trigger;
|
||||
if (debouncedFilters.event) {
|
||||
const eventId = parseInt(debouncedFilters.event, 10);
|
||||
if (!isNaN(eventId)) {
|
||||
params.event = eventId;
|
||||
}
|
||||
}
|
||||
|
||||
// Include status filter if exactly one status is selected
|
||||
// API only supports single status, so we use the first one for filtering
|
||||
// and show all results if multiple are selected
|
||||
if (debouncedStatuses.length === 1) {
|
||||
params.status = debouncedStatuses[0] as EnforcementStatus;
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [page, pageSize, debouncedFilters, debouncedStatuses]);
|
||||
|
||||
const { data, isLoading, error } = useEnforcements(queryParams);
|
||||
|
||||
// Subscribe to real-time updates for all enforcements
|
||||
const { isConnected } = useEnforcementStream({ enabled: true });
|
||||
|
||||
const enforcements = data?.data || [];
|
||||
const total = data?.pagination?.total_items || 0;
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
// Client-side filtering for multiple status selection and rule_ref (when > 1 status selected)
|
||||
const filteredEnforcements = useMemo(() => {
|
||||
let filtered = enforcements;
|
||||
|
||||
// Filter by rule_ref (client-side since API doesn't support it)
|
||||
if (debouncedFilters.rule) {
|
||||
filtered = filtered.filter((enf: any) =>
|
||||
enf.rule_ref
|
||||
.toLowerCase()
|
||||
.includes(debouncedFilters.rule.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
// If multiple statuses selected, filter client-side
|
||||
if (debouncedStatuses.length > 1) {
|
||||
filtered = filtered.filter((enf: any) =>
|
||||
debouncedStatuses.includes(enf.status),
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [enforcements, debouncedFilters.rule, debouncedStatuses]);
|
||||
|
||||
const handleFilterChange = useCallback((field: string, value: string) => {
|
||||
setSearchFilters((prev) => ({ ...prev, [field]: value }));
|
||||
setPage(1); // Reset to first page on filter change
|
||||
}, []);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setSearchFilters({
|
||||
rule: "",
|
||||
trigger: "",
|
||||
event: "",
|
||||
});
|
||||
setSelectedStatuses([]);
|
||||
setPage(1); // Reset to first page
|
||||
}, []);
|
||||
|
||||
const hasActiveFilters =
|
||||
Object.values(searchFilters).some((v) => v !== "") ||
|
||||
selectedStatuses.length > 0;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusColor = (status: EnforcementStatus) => {
|
||||
switch (status) {
|
||||
case EnforcementStatus.PROCESSED:
|
||||
return "bg-green-100 text-green-800";
|
||||
case EnforcementStatus.DISABLED:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
case EnforcementStatus.CREATED:
|
||||
return "bg-blue-100 text-blue-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const getConditionBadge = (condition: string) => {
|
||||
const colors = {
|
||||
all: "bg-purple-100 text-purple-800",
|
||||
any: "bg-indigo-100 text-indigo-800",
|
||||
};
|
||||
return (
|
||||
colors[condition as keyof typeof colors] || "bg-gray-100 text-gray-800"
|
||||
);
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
if (diff < 60000) return "just now";
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Enforcements</h1>
|
||||
{isLoading && hasActiveFilters && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Searching enforcements...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isConnected && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
||||
<div className="h-2 w-2 rounded-full bg-green-600 animate-pulse" />
|
||||
<span>Live Updates</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Filters */}
|
||||
<div className="bg-white shadow rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
<h2 className="text-lg font-semibold">Filter Enforcements</h2>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<FilterInput
|
||||
label="Rule"
|
||||
value={searchFilters.rule}
|
||||
onChange={(value) => handleFilterChange("rule", value)}
|
||||
placeholder="e.g., core.on_timer"
|
||||
/>
|
||||
<FilterInput
|
||||
label="Trigger"
|
||||
value={searchFilters.trigger}
|
||||
onChange={(value) => handleFilterChange("trigger", value)}
|
||||
placeholder="e.g., core.webhook"
|
||||
/>
|
||||
<FilterInput
|
||||
label="Event ID"
|
||||
value={searchFilters.event}
|
||||
onChange={(value) => handleFilterChange("event", value)}
|
||||
placeholder="e.g., 123"
|
||||
/>
|
||||
<div>
|
||||
<MultiSelect
|
||||
label="Status"
|
||||
options={STATUS_OPTIONS}
|
||||
value={selectedStatuses}
|
||||
onChange={setSelectedStatuses}
|
||||
placeholder="All Statuses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredEnforcements.length === 0 ? (
|
||||
<div className="bg-white p-12 text-center rounded-lg shadow">
|
||||
<p>
|
||||
{enforcements.length === 0
|
||||
? "No enforcements found"
|
||||
: "No enforcements match the selected filters"}
|
||||
</p>
|
||||
{enforcements.length > 0 && hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="mt-3 text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Rule
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Trigger
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Event
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Condition
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Created
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredEnforcements.map((enforcement: any) => (
|
||||
<tr key={enforcement.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 font-mono text-sm">
|
||||
<Link
|
||||
to={`/enforcements/${enforcement.id}`}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
#{enforcement.id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{enforcement.rule ? (
|
||||
<Link
|
||||
to={`/rules/${enforcement.rule}`}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{enforcement.rule_ref}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm text-gray-900">
|
||||
{enforcement.rule_ref}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-sm text-gray-700">
|
||||
{enforcement.trigger_ref}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{enforcement.event ? (
|
||||
<Link
|
||||
to={`/events/${enforcement.event}`}
|
||||
className="text-sm font-mono text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
#{enforcement.event}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 italic">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded ${getConditionBadge(enforcement.condition)}`}
|
||||
>
|
||||
{enforcement.condition}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded ${getStatusColor(enforcement.status)}`}
|
||||
>
|
||||
{enforcement.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-900">
|
||||
{formatTime(enforcement.created)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatDate(enforcement.created)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between border-t border-gray-200">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing{" "}
|
||||
<span className="font-medium">
|
||||
{(page - 1) * pageSize + 1}
|
||||
</span>{" "}
|
||||
to{" "}
|
||||
<span className="font-medium">
|
||||
{Math.min(page * pageSize, total)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium">{total}</span> enforcements
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
263
web/src/pages/events/EventDetailPage.tsx
Normal file
263
web/src/pages/events/EventDetailPage.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useEvent } from "@/hooks/useEvents";
|
||||
|
||||
export default function EventDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const eventId = id ? parseInt(id, 10) : 0;
|
||||
|
||||
const { data: eventData, isLoading, error } = useEvent(eventId);
|
||||
const event = eventData?.data;
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !event) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-red-900 mb-2">
|
||||
Failed to load event
|
||||
</h3>
|
||||
<p className="text-red-700">
|
||||
{error instanceof Error ? error.message : "Event not found"}
|
||||
</p>
|
||||
<Link
|
||||
to="/events"
|
||||
className="inline-block mt-4 text-red-600 hover:text-red-800"
|
||||
>
|
||||
← Back to Events
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to="/events"
|
||||
className="text-sm text-blue-600 hover:text-blue-800 mb-2 inline-block"
|
||||
>
|
||||
← Back to Events
|
||||
</Link>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Event #{event.id}
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">Trigger: {event.trigger_ref}</p>
|
||||
<div className="flex items-center gap-4 mt-3 text-sm text-gray-500">
|
||||
<span>ID: {event.id}</span>
|
||||
<span>•</span>
|
||||
<span>Created: {formatDate(event.created)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Main Info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Overview Card */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Overview</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<dl className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Trigger</dt>
|
||||
<dd className="mt-1">
|
||||
<Link
|
||||
to={`/triggers/${event.trigger_ref}`}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{event.trigger_ref}
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Source</dt>
|
||||
<dd className="mt-1 text-gray-900">
|
||||
{event.source_ref || "N/A"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Rule</dt>
|
||||
<dd className="mt-1">
|
||||
{event.rule_ref ? (
|
||||
<Link
|
||||
to={`/rules/${event.rule}`}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{event.rule_ref}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-gray-500">No rule associated</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">
|
||||
Created At
|
||||
</dt>
|
||||
<dd className="mt-1 text-gray-900">
|
||||
{formatDate(event.created)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payload Card */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Event Payload
|
||||
</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
{event.payload && Object.keys(event.payload).length > 0 ? (
|
||||
<pre className="text-sm bg-gray-50 rounded-lg p-4 overflow-x-auto">
|
||||
{JSON.stringify(event.payload, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No payload data</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Quick Links and Info */}
|
||||
<div className="space-y-6">
|
||||
{/* Quick Links */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Quick Links
|
||||
</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-2">
|
||||
<Link
|
||||
to={`/triggers/${event.trigger_ref}`}
|
||||
className="block text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
→ View Trigger: {event.trigger_ref}
|
||||
</Link>
|
||||
{event.rule_ref && (
|
||||
<Link
|
||||
to={`/rules/${event.rule}`}
|
||||
className="block text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
→ View Rule: {event.rule_ref}
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to={`/enforcements?event=${event.id}`}
|
||||
className="block text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
→ View Enforcements
|
||||
</Link>
|
||||
<Link
|
||||
to={`/events?trigger_ref=${event.trigger_ref}`}
|
||||
className="block text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
→ View Similar Events
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Metadata</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-gray-500">Event ID</dt>
|
||||
<dd className="text-gray-900 font-mono">{event.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Trigger</dt>
|
||||
<dd className="text-gray-900 font-mono">
|
||||
{event.trigger || "N/A"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Trigger Reference</dt>
|
||||
<dd className="text-gray-900">{event.trigger_ref}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Source</dt>
|
||||
<dd className="text-gray-900">{event.source_ref || "N/A"}</dd>
|
||||
</div>
|
||||
{event.rule_ref && (
|
||||
<>
|
||||
<div>
|
||||
<dt className="text-gray-500">Rule ID</dt>
|
||||
<dd className="text-gray-900 font-mono">{event.rule}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Rule Reference</dt>
|
||||
<dd className="text-gray-900">{event.rule_ref}</dd>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-gray-500">Created</dt>
|
||||
<dd className="text-gray-900">{formatDate(event.created)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Statistics
|
||||
</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<div className="text-sm text-gray-700">
|
||||
<p className="mb-2">
|
||||
This event was generated by the{" "}
|
||||
<span className="font-medium">{event.trigger_ref}</span>{" "}
|
||||
trigger
|
||||
{event.rule_ref && (
|
||||
<>
|
||||
{" "}
|
||||
from rule{" "}
|
||||
<span className="font-medium">{event.rule_ref}</span>
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{event.rule_ref
|
||||
? "This event is associated with a specific rule and will only trigger that rule's actions."
|
||||
: "Check the enforcements to see if any rules were activated by this event."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
368
web/src/pages/events/EventsPage.tsx
Normal file
368
web/src/pages/events/EventsPage.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEvents } from "@/hooks/useEvents";
|
||||
import {
|
||||
useEntityNotifications,
|
||||
Notification,
|
||||
} from "@/contexts/WebSocketContext";
|
||||
import type { EventSummary } from "@/api";
|
||||
|
||||
export default function EventsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [triggerFilter, setTriggerFilter] = useState<string>("");
|
||||
const pageSize = 50;
|
||||
|
||||
// Set up WebSocket for real-time event updates with stable callback
|
||||
const handleEventNotification = useCallback(
|
||||
(notification: Notification) => {
|
||||
// Extract event data from notification payload
|
||||
if (
|
||||
notification.notification_type === "event_created" &&
|
||||
notification.payload
|
||||
) {
|
||||
const eventData = (notification.payload as any).data;
|
||||
|
||||
if (eventData) {
|
||||
// Create EventSummary from notification data
|
||||
const newEvent: EventSummary = {
|
||||
id: eventData.id,
|
||||
trigger: eventData.trigger,
|
||||
trigger_ref: eventData.trigger_ref,
|
||||
rule: eventData.rule,
|
||||
rule_ref: eventData.rule_ref,
|
||||
source: eventData.source,
|
||||
source_ref: eventData.source_ref,
|
||||
has_payload:
|
||||
eventData.payload !== null && eventData.payload !== undefined,
|
||||
created: eventData.created,
|
||||
};
|
||||
|
||||
// Update the query cache directly instead of invalidating
|
||||
queryClient.setQueryData(
|
||||
[
|
||||
"events",
|
||||
{ page, pageSize, triggerRef: triggerFilter || undefined },
|
||||
],
|
||||
(oldData: any) => {
|
||||
if (!oldData) return oldData;
|
||||
|
||||
// Check if filtering and event matches filter
|
||||
if (triggerFilter && newEvent.trigger_ref !== triggerFilter) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
// Add new event to the beginning of the list if on first page
|
||||
if (page === 1) {
|
||||
return {
|
||||
...oldData,
|
||||
data: [newEvent, ...oldData.data].slice(0, pageSize),
|
||||
pagination: {
|
||||
...oldData.pagination,
|
||||
total_items: (oldData.pagination?.total_items || 0) + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// For other pages, just update the total count
|
||||
return {
|
||||
...oldData,
|
||||
pagination: {
|
||||
...oldData.pagination,
|
||||
total_items: (oldData.pagination?.total_items || 0) + 1,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[queryClient, page, pageSize, triggerFilter],
|
||||
);
|
||||
|
||||
const { connected: wsConnected } = useEntityNotifications(
|
||||
"event",
|
||||
handleEventNotification,
|
||||
);
|
||||
|
||||
const { data, isLoading, error } = useEvents({
|
||||
page,
|
||||
pageSize,
|
||||
triggerRef: triggerFilter || undefined,
|
||||
});
|
||||
|
||||
const events = data?.data || [];
|
||||
const total = data?.pagination?.total_items || 0;
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
if (diff < 60000) return "just now";
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const totalPages = total ? Math.ceil(total / pageSize) : 0;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Events</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Event instances generated by sensors and triggers
|
||||
</p>
|
||||
</div>
|
||||
{wsConnected && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
||||
<div className="w-2 h-2 bg-green-600 rounded-full animate-pulse"></div>
|
||||
<span>Live updates</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow mb-6 p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div className="flex-1 max-w-md">
|
||||
<label
|
||||
htmlFor="trigger-filter"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Filter by Trigger
|
||||
</label>
|
||||
<input
|
||||
id="trigger-filter"
|
||||
type="text"
|
||||
value={triggerFilter}
|
||||
onChange={(e) => {
|
||||
setTriggerFilter(e.target.value);
|
||||
setPage(1); // Reset to first page on filter change
|
||||
}}
|
||||
placeholder="e.g., core.webhook"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{triggerFilter && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setTriggerFilter("");
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Clear Filter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Showing {events.length} of {total} events
|
||||
{triggerFilter && ` (filtered by "${triggerFilter}")`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p className="mt-4 text-gray-600">Loading events...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-red-600">Failed to load events</p>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
{error instanceof Error ? error.message : "Unknown error"}
|
||||
</p>
|
||||
</div>
|
||||
) : !events || events.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-4 text-gray-600">No events found</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{triggerFilter
|
||||
? "Try adjusting your filter"
|
||||
: "Events will appear here when triggers fire"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Trigger
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Rule
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Source
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{events.map((event) => (
|
||||
<tr key={event.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm font-mono text-gray-900">
|
||||
{event.id}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-gray-900">
|
||||
{event.trigger_ref}
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs">
|
||||
ID: {event.trigger || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{event.rule_ref ? (
|
||||
<div className="text-sm">
|
||||
<Link
|
||||
to={`/rules/${event.rule}`}
|
||||
className="font-medium text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
{event.rule_ref}
|
||||
</Link>
|
||||
<div className="text-gray-500 text-xs">
|
||||
ID: {event.rule}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 italic">
|
||||
No rule
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{event.source_ref ? (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-gray-900">
|
||||
{event.source_ref}
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs">
|
||||
ID: {event.source || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 italic">
|
||||
No source
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{formatTime(event.created)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatDate(event.created)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link
|
||||
to={`/events/${event.id}`}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
View Details
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between border-t border-gray-200">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Page <span className="font-medium">{page}</span> of{" "}
|
||||
<span className="font-medium">{totalPages}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
316
web/src/pages/executions/ExecutionDetailPage.tsx
Normal file
316
web/src/pages/executions/ExecutionDetailPage.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useExecution } from "@/hooks/useExecutions";
|
||||
import { useExecutionStream } from "@/hooks/useExecutionStream";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ExecutionStatus } from "@/api";
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "succeeded":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "failed":
|
||||
return "bg-red-100 text-red-800";
|
||||
case "running":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
case "pending":
|
||||
case "scheduled":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case "timeout":
|
||||
return "bg-orange-100 text-orange-800";
|
||||
case "canceled":
|
||||
return "bg-gray-100 text-gray-800";
|
||||
case "paused":
|
||||
return "bg-purple-100 text-purple-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
export default function ExecutionDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: executionData, isLoading, error } = useExecution(Number(id));
|
||||
const execution = executionData?.data;
|
||||
|
||||
// Subscribe to real-time updates for this execution
|
||||
const { isConnected } = useExecutionStream({
|
||||
executionId: Number(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
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 || !execution) {
|
||||
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 : "Execution not found"}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/executions"
|
||||
className="mt-4 inline-block text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
← Back to Executions
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isRunning =
|
||||
execution.status === ExecutionStatus.RUNNING ||
|
||||
execution.status === ExecutionStatus.SCHEDULING ||
|
||||
execution.status === ExecutionStatus.SCHEDULED ||
|
||||
execution.status === ExecutionStatus.REQUESTED;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to="/executions"
|
||||
className="text-blue-600 hover:text-blue-800 mb-2 inline-block"
|
||||
>
|
||||
← Back to Executions
|
||||
</Link>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-3xl font-bold">Execution #{execution.id}</h1>
|
||||
<span
|
||||
className={`px-3 py-1 text-sm rounded-full ${getStatusColor(execution.status)}`}
|
||||
>
|
||||
{execution.status}
|
||||
</span>
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600" />
|
||||
<span>In Progress</span>
|
||||
</div>
|
||||
)}
|
||||
{isConnected && (
|
||||
<div className="flex items-center gap-2 text-xs text-green-600">
|
||||
<div className="h-2 w-2 rounded-full bg-green-600 animate-pulse" />
|
||||
<span>Live</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 mt-2">
|
||||
<Link
|
||||
to={`/actions/${execution.action_ref}`}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{execution.action_ref}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Status & Timing */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Execution Details</h2>
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Status</dt>
|
||||
<dd className="mt-1">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded ${getStatusColor(execution.status)}`}
|
||||
>
|
||||
{execution.status}
|
||||
</span>
|
||||
</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(execution.created).toLocaleString()}
|
||||
<span className="text-gray-500 ml-2 text-xs">
|
||||
(
|
||||
{formatDistanceToNow(new Date(execution.created), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
)
|
||||
</span>
|
||||
</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(execution.updated).toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
{execution.enforcement && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">
|
||||
Enforcement ID
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{execution.enforcement}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{execution.parent && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">
|
||||
Parent Execution
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
<Link
|
||||
to={`/executions/${execution.parent}`}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
#{execution.parent}
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{execution.executor && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">
|
||||
Executor ID
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{execution.executor}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Config/Parameters */}
|
||||
{execution.config && Object.keys(execution.config).length > 0 && (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Configuration</h2>
|
||||
<pre className="bg-gray-50 p-4 rounded text-sm overflow-x-auto">
|
||||
{JSON.stringify(execution.config, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{execution.result && Object.keys(execution.result).length > 0 && (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Result</h2>
|
||||
<pre className="bg-gray-50 p-4 rounded text-sm overflow-x-auto">
|
||||
{JSON.stringify(execution.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Timeline</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||
{!isRunning && <div className="w-0.5 h-full bg-gray-300" />}
|
||||
</div>
|
||||
<div className="flex-1 pb-4">
|
||||
<p className="font-medium">Execution Created</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(execution.created).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{execution.status === ExecutionStatus.COMPLETED && (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Execution Completed</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(execution.updated).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{execution.status === ExecutionStatus.FAILED && (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Execution Failed</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(execution.updated).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRunning && (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-blue-600">In Progress...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Quick Info */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Quick Info</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Action</p>
|
||||
<Link
|
||||
to={`/actions/${execution.action_ref}`}
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{execution.action_ref}
|
||||
</Link>
|
||||
</div>
|
||||
{execution.enforcement && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Enforcement ID</p>
|
||||
<p className="text-sm font-medium">{execution.enforcement}</p>
|
||||
</div>
|
||||
)}
|
||||
</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={`/actions/${execution.action_ref}`}
|
||||
className="block w-full px-4 py-2 text-sm text-center bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
View Action
|
||||
</Link>
|
||||
<Link
|
||||
to={`/executions?action_ref=${execution.action_ref}`}
|
||||
className="block w-full px-4 py-2 text-sm text-center bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
View All Executions
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
414
web/src/pages/executions/ExecutionsPage.tsx
Normal file
414
web/src/pages/executions/ExecutionsPage.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useExecutions } from "@/hooks/useExecutions";
|
||||
import { useExecutionStream } from "@/hooks/useExecutionStream";
|
||||
import { ExecutionStatus } from "@/api";
|
||||
import { useState, useMemo, memo, useCallback, useEffect } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import MultiSelect from "@/components/common/MultiSelect";
|
||||
|
||||
// Memoized filter input component to prevent re-render on WebSocket updates
|
||||
const FilterInput = memo(
|
||||
({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
FilterInput.displayName = "FilterInput";
|
||||
|
||||
// Status options moved outside component to prevent recreation
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: ExecutionStatus.REQUESTED, label: "Requested" },
|
||||
{ value: ExecutionStatus.SCHEDULING, label: "Scheduling" },
|
||||
{ value: ExecutionStatus.SCHEDULED, label: "Scheduled" },
|
||||
{ value: ExecutionStatus.RUNNING, label: "Running" },
|
||||
{ value: ExecutionStatus.COMPLETED, label: "Completed" },
|
||||
{ value: ExecutionStatus.FAILED, label: "Failed" },
|
||||
{ value: ExecutionStatus.CANCELING, label: "Canceling" },
|
||||
{ value: ExecutionStatus.CANCELLED, label: "Cancelled" },
|
||||
{ value: ExecutionStatus.TIMEOUT, label: "Timeout" },
|
||||
{ value: ExecutionStatus.ABANDONED, label: "Abandoned" },
|
||||
];
|
||||
|
||||
export default function ExecutionsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
const [searchFilters, setSearchFilters] = useState({
|
||||
pack: "",
|
||||
rule: "",
|
||||
action: "",
|
||||
trigger: "",
|
||||
executor: "",
|
||||
});
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
|
||||
|
||||
// Debounced filter state for API calls
|
||||
const [debouncedFilters, setDebouncedFilters] = useState(searchFilters);
|
||||
const [debouncedStatuses, setDebouncedStatuses] = useState(selectedStatuses);
|
||||
|
||||
// Debounce filter changes (500ms delay)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedFilters(searchFilters);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchFilters]);
|
||||
|
||||
// Debounce status changes (300ms delay - shorter since it's a selection)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedStatuses(selectedStatuses);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [selectedStatuses]);
|
||||
|
||||
const queryParams = useMemo(() => {
|
||||
const params: any = { page, pageSize };
|
||||
if (debouncedFilters.pack) params.packName = debouncedFilters.pack;
|
||||
if (debouncedFilters.rule) params.ruleRef = debouncedFilters.rule;
|
||||
if (debouncedFilters.action) params.actionRef = debouncedFilters.action;
|
||||
if (debouncedFilters.trigger) params.triggerRef = debouncedFilters.trigger;
|
||||
if (debouncedFilters.executor)
|
||||
params.executor = parseInt(debouncedFilters.executor, 10);
|
||||
|
||||
// Include status filter if exactly one status is selected
|
||||
// API only supports single status, so we use the first one for filtering
|
||||
// and show all results if multiple are selected
|
||||
if (debouncedStatuses.length === 1) {
|
||||
params.status = debouncedStatuses[0] as ExecutionStatus;
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [page, pageSize, debouncedFilters, debouncedStatuses]);
|
||||
|
||||
const { data, isLoading, error } = useExecutions(queryParams);
|
||||
|
||||
// Subscribe to real-time updates for all executions
|
||||
const { isConnected } = useExecutionStream({ enabled: true });
|
||||
|
||||
const executions = data?.data || [];
|
||||
const total = data?.pagination?.total_items || 0;
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
// Client-side filtering for multiple status selection (when > 1 selected)
|
||||
const filteredExecutions = useMemo(() => {
|
||||
// If no statuses selected or only one (already filtered by API), show all
|
||||
if (debouncedStatuses.length <= 1) {
|
||||
return executions;
|
||||
}
|
||||
// If multiple statuses selected, filter client-side
|
||||
return executions.filter((exec: any) =>
|
||||
debouncedStatuses.includes(exec.status),
|
||||
);
|
||||
}, [executions, debouncedStatuses]);
|
||||
|
||||
const handleFilterChange = useCallback((field: string, value: string) => {
|
||||
setSearchFilters((prev) => ({ ...prev, [field]: value }));
|
||||
setPage(1); // Reset to first page on filter change
|
||||
}, []);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setSearchFilters({
|
||||
pack: "",
|
||||
rule: "",
|
||||
action: "",
|
||||
trigger: "",
|
||||
executor: "",
|
||||
});
|
||||
setSelectedStatuses([]);
|
||||
setPage(1); // Reset to first page
|
||||
}, []);
|
||||
|
||||
const hasActiveFilters =
|
||||
Object.values(searchFilters).some((v) => v !== "") ||
|
||||
selectedStatuses.length > 0;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusColor = (status: ExecutionStatus) => {
|
||||
switch (status) {
|
||||
case ExecutionStatus.COMPLETED:
|
||||
return "bg-green-100 text-green-800";
|
||||
case ExecutionStatus.FAILED:
|
||||
case ExecutionStatus.TIMEOUT:
|
||||
return "bg-red-100 text-red-800";
|
||||
case ExecutionStatus.RUNNING:
|
||||
return "bg-blue-100 text-blue-800";
|
||||
case ExecutionStatus.SCHEDULED:
|
||||
case ExecutionStatus.SCHEDULING:
|
||||
case ExecutionStatus.REQUESTED:
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Executions</h1>
|
||||
{isLoading && hasActiveFilters && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Searching executions...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isConnected && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
||||
<div className="h-2 w-2 rounded-full bg-green-600 animate-pulse" />
|
||||
<span>Live Updates</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Filters */}
|
||||
<div className="bg-white shadow rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
<h2 className="text-lg font-semibold">Filter Executions</h2>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
|
||||
<FilterInput
|
||||
label="Pack"
|
||||
value={searchFilters.pack}
|
||||
onChange={(value) => handleFilterChange("pack", value)}
|
||||
placeholder="e.g., core"
|
||||
/>
|
||||
<FilterInput
|
||||
label="Rule"
|
||||
value={searchFilters.rule}
|
||||
onChange={(value) => handleFilterChange("rule", value)}
|
||||
placeholder="e.g., core.on_timer"
|
||||
/>
|
||||
<FilterInput
|
||||
label="Action"
|
||||
value={searchFilters.action}
|
||||
onChange={(value) => handleFilterChange("action", value)}
|
||||
placeholder="e.g., core.echo"
|
||||
/>
|
||||
<FilterInput
|
||||
label="Trigger"
|
||||
value={searchFilters.trigger}
|
||||
onChange={(value) => handleFilterChange("trigger", value)}
|
||||
placeholder="e.g., core.timer"
|
||||
/>
|
||||
<FilterInput
|
||||
label="Executor ID"
|
||||
value={searchFilters.executor}
|
||||
onChange={(value) => handleFilterChange("executor", value)}
|
||||
placeholder="e.g., 1"
|
||||
/>
|
||||
<div>
|
||||
<MultiSelect
|
||||
label="Status"
|
||||
options={STATUS_OPTIONS}
|
||||
value={selectedStatuses}
|
||||
onChange={setSelectedStatuses}
|
||||
placeholder="All Statuses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredExecutions.length === 0 ? (
|
||||
<div className="bg-white p-12 text-center rounded-lg shadow">
|
||||
<p>
|
||||
{executions.length === 0
|
||||
? "No executions found"
|
||||
: "No executions match the selected filters"}
|
||||
</p>
|
||||
{executions.length > 0 && hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="mt-3 text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Rule
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Trigger
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Created
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredExecutions.map((exec: any) => (
|
||||
<tr key={exec.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 font-mono text-sm">
|
||||
<Link
|
||||
to={`/executions/${exec.id}`}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
#{exec.id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-sm text-gray-900">
|
||||
{exec.action_ref}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{exec.rule_ref ? (
|
||||
<span className="text-sm text-gray-700">
|
||||
{exec.rule_ref}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 italic">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{exec.trigger_ref ? (
|
||||
<span className="text-sm text-gray-700">
|
||||
{exec.trigger_ref}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 italic">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded ${getStatusColor(exec.status)}`}
|
||||
>
|
||||
{exec.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{new Date(exec.created).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between border-t border-gray-200">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing{" "}
|
||||
<span className="font-medium">
|
||||
{(page - 1) * pageSize + 1}
|
||||
</span>{" "}
|
||||
to
|
||||
<span className="font-medium">
|
||||
{Math.min(page * pageSize, total)}
|
||||
</span>{" "}
|
||||
of
|
||||
<span className="font-medium">{total}</span> executions
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
266
web/src/pages/keys/KeyCreateModal.tsx
Normal file
266
web/src/pages/keys/KeyCreateModal.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { useState } from "react";
|
||||
import { useCreateKey } from "@/hooks/useKeys";
|
||||
import { OwnerType } from "@/api";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface KeyCreateModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export type KeyFormat = "text" | "json" | "yaml" | "number" | "int" | "bool";
|
||||
|
||||
export default function KeyCreateModal({ onClose }: KeyCreateModalProps) {
|
||||
const [ref, setRef] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [value, setValue] = useState("");
|
||||
const [format, setFormat] = useState<KeyFormat>("text");
|
||||
const [encrypted, setEncrypted] = useState(true);
|
||||
const [ownerType, setOwnerType] = useState<OwnerType>(OwnerType.SYSTEM);
|
||||
const [owner, setOwner] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createKeyMutation = useCreateKey();
|
||||
|
||||
// Determine if encryption is allowed based on format
|
||||
const canEncrypt = format === "text" || format === "json" || format === "yaml";
|
||||
|
||||
// Auto-disable encryption for non-encryptable formats
|
||||
const isEncrypted = canEncrypt && encrypted;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Validate ref format
|
||||
if (!/^[a-zA-Z0-9_.-]+$/.test(ref)) {
|
||||
setError("Reference must contain only letters, numbers, underscores, hyphens, and dots");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate value based on format
|
||||
let validatedValue = value;
|
||||
try {
|
||||
if (format === "json") {
|
||||
JSON.parse(value);
|
||||
} else if (format === "yaml") {
|
||||
// Basic YAML validation (not exhaustive)
|
||||
if (!value.trim()) {
|
||||
throw new Error("YAML value cannot be empty");
|
||||
}
|
||||
} else if (format === "number" || format === "int") {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) {
|
||||
throw new Error(`Value must be a valid ${format}`);
|
||||
}
|
||||
if (format === "int" && !Number.isInteger(num)) {
|
||||
throw new Error("Value must be an integer");
|
||||
}
|
||||
} else if (format === "bool") {
|
||||
const lower = value.toLowerCase();
|
||||
if (lower !== "true" && lower !== "false") {
|
||||
throw new Error('Value must be "true" or "false"');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Invalid value format");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createKeyMutation.mutateAsync({
|
||||
ref,
|
||||
name,
|
||||
value: validatedValue,
|
||||
encrypted: isEncrypted,
|
||||
owner_type: ownerType,
|
||||
owner: owner || undefined,
|
||||
});
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to create key");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Create New Key</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="ref" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Reference <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="ref"
|
||||
type="text"
|
||||
value={ref}
|
||||
onChange={(e) => setRef(e.target.value)}
|
||||
placeholder="e.g., github_token, database_password"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Unique identifier (letters, numbers, _, -, .)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., GitHub API Token"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Human-readable name</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="format" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Value Format <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="format"
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value as KeyFormat)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="text">Text (can be encrypted)</option>
|
||||
<option value="json">JSON (can be encrypted)</option>
|
||||
<option value="yaml">YAML (can be encrypted)</option>
|
||||
<option value="number">Number (cannot be encrypted)</option>
|
||||
<option value="int">Integer (cannot be encrypted)</option>
|
||||
<option value="bool">Boolean (cannot be encrypted)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{canEncrypt
|
||||
? "This format can be encrypted for security"
|
||||
: "This format cannot be encrypted - stored as plain text"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="value" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Value <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="value"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={
|
||||
format === "json"
|
||||
? '{"key": "value"}'
|
||||
: format === "yaml"
|
||||
? "key: value"
|
||||
: format === "bool"
|
||||
? "true or false"
|
||||
: format === "number" || format === "int"
|
||||
? "123"
|
||||
: "Enter value..."
|
||||
}
|
||||
required
|
||||
rows={format === "json" || format === "yaml" ? 6 : 3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="encrypted"
|
||||
type="checkbox"
|
||||
checked={isEncrypted}
|
||||
onChange={(e) => setEncrypted(e.target.checked)}
|
||||
disabled={!canEncrypt}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded disabled:opacity-50"
|
||||
/>
|
||||
<label htmlFor="encrypted" className="ml-2 block text-sm text-gray-900">
|
||||
Encrypt value (recommended for secrets)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="ownerType" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Scope <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="ownerType"
|
||||
value={ownerType}
|
||||
onChange={(e) => setOwnerType(e.target.value as OwnerType)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value={OwnerType.SYSTEM}>System (global)</option>
|
||||
<option value={OwnerType.IDENTITY}>User (identity)</option>
|
||||
<option value={OwnerType.PACK}>Pack</option>
|
||||
<option value={OwnerType.ACTION}>Action</option>
|
||||
<option value={OwnerType.SENSOR}>Sensor</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{ownerType !== OwnerType.SYSTEM && (
|
||||
<div>
|
||||
<label htmlFor="owner" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Owner Identifier
|
||||
</label>
|
||||
<input
|
||||
id="owner"
|
||||
type="text"
|
||||
value={owner}
|
||||
onChange={(e) => setOwner(e.target.value)}
|
||||
placeholder={
|
||||
ownerType === OwnerType.PACK
|
||||
? "e.g., core"
|
||||
: ownerType === OwnerType.ACTION
|
||||
? "e.g., core.echo"
|
||||
: ownerType === OwnerType.SENSOR
|
||||
? "e.g., core.timer_sensor"
|
||||
: "e.g., username"
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Optional owner reference
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createKeyMutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createKeyMutation.isPending ? "Creating..." : "Create Key"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
web/src/pages/keys/KeyEditModal.tsx
Normal file
199
web/src/pages/keys/KeyEditModal.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useKey, useUpdateKey } from "@/hooks/useKeys";
|
||||
import { X, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
interface KeyEditModalProps {
|
||||
keyRef: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function KeyEditModal({ keyRef, onClose }: KeyEditModalProps) {
|
||||
const { data: keyData, isLoading } = useKey(keyRef);
|
||||
const key = keyData?.data;
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [value, setValue] = useState("");
|
||||
const [encrypted, setEncrypted] = useState(true);
|
||||
const [showValue, setShowValue] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const updateKeyMutation = useUpdateKey();
|
||||
|
||||
useEffect(() => {
|
||||
if (key) {
|
||||
setName(key.name);
|
||||
setValue(key.value);
|
||||
setEncrypted(key.encrypted);
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await updateKeyMutation.mutateAsync({
|
||||
ref: keyRef,
|
||||
data: {
|
||||
name: name !== key?.name ? name : undefined,
|
||||
value: value !== key?.value ? value : undefined,
|
||||
encrypted: encrypted !== key?.encrypted ? encrypted : undefined,
|
||||
},
|
||||
});
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to update key");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p className="mt-4 text-gray-600">Loading key...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6">
|
||||
<p className="text-red-600">Key not found</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-4 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Edit Key: {keyRef}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-500">Reference:</span>
|
||||
<span className="text-sm font-mono text-gray-900">{key.ref}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-500">Scope:</span>
|
||||
<span className="text-sm text-gray-900">{key.owner_type}</span>
|
||||
</div>
|
||||
{key.owner && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-500">Owner:</span>
|
||||
<span className="text-sm text-gray-900">{key.owner}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="value" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Value <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
id="value"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
required
|
||||
rows={6}
|
||||
className={`w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm ${
|
||||
!showValue ? "text-security-disc" : ""
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowValue(!showValue)}
|
||||
className="absolute right-2 top-2 text-gray-400 hover:text-gray-600"
|
||||
title={showValue ? "Hide value" : "Show value"}
|
||||
>
|
||||
{showValue ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{key.encrypted
|
||||
? "Current value is encrypted in database"
|
||||
: "Current value is stored as plain text"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="encrypted"
|
||||
type="checkbox"
|
||||
checked={encrypted}
|
||||
onChange={(e) => setEncrypted(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="encrypted" className="ml-2 block text-sm text-gray-900">
|
||||
Encrypt value (recommended for secrets)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{encrypted !== key.encrypted && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 px-4 py-3 rounded-lg text-sm">
|
||||
{encrypted
|
||||
? "⚠️ Changing from unencrypted to encrypted will re-encrypt the value"
|
||||
: "⚠️ Warning: Changing from encrypted to unencrypted will store the value as plain text"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateKeyMutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{updateKeyMutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
web/src/pages/keys/KeysPage.tsx
Normal file
267
web/src/pages/keys/KeysPage.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useState } from "react";
|
||||
import { useKeys, useDeleteKey } from "@/hooks/useKeys";
|
||||
import { OwnerType } from "@/api";
|
||||
import { Key, Plus, Trash2, Edit, Eye, EyeOff, Search } from "lucide-react";
|
||||
import KeyCreateModal from "./KeyCreateModal";
|
||||
import KeyEditModal from "./KeyEditModal";
|
||||
|
||||
export default function KeysPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [ownerTypeFilter, setOwnerTypeFilter] = useState<OwnerType | "">("");
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const pageSize = 20;
|
||||
|
||||
const { data, isLoading, error } = useKeys({
|
||||
page,
|
||||
pageSize,
|
||||
ownerType: ownerTypeFilter || undefined,
|
||||
});
|
||||
|
||||
const deleteKeyMutation = useDeleteKey();
|
||||
|
||||
const keys = data?.data || [];
|
||||
const total = data?.pagination?.total_items || 0;
|
||||
const totalPages = total ? Math.ceil(total / pageSize) : 0;
|
||||
|
||||
// Client-side filtering by search term (ref or name)
|
||||
const filteredKeys = searchTerm
|
||||
? keys.filter(
|
||||
(key) =>
|
||||
key.ref.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
key.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
)
|
||||
: keys;
|
||||
|
||||
const hasActiveFilters = searchTerm || ownerTypeFilter;
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm("");
|
||||
setOwnerTypeFilter("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleDelete = async (ref: string) => {
|
||||
if (window.confirm(`Are you sure you want to delete key "${ref}"?`)) {
|
||||
try {
|
||||
await deleteKeyMutation.mutateAsync(ref);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete key:", err);
|
||||
alert("Failed to delete key. Please try again.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getOwnerTypeLabel = (ownerType: OwnerType) => {
|
||||
const labels: Record<OwnerType, string> = {
|
||||
[OwnerType.SYSTEM]: "System",
|
||||
[OwnerType.IDENTITY]: "User",
|
||||
[OwnerType.PACK]: "Pack",
|
||||
[OwnerType.ACTION]: "Action",
|
||||
[OwnerType.SENSOR]: "Sensor",
|
||||
};
|
||||
return labels[ownerType] || ownerType;
|
||||
};
|
||||
|
||||
const getOwnerTypeBadge = (ownerType: OwnerType) => {
|
||||
const colors: Record<OwnerType, string> = {
|
||||
[OwnerType.SYSTEM]: "bg-purple-100 text-purple-800",
|
||||
[OwnerType.IDENTITY]: "bg-blue-100 text-blue-800",
|
||||
[OwnerType.PACK]: "bg-green-100 text-green-800",
|
||||
[OwnerType.ACTION]: "bg-yellow-100 text-yellow-800",
|
||||
[OwnerType.SENSOR]: "bg-indigo-100 text-indigo-800",
|
||||
};
|
||||
return colors[ownerType] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Keys & Secrets</h1>
|
||||
<p className="mt-2 text-gray-600">Manage encrypted secrets and configuration values</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow mb-6 p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label htmlFor="search" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="w-4 h-4" />
|
||||
Search Keys
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
id="search"
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Search by reference or name..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="owner-type-filter" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Filter by Scope
|
||||
</label>
|
||||
<select
|
||||
id="owner-type-filter"
|
||||
value={ownerTypeFilter}
|
||||
onChange={(e) => {
|
||||
setOwnerTypeFilter(e.target.value as OwnerType | "");
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Scopes</option>
|
||||
<option value={OwnerType.SYSTEM}>System</option>
|
||||
<option value={OwnerType.IDENTITY}>User</option>
|
||||
<option value={OwnerType.PACK}>Pack</option>
|
||||
<option value={OwnerType.ACTION}>Action</option>
|
||||
<option value={OwnerType.SENSOR}>Sensor</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
{filteredKeys.length > 0 && (
|
||||
<>Showing {filteredKeys.length} of {total} keys{hasActiveFilters && " (filtered)"}</>
|
||||
)}
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<button onClick={clearFilters} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900">
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p className="mt-4 text-gray-600">Loading keys...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-red-600">Failed to load keys</p>
|
||||
<p className="text-sm text-gray-600 mt-2">{error instanceof Error ? error.message : "Unknown error"}</p>
|
||||
</div>
|
||||
) : !filteredKeys || filteredKeys.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<Key className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<p className="mt-4 text-gray-600">No keys found</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{hasActiveFilters ? "Try adjusting your filters" : "Create your first key to get started"}
|
||||
</p>
|
||||
{!hasActiveFilters && (
|
||||
<button onClick={() => setShowCreateModal(true)} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
Create Key
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Reference</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Scope</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Owner</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Encrypted</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredKeys.map((key) => (
|
||||
<tr key={key.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm font-mono text-gray-900">{key.ref}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4"><div className="text-sm text-gray-900">{key.name}</div></td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getOwnerTypeBadge(key.owner_type)}`}>
|
||||
{getOwnerTypeLabel(key.owner_type)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap"><div className="text-sm text-gray-900">{key.owner || "—"}</div></td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{key.encrypted ? (
|
||||
<><EyeOff className="w-4 h-4 text-green-600" /><span className="text-sm text-green-600 font-medium">Yes</span></>
|
||||
) : (
|
||||
<><Eye className="w-4 h-4 text-gray-400" /><span className="text-sm text-gray-600">No</span></>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap"><div className="text-sm text-gray-900">{formatDate(key.created)}</div></td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button onClick={() => setEditingKey(key.ref)} className="text-blue-600 hover:text-blue-900" title="Edit key">
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(key.ref)} className="text-red-600 hover:text-red-900" title="Delete key">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between border-t border-gray-200">
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">Page <span className="font-medium">{page}</span> of <span className="font-medium">{totalPages}</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
<button onClick={() => setPage(page - 1)} disabled={page === 1} className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Previous
|
||||
</button>
|
||||
<button onClick={() => setPage(page + 1)} disabled={page === totalPages} className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreateModal && <KeyCreateModal onClose={() => setShowCreateModal(false)} />}
|
||||
{editingKey && <KeyEditModal keyRef={editingKey} onClose={() => setEditingKey(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
web/src/pages/packs/PackCreatePage.tsx
Normal file
77
web/src/pages/packs/PackCreatePage.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Info, Workflow, Radio, PencilRuler } from "lucide-react";
|
||||
import PackForm from "@/components/forms/PackForm";
|
||||
|
||||
export default function PackCreatePage() {
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to="/packs"
|
||||
className="text-sm text-blue-600 hover:text-blue-800 mb-2 inline-block"
|
||||
>
|
||||
← Back to Packs
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Create Empty Pack</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Create an empty pack for ad-hoc rules, workflows, webhook triggers,
|
||||
and custom actions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">
|
||||
When to Use Empty Packs
|
||||
</h3>
|
||||
<p className="text-sm text-blue-800 mb-3">
|
||||
Empty packs provide a namespace for ad-hoc automation content that
|
||||
doesn't require filesystem-based pack structure. Perfect for:
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<PencilRuler className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<strong>Ad-hoc Rules</strong>
|
||||
<p className="text-xs text-blue-700 mt-0.5">
|
||||
Quick automation rules created via the UI
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Workflow className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<strong>Custom Workflows</strong>
|
||||
<p className="text-xs text-blue-700 mt-0.5">
|
||||
Workflow actions defined in the database
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Radio className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<strong>Webhook Triggers</strong>
|
||||
<p className="text-xs text-blue-700 mt-0.5">
|
||||
Webhook endpoints for external integrations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-blue-700 mt-3">
|
||||
<strong>Tip:</strong> Use the "Register from Filesystem" option if
|
||||
you have a pack directory with actions, sensors, and pack.yaml
|
||||
metadata.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<PackForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
web/src/pages/packs/PackEditPage.tsx
Normal file
77
web/src/pages/packs/PackEditPage.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import { usePack } from "@/hooks/usePacks";
|
||||
import PackForm from "@/components/forms/PackForm";
|
||||
|
||||
export default function PackEditPage() {
|
||||
const { ref } = useParams<{ ref: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: packResponse, isLoading, error } = usePack(ref!);
|
||||
const pack = packResponse?.data;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !pack) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-red-900 mb-2">
|
||||
Failed to load pack
|
||||
</h3>
|
||||
<p className="text-red-700">
|
||||
{error instanceof Error ? error.message : "Pack not found"}
|
||||
</p>
|
||||
<Link
|
||||
to="/packs"
|
||||
className="inline-block mt-4 text-red-600 hover:text-red-800"
|
||||
>
|
||||
← Back to Packs
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to={`/packs/${pack?.ref}`}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 mb-2 inline-block"
|
||||
>
|
||||
← Back to Pack
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Edit Pack</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Update pack configuration: <strong>{pack?.label}</strong>
|
||||
</p>
|
||||
{pack?.is_standard && (
|
||||
<div className="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Note:</strong> This is a standard pack. Only configuration
|
||||
values can be edited.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
{pack && (
|
||||
<PackForm
|
||||
pack={pack}
|
||||
onSuccess={() => navigate(`/packs/${pack.ref}`)}
|
||||
onCancel={() => navigate(`/packs/${pack.ref}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
501
web/src/pages/packs/PackInstallPage.tsx
Normal file
501
web/src/pages/packs/PackInstallPage.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useInstallPack } from "@/hooks/usePackTests";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
GitBranch,
|
||||
Package,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
|
||||
type SourceType = "git" | "archive" | "registry";
|
||||
|
||||
export default function PackInstallPage() {
|
||||
const navigate = useNavigate();
|
||||
const installPack = useInstallPack();
|
||||
|
||||
const [sourceType, setSourceType] = useState<SourceType>("git");
|
||||
const [formData, setFormData] = useState({
|
||||
source: "",
|
||||
refSpec: "",
|
||||
force: false,
|
||||
skipTests: false,
|
||||
skipDeps: false,
|
||||
});
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
if (!formData.source) {
|
||||
setError("Pack source is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await installPack.mutateAsync({
|
||||
source: formData.source,
|
||||
refSpec: formData.refSpec || undefined,
|
||||
force: formData.force,
|
||||
skipTests: formData.skipTests,
|
||||
skipDeps: formData.skipDeps,
|
||||
});
|
||||
|
||||
const packRef = result.data.pack.ref;
|
||||
setSuccess(
|
||||
`Pack '${result.data.pack.label}' (${result.data.pack.version}) installed successfully! ${
|
||||
result.data.tests_skipped
|
||||
? "Tests were skipped."
|
||||
: result.data.test_result
|
||||
? `Tests ${result.data.test_result.status}: ${result.data.test_result.passed}/${result.data.test_result.total_tests} passed.`
|
||||
: ""
|
||||
}`,
|
||||
);
|
||||
|
||||
// Redirect to pack details after 2 seconds
|
||||
setTimeout(() => {
|
||||
navigate(`/packs/${packRef}`);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
setError((err as Error).message || "Failed to install pack");
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const { name, type, checked, value } = target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const getSourcePlaceholder = () => {
|
||||
switch (sourceType) {
|
||||
case "git":
|
||||
return "https://github.com/example/pack-slack.git";
|
||||
case "archive":
|
||||
return "https://example.com/packs/pack-slack-1.0.0.tar.gz";
|
||||
case "registry":
|
||||
return "slack";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceLabel = () => {
|
||||
switch (sourceType) {
|
||||
case "git":
|
||||
return "Git Repository URL";
|
||||
case "archive":
|
||||
return "Archive URL";
|
||||
case "registry":
|
||||
return "Pack Reference";
|
||||
default:
|
||||
return "Source";
|
||||
}
|
||||
};
|
||||
|
||||
const showRefSpec = sourceType === "git" || sourceType === "registry";
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to="/packs"
|
||||
className="text-blue-600 hover:text-blue-800 mb-2 inline-block"
|
||||
>
|
||||
← Back to Packs
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold">Install Pack</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Install a pack from git, archive URL, or pack registry
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<Package className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">
|
||||
Remote Pack Installation
|
||||
</h3>
|
||||
<p className="text-sm text-blue-800 mb-2">
|
||||
This option installs a pack from a remote source:
|
||||
</p>
|
||||
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside ml-2">
|
||||
<li>
|
||||
<strong>Git Repository</strong> - Clone from GitHub, GitLab, or
|
||||
any git server
|
||||
</li>
|
||||
<li>
|
||||
<strong>Archive URL</strong> - Download from .zip or .tar.gz
|
||||
URL
|
||||
</li>
|
||||
<li>
|
||||
<strong>Pack Registry</strong> - Install from configured
|
||||
registries (coming soon)
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-3 pt-3 border-t border-blue-300">
|
||||
<p className="text-xs text-blue-700">
|
||||
<strong>Local development?</strong> Use{" "}
|
||||
<Link
|
||||
to="/packs/register"
|
||||
className="underline hover:text-blue-900 font-medium"
|
||||
>
|
||||
filesystem registration
|
||||
</Link>{" "}
|
||||
for local pack directories.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-red-800">Error</h3>
|
||||
<p className="text-sm text-red-700 mt-1 whitespace-pre-line">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-green-800">Success</h3>
|
||||
<p className="text-sm text-green-700 mt-1">{success}</p>
|
||||
<p className="text-xs text-green-600 mt-2">
|
||||
Redirecting to pack details...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Source Type Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Installation Source Type{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourceType("git")}
|
||||
className={`px-4 py-3 border rounded-lg text-sm font-medium transition-colors ${
|
||||
sourceType === "git"
|
||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="w-4 h-4 mx-auto mb-1" />
|
||||
Git Repository
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourceType("archive")}
|
||||
className={`px-4 py-3 border rounded-lg text-sm font-medium transition-colors ${
|
||||
sourceType === "archive"
|
||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Package className="w-4 h-4 mx-auto mb-1" />
|
||||
Archive URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourceType("registry")}
|
||||
disabled
|
||||
className="px-4 py-3 border rounded-lg text-sm font-medium border-gray-300 bg-gray-100 text-gray-400 cursor-not-allowed"
|
||||
>
|
||||
<Package className="w-4 h-4 mx-auto mb-1" />
|
||||
Registry
|
||||
<span className="block text-xs mt-1">(Coming Soon)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="source"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
{getSourceLabel()} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="source"
|
||||
name="source"
|
||||
value={formData.source}
|
||||
onChange={handleChange}
|
||||
placeholder={getSourcePlaceholder()}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
{sourceType === "git" && (
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Supports HTTPS and SSH URLs. Examples:
|
||||
<br />
|
||||
• https://github.com/username/pack-name.git
|
||||
<br />• git@github.com:username/pack-name.git
|
||||
</p>
|
||||
)}
|
||||
{sourceType === "archive" && (
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Direct URL to .zip or .tar.gz archive containing pack files
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Git Reference (for git and registry sources) */}
|
||||
{showRefSpec && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="refSpec"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
{sourceType === "git" ? "Git Reference" : "Version"}
|
||||
<span className="text-gray-500 text-xs ml-2">(Optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="refSpec"
|
||||
name="refSpec"
|
||||
value={formData.refSpec}
|
||||
onChange={handleChange}
|
||||
placeholder={
|
||||
sourceType === "git"
|
||||
? "main, v1.0.0, commit-hash, etc."
|
||||
: "1.0.0, latest, etc."
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{sourceType === "git" && (
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Branch name, tag, or commit hash. Defaults to default branch
|
||||
if not specified.
|
||||
</p>
|
||||
)}
|
||||
{sourceType === "registry" && (
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Specific version or "latest". Defaults to latest if not
|
||||
specified.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Installation Options */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Installation Options
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Skip Dependencies */}
|
||||
<div className="flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="skipDeps"
|
||||
name="skipDeps"
|
||||
checked={formData.skipDeps}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<label
|
||||
htmlFor="skipDeps"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
Skip Dependency Validation
|
||||
</label>
|
||||
<p className="text-sm text-gray-500">
|
||||
Skip checking for required runtime dependencies and pack
|
||||
dependencies. Use with caution.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skip Tests */}
|
||||
<div className="flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="skipTests"
|
||||
name="skipTests"
|
||||
checked={formData.skipTests}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<label
|
||||
htmlFor="skipTests"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
Skip Tests
|
||||
</label>
|
||||
<p className="text-sm text-gray-500">
|
||||
Skip running pack tests during installation. Useful when
|
||||
tests are not available or trusted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Force Installation */}
|
||||
<div className="flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="force"
|
||||
name="force"
|
||||
checked={formData.force}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<label
|
||||
htmlFor="force"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
Force Installation
|
||||
</label>
|
||||
<p className="text-sm text-gray-500">
|
||||
Proceed with installation even if pack exists, dependencies
|
||||
are missing, or tests fail. This will replace any existing
|
||||
pack.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-amber-900 mb-2">
|
||||
Installation Process
|
||||
</h4>
|
||||
<ul className="text-sm text-amber-800 space-y-1 list-disc list-inside">
|
||||
<li>Pack is downloaded from the specified source</li>
|
||||
{!formData.skipDeps && (
|
||||
<li className="font-medium">
|
||||
Dependencies are validated (runtime & pack dependencies)
|
||||
</li>
|
||||
)}
|
||||
<li>Pack metadata is registered in the database</li>
|
||||
<li>
|
||||
Pack files are copied to permanent storage (
|
||||
{sourceType === "git" && "cloned from git"}
|
||||
{sourceType === "archive" && "extracted from archive"}
|
||||
{sourceType === "registry" && "downloaded from registry"})
|
||||
</li>
|
||||
<li>Workflows are automatically synced</li>
|
||||
{!formData.skipTests && (
|
||||
<li className="font-medium">
|
||||
Tests are executed and must pass (unless forced)
|
||||
</li>
|
||||
)}
|
||||
{formData.skipDeps && (
|
||||
<li className="text-amber-600 font-medium">
|
||||
⚠️ Dependency validation will be skipped
|
||||
</li>
|
||||
)}
|
||||
{formData.skipTests && (
|
||||
<li className="text-amber-600 font-medium">
|
||||
⚠️ Tests will be skipped
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={installPack.isPending || !!success}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium flex items-center gap-2"
|
||||
>
|
||||
{installPack.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Package className="w-4 h-4" />
|
||||
Install Pack
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
to="/packs"
|
||||
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors font-medium"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Help Section */}
|
||||
<div className="mt-6 bg-gray-50 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
Installation Requirements
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm text-gray-600">
|
||||
<div>
|
||||
<strong className="text-gray-900">Pack Structure:</strong> The
|
||||
source must contain a valid pack.yaml file with pack metadata,
|
||||
either at the root or in a pack/ subdirectory
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-gray-900">Git Installation:</strong>{" "}
|
||||
Requires git to be installed on the Attune server. Supports HTTPS
|
||||
and SSH URLs.
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-gray-900">Dependencies:</strong> Pack
|
||||
dependencies (runtime and other packs) are validated before
|
||||
installation unless skipped
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-gray-900">Testing:</strong> If tests are
|
||||
configured in pack.yaml, they will run automatically unless skipped
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-gray-900">Force Mode:</strong> Use this to
|
||||
replace existing packs, bypass dependency checks, or proceed despite
|
||||
test failures
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
web/src/pages/packs/PackRegisterPage.tsx
Normal file
304
web/src/pages/packs/PackRegisterPage.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useRegisterPack } from "@/hooks/usePackTests";
|
||||
import { AlertCircle, CheckCircle, Loader2, FolderOpen } from "lucide-react";
|
||||
|
||||
export default function PackRegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const registerPack = useRegisterPack();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
path: "",
|
||||
force: false,
|
||||
skipTests: false,
|
||||
});
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
if (!formData.path) {
|
||||
setError("Pack path is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await registerPack.mutateAsync({
|
||||
path: formData.path,
|
||||
force: formData.force,
|
||||
skipTests: formData.skipTests,
|
||||
});
|
||||
|
||||
const packRef = result.data.pack.ref;
|
||||
setSuccess(
|
||||
`Pack '${result.data.pack.label}' registered successfully! ${
|
||||
result.data.tests_skipped
|
||||
? "Tests were skipped."
|
||||
: result.data.test_result
|
||||
? `Tests ${result.data.test_result.status}: ${result.data.test_result.passed}/${result.data.test_result.total_tests} passed.`
|
||||
: ""
|
||||
}`,
|
||||
);
|
||||
|
||||
// Redirect to pack details after 2 seconds
|
||||
setTimeout(() => {
|
||||
navigate(`/packs/${packRef}`);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
setError((err as Error).message || "Failed to register pack");
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, type, checked, value } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to="/packs"
|
||||
className="text-blue-600 hover:text-blue-800 mb-2 inline-block"
|
||||
>
|
||||
← Back to Packs
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold">Register Pack from Filesystem</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Load an existing pack directory with actions, sensors, and metadata
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<FolderOpen className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-green-900 mb-2">
|
||||
Filesystem-Based Pack Registration
|
||||
</h3>
|
||||
<p className="text-sm text-green-800 mb-2">
|
||||
This option registers a pack from a local directory containing:
|
||||
</p>
|
||||
<ul className="text-sm text-green-800 space-y-1 list-disc list-inside ml-2">
|
||||
<li>
|
||||
<strong>pack.yaml</strong> - Pack metadata and configuration
|
||||
</li>
|
||||
<li>
|
||||
<strong>actions/</strong> - Executable action scripts (Python,
|
||||
Shell, Node.js)
|
||||
</li>
|
||||
<li>
|
||||
<strong>sensors/</strong> - Sensor monitoring scripts (optional)
|
||||
</li>
|
||||
<li>
|
||||
<strong>rules/</strong> - Pre-defined automation rules
|
||||
(optional)
|
||||
</li>
|
||||
<li>
|
||||
<strong>workflows/</strong> - Workflow definitions (optional)
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-3 pt-3 border-t border-green-300">
|
||||
<p className="text-xs text-green-700">
|
||||
<strong>Need an empty pack instead?</strong> If you're creating
|
||||
ad-hoc rules, workflows, or webhooks without filesystem-based
|
||||
actions,{" "}
|
||||
<Link
|
||||
to="/packs/new"
|
||||
className="underline hover:text-green-900 font-medium"
|
||||
>
|
||||
create an empty pack
|
||||
</Link>{" "}
|
||||
instead.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-red-800">Error</h3>
|
||||
<p className="text-sm text-red-700 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-green-800">Success</h3>
|
||||
<p className="text-sm text-green-700 mt-1">{success}</p>
|
||||
<p className="text-xs text-green-600 mt-2">
|
||||
Redirecting to pack details...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Pack Path */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="path"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Pack Directory Path <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="path"
|
||||
name="path"
|
||||
value={formData.path}
|
||||
onChange={handleChange}
|
||||
placeholder="/path/to/pack/directory"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Absolute path to the pack directory containing pack.yaml
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Test Options */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Test Options
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Skip Tests */}
|
||||
<div className="flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="skipTests"
|
||||
name="skipTests"
|
||||
checked={formData.skipTests}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<label
|
||||
htmlFor="skipTests"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
Skip Tests
|
||||
</label>
|
||||
<p className="text-sm text-gray-500">
|
||||
Skip running tests during registration. Useful for
|
||||
development or when tests are not yet ready.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Force Registration */}
|
||||
<div className="flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="force"
|
||||
name="force"
|
||||
checked={formData.force}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<label
|
||||
htmlFor="force"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
Force Registration
|
||||
</label>
|
||||
<p className="text-sm text-gray-500">
|
||||
Proceed with registration even if pack exists or tests fail.
|
||||
This will replace any existing pack with the same reference.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-blue-800 mb-2">
|
||||
Registration Process
|
||||
</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1 list-disc list-inside">
|
||||
<li>Pack metadata is read from pack.yaml</li>
|
||||
<li>Pack is registered in the database</li>
|
||||
<li>Workflows are automatically synced</li>
|
||||
{!formData.skipTests && (
|
||||
<li className="font-medium">
|
||||
Tests are executed and must pass (unless forced)
|
||||
</li>
|
||||
)}
|
||||
{formData.skipTests && (
|
||||
<li className="text-blue-600 font-medium">
|
||||
Tests will be skipped - no validation
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={registerPack.isPending || !!success}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium flex items-center gap-2"
|
||||
>
|
||||
{registerPack.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Registering...
|
||||
</>
|
||||
) : (
|
||||
"Register Pack"
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
to="/packs"
|
||||
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors font-medium"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Help Section */}
|
||||
<div className="mt-6 bg-gray-50 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Need Help?</h3>
|
||||
<div className="space-y-3 text-sm text-gray-600">
|
||||
<div>
|
||||
<strong className="text-gray-900">Pack Directory:</strong> Must
|
||||
contain a valid pack.yaml file with pack metadata
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-gray-900">Testing:</strong> If tests are
|
||||
configured in pack.yaml, they will run automatically unless skipped
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-gray-900">Force Mode:</strong> Use this to
|
||||
replace existing packs or proceed despite test failures
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
625
web/src/pages/packs/PacksPage.tsx
Normal file
625
web/src/pages/packs/PacksPage.tsx
Normal file
@@ -0,0 +1,625 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { usePacks, usePack, useDeletePack } from "@/hooks/usePacks";
|
||||
import { usePackActions } from "@/hooks/useActions";
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
Search,
|
||||
X,
|
||||
Package,
|
||||
Plus,
|
||||
ChevronDown,
|
||||
GitBranch,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function PacksPage() {
|
||||
const { ref } = useParams<{ ref?: string }>();
|
||||
const { data, isLoading, error } = usePacks();
|
||||
const packs = data?.data || [];
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showPackMenu, setShowPackMenu] = useState(false);
|
||||
|
||||
// Filter packs based on search query
|
||||
const filteredPacks = useMemo(() => {
|
||||
if (!searchQuery.trim()) return packs;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return packs.filter((pack: any) => {
|
||||
return (
|
||||
pack.label?.toLowerCase().includes(query) ||
|
||||
pack.ref?.toLowerCase().includes(query) ||
|
||||
pack.description?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [packs, searchQuery]);
|
||||
|
||||
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 - Packs 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-2">
|
||||
<h1 className="text-2xl font-bold">Packs</h1>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowPackMenu(!showPackMenu)}
|
||||
className="flex items-center gap-2 px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Pack
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{showPackMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setShowPackMenu(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-2 w-56 bg-white border border-gray-200 rounded-lg shadow-lg z-20">
|
||||
<div className="py-1">
|
||||
<Link
|
||||
to="/packs/new"
|
||||
className="flex items-start gap-3 px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setShowPackMenu(false)}
|
||||
>
|
||||
<Plus className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
Create Empty Pack
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
For ad-hoc rules, workflows, and webhooks
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/packs/register"
|
||||
className="flex items-start gap-3 px-4 py-3 hover:bg-gray-50 transition-colors border-t border-gray-100"
|
||||
onClick={() => setShowPackMenu(false)}
|
||||
>
|
||||
<Package className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
Register from Filesystem
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
Load pack from local directory
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/packs/install"
|
||||
className="flex items-start gap-3 px-4 py-3 hover:bg-gray-50 transition-colors border-t border-gray-100"
|
||||
onClick={() => setShowPackMenu(false)}
|
||||
>
|
||||
<GitBranch className="w-5 h-5 text-purple-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
Install from Remote
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
Install from git, archive, or registry
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{filteredPacks.length} of {packs.length} packs
|
||||
</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 packs..."
|
||||
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">
|
||||
{packs.length === 0 ? (
|
||||
<div className="bg-white p-8 text-center rounded-lg shadow-sm m-2">
|
||||
<p className="text-gray-500">No packs found</p>
|
||||
<div className="mt-3 flex flex-col gap-2 items-center">
|
||||
<Link
|
||||
to="/packs/new"
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Create an empty pack
|
||||
</Link>
|
||||
<span className="text-xs text-gray-400">or</span>
|
||||
<Link
|
||||
to="/packs/register"
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Register from filesystem
|
||||
</Link>
|
||||
<span className="text-xs text-gray-400">or</span>
|
||||
<Link
|
||||
to="/packs/install"
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Install from remote
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : filteredPacks.length === 0 ? (
|
||||
<div className="bg-white p-8 text-center rounded-lg shadow-sm m-2">
|
||||
<p className="text-gray-500">No packs 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-1">
|
||||
{filteredPacks.map((pack: any) => (
|
||||
<Link
|
||||
key={pack.id}
|
||||
to={`/packs/${pack.ref}`}
|
||||
className={`block p-3 rounded-lg transition-colors ${
|
||||
ref === pack.ref
|
||||
? "bg-blue-50 border-2 border-blue-500"
|
||||
: "bg-white border-2 border-transparent hover:bg-gray-100 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium text-sm text-gray-900 truncate">
|
||||
{pack.label}
|
||||
</div>
|
||||
{pack.is_standard && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Standard
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-gray-500 mt-1 truncate">
|
||||
{pack.ref}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1 truncate">
|
||||
v{pack.version}
|
||||
</div>
|
||||
{pack.description && (
|
||||
<div className="text-xs text-gray-400 mt-1 line-clamp-2">
|
||||
{pack.description}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel - Pack Detail or Empty State */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{ref ? (
|
||||
<PackDetail packRef={ref} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center text-gray-500">
|
||||
<Package className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">
|
||||
No pack selected
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Select a pack from the list to view its details
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PackDetail({ packRef }: { packRef: string }) {
|
||||
const { data: pack, isLoading, error } = usePack(packRef);
|
||||
const { data: actions } = usePackActions(packRef);
|
||||
const deletePack = useDeletePack();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deletePack.mutateAsync(packRef);
|
||||
window.location.href = "/packs";
|
||||
} catch (err) {
|
||||
console.error("Failed to delete pack:", 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 || !pack) {
|
||||
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 : "Pack not found"}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const packActions = actions || [];
|
||||
|
||||
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">{pack.data?.label}</h1>
|
||||
{pack.data?.is_standard && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
|
||||
Standard
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
to={`/packs/${packRef}/edit`}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={deletePack.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 pack{" "}
|
||||
<strong>{pack.data?.label}</strong>? This will also delete all
|
||||
associated actions, triggers, sensors, and rules.
|
||||
</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">Pack 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">
|
||||
{pack.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">
|
||||
{pack.data?.label}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Version</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{pack.data?.version}
|
||||
</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">
|
||||
{pack.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(pack.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(pack.data?.updated || "").toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<PackConfiguration pack={pack.data} />
|
||||
|
||||
{/* Actions */}
|
||||
{packActions.length > 0 && (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
Actions ({packActions.length})
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{packActions.map((action: any) => (
|
||||
<Link
|
||||
key={action.id}
|
||||
to={`/actions/${action.ref}`}
|
||||
className="block p-3 border border-gray-200 rounded hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="font-medium text-sm text-gray-900">
|
||||
{action.label}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-gray-500 mt-1">
|
||||
{action.ref}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{action.description && (
|
||||
<p className="text-xs text-gray-600 mt-2">
|
||||
{action.description}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Stats */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Statistics</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">Actions</span>
|
||||
<span className="text-lg font-semibold">
|
||||
{packActions.length}
|
||||
</span>
|
||||
</div>
|
||||
</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={`/actions?pack=${packRef}`}
|
||||
className="block w-full px-4 py-2 text-sm text-center bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
View Actions
|
||||
</Link>
|
||||
<Link
|
||||
to={`/triggers?pack=${packRef}`}
|
||||
className="block w-full px-4 py-2 text-sm text-center bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
View Triggers
|
||||
</Link>
|
||||
<Link
|
||||
to={`/sensors?pack=${packRef}`}
|
||||
className="block w-full px-4 py-2 text-sm text-center bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
View Sensors
|
||||
</Link>
|
||||
<Link
|
||||
to={`/rules?pack=${packRef}`}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper component to display pack configuration
|
||||
function PackConfiguration({ pack }: { pack: any }) {
|
||||
if (!pack) return null;
|
||||
|
||||
const confSchema = pack.conf_schema || {};
|
||||
const config = pack.config || {};
|
||||
const properties = confSchema.properties || {};
|
||||
|
||||
// If no schema properties, don't show the section
|
||||
if (Object.keys(properties).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Settings className="w-5 h-5 text-gray-600" />
|
||||
<h2 className="text-xl font-semibold">Configuration</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{Object.entries(properties).map(([key, schema]: [string, any]) => {
|
||||
const value = config[key];
|
||||
const hasValue = value !== undefined && value !== null;
|
||||
const displayValue = hasValue ? value : schema.default;
|
||||
const isUsingDefault = !hasValue && schema.default !== undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="border-b border-gray-200 pb-4 last:border-0 last:pb-0"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<dt className="text-sm font-medium text-gray-900 font-mono">
|
||||
{key}
|
||||
</dt>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700">
|
||||
{schema.type || "any"}
|
||||
</span>
|
||||
{isUsingDefault && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{schema.description && (
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
{schema.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<dd className="ml-4 text-sm text-right">
|
||||
<ConfigValue value={displayValue} type={schema.type} />
|
||||
</dd>
|
||||
</div>
|
||||
{schema.minimum !== undefined && schema.maximum !== undefined && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Range: {schema.minimum} - {schema.maximum}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper component to render config values based on type
|
||||
function ConfigValue({ value, type }: { value: any; type?: string }) {
|
||||
if (value === undefined || value === null) {
|
||||
return <span className="text-gray-400 italic">not set</span>;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "boolean":
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
value ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{value ? "✓ true" : "✗ false"}
|
||||
</span>
|
||||
);
|
||||
case "integer":
|
||||
case "number":
|
||||
return <span className="font-mono text-gray-900">{value}</span>;
|
||||
case "string":
|
||||
if (typeof value === "string" && value.length > 50) {
|
||||
return (
|
||||
<span className="text-gray-900 text-xs break-all max-w-xs block">
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-gray-900">{String(value)}</span>;
|
||||
case "array":
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<span className="text-gray-900 text-xs">[{value.length} items]</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-gray-900">{JSON.stringify(value)}</span>;
|
||||
case "object":
|
||||
if (typeof value === "object") {
|
||||
return (
|
||||
<span className="text-gray-900 text-xs">
|
||||
{"{" + Object.keys(value).length + " keys}"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-gray-900">{JSON.stringify(value)}</span>;
|
||||
default:
|
||||
// For unknown types, try to display intelligently
|
||||
if (typeof value === "boolean") {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
value
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{value ? "✓ true" : "✗ false"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return (
|
||||
<pre className="text-xs text-gray-900 max-w-xs overflow-auto">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
return <span className="text-gray-900">{String(value)}</span>;
|
||||
}
|
||||
}
|
||||
25
web/src/pages/rules/RuleCreatePage.tsx
Normal file
25
web/src/pages/rules/RuleCreatePage.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import RuleForm from "@/components/forms/RuleForm";
|
||||
|
||||
export default function RuleCreatePage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to="/rules"
|
||||
className="text-sm text-blue-600 hover:text-blue-800 mb-2 inline-block"
|
||||
>
|
||||
← Back to Rules
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Create New Rule</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Define a new automation rule by connecting a trigger to an action
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<RuleForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
web/src/pages/rules/RuleEditPage.tsx
Normal file
67
web/src/pages/rules/RuleEditPage.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import { useRule } from "@/hooks/useRules";
|
||||
import RuleForm from "@/components/forms/RuleForm";
|
||||
|
||||
export default function RuleEditPage() {
|
||||
const { ref } = useParams<{ ref: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: ruleData, isLoading, error } = useRule(ref || "");
|
||||
const rule = ruleData?.data;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !rule) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-red-900 mb-2">
|
||||
Failed to load rule
|
||||
</h3>
|
||||
<p className="text-red-700">
|
||||
{error instanceof Error ? error.message : "Rule not found"}
|
||||
</p>
|
||||
<Link
|
||||
to="/rules"
|
||||
className="inline-block mt-4 text-red-600 hover:text-red-800"
|
||||
>
|
||||
← Back to Rules
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to={`/rules/${rule.ref}`}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 mb-2 inline-block"
|
||||
>
|
||||
← Back to Rule
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Edit Rule</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Update the automation rule: <strong>{rule.label}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<RuleForm
|
||||
rule={rule}
|
||||
onSuccess={() => navigate(`/rules/${rule.ref}`)}
|
||||
onCancel={() => navigate(`/rules/${rule.ref}`)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
590
web/src/pages/rules/RulesPage.tsx
Normal file
590
web/src/pages/rules/RulesPage.tsx
Normal file
@@ -0,0 +1,590 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import {
|
||||
useRules,
|
||||
useRule,
|
||||
useDeleteRule,
|
||||
useEnableRule,
|
||||
useDisableRule,
|
||||
} from "@/hooks/useRules";
|
||||
import { useTrigger } from "@/hooks/useTriggers";
|
||||
import { useAction } from "@/hooks/useActions";
|
||||
import { useState, useMemo } from "react";
|
||||
import { ChevronDown, ChevronRight, Search, X } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import ParamSchemaDisplay, {
|
||||
type ParamSchema,
|
||||
} from "@/components/common/ParamSchemaDisplay";
|
||||
|
||||
export default function RulesPage() {
|
||||
const { ref } = useParams<{ ref?: string }>();
|
||||
const { data, isLoading, error } = useRules({});
|
||||
const rules = data?.data || [];
|
||||
const [collapsedPacks, setCollapsedPacks] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Filter rules based on search query
|
||||
const filteredRules = useMemo(() => {
|
||||
if (!searchQuery.trim()) return rules;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return rules.filter((rule: any) => {
|
||||
return (
|
||||
rule.label?.toLowerCase().includes(query) ||
|
||||
rule.ref?.toLowerCase().includes(query) ||
|
||||
rule.description?.toLowerCase().includes(query) ||
|
||||
rule.pack_ref?.toLowerCase().includes(query) ||
|
||||
rule.trigger_ref?.toLowerCase().includes(query) ||
|
||||
rule.action_ref?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [rules, searchQuery]);
|
||||
|
||||
// Group filtered rules by pack
|
||||
const rulesByPack = useMemo(() => {
|
||||
const grouped = new Map<string, any[]>();
|
||||
filteredRules.forEach((rule: any) => {
|
||||
const packRef = rule.pack_ref || "unknown";
|
||||
if (!grouped.has(packRef)) {
|
||||
grouped.set(packRef, []);
|
||||
}
|
||||
grouped.get(packRef)!.push(rule);
|
||||
});
|
||||
// Sort packs alphabetically
|
||||
return new Map(
|
||||
[...grouped.entries()].sort((a, b) => a[0].localeCompare(b[0])),
|
||||
);
|
||||
}, [filteredRules]);
|
||||
|
||||
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 - Rules 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-2">
|
||||
<h1 className="text-2xl font-bold">Rules</h1>
|
||||
<Link
|
||||
to="/rules/new"
|
||||
className="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm font-medium"
|
||||
>
|
||||
+ New
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{filteredRules.length} of {rules.length} rules
|
||||
</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 rules..."
|
||||
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">
|
||||
{rules.length === 0 ? (
|
||||
<div className="bg-white p-8 text-center rounded-lg shadow-sm m-2">
|
||||
<p className="text-gray-500">No rules found</p>
|
||||
<Link
|
||||
to="/rules/new"
|
||||
className="mt-3 inline-block text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Create your first rule
|
||||
</Link>
|
||||
</div>
|
||||
) : filteredRules.length === 0 ? (
|
||||
<div className="bg-white p-8 text-center rounded-lg shadow-sm m-2">
|
||||
<p className="text-gray-500">No rules 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(rulesByPack.entries()).map(([packRef, packRules]) => {
|
||||
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">
|
||||
{packRules.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Rules List */}
|
||||
{!isCollapsed && (
|
||||
<div className="p-1">
|
||||
{packRules.map((rule: any) => (
|
||||
<Link
|
||||
key={rule.id}
|
||||
to={`/rules/${rule.ref}`}
|
||||
className={`block p-3 rounded transition-colors ${
|
||||
ref === rule.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">
|
||||
{rule.label}
|
||||
</div>
|
||||
<span
|
||||
className={`ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
rule.enabled
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{rule.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-gray-500 mt-1 truncate">
|
||||
{rule.ref}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1 truncate">
|
||||
{rule.trigger_ref} → {rule.action_ref}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel - Rule Detail or Empty State */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{ref ? (
|
||||
<RuleDetail ruleRef={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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">
|
||||
No rule selected
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Select a rule from the list to view its details
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleDetail({ ruleRef }: { ruleRef: string }) {
|
||||
const { data: rule, isLoading, error } = useRule(ruleRef);
|
||||
const { isAuthenticated } = useAuth();
|
||||
const deleteRule = useDeleteRule();
|
||||
const enableRule = useEnableRule();
|
||||
const disableRule = useDisableRule();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [isTogglingEnabled, setIsTogglingEnabled] = useState(false);
|
||||
|
||||
// Fetch trigger and action details to get param schemas
|
||||
const { data: triggerData } = useTrigger(rule?.data?.trigger_ref || "");
|
||||
const { data: actionData } = useAction(rule?.data?.action_ref || "");
|
||||
|
||||
const triggerParamSchema: ParamSchema =
|
||||
(triggerData?.data as any)?.param_schema || {};
|
||||
const actionParamSchema: ParamSchema =
|
||||
(actionData?.data as any)?.param_schema || {};
|
||||
|
||||
const handleToggleEnabled = async () => {
|
||||
if (!rule?.data) return;
|
||||
|
||||
setIsTogglingEnabled(true);
|
||||
try {
|
||||
if (rule.data.enabled) {
|
||||
await disableRule.mutateAsync(ruleRef);
|
||||
} else {
|
||||
await enableRule.mutateAsync(ruleRef);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle rule enabled status:", err);
|
||||
} finally {
|
||||
setIsTogglingEnabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteRule.mutateAsync(ruleRef);
|
||||
window.location.href = "/rules";
|
||||
} catch (err) {
|
||||
console.error("Failed to delete rule:", 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 || !rule) {
|
||||
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 : "Rule not found"}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">{rule.data?.pack_ref}.</span>
|
||||
{rule.data?.label}
|
||||
</h1>
|
||||
{/* Toggle Switch */}
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.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={
|
||||
rule.data?.enabled ? "text-green-700" : "text-gray-700"
|
||||
}
|
||||
>
|
||||
{rule.data?.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
to={`/rules/${ruleRef}/edit`}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={deleteRule.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 rule{" "}
|
||||
<strong>
|
||||
{rule.data?.pack_ref}.{rule.data?.label}
|
||||
</strong>
|
||||
? This will prevent the rule from triggering any actions.
|
||||
</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">Rule 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">
|
||||
{rule.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">
|
||||
{rule.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/${rule.data?.pack_ref}`}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{rule.data?.pack_ref}
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Status</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{rule.data?.enabled ? "Enabled" : "Disabled"}
|
||||
</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">
|
||||
{rule.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(rule.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(rule.data?.updated || "").toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Trigger and Action */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Configuration</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-1">
|
||||
<dt className="text-sm font-medium text-gray-500 mb-1">
|
||||
Trigger
|
||||
</dt>
|
||||
<dd className="text-sm text-gray-900">
|
||||
<Link
|
||||
to={`/triggers/${rule.data?.trigger_ref}`}
|
||||
className="text-blue-600 hover:text-blue-800 font-mono"
|
||||
>
|
||||
{rule.data?.trigger_ref}
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<svg
|
||||
className="h-6 w-6 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<dt className="text-sm font-medium text-gray-500 mb-1">
|
||||
Action
|
||||
</dt>
|
||||
<dd className="text-sm text-gray-900">
|
||||
<Link
|
||||
to={`/actions/${rule.data?.action_ref}`}
|
||||
className="text-blue-600 hover:text-blue-800 font-mono"
|
||||
>
|
||||
{rule.data?.action_ref}
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rule.data?.conditions &&
|
||||
Object.keys(rule.data.conditions).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<dt className="text-sm font-medium text-gray-500 mb-2">
|
||||
Conditions
|
||||
</dt>
|
||||
<pre className="bg-gray-50 p-3 rounded text-xs overflow-x-auto">
|
||||
{JSON.stringify(rule.data.conditions, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trigger Parameters - Human-friendly display */}
|
||||
{rule.data?.trigger_params &&
|
||||
Object.keys(rule.data.trigger_params).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<dt className="text-sm font-medium text-gray-500 mb-3">
|
||||
Trigger Parameters
|
||||
</dt>
|
||||
<ParamSchemaDisplay
|
||||
schema={triggerParamSchema}
|
||||
values={rule.data.trigger_params}
|
||||
emptyMessage="No trigger parameters configured"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Parameters - Human-friendly display */}
|
||||
{rule.data?.action_params &&
|
||||
Object.keys(rule.data.action_params).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<dt className="text-sm font-medium text-gray-500 mb-3">
|
||||
Action Parameters
|
||||
</dt>
|
||||
<ParamSchemaDisplay
|
||||
schema={actionParamSchema}
|
||||
values={rule.data.action_params}
|
||||
emptyMessage="No action parameters configured"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* 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/${rule.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={`/triggers/${rule.data?.trigger_ref}`}
|
||||
className="block w-full px-4 py-2 text-sm text-center bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
View Trigger
|
||||
</Link>
|
||||
<Link
|
||||
to={`/actions/${rule.data?.action_ref}`}
|
||||
className="block w-full px-4 py-2 text-sm text-center bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
View Action
|
||||
</Link>
|
||||
<Link
|
||||
to={`/executions?rule=${rule.data?.ref}`}
|
||||
className="block w-full px-4 py-2 text-sm text-center bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
View Executions
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
438
web/src/pages/sensors/SensorsPage.tsx
Normal file
438
web/src/pages/sensors/SensorsPage.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useSensors, useSensor, useDeleteSensor } from "@/hooks/useSensors";
|
||||
import { useState, useMemo } from "react";
|
||||
import { ChevronDown, ChevronRight, Search, X } from "lucide-react";
|
||||
|
||||
export default function SensorsPage() {
|
||||
const { ref } = useParams<{ ref?: string }>();
|
||||
const { data, isLoading, error } = useSensors({});
|
||||
const sensors = data?.data || [];
|
||||
const [collapsedPacks, setCollapsedPacks] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Filter sensors based on search query
|
||||
const filteredSensors = useMemo(() => {
|
||||
if (!searchQuery.trim()) return sensors;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return sensors.filter((sensor: any) => {
|
||||
return (
|
||||
sensor.label?.toLowerCase().includes(query) ||
|
||||
sensor.ref?.toLowerCase().includes(query) ||
|
||||
sensor.description?.toLowerCase().includes(query) ||
|
||||
sensor.pack_ref?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [sensors, searchQuery]);
|
||||
|
||||
// Group filtered sensors by pack
|
||||
const sensorsByPack = useMemo(() => {
|
||||
const grouped = new Map<string, any[]>();
|
||||
filteredSensors.forEach((sensor: any) => {
|
||||
const packRef = sensor.pack_ref || "unknown";
|
||||
if (!grouped.has(packRef)) {
|
||||
grouped.set(packRef, []);
|
||||
}
|
||||
grouped.get(packRef)!.push(sensor);
|
||||
});
|
||||
// Sort packs alphabetically
|
||||
return new Map(
|
||||
[...grouped.entries()].sort((a, b) => a[0].localeCompare(b[0])),
|
||||
);
|
||||
}, [filteredSensors]);
|
||||
|
||||
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 - Sensors 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">
|
||||
<h1 className="text-2xl font-bold">Sensors</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{filteredSensors.length} of {sensors.length} sensors
|
||||
</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 sensors..."
|
||||
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">
|
||||
{sensors.length === 0 ? (
|
||||
<div className="bg-white p-8 text-center rounded-lg shadow-sm m-2">
|
||||
<p className="text-gray-500">No sensors found</p>
|
||||
</div>
|
||||
) : filteredSensors.length === 0 ? (
|
||||
<div className="bg-white p-8 text-center rounded-lg shadow-sm m-2">
|
||||
<p className="text-gray-500">No sensors 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(sensorsByPack.entries()).map(
|
||||
([packRef, packSensors]) => {
|
||||
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">
|
||||
{packSensors.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Sensors List */}
|
||||
{!isCollapsed && (
|
||||
<div className="p-1">
|
||||
{packSensors.map((sensor: any) => (
|
||||
<Link
|
||||
key={sensor.id}
|
||||
to={`/sensors/${sensor.ref}`}
|
||||
className={`block p-3 rounded transition-colors ${
|
||||
ref === sensor.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">
|
||||
{sensor.label}
|
||||
</div>
|
||||
<span
|
||||
className={`ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
sensor.enabled
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{sensor.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-gray-500 mt-1 truncate">
|
||||
{sensor.ref}
|
||||
</div>
|
||||
{sensor.description && (
|
||||
<div className="text-xs text-gray-400 mt-1 line-clamp-2">
|
||||
{sensor.description}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel - Sensor Detail or Empty State */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{ref ? (
|
||||
<SensorDetail sensorRef={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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">
|
||||
No sensor selected
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Select a sensor from the list to view its details
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SensorDetail({ sensorRef }: { sensorRef: string }) {
|
||||
const { data: sensor, isLoading, error } = useSensor(sensorRef);
|
||||
const deleteSensor = useDeleteSensor();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteSensor.mutateAsync(sensorRef);
|
||||
window.location.href = "/sensors";
|
||||
} catch (err) {
|
||||
console.error("Failed to delete sensor:", 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 || !sensor) {
|
||||
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 : "Sensor not found"}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">{sensor.data?.pack_ref}.</span>
|
||||
{sensor.data?.label}
|
||||
</h1>
|
||||
<span
|
||||
className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${
|
||||
sensor.data?.enabled
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{sensor.data?.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={deleteSensor.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 sensor{" "}
|
||||
<strong>
|
||||
{sensor.data?.pack_ref}.{sensor.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">Sensor 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">
|
||||
{sensor.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">
|
||||
{sensor.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/${sensor.data?.pack_ref}`}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{sensor.data?.pack_ref}
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">
|
||||
Entry Point
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 font-mono">
|
||||
{sensor.data?.entrypoint}
|
||||
</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">
|
||||
{sensor.data?.description || "No description provided"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Status</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{sensor.data?.enabled ? "Enabled" : "Disabled"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">
|
||||
Trigger Type
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
<Link
|
||||
to={`/triggers/${sensor.data?.trigger_ref}`}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{sensor.data?.trigger_ref}
|
||||
</Link>
|
||||
</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(sensor.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(sensor.data?.updated || "").toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* 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/${sensor.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={`/triggers/${sensor.data?.trigger_ref}`}
|
||||
className="block w-full px-4 py-2 text-sm text-center bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
View Trigger Type
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
web/src/pages/triggers/TriggerCreatePage.tsx
Normal file
45
web/src/pages/triggers/TriggerCreatePage.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import TriggerForm from "@/components/forms/TriggerForm";
|
||||
|
||||
export default function TriggerCreatePage() {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to="/triggers"
|
||||
className="inline-flex items-center text-sm text-gray-600 hover:text-gray-900 mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back to Triggers
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Create Trigger</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Create a new ad-hoc trigger for webhooks or workflow-generated events
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">
|
||||
About Ad-hoc Triggers
|
||||
</h3>
|
||||
<ul className="text-sm text-blue-700 space-y-1 list-disc list-inside">
|
||||
<li>
|
||||
<strong>Webhooks:</strong> Create triggers that can be activated via HTTP webhooks
|
||||
</li>
|
||||
<li>
|
||||
<strong>Workflow Events:</strong> Define custom event types for workflow orchestration
|
||||
</li>
|
||||
<li>
|
||||
<strong>Schema Validation:</strong> Optionally define JSON schemas to validate event data
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<TriggerForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
web/src/pages/triggers/TriggerEditPage.tsx
Normal file
83
web/src/pages/triggers/TriggerEditPage.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import TriggerForm from "@/components/forms/TriggerForm";
|
||||
import { useTrigger } from "@/hooks/useTriggers";
|
||||
|
||||
export default function TriggerEditPage() {
|
||||
const { ref } = useParams<{ ref: string }>();
|
||||
const { data: trigger, isLoading, error } = useTrigger(ref || "");
|
||||
|
||||
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 || !trigger?.data) {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (!trigger.data.is_adhoc) {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="bg-yellow-50 border border-yellow-200 text-yellow-700 px-4 py-3 rounded">
|
||||
<p>
|
||||
Only ad-hoc triggers can be edited. This trigger was installed from
|
||||
a pack and cannot be modified.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to={`/triggers/${encodeURIComponent(ref || "")}`}
|
||||
className="inline-flex items-center text-sm text-gray-600 hover:text-gray-900 mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back to Trigger
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Edit Trigger</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Update the configuration for{" "}
|
||||
<span className="font-mono text-gray-800">
|
||||
{trigger.data.pack_ref}.{trigger.data.label}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">
|
||||
About Editing Triggers
|
||||
</h3>
|
||||
<ul className="text-sm text-blue-700 space-y-1 list-disc list-inside">
|
||||
<li>Pack and reference cannot be changed after creation</li>
|
||||
<li>
|
||||
Webhook settings can be toggled on or off
|
||||
</li>
|
||||
<li>
|
||||
Schema changes will affect future events only
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<TriggerForm initialData={trigger.data} isEditing={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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