re-uploading work

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

View File

@@ -0,0 +1,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>
);
}

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

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

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

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

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

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

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

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

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

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

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

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