mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
added template vars to analyze rules for better user journey
This commit is contained in:
+1
-1
Submodule .claude/commands updated: 7dc8ff3a53...06d4f6796f
@@ -6,7 +6,7 @@
|
||||
"severity": "warning",
|
||||
"category": "identity",
|
||||
"isCommunity": true,
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -61,5 +61,15 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["identity", "certificates", "machine-store", "subject-check"]
|
||||
"tags": ["identity", "certificates", "machine-store", "subject-check"],
|
||||
"templateVariables": [
|
||||
{
|
||||
"name": "cert_subject",
|
||||
"label": "Certificate Subject",
|
||||
"description": "The expected certificate subject in your environment (e.g., 'CN=Contoso Root CA'). Case-insensitive substring match against the local machine certificate store.",
|
||||
"conditionIndex": 1,
|
||||
"field": "value",
|
||||
"placeholder": "CN=YOUR-CERTIFICATE-SUBJECT"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+11
-1
@@ -960,7 +960,7 @@
|
||||
"severity": "warning",
|
||||
"category": "identity",
|
||||
"isCommunity": true,
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -1020,6 +1020,16 @@
|
||||
"certificates",
|
||||
"machine-store",
|
||||
"subject-check"
|
||||
],
|
||||
"templateVariables": [
|
||||
{
|
||||
"name": "cert_subject",
|
||||
"label": "Certificate Subject",
|
||||
"description": "The expected certificate subject in your environment (e.g., 'CN=Contoso Root CA'). Case-insensitive substring match against the local machine certificate store.",
|
||||
"conditionIndex": 1,
|
||||
"field": "value",
|
||||
"placeholder": "CN=YOUR-CERTIFICATE-SUBJECT"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -122,6 +122,16 @@
|
||||
"description": "Tags for filtering and categorization.",
|
||||
"items": { "type": "string" },
|
||||
"default": []
|
||||
},
|
||||
"templateVariables": {
|
||||
"type": "array",
|
||||
"description": "Template variables that must be customized per-tenant. If non-empty, the rule is a template: enabling it creates a tenant custom copy with the user's values substituted.",
|
||||
"items": { "$ref": "#/$defs/templateVariable" },
|
||||
"default": []
|
||||
},
|
||||
"derivedFromTemplateRuleId": {
|
||||
"type": "string",
|
||||
"description": "If this custom rule was created from a template, the original template rule's ID. Used to track lineage and prevent duplicate copies."
|
||||
}
|
||||
},
|
||||
"required": ["ruleId", "title", "severity", "category", "conditions", "explanation"],
|
||||
@@ -247,6 +257,46 @@
|
||||
},
|
||||
"required": ["title", "url"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"templateVariable": {
|
||||
"type": "object",
|
||||
"description": "A variable in a rule condition that must be customized per-tenant before the rule can be used.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Machine name for this variable (e.g., \"cert_subject\")."
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Human-readable label shown in the configuration UI."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Help text explaining what value is expected."
|
||||
},
|
||||
"conditionIndex": {
|
||||
"type": "integer",
|
||||
"description": "Zero-based index into the conditions array where this variable lives.",
|
||||
"minimum": 0
|
||||
},
|
||||
"field": {
|
||||
"type": "string",
|
||||
"description": "Which field on the condition to customize.",
|
||||
"enum": ["value", "eventType", "dataField", "eventAFilterValue"],
|
||||
"default": "value"
|
||||
},
|
||||
"placeholder": {
|
||||
"type": "string",
|
||||
"description": "The placeholder value that ships with the template (e.g., \"CN=YOUR-CERTIFICATE-SUBJECT\")."
|
||||
},
|
||||
"validation": {
|
||||
"type": "string",
|
||||
"description": "Optional regex pattern to validate user input.",
|
||||
"format": "regex"
|
||||
}
|
||||
},
|
||||
"required": ["name", "label", "conditionIndex", "field", "placeholder"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
805
|
||||
823
|
||||
|
||||
@@ -45,6 +45,66 @@ public class BuiltInRulesTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInAnalyzeRules_TemplateVariablesAreValid()
|
||||
{
|
||||
var rules = BuiltInAnalyzeRules.GetAll();
|
||||
var templateRules = rules.Where(r => r.TemplateVariables != null && r.TemplateVariables.Count > 0).ToList();
|
||||
|
||||
foreach (var rule in templateRules)
|
||||
{
|
||||
// Template rules should be disabled by default (they need configuration first)
|
||||
Assert.False(rule.Enabled, $"Template rule {rule.RuleId} should be disabled by default");
|
||||
|
||||
foreach (var tv in rule.TemplateVariables)
|
||||
{
|
||||
Assert.False(string.IsNullOrEmpty(tv.Name), $"Rule {rule.RuleId}: TemplateVariable is missing Name");
|
||||
Assert.False(string.IsNullOrEmpty(tv.Label), $"Rule {rule.RuleId}: TemplateVariable '{tv.Name}' is missing Label");
|
||||
Assert.False(string.IsNullOrEmpty(tv.Placeholder), $"Rule {rule.RuleId}: TemplateVariable '{tv.Name}' is missing Placeholder");
|
||||
|
||||
// conditionIndex must reference a valid condition
|
||||
Assert.True(tv.ConditionIndex >= 0 && tv.ConditionIndex < rule.Conditions.Count,
|
||||
$"Rule {rule.RuleId}: TemplateVariable '{tv.Name}' has conditionIndex {tv.ConditionIndex} but rule only has {rule.Conditions.Count} conditions");
|
||||
|
||||
// Field must be one of the known fields
|
||||
var validFields = new HashSet<string> { "value", "eventType", "dataField", "eventAFilterValue" };
|
||||
Assert.True(validFields.Contains(tv.Field),
|
||||
$"Rule {rule.RuleId}: TemplateVariable '{tv.Name}' has unknown field '{tv.Field}'");
|
||||
|
||||
// The placeholder should match the current value in the condition
|
||||
var condition = rule.Conditions[tv.ConditionIndex];
|
||||
var actualValue = tv.Field switch
|
||||
{
|
||||
"value" => condition.Value,
|
||||
"eventType" => condition.EventType,
|
||||
"dataField" => condition.DataField,
|
||||
"eventAFilterValue" => condition.EventAFilterValue,
|
||||
_ => null
|
||||
};
|
||||
Assert.Equal(tv.Placeholder, actualValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInAnalyzeRules_ANALYZE_ID_001_IsTemplate()
|
||||
{
|
||||
var rules = BuiltInAnalyzeRules.GetAll();
|
||||
var rule = rules.FirstOrDefault(r => r.RuleId == "ANALYZE-ID-001");
|
||||
|
||||
Assert.NotNull(rule);
|
||||
Assert.False(rule.Enabled, "ANALYZE-ID-001 should be disabled (it's a template)");
|
||||
Assert.NotNull(rule.TemplateVariables);
|
||||
Assert.Single(rule.TemplateVariables);
|
||||
|
||||
var tv = rule.TemplateVariables[0];
|
||||
Assert.Equal("cert_subject", tv.Name);
|
||||
Assert.Equal("Certificate Subject", tv.Label);
|
||||
Assert.Equal(1, tv.ConditionIndex);
|
||||
Assert.Equal("value", tv.Field);
|
||||
Assert.Equal("CN=YOUR-CERTIFICATE-SUBJECT", tv.Placeholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltInImeLogPatterns_LoadsFromEmbeddedResource()
|
||||
{
|
||||
|
||||
@@ -139,6 +139,7 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
[InlineData("DELETE", "/api/devices/block/SN123456", "devices/block/{encodedSerialNumber}")]
|
||||
[InlineData("DELETE", "/api/versions/block/v1.0.*", "versions/block/{encodedPattern}")]
|
||||
[InlineData("PATCH", "/api/global/session-reports/report-1/note", "global/session-reports/{reportId}/note")]
|
||||
[InlineData("POST", "/api/rules/analyze/ANALYZE-ID-001/create-from-template", "rules/analyze/{ruleId}/create-from-template")]
|
||||
public void ParameterizedRoutes_MatchCorrectly(string httpMethod, string requestPath, string expectedTemplate)
|
||||
{
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy(httpMethod, requestPath);
|
||||
|
||||
@@ -57,6 +57,12 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<bool> DeleteAnalyzeRuleAsync(string tenantId, string ruleId)
|
||||
=> _storage.DeleteAnalyzeRuleAsync(tenantId, ruleId);
|
||||
|
||||
public Task<bool> AnalyzeRuleExistsAsync(string partitionKey, string ruleId)
|
||||
=> _storage.AnalyzeRuleExistsAsync(partitionKey, ruleId);
|
||||
|
||||
public Task<bool> GatherRuleExistsAsync(string partitionKey, string ruleId)
|
||||
=> _storage.GatherRuleExistsAsync(partitionKey, ruleId);
|
||||
|
||||
public Task<bool> StoreImeLogPatternAsync(ImeLogPattern pattern, string tenantId = "global")
|
||||
=> _storage.StoreImeLogPatternAsync(pattern, tenantId);
|
||||
|
||||
|
||||
@@ -64,11 +64,20 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
var success = await _ruleService.CreateRuleAsync(tenantId, rule);
|
||||
try
|
||||
{
|
||||
var success = await _ruleService.CreateRuleAsync(tenantId, rule);
|
||||
|
||||
var response = req.CreateResponse(success ? HttpStatusCode.Created : HttpStatusCode.InternalServerError);
|
||||
await response.WriteAsJsonAsync(new { success, message = success ? "Rule created" : "Failed to create rule" });
|
||||
return response;
|
||||
var response = req.CreateResponse(success ? HttpStatusCode.Created : HttpStatusCode.InternalServerError);
|
||||
await response.WriteAsJsonAsync(new { success, message = success ? "Rule created" : "Failed to create rule" });
|
||||
return response;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
|
||||
await conflict.WriteAsJsonAsync(new { success = false, message = ex.Message });
|
||||
return conflict;
|
||||
}
|
||||
}
|
||||
|
||||
[Function("UpdateAnalyzeRule")]
|
||||
@@ -105,6 +114,46 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
return response;
|
||||
}
|
||||
|
||||
[Function("CreateAnalyzeRuleFromTemplate")]
|
||||
public async Task<HttpResponseData> CreateFromTemplate(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "rules/analyze/{ruleId}/create-from-template")] HttpRequestData req,
|
||||
string ruleId)
|
||||
{
|
||||
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var body = await new StreamReader(req.Body).ReadToEndAsync();
|
||||
var variables = JsonConvert.DeserializeObject<Dictionary<string, string>>(body);
|
||||
|
||||
if (variables == null || variables.Count == 0)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = "Template variable values are required" });
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var newRule = await _ruleService.CreateFromTemplateAsync(tenantId, ruleId, variables);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.Created);
|
||||
await response.WriteAsJsonAsync(new { success = true, rule = newRule, message = "Custom rule created from template" });
|
||||
return response;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
|
||||
await conflict.WriteAsJsonAsync(new { success = false, message = ex.Message });
|
||||
return conflict;
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badReq.WriteAsJsonAsync(new { success = false, message = ex.Message });
|
||||
return badReq;
|
||||
}
|
||||
}
|
||||
|
||||
[Function("DeleteAnalyzeRule")]
|
||||
public async Task<HttpResponseData> DeleteRule(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "rules/analyze/{ruleId}")] HttpRequestData req,
|
||||
|
||||
@@ -64,11 +64,20 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
var success = await _ruleService.CreateRuleAsync(tenantId, rule);
|
||||
try
|
||||
{
|
||||
var success = await _ruleService.CreateRuleAsync(tenantId, rule);
|
||||
|
||||
var response = req.CreateResponse(success ? HttpStatusCode.Created : HttpStatusCode.InternalServerError);
|
||||
await response.WriteAsJsonAsync(new { success, message = success ? "Rule created" : "Failed to create rule" });
|
||||
return response;
|
||||
var response = req.CreateResponse(success ? HttpStatusCode.Created : HttpStatusCode.InternalServerError);
|
||||
await response.WriteAsJsonAsync(new { success, message = success ? "Rule created" : "Failed to create rule" });
|
||||
return response;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
|
||||
await conflict.WriteAsJsonAsync(new { success = false, message = ex.Message });
|
||||
return conflict;
|
||||
}
|
||||
}
|
||||
|
||||
[Function("UpdateGatherRule")]
|
||||
|
||||
@@ -158,6 +158,7 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("PUT", "rules/gather/{ruleId}", EndpointPolicy.TenantAdminOrGA),
|
||||
new("DELETE", "rules/gather/{ruleId}", EndpointPolicy.TenantAdminOrGA),
|
||||
new("POST", "rules/analyze", EndpointPolicy.TenantAdminOrGA),
|
||||
new("POST", "rules/analyze/{ruleId}/create-from-template", EndpointPolicy.TenantAdminOrGA),
|
||||
new("PUT", "rules/analyze/{ruleId}", EndpointPolicy.TenantAdminOrGA),
|
||||
new("DELETE", "rules/analyze/{ruleId}", EndpointPolicy.TenantAdminOrGA),
|
||||
new("PUT", "rules/ime-log-patterns/{patternId}", EndpointPolicy.TenantAdminOrGA),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
@@ -66,10 +67,18 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a custom analyze rule for a tenant
|
||||
/// Creates a custom analyze rule for a tenant.
|
||||
/// Throws if a rule with the same ID already exists (global or tenant partition).
|
||||
/// Uses point queries (O(1)) instead of loading all rules.
|
||||
/// </summary>
|
||||
public async Task<bool> CreateRuleAsync(string tenantId, AnalyzeRule rule)
|
||||
{
|
||||
if (await _ruleRepo.AnalyzeRuleExistsAsync("global", rule.RuleId)
|
||||
|| await _ruleRepo.AnalyzeRuleExistsAsync(tenantId, rule.RuleId))
|
||||
{
|
||||
throw new InvalidOperationException($"A rule with ID '{rule.RuleId}' already exists.");
|
||||
}
|
||||
|
||||
rule.IsBuiltIn = false;
|
||||
rule.IsCommunity = false;
|
||||
rule.CreatedAt = DateTime.UtcNow;
|
||||
@@ -93,6 +102,94 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return await _ruleRepo.StoreAnalyzeRuleAsync(rule, tenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a tenant custom rule from a template rule, substituting template variables
|
||||
/// with tenant-specific values. The original template remains disabled for the tenant.
|
||||
/// </summary>
|
||||
public async Task<AnalyzeRule> CreateFromTemplateAsync(
|
||||
string tenantId,
|
||||
string templateRuleId,
|
||||
Dictionary<string, string> variableValues)
|
||||
{
|
||||
var allRules = await GetAllRulesForTenantAsync(tenantId);
|
||||
var template = allRules.FirstOrDefault(r => r.RuleId == templateRuleId);
|
||||
|
||||
if (template == null)
|
||||
throw new InvalidOperationException($"Rule '{templateRuleId}' not found.");
|
||||
|
||||
if (template.TemplateVariables == null || template.TemplateVariables.Count == 0)
|
||||
throw new InvalidOperationException($"Rule '{templateRuleId}' is not a template rule.");
|
||||
|
||||
// Check if a custom copy already exists for this tenant (by lineage)
|
||||
var existingCopy = allRules.FirstOrDefault(r => r.DerivedFromTemplateRuleId == templateRuleId);
|
||||
if (existingCopy != null)
|
||||
throw new InvalidOperationException($"A custom copy of '{templateRuleId}' already exists: '{existingCopy.RuleId}'.");
|
||||
|
||||
// Check for RuleId collision via point query (e.g., someone manually created a rule with the same ID)
|
||||
var targetRuleId = $"{templateRuleId}-CUSTOM";
|
||||
if (await _ruleRepo.AnalyzeRuleExistsAsync("global", targetRuleId)
|
||||
|| await _ruleRepo.AnalyzeRuleExistsAsync(tenantId, targetRuleId))
|
||||
throw new InvalidOperationException($"A rule with ID '{targetRuleId}' already exists. Delete or rename it first.");
|
||||
|
||||
// Validate all template variables have values
|
||||
foreach (var tv in template.TemplateVariables)
|
||||
{
|
||||
if (!variableValues.TryGetValue(tv.Name, out var val) || string.IsNullOrWhiteSpace(val))
|
||||
throw new ArgumentException($"Missing required value for template variable '{tv.Name}'.");
|
||||
}
|
||||
|
||||
// Deep-clone the template
|
||||
var customRule = JsonConvert.DeserializeObject<AnalyzeRule>(
|
||||
JsonConvert.SerializeObject(template))
|
||||
?? throw new InvalidOperationException("Failed to clone template rule.");
|
||||
|
||||
customRule.RuleId = $"{templateRuleId}-CUSTOM";
|
||||
customRule.IsBuiltIn = false;
|
||||
customRule.IsCommunity = false;
|
||||
customRule.Enabled = true;
|
||||
customRule.DerivedFromTemplateRuleId = templateRuleId;
|
||||
customRule.TemplateVariables = new List<TemplateVariable>();
|
||||
customRule.CreatedAt = DateTime.UtcNow;
|
||||
customRule.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Substitute variable values into conditions
|
||||
foreach (var tv in template.TemplateVariables)
|
||||
{
|
||||
var userValue = variableValues[tv.Name];
|
||||
|
||||
if (tv.ConditionIndex < 0 || tv.ConditionIndex >= customRule.Conditions.Count)
|
||||
{
|
||||
_logger.LogWarning("Template variable '{Name}' has invalid conditionIndex {Index}", tv.Name, tv.ConditionIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
var condition = customRule.Conditions[tv.ConditionIndex];
|
||||
switch (tv.Field?.ToLowerInvariant())
|
||||
{
|
||||
case "value": condition.Value = userValue; break;
|
||||
case "eventtype": condition.EventType = userValue; break;
|
||||
case "datafield": condition.DataField = userValue; break;
|
||||
case "eventafiltervalue": condition.EventAFilterValue = userValue; break;
|
||||
default:
|
||||
_logger.LogWarning("Template variable '{Name}' has unknown field '{Field}'", tv.Name, tv.Field);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Store the custom rule in the tenant partition
|
||||
var success = await _ruleRepo.StoreAnalyzeRuleAsync(customRule, tenantId);
|
||||
if (!success)
|
||||
throw new InvalidOperationException("Failed to store custom rule.");
|
||||
|
||||
// Ensure the template rule is disabled for this tenant
|
||||
await _ruleRepo.StoreRuleStateAsync(tenantId, templateRuleId, false);
|
||||
|
||||
_logger.LogInformation("Created custom rule '{CustomRuleId}' from template '{TemplateRuleId}' for tenant '{TenantId}'",
|
||||
customRule.RuleId, templateRuleId, tenantId);
|
||||
|
||||
return customRule;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an analyze rule.
|
||||
/// For built-in/community rules: removes the tenant's state override (resets to rule default).
|
||||
|
||||
@@ -67,10 +67,18 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a custom gather rule for a tenant
|
||||
/// Creates a custom gather rule for a tenant.
|
||||
/// Throws if a rule with the same ID already exists (global or tenant partition).
|
||||
/// Uses point queries (O(1)) instead of loading all rules.
|
||||
/// </summary>
|
||||
public async Task<bool> CreateRuleAsync(string tenantId, GatherRule rule)
|
||||
{
|
||||
if (await _ruleRepo.GatherRuleExistsAsync("global", rule.RuleId)
|
||||
|| await _ruleRepo.GatherRuleExistsAsync(tenantId, rule.RuleId))
|
||||
{
|
||||
throw new InvalidOperationException($"A rule with ID '{rule.RuleId}' already exists.");
|
||||
}
|
||||
|
||||
rule.IsBuiltIn = false;
|
||||
rule.IsCommunity = false;
|
||||
rule.CreatedAt = DateTime.UtcNow;
|
||||
|
||||
@@ -388,6 +388,44 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
// ===== RULE EXISTENCE CHECKS (Point Queries) =====
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an analyze rule exists via point query (PartitionKey + RowKey).
|
||||
/// O(1) in Table Storage — no partition scan needed.
|
||||
/// </summary>
|
||||
public async Task<bool> AnalyzeRuleExistsAsync(string partitionKey, string ruleId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AnalyzeRules);
|
||||
await tableClient.GetEntityAsync<TableEntity>(partitionKey, ruleId, select: new[] { "PartitionKey" });
|
||||
return true;
|
||||
}
|
||||
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a gather rule exists via point query (PartitionKey + RowKey).
|
||||
/// O(1) in Table Storage — no partition scan needed.
|
||||
/// </summary>
|
||||
public async Task<bool> GatherRuleExistsAsync(string partitionKey, string ruleId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GatherRules);
|
||||
await tableClient.GetEntityAsync<TableEntity>(partitionKey, ruleId, select: new[] { "PartitionKey" });
|
||||
return true;
|
||||
}
|
||||
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private AnalyzeRule MapToAnalyzeRule(TableEntity entity)
|
||||
{
|
||||
return new AnalyzeRule
|
||||
|
||||
@@ -29,6 +29,19 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
Task<List<AnalyzeRule>> GetAnalyzeRulesAsync(string partitionKey);
|
||||
Task<bool> DeleteAnalyzeRuleAsync(string tenantId, string ruleId);
|
||||
|
||||
// --- Rule Existence Checks (Point Queries) ---
|
||||
/// <summary>
|
||||
/// Checks if an analyze rule with the given ID exists in the specified partition.
|
||||
/// Uses a point query (O(1)) instead of loading the full partition.
|
||||
/// </summary>
|
||||
Task<bool> AnalyzeRuleExistsAsync(string partitionKey, string ruleId);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a gather rule with the given ID exists in the specified partition.
|
||||
/// Uses a point query (O(1)) instead of loading the full partition.
|
||||
/// </summary>
|
||||
Task<bool> GatherRuleExistsAsync(string partitionKey, string ruleId);
|
||||
|
||||
// --- IME Log Patterns ---
|
||||
Task<bool> StoreImeLogPatternAsync(ImeLogPattern pattern, string tenantId = "global");
|
||||
Task<List<ImeLogPattern>> GetImeLogPatternsAsync(string partitionKey);
|
||||
|
||||
@@ -112,6 +112,21 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public List<RelatedDoc> RelatedDocs { get; set; } = new List<RelatedDoc>();
|
||||
|
||||
// ===== TEMPLATE =====
|
||||
|
||||
/// <summary>
|
||||
/// Template variables that must be customized per-tenant before the rule can be used.
|
||||
/// If non-empty, the rule is a template: enabling it creates a tenant custom copy
|
||||
/// with the user's values substituted into the conditions.
|
||||
/// </summary>
|
||||
public List<TemplateVariable> TemplateVariables { get; set; } = new List<TemplateVariable>();
|
||||
|
||||
/// <summary>
|
||||
/// If this custom rule was created from a template, stores the original template rule's ID.
|
||||
/// Used to track lineage and prevent duplicate copies.
|
||||
/// </summary>
|
||||
public string? DerivedFromTemplateRuleId { get; set; }
|
||||
|
||||
// ===== METADATA =====
|
||||
|
||||
/// <summary>
|
||||
@@ -268,4 +283,46 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public string Url { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines a template variable in a rule condition that must be customized per-tenant.
|
||||
/// Points at a specific condition field (by index) and describes what value the user needs to provide.
|
||||
/// </summary>
|
||||
public class TemplateVariable
|
||||
{
|
||||
/// <summary>
|
||||
/// Machine name for this variable (e.g., "cert_subject")
|
||||
/// </summary>
|
||||
public string Name { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable label shown in the configuration UI (e.g., "Certificate Subject")
|
||||
/// </summary>
|
||||
public string Label { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Help text explaining what value the user should provide
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Zero-based index into the rule's Conditions array where this variable lives
|
||||
/// </summary>
|
||||
public int ConditionIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Which field on the condition to customize: "value", "eventType", "dataField", "eventAFilterValue"
|
||||
/// </summary>
|
||||
public string Field { get; set; } = "value";
|
||||
|
||||
/// <summary>
|
||||
/// The placeholder value that ships with the template (e.g., "CN=YOUR-CERTIFICATE-SUBJECT")
|
||||
/// </summary>
|
||||
public string Placeholder { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Optional regex pattern to validate user input
|
||||
/// </summary>
|
||||
public string? Validation { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ interface AnalyzeRuleCardProps {
|
||||
onSetJsonText: (text: string) => void;
|
||||
onSetJsonError: (error: string | null) => void;
|
||||
readOnly?: boolean;
|
||||
onConfigureTemplate?: (rule: AnalyzeRule) => void;
|
||||
templateCopyExists?: boolean;
|
||||
templateCopyRuleId?: string;
|
||||
onScrollToCopy?: (ruleId: string) => void;
|
||||
}
|
||||
|
||||
export default function AnalyzeRuleCard({
|
||||
@@ -40,11 +44,14 @@ export default function AnalyzeRuleCard({
|
||||
onDelete, onExport,
|
||||
onSetJsonModeEdit, onSetJsonText, onSetJsonError,
|
||||
readOnly = false,
|
||||
onConfigureTemplate, templateCopyExists, templateCopyRuleId, onScrollToCopy,
|
||||
}: AnalyzeRuleCardProps) {
|
||||
const [showJson, setShowJson] = useState(false);
|
||||
const sevColor = getSeverityColor(rule.severity);
|
||||
const catColor = getCategoryColor(rule.category);
|
||||
const canEdit = !rule.isBuiltIn && !rule.isCommunity;
|
||||
const isTemplate = (rule.templateVariables?.length ?? 0) > 0;
|
||||
const isDerived = !!rule.derivedFromTemplateRuleId;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -59,6 +66,14 @@ export default function AnalyzeRuleCard({
|
||||
<span className={`inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full ${rule.enabled ? "bg-green-500" : "bg-gray-300"}`} title={rule.enabled ? "Enabled" : "Disabled"}>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white ${rule.enabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</span>
|
||||
) : isTemplate && templateCopyExists ? (
|
||||
<button onClick={(e) => { e.stopPropagation(); if (templateCopyRuleId && onScrollToCopy) onScrollToCopy(templateCopyRuleId); }} className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full bg-gray-300 cursor-pointer" title="Custom copy already exists - click to view">
|
||||
<span className="inline-block h-4 w-4 transform rounded-full bg-white translate-x-1" />
|
||||
</button>
|
||||
) : isTemplate && !rule.enabled ? (
|
||||
<button onClick={(e) => { e.stopPropagation(); onConfigureTemplate?.(rule); }} className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full bg-amber-400 hover:bg-amber-500 cursor-pointer transition-colors" title="Configure and enable this template rule">
|
||||
<span className="inline-block h-4 w-4 transform rounded-full bg-white translate-x-1" />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={(e) => { e.stopPropagation(); onToggleEnabled(rule); }} disabled={togglingRuleId === rule.ruleId} className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${togglingRuleId === rule.ruleId ? "opacity-50 cursor-not-allowed" : "cursor-pointer"} ${rule.enabled ? "bg-green-500" : "bg-gray-300"}`} title={rule.enabled ? "Disable rule" : "Enable rule"}>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${rule.enabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||
@@ -78,6 +93,16 @@ export default function AnalyzeRuleCard({
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ${rule.isBuiltIn ? "bg-blue-50 text-blue-600 border border-blue-200" : rule.isCommunity ? "bg-green-100 text-green-700" : "bg-purple-50 text-purple-600 border border-purple-200"}`}>
|
||||
{rule.isBuiltIn ? "Built-in" : rule.isCommunity ? "Community" : "Custom"}
|
||||
</span>
|
||||
{isTemplate && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 bg-amber-100 text-amber-800 border border-amber-200">
|
||||
Requires Setup
|
||||
</span>
|
||||
)}
|
||||
{isDerived && (
|
||||
<span className="text-xs text-gray-400 flex-shrink-0 hidden lg:inline" title={`Based on template ${rule.derivedFromTemplateRuleId}`}>
|
||||
Based on {rule.derivedFromTemplateRuleId}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 flex-shrink-0 hidden md:inline" title="Confidence Threshold">Threshold: {rule.confidenceThreshold}%</span>
|
||||
<svg className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ${isExpanded ? "rotate-180" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
</div>
|
||||
|
||||
+10
-2
@@ -10,9 +10,14 @@ interface AnalyzeRuleFormFieldsProps {
|
||||
form: RuleForm;
|
||||
setForm: (f: RuleForm) => void;
|
||||
showRuleId: boolean;
|
||||
existingRuleIds?: string[];
|
||||
}
|
||||
|
||||
export default function AnalyzeRuleFormFields({ form, setForm, showRuleId }: AnalyzeRuleFormFieldsProps) {
|
||||
export default function AnalyzeRuleFormFields({ form, setForm, showRuleId, existingRuleIds }: AnalyzeRuleFormFieldsProps) {
|
||||
const ruleIdTrimmed = form.ruleId.trim().toLowerCase();
|
||||
const isDuplicate = showRuleId && existingRuleIds && ruleIdTrimmed.length > 0
|
||||
&& existingRuleIds.some(id => id.toLowerCase() === ruleIdTrimmed);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Basic Fields */}
|
||||
@@ -20,7 +25,10 @@ export default function AnalyzeRuleFormFields({ form, setForm, showRuleId }: Ana
|
||||
{showRuleId && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rule ID <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={form.ruleId} onChange={(e) => setForm({ ...form, ruleId: e.target.value })} placeholder="e.g., ANALYZE-CUSTOM-001" autoComplete="off" className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" />
|
||||
<input type="text" value={form.ruleId} onChange={(e) => setForm({ ...form, ruleId: e.target.value })} placeholder="e.g., ANALYZE-CUSTOM-001" autoComplete="off" className={`w-full px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 ${isDuplicate ? "border-red-400 focus:ring-red-300 focus:border-red-400" : "border-gray-300 focus:ring-indigo-500 focus:border-indigo-500"}`} />
|
||||
{isDuplicate && (
|
||||
<p className="mt-1 text-xs text-red-600">A rule with this ID already exists. Please choose a unique Rule ID.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { AnalyzeRule, TemplateVariable } from "../types";
|
||||
|
||||
interface TemplateConfigModalProps {
|
||||
rule: AnalyzeRule;
|
||||
saving: boolean;
|
||||
onSave: (variables: Record<string, string>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function TemplateConfigModal({
|
||||
rule,
|
||||
saving,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: TemplateConfigModalProps) {
|
||||
const templateVars = rule.templateVariables || [];
|
||||
const [values, setValues] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
for (const tv of templateVars) {
|
||||
initial[tv.name] = "";
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const firstInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Focus the first input on mount
|
||||
const timer = setTimeout(() => firstInputRef.current?.focus(), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
for (const tv of templateVars) {
|
||||
const val = values[tv.name]?.trim();
|
||||
if (!val) {
|
||||
newErrors[tv.name] = "This field is required.";
|
||||
} else if (tv.validation) {
|
||||
try {
|
||||
const regex = new RegExp(tv.validation);
|
||||
if (!regex.test(val)) {
|
||||
newErrors[tv.name] = "Value does not match the expected format.";
|
||||
}
|
||||
} catch {
|
||||
// Invalid regex in rule definition, skip validation
|
||||
}
|
||||
}
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!validate()) return;
|
||||
const trimmed: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
trimmed[k] = v.trim();
|
||||
}
|
||||
onSave(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onCancel} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-xl shadow-2xl max-w-lg w-full mx-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-orange-50">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">Configure: {rule.title}</h3>
|
||||
<div className="flex items-center space-x-2 mt-0.5">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
|
||||
Template
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{rule.ruleId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-5 space-y-5">
|
||||
{/* Info box */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 flex items-start space-x-2.5">
|
||||
<svg className="w-5 h-5 text-amber-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-sm text-amber-800">
|
||||
This rule needs your environment-specific values before it can be activated.
|
||||
A custom copy will be created for your tenant.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Variable fields */}
|
||||
{templateVars.map((tv, idx) => (
|
||||
<div key={tv.name}>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1">
|
||||
{tv.label}
|
||||
</label>
|
||||
{tv.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{tv.description}</p>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={idx === 0 ? firstInputRef : undefined}
|
||||
type="text"
|
||||
value={values[tv.name] || ""}
|
||||
onChange={(e) => {
|
||||
setValues({ ...values, [tv.name]: e.target.value });
|
||||
if (errors[tv.name]) {
|
||||
setErrors({ ...errors, [tv.name]: "" });
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmit();
|
||||
}}
|
||||
placeholder={tv.placeholder}
|
||||
className={`w-full px-3 py-2.5 border-2 rounded-lg text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-offset-1 ${
|
||||
errors[tv.name]
|
||||
? "border-red-300 focus:border-red-400 focus:ring-red-200"
|
||||
: "border-amber-300 focus:border-amber-400 focus:ring-amber-200 animate-pulse-border"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{errors[tv.name] && (
|
||||
<p className="mt-1 text-xs text-red-600">{errors[tv.name]}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
Replaces: <code className="bg-gray-100 px-1 rounded">{tv.placeholder}</code>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 flex items-center justify-end space-x-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={saving}
|
||||
className="px-5 py-2 border border-gray-300 rounded-lg text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={saving}
|
||||
className="px-5 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2 text-sm font-medium"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Creating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Save & Enable</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pulse animation for the amber border */}
|
||||
<style jsx>{`
|
||||
@keyframes pulse-border {
|
||||
0%, 100% { border-color: rgb(252 211 77); }
|
||||
50% { border-color: rgb(245 158 11); }
|
||||
}
|
||||
.animate-pulse-border {
|
||||
animation: pulse-border 2s ease-in-out 3;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { useAdminMode } from "@/hooks/useAdminMode";
|
||||
import { AnalyzeRule, RuleForm, EMPTY_FORM, EMPTY_CONDITION, ruleToForm } from "./types";
|
||||
import AnalyzeRuleFormFields from "./components/AnalyzeRuleFormFields";
|
||||
import AnalyzeRuleCard from "./components/AnalyzeRuleCard";
|
||||
import TemplateConfigModal from "./components/TemplateConfigModal";
|
||||
|
||||
interface TenantInfo {
|
||||
tenantId: string;
|
||||
@@ -71,6 +72,10 @@ export default function AnalyzeRulesPage() {
|
||||
const [togglingRuleId, setTogglingRuleId] = useState<string | null>(null);
|
||||
const [deletingRuleId, setDeletingRuleId] = useState<string | null>(null);
|
||||
|
||||
// Template modal state
|
||||
const [configureTemplateRule, setConfigureTemplateRule] = useState<AnalyzeRule | null>(null);
|
||||
const [creatingFromTemplate, setCreatingFromTemplate] = useState(false);
|
||||
|
||||
// Global admin mode
|
||||
const { globalAdminMode } = useAdminMode();
|
||||
const [tenants, setTenants] = useState<TenantInfo[]>([]);
|
||||
@@ -129,6 +134,12 @@ export default function AnalyzeRulesPage() {
|
||||
|
||||
// Toggle rule enabled/disabled
|
||||
const handleToggleRule = async (rule: AnalyzeRule) => {
|
||||
// Intercept: if this is a template rule being enabled, open the config modal instead
|
||||
if ((rule.templateVariables?.length ?? 0) > 0 && !rule.enabled) {
|
||||
setConfigureTemplateRule(rule);
|
||||
return;
|
||||
}
|
||||
|
||||
setTogglingRuleId(rule.ruleId);
|
||||
const result = await mutate(
|
||||
api.rules.analyzeRule(rule.ruleId),
|
||||
@@ -265,8 +276,40 @@ export default function AnalyzeRulesPage() {
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
// Create custom rule from template
|
||||
const handleCreateFromTemplate = async (variables: Record<string, string>) => {
|
||||
if (!configureTemplateRule) return;
|
||||
setCreatingFromTemplate(true);
|
||||
const result = await mutate(
|
||||
api.rules.analyzeRuleFromTemplate(configureTemplateRule.ruleId),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(variables),
|
||||
}
|
||||
);
|
||||
if (result !== null) {
|
||||
const newRuleId = `${configureTemplateRule.ruleId}-CUSTOM`;
|
||||
trackEvent("rule_created_from_template", { ruleType: "analyze", templateRuleId: configureTemplateRule.ruleId });
|
||||
showSuccess(`Rule "${configureTemplateRule.title}" configured and enabled successfully!`);
|
||||
setConfigureTemplateRule(null);
|
||||
await fetchRules();
|
||||
// Auto-expand the newly created custom rule
|
||||
setExpandedRuleId(newRuleId);
|
||||
}
|
||||
setCreatingFromTemplate(false);
|
||||
};
|
||||
|
||||
const rulesList = rules || [];
|
||||
|
||||
// Map: templateRuleId -> custom copy ruleId (for template rules that already have a tenant copy)
|
||||
const templateCopyMap = new Map<string, string>();
|
||||
for (const r of rulesList) {
|
||||
if (r.derivedFromTemplateRuleId) {
|
||||
templateCopyMap.set(r.derivedFromTemplateRuleId, r.ruleId);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter rules
|
||||
const filteredRules = rulesList.filter((rule) => {
|
||||
const matchesSearch =
|
||||
@@ -506,7 +549,7 @@ export default function AnalyzeRulesPage() {
|
||||
textareaRows={30}
|
||||
description={<>Edit the rule as JSON. All fields are supported including <code className="bg-gray-100 px-1 rounded text-xs">event_correlation</code> condition properties.</>}
|
||||
>
|
||||
<AnalyzeRuleFormFields form={newRule} setForm={setNewRule} showRuleId={true} />
|
||||
<AnalyzeRuleFormFields form={newRule} setForm={setNewRule} showRuleId={true} existingRuleIds={rulesList.map(r => r.ruleId)} />
|
||||
</FormJsonToggle>
|
||||
<div className="flex items-center justify-end space-x-3 pt-4 mt-5 border-t border-gray-200">
|
||||
<button onClick={() => { setShowCreateForm(false); setJsonModeCreate(false); setJsonError(null); setNewRule({ ...EMPTY_FORM, conditions: [{ ...EMPTY_CONDITION }] }); }} disabled={creating} className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium">Cancel</button>
|
||||
@@ -568,6 +611,10 @@ export default function AnalyzeRulesPage() {
|
||||
onSetJsonText={setJsonText}
|
||||
onSetJsonError={setJsonError}
|
||||
readOnly={isReadOnly}
|
||||
onConfigureTemplate={(r) => setConfigureTemplateRule(r)}
|
||||
templateCopyExists={templateCopyMap.has(rule.ruleId)}
|
||||
templateCopyRuleId={templateCopyMap.get(rule.ruleId)}
|
||||
onScrollToCopy={(copyId) => setExpandedRuleId(copyId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -575,6 +622,16 @@ export default function AnalyzeRulesPage() {
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Template Configuration Modal */}
|
||||
{configureTemplateRule && (
|
||||
<TemplateConfigModal
|
||||
rule={configureTemplateRule}
|
||||
saving={creatingFromTemplate}
|
||||
onSave={handleCreateFromTemplate}
|
||||
onCancel={() => setConfigureTemplateRule(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
|
||||
@@ -31,6 +31,16 @@ export interface RuleCondition {
|
||||
eventAFilterValue?: string;
|
||||
}
|
||||
|
||||
export interface TemplateVariable {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
conditionIndex: number;
|
||||
field: string;
|
||||
placeholder: string;
|
||||
validation?: string;
|
||||
}
|
||||
|
||||
export interface AnalyzeRule {
|
||||
ruleId: string;
|
||||
title: string;
|
||||
@@ -51,6 +61,8 @@ export interface AnalyzeRule {
|
||||
remediation: RemediationStep[];
|
||||
relatedDocs: RelatedDoc[];
|
||||
tags: string[];
|
||||
templateVariables?: TemplateVariable[];
|
||||
derivedFromTemplateRuleId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -317,6 +317,52 @@ export function SectionAnalyzeRules() {
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Template Rules ── */}
|
||||
<h3 className="text-xl font-semibold text-gray-900 pt-4 border-t border-gray-200">Template Rules</h3>
|
||||
|
||||
<p className="text-gray-700 text-sm">
|
||||
Some community rules contain <strong>placeholder values</strong> that must be customized for your environment before
|
||||
they can work correctly. These rules are marked with a <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 border border-amber-200">Requires Setup</span> badge.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border border-amber-200 rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-amber-50 border-b border-amber-200">
|
||||
<p className="font-semibold text-sm text-amber-900">How template rules work</p>
|
||||
</div>
|
||||
<div className="px-4 py-4 space-y-3 text-sm text-gray-700">
|
||||
<ol className="list-decimal list-inside space-y-2">
|
||||
<li>When you click the <strong>enable toggle</strong> on a template rule, a configuration dialog opens instead of directly enabling the rule.</li>
|
||||
<li>The dialog highlights the fields that need your environment-specific values (e.g., a certificate subject name).</li>
|
||||
<li>After you enter your values and click <strong>Save & Enable</strong>, a <strong>custom copy</strong> of the rule is created for your tenant with your values applied.</li>
|
||||
<li>The original template rule remains disabled. Your custom copy is fully editable — you can adjust conditions, confidence, or other settings as needed.</li>
|
||||
</ol>
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg text-xs text-blue-900">
|
||||
<strong>Note:</strong> If you delete the custom copy, the template rule becomes available again and you can re-configure it.
|
||||
Centrally updated templates (via reseed) do not affect your existing custom copies.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-indigo-50 border-b border-indigo-200">
|
||||
<p className="font-semibold text-sm text-indigo-900">Example — No Machine Certificates Found</p>
|
||||
<p className="text-xs text-indigo-700 mt-0.5">Template rule that checks for a specific certificate subject in the local machine store</p>
|
||||
</div>
|
||||
<div className="px-4 py-4 text-xs space-y-2">
|
||||
<p className="text-gray-600">This rule ships with the placeholder <code className="bg-gray-100 px-1 rounded">CN=YOUR-CERTIFICATE-SUBJECT</code>.
|
||||
When you enable it, you are prompted to enter your actual certificate subject (e.g., <code className="bg-gray-100 px-1 rounded">CN=Contoso Root CA</code>).
|
||||
The system creates a custom rule with your value automatically substituted into the condition.</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mt-2">
|
||||
<div><p className="text-gray-500">Rule ID</p><p className="font-medium">ANALYZE-ID-001</p></div>
|
||||
<div><p className="text-gray-500">Variable</p><p className="font-medium">Certificate Subject</p></div>
|
||||
<div><p className="text-gray-500">Placeholder</p><p className="font-medium font-mono">CN=YOUR-CERTIFICATE-SUBJECT</p></div>
|
||||
<div><p className="text-gray-500">Custom Copy ID</p><p className="font-medium font-mono">ANALYZE-ID-001-CUSTOM</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── JSON editing tip ── */}
|
||||
<div className="p-4 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-700">
|
||||
<p className="font-semibold text-gray-900 mb-1">JSON editing</p>
|
||||
|
||||
@@ -20,13 +20,18 @@ interface GatherRuleFormFieldsProps {
|
||||
setForm: (f: NewRuleForm) => void;
|
||||
showRuleId: boolean;
|
||||
unrestrictedMode?: boolean;
|
||||
existingRuleIds?: string[];
|
||||
}
|
||||
|
||||
export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMode = false }: GatherRuleFormFieldsProps) {
|
||||
export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMode = false, existingRuleIds }: GatherRuleFormFieldsProps) {
|
||||
const targetValidation = useMemo(
|
||||
() => form.target.trim() ? validateGatherRuleTarget(form.collectorType, form.target, unrestrictedMode) : null,
|
||||
[form.collectorType, form.target, unrestrictedMode]
|
||||
);
|
||||
const ruleIdTrimmed = form.ruleId.trim().toLowerCase();
|
||||
const isDuplicate = showRuleId && existingRuleIds && ruleIdTrimmed.length > 0
|
||||
&& existingRuleIds.some(id => id.toLowerCase() === ruleIdTrimmed);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Row 1: Rule ID (create only), Title */}
|
||||
@@ -42,8 +47,11 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
onChange={(e) => setForm({ ...form, ruleId: e.target.value })}
|
||||
placeholder="e.g., custom-network-check"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
className={`w-full px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 transition-colors ${isDuplicate ? "border-red-400 focus:ring-red-300 focus:border-red-400" : "border-gray-300 focus:ring-indigo-500 focus:border-indigo-500"}`}
|
||||
/>
|
||||
{isDuplicate && (
|
||||
<p className="mt-1 text-xs text-red-600">A rule with this ID already exists. Please choose a unique Rule ID.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
|
||||
@@ -575,7 +575,7 @@ export default function GatherRulesPage() {
|
||||
textareaRows={20}
|
||||
description={<>Edit the rule as JSON. All fields are supported including <code className="bg-gray-100 px-1 rounded text-xs">parameters</code> for collector-specific options.</>}
|
||||
>
|
||||
<GatherRuleFormFields form={newRule} setForm={setNewRule} showRuleId={true} unrestrictedMode={unrestrictedMode} />
|
||||
<GatherRuleFormFields form={newRule} setForm={setNewRule} showRuleId={true} unrestrictedMode={unrestrictedMode} existingRuleIds={rulesList.map(r => r.ruleId)} />
|
||||
</FormJsonToggle>
|
||||
|
||||
{/* Action Buttons */}
|
||||
|
||||
@@ -107,6 +107,8 @@ export const api = {
|
||||
analyze: () => `${API_BASE_URL}/api/rules/analyze`,
|
||||
analyzeRule: (ruleId: string) =>
|
||||
`${API_BASE_URL}/api/rules/analyze/${encodeURIComponent(ruleId)}`,
|
||||
analyzeRuleFromTemplate: (ruleId: string) =>
|
||||
`${API_BASE_URL}/api/rules/analyze/${encodeURIComponent(ruleId)}/create-from-template`,
|
||||
globalAnalyze: (tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/global/rules/analyze${qs({ tenantId })}`,
|
||||
gather: (tenantId?: string) =>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user