first pass at access control setup

This commit is contained in:
2026-03-24 14:45:07 -05:00
parent af5175b96a
commit 2ebb03b868
105 changed files with 6163 additions and 1416 deletions

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useCreatePack, useUpdatePack } from "@/hooks/usePacks";
import type { PackResponse } from "@/api";
import { PackDescriptionPatch, type PackResponse } from "@/api";
import { labelToRef } from "@/lib/format-utils";
import SchemaBuilder from "@/components/common/SchemaBuilder";
import ParamSchemaForm, {
@@ -173,7 +173,9 @@ export default function PackForm({ pack, onSuccess, onCancel }: PackFormProps) {
if (isEditing) {
const updateData = {
label: label.trim(),
description: description.trim() || undefined,
description: description.trim()
? { op: PackDescriptionPatch.op.SET, value: description.trim() }
: { op: PackDescriptionPatch.op.CLEAR },
version: version.trim(),
conf_schema: parsedConfSchema,
config: configValues,

View File

@@ -9,6 +9,7 @@ import ParamSchemaForm, {
type ParamSchema,
} from "@/components/common/ParamSchemaForm";
import SearchableSelect from "@/components/common/SearchableSelect";
import RuleMatchConditionsEditor from "@/components/forms/RuleMatchConditionsEditor";
import type {
RuleResponse,
ActionSummary,
@@ -40,9 +41,21 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
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 [conditions, setConditions] = useState<JsonValue | undefined>(() => {
if (!rule?.conditions) {
return undefined;
}
if (
typeof rule.conditions === "object" &&
!Array.isArray(rule.conditions) &&
Object.keys(rule.conditions).length === 0
) {
return undefined;
}
return rule.conditions;
});
const [triggerParameters, setTriggerParameters] = useState<
Record<string, JsonValue>
>(rule?.trigger_params || {});
@@ -57,6 +70,7 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
const [actionParamErrors, setActionParamErrors] = useState<
Record<string, string>
>({});
const [conditionsError, setConditionsError] = useState<string | undefined>();
// Data fetching
const { data: packsData } = usePacks({ pageSize: 1000 });
@@ -143,10 +157,6 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
newErrors.label = "Label is required";
}
if (!description.trim()) {
newErrors.description = "Description is required";
}
if (!packId) {
newErrors.pack = "Pack is required";
}
@@ -159,13 +169,8 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
newErrors.action = "Action is required";
}
// Validate conditions JSON if provided
if (conditions.trim()) {
try {
JSON.parse(conditions);
} catch {
newErrors.conditions = "Invalid JSON format";
}
if (conditionsError) {
newErrors.conditions = conditionsError;
}
// Validate trigger parameters (allow templates in rule context)
@@ -210,15 +215,18 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
pack_ref: selectedPackData?.ref || "",
ref: fullRef,
label: label.trim(),
description: description.trim(),
trigger_ref: selectedTrigger?.ref || "",
action_ref: selectedAction?.ref || "",
enabled,
};
if (description.trim()) {
formData.description = description.trim();
}
// Only add optional fields if they have values
if (conditions.trim()) {
formData.conditions = JSON.parse(conditions);
if (conditions !== undefined) {
formData.conditions = conditions;
}
// Add trigger parameters if any
@@ -274,280 +282,252 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
)}
{/* Basic Information */}
<div className="bg-white rounded-lg shadow p-6 space-y-4">
<div className="bg-white rounded-lg shadow p-5 lg:p-6">
<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>
<SearchableSelect
id="pack"
value={packId}
onChange={(v) => setPackId(Number(v))}
options={packs.map((pack) => ({
value: pack.id,
label: `${pack.label} (${pack.version})`,
}))}
placeholder="Select a pack..."
disabled={isEditing}
error={!!errors.pack}
/>
{errors.pack && (
<p className="mt-1 text-sm text-red-600">{errors.pack}</p>
)}
</div>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-12">
{/* Pack Selection */}
<div className="lg:col-span-4">
<label
htmlFor="pack"
className="block text-sm font-medium text-gray-700 mb-1"
>
Pack <span className="text-red-500">*</span>
</label>
<SearchableSelect
id="pack"
value={packId}
onChange={(v) => setPackId(Number(v))}
options={packs.map((pack) => ({
value: pack.id,
label: `${pack.label} (${pack.version})`,
}))}
placeholder="Select a pack..."
disabled={isEditing}
error={!!errors.pack}
/>
{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>
{/* Label */}
<div className="lg:col-span-8">
<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="ref"
value={localRef}
onChange={(e) => setLocalRef(e.target.value)}
placeholder="e.g., notify_on_error"
disabled={isEditing}
className={errors.ref ? "error" : ""}
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>
)}
</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 <span className="text-red-500">*</span>
</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 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.description ? "border-red-500" : "border-gray-300"
}`}
/>
{errors.description && (
<p className="mt-1 text-sm text-red-600">{errors.description}</p>
)}
</div>
{/* Reference */}
<div className="lg:col-span-7">
<label
htmlFor="ref"
className="block text-sm font-medium text-gray-700 mb-1"
>
Reference <span className="text-red-500">*</span>
</label>
<div className="flex flex-col gap-3 xl:flex-row xl:items-center">
<div className="input-with-prefix flex-1">
<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>
<label
htmlFor="enabled"
className="flex items-center gap-2 whitespace-nowrap rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-700"
>
<input
type="checkbox"
id="enabled"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Enable immediately
</label>
</div>
{errors.ref && (
<p className="mt-1 text-sm text-red-600">{errors.ref}</p>
)}
</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>
{/* Description */}
<div className="lg:col-span-12">
<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={2}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.description ? "border-red-500" : "border-gray-300"
}`}
/>
{errors.description && (
<p className="mt-1 text-sm text-red-600">{errors.description}</p>
)}
</div>
</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>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
{/* Trigger Configuration */}
<div className="bg-white rounded-lg shadow p-5 lg: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>
<SearchableSelect
id="trigger"
value={triggerId}
onChange={(v) => setTriggerId(Number(v))}
options={triggers.map((trigger) => ({
value: trigger.id,
label: `${trigger.ref} - ${trigger.label}`,
}))}
placeholder="Select a trigger..."
disabled={isEditing}
error={!!errors.trigger}
/>
{errors.trigger && (
<p className="mt-1 text-sm text-red-600">{errors.trigger}</p>
)}
</div>
{/* Trigger Parameters - Dynamic Form */}
{selectedTrigger && (
{!triggers || triggers.length === 0 ? (
<p className="text-sm text-gray-500">
No triggers available in the system
</p>
) : (
<>
{/* Trigger Selection */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">
Trigger Parameters
</h4>
<ParamSchemaForm
schema={triggerParamSchema}
values={triggerParameters}
onChange={setTriggerParameters}
errors={triggerParamErrors}
allowTemplates
<label
htmlFor="trigger"
className="block text-sm font-medium text-gray-700 mb-1"
>
Trigger <span className="text-red-500">*</span>
</label>
<SearchableSelect
id="trigger"
value={triggerId}
onChange={(v) => setTriggerId(Number(v))}
options={triggers.map((trigger) => ({
value: trigger.id,
label: `${trigger.ref} - ${trigger.label}`,
}))}
placeholder="Select a trigger..."
disabled={isEditing}
error={!!errors.trigger}
/>
{errors.trigger && (
<p className="mt-1 text-sm text-red-600">{errors.trigger}</p>
)}
</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"
{/* 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}
allowTemplates
/>
</div>
)}
<RuleMatchConditionsEditor
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"
}`}
onChange={setConditions}
error={errors.conditions}
onErrorChange={setConditionsError}
/>
{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>
</>
)}
</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>
{/* Action Configuration */}
<div className="bg-white rounded-lg shadow p-5 lg: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>
<SearchableSelect
id="action"
value={actionId}
onChange={(v) => setActionId(Number(v))}
options={actions.map((action) => ({
value: action.id,
label: `${action.ref} - ${action.label}`,
}))}
placeholder="Select an action..."
disabled={isEditing}
error={!!errors.action}
/>
{errors.action && (
<p className="mt-1 text-sm text-red-600">{errors.action}</p>
)}
</div>
{/* Action Parameters - Dynamic Form */}
{selectedAction && (
{!actions || actions.length === 0 ? (
<p className="text-sm text-gray-500">
No actions available in the system
</p>
) : (
<>
{/* Action Selection */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">
Action Parameters
</h4>
<ParamSchemaForm
schema={actionParamSchema}
values={actionParameters}
onChange={setActionParameters}
errors={actionParamErrors}
allowTemplates
<label
htmlFor="action"
className="block text-sm font-medium text-gray-700 mb-1"
>
Action <span className="text-red-500">*</span>
</label>
<SearchableSelect
id="action"
value={actionId}
onChange={(v) => setActionId(Number(v))}
options={actions.map((action) => ({
value: action.id,
label: `${action.ref} - ${action.label}`,
}))}
placeholder="Select an action..."
disabled={isEditing}
error={!!errors.action}
/>
{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}
allowTemplates
/>
</div>
)}
</>
)}
</div>
</div>
{/* Form Actions */}

View File

@@ -0,0 +1,507 @@
import { useEffect, useId, useState } from "react";
import { Braces, ListFilter, Plus, Trash2 } from "lucide-react";
import SearchableSelect from "@/components/common/SearchableSelect";
type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
type ConditionOperator = "equals" | "not_equals" | "contains";
type ConditionValueType = "string" | "number" | "boolean" | "null" | "json";
type EditorMode = "guided" | "raw";
interface ConditionRow {
id: string;
field: string;
operator: ConditionOperator;
valueType: ConditionValueType;
valueInput: string;
}
interface RuleMatchConditionsEditorProps {
value: unknown;
onChange: (value: JsonValue[] | JsonValue | undefined) => void;
error?: string;
onErrorChange?: (message?: string) => void;
}
const OPERATOR_OPTIONS = [
{
value: "equals",
label: "Equals",
},
{
value: "not_equals",
label: "Does not equal",
},
{
value: "contains",
label: "Contains",
},
] satisfies Array<{ value: ConditionOperator; label: string }>;
const VALUE_TYPE_OPTIONS = [
{
value: "string",
label: "Text",
},
{
value: "number",
label: "Number",
},
{
value: "boolean",
label: "True/False",
},
{
value: "null",
label: "Empty",
},
{
value: "json",
label: "JSON",
},
] satisfies Array<{ value: ConditionValueType; label: string }>;
const DEFAULT_OPERATOR: ConditionOperator = "equals";
const DEFAULT_VALUE_TYPE: ConditionValueType = "string";
function createRow(partial?: Partial<ConditionRow>): ConditionRow {
return {
id: Math.random().toString(36).slice(2, 10),
field: partial?.field || "",
operator: partial?.operator || DEFAULT_OPERATOR,
valueType: partial?.valueType || DEFAULT_VALUE_TYPE,
valueInput: partial?.valueInput || "",
};
}
function inferValueType(value: unknown): ConditionValueType {
if (value === null) {
return "null";
}
if (typeof value === "string") {
return "string";
}
if (typeof value === "number") {
return "number";
}
if (typeof value === "boolean") {
return "boolean";
}
return "json";
}
function formatValueInput(
value: unknown,
valueType: ConditionValueType,
): string {
if (valueType === "null") {
return "";
}
if (valueType === "json") {
return JSON.stringify(value, null, 2);
}
return String(value ?? "");
}
function isGuidedCondition(value: unknown): value is {
field: string;
operator: ConditionOperator;
value: unknown;
} {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const condition = value as Record<string, unknown>;
return (
typeof condition.field === "string" &&
typeof condition.operator === "string" &&
Object.prototype.hasOwnProperty.call(condition, "value") &&
OPERATOR_OPTIONS.some((option) => option.value === condition.operator)
);
}
function parseInitialState(value: unknown): {
mode: EditorMode;
rows: ConditionRow[];
rawText: string;
unsupportedMessage?: string;
} {
if (
value == null ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value as Record<string, unknown>).length === 0)
) {
return {
mode: "guided",
rows: [],
rawText: "",
};
}
if (Array.isArray(value) && value.every(isGuidedCondition)) {
return {
mode: "guided",
rows: value.map((condition) => {
const valueType = inferValueType(condition.value);
return createRow({
field: condition.field,
operator: condition.operator,
valueType,
valueInput: formatValueInput(condition.value, valueType),
});
}),
rawText: JSON.stringify(value, null, 2),
};
}
return {
mode: "raw",
rows: [],
rawText: JSON.stringify(value, null, 2),
unsupportedMessage:
"This rule uses a condition shape outside the guided builder. Edit it in raw JSON to preserve it.",
};
}
function parseConditionValue(row: ConditionRow): {
value?: JsonValue;
error?: string;
} {
switch (row.valueType) {
case "string":
return { value: row.valueInput };
case "number": {
const trimmed = row.valueInput.trim();
if (!trimmed) {
return { error: "Number value is required." };
}
const parsed = Number(trimmed);
if (Number.isNaN(parsed)) {
return { error: "Enter a valid number." };
}
return { value: parsed };
}
case "boolean":
return { value: row.valueInput === "true" };
case "null":
return { value: null };
case "json":
if (!row.valueInput.trim()) {
return { error: "JSON value is required." };
}
try {
return { value: JSON.parse(row.valueInput) as JsonValue };
} catch {
return { error: "Enter valid JSON." };
}
}
}
export default function RuleMatchConditionsEditor({
value,
onChange,
error,
onErrorChange,
}: RuleMatchConditionsEditorProps) {
const fieldId = useId();
const [mode, setMode] = useState<EditorMode>(
() => parseInitialState(value).mode,
);
const [rows, setRows] = useState<ConditionRow[]>(
() => parseInitialState(value).rows,
);
const [rawText, setRawText] = useState(
() => parseInitialState(value).rawText,
);
const [unsupportedMessage] = useState<string | undefined>(
() => parseInitialState(value).unsupportedMessage,
);
useEffect(() => {
if (mode === "raw") {
if (!rawText.trim()) {
onErrorChange?.(undefined);
onChange(undefined);
return;
}
try {
onErrorChange?.(undefined);
onChange(JSON.parse(rawText) as JsonValue);
} catch {
onErrorChange?.("Invalid JSON format");
}
return;
}
const nextConditions: JsonValue[] = [];
for (let index = 0; index < rows.length; index += 1) {
const row = rows[index];
if (!row.field.trim()) {
onErrorChange?.(`Condition ${index + 1}: field is required.`);
return;
}
const parsedValue = parseConditionValue(row);
if (parsedValue.error) {
onErrorChange?.(`Condition ${index + 1}: ${parsedValue.error}`);
return;
}
nextConditions.push({
field: row.field.trim(),
operator: row.operator,
value: parsedValue.value ?? null,
});
}
onErrorChange?.(undefined);
onChange(nextConditions.length > 0 ? nextConditions : undefined);
}, [mode, onChange, onErrorChange, rawText, rows]);
const addCondition = () => {
setRows((current) => [...current, createRow()]);
};
const updateRow = (
id: string,
updater: (row: ConditionRow) => ConditionRow,
) => {
setRows((current) =>
current.map((row) => (row.id === id ? updater(row) : row)),
);
};
const removeCondition = (id: string) => {
setRows((current) => current.filter((row) => row.id !== id));
};
const currentError = error;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h4 className="text-sm font-medium text-gray-700">
Match Conditions
</h4>
<p className="mt-1 text-xs text-gray-500">
All conditions must match. Leave this empty to match every event
from the selected trigger.
</p>
</div>
<div className="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-1">
<button
type="button"
onClick={() => setMode("guided")}
className={`inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors ${
mode === "guided"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
<ListFilter className="h-4 w-4" />
Guided
</button>
<button
type="button"
onClick={() => setMode("raw")}
className={`inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors ${
mode === "raw"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
<Braces className="h-4 w-4" />
Raw JSON
</button>
</div>
</div>
{unsupportedMessage && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
{unsupportedMessage}
</div>
)}
{mode === "guided" ? (
<div className="space-y-3">
{rows.length === 0 ? (
<div className="rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-5 text-sm text-gray-500">
No conditions configured.
</div>
) : (
rows.map((row, index) => (
<div
key={row.id}
className="rounded-xl border border-gray-200 bg-gray-50/70 p-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-sm font-medium text-gray-700">
Condition {index + 1}
</span>
<button
type="button"
onClick={() => removeCondition(row.id)}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm text-gray-500 hover:bg-white hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
Remove
</button>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-12">
<div className="xl:col-span-5">
<label
htmlFor={`${fieldId}-${row.id}-field`}
className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500"
>
Event field
</label>
<input
id={`${fieldId}-${row.id}-field`}
type="text"
value={row.field}
onChange={(e) =>
updateRow(row.id, (current) => ({
...current,
field: e.target.value,
}))
}
placeholder="status or nested.path"
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="xl:col-span-3">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500">
Operator
</label>
<SearchableSelect
value={row.operator}
onChange={(nextValue) =>
updateRow(row.id, (current) => ({
...current,
operator: nextValue as ConditionOperator,
}))
}
options={OPERATOR_OPTIONS}
placeholder="Choose operator"
/>
</div>
<div className="xl:col-span-4">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500">
Value type
</label>
<SearchableSelect
value={row.valueType}
onChange={(nextValue) =>
updateRow(row.id, (current) => ({
...current,
valueType: nextValue as ConditionValueType,
valueInput:
nextValue === "boolean"
? "true"
: nextValue === "null"
? ""
: current.valueInput,
}))
}
options={VALUE_TYPE_OPTIONS}
placeholder="Choose type"
/>
</div>
<div className="xl:col-span-12">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500">
Expected value
</label>
{row.valueType === "boolean" ? (
<SearchableSelect
value={row.valueInput || "true"}
onChange={(nextValue) =>
updateRow(row.id, (current) => ({
...current,
valueInput: String(nextValue),
}))
}
options={[
{ value: "true", label: "True" },
{ value: "false", label: "False" },
]}
/>
) : row.valueType === "null" ? (
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-3 py-2 text-sm text-gray-500">
This condition matches a null value.
</div>
) : row.valueType === "json" ? (
<textarea
value={row.valueInput}
onChange={(e) =>
updateRow(row.id, (current) => ({
...current,
valueInput: e.target.value,
}))
}
rows={4}
placeholder='{"expected": "value"}'
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<input
type={row.valueType === "number" ? "number" : "text"}
value={row.valueInput}
onChange={(e) =>
updateRow(row.id, (current) => ({
...current,
valueInput: e.target.value,
}))
}
placeholder={
row.valueType === "number" ? "42" : "expected value"
}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
)}
</div>
</div>
</div>
))
)}
<button
type="button"
onClick={addCondition}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<Plus className="h-4 w-4" />
Add condition
</button>
</div>
) : (
<textarea
value={rawText}
onChange={(e) => setRawText(e.target.value)}
rows={10}
placeholder={`[\n {\n "field": "status",\n "operator": "equals",\n "value": "error"\n }\n]`}
className="w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
)}
{currentError && <p className="text-sm text-red-600">{currentError}</p>}
</div>
);
}

View File

@@ -10,7 +10,7 @@ import {
} from "@/lib/format-utils";
import SchemaBuilder from "@/components/common/SchemaBuilder";
import SearchableSelect from "@/components/common/SearchableSelect";
import { WebhooksService } from "@/api";
import { TriggerStringPatch, WebhooksService } from "@/api";
import type { TriggerResponse, PackSummary } from "@/api";
/** Flat schema format: each key is a parameter name mapped to its definition */
@@ -116,7 +116,6 @@ export default function TriggerForm({
pack_ref: selectedPackData.ref,
ref: fullRef,
label: label.trim(),
description: description.trim() || undefined,
enabled,
param_schema:
Object.keys(paramSchema).length > 0 ? paramSchema : undefined,
@@ -124,9 +123,16 @@ export default function TriggerForm({
};
if (isEditing && initialData?.ref) {
const updateData = {
...formData,
description: description.trim()
? { op: TriggerStringPatch.op.SET, value: description.trim() }
: { op: TriggerStringPatch.op.CLEAR },
};
await updateTrigger.mutateAsync({
ref: initialData.ref,
data: formData,
data: updateData,
});
// Handle webhook enable/disable separately for updates
@@ -152,7 +158,12 @@ export default function TriggerForm({
navigate(`/triggers/${encodeURIComponent(initialData.ref)}`);
return;
} else {
const response = await createTrigger.mutateAsync(formData);
const createData = {
...formData,
description: description.trim() || undefined,
};
const response = await createTrigger.mutateAsync(createData);
const newTrigger = response?.data;
if (newTrigger?.ref) {
// If webhook is enabled, enable it after trigger creation

View File

@@ -1,24 +1,8 @@
import React, { useState, useEffect } from "react";
import { Link, Outlet, 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,
FolderArchive,
TerminalSquare,
} from "lucide-react";
import { ChevronLeft, ChevronRight, User, LogOut } from "lucide-react";
import { navIcons } from "./navIcons";
// Color mappings for navigation items — defined outside component for stable reference
const colorClasses = {
@@ -68,29 +52,36 @@ const colorClasses = {
// Navigation sections with dividers and colors
const navSections = [
{
items: [{ to: "/", label: "Dashboard", icon: Home, color: "gray" }],
items: [
{ to: "/", label: "Dashboard", icon: navIcons.dashboard, color: "gray" },
],
},
{
// Component Management - Cool colors (cyan -> blue -> violet)
items: [
{ to: "/actions", label: "Actions", icon: SquarePlay, color: "cyan" },
{
to: "/actions",
label: "Actions",
icon: navIcons.actions,
color: "cyan",
},
{
to: "/runtimes",
label: "Runtimes",
icon: TerminalSquare,
icon: navIcons.runtimes,
color: "blue",
},
{ to: "/rules", label: "Rules", icon: SquareArrowRight, color: "blue" },
{ to: "/rules", label: "Rules", icon: navIcons.rules, color: "blue" },
{
to: "/triggers",
label: "Triggers",
icon: SquareDot,
icon: navIcons.triggers,
color: "violet",
},
{
to: "/sensors",
label: "Sensors",
icon: SquareAsterisk,
icon: navIcons.sensors,
color: "purple",
},
],
@@ -101,36 +92,47 @@ const navSections = [
{
to: "/executions",
label: "Execution History",
icon: CirclePlay,
icon: navIcons.executions,
color: "fuchsia",
},
{
to: "/enforcements",
label: "Enforcement History",
icon: CircleArrowRight,
icon: navIcons.enforcements,
color: "rose",
},
{
to: "/events",
label: "Event History",
icon: CircleDot,
icon: navIcons.events,
color: "orange",
},
],
},
{
items: [
{ to: "/keys", label: "Keys & Secrets", icon: KeyRound, color: "gray" },
{
to: "/keys",
label: "Keys & Secrets",
icon: navIcons.keys,
color: "gray",
},
{
to: "/artifacts",
label: "Artifacts",
icon: FolderArchive,
icon: navIcons.artifacts,
color: "gray",
},
{
to: "/access-control",
label: "Access Control",
icon: navIcons.accessControl,
color: "gray",
},
{
to: "/packs",
label: "Pack Management",
icon: Package,
icon: navIcons.packs,
color: "gray",
},
],

View File

@@ -0,0 +1,31 @@
import {
CircleArrowRight,
CircleDot,
CirclePlay,
FolderArchive,
Home,
KeyRound,
Package,
ShieldCheck,
SquareArrowRight,
SquareAsterisk,
SquareDot,
SquarePlay,
TerminalSquare,
} from "lucide-react";
export const navIcons = {
dashboard: Home,
actions: SquarePlay,
runtimes: TerminalSquare,
rules: SquareArrowRight,
triggers: SquareDot,
sensors: SquareAsterisk,
executions: CirclePlay,
enforcements: CircleArrowRight,
events: CircleDot,
keys: KeyRound,
artifacts: FolderArchive,
accessControl: ShieldCheck,
packs: Package,
} as const;