first pass at access control setup
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
507
web/src/components/forms/RuleMatchConditionsEditor.tsx
Normal file
507
web/src/components/forms/RuleMatchConditionsEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
31
web/src/components/layout/navIcons.tsx
Normal file
31
web/src/components/layout/navIcons.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user