re-uploading work
This commit is contained in:
133
web/src/components/common/ErrorDisplay.tsx
Normal file
133
web/src/components/common/ErrorDisplay.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { AlertCircle, ShieldAlert } from "lucide-react";
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
error: Error | unknown;
|
||||
title?: string;
|
||||
showRetry?: boolean;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorDisplay component for consistent error messaging across the app.
|
||||
*
|
||||
* Distinguishes between:
|
||||
* - 403 Forbidden (insufficient permissions)
|
||||
* - 401 Unauthorized (handled by interceptor, but just in case)
|
||||
* - Other errors (network, server, etc.)
|
||||
*/
|
||||
export default function ErrorDisplay({
|
||||
error,
|
||||
title,
|
||||
showRetry = false,
|
||||
onRetry,
|
||||
}: ErrorDisplayProps) {
|
||||
// Type guard for axios errors
|
||||
const isAxiosError = (err: any): boolean => {
|
||||
return err?.response?.status !== undefined;
|
||||
};
|
||||
|
||||
// Check if this is a 403 (Forbidden) error
|
||||
const is403Error = (err: any): boolean => {
|
||||
return (
|
||||
err?.response?.status === 403 ||
|
||||
err?.isAuthorizationError === true
|
||||
);
|
||||
};
|
||||
|
||||
// Check if this is a 401 (Unauthorized) error
|
||||
const is401Error = (err: any): boolean => {
|
||||
return err?.response?.status === 401;
|
||||
};
|
||||
|
||||
// Extract error message
|
||||
const getErrorMessage = (err: any): string => {
|
||||
if (err?.response?.data?.message) {
|
||||
return err.response.data.message;
|
||||
}
|
||||
if (err?.message) {
|
||||
return err.message;
|
||||
}
|
||||
return "An unexpected error occurred";
|
||||
};
|
||||
|
||||
// Determine error type and render appropriate UI
|
||||
if (is403Error(error)) {
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<ShieldAlert className="h-6 w-6 text-amber-600" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="text-lg font-semibold text-amber-900">
|
||||
{title || "Access Denied"}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-amber-800">
|
||||
You do not have permission to access this resource. Your current
|
||||
role or permissions do not allow this action.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-amber-700">
|
||||
If you believe you should have access, please contact your
|
||||
system administrator.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (is401Error(error)) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="text-lg font-semibold text-red-900">
|
||||
{title || "Authentication Required"}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-red-800">
|
||||
Your session has expired or is invalid. Please log in again.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-red-700">
|
||||
You will be redirected to the login page automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Generic error display
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="text-lg font-semibold text-red-900">
|
||||
{title || "Error"}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-red-800">
|
||||
{getErrorMessage(error)}
|
||||
</p>
|
||||
{isAxiosError(error) && (error as any)?.response?.status && (
|
||||
<p className="mt-1 text-xs text-red-600">
|
||||
Status Code: {(error as any).response.status}
|
||||
</p>
|
||||
)}
|
||||
{showRetry && onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
web/src/components/common/MultiSelect.tsx
Normal file
213
web/src/components/common/MultiSelect.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Check, ChevronDown, X } from "lucide-react";
|
||||
|
||||
interface MultiSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
options: MultiSelectOption[];
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function MultiSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select...",
|
||||
label,
|
||||
className = "",
|
||||
}: MultiSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Filter options based on search query
|
||||
const filteredOptions = options.filter((option) =>
|
||||
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Focus search input when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const toggleOption = (optionValue: string) => {
|
||||
if (value.includes(optionValue)) {
|
||||
onChange(value.filter((v) => v !== optionValue));
|
||||
} else {
|
||||
onChange([...value, optionValue]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeOption = (optionValue: string) => {
|
||||
onChange(value.filter((v) => v !== optionValue));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
onChange([]);
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
const getSelectedLabels = () => {
|
||||
return value
|
||||
.map((v) => options.find((opt) => opt.value === v)?.label)
|
||||
.filter(Boolean) as string[];
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative ${className}`}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Selected Items Display */}
|
||||
<div
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="min-h-[42px] w-full px-3 py-2 border border-gray-300 rounded-md bg-white cursor-pointer hover:border-gray-400 focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-blue-500"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 flex flex-wrap gap-1.5">
|
||||
{value.length === 0 ? (
|
||||
<span className="text-gray-400 text-sm">{placeholder}</span>
|
||||
) : (
|
||||
<>
|
||||
{getSelectedLabels().map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-100 text-blue-800 text-sm rounded"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const optionValue = options.find(
|
||||
(opt) => opt.label === label
|
||||
)?.value;
|
||||
if (optionValue) removeOption(optionValue);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<X className="h-3 w-3 cursor-pointer hover:text-blue-600" />
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{value.length > 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearAll();
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 text-gray-400 transition-transform ${
|
||||
isOpen ? "transform rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-1 w-full bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-hidden">
|
||||
{/* Search Input */}
|
||||
<div className="p-2 border-b border-gray-200">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Type to search..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Options List */}
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-gray-500 text-center">
|
||||
No options found
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((option) => {
|
||||
const isSelected = value.includes(option.value);
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => toggleOption(option.value)}
|
||||
className={`px-3 py-2 cursor-pointer hover:bg-gray-100 flex items-center justify-between ${
|
||||
isSelected ? "bg-blue-50" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
isSelected
|
||||
? "font-medium text-blue-900"
|
||||
: "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<Check className="h-4 w-4 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with selected count */}
|
||||
{value.length > 0 && (
|
||||
<div className="p-2 border-t border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||
<span>{value.length} selected</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearAll();
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
366
web/src/components/common/ParamSchemaDisplay.tsx
Normal file
366
web/src/components/common/ParamSchemaDisplay.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* ParamSchemaDisplay - Read-only display component for parameters
|
||||
* Shows parameter values in a human-friendly format based on their schema
|
||||
* Supports standard JSON Schema format (https://json-schema.org/draft/2020-12/schema)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Standard JSON Schema format for parameters
|
||||
*/
|
||||
export interface ParamSchema {
|
||||
type?: "object";
|
||||
properties?: {
|
||||
[key: string]: {
|
||||
type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
|
||||
description?: string;
|
||||
default?: any;
|
||||
enum?: string[];
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
secret?: boolean;
|
||||
};
|
||||
};
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
interface ParamSchemaDisplayProps {
|
||||
schema: ParamSchema;
|
||||
values: Record<string, any>;
|
||||
className?: string;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only component that displays parameter values based on a schema
|
||||
*/
|
||||
export default function ParamSchemaDisplay({
|
||||
schema,
|
||||
values,
|
||||
className = "",
|
||||
emptyMessage = "No parameters configured",
|
||||
}: ParamSchemaDisplayProps) {
|
||||
const properties = schema.properties || {};
|
||||
const requiredFields = schema.required || [];
|
||||
const paramEntries = Object.entries(properties);
|
||||
|
||||
// Filter to only show parameters that have values
|
||||
const populatedParams = paramEntries.filter(([key]) => {
|
||||
const value = values[key];
|
||||
return value !== undefined && value !== null && value !== "";
|
||||
});
|
||||
|
||||
if (populatedParams.length === 0) {
|
||||
return (
|
||||
<div className="p-4 bg-gray-50 rounded-lg text-center text-sm text-gray-600">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a field is required
|
||||
*/
|
||||
const isRequired = (key: string): boolean => {
|
||||
return requiredFields.includes(key);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format value for display based on its type
|
||||
* Returns both the formatted value and whether it should be displayed inline
|
||||
*/
|
||||
const formatValue = (
|
||||
value: any,
|
||||
type?: string,
|
||||
): { element: React.JSX.Element; isInline: boolean } => {
|
||||
if (value === undefined || value === null) {
|
||||
return {
|
||||
element: (
|
||||
<span className="bg-gray-100 text-gray-500 italic px-2 py-1 rounded">
|
||||
Not set
|
||||
</span>
|
||||
),
|
||||
isInline: true,
|
||||
};
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "boolean":
|
||||
return {
|
||||
element: (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-1 rounded text-sm font-medium ${
|
||||
value
|
||||
? "bg-green-50 text-green-700 border border-green-200"
|
||||
: "bg-gray-100 text-gray-700 border border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{value ? "✓ Enabled" : "✗ Disabled"}
|
||||
</span>
|
||||
),
|
||||
isInline: true,
|
||||
};
|
||||
|
||||
case "array":
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return {
|
||||
element: (
|
||||
<span className="bg-gray-100 text-gray-500 italic px-2 py-1 rounded">
|
||||
Empty array
|
||||
</span>
|
||||
),
|
||||
isInline: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
element: (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-1">
|
||||
{value.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-2 text-sm text-gray-800"
|
||||
>
|
||||
<span className="text-blue-400">•</span>
|
||||
<span className="font-mono">{JSON.stringify(item)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
isInline: false,
|
||||
};
|
||||
}
|
||||
// Fallback for non-array values
|
||||
return {
|
||||
element: (
|
||||
<pre className="bg-blue-50 border border-blue-200 px-3 py-2 rounded text-xs font-mono overflow-x-auto">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
),
|
||||
isInline: false,
|
||||
};
|
||||
|
||||
case "object":
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
element: (
|
||||
<span className="bg-gray-100 text-gray-500 italic px-2 py-1 rounded">
|
||||
Empty object
|
||||
</span>
|
||||
),
|
||||
isInline: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
element: (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 space-y-2">
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k} className="flex gap-2">
|
||||
<span className="font-mono text-xs text-amber-900 font-semibold min-w-[100px]">
|
||||
{k}:
|
||||
</span>
|
||||
<span className="font-mono text-xs text-gray-900">
|
||||
{JSON.stringify(v)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
isInline: false,
|
||||
};
|
||||
}
|
||||
// Fallback for non-object values
|
||||
return {
|
||||
element: (
|
||||
<pre className="bg-amber-50 border border-amber-200 px-3 py-2 rounded text-xs font-mono overflow-x-auto">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
),
|
||||
isInline: false,
|
||||
};
|
||||
|
||||
case "number":
|
||||
case "integer":
|
||||
return {
|
||||
element: (
|
||||
<span className="bg-indigo-50 text-indigo-900 font-mono text-sm font-medium px-2 py-1 rounded border border-indigo-200">
|
||||
{value}
|
||||
</span>
|
||||
),
|
||||
isInline: true,
|
||||
};
|
||||
|
||||
default:
|
||||
// String or unknown type
|
||||
if (typeof value === "string") {
|
||||
// Check if it looks like a template/expression
|
||||
if (value.includes("{{") || value.includes("${")) {
|
||||
return {
|
||||
element: (
|
||||
<code className="bg-purple-50 border border-purple-200 text-purple-800 px-2 py-1 rounded text-sm font-mono">
|
||||
{value}
|
||||
</code>
|
||||
),
|
||||
isInline: true,
|
||||
};
|
||||
}
|
||||
// Regular string - check length for inline vs block
|
||||
const isShort = value.length < 60;
|
||||
return {
|
||||
element: (
|
||||
<span className="bg-slate-50 border border-slate-200 text-gray-900 px-2 py-1 rounded text-sm">
|
||||
{value}
|
||||
</span>
|
||||
),
|
||||
isInline: isShort,
|
||||
};
|
||||
}
|
||||
// Fallback for complex types
|
||||
return {
|
||||
element: (
|
||||
<pre className="bg-gray-50 border border-gray-200 px-3 py-2 rounded text-xs font-mono overflow-x-auto">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
),
|
||||
isInline: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-white border border-gray-300 rounded-lg p-4 shadow-sm ${className}`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{populatedParams.map(([key, param]) => {
|
||||
const value = values[key];
|
||||
const type = param?.type || "string";
|
||||
const { element: valueElement, isInline } = formatValue(value, type);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="border-b border-gray-200 pb-4 last:border-0 last:pb-0"
|
||||
>
|
||||
{isInline ? (
|
||||
// Inline layout for small values
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono font-semibold text-sm text-gray-900">
|
||||
{key}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-50 text-blue-700 rounded font-medium">
|
||||
{type}
|
||||
</span>
|
||||
{isRequired(key) && (
|
||||
<span className="text-xs px-2 py-0.5 bg-red-50 text-red-700 rounded font-medium">
|
||||
Required
|
||||
</span>
|
||||
)}
|
||||
{param?.secret && (
|
||||
<span className="text-xs px-2 py-0.5 bg-yellow-50 text-yellow-700 rounded font-medium">
|
||||
Secret
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{param?.description && (
|
||||
<p className="text-xs text-gray-600">
|
||||
{param.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start">{valueElement}</div>
|
||||
</div>
|
||||
) : (
|
||||
// Block layout for large values
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-mono font-semibold text-sm text-gray-900">
|
||||
{key}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-50 text-blue-700 rounded font-medium">
|
||||
{type}
|
||||
</span>
|
||||
{isRequired(key) && (
|
||||
<span className="text-xs px-2 py-0.5 bg-red-50 text-red-700 rounded font-medium">
|
||||
Required
|
||||
</span>
|
||||
)}
|
||||
{param?.secret && (
|
||||
<span className="text-xs px-2 py-0.5 bg-yellow-50 text-yellow-700 rounded font-medium">
|
||||
Secret
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{param?.description && (
|
||||
<p className="text-xs text-gray-600 mb-2">
|
||||
{param.description}
|
||||
</p>
|
||||
)}
|
||||
<div>{valueElement}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact variant for smaller displays - just shows key-value pairs
|
||||
*/
|
||||
export function ParamSchemaDisplayCompact({
|
||||
schema,
|
||||
values,
|
||||
className = "",
|
||||
}: ParamSchemaDisplayProps) {
|
||||
const properties = schema.properties || {};
|
||||
const paramEntries = Object.entries(properties);
|
||||
const populatedParams = paramEntries.filter(([key]) => {
|
||||
const value = values[key];
|
||||
return value !== undefined && value !== null && value !== "";
|
||||
});
|
||||
|
||||
if (populatedParams.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-gray-500 italic">No parameters set</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className={`grid grid-cols-1 gap-2 ${className}`}>
|
||||
{populatedParams.map(([key, param]) => {
|
||||
const value = values[key];
|
||||
const type = param?.type || "string";
|
||||
|
||||
let displayValue: string;
|
||||
if (type === "boolean") {
|
||||
displayValue = value ? "Yes" : "No";
|
||||
} else if (type === "array" || type === "object") {
|
||||
displayValue = JSON.stringify(value);
|
||||
} else if (param?.secret && value) {
|
||||
displayValue = "••••••••";
|
||||
} else {
|
||||
displayValue = String(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} className="flex gap-2">
|
||||
<dt className="font-mono text-xs text-gray-600 font-semibold min-w-[120px]">
|
||||
{key}:
|
||||
</dt>
|
||||
<dd className="font-mono text-xs text-gray-900 break-all">
|
||||
{displayValue}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
404
web/src/components/common/ParamSchemaForm.tsx
Normal file
404
web/src/components/common/ParamSchemaForm.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Standard JSON Schema format for parameters
|
||||
* Follows https://json-schema.org/draft/2020-12/schema
|
||||
*/
|
||||
export interface ParamSchema {
|
||||
type?: "object";
|
||||
properties?: {
|
||||
[key: string]: {
|
||||
type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
|
||||
description?: string;
|
||||
default?: any;
|
||||
enum?: string[];
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
secret?: boolean;
|
||||
};
|
||||
};
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for ParamSchemaForm component
|
||||
*/
|
||||
interface ParamSchemaFormProps {
|
||||
schema: ParamSchema;
|
||||
values: Record<string, any>;
|
||||
onChange: (values: Record<string, any>) => void;
|
||||
errors?: Record<string, string>;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic form component that renders inputs based on a parameter schema.
|
||||
* Supports standard JSON Schema format with properties and required array.
|
||||
* Supports string, number, integer, boolean, array, object, and enum types.
|
||||
*/
|
||||
export default function ParamSchemaForm({
|
||||
schema,
|
||||
values,
|
||||
onChange,
|
||||
errors = {},
|
||||
disabled = false,
|
||||
className = "",
|
||||
}: ParamSchemaFormProps) {
|
||||
const [localErrors, setLocalErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Merge external and local errors
|
||||
const allErrors = { ...localErrors, ...errors };
|
||||
|
||||
const properties = schema.properties || {};
|
||||
const requiredFields = schema.required || [];
|
||||
|
||||
// Initialize values with defaults from schema
|
||||
useEffect(() => {
|
||||
const initialValues = Object.entries(properties).reduce(
|
||||
(acc, [key, param]) => {
|
||||
if (values[key] === undefined && param?.default !== undefined) {
|
||||
acc[key] = param.default;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ ...values } as Record<string, any>,
|
||||
);
|
||||
|
||||
// Only update if there are new defaults
|
||||
if (JSON.stringify(initialValues) !== JSON.stringify(values)) {
|
||||
onChange(initialValues);
|
||||
}
|
||||
}, [schema]); // Only run when schema changes
|
||||
|
||||
/**
|
||||
* Handle input change for a specific field
|
||||
*/
|
||||
const handleInputChange = (key: string, value: any) => {
|
||||
const newValues = { ...values, [key]: value };
|
||||
onChange(newValues);
|
||||
|
||||
// Clear error for this field
|
||||
if (allErrors[key]) {
|
||||
setLocalErrors((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[key];
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a field is required
|
||||
*/
|
||||
const isRequired = (key: string): boolean => {
|
||||
return requiredFields.includes(key);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render input field based on parameter type
|
||||
*/
|
||||
const renderInput = (key: string, param: any) => {
|
||||
const type = param?.type || "string";
|
||||
const value = values[key] ?? param?.default ?? "";
|
||||
const isDisabled = disabled;
|
||||
|
||||
switch (type) {
|
||||
case "boolean":
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!value}
|
||||
onChange={(e) => handleInputChange(key, e.target.checked)}
|
||||
disabled={isDisabled}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{param?.description || "Enable"}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
case "number":
|
||||
case "integer":
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
key,
|
||||
type === "integer"
|
||||
? parseInt(e.target.value) || 0
|
||||
: parseFloat(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
disabled={isDisabled}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
placeholder={param?.description}
|
||||
step={type === "integer" ? "1" : "any"}
|
||||
min={param?.minimum}
|
||||
max={param?.maximum}
|
||||
/>
|
||||
);
|
||||
|
||||
case "array":
|
||||
return (
|
||||
<textarea
|
||||
value={
|
||||
Array.isArray(value) ? JSON.stringify(value, null, 2) : value
|
||||
}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
handleInputChange(key, parsed);
|
||||
} catch {
|
||||
// Allow intermediate invalid JSON while typing
|
||||
handleInputChange(key, e.target.value);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Validate on blur
|
||||
try {
|
||||
if (typeof value === "string") {
|
||||
const parsed = JSON.parse(value);
|
||||
handleInputChange(key, parsed);
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON - will be caught by validation
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
placeholder='["item1", "item2"]'
|
||||
/>
|
||||
);
|
||||
|
||||
case "object":
|
||||
return (
|
||||
<textarea
|
||||
value={
|
||||
typeof value === "object" && value !== null
|
||||
? JSON.stringify(value, null, 2)
|
||||
: value
|
||||
}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
handleInputChange(key, parsed);
|
||||
} catch {
|
||||
// Allow intermediate invalid JSON while typing
|
||||
handleInputChange(key, e.target.value);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Validate on blur
|
||||
try {
|
||||
if (typeof value === "string") {
|
||||
const parsed = JSON.parse(value);
|
||||
handleInputChange(key, parsed);
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON - will be caught by validation
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
// String type - check for enum
|
||||
if (param?.enum && param.enum.length > 0) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(key, e.target.value)}
|
||||
disabled={isDisabled}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{param.enum.map((option: any) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// Default to text input
|
||||
return (
|
||||
<input
|
||||
type={param?.secret ? "password" : "text"}
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(key, e.target.value)}
|
||||
disabled={isDisabled}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
placeholder={param?.description}
|
||||
minLength={param?.minLength}
|
||||
maxLength={param?.maxLength}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const paramEntries = Object.entries(properties);
|
||||
|
||||
if (paramEntries.length === 0) {
|
||||
return (
|
||||
<div className="p-4 bg-gray-50 rounded-lg text-center text-sm text-gray-600">
|
||||
No parameters required
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{paramEntries.map(([key, param]) => (
|
||||
<div key={key}>
|
||||
<label className="block mb-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono font-semibold text-sm">{key}</span>
|
||||
{isRequired(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 || "string"}
|
||||
</span>
|
||||
{param?.secret && (
|
||||
<span className="text-xs px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded">
|
||||
Secret
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{param?.description && param?.type !== "boolean" && (
|
||||
<p className="text-xs text-gray-600 mb-2">{param.description}</p>
|
||||
)}
|
||||
</label>
|
||||
{renderInput(key, param)}
|
||||
{allErrors[key] && (
|
||||
<p className="text-xs text-red-600 mt-1">{allErrors[key]}</p>
|
||||
)}
|
||||
{param?.default !== undefined &&
|
||||
!values[key] &&
|
||||
values[key] !== param.default && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Default: {JSON.stringify(param.default)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to validate parameter values against a schema
|
||||
* Supports standard JSON Schema format
|
||||
*/
|
||||
export function validateParamSchema(
|
||||
schema: ParamSchema,
|
||||
values: Record<string, any>,
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
const properties = schema.properties || {};
|
||||
const requiredFields = schema.required || [];
|
||||
|
||||
// Check required fields
|
||||
requiredFields.forEach((key) => {
|
||||
const value = values[key];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
errors[key] = "This field is required";
|
||||
}
|
||||
});
|
||||
|
||||
// Type-specific validation
|
||||
Object.entries(properties).forEach(([key, param]) => {
|
||||
const value = values[key];
|
||||
|
||||
// Skip if no value and not required
|
||||
if (
|
||||
(value === undefined || value === null || value === "") &&
|
||||
!requiredFields.includes(key)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = param?.type || "string";
|
||||
|
||||
switch (type) {
|
||||
case "number":
|
||||
case "integer":
|
||||
if (typeof value !== "number" && isNaN(Number(value))) {
|
||||
errors[key] = `Must be a valid ${type}`;
|
||||
} else {
|
||||
const numValue = typeof value === "number" ? value : Number(value);
|
||||
if (param?.minimum !== undefined && numValue < param.minimum) {
|
||||
errors[key] = `Must be at least ${param.minimum}`;
|
||||
}
|
||||
if (param?.maximum !== undefined && numValue > param.maximum) {
|
||||
errors[key] = `Must be at most ${param.maximum}`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "array":
|
||||
if (!Array.isArray(value)) {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
} catch {
|
||||
errors[key] = "Must be a valid array (JSON format)";
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "object":
|
||||
if (typeof value !== "object" || Array.isArray(value)) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
errors[key] = "Must be a valid object (JSON format)";
|
||||
}
|
||||
} catch {
|
||||
errors[key] = "Must be a valid object (JSON format)";
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "string":
|
||||
if (typeof value === "string") {
|
||||
if (
|
||||
param?.minLength !== undefined &&
|
||||
value.length < param.minLength
|
||||
) {
|
||||
errors[key] = `Must be at least ${param.minLength} characters`;
|
||||
}
|
||||
if (
|
||||
param?.maxLength !== undefined &&
|
||||
value.length > param.maxLength
|
||||
) {
|
||||
errors[key] = `Must be at most ${param.maxLength} characters`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Enum validation
|
||||
if (param?.enum && param.enum.length > 0) {
|
||||
if (!param.enum.includes(value)) {
|
||||
errors[key] = `Must be one of: ${param.enum.join(", ")}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
28
web/src/components/common/ProtectedRoute.tsx
Normal file
28
web/src/components/common/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
672
web/src/components/common/SchemaBuilder.tsx
Normal file
672
web/src/components/common/SchemaBuilder.tsx
Normal file
@@ -0,0 +1,672 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Trash2, ChevronDown, ChevronRight, Code } from "lucide-react";
|
||||
|
||||
interface SchemaProperty {
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
default?: string;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
enum?: string[];
|
||||
}
|
||||
|
||||
interface SchemaBuilderProps {
|
||||
value: Record<string, any>;
|
||||
onChange: (schema: Record<string, any>) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const PROPERTY_TYPES = [
|
||||
{ value: "string", label: "String" },
|
||||
{ value: "number", label: "Number" },
|
||||
{ value: "integer", label: "Integer" },
|
||||
{ value: "boolean", label: "Boolean" },
|
||||
{ value: "array", label: "Array" },
|
||||
{ value: "object", label: "Object" },
|
||||
];
|
||||
|
||||
export default function SchemaBuilder({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder,
|
||||
error,
|
||||
className = "",
|
||||
disabled = false,
|
||||
}: SchemaBuilderProps) {
|
||||
const [properties, setProperties] = useState<SchemaProperty[]>([]);
|
||||
const [showRawJson, setShowRawJson] = useState(false);
|
||||
const [rawJson, setRawJson] = useState("");
|
||||
const [rawJsonError, setRawJsonError] = useState("");
|
||||
const [expandedProperties, setExpandedProperties] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
// Initialize properties from schema value
|
||||
useEffect(() => {
|
||||
if (value && value.properties) {
|
||||
const props: SchemaProperty[] = [];
|
||||
const requiredFields = value.required || [];
|
||||
|
||||
Object.entries(value.properties).forEach(
|
||||
([name, propDef]: [string, any]) => {
|
||||
props.push({
|
||||
name,
|
||||
type: propDef.type || "string",
|
||||
description: propDef.description || "",
|
||||
required: requiredFields.includes(name),
|
||||
default:
|
||||
propDef.default !== undefined
|
||||
? JSON.stringify(propDef.default)
|
||||
: undefined,
|
||||
minimum: propDef.minimum,
|
||||
maximum: propDef.maximum,
|
||||
minLength: propDef.minLength,
|
||||
maxLength: propDef.maxLength,
|
||||
pattern: propDef.pattern,
|
||||
enum: propDef.enum,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
setProperties(props);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update raw JSON when switching to raw view
|
||||
useEffect(() => {
|
||||
if (showRawJson) {
|
||||
setRawJson(JSON.stringify(buildSchema(), null, 2));
|
||||
setRawJsonError("");
|
||||
}
|
||||
}, [showRawJson]);
|
||||
|
||||
const buildSchema = (): Record<string, any> => {
|
||||
if (properties.length === 0) {
|
||||
return {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
};
|
||||
}
|
||||
|
||||
const schema: Record<string, any> = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [] as string[],
|
||||
};
|
||||
|
||||
properties.forEach((prop) => {
|
||||
const propSchema: Record<string, any> = {
|
||||
type: prop.type,
|
||||
};
|
||||
|
||||
if (prop.description) {
|
||||
propSchema.description = prop.description;
|
||||
}
|
||||
|
||||
if (prop.default !== undefined && prop.default !== "") {
|
||||
try {
|
||||
propSchema.default = JSON.parse(prop.default);
|
||||
} catch {
|
||||
propSchema.default = prop.default;
|
||||
}
|
||||
}
|
||||
|
||||
// Type-specific constraints
|
||||
if (prop.type === "string") {
|
||||
if (prop.minLength !== undefined) propSchema.minLength = prop.minLength;
|
||||
if (prop.maxLength !== undefined) propSchema.maxLength = prop.maxLength;
|
||||
if (prop.pattern) propSchema.pattern = prop.pattern;
|
||||
if (prop.enum && prop.enum.length > 0) propSchema.enum = prop.enum;
|
||||
}
|
||||
|
||||
if (prop.type === "number" || prop.type === "integer") {
|
||||
if (prop.minimum !== undefined) propSchema.minimum = prop.minimum;
|
||||
if (prop.maximum !== undefined) propSchema.maximum = prop.maximum;
|
||||
}
|
||||
|
||||
schema.properties[prop.name] = propSchema;
|
||||
|
||||
if (prop.required) {
|
||||
schema.required.push(prop.name);
|
||||
}
|
||||
});
|
||||
|
||||
return schema;
|
||||
};
|
||||
|
||||
const handlePropertiesChange = (newProperties: SchemaProperty[]) => {
|
||||
setProperties(newProperties);
|
||||
const schema = buildSchemaFromProperties(newProperties);
|
||||
onChange(schema);
|
||||
};
|
||||
|
||||
const buildSchemaFromProperties = (
|
||||
props: SchemaProperty[],
|
||||
): Record<string, any> => {
|
||||
if (props.length === 0) {
|
||||
return {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
};
|
||||
}
|
||||
|
||||
const schema: Record<string, any> = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [] as string[],
|
||||
};
|
||||
|
||||
props.forEach((prop) => {
|
||||
const propSchema: Record<string, any> = {
|
||||
type: prop.type,
|
||||
};
|
||||
|
||||
if (prop.description) {
|
||||
propSchema.description = prop.description;
|
||||
}
|
||||
|
||||
if (prop.default !== undefined && prop.default !== "") {
|
||||
try {
|
||||
propSchema.default = JSON.parse(prop.default);
|
||||
} catch {
|
||||
propSchema.default = prop.default;
|
||||
}
|
||||
}
|
||||
|
||||
if (prop.type === "string") {
|
||||
if (prop.minLength !== undefined) propSchema.minLength = prop.minLength;
|
||||
if (prop.maxLength !== undefined) propSchema.maxLength = prop.maxLength;
|
||||
if (prop.pattern) propSchema.pattern = prop.pattern;
|
||||
if (prop.enum && prop.enum.length > 0) propSchema.enum = prop.enum;
|
||||
}
|
||||
|
||||
if (prop.type === "number" || prop.type === "integer") {
|
||||
if (prop.minimum !== undefined) propSchema.minimum = prop.minimum;
|
||||
if (prop.maximum !== undefined) propSchema.maximum = prop.maximum;
|
||||
}
|
||||
|
||||
schema.properties[prop.name] = propSchema;
|
||||
|
||||
if (prop.required) {
|
||||
schema.required.push(prop.name);
|
||||
}
|
||||
});
|
||||
|
||||
return schema;
|
||||
};
|
||||
|
||||
const addProperty = () => {
|
||||
const newProp: SchemaProperty = {
|
||||
name: `property_${properties.length + 1}`,
|
||||
type: "string",
|
||||
description: "",
|
||||
required: false,
|
||||
};
|
||||
const newIndex = properties.length;
|
||||
handlePropertiesChange([...properties, newProp]);
|
||||
setExpandedProperties(new Set([...expandedProperties, newIndex]));
|
||||
};
|
||||
|
||||
const removeProperty = (index: number) => {
|
||||
const newProperties = properties.filter((_, i) => i !== index);
|
||||
handlePropertiesChange(newProperties);
|
||||
|
||||
// Update expanded indices: remove the deleted index and shift down higher indices
|
||||
const newExpanded = new Set<number>();
|
||||
expandedProperties.forEach((expandedIndex) => {
|
||||
if (expandedIndex < index) {
|
||||
newExpanded.add(expandedIndex);
|
||||
} else if (expandedIndex > index) {
|
||||
newExpanded.add(expandedIndex - 1);
|
||||
}
|
||||
// If expandedIndex === index, it's removed (not added to newExpanded)
|
||||
});
|
||||
setExpandedProperties(newExpanded);
|
||||
};
|
||||
|
||||
const updateProperty = (index: number, updates: Partial<SchemaProperty>) => {
|
||||
const newProperties = [...properties];
|
||||
newProperties[index] = { ...newProperties[index], ...updates };
|
||||
handlePropertiesChange(newProperties);
|
||||
};
|
||||
|
||||
const toggleExpanded = (index: number) => {
|
||||
const newExpanded = new Set(expandedProperties);
|
||||
if (newExpanded.has(index)) {
|
||||
newExpanded.delete(index);
|
||||
} else {
|
||||
newExpanded.add(index);
|
||||
}
|
||||
setExpandedProperties(newExpanded);
|
||||
};
|
||||
|
||||
const handleRawJsonChange = (newJson: string) => {
|
||||
setRawJson(newJson);
|
||||
setRawJsonError("");
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(newJson);
|
||||
if (parsed.type !== "object") {
|
||||
setRawJsonError('Schema must have type "object" at root level');
|
||||
return;
|
||||
}
|
||||
onChange(parsed);
|
||||
|
||||
// Update properties from parsed JSON
|
||||
const props: SchemaProperty[] = [];
|
||||
const requiredFields = parsed.required || [];
|
||||
|
||||
if (parsed.properties) {
|
||||
Object.entries(parsed.properties).forEach(
|
||||
([name, propDef]: [string, any]) => {
|
||||
props.push({
|
||||
name,
|
||||
type: propDef.type || "string",
|
||||
description: propDef.description || "",
|
||||
required: requiredFields.includes(name),
|
||||
default:
|
||||
propDef.default !== undefined
|
||||
? JSON.stringify(propDef.default)
|
||||
: undefined,
|
||||
minimum: propDef.minimum,
|
||||
maximum: propDef.maximum,
|
||||
minLength: propDef.minLength,
|
||||
maxLength: propDef.maxLength,
|
||||
pattern: propDef.pattern,
|
||||
enum: propDef.enum,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setProperties(props);
|
||||
} catch (e: any) {
|
||||
setRawJsonError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="border border-gray-300 rounded-lg overflow-hidden">
|
||||
{/* Header with view toggle */}
|
||||
<div className="bg-gray-50 px-4 py-2 border-b border-gray-200 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{showRawJson ? "Raw JSON Schema" : "Schema Properties"}
|
||||
{disabled && (
|
||||
<span className="ml-2 text-xs px-2 py-0.5 bg-gray-200 text-gray-600 rounded">
|
||||
Read-only
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRawJson(!showRawJson)}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
{showRawJson ? "Visual Editor" : "Raw JSON"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white p-4">
|
||||
{showRawJson ? (
|
||||
// Raw JSON editor
|
||||
<div>
|
||||
<textarea
|
||||
value={rawJson}
|
||||
onChange={(e) => handleRawJsonChange(e.target.value)}
|
||||
rows={12}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-xs ${
|
||||
rawJsonError ? "border-red-500" : "border-gray-300"
|
||||
} ${disabled ? "bg-gray-100 cursor-not-allowed" : ""}`}
|
||||
placeholder={
|
||||
placeholder || '{"type": "object", "properties": {...}}'
|
||||
}
|
||||
/>
|
||||
{rawJsonError && (
|
||||
<p className="mt-1 text-sm text-red-600">{rawJsonError}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Visual property editor
|
||||
<div className="space-y-3">
|
||||
{properties.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p className="text-sm">No properties defined</p>
|
||||
<p className="text-xs mt-1">
|
||||
Click "Add Property" to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
properties.map((prop, index) => {
|
||||
const isExpanded = expandedProperties.has(index);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 rounded-lg overflow-hidden"
|
||||
>
|
||||
{/* Property header */}
|
||||
<div className="bg-gray-50 px-3 py-2 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(index)}
|
||||
className="flex items-center gap-2 flex-1 text-left"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-gray-500" />
|
||||
)}
|
||||
<span className="font-mono text-sm font-medium text-gray-900">
|
||||
{prop.name}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 rounded">
|
||||
{prop.type}
|
||||
</span>
|
||||
{prop.required && (
|
||||
<span className="text-xs px-2 py-0.5 bg-red-100 text-red-700 rounded">
|
||||
Required
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeProperty(index)}
|
||||
className="text-red-600 hover:text-red-800 p-1"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Property details (collapsible) */}
|
||||
{isExpanded && (
|
||||
<div className="p-3 space-y-3 bg-white">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Property Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={prop.name}
|
||||
onChange={(e) =>
|
||||
updateProperty(index, { name: e.target.value })
|
||||
}
|
||||
disabled={disabled}
|
||||
className={`w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
disabled ? "bg-gray-100 cursor-not-allowed" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
value={prop.type}
|
||||
onChange={(e) =>
|
||||
updateProperty(index, { type: e.target.value })
|
||||
}
|
||||
disabled={disabled}
|
||||
className={`w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
disabled ? "bg-gray-100 cursor-not-allowed" : ""
|
||||
}`}
|
||||
>
|
||||
{PROPERTY_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={prop.description}
|
||||
onChange={(e) =>
|
||||
updateProperty(index, {
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Describe this property..."
|
||||
disabled={disabled}
|
||||
className={`w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
disabled ? "bg-gray-100 cursor-not-allowed" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Required checkbox */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`required-${index}`}
|
||||
checked={prop.required}
|
||||
onChange={(e) =>
|
||||
updateProperty(index, {
|
||||
required: e.target.checked,
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
className={`h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded ${
|
||||
disabled ? "cursor-not-allowed opacity-50" : ""
|
||||
}`}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`required-${index}`}
|
||||
className="ml-2 text-xs font-medium text-gray-700"
|
||||
>
|
||||
Required field
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Default value */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Default Value (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={prop.default || ""}
|
||||
onChange={(e) =>
|
||||
updateProperty(index, {
|
||||
default: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={
|
||||
prop.type === "string"
|
||||
? '"default value"'
|
||||
: prop.type === "number"
|
||||
? "0"
|
||||
: prop.type === "boolean"
|
||||
? "true"
|
||||
: ""
|
||||
}
|
||||
disabled={disabled}
|
||||
className={`w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono ${
|
||||
disabled ? "bg-gray-100 cursor-not-allowed" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* String-specific fields */}
|
||||
{prop.type === "string" && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Min Length
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={prop.minLength || ""}
|
||||
onChange={(e) =>
|
||||
updateProperty(index, {
|
||||
minLength: e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
className={`w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
disabled
|
||||
? "bg-gray-100 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Max Length
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={prop.maxLength || ""}
|
||||
onChange={(e) =>
|
||||
updateProperty(index, {
|
||||
maxLength: e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
className={`w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
disabled
|
||||
? "bg-gray-100 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Pattern (regex)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={prop.pattern || ""}
|
||||
onChange={(e) =>
|
||||
updateProperty(index, {
|
||||
pattern: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="^[a-z0-9_]+$"
|
||||
disabled={disabled}
|
||||
className={`w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono ${
|
||||
disabled
|
||||
? "bg-gray-100 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Number-specific fields */}
|
||||
{(prop.type === "number" ||
|
||||
prop.type === "integer") && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Minimum
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={prop.minimum || ""}
|
||||
onChange={(e) =>
|
||||
updateProperty(index, {
|
||||
minimum: e.target.value
|
||||
? parseFloat(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
className={`w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
disabled
|
||||
? "bg-gray-100 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Maximum
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={prop.maximum || ""}
|
||||
onChange={(e) =>
|
||||
updateProperty(index, {
|
||||
maximum: e.target.value
|
||||
? parseFloat(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
className={`w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
disabled
|
||||
? "bg-gray-100 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Add property button */}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addProperty}
|
||||
className="w-full px-4 py-2 border-2 border-dashed border-gray-300 rounded-lg text-gray-600 hover:border-blue-500 hover:text-blue-600 flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Property
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
647
web/src/components/forms/PackForm.tsx
Normal file
647
web/src/components/forms/PackForm.tsx
Normal file
@@ -0,0 +1,647 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCreatePack, useUpdatePack } from "@/hooks/usePacks";
|
||||
import type { PackResponse } from "@/api";
|
||||
import { labelToRef } from "@/lib/format-utils";
|
||||
import SchemaBuilder from "@/components/common/SchemaBuilder";
|
||||
import ParamSchemaForm from "@/components/common/ParamSchemaForm";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
|
||||
interface PackFormProps {
|
||||
pack?: PackResponse;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function PackForm({ pack, onSuccess, onCancel }: PackFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const isEditing = !!pack;
|
||||
|
||||
// Store initial/database state for reset
|
||||
const initialConfSchema = pack?.conf_schema || {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
};
|
||||
const initialConfig = pack?.config || {};
|
||||
|
||||
// Form state
|
||||
const [ref, setRef] = useState(pack?.ref || "");
|
||||
const [label, setLabel] = useState(pack?.label || "");
|
||||
const [description, setDescription] = useState(pack?.description || "");
|
||||
const [version, setVersion] = useState(pack?.version || "1.0.0");
|
||||
const [tags, setTags] = useState(pack?.tags?.join(", ") || "");
|
||||
const [runtimeDeps, setRuntimeDeps] = useState(
|
||||
pack?.runtime_deps?.join(", ") || "",
|
||||
);
|
||||
const [isStandard, setIsStandard] = useState(pack?.is_standard ?? false);
|
||||
|
||||
const [configValues, setConfigValues] =
|
||||
useState<Record<string, any>>(initialConfig);
|
||||
const [confSchema, setConfSchema] =
|
||||
useState<Record<string, any>>(initialConfSchema);
|
||||
const [meta, setMeta] = useState(
|
||||
pack?.meta ? JSON.stringify(pack.meta, null, 2) : "{}",
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Mutations
|
||||
const createPack = useCreatePack();
|
||||
const updatePack = useUpdatePack();
|
||||
|
||||
// Check if schema has properties
|
||||
const hasSchemaProperties =
|
||||
confSchema?.properties && Object.keys(confSchema.properties).length > 0;
|
||||
|
||||
// Sync config values when schema changes (for ad-hoc packs only)
|
||||
useEffect(() => {
|
||||
if (!isStandard && hasSchemaProperties) {
|
||||
// Get current schema property names
|
||||
const schemaKeys = Object.keys(confSchema.properties || {});
|
||||
|
||||
// Create new config with only keys that exist in schema
|
||||
const syncedConfig: Record<string, any> = {};
|
||||
schemaKeys.forEach((key) => {
|
||||
if (configValues[key] !== undefined) {
|
||||
// Preserve existing value
|
||||
syncedConfig[key] = configValues[key];
|
||||
} else {
|
||||
// Use default from schema if available
|
||||
const defaultValue = confSchema.properties[key]?.default;
|
||||
if (defaultValue !== undefined) {
|
||||
syncedConfig[key] = defaultValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Only update if there's a difference
|
||||
const currentKeys = Object.keys(configValues).sort().join(",");
|
||||
const syncedKeys = Object.keys(syncedConfig).sort().join(",");
|
||||
if (currentKeys !== syncedKeys) {
|
||||
setConfigValues(syncedConfig);
|
||||
}
|
||||
}
|
||||
}, [confSchema, isStandard]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!label.trim()) {
|
||||
newErrors.label = "Label is required";
|
||||
}
|
||||
|
||||
if (!ref.trim()) {
|
||||
newErrors.ref = "Reference is required";
|
||||
} else if (!/^[a-z0-9_-]+$/.test(ref)) {
|
||||
newErrors.ref =
|
||||
"Reference must contain only lowercase letters, numbers, hyphens, and underscores";
|
||||
}
|
||||
|
||||
if (!version.trim()) {
|
||||
newErrors.version = "Version is required";
|
||||
}
|
||||
|
||||
// Validate conf_schema
|
||||
if (confSchema && confSchema.type !== "object") {
|
||||
newErrors.confSchema =
|
||||
'Config schema must have type "object" at root level';
|
||||
}
|
||||
|
||||
// Validate meta JSON
|
||||
if (meta.trim()) {
|
||||
try {
|
||||
JSON.parse(meta);
|
||||
} catch (e) {
|
||||
newErrors.meta = "Invalid JSON format";
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedConfSchema =
|
||||
Object.keys(confSchema.properties || {}).length > 0 ? confSchema : {};
|
||||
const parsedMeta = meta.trim() ? JSON.parse(meta) : {};
|
||||
const tagsList = tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t);
|
||||
const runtimeDepsList = runtimeDeps
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
.filter((d) => d);
|
||||
|
||||
try {
|
||||
if (isEditing) {
|
||||
const updateData = {
|
||||
label: label.trim(),
|
||||
description: description.trim() || undefined,
|
||||
version: version.trim(),
|
||||
conf_schema: parsedConfSchema,
|
||||
config: configValues,
|
||||
meta: parsedMeta,
|
||||
tags: tagsList,
|
||||
runtime_deps: runtimeDepsList,
|
||||
is_standard: isStandard,
|
||||
};
|
||||
await updatePack.mutateAsync({ ref: pack!.ref, data: updateData });
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} else {
|
||||
const createData = {
|
||||
ref: ref.trim(),
|
||||
label: label.trim(),
|
||||
description: description.trim() || undefined,
|
||||
version: version.trim(),
|
||||
conf_schema: parsedConfSchema,
|
||||
config: configValues,
|
||||
meta: parsedMeta,
|
||||
tags: tagsList,
|
||||
runtime_deps: runtimeDepsList,
|
||||
is_standard: isStandard,
|
||||
};
|
||||
const newPackResponse = await createPack.mutateAsync(createData);
|
||||
const newPack = newPackResponse?.data;
|
||||
if (newPack?.ref) {
|
||||
navigate(`/packs/${newPack.ref}`);
|
||||
return;
|
||||
}
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
setErrors({
|
||||
submit:
|
||||
error.response?.data?.message ||
|
||||
error.message ||
|
||||
"Failed to save pack",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
} else {
|
||||
navigate("/packs");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setConfSchema(initialConfSchema);
|
||||
setConfigValues(initialConfig);
|
||||
};
|
||||
|
||||
const insertSchemaExample = (type: "api" | "database" | "webhook") => {
|
||||
let example;
|
||||
switch (type) {
|
||||
case "api":
|
||||
example = {
|
||||
type: "object",
|
||||
properties: {
|
||||
api_key: {
|
||||
type: "string",
|
||||
description: "API authentication key",
|
||||
},
|
||||
endpoint: {
|
||||
type: "string",
|
||||
description: "API endpoint URL",
|
||||
default: "https://api.example.com",
|
||||
},
|
||||
},
|
||||
required: ["api_key"],
|
||||
};
|
||||
break;
|
||||
|
||||
case "database":
|
||||
example = {
|
||||
type: "object",
|
||||
properties: {
|
||||
host: {
|
||||
type: "string",
|
||||
description: "Database host",
|
||||
default: "localhost",
|
||||
},
|
||||
port: {
|
||||
type: "integer",
|
||||
description: "Database port",
|
||||
default: 5432,
|
||||
},
|
||||
database: {
|
||||
type: "string",
|
||||
description: "Database name",
|
||||
},
|
||||
username: {
|
||||
type: "string",
|
||||
description: "Database username",
|
||||
},
|
||||
password: {
|
||||
type: "string",
|
||||
description: "Database password",
|
||||
},
|
||||
},
|
||||
required: ["host", "database", "username", "password"],
|
||||
};
|
||||
break;
|
||||
|
||||
case "webhook":
|
||||
example = {
|
||||
type: "object",
|
||||
properties: {
|
||||
webhook_url: {
|
||||
type: "string",
|
||||
description: "Webhook destination URL",
|
||||
},
|
||||
auth_token: {
|
||||
type: "string",
|
||||
description: "Authentication token",
|
||||
},
|
||||
timeout: {
|
||||
type: "integer",
|
||||
description: "Request timeout in seconds",
|
||||
minimum: 1,
|
||||
maximum: 300,
|
||||
default: 30,
|
||||
},
|
||||
},
|
||||
required: ["webhook_url"],
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
// Update schema
|
||||
setConfSchema(example);
|
||||
|
||||
// Immediately sync config values with schema defaults
|
||||
const syncedConfig: Record<string, any> = {};
|
||||
if (example.properties) {
|
||||
Object.entries(example.properties).forEach(
|
||||
([key, propDef]: [string, any]) => {
|
||||
if (propDef.default !== undefined) {
|
||||
syncedConfig[key] = propDef.default;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
setConfigValues(syncedConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{errors.submit && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<p className="text-sm text-red-800">{errors.submit}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic Information */}
|
||||
<div className="bg-white shadow rounded-lg p-6 space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 border-b pb-2">
|
||||
Basic Information
|
||||
</h3>
|
||||
|
||||
{/* Label (display name) - MOVED FIRST */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="label"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Label <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Auto-populate ref from label if ref is empty and not editing
|
||||
if (!isEditing && !ref.trim() && label.trim()) {
|
||||
setRef(labelToRef(label));
|
||||
}
|
||||
}}
|
||||
placeholder="e.g., My Custom Pack"
|
||||
disabled={isStandard}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.label ? "border-red-500" : "border-gray-300"
|
||||
} ${isStandard ? "bg-gray-100 cursor-not-allowed" : ""}`}
|
||||
/>
|
||||
{errors.label && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.label}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Human-readable display name
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Ref (identifier) - MOVED AFTER LABEL */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ref"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Reference ID <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ref"
|
||||
value={ref}
|
||||
onChange={(e) => setRef(e.target.value)}
|
||||
disabled={isEditing}
|
||||
placeholder="e.g., my_custom_pack"
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.ref ? "border-red-500" : "border-gray-300"
|
||||
} ${isEditing ? "bg-gray-100 cursor-not-allowed" : ""}`}
|
||||
/>
|
||||
{errors.ref && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.ref}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Unique identifier. Lowercase letters, numbers, hyphens, and
|
||||
underscores only. Auto-populated from label.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="description"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe what this pack does..."
|
||||
rows={3}
|
||||
disabled={isStandard}
|
||||
className={`w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
isStandard ? "bg-gray-100 cursor-not-allowed" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="version"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Version <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="version"
|
||||
value={version}
|
||||
onChange={(e) => setVersion(e.target.value)}
|
||||
placeholder="1.0.0"
|
||||
disabled={isStandard}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.version ? "border-red-500" : "border-gray-300"
|
||||
} ${isStandard ? "bg-gray-100 cursor-not-allowed" : ""}`}
|
||||
/>
|
||||
{errors.version && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.version}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Semantic version (e.g., 1.0.0)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="tags"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Tags
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tags"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
disabled={isStandard}
|
||||
className={`w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
isStandard ? "bg-gray-100 cursor-not-allowed" : ""
|
||||
}`}
|
||||
placeholder="e.g., automation, cloud, monitoring"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Comma-separated tags for categorization
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Runtime Dependencies */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="runtimeDeps"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Runtime Dependencies
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="runtimeDeps"
|
||||
value={runtimeDeps}
|
||||
onChange={(e) => setRuntimeDeps(e.target.value)}
|
||||
disabled={isStandard}
|
||||
className={`w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
isStandard ? "bg-gray-100 cursor-not-allowed" : ""
|
||||
}`}
|
||||
placeholder="e.g., core, utils"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Comma-separated list of required pack refs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Standard Pack toggle */}
|
||||
{!isEditing && (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isStandard"
|
||||
checked={isStandard}
|
||||
onChange={(e) => setIsStandard(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label
|
||||
htmlFor="isStandard"
|
||||
className="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Mark as standard pack (installed/deployed)
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Configuration Schema */}
|
||||
<div className="bg-white shadow rounded-lg p-6 space-y-4">
|
||||
<div className="flex items-center justify-between border-b pb-2">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Configuration Schema
|
||||
</h3>
|
||||
{!isStandard && isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 px-3 py-1 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
title="Reset to database values"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isStandard && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-gray-500">Quick examples:</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertSchemaExample("api")}
|
||||
className="text-xs px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
API Example
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertSchemaExample("database")}
|
||||
className="text-xs px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
Database Example
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertSchemaExample("webhook")}
|
||||
className="text-xs px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
Webhook Example
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SchemaBuilder
|
||||
label="Configuration Schema"
|
||||
value={confSchema}
|
||||
onChange={setConfSchema}
|
||||
error={errors.confSchema}
|
||||
disabled={isStandard}
|
||||
/>
|
||||
{isStandard ? (
|
||||
<div className="-mt-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
Schema is locked for installed packs. Only configuration values
|
||||
can be edited.
|
||||
</p>
|
||||
{!hasSchemaProperties && (
|
||||
<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 installed pack has no
|
||||
configuration schema. Configuration schemas for installed
|
||||
packs must be updated via pack installation/upgrade and cannot
|
||||
be edited through the web interface.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 -mt-2">
|
||||
Define the pack's configuration parameters that can be customized
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Configuration Values - Only show if schema has properties */}
|
||||
{hasSchemaProperties && (
|
||||
<div className="pt-4 border-t">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
Configuration Values
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Set values for the configuration parameters defined above
|
||||
</p>
|
||||
</div>
|
||||
<ParamSchemaForm
|
||||
schema={confSchema.properties}
|
||||
values={configValues}
|
||||
onChange={setConfigValues}
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="bg-white shadow rounded-lg p-6 space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 border-b pb-2">
|
||||
Metadata
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="meta"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Metadata (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
id="meta"
|
||||
value={meta}
|
||||
onChange={(e) => setMeta(e.target.value)}
|
||||
rows={6}
|
||||
disabled={isStandard}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-xs ${
|
||||
errors.meta ? "border-red-500" : "border-gray-300"
|
||||
} ${isStandard ? "bg-gray-100 cursor-not-allowed" : ""}`}
|
||||
placeholder='{"author": "Your Name", "homepage": "https://..."}'
|
||||
/>
|
||||
{errors.meta && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.meta}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Additional metadata for the pack (author, license, etc.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createPack.isPending || updatePack.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{createPack.isPending || updatePack.isPending
|
||||
? "Saving..."
|
||||
: isEditing
|
||||
? "Update Pack"
|
||||
: "Create Pack"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
550
web/src/components/forms/RuleForm.tsx
Normal file
550
web/src/components/forms/RuleForm.tsx
Normal file
@@ -0,0 +1,550 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { usePacks } from "@/hooks/usePacks";
|
||||
import { useTriggers, useTrigger } from "@/hooks/useTriggers";
|
||||
import { useActions, useAction } from "@/hooks/useActions";
|
||||
import { useCreateRule, useUpdateRule } from "@/hooks/useRules";
|
||||
import ParamSchemaForm, {
|
||||
validateParamSchema,
|
||||
type ParamSchema,
|
||||
} from "@/components/common/ParamSchemaForm";
|
||||
import type { RuleResponse } from "@/types/api";
|
||||
import { labelToRef, extractLocalRef, combineRefs } from "@/lib/format-utils";
|
||||
|
||||
interface RuleFormProps {
|
||||
rule?: RuleResponse;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const isEditing = !!rule;
|
||||
|
||||
// Form state
|
||||
const [packId, setPackId] = useState<number>(rule?.pack || 0);
|
||||
const [localRef, setLocalRef] = useState(
|
||||
rule?.ref ? extractLocalRef(rule.ref) : "",
|
||||
);
|
||||
const [label, setLabel] = useState(rule?.label || "");
|
||||
const [description, setDescription] = useState(rule?.description || "");
|
||||
const [triggerId, setTriggerId] = useState<number>(rule?.trigger || 0);
|
||||
const [actionId, setActionId] = useState<number>(rule?.action || 0);
|
||||
const [conditions, setConditions] = useState(
|
||||
rule?.conditions ? JSON.stringify(rule.conditions, null, 2) : "",
|
||||
);
|
||||
const [triggerParameters, setTriggerParameters] = useState<
|
||||
Record<string, any>
|
||||
>(rule?.trigger_params || {});
|
||||
const [actionParameters, setActionParameters] = useState<Record<string, any>>(
|
||||
rule?.action_params || {},
|
||||
);
|
||||
const [enabled, setEnabled] = useState(rule?.enabled ?? true);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [triggerParamErrors, setTriggerParamErrors] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [actionParamErrors, setActionParamErrors] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
// Data fetching
|
||||
const { data: packsData } = usePacks({ pageSize: 1000 });
|
||||
const packs = packsData?.data || [];
|
||||
|
||||
const selectedPack = packs.find((p) => p.id === packId);
|
||||
|
||||
// Fetch ALL triggers and actions from all packs, not just the selected pack
|
||||
// This allows rules in ad-hoc packs to reference triggers/actions from other packs
|
||||
const { data: triggersData } = useTriggers({ pageSize: 1000 });
|
||||
const { data: actionsData } = useActions({ pageSize: 1000 });
|
||||
|
||||
const triggers = triggersData?.data || [];
|
||||
const actions = actionsData?.data || [];
|
||||
|
||||
// Get selected trigger and action refs for detail fetching
|
||||
const selectedTriggerSummary = triggers.find((t) => t.id === triggerId);
|
||||
const selectedActionSummary = actions.find((a: any) => a.id === actionId);
|
||||
|
||||
// Fetch full trigger details (including param_schema) when a trigger is selected
|
||||
const { data: triggerDetailsData } = useTrigger(
|
||||
selectedTriggerSummary?.ref || "",
|
||||
);
|
||||
const selectedTrigger = triggerDetailsData?.data;
|
||||
|
||||
// Fetch full action details (including param_schema) when an action is selected
|
||||
const { data: actionDetailsData } = useAction(
|
||||
selectedActionSummary?.ref || "",
|
||||
);
|
||||
const selectedAction = actionDetailsData?.data;
|
||||
|
||||
// Extract param schemas from full details
|
||||
const triggerParamSchema: ParamSchema =
|
||||
((selectedTrigger as any)?.param_schema as ParamSchema) || {};
|
||||
const actionParamSchema: ParamSchema =
|
||||
((selectedAction as any)?.param_schema as ParamSchema) || {};
|
||||
|
||||
// Mutations
|
||||
const createRule = useCreateRule();
|
||||
const updateRule = useUpdateRule();
|
||||
|
||||
// Reset triggers, actions, and parameters when pack changes
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setTriggerId(0);
|
||||
setActionId(0);
|
||||
setTriggerParameters({});
|
||||
setActionParameters({});
|
||||
}
|
||||
}, [packId, isEditing]);
|
||||
|
||||
// Reset trigger parameters when trigger changes
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setTriggerParameters({});
|
||||
}
|
||||
}, [triggerId, isEditing]);
|
||||
|
||||
// Reset action parameters when action changes
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setActionParameters({});
|
||||
}
|
||||
}, [actionId, isEditing]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!localRef.trim()) {
|
||||
newErrors.ref = "Reference is required";
|
||||
}
|
||||
|
||||
if (!label.trim()) {
|
||||
newErrors.label = "Label is required";
|
||||
}
|
||||
|
||||
if (!packId) {
|
||||
newErrors.pack = "Pack is required";
|
||||
}
|
||||
|
||||
if (!triggerId) {
|
||||
newErrors.trigger = "Trigger is required";
|
||||
}
|
||||
|
||||
if (!actionId) {
|
||||
newErrors.action = "Action is required";
|
||||
}
|
||||
|
||||
// Validate conditions JSON if provided
|
||||
if (conditions.trim()) {
|
||||
try {
|
||||
JSON.parse(conditions);
|
||||
} catch (e) {
|
||||
newErrors.conditions = "Invalid JSON format";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate trigger parameters
|
||||
const triggerErrors = validateParamSchema(
|
||||
triggerParamSchema,
|
||||
triggerParameters,
|
||||
);
|
||||
setTriggerParamErrors(triggerErrors);
|
||||
|
||||
// Validate action parameters
|
||||
const actionErrors = validateParamSchema(
|
||||
actionParamSchema,
|
||||
actionParameters,
|
||||
);
|
||||
setActionParamErrors(actionErrors);
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
return (
|
||||
Object.keys(newErrors).length === 0 &&
|
||||
Object.keys(triggerErrors).length === 0 &&
|
||||
Object.keys(actionErrors).length === 0
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the selected pack, trigger, and action
|
||||
const selectedPackData = packs.find((p) => p.id === packId);
|
||||
|
||||
// Combine pack ref and local ref to create full ref
|
||||
const fullRef = combineRefs(selectedPackData?.ref || "", localRef.trim());
|
||||
|
||||
const formData: any = {
|
||||
pack_ref: selectedPackData?.ref || "",
|
||||
ref: fullRef,
|
||||
label: label.trim(),
|
||||
description: description.trim(),
|
||||
trigger_ref: selectedTrigger?.ref || "",
|
||||
action_ref: selectedAction?.ref || "",
|
||||
enabled,
|
||||
};
|
||||
|
||||
// Only add optional fields if they have values
|
||||
if (conditions.trim()) {
|
||||
formData.conditions = JSON.parse(conditions);
|
||||
}
|
||||
|
||||
// Add trigger parameters if any
|
||||
if (Object.keys(triggerParameters).length > 0) {
|
||||
formData.trigger_params = triggerParameters;
|
||||
}
|
||||
|
||||
// Add action parameters if any
|
||||
if (Object.keys(actionParameters).length > 0) {
|
||||
formData.action_params = actionParameters;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEditing && rule) {
|
||||
await updateRule.mutateAsync({ ref: rule.ref, data: formData });
|
||||
} else {
|
||||
const newRuleResponse = await createRule.mutateAsync(formData);
|
||||
if (!onSuccess) {
|
||||
navigate(`/rules/${newRuleResponse.data.ref}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to save rule:", err);
|
||||
setErrors({
|
||||
submit: err instanceof Error ? err.message : "Failed to save rule",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
} else {
|
||||
navigate("/rules");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{errors.submit && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-sm text-red-600">{errors.submit}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic Information */}
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Basic Information
|
||||
</h3>
|
||||
|
||||
{/* Pack Selection */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="pack"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Pack <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="pack"
|
||||
value={packId}
|
||||
onChange={(e) => setPackId(Number(e.target.value))}
|
||||
disabled={isEditing}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.pack ? "border-red-500" : "border-gray-300"
|
||||
} ${isEditing ? "bg-gray-100 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<option value={0}>Select a pack...</option>
|
||||
{packs.map((pack: any) => (
|
||||
<option key={pack.id} value={pack.id}>
|
||||
{pack.label} ({pack.version})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.pack && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.pack}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label - MOVED FIRST */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="label"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Label <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Auto-populate localRef from label if localRef is empty and not editing
|
||||
if (!isEditing && !localRef.trim() && label.trim()) {
|
||||
setLocalRef(labelToRef(label));
|
||||
}
|
||||
}}
|
||||
placeholder="e.g., Notify on Error"
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.label ? "border-red-500" : "border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
{errors.label && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.label}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Human-readable name for display
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reference - MOVED AFTER LABEL with Pack Prefix */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ref"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Reference <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="input-with-prefix">
|
||||
<span className={`prefix ${errors.ref ? "error" : ""}`}>
|
||||
{selectedPack?.ref || "pack"}.
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
id="ref"
|
||||
value={localRef}
|
||||
onChange={(e) => setLocalRef(e.target.value)}
|
||||
placeholder="e.g., notify_on_error"
|
||||
disabled={isEditing}
|
||||
className={errors.ref ? "error" : ""}
|
||||
/>
|
||||
</div>
|
||||
{errors.ref && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.ref}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Local identifier within the pack. Auto-populated from label.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="description"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe what this rule does..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Enabled Toggle */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="enabled" className="ml-2 text-sm text-gray-700">
|
||||
Enable rule immediately
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trigger Configuration */}
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Trigger Configuration
|
||||
</h3>
|
||||
|
||||
{!packId ? (
|
||||
<p className="text-sm text-gray-500">
|
||||
Select a pack first to choose a trigger
|
||||
</p>
|
||||
) : !triggers || triggers.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">
|
||||
No triggers available in the system
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Trigger Selection */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="trigger"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Trigger <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="trigger"
|
||||
value={triggerId}
|
||||
onChange={(e) => setTriggerId(Number(e.target.value))}
|
||||
disabled={isEditing}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.trigger ? "border-red-500" : "border-gray-300"
|
||||
} ${isEditing ? "bg-gray-100 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<option value={0}>Select a trigger...</option>
|
||||
{triggers.map((trigger: any) => (
|
||||
<option key={trigger.id} value={trigger.id}>
|
||||
{trigger.ref} - {trigger.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.trigger && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.trigger}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trigger Parameters - Dynamic Form */}
|
||||
{selectedTrigger && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">
|
||||
Trigger Parameters
|
||||
</h4>
|
||||
<ParamSchemaForm
|
||||
schema={triggerParamSchema}
|
||||
values={triggerParameters}
|
||||
onChange={setTriggerParameters}
|
||||
errors={triggerParamErrors}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conditions (JSON) */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="conditions"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Match Conditions (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
id="conditions"
|
||||
value={conditions}
|
||||
onChange={(e) => setConditions(e.target.value)}
|
||||
placeholder={`{\n "and": [\n {"var": "payload.severity", ">=": 3},\n {"var": "payload.status", "==": "error"}\n ]\n}`}
|
||||
rows={8}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm ${
|
||||
errors.conditions ? "border-red-500" : "border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
{errors.conditions && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.conditions}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Optional. Leave empty to match all events from this trigger.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Configuration */}
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Action Configuration
|
||||
</h3>
|
||||
|
||||
{!packId ? (
|
||||
<p className="text-sm text-gray-500">
|
||||
Select a pack first to choose an action
|
||||
</p>
|
||||
) : !actions || actions.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">
|
||||
No actions available in the system
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Action Selection */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="action"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Action <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="action"
|
||||
value={actionId}
|
||||
onChange={(e) => setActionId(Number(e.target.value))}
|
||||
disabled={isEditing}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.action ? "border-red-500" : "border-gray-300"
|
||||
} ${isEditing ? "bg-gray-100 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<option value={0}>Select an action...</option>
|
||||
{actions.map((action: any) => (
|
||||
<option key={action.id} value={action.id}>
|
||||
{action.ref} - {action.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.action && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.action}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Parameters - Dynamic Form */}
|
||||
{selectedAction && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">
|
||||
Action Parameters
|
||||
</h4>
|
||||
<ParamSchemaForm
|
||||
schema={actionParamSchema}
|
||||
values={actionParameters}
|
||||
onChange={setActionParameters}
|
||||
errors={actionParamErrors}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createRule.isPending || updateRule.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"
|
||||
>
|
||||
{createRule.isPending || updateRule.isPending
|
||||
? "Saving..."
|
||||
: isEditing
|
||||
? "Update Rule"
|
||||
: "Create Rule"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
439
web/src/components/forms/TriggerForm.tsx
Normal file
439
web/src/components/forms/TriggerForm.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { usePacks } from "@/hooks/usePacks";
|
||||
import { useCreateTrigger, useUpdateTrigger } from "@/hooks/useTriggers";
|
||||
import {
|
||||
labelToRef,
|
||||
extractLocalRef,
|
||||
combinePackLocalRef,
|
||||
} from "@/lib/format-utils";
|
||||
import SchemaBuilder from "@/components/common/SchemaBuilder";
|
||||
import { WebhooksService } from "@/api";
|
||||
|
||||
interface TriggerFormProps {
|
||||
initialData?: any;
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
export default function TriggerForm({
|
||||
initialData,
|
||||
isEditing = false,
|
||||
}: TriggerFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Form fields
|
||||
const [packId, setPackId] = useState<number>(0);
|
||||
const [localRef, setLocalRef] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [webhookEnabled, setWebhookEnabled] = useState(false);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [paramSchema, setParamSchema] = useState<Record<string, any>>({
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
});
|
||||
const [outSchema, setOutSchema] = useState<Record<string, any>>({
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Fetch packs
|
||||
const { data: packsData } = usePacks({ page: 1, pageSize: 100 });
|
||||
const packs = packsData?.data || [];
|
||||
const selectedPack = packs.find((p: any) => p.id === packId);
|
||||
|
||||
// Mutations
|
||||
const createTrigger = useCreateTrigger();
|
||||
const updateTrigger = useUpdateTrigger();
|
||||
|
||||
// Initialize form with existing data
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setLabel(initialData.label || "");
|
||||
setDescription(initialData.description || "");
|
||||
setWebhookEnabled(initialData.webhook_enabled || false);
|
||||
setEnabled(initialData.enabled ?? true);
|
||||
setParamSchema(
|
||||
initialData.param_schema || {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
);
|
||||
setOutSchema(
|
||||
initialData.out_schema || {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
);
|
||||
|
||||
if (isEditing) {
|
||||
// Find pack by pack_ref
|
||||
const pack = packs.find((p: any) => p.ref === initialData.pack_ref);
|
||||
if (pack) {
|
||||
setPackId(pack.id);
|
||||
}
|
||||
// Extract local ref from full ref
|
||||
setLocalRef(extractLocalRef(initialData.ref, initialData.pack_ref));
|
||||
}
|
||||
}
|
||||
}, [initialData, packs, isEditing]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!packId) {
|
||||
newErrors.pack = "Pack is required";
|
||||
}
|
||||
|
||||
if (!label.trim()) {
|
||||
newErrors.label = "Label is required";
|
||||
}
|
||||
|
||||
if (!localRef.trim()) {
|
||||
newErrors.ref = "Reference is required";
|
||||
} else if (!/^[a-z0-9_]+$/.test(localRef)) {
|
||||
newErrors.ref =
|
||||
"Reference must contain only lowercase letters, numbers, and underscores";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const selectedPackData = packs.find((p: any) => p.id === packId);
|
||||
if (!selectedPackData) {
|
||||
throw new Error("Selected pack not found");
|
||||
}
|
||||
|
||||
const fullRef = combinePackLocalRef(selectedPackData.ref, localRef);
|
||||
|
||||
const formData = {
|
||||
pack_ref: selectedPackData.ref,
|
||||
ref: fullRef,
|
||||
label: label.trim(),
|
||||
description: description.trim() || undefined,
|
||||
enabled,
|
||||
param_schema:
|
||||
Object.keys(paramSchema.properties || {}).length > 0
|
||||
? paramSchema
|
||||
: undefined,
|
||||
out_schema:
|
||||
Object.keys(outSchema.properties || {}).length > 0
|
||||
? outSchema
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (isEditing && initialData?.ref) {
|
||||
await updateTrigger.mutateAsync({
|
||||
ref: initialData.ref,
|
||||
data: formData,
|
||||
});
|
||||
|
||||
// Handle webhook enable/disable separately for updates
|
||||
if (webhookEnabled !== initialData?.webhook_enabled) {
|
||||
try {
|
||||
if (webhookEnabled) {
|
||||
await WebhooksService.enableWebhook({ ref: initialData.ref });
|
||||
} else {
|
||||
await WebhooksService.disableWebhook({ ref: initialData.ref });
|
||||
}
|
||||
// Invalidate trigger cache to refresh UI with updated webhook status
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["triggers", initialData.ref],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["triggers"] });
|
||||
} catch (webhookError) {
|
||||
console.error("Failed to update webhook status:", webhookError);
|
||||
// Continue anyway - user can update it manually
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate back to trigger detail page
|
||||
navigate(`/triggers/${encodeURIComponent(initialData.ref)}`);
|
||||
return;
|
||||
} else {
|
||||
const response = await createTrigger.mutateAsync(formData);
|
||||
const newTrigger = response?.data;
|
||||
if (newTrigger?.ref) {
|
||||
// If webhook is enabled, enable it after trigger creation
|
||||
if (webhookEnabled) {
|
||||
try {
|
||||
await WebhooksService.enableWebhook({ ref: newTrigger.ref });
|
||||
} catch (webhookError) {
|
||||
console.error("Failed to enable webhook:", webhookError);
|
||||
// Continue anyway - user can enable it manually
|
||||
}
|
||||
// Invalidate trigger cache to refresh UI with webhook data
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["triggers", newTrigger.ref],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["triggers"] });
|
||||
}
|
||||
navigate(`/triggers/${encodeURIComponent(newTrigger.ref)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
navigate("/triggers");
|
||||
} catch (error: any) {
|
||||
console.error("Error submitting trigger:", error);
|
||||
setErrors({
|
||||
submit:
|
||||
error.response?.data?.message ||
|
||||
error.message ||
|
||||
"Failed to save trigger",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (isEditing && initialData?.ref) {
|
||||
navigate(`/triggers/${encodeURIComponent(initialData.ref)}`);
|
||||
} else {
|
||||
navigate("/triggers");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{errors.submit && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-sm text-red-600">{errors.submit}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic Information */}
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Basic Information
|
||||
</h3>
|
||||
|
||||
{/* Pack Selection */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="pack"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Pack <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="pack"
|
||||
value={packId}
|
||||
onChange={(e) => setPackId(Number(e.target.value))}
|
||||
disabled={isEditing}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.pack ? "border-red-500" : "border-gray-300"
|
||||
} ${isEditing ? "bg-gray-100 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<option value={0}>Select a pack...</option>
|
||||
{packs.map((pack: any) => (
|
||||
<option key={pack.id} value={pack.id}>
|
||||
{pack.label} ({pack.version})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.pack && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.pack}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="label"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Label <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Auto-populate localRef from label if localRef is empty and not editing
|
||||
if (!isEditing && !localRef.trim() && label.trim()) {
|
||||
setLocalRef(labelToRef(label));
|
||||
}
|
||||
}}
|
||||
placeholder="e.g., Webhook Received"
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.label ? "border-red-500" : "border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
{errors.label && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.label}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Human-readable name for display
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reference with Pack Prefix */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ref"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Reference <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="input-with-prefix">
|
||||
<span className={`prefix ${errors.ref ? "error" : ""}`}>
|
||||
{selectedPack?.ref || "pack"}.
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
id="ref"
|
||||
value={localRef}
|
||||
onChange={(e) => setLocalRef(e.target.value)}
|
||||
placeholder="e.g., webhook_received"
|
||||
disabled={isEditing}
|
||||
className={errors.ref ? "error" : ""}
|
||||
/>
|
||||
</div>
|
||||
{errors.ref && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.ref}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Local identifier within the pack. Auto-populated from label.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="description"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Describe what this trigger does..."
|
||||
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>
|
||||
|
||||
{/* Schema Configuration */}
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Schema Configuration
|
||||
</h3>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-700">
|
||||
Define schemas to validate event parameters and outputs. Leave empty
|
||||
for flexible schemas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Parameter Schema */}
|
||||
<SchemaBuilder
|
||||
label="Parameter Schema"
|
||||
value={paramSchema}
|
||||
onChange={setParamSchema}
|
||||
error={errors.paramSchema}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 -mt-2">
|
||||
Define the structure of event parameters that will be passed to this
|
||||
trigger
|
||||
</p>
|
||||
|
||||
{/* Output Schema */}
|
||||
<SchemaBuilder
|
||||
label="Output Schema"
|
||||
value={outSchema}
|
||||
onChange={setOutSchema}
|
||||
error={errors.outSchema}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 -mt-2">
|
||||
Define the structure of event data that will be produced by this
|
||||
trigger
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Settings</h3>
|
||||
|
||||
{/* Webhook Enabled */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="webhookEnabled"
|
||||
checked={webhookEnabled}
|
||||
onChange={(e) => setWebhookEnabled(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label
|
||||
htmlFor="webhookEnabled"
|
||||
className="ml-2 block text-sm text-gray-900"
|
||||
>
|
||||
Enable Webhook
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 ml-6">
|
||||
Allow this trigger to be activated via HTTP webhook
|
||||
</p>
|
||||
|
||||
{/* Enabled */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="enabled" className="ml-2 block text-sm text-gray-900">
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 ml-6">
|
||||
Enable or disable this trigger
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createTrigger.isPending || updateTrigger.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createTrigger.isPending || updateTrigger.isPending
|
||||
? "Saving..."
|
||||
: isEditing
|
||||
? "Update Trigger"
|
||||
: "Create Trigger"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
301
web/src/components/layout/MainLayout.tsx
Normal file
301
web/src/components/layout/MainLayout.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Link, Outlet, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import {
|
||||
Package,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
User,
|
||||
LogOut,
|
||||
CirclePlay,
|
||||
CircleArrowRight,
|
||||
SquareArrowRight,
|
||||
SquarePlay,
|
||||
SquareDot,
|
||||
CircleDot,
|
||||
SquareAsterisk,
|
||||
KeyRound,
|
||||
Home,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function MainLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
// Initialize from localStorage
|
||||
const saved = localStorage.getItem("sidebar-collapsed");
|
||||
return saved === "true";
|
||||
});
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
|
||||
// Persist collapsed state to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("sidebar-collapsed", isCollapsed.toString());
|
||||
}, [isCollapsed]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
// Navigation sections with dividers and colors
|
||||
const navSections = [
|
||||
{
|
||||
items: [{ to: "/", label: "Dashboard", icon: Home, color: "gray" }],
|
||||
},
|
||||
{
|
||||
// Component Management - Cool colors (cyan -> blue -> violet)
|
||||
items: [
|
||||
{ to: "/actions", label: "Actions", icon: SquarePlay, color: "cyan" },
|
||||
{ to: "/rules", label: "Rules", icon: SquareArrowRight, color: "blue" },
|
||||
{
|
||||
to: "/triggers",
|
||||
label: "Triggers",
|
||||
icon: SquareDot,
|
||||
color: "violet",
|
||||
},
|
||||
{
|
||||
to: "/sensors",
|
||||
label: "Sensors",
|
||||
icon: SquareAsterisk,
|
||||
color: "purple",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Runtime Logs - Warm colors (fuchsia -> rose -> orange)
|
||||
items: [
|
||||
{
|
||||
to: "/executions",
|
||||
label: "Execution History",
|
||||
icon: CirclePlay,
|
||||
color: "fuchsia",
|
||||
},
|
||||
{
|
||||
to: "/enforcements",
|
||||
label: "Enforcement History",
|
||||
icon: CircleArrowRight,
|
||||
color: "rose",
|
||||
},
|
||||
{
|
||||
to: "/events",
|
||||
label: "Event History",
|
||||
icon: CircleDot,
|
||||
color: "orange",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ to: "/keys", label: "Keys & Secrets", icon: KeyRound, color: "gray" },
|
||||
{
|
||||
to: "/packs",
|
||||
label: "Pack Management",
|
||||
icon: Package,
|
||||
color: "gray",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Color mappings for navigation items
|
||||
const colorClasses = {
|
||||
gray: {
|
||||
inactive: "text-gray-300 hover:text-white hover:bg-gray-800",
|
||||
active: "bg-gray-800 text-white",
|
||||
icon: "text-gray-400",
|
||||
},
|
||||
cyan: {
|
||||
inactive: "text-cyan-300 hover:text-cyan-100 hover:bg-cyan-950/30",
|
||||
active: "bg-cyan-950/50 text-cyan-100 shadow-lg shadow-cyan-900/50",
|
||||
icon: "text-cyan-400",
|
||||
},
|
||||
blue: {
|
||||
inactive: "text-blue-300 hover:text-blue-100 hover:bg-blue-950/30",
|
||||
active: "bg-blue-950/50 text-blue-100 shadow-lg shadow-blue-900/50",
|
||||
icon: "text-blue-400",
|
||||
},
|
||||
violet: {
|
||||
inactive: "text-violet-300 hover:text-violet-100 hover:bg-violet-950/30",
|
||||
active: "bg-violet-950/50 text-violet-100 shadow-lg shadow-violet-900/50",
|
||||
icon: "text-violet-400",
|
||||
},
|
||||
purple: {
|
||||
inactive: "text-purple-300 hover:text-purple-100 hover:bg-purple-950/30",
|
||||
active: "bg-purple-950/50 text-purple-100 shadow-lg shadow-purple-900/50",
|
||||
icon: "text-purple-400",
|
||||
},
|
||||
fuchsia: {
|
||||
inactive:
|
||||
"text-fuchsia-300 hover:text-fuchsia-100 hover:bg-fuchsia-950/30",
|
||||
active:
|
||||
"bg-fuchsia-950/50 text-fuchsia-100 shadow-lg shadow-fuchsia-900/50",
|
||||
icon: "text-fuchsia-400",
|
||||
},
|
||||
rose: {
|
||||
inactive: "text-rose-300 hover:text-rose-100 hover:bg-rose-950/30",
|
||||
active: "bg-rose-950/50 text-rose-100 shadow-lg shadow-rose-900/50",
|
||||
icon: "text-rose-400",
|
||||
},
|
||||
orange: {
|
||||
inactive: "text-orange-300 hover:text-orange-100 hover:bg-orange-950/30",
|
||||
active: "bg-orange-950/50 text-orange-100 shadow-lg shadow-orange-900/50",
|
||||
icon: "text-orange-400",
|
||||
},
|
||||
};
|
||||
|
||||
const NavLink = ({
|
||||
to,
|
||||
label,
|
||||
icon: Icon,
|
||||
color = "gray",
|
||||
}: {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
color?: string;
|
||||
}) => {
|
||||
const isActive =
|
||||
location.pathname === to ||
|
||||
(to !== "/" && location.pathname.startsWith(to));
|
||||
|
||||
const colors =
|
||||
colorClasses[color as keyof typeof colorClasses] || colorClasses.gray;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={`flex items-center gap-3 px-4 py-2 rounded-md transition-all duration-200 ${
|
||||
isActive ? colors.active : colors.inactive
|
||||
} ${isCollapsed ? "justify-center" : ""}`}
|
||||
title={isCollapsed ? label : undefined}
|
||||
>
|
||||
<Icon
|
||||
className={`w-5 h-5 flex-shrink-0 ${isActive ? "" : colors.icon}`}
|
||||
/>
|
||||
{!isCollapsed && <span>{label}</span>}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-gray-100 flex overflow-hidden">
|
||||
<div
|
||||
className={`${
|
||||
isCollapsed ? "w-20" : "w-64"
|
||||
} bg-gray-900 text-white flex flex-col transition-all duration-300 relative flex-shrink-0`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-center h-16 bg-gray-800">
|
||||
<Link
|
||||
to="/"
|
||||
className={`font-bold transition-all ${
|
||||
isCollapsed ? "text-lg" : "text-xl"
|
||||
}`}
|
||||
>
|
||||
{isCollapsed ? "A" : "Attune"}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 py-6 overflow-y-auto">
|
||||
{navSections.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex}>
|
||||
<div className="space-y-1 mb-3">
|
||||
{section.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
color={item.color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{sectionIndex < navSections.length - 1 && (
|
||||
<div className="my-3 mx-2 border-t border-gray-700" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Toggle Button */}
|
||||
<div
|
||||
className={`px-4 py-3 ${isCollapsed ? "flex justify-center" : ""}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-md transition-colors"
|
||||
title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
) : (
|
||||
<>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
<span className="text-sm">Collapse</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User Section */}
|
||||
<div className="p-4 bg-gray-800 border-t border-gray-700">
|
||||
{isCollapsed ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="w-full flex items-center justify-center p-2 rounded-md hover:bg-gray-700 transition-colors"
|
||||
title={user?.login}
|
||||
>
|
||||
<User className="w-6 h-6 text-gray-400" />
|
||||
</button>
|
||||
|
||||
{/* User Menu Popup */}
|
||||
{showUserMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
/>
|
||||
<div className="absolute bottom-full left-0 mb-2 w-48 bg-gray-800 border border-gray-700 rounded-md shadow-lg z-20">
|
||||
<div className="px-4 py-3 border-b border-gray-700">
|
||||
<p className="text-sm font-medium text-white">
|
||||
{user?.login}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-left text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<User className="w-5 h-5 text-gray-400 flex-shrink-0" />
|
||||
<p className="font-medium text-sm truncate">{user?.login}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-gray-400 hover:text-white p-1 flex-shrink-0"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
web/src/components/packs/PackTestBadge.tsx
Normal file
99
web/src/components/packs/PackTestBadge.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { CheckCircle, XCircle, Clock, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface PackTestBadgeProps {
|
||||
status: string;
|
||||
passed?: number;
|
||||
total?: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showCounts?: boolean;
|
||||
}
|
||||
|
||||
export default function PackTestBadge({
|
||||
status,
|
||||
passed,
|
||||
total,
|
||||
size = 'md',
|
||||
showCounts = true,
|
||||
}: PackTestBadgeProps) {
|
||||
const getStatusConfig = () => {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
text: 'Passed',
|
||||
bgColor: 'bg-green-50',
|
||||
textColor: 'text-green-700',
|
||||
borderColor: 'border-green-200',
|
||||
iconColor: 'text-green-600',
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
icon: XCircle,
|
||||
text: 'Failed',
|
||||
bgColor: 'bg-red-50',
|
||||
textColor: 'text-red-700',
|
||||
borderColor: 'border-red-200',
|
||||
iconColor: 'text-red-600',
|
||||
};
|
||||
case 'skipped':
|
||||
return {
|
||||
icon: Clock,
|
||||
text: 'Skipped',
|
||||
bgColor: 'bg-gray-50',
|
||||
textColor: 'text-gray-700',
|
||||
borderColor: 'border-gray-200',
|
||||
iconColor: 'text-gray-600',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
text: 'Unknown',
|
||||
bgColor: 'bg-yellow-50',
|
||||
textColor: 'text-yellow-700',
|
||||
borderColor: 'border-yellow-200',
|
||||
iconColor: 'text-yellow-600',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getSizeClasses = () => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return {
|
||||
container: 'px-2 py-1 text-xs',
|
||||
icon: 'w-3 h-3',
|
||||
gap: 'gap-1',
|
||||
};
|
||||
case 'lg':
|
||||
return {
|
||||
container: 'px-4 py-2 text-base',
|
||||
icon: 'w-5 h-5',
|
||||
gap: 'gap-2',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
container: 'px-3 py-1.5 text-sm',
|
||||
icon: 'w-4 h-4',
|
||||
gap: 'gap-1.5',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig();
|
||||
const sizeClasses = getSizeClasses();
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center ${sizeClasses.gap} ${sizeClasses.container} ${config.bgColor} ${config.textColor} border ${config.borderColor} rounded-full font-medium`}
|
||||
>
|
||||
<Icon className={`${sizeClasses.icon} ${config.iconColor}`} />
|
||||
<span>{config.text}</span>
|
||||
{showCounts && passed !== undefined && total !== undefined && (
|
||||
<span className="font-semibold">
|
||||
{passed}/{total}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
212
web/src/components/packs/PackTestHistory.tsx
Normal file
212
web/src/components/packs/PackTestHistory.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Calendar, Clock } from 'lucide-react';
|
||||
import PackTestBadge from './PackTestBadge';
|
||||
|
||||
interface TestExecution {
|
||||
id: number;
|
||||
pack_id: number;
|
||||
pack_version: string;
|
||||
execution_time: string;
|
||||
trigger_reason: string;
|
||||
total_tests: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
pass_rate: number;
|
||||
duration_ms: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface PackTestHistoryProps {
|
||||
executions: TestExecution[];
|
||||
isLoading?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
hasMore?: boolean;
|
||||
}
|
||||
|
||||
export default function PackTestHistory({
|
||||
executions,
|
||||
isLoading = false,
|
||||
onLoadMore,
|
||||
hasMore = false,
|
||||
}: PackTestHistoryProps) {
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
||||
const getTriggerBadgeColor = (trigger: string) => {
|
||||
switch (trigger.toLowerCase()) {
|
||||
case 'register':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'manual':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
case 'ci':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'schedule':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatus = (execution: TestExecution): string => {
|
||||
if (execution.status) return execution.status;
|
||||
if (execution.failed > 0) return 'failed';
|
||||
if (execution.passed === execution.total_tests) return 'passed';
|
||||
return 'partial';
|
||||
};
|
||||
|
||||
if (isLoading && executions.length === 0) {
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-8">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (executions.length === 0) {
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-8">
|
||||
<div className="text-center text-gray-500">
|
||||
<p className="text-lg font-medium mb-2">No test history</p>
|
||||
<p className="text-sm">Test executions will appear here once tests are run.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<div className="divide-y divide-gray-200">
|
||||
{executions.map((execution) => {
|
||||
const status = getStatus(execution);
|
||||
const isExpanded = expandedId === execution.id;
|
||||
|
||||
return (
|
||||
<div key={execution.id} className="hover:bg-gray-50 transition-colors">
|
||||
<button
|
||||
onClick={() => setExpandedId(isExpanded ? null : execution.id)}
|
||||
className="w-full px-6 py-4 text-left"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
{/* Status Badge */}
|
||||
<PackTestBadge
|
||||
status={status}
|
||||
passed={execution.passed}
|
||||
total={execution.total_tests}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{/* Test Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
Version {execution.pack_version}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs rounded-full ${getTriggerBadgeColor(
|
||||
execution.trigger_reason
|
||||
)}`}
|
||||
>
|
||||
{execution.trigger_reason}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>
|
||||
{new Date(execution.execution_time).toLocaleDateString()}
|
||||
{' at '}
|
||||
{new Date(execution.execution_time).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{formatDuration(execution.duration_ms)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pass Rate */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
{(execution.pass_rate * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">pass rate</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{isExpanded && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="grid grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{execution.total_tests}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Total</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{execution.passed}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Passed</div>
|
||||
</div>
|
||||
{execution.failed > 0 && (
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{execution.failed}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Failed</div>
|
||||
</div>
|
||||
)}
|
||||
{execution.skipped > 0 && (
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-600">
|
||||
{execution.skipped}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Skipped</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Link
|
||||
to={`/packs/tests/${execution.id}`}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
View Full Results →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Load More Button */}
|
||||
{hasMore && (
|
||||
<div className="p-4 border-t border-gray-200 bg-gray-50">
|
||||
<button
|
||||
onClick={onLoadMore}
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Load More'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
web/src/components/packs/PackTestResult.tsx
Normal file
267
web/src/components/packs/PackTestResult.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface TestCaseResult {
|
||||
name: string;
|
||||
status: 'passed' | 'failed' | 'skipped' | 'error';
|
||||
duration_ms: number;
|
||||
error_message?: string;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
}
|
||||
|
||||
interface TestSuiteResult {
|
||||
name: string;
|
||||
runner_type: string;
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
duration_ms: number;
|
||||
test_cases: TestCaseResult[];
|
||||
}
|
||||
|
||||
interface PackTestResultData {
|
||||
pack_ref: string;
|
||||
pack_version: string;
|
||||
execution_time: string;
|
||||
status: string;
|
||||
total_tests: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
pass_rate: number;
|
||||
duration_ms: number;
|
||||
test_suites: TestSuiteResult[];
|
||||
}
|
||||
|
||||
interface PackTestResultProps {
|
||||
result: PackTestResultData;
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
export default function PackTestResult({
|
||||
result,
|
||||
showDetails = false,
|
||||
}: PackTestResultProps) {
|
||||
const [expandedSuites, setExpandedSuites] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSuite = (suiteName: string) => {
|
||||
setExpandedSuites((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(suiteName)) {
|
||||
next.delete(suiteName);
|
||||
} else {
|
||||
next.add(suiteName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return 'text-green-600 bg-green-50';
|
||||
case 'failed':
|
||||
return 'text-red-600 bg-red-50';
|
||||
case 'skipped':
|
||||
return 'text-gray-600 bg-gray-50';
|
||||
default:
|
||||
return 'text-yellow-600 bg-yellow-50';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return <CheckCircle className="w-5 h-5 text-green-600" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-5 h-5 text-red-600" />;
|
||||
default:
|
||||
return <Clock className="w-5 h-5 text-gray-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{/* Summary Header */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(result.status)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{result.status === 'passed' ? 'All Tests Passed' : 'Tests Failed'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(result.execution_time).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(
|
||||
result.status
|
||||
)}`}
|
||||
>
|
||||
{result.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Test Statistics */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Total Tests</div>
|
||||
<div className="text-2xl font-bold">{result.total_tests}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Passed</div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{result.passed}
|
||||
</div>
|
||||
</div>
|
||||
{result.failed > 0 && (
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Failed</div>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{result.failed}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.skipped > 0 && (
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Skipped</div>
|
||||
<div className="text-2xl font-bold text-gray-600">
|
||||
{result.skipped}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4 text-sm text-gray-600">
|
||||
<div>
|
||||
Pass Rate:{' '}
|
||||
<span className="font-semibold">
|
||||
{(result.pass_rate * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Duration:{' '}
|
||||
<span className="font-semibold">
|
||||
{formatDuration(result.duration_ms)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Results */}
|
||||
{showDetails && result.test_suites.length > 0 && (
|
||||
<div className="p-6">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-4">
|
||||
Test Suites ({result.test_suites.length})
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{result.test_suites.map((suite) => (
|
||||
<div
|
||||
key={suite.name}
|
||||
className="border border-gray-200 rounded-lg overflow-hidden"
|
||||
>
|
||||
{/* Suite Header */}
|
||||
<button
|
||||
onClick={() => toggleSuite(suite.name)}
|
||||
className="w-full px-4 py-3 bg-gray-50 hover:bg-gray-100 flex items-center justify-between transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{expandedSuites.has(suite.name) ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-gray-900">
|
||||
{suite.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{suite.runner_type} • {formatDuration(suite.duration_ms)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-green-600">{suite.passed} passed</span>
|
||||
{suite.failed > 0 && (
|
||||
<span className="text-red-600">{suite.failed} failed</span>
|
||||
)}
|
||||
{suite.skipped > 0 && (
|
||||
<span className="text-gray-600">
|
||||
{suite.skipped} skipped
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Test Cases */}
|
||||
{expandedSuites.has(suite.name) && (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{suite.test_cases.map((testCase, idx) => (
|
||||
<div key={idx} className="px-4 py-3 bg-white">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-2 flex-1">
|
||||
{testCase.status === 'passed' ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600 mt-0.5 flex-shrink-0" />
|
||||
) : testCase.status === 'failed' ? (
|
||||
<XCircle className="w-4 h-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<Clock className="w-4 h-4 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-sm text-gray-900">
|
||||
{testCase.name}
|
||||
</div>
|
||||
{testCase.error_message && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded text-xs">
|
||||
<div className="font-semibold text-red-800 mb-1">
|
||||
Error:
|
||||
</div>
|
||||
<pre className="text-red-700 whitespace-pre-wrap break-words font-mono">
|
||||
{testCase.error_message}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{testCase.stderr && (
|
||||
<div className="mt-2 p-2 bg-gray-50 border border-gray-200 rounded text-xs">
|
||||
<div className="font-semibold text-gray-800 mb-1">
|
||||
stderr:
|
||||
</div>
|
||||
<pre className="text-gray-700 whitespace-pre-wrap break-words font-mono">
|
||||
{testCase.stderr}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 ml-4 flex-shrink-0">
|
||||
{formatDuration(testCase.duration_ms)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user