From 7012193e0485f38426e90bfaba952538aa2f2d60 Mon Sep 17 00:00:00 2001 From: Duy Dao Date: Mon, 28 Jul 2025 22:34:52 +0700 Subject: [PATCH] feat: add tag extraction functionality to Caddy configuration and display in UI --- .../ICaddyConfigurationParsingService.cs | 7 + .../Models/Caddy/CaddyConfigurationInfo.cs | 5 + .../Caddy/CaddyConfigurationParsingService.cs | 37 ++- CaddyManager.Services/Caddy/CaddyService.cs | 7 +- .../Caddy/CaddyConfigurationInfoTests.cs | 7 +- .../CaddyConfigurationParsingServiceTests.cs | 285 ++++++++++++++++++ .../Services/Caddy/CaddyServiceTests.cs | 55 ++++ .../CaddyReverseProxyItem.razor | 15 + .../CaddyReverseProxyItem.razor.cs | 9 + .../CaddyfileEditor/CaddyfileEditor.razor.cs | 8 +- 10 files changed, 426 insertions(+), 9 deletions(-) diff --git a/CaddyManager.Contracts/Caddy/ICaddyConfigurationParsingService.cs b/CaddyManager.Contracts/Caddy/ICaddyConfigurationParsingService.cs index 901b0f4..14f1390 100644 --- a/CaddyManager.Contracts/Caddy/ICaddyConfigurationParsingService.cs +++ b/CaddyManager.Contracts/Caddy/ICaddyConfigurationParsingService.cs @@ -35,4 +35,11 @@ public interface ICaddyConfigurationParsingService /// /// List GetReverseProxyPortsFromCaddyfileContent(string caddyfileContent); + + /// + /// Extracts tags from a Caddyfile content using the format: # Tags: [tag1;tag2;tag3] + /// + /// + /// + List GetTagsFromCaddyfileContent(string caddyfileContent); } \ No newline at end of file diff --git a/CaddyManager.Contracts/Models/Caddy/CaddyConfigurationInfo.cs b/CaddyManager.Contracts/Models/Caddy/CaddyConfigurationInfo.cs index fc91a85..7178e51 100644 --- a/CaddyManager.Contracts/Models/Caddy/CaddyConfigurationInfo.cs +++ b/CaddyManager.Contracts/Models/Caddy/CaddyConfigurationInfo.cs @@ -30,6 +30,11 @@ public class CaddyConfigurationInfo /// public List AggregatedReverseProxyPorts { get; set; } = []; + /// + /// Tags extracted from the configuration content using the format: # Tags: [tag1;tag2;tag3] + /// + public List Tags { get; set; } = []; + public override bool Equals(object? obj) { if (obj is not CaddyConfigurationInfo other) diff --git a/CaddyManager.Services/Caddy/CaddyConfigurationParsingService.cs b/CaddyManager.Services/Caddy/CaddyConfigurationParsingService.cs index a4cf8fc..27ef822 100644 --- a/CaddyManager.Services/Caddy/CaddyConfigurationParsingService.cs +++ b/CaddyManager.Services/Caddy/CaddyConfigurationParsingService.cs @@ -39,7 +39,7 @@ public partial class CaddyConfigurationParsingService: ICaddyConfigurationParsin hostnames.AddRange(splitHostnames); } // Remove duplicates and return the list - return hostnames.Distinct().ToList(); + return [.. hostnames.Distinct()]; } /// @@ -80,6 +80,39 @@ public partial class CaddyConfigurationParsingService: ICaddyConfigurationParsin } } - return results.Distinct().ToList(); + return [.. results.Distinct()]; + } + + /// + public List GetTagsFromCaddyfileContent(string caddyfileContent) + { + // Split the content into lines and look for the tags line + var lines = caddyfileContent.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines) + { + var trimmedLine = line.Trim(); + if (trimmedLine.StartsWith("#")) + { + // Remove the # and any leading whitespace, then check if it starts with "tags:" + var afterHash = trimmedLine.Substring(1).TrimStart(); + if (afterHash.StartsWith("tags:", StringComparison.OrdinalIgnoreCase)) + { + // Extract the part after "tags:" + var tagsString = afterHash.Substring(5).Trim(); // 5 = length of "tags:" + if (string.IsNullOrWhiteSpace(tagsString)) + return []; + + // Split by semicolon and clean up each tag + return [.. tagsString.Split(';') + .Select(tag => tag.Trim()) + .Where(tag => !string.IsNullOrWhiteSpace(tag)) + .Distinct()]; + } + } + } + + // No tags line found + return []; } } \ No newline at end of file diff --git a/CaddyManager.Services/Caddy/CaddyService.cs b/CaddyManager.Services/Caddy/CaddyService.cs index fe5c768..b2e637f 100644 --- a/CaddyManager.Services/Caddy/CaddyService.cs +++ b/CaddyManager.Services/Caddy/CaddyService.cs @@ -146,7 +146,11 @@ public class CaddyService( /// public CaddyConfigurationInfo GetCaddyConfigurationInfo(string configurationName) { - var result = new CaddyConfigurationInfo(); + var result = new CaddyConfigurationInfo + { + FileName = configurationName + }; + var content = GetCaddyConfigurationContent(configurationName); if (string.IsNullOrWhiteSpace(content)) { @@ -156,6 +160,7 @@ public class CaddyService( result.Hostnames = parsingService.GetHostnamesFromCaddyfileContent(content); result.ReverseProxyHostname = parsingService.GetReverseProxyTargetFromCaddyfileContent(content); result.ReverseProxyPorts = parsingService.GetReverseProxyPortsFromCaddyfileContent(content); + result.Tags = parsingService.GetTagsFromCaddyfileContent(content); return result; } diff --git a/CaddyManager.Tests/Models/Caddy/CaddyConfigurationInfoTests.cs b/CaddyManager.Tests/Models/Caddy/CaddyConfigurationInfoTests.cs index d4e5d0a..cb98a94 100644 --- a/CaddyManager.Tests/Models/Caddy/CaddyConfigurationInfoTests.cs +++ b/CaddyManager.Tests/Models/Caddy/CaddyConfigurationInfoTests.cs @@ -27,6 +27,8 @@ public class CaddyConfigurationInfoTests info.FileName.Should().Be(string.Empty); info.AggregatedReverseProxyPorts.Should().NotBeNull(); info.AggregatedReverseProxyPorts.Should().BeEmpty(); + info.Tags.Should().NotBeNull(); + info.Tags.Should().BeEmpty(); } /// @@ -43,6 +45,7 @@ public class CaddyConfigurationInfoTests var reverseProxyPorts = new List { 8080, 9090 }; var fileName = "test-config"; var aggregatedPorts = new List { 8080, 9090, 3000 }; + var tags = new List { "web", "production", "ssl" }; // Act var info = new CaddyConfigurationInfo @@ -51,7 +54,8 @@ public class CaddyConfigurationInfoTests ReverseProxyHostname = reverseProxyHostname, ReverseProxyPorts = reverseProxyPorts, FileName = fileName, - AggregatedReverseProxyPorts = aggregatedPorts + AggregatedReverseProxyPorts = aggregatedPorts, + Tags = tags }; // Assert @@ -60,6 +64,7 @@ public class CaddyConfigurationInfoTests info.ReverseProxyPorts.Should().BeEquivalentTo(reverseProxyPorts); info.FileName.Should().Be(fileName); info.AggregatedReverseProxyPorts.Should().BeEquivalentTo(aggregatedPorts); + info.Tags.Should().BeEquivalentTo(tags); } /// diff --git a/CaddyManager.Tests/Services/Caddy/CaddyConfigurationParsingServiceTests.cs b/CaddyManager.Tests/Services/Caddy/CaddyConfigurationParsingServiceTests.cs index 218488c..f1882a6 100644 --- a/CaddyManager.Tests/Services/Caddy/CaddyConfigurationParsingServiceTests.cs +++ b/CaddyManager.Tests/Services/Caddy/CaddyConfigurationParsingServiceTests.cs @@ -665,4 +665,289 @@ api.test { } #endregion + + #region GetTagsFromCaddyfileContent Tests + + /// + /// Tests that the parsing service correctly extracts tags from a basic tags comment line. + /// Setup: Provides a Caddyfile content string with a simple tags comment in the correct format. + /// Expectation: The service should return a list containing all tags separated by semicolons, enabling proper tag-based organization of Caddy configurations. + /// + [Fact] + public void GetTagsFromCaddyfileContent_WithBasicTags_ReturnsCorrectTags() + { + // Arrange + var caddyfileContent = @" +# Tags: web;production;ssl +example.com { + reverse_proxy localhost:8080 +}"; + + // Act + var result = _service.GetTagsFromCaddyfileContent(caddyfileContent); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(3); + result.Should().Contain("web"); + result.Should().Contain("production"); + result.Should().Contain("ssl"); + } + + /// + /// Tests that the parsing service correctly extracts tags with whitespace variations. + /// Setup: Provides a Caddyfile content string with tags containing various whitespace patterns. + /// Expectation: The service should trim whitespace and return clean tag names, ensuring consistent tag handling regardless of formatting. + /// + [Fact] + public void GetTagsFromCaddyfileContent_WithWhitespaceVariations_ReturnsTrimmedTags() + { + // Arrange + var caddyfileContent = @" +# Tags: web ; production ; ssl +example.com { + reverse_proxy localhost:8080 +}"; + + // Act + var result = _service.GetTagsFromCaddyfileContent(caddyfileContent); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(3); + result.Should().Contain("web"); + result.Should().Contain("production"); + result.Should().Contain("ssl"); + } + + /// + /// Tests that the parsing service correctly extracts a single tag. + /// Setup: Provides a Caddyfile content string with only one tag in the tags comment. + /// Expectation: The service should return a list containing exactly one tag, ensuring proper handling of minimal tag configurations. + /// + [Fact] + public void GetTagsFromCaddyfileContent_WithSingleTag_ReturnsOneTag() + { + // Arrange + var caddyfileContent = @" +# Tags: production +example.com { + reverse_proxy localhost:8080 +}"; + + // Act + var result = _service.GetTagsFromCaddyfileContent(caddyfileContent); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(1); + result.Should().Contain("production"); + } + + /// + /// Tests that the parsing service handles case-insensitive tags comment detection. + /// Setup: Provides a Caddyfile content string with various case combinations for the tags comment. + /// Expectation: The service should detect tags comments regardless of case, ensuring robust tag parsing across different writing styles. + /// + [Theory] + [InlineData("# Tags: web;production")] + [InlineData("# tags: web;production")] + [InlineData("# TAGS: web;production")] + [InlineData("#Tags: web;production")] + [InlineData("# Tags: web;production")] + public void GetTagsFromCaddyfileContent_WithCaseVariations_ReturnsCorrectTags(string tagsLine) + { + // Arrange + var caddyfileContent = $@" +{tagsLine} +example.com {{ + reverse_proxy localhost:8080 +}}"; + + // Act + var result = _service.GetTagsFromCaddyfileContent(caddyfileContent); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(2); + result.Should().Contain("web"); + result.Should().Contain("production"); + } + + /// + /// Tests that the parsing service handles empty tags list gracefully. + /// Setup: Provides a Caddyfile content string with an empty tags comment. + /// Expectation: The service should return an empty list when tags are empty, ensuring proper handling of configurations without tags. + /// + [Fact] + public void GetTagsFromCaddyfileContent_WithEmptyTagsList_ReturnsEmptyList() + { + // Arrange + var caddyfileContent = @" +# Tags: +example.com { + reverse_proxy localhost:8080 +}"; + + // Act + var result = _service.GetTagsFromCaddyfileContent(caddyfileContent); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + + /// + /// Tests that the parsing service handles duplicate tags by removing them. + /// Setup: Provides a Caddyfile content string with duplicate tags in the tags comment. + /// Expectation: The service should return a list with unique tags only, ensuring clean tag lists without duplicates. + /// + [Fact] + public void GetTagsFromCaddyfileContent_WithDuplicateTags_ReturnsUniqueTags() + { + // Arrange + var caddyfileContent = @" +# Tags: web;production;web;ssl;production +example.com { + reverse_proxy localhost:8080 +}"; + + // Act + var result = _service.GetTagsFromCaddyfileContent(caddyfileContent); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(3); + result.Should().Contain("web"); + result.Should().Contain("production"); + result.Should().Contain("ssl"); + } + + /// + /// Tests that the parsing service handles content without tags comment gracefully. + /// Setup: Provides a Caddyfile content string without any tags comment. + /// Expectation: The service should return an empty list when no tags comment is found, ensuring proper handling of non-tagged configurations. + /// + [Fact] + public void GetTagsFromCaddyfileContent_WithoutTagsComment_ReturnsEmptyList() + { + // Arrange + var caddyfileContent = @" +example.com { + reverse_proxy localhost:8080 +}"; + + // Act + var result = _service.GetTagsFromCaddyfileContent(caddyfileContent); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + + /// + /// Tests that the parsing service handles malformed tags comment gracefully. + /// Setup: Provides a Caddyfile content string with malformed tags comment that doesn't match the expected format. + /// Expectation: The service should return an empty list when tags comment is malformed, ensuring robust error handling for invalid tag formats. + /// + [Theory] + [InlineData("# Tags web;production")] // Missing colon + [InlineData("Tags: web;production")] // Missing hash + public void GetTagsFromCaddyfileContent_WithMalformedTagsComment_ReturnsEmptyList(string tagsLine) + { + // Arrange + var caddyfileContent = $@" +{tagsLine} +example.com {{ + reverse_proxy localhost:8080 +}}"; + + // Act + var result = _service.GetTagsFromCaddyfileContent(caddyfileContent); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + + /// + /// Tests that the parsing service handles empty string content gracefully. + /// Setup: Provides an empty string as Caddyfile content. + /// Expectation: The service should return an empty list when content is empty, ensuring robust error handling for missing configurations. + /// + [Fact] + public void GetTagsFromCaddyfileContent_WithEmptyContent_ReturnsEmptyList() + { + // Arrange + var caddyfileContent = string.Empty; + + // Act + var result = _service.GetTagsFromCaddyfileContent(caddyfileContent); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + + /// + /// Tests that the parsing service handles complex tag names with special characters. + /// Setup: Provides a Caddyfile content string with tags containing special characters and complex names. + /// Expectation: The service should correctly extract tags with special characters, ensuring support for comprehensive tag naming schemes. + /// + [Fact] + public void GetTagsFromCaddyfileContent_WithComplexTagNames_ReturnsCorrectTags() + { + // Arrange + var caddyfileContent = @" +# Tags: web-frontend;backend_api;v2.0;production-env;team:alpha +example.com { + reverse_proxy localhost:8080 +}"; + + // Act + var result = _service.GetTagsFromCaddyfileContent(caddyfileContent); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(5); + result.Should().Contain("web-frontend"); + result.Should().Contain("backend_api"); + result.Should().Contain("v2.0"); + result.Should().Contain("production-env"); + result.Should().Contain("team:alpha"); + } + + /// + /// Tests that the parsing service only extracts the first tags comment when multiple are present. + /// Setup: Provides a Caddyfile content string with multiple tags comments. + /// Expectation: The service should only extract tags from the first valid tags comment, ensuring consistent behavior when multiple tag definitions exist. + /// + [Fact] + public void GetTagsFromCaddyfileContent_WithMultipleTagsComments_ReturnsFirstMatch() + { + // Arrange + var caddyfileContent = @" +# Tags: web;production +example.com { + reverse_proxy localhost:8080 +} + +# Tags: api;development +api.example.com { + reverse_proxy localhost:8081 +}"; + + // Act + var result = _service.GetTagsFromCaddyfileContent(caddyfileContent); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(2); + result.Should().Contain("web"); + result.Should().Contain("production"); + result.Should().NotContain("api"); + result.Should().NotContain("development"); + } + + #endregion } \ No newline at end of file diff --git a/CaddyManager.Tests/Services/Caddy/CaddyServiceTests.cs b/CaddyManager.Tests/Services/Caddy/CaddyServiceTests.cs index 2b7f22d..0c1c416 100644 --- a/CaddyManager.Tests/Services/Caddy/CaddyServiceTests.cs +++ b/CaddyManager.Tests/Services/Caddy/CaddyServiceTests.cs @@ -594,6 +594,7 @@ public class CaddyServiceTests : IDisposable var expectedHostnames = new List { "example.com" }; var expectedTarget = "localhost"; var expectedPorts = new List { 8080 }; + var expectedTags = new List(); _mockParsingService .Setup(x => x.GetHostnamesFromCaddyfileContent(testContent)) @@ -604,6 +605,9 @@ public class CaddyServiceTests : IDisposable _mockParsingService .Setup(x => x.GetReverseProxyPortsFromCaddyfileContent(testContent)) .Returns(expectedPorts); + _mockParsingService + .Setup(x => x.GetTagsFromCaddyfileContent(testContent)) + .Returns(expectedTags); // Act var result = _service.GetCaddyConfigurationInfo("test"); @@ -613,6 +617,57 @@ public class CaddyServiceTests : IDisposable result.Hostnames.Should().BeEquivalentTo(expectedHostnames); result.ReverseProxyHostname.Should().Be(expectedTarget); result.ReverseProxyPorts.Should().BeEquivalentTo(expectedPorts); + result.Tags.Should().BeEquivalentTo(expectedTags); + } + + /// + /// Tests that the Caddy service correctly populates the Tags property when configuration content contains tags. + /// Setup: Creates a configuration file with tags comment and mocks the parsing service to return expected tags. + /// Expectation: The service should correctly populate the Tags property using the parsing service, ensuring tag information is available for configuration management. + /// + [Fact] + public void GetCaddyConfigurationInfo_WithTags_PopulatesTagsCorrectly() + { + // Arrange + var testContent = @" +# Tags: [web;production;ssl] +example.com { + reverse_proxy localhost:8080 +}"; + var filePath = Path.Combine(_tempConfigDir, "test-with-tags.caddy"); + File.WriteAllText(filePath, testContent); + + var expectedHostnames = new List { "example.com" }; + var expectedTarget = "localhost"; + var expectedPorts = new List { 8080 }; + var expectedTags = new List { "web", "production", "ssl" }; + + _mockParsingService + .Setup(x => x.GetHostnamesFromCaddyfileContent(testContent)) + .Returns(expectedHostnames); + _mockParsingService + .Setup(x => x.GetReverseProxyTargetFromCaddyfileContent(testContent)) + .Returns(expectedTarget); + _mockParsingService + .Setup(x => x.GetReverseProxyPortsFromCaddyfileContent(testContent)) + .Returns(expectedPorts); + _mockParsingService + .Setup(x => x.GetTagsFromCaddyfileContent(testContent)) + .Returns(expectedTags); + + // Act + var result = _service.GetCaddyConfigurationInfo("test-with-tags"); + + // Assert + result.Should().NotBeNull(); + result.Hostnames.Should().BeEquivalentTo(expectedHostnames); + result.ReverseProxyHostname.Should().Be(expectedTarget); + result.ReverseProxyPorts.Should().BeEquivalentTo(expectedPorts); + result.Tags.Should().BeEquivalentTo(expectedTags); + result.Tags.Should().HaveCount(3); + result.Tags.Should().Contain("web"); + result.Tags.Should().Contain("production"); + result.Tags.Should().Contain("ssl"); } /// diff --git a/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxyItem.razor b/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxyItem.razor index e22a837..92a696d 100644 --- a/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxyItem.razor +++ b/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxyItem.razor @@ -7,6 +7,21 @@ @ConfigurationInfo.FileName + @if (ConfigurationInfo.Tags.Count > 0) + { + + + @("tag".ToQuantity(ConfigurationInfo.Tags.Count)) + + + @foreach (var tag in ConfigurationInfo.Tags) + { + ⏵ @tag + } + + + } @ConfigurationInfo.ReverseProxyHostname diff --git a/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxyItem.razor.cs b/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxyItem.razor.cs index ada5e94..5d558ae 100644 --- a/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxyItem.razor.cs +++ b/CaddyManager/Components/Pages/Caddy/CaddyReverseProxies/CaddyReverseProxyItem.razor.cs @@ -1,3 +1,4 @@ +using CaddyManager.Contracts.Caddy; using CaddyManager.Contracts.Models.Caddy; using Microsoft.AspNetCore.Components; using MudBlazor; @@ -28,6 +29,11 @@ public partial class CaddyReverseProxyItem : ComponentBase [Inject] private IDialogService DialogService { get; set; } = null!; + /// + /// Caddy service for ops on the Caddy configuration + /// + [Inject] private ICaddyService CaddyService { get; set; } = null!; + /// /// Show the Caddy file editor dialog /// @@ -45,6 +51,9 @@ public partial class CaddyReverseProxyItem : ComponentBase }); var result = await dialog.Result; + + ConfigurationInfo = CaddyService.GetCaddyConfigurationInfo(ConfigurationInfo.FileName); + await InvokeAsync(StateHasChanged); if (result is { Data: bool, Canceled: false } && (bool)result.Data) { diff --git a/CaddyManager/Components/Pages/Caddy/CaddyfileEditor/CaddyfileEditor.razor.cs b/CaddyManager/Components/Pages/Caddy/CaddyfileEditor/CaddyfileEditor.razor.cs index 086b5ad..181cbfb 100644 --- a/CaddyManager/Components/Pages/Caddy/CaddyfileEditor/CaddyfileEditor.razor.cs +++ b/CaddyManager/Components/Pages/Caddy/CaddyfileEditor/CaddyfileEditor.razor.cs @@ -95,7 +95,7 @@ public partial class CaddyfileEditor : ComponentBase else { Snackbar.Add(response.Message, Severity.Error); - MudDialog.Close(DialogResult.Ok(false)); // Indicate failed save + // MudDialog.Close(DialogResult.Ok(false)); // Indicate failed save } } @@ -129,7 +129,7 @@ public partial class CaddyfileEditor : ComponentBase { Snackbar.Add(submitResponse.Message, Severity.Error); // Indicate failed save, no restart needed - MudDialog.Close(DialogResult.Ok(false)); + // MudDialog.Close(DialogResult.Ok(false)); } } @@ -139,9 +139,7 @@ public partial class CaddyfileEditor : ComponentBase private async Task Duplicate() { var content = await _codeEditor.GetValue(); - - await OnDuplicate.InvokeAsync(content); - MudDialog.Close(DialogResult.Ok(false)); + await OnDuplicate.InvokeAsync(content); } } \ No newline at end of file