From 876efc479a269d18e2301831fd4af94fc55b59d3 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Mon, 18 Aug 2025 20:31:12 +0000 Subject: [PATCH 001/104] added workflow for develop branch --- .github/workflows/{main.yml => docker.yml} | 2 ++ 1 file changed, 2 insertions(+) rename .github/workflows/{main.yml => docker.yml} (92%) diff --git a/.github/workflows/main.yml b/.github/workflows/docker.yml similarity index 92% rename from .github/workflows/main.yml rename to .github/workflows/docker.yml index 1cffe77..dd7186a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/docker.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - develop jobs: build-and-push: @@ -35,6 +36,7 @@ jobs: type=ref,event=branch type=semver,pattern={{version}} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=develop,enable=${{ github.ref == 'refs/heads/develop' }} - name: Build and push Docker image uses: docker/build-push-action@v6 From 06473baa807ac066f876d856ae8f182748c64850 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Mon, 18 Aug 2025 21:26:52 +0000 Subject: [PATCH 002/104] Fixed issue with key algorithm --- .../CertificateManagement/CertificateManagementService.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tilework.core/Services/CertificateManagement/CertificateManagementService.cs b/tilework.core/Services/CertificateManagement/CertificateManagementService.cs index 0bde61e..0fb07ae 100644 --- a/tilework.core/Services/CertificateManagement/CertificateManagementService.cs +++ b/tilework.core/Services/CertificateManagement/CertificateManagementService.cs @@ -114,7 +114,11 @@ private PrivateKey GenerateKey(KeyAlgorithm algorithm) _ => throw new NotImplementedException(), }; - return new PrivateKey() { KeyData = keyAlg }; + return new PrivateKey() + { + Algorithm = algorithm, + KeyData = keyAlg + }; } private CertificateRequest GenerateCsr(Certificate certificate) From 192a7f89125c386a4c3060909d3e18812e60cc7e Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Tue, 19 Aug 2025 10:11:00 +0300 Subject: [PATCH 003/104] Fix lazy loading of load balancer certificates (#32) --- .../Persistence/Entities/LoadBalancing/BaseLoadBalancer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tilework.core/Persistence/Entities/LoadBalancing/BaseLoadBalancer.cs b/tilework.core/Persistence/Entities/LoadBalancing/BaseLoadBalancer.cs index ca8e458..d345879 100644 --- a/tilework.core/Persistence/Entities/LoadBalancing/BaseLoadBalancer.cs +++ b/tilework.core/Persistence/Entities/LoadBalancing/BaseLoadBalancer.cs @@ -18,5 +18,5 @@ public abstract class BaseLoadBalancer public bool Enabled { get; set; } - public List Certificates { get; set; } = new(); + public virtual List Certificates { get; set; } = new(); } \ No newline at end of file From 30cf9508e16f50399194d0407ae5d9b0dcc4e684 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Tue, 19 Aug 2025 07:27:40 +0000 Subject: [PATCH 004/104] fixing ui stuff --- .../CertificateManagement/CertificateAuthorityDetail.razor | 2 ++ .../CertificateManagement/CertificateAuthorityList.razor | 2 ++ .../Pages/CertificateManagement/CertificateAuthorityNew.razor | 2 ++ .../Pages/CertificateManagement/CertificateDetail.razor | 2 ++ .../Pages/CertificateManagement/CertificateList.razor | 2 ++ .../Pages/CertificateManagement/CertificateNew.razor | 2 ++ .../Components/Pages/LoadBalancing/LoadBalancerDetail.razor | 2 ++ .../Components/Pages/LoadBalancing/LoadBalancerEdit.razor | 2 ++ .../Components/Pages/LoadBalancing/LoadBalancerList.razor | 2 ++ .../Components/Pages/LoadBalancing/LoadBalancerNew.razor | 2 ++ .../Components/Pages/LoadBalancing/TargetGroupDetail.razor | 1 + .../Components/Pages/LoadBalancing/TargetGroupEdit.razor | 2 ++ .../Components/Pages/LoadBalancing/TargetGroupList.razor | 2 ++ .../Components/Pages/LoadBalancing/TargetGroupNew.razor | 2 ++ .../CertificateManagement/NewCertificateAuthorityForm.cs | 4 ++-- 15 files changed, 29 insertions(+), 2 deletions(-) diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor index 9356ee6..f1c4a4c 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor @@ -11,6 +11,8 @@ @page "/cm/authorities/{Id:guid}" +Certificate authority details + diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityList.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityList.razor index fb23f74..52f4465 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityList.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityList.razor @@ -10,6 +10,8 @@ @page "/cm/authorities" +Certificate authorities + Name diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityNew.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityNew.razor index 6c7621b..f16b5fd 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityNew.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityNew.razor @@ -14,6 +14,8 @@ @page "/cm/authorities/new" +New certificate authority + @if(form is NewAcmeCertificateAuthorityForm acmeForm) { Certificate details + diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor index f6ec1cd..59da021 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor @@ -10,6 +10,8 @@ @page "/cm/certificates" +Certificates + Name diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateNew.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateNew.razor index 8d49418..4e72508 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateNew.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateNew.razor @@ -12,6 +12,8 @@ @page "/cm/certificates/new" +New certificate + diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index b92452a..d9b212e 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -16,6 +16,8 @@ @page "/lb/loadbalancers/{Id:guid}" +Load balancer details + diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerEdit.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerEdit.razor index e0e3ff3..955c2b4 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerEdit.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerEdit.razor @@ -12,6 +12,8 @@ @inject ISnackbar Snackbar @page "/lb/loadbalancers/{Id:guid}/edit" +Edit load balancer + @if(form is EditApplicationLoadBalancerForm albForm) { Load balancers + Name diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerNew.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerNew.razor index 68a7fc6..d860411 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerNew.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerNew.razor @@ -15,6 +15,8 @@ @page "/lb/loadbalancers/new" +New balancer + @if(form is NewApplicationLoadBalancerForm albForm) { Target group details diff --git a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupEdit.razor b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupEdit.razor index f1743dd..4c70f54 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupEdit.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupEdit.razor @@ -14,6 +14,8 @@ @page "/lb/targetgroups/{Id:guid}/edit" +Edit target group + diff --git a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupList.razor b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupList.razor index 95c04d4..f32dc5b 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupList.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupList.razor @@ -8,6 +8,8 @@ @page "/lb/targetgroups" +Target groups + Name diff --git a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupNew.razor b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupNew.razor index 83bb2b6..46a4927 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupNew.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupNew.razor @@ -15,6 +15,8 @@ @page "/lb/targetgroups/new" +New target group + diff --git a/tilework.ui/Models/CertificateManagement/NewCertificateAuthorityForm.cs b/tilework.ui/Models/CertificateManagement/NewCertificateAuthorityForm.cs index b0b8f9e..b4add4d 100644 --- a/tilework.ui/Models/CertificateManagement/NewCertificateAuthorityForm.cs +++ b/tilework.ui/Models/CertificateManagement/NewCertificateAuthorityForm.cs @@ -15,7 +15,7 @@ public class NewPredefinedAcmeCertificateAuthorityForm : NewCertificateAuthority [Required, EmailAddress] public string? Email { get; set; } - [Display(Name = "I accept CA terms of service")] + [Display(Name = "I accept the CA terms of service")] [Range(typeof(bool), "true", "true", ErrorMessage = "You must accept the terms of service to continue.")] public bool AcceptTos { get; set; } = false; } @@ -28,7 +28,7 @@ public class NewAcmeCertificateAuthorityForm : NewCertificateAuthorityForm [Required, EmailAddress] public string? Email { get; set; } - [Display(Name = "I accept CA terms of service")] + [Display(Name = "I accept the CA terms of service")] [Range(typeof(bool), "true", "true", ErrorMessage = "You must accept the terms of service to continue.")] public bool AcceptTos { get; set; } = false; } From a9f78ed7b0b233af8b13c6a610bdf14293d315af Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Tue, 19 Aug 2025 07:53:01 +0000 Subject: [PATCH 005/104] very simple readme --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..01454f1 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Tilework + +## About + +Tilework is a service delivery platform for tools and services of the modern software landscape. It is designed with the main objective of being simple and fast to configure rather than being in the way. + +## Features +- Deployment of HTTP/TCP/UDP load balancers with multiple backends +- HTTP rules based routing, including hostname, URL path, query string +- Certificate issuing via popular services, lifecycle management, auto-renewal +- Docker based service deployment - no disruption of the host environement + + +## Install +1. Install the [docker engine](https://docs.docker.com/engine/install/) or [docker desktop](https://docs.docker.com/get-started/get-docker/). Be sure that docker compose is also installed. + + + + +2. Create a docker-compose.yml file as follows: + +```yaml +services: + tileworkui: + image: tilework/tilework:latest + ports: + - 5180:5180 + environment: + - ASPNETCORE_ENVIRONMENT=Docker + volumes: + - tilework_data:/var/lib/tilework + - /var/run/docker.sock:/var/run/docker.sock + +volumes: + tilework_data: + external: false +``` + +3. Start the service up +``` +# If using the docker-compose command +docker-compose up -d + +# If using docker-compose-plugin +docker compose up -d +``` + +4. Navigate your browser to http://\:5180 \ No newline at end of file From 50cf6723c60d813db2d5f020f98dc9c5bc6cbf18 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Tue, 19 Aug 2025 10:03:50 +0000 Subject: [PATCH 006/104] Better exception handling --- .../CertificateAuthorityDetail.razor | 11 +++++++++-- .../CertificateManagement/CertificateDetail.razor | 11 +++++++++-- .../Pages/LoadBalancing/LoadBalancerDetail.razor | 13 ++++++++++--- .../Pages/LoadBalancing/TargetGroupDetail.razor | 13 ++++++++++--- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor index f1c4a4c..43da60a 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor @@ -73,8 +73,15 @@ var result = await dialog.Result; if (!result.Canceled) { - await _certificateManagementService.DeleteCertificateAuthority(_item.Id); - _navigationManager.NavigateTo("/cm/authorities"); + try { + await _certificateManagementService.DeleteCertificateAuthority(_item.Id); + _navigationManager.NavigateTo("/cm/authorities"); + _snackbar.Add($"Certificate authority deleted successfully", Severity.Success); + } + catch(Exception ex) + { + _snackbar.Add($"Failed to delete certificate authority: {ex.Message}", Severity.Error); + } } } } diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor index 9b4066f..594748d 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor @@ -96,8 +96,15 @@ var result = await dialog.Result; if (!result.Canceled) { - await _certificateManagementService.DeleteCertificate(_item.Id); - _navigationManager.NavigateTo("/cm/certificates"); + try { + await _certificateManagementService.DeleteCertificate(_item.Id); + _navigationManager.NavigateTo("/cm/certificates"); + _snackbar.Add($"Certificate deleted successfully", Severity.Success); + } + catch(Exception ex) + { + _snackbar.Add($"Failed to delete certificate: {ex.Message}", Severity.Error); + } } } diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index d9b212e..06f820f 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -172,9 +172,16 @@ var result = await dialog.Result; if (!result.Canceled) { - await _loadBalancerService.DeleteLoadBalancer(_item.Id); - await _loadBalancerService.ApplyConfiguration(); - _navigationManager.NavigateTo("/lb/loadbalancers"); + try { + await _loadBalancerService.DeleteLoadBalancer(_item.Id); + await _loadBalancerService.ApplyConfiguration(); + _navigationManager.NavigateTo("/lb/loadbalancers"); + _snackbar.Add($"Load balancer deleted successfully", Severity.Success); + } + catch(Exception ex) + { + _snackbar.Add($"Failed to delete load balancer: {ex.Message}", Severity.Error); + } } } diff --git a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupDetail.razor index 4bf9e77..681a3fb 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupDetail.razor @@ -87,9 +87,16 @@ var result = await dialog.Result; if (!result.Canceled) { - await _loadBalancerService.DeleteTargetGroup(_item.Id); - await _loadBalancerService.ApplyConfiguration(); - _navigationManager.NavigateTo("/lb/targetgroups"); + try { + await _loadBalancerService.DeleteTargetGroup(_item.Id); + await _loadBalancerService.ApplyConfiguration(); + _navigationManager.NavigateTo("/lb/targetgroups"); + _snackbar.Add($"Target group deleted successfully", Severity.Success); + } + catch(Exception ex) + { + _snackbar.Add($"Failed to delete target group: {ex.Message}", Severity.Error); + } } } From 8f60383b80f27c6f228a77e8ed8162854de46f6b Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Tue, 19 Aug 2025 13:20:33 +0300 Subject: [PATCH 007/104] feat: select condition type when adding rule condition (#33) * feat: select condition type before adding form * fix: wrap condition menu items --- .../Components/Dialogs/RuleDialog.razor | 30 +++++++----- .../Components/Forms/ConditionForm.razor | 49 +++++++------------ 2 files changed, 36 insertions(+), 43 deletions(-) diff --git a/tilework.ui/Components/Dialogs/RuleDialog.razor b/tilework.ui/Components/Dialogs/RuleDialog.razor index cac6b87..6a09c8a 100644 --- a/tilework.ui/Components/Dialogs/RuleDialog.razor +++ b/tilework.ui/Components/Dialogs/RuleDialog.razor @@ -1,5 +1,6 @@ @using Tilework.LoadBalancing.Enums @using Tilework.LoadBalancing.Models +@using Tilework.Core.Enums @using System.Linq @using System.Collections.Generic @@ -17,13 +18,22 @@ @foreach (var condition in Rule.Conditions) { } - - Add condition - + + + + Add condition + + + + @foreach (var type in AvailableConditionTypes) + { + @type.GetDescription() + } + + @@ -82,15 +92,13 @@ void Cancel() => MudDialog.Cancel(); - bool CanAddCondition => Enum.GetValues().Except(Rule.Conditions.Select(c => c.Type)).Any(); + IEnumerable AvailableConditionTypes => Enum.GetValues().Except(Rule.Conditions.Select(c => c.Type)); - void AddCondition() + bool CanAddCondition => AvailableConditionTypes.Any(); + + void AddCondition(ConditionType type) { - var availableType = Enum.GetValues().Except(Rule.Conditions.Select(c => c.Type)).FirstOrDefault(); - if (!EqualityComparer.Default.Equals(availableType, default)) - { - Rule.Conditions.Add(new Condition { Type = availableType }); - } + Rule.Conditions.Add(new Condition { Type = type }); } void RemoveCondition(Condition condition) diff --git a/tilework.ui/Components/Forms/ConditionForm.razor b/tilework.ui/Components/Forms/ConditionForm.razor index 6d8eb6d..432747d 100644 --- a/tilework.ui/Components/Forms/ConditionForm.razor +++ b/tilework.ui/Components/Forms/ConditionForm.razor @@ -1,51 +1,36 @@ @using Tilework.LoadBalancing.Enums @using Tilework.LoadBalancing.Models @using Tilework.Core.Enums -@using System.Linq @using System.Collections.Generic @namespace Tilework.Ui.Components.Forms -
+
+ @Condition.Type.GetDescription()
- - - - @foreach (var type in AvailableTypes) - { - @type.GetDescription() - } - - - - - @for (var i = 0; i < Condition.Values.Count; i++) - { - var index = i; - - - - - - - - - } - - - - + + @for (var i = 0; i < Condition.Values.Count; i++) + { + var index = i; + + + + + + + + + } + + @code { [Parameter] public Condition Condition { get; set; } - [Parameter] public IEnumerable UsedTypes { get; set; } = Enumerable.Empty(); [Parameter] public EventCallback OnDelete { get; set; } - private IEnumerable AvailableTypes => Enum.GetValues().Except(UsedTypes); - protected override void OnParametersSet() { if (Condition?.Values == null || Condition.Values.Count == 0) From 29a89debf93b60e130276b0df013bd72df7f3aca Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Tue, 19 Aug 2025 11:11:34 +0000 Subject: [PATCH 008/104] Refactored rule dialog --- .../Components/Dialogs/RuleDialog.razor | 33 +++++++++---------- .../Components/Forms/ConditionForm.razor | 31 ++++++++--------- 2 files changed, 31 insertions(+), 33 deletions(-) diff --git a/tilework.ui/Components/Dialogs/RuleDialog.razor b/tilework.ui/Components/Dialogs/RuleDialog.razor index 6a09c8a..9ff137a 100644 --- a/tilework.ui/Components/Dialogs/RuleDialog.razor +++ b/tilework.ui/Components/Dialogs/RuleDialog.razor @@ -21,19 +21,21 @@ OnDelete="@(() => RemoveCondition(condition))" /> } - - - - Add condition - - - - @foreach (var type in AvailableConditionTypes) - { - @type.GetDescription() - } - - + + + + + Add condition + + + + @foreach (var type in AvailableConditionTypes) + { + @type.GetDescription() + } + + + @@ -66,11 +68,6 @@ protected override async Task OnInitializedAsync() { - if (Rule.Conditions == null || !Rule.Conditions.Any()) - { - Rule.Conditions = new List { new Condition() }; - } - if (Rule.TargetGroup != Guid.Empty) { SelectedTargetGroup = Rule.TargetGroup; diff --git a/tilework.ui/Components/Forms/ConditionForm.razor b/tilework.ui/Components/Forms/ConditionForm.razor index 432747d..1258dcd 100644 --- a/tilework.ui/Components/Forms/ConditionForm.razor +++ b/tilework.ui/Components/Forms/ConditionForm.razor @@ -5,25 +5,26 @@ @namespace Tilework.Ui.Components.Forms - -
- @Condition.Type.GetDescription() - -
- + + + @Condition.Type.GetDescription() + + + + @for (var i = 0; i < Condition.Values.Count; i++) { var index = i; - - - - - - - - + + + + } - + + + Add OR condition value + + From 59e5b9b8392bf3111de57e2d74ef9e76c7d9470a Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Tue, 19 Aug 2025 11:17:22 +0000 Subject: [PATCH 009/104] switched order --- tilework.core/Services/LoadBalancing/LoadBalancerService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs index 7571c6f..1c611d0 100644 --- a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs +++ b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs @@ -166,7 +166,7 @@ public async Task DisableLoadBalancer(Guid Id) public async Task> GetRules(ApplicationLoadBalancerDTO balancer) { var entity = (ApplicationLoadBalancer?)await _dbContext.LoadBalancers.FindAsync(balancer.Id); - return _mapper.Map>(entity.Rules.OrderByDescending(r => r.Priority)); + return _mapper.Map>(entity.Rules.OrderBy(r => r.Priority)); } public async Task AddRule(ApplicationLoadBalancerDTO balancer, RuleDTO rule) From 54c3a59d8043a6c769444658bf614efa94ff59fd Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Tue, 19 Aug 2025 12:07:09 +0000 Subject: [PATCH 010/104] Enable statistics --- tilework.core/Resources/haproxy.cfg | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tilework.core/Resources/haproxy.cfg b/tilework.core/Resources/haproxy.cfg index 953e8fb..16d1472 100644 --- a/tilework.core/Resources/haproxy.cfg +++ b/tilework.core/Resources/haproxy.cfg @@ -1,6 +1,7 @@ global log stdout format raw local0 - stats socket /tmp/haproxy.sock mode 600 level admin + stats socket ipv4@0.0.0.0:4380 level user + stats timeout 30s user haproxy group haproxy From a626b1c10e45855e54245e2aa27cfb00d55fc9b7 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Thu, 28 Aug 2025 18:43:56 +0000 Subject: [PATCH 011/104] Initial implementation of haproxy statistics + infra --- .../Initializers/LoadBalancingInitializer.cs | 12 +- .../Interfaces/Core/IContainerManager.cs | 4 + .../LoadBalancing/ILoadBalancerService.cs | 3 + .../LoadBalancing/ILoadBalancingMonitor.cs | 9 + .../LoadBalancerMonitoringJob.cs | 35 ++ .../20250828180030_LbStatistics.Designer.cs | 432 ++++++++++++++++++ .../Migrations/20250828180030_LbStatistics.cs | 47 ++ .../TileworkContextModelSnapshot.cs | 54 +++ .../Monitoring/LoadBalancingStatistics.cs | 66 +++ tilework.core/Persistence/DbContext.cs | 25 +- .../LoadBalancing/LoadBalancerStatistics.cs | 18 + .../HAProxy}/Configuration.cs | 0 .../HAProxy}/Enums/BalanceMode.cs | 0 .../HAProxy}/Enums/Mode.cs | 0 .../HAProxy}/HAProxyConfigurator.cs | 18 +- .../HAProxy/HAProxymonitor.cs | 92 ++++ .../Mappers/HAProxyConfigurationProfile.cs} | 4 +- .../Mappers/HAProxyMonitoringProfile.cs | 15 + .../HAProxy/Models/HAProxyStatisticsRow.cs | 90 ++++ .../Models}/Sections/BackendSection.cs | 0 .../HAProxy/Models}/Sections/ConfigSection.cs | 0 .../Models}/Sections/ConfigSectionUtils.cs | 0 .../Models}/Sections/DefaultsSection.cs | 0 .../Models}/Sections/FrontendSection.cs | 0 .../HAProxy/Models}/Sections/GlobalSection.cs | 0 .../Models}/Sections/StatementAttribute.cs | 0 .../HAProxy/Models}/Statements/Acl.cs | 0 .../HAProxy/Models}/Statements/Bind.cs | 0 .../HAProxy/Models}/Statements/Server.cs | 0 .../HAProxy/Models}/Statements/UseBackend.cs | 0 tilework.core/ServiceCollectionExtensions.cs | 14 +- .../Services/Core/DockerServiceManager.cs | 29 +- .../LoadBalancing/LoadBalancerService.cs | 57 ++- tilework.core/tilework.core.csproj | 2 + .../Components/Layout/GenericDetailview.razor | 10 +- .../LoadBalancing/LoadBalancerDetail.razor | 12 +- 36 files changed, 1018 insertions(+), 30 deletions(-) create mode 100644 tilework.core/Interfaces/LoadBalancing/ILoadBalancingMonitor.cs create mode 100644 tilework.core/Jobs/LoadBalancing/LoadBalancerMonitoringJob.cs create mode 100644 tilework.core/Migrations/20250828180030_LbStatistics.Designer.cs create mode 100644 tilework.core/Migrations/20250828180030_LbStatistics.cs create mode 100644 tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs create mode 100644 tilework.core/Persistence/Entities/LoadBalancing/LoadBalancerStatistics.cs rename tilework.core/Providers/{LoadBalancingConfigurators/HAProxy/Configuration => LoadBalancingProviders/HAProxy}/Configuration.cs (100%) rename tilework.core/Providers/{LoadBalancingConfigurators/HAProxy/Configuration => LoadBalancingProviders/HAProxy}/Enums/BalanceMode.cs (100%) rename tilework.core/Providers/{LoadBalancingConfigurators/HAProxy/Configuration => LoadBalancingProviders/HAProxy}/Enums/Mode.cs (100%) rename tilework.core/Providers/{LoadBalancingConfigurators/HAProxy/Configuration => LoadBalancingProviders/HAProxy}/HAProxyConfigurator.cs (93%) create mode 100644 tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxymonitor.cs rename tilework.core/Providers/{LoadBalancingConfigurators/HAProxy/Configuration/Mappers/HAProxyProfile.cs => LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs} (97%) create mode 100644 tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyMonitoringProfile.cs create mode 100644 tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/HAProxyStatisticsRow.cs rename tilework.core/Providers/{LoadBalancingConfigurators/HAProxy/Configuration => LoadBalancingProviders/HAProxy/Models}/Sections/BackendSection.cs (100%) rename tilework.core/Providers/{LoadBalancingConfigurators/HAProxy/Configuration => LoadBalancingProviders/HAProxy/Models}/Sections/ConfigSection.cs (100%) rename tilework.core/Providers/{LoadBalancingConfigurators/HAProxy/Configuration => LoadBalancingProviders/HAProxy/Models}/Sections/ConfigSectionUtils.cs (100%) rename tilework.core/Providers/{LoadBalancingConfigurators/HAProxy/Configuration => LoadBalancingProviders/HAProxy/Models}/Sections/DefaultsSection.cs (100%) rename tilework.core/Providers/{LoadBalancingConfigurators/HAProxy/Configuration => LoadBalancingProviders/HAProxy/Models}/Sections/FrontendSection.cs (100%) rename tilework.core/Providers/{LoadBalancingConfigurators/HAProxy/Configuration => LoadBalancingProviders/HAProxy/Models}/Sections/GlobalSection.cs (100%) rename tilework.core/Providers/{LoadBalancingConfigurators/HAProxy/Configuration => LoadBalancingProviders/HAProxy/Models}/Sections/StatementAttribute.cs (100%) rename tilework.core/Providers/{LoadBalancingConfigurators/HAProxy/Configuration => LoadBalancingProviders/HAProxy/Models}/Statements/Acl.cs (100%) rename tilework.core/Providers/{LoadBalancingConfigurators/HAProxy/Configuration => LoadBalancingProviders/HAProxy/Models}/Statements/Bind.cs (100%) rename tilework.core/Providers/{LoadBalancingConfigurators/HAProxy/Configuration => LoadBalancingProviders/HAProxy/Models}/Statements/Server.cs (100%) rename tilework.core/Providers/{LoadBalancingConfigurators/HAProxy/Configuration => LoadBalancingProviders/HAProxy/Models}/Statements/UseBackend.cs (100%) diff --git a/tilework.core/Initializers/LoadBalancingInitializer.cs b/tilework.core/Initializers/LoadBalancingInitializer.cs index 60d0e69..5e4a6cc 100644 --- a/tilework.core/Initializers/LoadBalancingInitializer.cs +++ b/tilework.core/Initializers/LoadBalancingInitializer.cs @@ -2,7 +2,10 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.DependencyInjection; +using Coravel; + using Tilework.LoadBalancing.Interfaces; +using Tilework.Core.Jobs.LoadBalancing; namespace Tilework.LoadBalancing.Services; @@ -25,6 +28,13 @@ public async Task StartAsync(CancellationToken ct) var loadBalancerService = scope.ServiceProvider.GetRequiredService(); await loadBalancerService.ApplyConfiguration(); + + scope.ServiceProvider.UseScheduler(s => + { + s.Schedule() + .EveryMinute() + .PreventOverlapping("LoadBalancerMonitoringJob"); + }); } public async Task StopAsync(CancellationToken ct) @@ -35,4 +45,4 @@ public async Task StopAsync(CancellationToken ct) await loadBalancerService.Shutdown(); } -} \ No newline at end of file +} diff --git a/tilework.core/Interfaces/Core/IContainerManager.cs b/tilework.core/Interfaces/Core/IContainerManager.cs index 83554af..9941cc4 100644 --- a/tilework.core/Interfaces/Core/IContainerManager.cs +++ b/tilework.core/Interfaces/Core/IContainerManager.cs @@ -1,3 +1,5 @@ +using System.Net; + using Tilework.Core.Enums; using Tilework.Core.Models; @@ -9,6 +11,8 @@ public interface IContainerManager public Task CreateNetwork(string name); public Task DeleteNetwork(string id); + public Task GetContainerAddress(string id); + public Task> ListContainers(string? module); public Task CreateContainer(string name, string image, string module, List? ports); public Task DeleteContainer(string id); diff --git a/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs b/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs index 3590486..425f30c 100644 --- a/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs +++ b/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs @@ -14,6 +14,9 @@ public interface ILoadBalancerService public Task EnableLoadBalancer(Guid Id); public Task DisableLoadBalancer(Guid Id); + public Task> GetStatistics(Guid Id, DateTimeOffset start, DateTimeOffset end); + public Task FetchStatistics(Guid Id); + public Task> GetRules(ApplicationLoadBalancerDTO balancer); public Task AddRule(ApplicationLoadBalancerDTO balancer, RuleDTO rule); public Task UpdateRule(ApplicationLoadBalancerDTO balancer, RuleDTO rule); diff --git a/tilework.core/Interfaces/LoadBalancing/ILoadBalancingMonitor.cs b/tilework.core/Interfaces/LoadBalancing/ILoadBalancingMonitor.cs new file mode 100644 index 0000000..c37fd82 --- /dev/null +++ b/tilework.core/Interfaces/LoadBalancing/ILoadBalancingMonitor.cs @@ -0,0 +1,9 @@ +using Tilework.LoadBalancing.Models; +using Tilework.Persistence.LoadBalancing.Models; + +namespace Tilework.LoadBalancing.Interfaces; + +public interface ILoadBalancingMonitor +{ + public Task GetRealtimeStatistics(BaseLoadBalancer balancer); +} \ No newline at end of file diff --git a/tilework.core/Jobs/LoadBalancing/LoadBalancerMonitoringJob.cs b/tilework.core/Jobs/LoadBalancing/LoadBalancerMonitoringJob.cs new file mode 100644 index 0000000..603a6ed --- /dev/null +++ b/tilework.core/Jobs/LoadBalancing/LoadBalancerMonitoringJob.cs @@ -0,0 +1,35 @@ +using Coravel.Invocable; +using Microsoft.Extensions.Logging; +using Tilework.LoadBalancing.Interfaces; + +namespace Tilework.Core.Jobs.LoadBalancing; + +public class LoadBalancerMonitoringJob : IInvocable +{ + private readonly ILoadBalancerService _loadBalancerService; + private readonly ILogger _logger; + public LoadBalancerMonitoringJob(ILoadBalancerService loadBalancerService, + ILogger logger) + { + _loadBalancerService = loadBalancerService; + _logger = logger; + } + + public async Task Invoke() + { + var balancers = await _loadBalancerService.GetLoadBalancers(); + + _logger.LogInformation($"Fetching load balancer statistics"); + foreach (var balancer in balancers.Where(b => b.Enabled == true)) + { + try + { + await _loadBalancerService.FetchStatistics(balancer.Id); + } + catch(Exception ex) + { + _logger.LogError($"Failed to fetch statistics for balancer {balancer.Id}: {ex.Message}"); + } + } + } +} diff --git a/tilework.core/Migrations/20250828180030_LbStatistics.Designer.cs b/tilework.core/Migrations/20250828180030_LbStatistics.Designer.cs new file mode 100644 index 0000000..586c4b5 --- /dev/null +++ b/tilework.core/Migrations/20250828180030_LbStatistics.Designer.cs @@ -0,0 +1,432 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Tilework.Core.Persistence; + +#nullable disable + +namespace tilework.core.Migrations +{ + [DbContext(typeof(TileworkContext))] + [Migration("20250828180030_LbStatistics")] + partial class LbStatistics + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true); + + modelBuilder.Entity("LoadBalancerCertificates", b => + { + b.Property("BalancerId") + .HasColumnType("TEXT"); + + b.Property("CertificateId") + .HasColumnType("TEXT"); + + b.HasKey("BalancerId", "CertificateId"); + + b.HasIndex("CertificateId"); + + b.ToTable("LoadBalancerCertificates"); + }); + + modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.Certificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorityId") + .HasColumnType("TEXT"); + + b.Property("CertificateDataString") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("INTEGER"); + + b.Property("Fqdn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrivateKeyId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuthorityId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("PrivateKeyId"); + + b.ToTable("Certificates"); + }); + + modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.CertificateAuthority", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CertificateAuthorities"); + }); + + modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.PrivateKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Algorithm") + .HasColumnType("INTEGER"); + + b.Property("KeyDataString") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PrivateKeys"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("LoadBalancers"); + + b.HasDiscriminator().HasValue("BaseLoadBalancer"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.LoadBalancerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("LoadBalancerId") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LoadBalancerId"); + + b.ToTable("LoadBalancerStatistics"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Rule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("LoadBalancerId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("TargetGroupId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LoadBalancerId"); + + b.HasIndex("TargetGroupId"); + + b.HasIndex("Priority", "LoadBalancerId") + .IsUnique(); + + b.ToTable("Rules"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Target", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("TargetGroupId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TargetGroupId", "Host", "Port") + .IsUnique(); + + b.ToTable("Targets"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.TargetGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("TargetGroups"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", b => + { + b.HasBaseType("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.HasDiscriminator().HasValue("ApplicationLoadBalancer"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.NetworkLoadBalancer", b => + { + b.HasBaseType("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.Property("TargetGroupId") + .HasColumnType("TEXT"); + + b.HasIndex("TargetGroupId"); + + b.ToTable("LoadBalancers", t => + { + t.Property("Protocol") + .HasColumnName("NetworkLoadBalancer_Protocol"); + }); + + b.HasDiscriminator().HasValue("NetworkLoadBalancer"); + }); + + modelBuilder.Entity("LoadBalancerCertificates", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer", null) + .WithMany() + .HasForeignKey("BalancerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tilework.Persistence.CertificateManagement.Models.Certificate", null) + .WithMany() + .HasForeignKey("CertificateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.Certificate", b => + { + b.HasOne("Tilework.Persistence.CertificateManagement.Models.CertificateAuthority", "Authority") + .WithMany() + .HasForeignKey("AuthorityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tilework.Persistence.CertificateManagement.Models.PrivateKey", "PrivateKey") + .WithMany() + .HasForeignKey("PrivateKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Authority"); + + b.Navigation("PrivateKey"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.LoadBalancerStatistics", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer", "LoadBalancer") + .WithMany() + .HasForeignKey("LoadBalancerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Tilework.LoadBalancing.Models.LoadBalancingStatistics", "Statistics", b1 => + { + b1.Property("LoadBalancerStatisticsId") + .HasColumnType("TEXT"); + + b1.Property("CurrentQueue") + .HasColumnType("INTEGER"); + + b1.Property("CurrentSessions") + .HasColumnType("INTEGER"); + + b1.HasKey("LoadBalancerStatisticsId"); + + b1.ToTable("LoadBalancerStatistics"); + + b1.ToJson("Statistics"); + + b1.WithOwner() + .HasForeignKey("LoadBalancerStatisticsId"); + }); + + b.Navigation("LoadBalancer"); + + b.Navigation("Statistics") + .IsRequired(); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Rule", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", "LoadBalancer") + .WithMany("Rules") + .HasForeignKey("LoadBalancerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") + .WithMany() + .HasForeignKey("TargetGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Tilework.LoadBalancing.Models.Condition", "Conditions", b1 => + { + b1.Property("RuleId") + .HasColumnType("TEXT"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("INTEGER"); + + b1.Property("Type") + .HasColumnType("INTEGER"); + + b1.PrimitiveCollection("Values") + .IsRequired() + .HasColumnType("TEXT"); + + b1.HasKey("RuleId", "__synthesizedOrdinal"); + + b1.ToTable("Rules"); + + b1.ToJson("Conditions"); + + b1.WithOwner() + .HasForeignKey("RuleId"); + }); + + b.Navigation("Conditions"); + + b.Navigation("LoadBalancer"); + + b.Navigation("TargetGroup"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Target", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") + .WithMany("Targets") + .HasForeignKey("TargetGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TargetGroup"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.NetworkLoadBalancer", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") + .WithMany() + .HasForeignKey("TargetGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TargetGroup"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.TargetGroup", b => + { + b.Navigation("Targets"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", b => + { + b.Navigation("Rules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/tilework.core/Migrations/20250828180030_LbStatistics.cs b/tilework.core/Migrations/20250828180030_LbStatistics.cs new file mode 100644 index 0000000..b8d5a1b --- /dev/null +++ b/tilework.core/Migrations/20250828180030_LbStatistics.cs @@ -0,0 +1,47 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace tilework.core.Migrations +{ + /// + public partial class LbStatistics : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "LoadBalancerStatistics", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + LoadBalancerId = table.Column(type: "TEXT", nullable: false), + Timestamp = table.Column(type: "INTEGER", nullable: false), + Statistics = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LoadBalancerStatistics", x => x.Id); + table.ForeignKey( + name: "FK_LoadBalancerStatistics_LoadBalancers_LoadBalancerId", + column: x => x.LoadBalancerId, + principalTable: "LoadBalancers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_LoadBalancerStatistics_LoadBalancerId", + table: "LoadBalancerStatistics", + column: "LoadBalancerId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "LoadBalancerStatistics"); + } + } +} diff --git a/tilework.core/Migrations/TileworkContextModelSnapshot.cs b/tilework.core/Migrations/TileworkContextModelSnapshot.cs index 5f15fd1..98e964d 100644 --- a/tilework.core/Migrations/TileworkContextModelSnapshot.cs +++ b/tilework.core/Migrations/TileworkContextModelSnapshot.cs @@ -153,6 +153,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.UseTphMappingStrategy(); }); + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.LoadBalancerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("LoadBalancerId") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LoadBalancerId"); + + b.ToTable("LoadBalancerStatistics"); + }); + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Rule", b => { b.Property("Id") @@ -291,6 +310,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("PrivateKey"); }); + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.LoadBalancerStatistics", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer", "LoadBalancer") + .WithMany() + .HasForeignKey("LoadBalancerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Tilework.LoadBalancing.Models.LoadBalancingStatistics", "Statistics", b1 => + { + b1.Property("LoadBalancerStatisticsId") + .HasColumnType("TEXT"); + + b1.Property("CurrentQueue") + .HasColumnType("INTEGER"); + + b1.Property("CurrentSessions") + .HasColumnType("INTEGER"); + + b1.HasKey("LoadBalancerStatisticsId"); + + b1.ToTable("LoadBalancerStatistics"); + + b1.ToJson("Statistics"); + + b1.WithOwner() + .HasForeignKey("LoadBalancerStatisticsId"); + }); + + b.Navigation("LoadBalancer"); + + b.Navigation("Statistics") + .IsRequired(); + }); + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Rule", b => { b.HasOne("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", "LoadBalancer") diff --git a/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs b/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs new file mode 100644 index 0000000..7cf016d --- /dev/null +++ b/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs @@ -0,0 +1,66 @@ +namespace Tilework.LoadBalancing.Models; + +public class LoadBalancingStatistics +{ + public int? CurrentSessions { get; init; } + public int? CurrentQueue { get; init; } + + + + // // Queue + + // public int? MaxQueue { get; init; } + // public int? QueueLimit { get; init; } + + // // Sessions / connections + + // public int? MaxSessions { get; init; } + // public int? SessionLimit { get; init; } + // public long? TotalSessions { get; init; } + + // // Traffic + // public long? BytesIn { get; init; } + // public long? BytesOut { get; init; } + + // // Errors / retries + // public long? DeniedRequests { get; init; } + // public long? DeniedResponses { get; init; } + // public long? RequestErrors { get; init; } + // public long? ConnectionErrors { get; init; } + // public long? ResponseErrors { get; init; } + // public long? Retries { get; init; } + // public long? Redispatches { get; init; } + + // // HTTP responses + // public long? Responses1xx { get; init; } + // public long? Responses2xx { get; init; } + // public long? Responses3xx { get; init; } + // public long? Responses4xx { get; init; } + // public long? Responses5xx { get; init; } + // public long? ResponsesOther { get; init; } + + // // Timings + // public int? AvgQueueTimeMs { get; init; } + // public int? AvgConnectTimeMs { get; init; } + // public int? AvgResponseTimeMs { get; init; } + // public int? AvgTotalTimeMs { get; init; } + + // // Status / health + // public string? Status { get; init; } + // public int? Weight { get; init; } + // public int? ActiveServers { get; init; } + // public int? BackupServers { get; init; } + // public long? FailedChecks { get; init; } + // public long? DowntimeTransitions { get; init; } + // public int? SecondsSinceLastChange { get; init; } + // public long? TotalDowntime { get; init; } + + // // Compression + // public long? CompressedIn { get; init; } + // public long? CompressedOut { get; init; } + // public long? CompressionBypassed { get; init; } + + // // Cache + // public long? CacheHits { get; init; } + // public long? CacheMisses { get; init; } +} \ No newline at end of file diff --git a/tilework.core/Persistence/DbContext.cs b/tilework.core/Persistence/DbContext.cs index e5a17e3..07cba2d 100644 --- a/tilework.core/Persistence/DbContext.cs +++ b/tilework.core/Persistence/DbContext.cs @@ -25,6 +25,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) public DbSet Rules { get; set; } public DbSet TargetGroups { get; set; } public DbSet Targets { get; set; } + public DbSet LoadBalancerStatistics { get; set; } // Certificate management @@ -38,10 +39,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // Load balancing modelBuilder.Entity() - .OwnsMany(r => r.Conditions, b => - { - b.ToJson(); - }); + .OwnsMany(r => r.Conditions, b => + { + b.ToJson(); + }); modelBuilder.Entity() .Property(e => e.Host) @@ -73,6 +74,22 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) ); + + modelBuilder.Entity() + .Property(x => x.Timestamp) + .HasConversion( + v => v.ToUnixTimeSeconds(), + v => DateTimeOffset.FromUnixTimeSeconds(v) + ) + .HasColumnType("INTEGER"); + + modelBuilder.Entity() + .OwnsOne(o => o.Statistics, b => + { + b.ToJson(); + }); + + // Certificate management modelBuilder.Entity() .Property(x => x.ExpiresAtUtc) diff --git a/tilework.core/Persistence/Entities/LoadBalancing/LoadBalancerStatistics.cs b/tilework.core/Persistence/Entities/LoadBalancing/LoadBalancerStatistics.cs new file mode 100644 index 0000000..425090e --- /dev/null +++ b/tilework.core/Persistence/Entities/LoadBalancing/LoadBalancerStatistics.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; + +using Tilework.LoadBalancing.Models; + +namespace Tilework.Persistence.LoadBalancing.Models; + +public class LoadBalancerStatistics +{ + public Guid Id { get; set; } + + public Guid LoadBalancerId { get; set; } + public virtual BaseLoadBalancer LoadBalancer { get; set; } + + public DateTimeOffset Timestamp { get; set; } + + public LoadBalancingStatistics Statistics { get; set; } +} \ No newline at end of file diff --git a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Configuration.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Configuration.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Configuration.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Configuration.cs diff --git a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Enums/BalanceMode.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Enums/BalanceMode.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Enums/BalanceMode.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Enums/BalanceMode.cs diff --git a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Enums/Mode.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Enums/Mode.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Enums/Mode.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Enums/Mode.cs diff --git a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/HAProxyConfigurator.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs similarity index 93% rename from tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/HAProxyConfigurator.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs index 26611b4..513073c 100644 --- a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/HAProxyConfigurator.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs @@ -226,11 +226,25 @@ private static string GetCertPem(X509Certificate2 cert) return new string(pem); } + private async Task GetContainer(BaseLoadBalancer balancer) + { + var containers = await GetLoadBalancerContainers(); + var container = containers.FirstOrDefault(cnt => cnt.Name == balancer.Id.ToString()); + if (container == null) + throw new ArgumentException($"Container for balancer {balancer.Id} not found"); + return container; + } + + public async Task GetLoadBalancerHostname(BaseLoadBalancer balancer) + { + var container = await GetContainer(balancer); + return (await _containerManager.GetContainerAddress(container.Id)).ToString(); + } + public async Task CheckLoadBalancerStatus(BaseLoadBalancer balancer) { - var containers = await GetLoadBalancerContainers(); - var container = containers.First(cnt => cnt.Name == balancer.Id.ToString()); + var container = await GetContainer(balancer); return container.State == ContainerState.Running; } diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxymonitor.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxymonitor.cs new file mode 100644 index 0000000..18e4286 --- /dev/null +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxymonitor.cs @@ -0,0 +1,92 @@ +using Microsoft.Extensions.Logging; +using System.Net.Sockets; +using System.Text; +using System.Globalization; + +using AutoMapper; +using CsvHelper; + +using Tilework.LoadBalancing.Models; +using Tilework.LoadBalancing.Interfaces; +using Tilework.Persistence.LoadBalancing.Models; + +using Tilework.Core.Interfaces; + +namespace Tilework.LoadBalancing.Haproxy; + +public class HAProxyMonitor : ILoadBalancingMonitor +{ + private readonly IContainerManager _containerManager; + private readonly HAProxyConfigurator _configurator; + private readonly ILogger _logger; + private readonly IMapper _mapper; + + public HAProxyMonitor(IContainerManager containerManager, + HAProxyConfigurator configurator, + ILogger logger, + IMapper mapper) + { + _logger = logger; + _configurator = configurator; + _containerManager = containerManager; + _mapper = mapper; + } + + public async Task> SendReceiveCommand(NetworkStream stream, string command) + { + var cmd = Encoding.ASCII.GetBytes($"{command}\n"); + stream.Write(cmd, 0, cmd.Length); + + var buffer = new byte[65535]; + int bytesRead = stream.Read(buffer, 0, buffer.Length); + var response = Encoding.ASCII.GetString(buffer, 0, bytesRead); + + var lines = response.Split('\n') + .Where(l => !string.IsNullOrWhiteSpace(l)) + .ToList(); + + var headerLine = lines.FirstOrDefault(l => l.TrimStart().StartsWith("#")); + if (headerLine is null) return new(); + + var header = headerLine.TrimStart('#', ' '); + var dataLines = lines.Where(l => !l.TrimStart().StartsWith("#")); + + var normalized = string.Join('\n', new[] { header }.Concat(dataLines)); + + using var reader = new StringReader(normalized); + var config = new CsvHelper.Configuration.CsvConfiguration(CultureInfo.InvariantCulture) + { + HasHeaderRecord = true, + BadDataFound = null, + MissingFieldFound = null, + HeaderValidated = null, + TrimOptions = CsvHelper.Configuration.TrimOptions.Trim, + DetectColumnCountChanges = false + }; + + using var csv = new CsvReader(reader, config); + + var intOpts = csv.Context.TypeConverterOptionsCache.GetOptions(); + intOpts.NullValues.AddRange(new[] { "", "-", "NA" }); + + return csv.GetRecords().ToList(); + } + + + public async Task GetRealtimeStatistics(BaseLoadBalancer balancer) + { + if (await _configurator.CheckLoadBalancerStatus(balancer) == false) + throw new ArgumentOutOfRangeException($"Cannot get statistics for balancer {balancer}: Balancer is not running"); + + var hostname = await _configurator.GetLoadBalancerHostname(balancer); + + using var client = new TcpClient(hostname, 4380); + using var stream = client.GetStream(); + + var response = await SendReceiveCommand(stream, "show stat"); + + var balancerStats = response.First(r => r.svname == "FRONTEND" && r.pxname == balancer.Id.ToString()); + + return _mapper.Map(balancerStats); + } +} \ No newline at end of file diff --git a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Mappers/HAProxyProfile.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs similarity index 97% rename from tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Mappers/HAProxyProfile.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs index 6ee6964..5d884b3 100644 --- a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Mappers/HAProxyProfile.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs @@ -6,9 +6,9 @@ namespace Tilework.LoadBalancing.Haproxy; -public class HAProxyProfile : Profile +public class HAProxyConfigurationProfile : Profile { - public HAProxyProfile() + public HAProxyConfigurationProfile() { CreateMap() .ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.Id.ToString())) diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyMonitoringProfile.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyMonitoringProfile.cs new file mode 100644 index 0000000..5d0196a --- /dev/null +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyMonitoringProfile.cs @@ -0,0 +1,15 @@ +using AutoMapper; +using Tilework.LoadBalancing.Models; + +namespace Tilework.LoadBalancing.Haproxy; + +public class HAProxyMonitoringProfile : Profile +{ + public HAProxyMonitoringProfile() + { + CreateMap() + .ForMember(dest => dest.CurrentSessions, opt => opt.MapFrom(src => src.scur)) + .ForPath(dest => dest.CurrentQueue, opt => opt.MapFrom(src => src.qcur)); + } +} + diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/HAProxyStatisticsRow.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/HAProxyStatisticsRow.cs new file mode 100644 index 0000000..4b04501 --- /dev/null +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/HAProxyStatisticsRow.cs @@ -0,0 +1,90 @@ +namespace Tilework.LoadBalancing.Haproxy; + +public class HAProxyStatisticsRow +{ + // Identity + public string pxname { get; init; } = ""; + public string svname { get; init; } = ""; + + // Queue + public int? qcur { get; init; } + public int? qmax { get; init; } + public int? qlimit { get; init; } + + // Sessions + public int? scur { get; init; } + public int? smax { get; init; } + public int? slim { get; init; } + public long? stot { get; init; } + + // Bytes + public long? bin { get; init; } + public long? bout { get; init; } + + // Denials / errors / retries + public long? dreq { get; init; } + public long? dresp { get; init; } + public long? ereq { get; init; } + public long? econ { get; init; } + public long? eresp { get; init; } + public long? wretr { get; init; } + public long? wredis { get; init; } + + // HTTP responses + public long? hrsp_1xx { get; init; } + public long? hrsp_2xx { get; init; } + public long? hrsp_3xx { get; init; } + public long? hrsp_4xx { get; init; } + public long? hrsp_5xx { get; init; } + public long? hrsp_other { get; init; } + + // Queue/connect/response times (ms) + public int? qtime { get; init; } + public int? ctime { get; init; } + public int? rtime { get; init; } + public int? ttime { get; init; } + + // Status & health + public string? status { get; init; } + public int? weight { get; init; } + public int? act { get; init; } + public int? bck { get; init; } + public long? chkfail { get; init; } + public long? chkdown { get; init; } + public int? lastchg { get; init; } + public long? downtime { get; init; } + public long? qlimit_id { get; init; } // sometimes appears depending on build + + // Throttle / rate + public int? throttle { get; init; } + public int? lbtot { get; init; } + + // Check info + public string? tracked { get; init; } + public string? type { get; init; } + public int? rate { get; init; } + public int? rate_lim { get; init; } + public int? rate_max { get; init; } + + // HTTP specifics + public string? check_status { get; init; } + public int? check_code { get; init; } + public long? check_duration { get; init; } + + // Compression + public long? comp_in { get; init; } + public long? comp_out { get; init; } + public long? comp_byp { get; init; } + + // HTTP cache + public long? cache_hits { get; init; } + public long? cache_miss { get; init; } + + // Last / agent info + public string? srv_abrt { get; init; } + public string? cli_abrt { get; init; } + + // Extra fields (newer HAProxy may add more, keep as strings) + public string? lastsess { get; init; } + public string? qid { get; init; } +} \ No newline at end of file diff --git a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Sections/BackendSection.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/BackendSection.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Sections/BackendSection.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/BackendSection.cs diff --git a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Sections/ConfigSection.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/ConfigSection.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Sections/ConfigSection.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/ConfigSection.cs diff --git a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Sections/ConfigSectionUtils.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/ConfigSectionUtils.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Sections/ConfigSectionUtils.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/ConfigSectionUtils.cs diff --git a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Sections/DefaultsSection.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/DefaultsSection.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Sections/DefaultsSection.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/DefaultsSection.cs diff --git a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Sections/FrontendSection.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/FrontendSection.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Sections/FrontendSection.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/FrontendSection.cs diff --git a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Sections/GlobalSection.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/GlobalSection.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Sections/GlobalSection.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/GlobalSection.cs diff --git a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Sections/StatementAttribute.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/StatementAttribute.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Sections/StatementAttribute.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/StatementAttribute.cs diff --git a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Statements/Acl.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Acl.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Statements/Acl.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Acl.cs diff --git a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Statements/Bind.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Bind.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Statements/Bind.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Bind.cs diff --git a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Statements/Server.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Server.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Statements/Server.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Server.cs diff --git a/tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Statements/UseBackend.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/UseBackend.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingConfigurators/HAProxy/Configuration/Statements/UseBackend.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/UseBackend.cs diff --git a/tilework.core/ServiceCollectionExtensions.cs b/tilework.core/ServiceCollectionExtensions.cs index 41766ec..aa60010 100644 --- a/tilework.core/ServiceCollectionExtensions.cs +++ b/tilework.core/ServiceCollectionExtensions.cs @@ -2,6 +2,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.EntityFrameworkCore; +using Coravel; + using Tilework.Core.Interfaces; using Tilework.LoadBalancing.Interfaces; using Tilework.CertificateManagement.Interfaces; @@ -16,6 +18,7 @@ using Tilework.CertificateManagement.Models; using Tilework.Core.Persistence; +using Tilework.Core.Jobs.LoadBalancing; namespace Tilework.Core.Services; @@ -27,6 +30,11 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service services.AddSingleton(); services.AddHostedService(); + + services.AddScheduler(); + services.AddQueue(); + services.AddEvents(); + return services; } @@ -36,10 +44,12 @@ public static IServiceCollection AddLoadBalancing(this IServiceCollection servic { services.Configure(configuration); - services.AddAutoMapper(typeof(HAProxyProfile)); + services.AddAutoMapper(typeof(HAProxyConfigurationProfile)); + services.AddAutoMapper(typeof(HAProxyMonitoringProfile)); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddDbContext(dbContextOptions); @@ -47,6 +57,8 @@ public static IServiceCollection AddLoadBalancing(this IServiceCollection servic services.AddAutoMapper(typeof(LoadBalancingMappingProfile)); + services.AddTransient(); + return services; } diff --git a/tilework.core/Services/Core/DockerServiceManager.cs b/tilework.core/Services/Core/DockerServiceManager.cs index 95ec499..e2f1a55 100644 --- a/tilework.core/Services/Core/DockerServiceManager.cs +++ b/tilework.core/Services/Core/DockerServiceManager.cs @@ -9,6 +9,7 @@ using Tilework.Core.Interfaces; using Tilework.Core.Models; using Tilework.Exceptions.Core; +using System.Net; namespace Tilework.Core.Services; @@ -48,7 +49,7 @@ public async Task> ListNetworks() { var labelFilters = new Dictionary { - { "TileworkManaged=true", true } + { "dev.tilework.managed=true", true } }; var networks = await _client.Networks.ListNetworksAsync( @@ -71,7 +72,7 @@ public async Task> ListNetworks() public async Task CreateNetwork(string name) { var tags = new Dictionary { - {"TileworkManaged", "true"} + {"dev.tilework.managed", "true"} }; var response = await _client.Networks.CreateNetworkAsync( @@ -91,16 +92,32 @@ public async Task DeleteNetwork(string id) await _client.Networks.DeleteNetworkAsync(id); } + public async Task GetContainerAddress(string id) + { + + var info = await _client.Containers.InspectContainerAsync(id); + + if (info.NetworkSettings.Networks.Count == 0) + return null; + + if (info.NetworkSettings.Networks.Count > 1) + _logger.LogWarning("Container is attached on multiple networks. Getting address on first"); + + var network = info.NetworkSettings.Networks.First(); + + return IPAddress.Parse(network.Value.IPAddress); + } + public async Task> ListContainers(string? module = null) { var labelFilters = new Dictionary { - { "TileworkManaged=true", true } + { "dev.tilework.managed=true", true } }; if (!string.IsNullOrEmpty(module)) - labelFilters.Add($"Module={module}", true); + labelFilters.Add($"dev.tilework.module={module}", true); var containers = await _client.Containers.ListContainersAsync( @@ -148,8 +165,8 @@ await _client.Images.CreateImageAsync( var tags = new Dictionary { - {"TileworkManaged", "true"}, - {"Module", module} + {"dev.tilework.managed", "true"}, + {"dev.tilework.module", module} }; var exposedPorts = new Dictionary(); diff --git a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs index 1c611d0..7ab27b9 100644 --- a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs +++ b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs @@ -13,7 +13,6 @@ using Tilework.LoadBalancing.Haproxy; using Tilework.CertificateManagement.Interfaces; -using Tilework.Persistence.CertificateManagement.Models; using Tilework.CertificateManagement.Models; using Tilework.Core.Persistence; @@ -27,15 +26,14 @@ public class LoadBalancerService : ILoadBalancerService private readonly TileworkContext _dbContext; private readonly LoadBalancerConfiguration _settings; private readonly ILoadBalancingConfigurator _configurator; + private readonly ILoadBalancingMonitor _monitor; private readonly ILogger _logger; private readonly IMapper _mapper; - private readonly ICertificateManagementService _certificateManagementService; public LoadBalancerService(IServiceProvider serviceProvider, TileworkContext dbContext, IMapper mapper, - ICertificateManagementService certificateManagementService, IOptions settings, ILogger logger) { @@ -43,8 +41,8 @@ public LoadBalancerService(IServiceProvider serviceProvider, _logger = logger; _settings = settings.Value; _configurator = LoadConfigurator(serviceProvider, _settings); + _monitor = LoadMonitor(serviceProvider, _settings); - _certificateManagementService = certificateManagementService; _mapper = mapper; } @@ -57,6 +55,15 @@ private ILoadBalancingConfigurator LoadConfigurator(IServiceProvider serviceProv }; } + private ILoadBalancingMonitor LoadMonitor(IServiceProvider serviceProvider, LoadBalancerConfiguration settings) + { + return settings.Backend switch + { + "haproxy" => serviceProvider.GetRequiredService(), + _ => throw new ArgumentException($"Invalid monitor in load balancing tile: {_settings.Backend}") + }; + } + private BaseLoadBalancerDTO MapBalancerToDto(BaseLoadBalancer entity) { return entity switch @@ -72,10 +79,10 @@ private BaseLoadBalancer MapDtoToBalancer(BaseLoadBalancerDTO dto, BaseLoadBalan return dto switch { ApplicationLoadBalancerDTO appBalancer => - entity == null ? _mapper.Map(appBalancer) : _mapper.Map(appBalancer, (ApplicationLoadBalancer) entity), + entity == null ? _mapper.Map(appBalancer) : _mapper.Map(appBalancer, (ApplicationLoadBalancer)entity), NetworkLoadBalancerDTO netBalancer => - entity == null ? _mapper.Map(netBalancer) : _mapper.Map(netBalancer, (NetworkLoadBalancer) entity), + entity == null ? _mapper.Map(netBalancer) : _mapper.Map(netBalancer, (NetworkLoadBalancer)entity), _ => throw new InvalidOperationException("Invalid balancer type") }; @@ -109,7 +116,7 @@ public async Task UpdateLoadBalancer(BaseLoadBalancerDTO ba _dbContext.LoadBalancers.Update(entity); await _dbContext.SaveChangesAsync(); - + return MapBalancerToDto(entity); } @@ -171,7 +178,7 @@ public async Task> GetRules(ApplicationLoadBalancerDTO balancer) public async Task AddRule(ApplicationLoadBalancerDTO balancer, RuleDTO rule) { - var entity = (ApplicationLoadBalancer?) await _dbContext.LoadBalancers.FindAsync(balancer.Id); + var entity = (ApplicationLoadBalancer?)await _dbContext.LoadBalancers.FindAsync(balancer.Id); entity.Rules.Add(_mapper.Map(rule)); _dbContext.LoadBalancers.Update(entity); await _dbContext.SaveChangesAsync(); @@ -179,7 +186,7 @@ public async Task AddRule(ApplicationLoadBalancerDTO balancer, RuleDTO rule) public async Task UpdateRule(ApplicationLoadBalancerDTO balancer, RuleDTO rule) { - var entity = (ApplicationLoadBalancer?) await _dbContext.LoadBalancers.FindAsync(balancer.Id); + var entity = (ApplicationLoadBalancer?)await _dbContext.LoadBalancers.FindAsync(balancer.Id); var r = entity.Rules.FirstOrDefault(t => t.Id == rule.Id); if (r != null) { @@ -191,7 +198,7 @@ public async Task UpdateRule(ApplicationLoadBalancerDTO balancer, RuleDTO rule) public async Task RemoveRule(ApplicationLoadBalancerDTO balancer, RuleDTO rule) { - var entity = (ApplicationLoadBalancer?) await _dbContext.LoadBalancers.FindAsync(balancer.Id); + var entity = (ApplicationLoadBalancer?)await _dbContext.LoadBalancers.FindAsync(balancer.Id); var r = entity.Rules.FirstOrDefault(t => t.Id == rule.Id); entity.Rules.Remove(r); _dbContext.LoadBalancers.Update(entity); @@ -270,7 +277,7 @@ public async Task UpdateTargetGroup(TargetGroupDTO group) _dbContext.TargetGroups.Update(entity); await _dbContext.SaveChangesAsync(); - + return _mapper.Map(entity); } @@ -327,4 +334,32 @@ public async Task Shutdown() { await _configurator.Shutdown(); } + + public async Task> GetStatistics(Guid Id, DateTimeOffset start, DateTimeOffset end) + { + return await _dbContext.LoadBalancerStatistics + .AsNoTracking() + .Where(lbs => lbs.LoadBalancerId == Id && lbs.Timestamp >= start && lbs.Timestamp <= end) + .Select(s => s.Statistics) + .ToListAsync(); + } + + public async Task FetchStatistics(Guid Id) + { + var entity = await _dbContext.LoadBalancers.FindAsync(Id); + if (entity == null) + throw new ArgumentNullException("Non-existent load balancer"); + + var statistics = await _monitor.GetRealtimeStatistics(entity); + + var statisticsPoint = new LoadBalancerStatistics() + { + LoadBalancer = entity, + Timestamp = DateTimeOffset.UtcNow, + Statistics = statistics + }; + + await _dbContext.LoadBalancerStatistics.AddAsync(statisticsPoint); + await _dbContext.SaveChangesAsync(); + } } diff --git a/tilework.core/tilework.core.csproj b/tilework.core/tilework.core.csproj index 8967be0..3866366 100644 --- a/tilework.core/tilework.core.csproj +++ b/tilework.core/tilework.core.csproj @@ -9,6 +9,8 @@ + + diff --git a/tilework.ui/Components/Layout/GenericDetailview.razor b/tilework.ui/Components/Layout/GenericDetailview.razor index 8496109..c4d8ef2 100644 --- a/tilework.ui/Components/Layout/GenericDetailview.razor +++ b/tilework.ui/Components/Layout/GenericDetailview.razor @@ -34,7 +34,7 @@ @if(TabContent != null) { - + @TabContent } @@ -48,4 +48,12 @@ [Parameter] public RenderFragment? ActionContent { get; set; } [Parameter] public RenderFragment? DetailContent { get; set; } [Parameter] public RenderFragment? TabContent { get; set; } + + [Parameter] public EventCallback OnTabChanged { get; set; } + + + private async Task TabChanged(int index) + { + await OnTabChanged.InvokeAsync(index); + } } diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index 06f820f..fa93286 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -18,7 +18,7 @@ Load balancer details - + @@ -109,7 +109,7 @@ } - TODO + @@ -379,4 +379,12 @@ { _certificates = await _loadBalancerService.GetCertificates(_item); } + + private async Task OnTabChanged(int index) + { + if(index == 1 && _item != null && _item.Enabled == true) + { + var stats = await _loadBalancerService.GetStatistics(_item.Id, DateTimeOffset.UtcNow - TimeSpan.FromHours(1), DateTimeOffset.UtcNow); + } + } } From b029d23602147459539a806d88440ffbe343dd41 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sat, 30 Aug 2025 08:52:54 +0000 Subject: [PATCH 012/104] Added timestamp to statistics --- .../Interfaces/LoadBalancing/ILoadBalancerService.cs | 2 +- .../Models/LoadBalancing/LoadBalancingStatisticsDTO.cs | 7 +++++++ .../Services/LoadBalancing/LoadBalancerService.cs | 9 ++++++--- 3 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 tilework.core/Models/LoadBalancing/LoadBalancingStatisticsDTO.cs diff --git a/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs b/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs index 425f30c..3ef99fe 100644 --- a/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs +++ b/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs @@ -14,7 +14,7 @@ public interface ILoadBalancerService public Task EnableLoadBalancer(Guid Id); public Task DisableLoadBalancer(Guid Id); - public Task> GetStatistics(Guid Id, DateTimeOffset start, DateTimeOffset end); + public Task> GetStatistics(Guid Id, DateTimeOffset start, DateTimeOffset end); public Task FetchStatistics(Guid Id); public Task> GetRules(ApplicationLoadBalancerDTO balancer); diff --git a/tilework.core/Models/LoadBalancing/LoadBalancingStatisticsDTO.cs b/tilework.core/Models/LoadBalancing/LoadBalancingStatisticsDTO.cs new file mode 100644 index 0000000..c52a695 --- /dev/null +++ b/tilework.core/Models/LoadBalancing/LoadBalancingStatisticsDTO.cs @@ -0,0 +1,7 @@ +namespace Tilework.LoadBalancing.Models; + +public class LoadBalancerStatisticsDTO +{ + public DateTimeOffset Timestamp { get; set; } + public LoadBalancingStatistics Statistics { get; set; } +} \ No newline at end of file diff --git a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs index 7ab27b9..fc6eddf 100644 --- a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs +++ b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs @@ -335,13 +335,16 @@ public async Task Shutdown() await _configurator.Shutdown(); } - public async Task> GetStatistics(Guid Id, DateTimeOffset start, DateTimeOffset end) + public async Task> GetStatistics(Guid Id, DateTimeOffset start, DateTimeOffset end) { return await _dbContext.LoadBalancerStatistics .AsNoTracking() .Where(lbs => lbs.LoadBalancerId == Id && lbs.Timestamp >= start && lbs.Timestamp <= end) - .Select(s => s.Statistics) - .ToListAsync(); + .Select(s => new LoadBalancerStatisticsDTO() + { + Timestamp = s.Timestamp, + Statistics = s.Statistics + }).ToListAsync(); } public async Task FetchStatistics(Guid Id) From 1e48f1129c3aa5d082f401aa35c445ef75000d37 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sat, 30 Aug 2025 12:14:04 +0000 Subject: [PATCH 013/104] Kinda working monitoring charts --- ...830103524_LbStatisticsDuration.Designer.cs | 441 ++++++++++++++++++ .../20250830103524_LbStatisticsDuration.cs | 30 ++ .../TileworkContextModelSnapshot.cs | 9 + .../Monitoring/LoadBalancingStatistics.cs | 7 +- .../LoadBalancing/LoadBalancerStatistics.cs | 1 + .../HAProxy/HAProxymonitor.cs | 46 +- .../Mappers/HAProxyMonitoringProfile.cs | 8 +- .../HAProxy/Models/HAProxyInfo.cs | 13 + .../LoadBalancing/LoadBalancerService.cs | 50 +- .../LoadBalancing/LoadBalancerDetail.razor | 76 ++- tilework.ui/appsettings.Development.json | 3 +- 11 files changed, 667 insertions(+), 17 deletions(-) create mode 100644 tilework.core/Migrations/20250830103524_LbStatisticsDuration.Designer.cs create mode 100644 tilework.core/Migrations/20250830103524_LbStatisticsDuration.cs create mode 100644 tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/HAProxyInfo.cs diff --git a/tilework.core/Migrations/20250830103524_LbStatisticsDuration.Designer.cs b/tilework.core/Migrations/20250830103524_LbStatisticsDuration.Designer.cs new file mode 100644 index 0000000..1fef90a --- /dev/null +++ b/tilework.core/Migrations/20250830103524_LbStatisticsDuration.Designer.cs @@ -0,0 +1,441 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Tilework.Core.Persistence; + +#nullable disable + +namespace tilework.core.Migrations +{ + [DbContext(typeof(TileworkContext))] + [Migration("20250830103524_LbStatisticsDuration")] + partial class LbStatisticsDuration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true); + + modelBuilder.Entity("LoadBalancerCertificates", b => + { + b.Property("BalancerId") + .HasColumnType("TEXT"); + + b.Property("CertificateId") + .HasColumnType("TEXT"); + + b.HasKey("BalancerId", "CertificateId"); + + b.HasIndex("CertificateId"); + + b.ToTable("LoadBalancerCertificates"); + }); + + modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.Certificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorityId") + .HasColumnType("TEXT"); + + b.Property("CertificateDataString") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("INTEGER"); + + b.Property("Fqdn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrivateKeyId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuthorityId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("PrivateKeyId"); + + b.ToTable("Certificates"); + }); + + modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.CertificateAuthority", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CertificateAuthorities"); + }); + + modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.PrivateKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Algorithm") + .HasColumnType("INTEGER"); + + b.Property("KeyDataString") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PrivateKeys"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("LoadBalancers"); + + b.HasDiscriminator().HasValue("BaseLoadBalancer"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.LoadBalancerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("LoadBalancerId") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LoadBalancerId"); + + b.ToTable("LoadBalancerStatistics"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Rule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("LoadBalancerId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("TargetGroupId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LoadBalancerId"); + + b.HasIndex("TargetGroupId"); + + b.HasIndex("Priority", "LoadBalancerId") + .IsUnique(); + + b.ToTable("Rules"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Target", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("TargetGroupId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TargetGroupId", "Host", "Port") + .IsUnique(); + + b.ToTable("Targets"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.TargetGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("TargetGroups"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", b => + { + b.HasBaseType("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.HasDiscriminator().HasValue("ApplicationLoadBalancer"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.NetworkLoadBalancer", b => + { + b.HasBaseType("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.Property("TargetGroupId") + .HasColumnType("TEXT"); + + b.HasIndex("TargetGroupId"); + + b.ToTable("LoadBalancers", t => + { + t.Property("Protocol") + .HasColumnName("NetworkLoadBalancer_Protocol"); + }); + + b.HasDiscriminator().HasValue("NetworkLoadBalancer"); + }); + + modelBuilder.Entity("LoadBalancerCertificates", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer", null) + .WithMany() + .HasForeignKey("BalancerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tilework.Persistence.CertificateManagement.Models.Certificate", null) + .WithMany() + .HasForeignKey("CertificateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.Certificate", b => + { + b.HasOne("Tilework.Persistence.CertificateManagement.Models.CertificateAuthority", "Authority") + .WithMany() + .HasForeignKey("AuthorityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tilework.Persistence.CertificateManagement.Models.PrivateKey", "PrivateKey") + .WithMany() + .HasForeignKey("PrivateKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Authority"); + + b.Navigation("PrivateKey"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.LoadBalancerStatistics", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer", "LoadBalancer") + .WithMany() + .HasForeignKey("LoadBalancerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Tilework.LoadBalancing.Models.LoadBalancingStatistics", "Statistics", b1 => + { + b1.Property("LoadBalancerStatisticsId") + .HasColumnType("TEXT"); + + b1.Property("CurrentQueue") + .HasColumnType("INTEGER"); + + b1.Property("CurrentSessions") + .HasColumnType("INTEGER"); + + b1.Property("TotalSessions") + .HasColumnType("INTEGER"); + + b1.Property("Uptime") + .HasColumnType("TEXT"); + + b1.HasKey("LoadBalancerStatisticsId"); + + b1.ToTable("LoadBalancerStatistics"); + + b1.ToJson("Statistics"); + + b1.WithOwner() + .HasForeignKey("LoadBalancerStatisticsId"); + }); + + b.Navigation("LoadBalancer"); + + b.Navigation("Statistics") + .IsRequired(); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Rule", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", "LoadBalancer") + .WithMany("Rules") + .HasForeignKey("LoadBalancerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") + .WithMany() + .HasForeignKey("TargetGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Tilework.LoadBalancing.Models.Condition", "Conditions", b1 => + { + b1.Property("RuleId") + .HasColumnType("TEXT"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("INTEGER"); + + b1.Property("Type") + .HasColumnType("INTEGER"); + + b1.PrimitiveCollection("Values") + .IsRequired() + .HasColumnType("TEXT"); + + b1.HasKey("RuleId", "__synthesizedOrdinal"); + + b1.ToTable("Rules"); + + b1.ToJson("Conditions"); + + b1.WithOwner() + .HasForeignKey("RuleId"); + }); + + b.Navigation("Conditions"); + + b.Navigation("LoadBalancer"); + + b.Navigation("TargetGroup"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Target", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") + .WithMany("Targets") + .HasForeignKey("TargetGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TargetGroup"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.NetworkLoadBalancer", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") + .WithMany() + .HasForeignKey("TargetGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TargetGroup"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.TargetGroup", b => + { + b.Navigation("Targets"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", b => + { + b.Navigation("Rules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/tilework.core/Migrations/20250830103524_LbStatisticsDuration.cs b/tilework.core/Migrations/20250830103524_LbStatisticsDuration.cs new file mode 100644 index 0000000..ed8ef26 --- /dev/null +++ b/tilework.core/Migrations/20250830103524_LbStatisticsDuration.cs @@ -0,0 +1,30 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace tilework.core.Migrations +{ + /// + public partial class LbStatisticsDuration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Duration", + table: "LoadBalancerStatistics", + type: "TEXT", + nullable: false, + defaultValue: new TimeSpan(0, 0, 0, 0, 0)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Duration", + table: "LoadBalancerStatistics"); + } + } +} diff --git a/tilework.core/Migrations/TileworkContextModelSnapshot.cs b/tilework.core/Migrations/TileworkContextModelSnapshot.cs index 98e964d..bf1954a 100644 --- a/tilework.core/Migrations/TileworkContextModelSnapshot.cs +++ b/tilework.core/Migrations/TileworkContextModelSnapshot.cs @@ -159,6 +159,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); + b.Property("Duration") + .HasColumnType("TEXT"); + b.Property("LoadBalancerId") .HasColumnType("TEXT"); @@ -329,6 +332,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b1.Property("CurrentSessions") .HasColumnType("INTEGER"); + b1.Property("TotalSessions") + .HasColumnType("INTEGER"); + + b1.Property("Uptime") + .HasColumnType("TEXT"); + b1.HasKey("LoadBalancerStatisticsId"); b1.ToTable("LoadBalancerStatistics"); diff --git a/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs b/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs index 7cf016d..f6512ae 100644 --- a/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs +++ b/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs @@ -2,8 +2,9 @@ namespace Tilework.LoadBalancing.Models; public class LoadBalancingStatistics { - public int? CurrentSessions { get; init; } - public int? CurrentQueue { get; init; } + public TimeSpan Uptime { get; init; } + // public int? CurrentSessions { get; init; } + // public int? CurrentQueue { get; init; } @@ -16,7 +17,7 @@ public class LoadBalancingStatistics // public int? MaxSessions { get; init; } // public int? SessionLimit { get; init; } - // public long? TotalSessions { get; init; } + public long? TotalSessions { get; init; } // // Traffic // public long? BytesIn { get; init; } diff --git a/tilework.core/Persistence/Entities/LoadBalancing/LoadBalancerStatistics.cs b/tilework.core/Persistence/Entities/LoadBalancing/LoadBalancerStatistics.cs index 425090e..3e4ca30 100644 --- a/tilework.core/Persistence/Entities/LoadBalancing/LoadBalancerStatistics.cs +++ b/tilework.core/Persistence/Entities/LoadBalancing/LoadBalancerStatistics.cs @@ -13,6 +13,7 @@ public class LoadBalancerStatistics public virtual BaseLoadBalancer LoadBalancer { get; set; } public DateTimeOffset Timestamp { get; set; } + public TimeSpan Duration { get; set; } public LoadBalancingStatistics Statistics { get; set; } } \ No newline at end of file diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxymonitor.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxymonitor.cs index 18e4286..d7697b9 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxymonitor.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxymonitor.cs @@ -2,6 +2,7 @@ using System.Net.Sockets; using System.Text; using System.Globalization; +using System.Text.Json; using AutoMapper; using CsvHelper; @@ -32,8 +33,11 @@ public HAProxyMonitor(IContainerManager containerManager, _mapper = mapper; } - public async Task> SendReceiveCommand(NetworkStream stream, string command) + private async Task> SendReceiveCommandCsv(string hostname, int port, string command) { + using var client = new TcpClient(hostname, port); + using var stream = client.GetStream(); + var cmd = Encoding.ASCII.GetBytes($"{command}\n"); stream.Write(cmd, 0, cmd.Length); @@ -73,6 +77,35 @@ public async Task> SendReceiveCommand(NetworkStream stream, string co } + private async Task SendReceiveCommandKv(string hostname, int port, string command) + { + using var client = new TcpClient(hostname, port); + using var stream = client.GetStream(); + + var cmd = Encoding.ASCII.GetBytes($"{command}\n"); + stream.Write(cmd, 0, cmd.Length); + + var buffer = new byte[65535]; + int bytesRead = stream.Read(buffer, 0, buffer.Length); + var response = Encoding.ASCII.GetString(buffer, 0, bytesRead); + + var raw = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var line in response.Split('\n')) + { + if (string.IsNullOrWhiteSpace(line)) continue; + var idx = line.IndexOf(':'); + if (idx <= 0) continue; + var key = line.Substring(0, idx).Trim(); + var value = line[(idx + 1)..].Trim(); + if (key.Length == 0) continue; + raw[key] = value; + } + + + var jsonString = JsonSerializer.Serialize(raw); + return JsonSerializer.Deserialize(jsonString); + } + public async Task GetRealtimeStatistics(BaseLoadBalancer balancer) { if (await _configurator.CheckLoadBalancerStatus(balancer) == false) @@ -80,13 +113,12 @@ public async Task GetRealtimeStatistics(BaseLoadBalance var hostname = await _configurator.GetLoadBalancerHostname(balancer); - using var client = new TcpClient(hostname, 4380); - using var stream = client.GetStream(); + var info = await SendReceiveCommandKv(hostname, 4380, "show info"); - var response = await SendReceiveCommand(stream, "show stat"); + var stats = await SendReceiveCommandCsv(hostname, 4380, "show stat"); - var balancerStats = response.First(r => r.svname == "FRONTEND" && r.pxname == balancer.Id.ToString()); + var balancerStats = stats.First(r => r.svname == "FRONTEND" && r.pxname == balancer.Id.ToString()); - return _mapper.Map(balancerStats); + return _mapper.Map((info, balancerStats)); } -} \ No newline at end of file +} diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyMonitoringProfile.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyMonitoringProfile.cs index 5d0196a..709c621 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyMonitoringProfile.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyMonitoringProfile.cs @@ -7,9 +7,11 @@ public class HAProxyMonitoringProfile : Profile { public HAProxyMonitoringProfile() { - CreateMap() - .ForMember(dest => dest.CurrentSessions, opt => opt.MapFrom(src => src.scur)) - .ForPath(dest => dest.CurrentQueue, opt => opt.MapFrom(src => src.qcur)); + CreateMap<(HAProxyInfo, HAProxyStatisticsRow), LoadBalancingStatistics>() + .ForMember(dest => dest.Uptime, opt => opt.MapFrom(src => TimeSpan.FromSeconds(Int32.Parse(src.Item1.Uptime_sec)))) + .ForMember(dest => dest.TotalSessions, opt => opt.MapFrom(src => src.Item2.stot)); + // .ForMember(dest => dest.CurrentSessions, opt => opt.MapFrom(src => src.scur)) + // .ForPath(dest => dest.CurrentQueue, opt => opt.MapFrom(src => src.qcur)); } } diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/HAProxyInfo.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/HAProxyInfo.cs new file mode 100644 index 0000000..f196164 --- /dev/null +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/HAProxyInfo.cs @@ -0,0 +1,13 @@ +namespace Tilework.LoadBalancing.Haproxy; + +public class HAProxyInfo +{ + public string? Name { get; set; } + public string? Version { get; set; } + public string? Release_date { get; set; } + public string? Nbproc { get; set; } + public string? Process_num { get; set; } + public string? Pid { get; set; } + public string? Uptime { get; set; } + public string? Uptime_sec { get; set; } +} diff --git a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs index fc6eddf..5d73987 100644 --- a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs +++ b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs @@ -355,10 +355,58 @@ public async Task FetchStatistics(Guid Id) var statistics = await _monitor.GetRealtimeStatistics(entity); + var last_statistics = await _dbContext.LoadBalancerStatistics.Where(s => s.LoadBalancerId == Id) + .OrderByDescending(s => s.Timestamp) + .FirstOrDefaultAsync(); + + TimeSpan duration = TimeSpan.Zero; + string? msg = null; + + var current_timestamp = DateTimeOffset.UtcNow; + + if (last_statistics != null) + { + // If duration between timestamps is bigger than monitoring interval, assume that + // monitoring stopped for some time. Restart monitoring + if (current_timestamp - last_statistics.Timestamp > TimeSpan.FromSeconds(60 + 10)) + { + msg = "monitoring interrupted"; + duration = TimeSpan.Zero; + } + + duration = statistics.Uptime - last_statistics.Statistics.Uptime; + + // If current uptime is smaller than previous, there was a restart + if (duration < TimeSpan.Zero) + { + msg = "smaller uptime"; + duration = TimeSpan.Zero; + } + + // If duration between uptimes and duration between timestamps have a variation of more than +-10s + // there was a restart + if (Math.Abs((last_statistics.Timestamp + duration - current_timestamp).TotalSeconds) > 10) + { + msg = "duration variation"; + duration = TimeSpan.Zero; + } + } + else + { + msg = "no prev statistics"; + } + + if (duration == TimeSpan.Zero && msg != null) + { + _logger.LogDebug($"Detected load balancer {Id} restart during collection of statistics ({msg}). Restarting statistics collection"); + } + + var statisticsPoint = new LoadBalancerStatistics() { + Duration = duration, LoadBalancer = entity, - Timestamp = DateTimeOffset.UtcNow, + Timestamp = current_timestamp, Statistics = statistics }; diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index fa93286..feba897 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -16,6 +16,7 @@ @page "/lb/loadbalancers/{Id:guid}" + Load balancer details @@ -109,7 +110,29 @@ } - + @if (_statsLoading) + { + + } + else if (_series?.Any() == true) + { + + Current Sessions (last hour) + + + + + + + + + } + else + { + No statistics available. + } @@ -134,6 +157,17 @@ private List _actions = new List(); + + private ChartOptions _options = new ChartOptions() + { + ShowLegend=false + }; + private AxisChartOptions _axisChartOptions = new AxisChartOptions(); + + private string[] _chartLabels = Array.Empty(); + private List _series = new(); + private bool _statsLoading = false; + protected override async Task OnInitializedAsync() { _item = await _loadBalancerService.GetLoadBalancer(Id); @@ -380,11 +414,49 @@ _certificates = await _loadBalancerService.GetCertificates(_item); } + private int GetMonitoringTabIndex() + { + // Count how many tabs are before Monitoring + var before = 0; + if (_item is ApplicationLoadBalancerDTO) + before += 1; // Rules + if (_showCertificates) + before += 1; // Certificates + return before; // Monitoring index + } + private async Task OnTabChanged(int index) { - if(index == 1 && _item != null && _item.Enabled == true) + if(_item != null && _item.Enabled == true && index == GetMonitoringTabIndex()) { + _statsLoading = true; + StateHasChanged(); + var stats = await _loadBalancerService.GetStatistics(_item.Id, DateTimeOffset.UtcNow - TimeSpan.FromHours(1), DateTimeOffset.UtcNow); + + // Order by timestamp to ensure correct sequence + var statistics = stats.OrderBy(s => s.Timestamp).ToList(); + + _chartLabels = statistics + .Select(s => + { + var t = s.Timestamp.ToLocalTime(); + return t.Minute % 5 == 0 ? t.ToString("HH:mm") : ""; + }) + .ToArray(); + + var dataset = statistics.Select(s => (double)(s.Statistics?.TotalSessions ?? 0)).ToArray(); + + _series = new List() + { + new ChartSeries() { + Name = "Sessions", + Data = dataset + } + }; + + _statsLoading = false; + StateHasChanged(); } } } diff --git a/tilework.ui/appsettings.Development.json b/tilework.ui/appsettings.Development.json index 1b2d3ba..1fe68de 100644 --- a/tilework.ui/appsettings.Development.json +++ b/tilework.ui/appsettings.Development.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Tilework" : "Debug" } } } \ No newline at end of file From 3426fce3c73f394f69f10528b085091f039f0851 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sat, 30 Aug 2025 12:33:00 +0000 Subject: [PATCH 014/104] Bug investigation --- tilework.core/Services/Core/DockerServiceManager.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tilework.core/Services/Core/DockerServiceManager.cs b/tilework.core/Services/Core/DockerServiceManager.cs index e2f1a55..03d2db0 100644 --- a/tilework.core/Services/Core/DockerServiceManager.cs +++ b/tilework.core/Services/Core/DockerServiceManager.cs @@ -104,6 +104,7 @@ public async Task DeleteNetwork(string id) _logger.LogWarning("Container is attached on multiple networks. Getting address on first"); var network = info.NetworkSettings.Networks.First(); + _logger.LogInformation($"Read address -> {network.Value.IPAddress}"); return IPAddress.Parse(network.Value.IPAddress); } From 2c49ddd9472436944821c3f4f5d3a2882bd02a45 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sat, 30 Aug 2025 15:42:53 +0000 Subject: [PATCH 015/104] cleanup --- .../Providers/LoadBalancingProviders/HAProxy/HAProxymonitor.cs | 2 ++ tilework.core/Services/Core/DockerServiceManager.cs | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxymonitor.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxymonitor.cs index d7697b9..5f2adc9 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxymonitor.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxymonitor.cs @@ -35,6 +35,7 @@ public HAProxyMonitor(IContainerManager containerManager, private async Task> SendReceiveCommandCsv(string hostname, int port, string command) { + _logger.LogDebug($"Connecting to haproxy stats: {hostname}:{port}"); using var client = new TcpClient(hostname, port); using var stream = client.GetStream(); @@ -79,6 +80,7 @@ private async Task> SendReceiveCommandCsv(string hostname, int port, private async Task SendReceiveCommandKv(string hostname, int port, string command) { + _logger.LogDebug($"Connecting to haproxy stats: {hostname}:{port}"); using var client = new TcpClient(hostname, port); using var stream = client.GetStream(); diff --git a/tilework.core/Services/Core/DockerServiceManager.cs b/tilework.core/Services/Core/DockerServiceManager.cs index 03d2db0..e2f1a55 100644 --- a/tilework.core/Services/Core/DockerServiceManager.cs +++ b/tilework.core/Services/Core/DockerServiceManager.cs @@ -104,7 +104,6 @@ public async Task DeleteNetwork(string id) _logger.LogWarning("Container is attached on multiple networks. Getting address on first"); var network = info.NetworkSettings.Networks.First(); - _logger.LogInformation($"Read address -> {network.Value.IPAddress}"); return IPAddress.Parse(network.Value.IPAddress); } From 410dde266837071ecb4545757d6126f981123bb8 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 31 Aug 2025 07:10:58 +0000 Subject: [PATCH 016/104] Cleanup of ui --- .../LoadBalancing/LoadBalancerDetail.razor | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index feba897..cde34fd 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -116,18 +116,36 @@ } else if (_series?.Any() == true) { - - Current Sessions (last hour) - - - - - - - - + + + + Monitoring + + + + + + + + + + + + + + + + + + + + + + + + } else { From 1ff3444a8cfdcf20f9fe226871166998f539d369 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 31 Aug 2025 08:53:39 +0000 Subject: [PATCH 017/104] Created new statistics service --- .../Attributes/CumulativeAttribute.cs | 4 + .../LoadBalancing/ILoadBalancerService.cs | 5 +- .../ILoadBalancerStatisticsService.cs | 10 ++ .../LoadBalancerMonitoringJob.cs | 5 +- .../Monitoring/LoadBalancingStatistics.cs | 100 +++++++++++---- tilework.core/ServiceCollectionExtensions.cs | 1 + .../LoadBalancing/LoadBalancerService.cs | 90 +------------ .../LoadBalancerStatisticsService.cs | 119 ++++++++++++++++++ .../LoadBalancing/LoadBalancerDetail.razor | 3 +- 9 files changed, 221 insertions(+), 116 deletions(-) create mode 100644 tilework.core/Attributes/CumulativeAttribute.cs create mode 100644 tilework.core/Interfaces/LoadBalancing/ILoadBalancerStatisticsService.cs create mode 100644 tilework.core/Services/LoadBalancing/LoadBalancerStatisticsService.cs diff --git a/tilework.core/Attributes/CumulativeAttribute.cs b/tilework.core/Attributes/CumulativeAttribute.cs new file mode 100644 index 0000000..0a402cc --- /dev/null +++ b/tilework.core/Attributes/CumulativeAttribute.cs @@ -0,0 +1,4 @@ +namespace Tilework.Core.Attributes; + +[AttributeUsage(AttributeTargets.Property)] +public sealed class CumulativeAttribute : Attribute { } \ No newline at end of file diff --git a/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs b/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs index 3ef99fe..ed9a9b1 100644 --- a/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs +++ b/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs @@ -14,8 +14,7 @@ public interface ILoadBalancerService public Task EnableLoadBalancer(Guid Id); public Task DisableLoadBalancer(Guid Id); - public Task> GetStatistics(Guid Id, DateTimeOffset start, DateTimeOffset end); - public Task FetchStatistics(Guid Id); + public Task> GetRules(ApplicationLoadBalancerDTO balancer); public Task AddRule(ApplicationLoadBalancerDTO balancer, RuleDTO rule); @@ -45,4 +44,4 @@ public interface ILoadBalancerService public Task ApplyConfiguration(); public Task Shutdown(); -} \ No newline at end of file +} diff --git a/tilework.core/Interfaces/LoadBalancing/ILoadBalancerStatisticsService.cs b/tilework.core/Interfaces/LoadBalancing/ILoadBalancerStatisticsService.cs new file mode 100644 index 0000000..6840cce --- /dev/null +++ b/tilework.core/Interfaces/LoadBalancing/ILoadBalancerStatisticsService.cs @@ -0,0 +1,10 @@ +using Tilework.LoadBalancing.Models; + +namespace Tilework.LoadBalancing.Interfaces; + +public interface ILoadBalancerStatisticsService +{ + public Task> GetStatistics(Guid Id, DateTimeOffset start, DateTimeOffset end); + public Task FetchStatistics(Guid Id); +} + diff --git a/tilework.core/Jobs/LoadBalancing/LoadBalancerMonitoringJob.cs b/tilework.core/Jobs/LoadBalancing/LoadBalancerMonitoringJob.cs index 603a6ed..74d459e 100644 --- a/tilework.core/Jobs/LoadBalancing/LoadBalancerMonitoringJob.cs +++ b/tilework.core/Jobs/LoadBalancing/LoadBalancerMonitoringJob.cs @@ -7,11 +7,14 @@ namespace Tilework.Core.Jobs.LoadBalancing; public class LoadBalancerMonitoringJob : IInvocable { private readonly ILoadBalancerService _loadBalancerService; + private readonly ILoadBalancerStatisticsService _statisticsService; private readonly ILogger _logger; public LoadBalancerMonitoringJob(ILoadBalancerService loadBalancerService, + ILoadBalancerStatisticsService statisticsService, ILogger logger) { _loadBalancerService = loadBalancerService; + _statisticsService = statisticsService; _logger = logger; } @@ -24,7 +27,7 @@ public async Task Invoke() { try { - await _loadBalancerService.FetchStatistics(balancer.Id); + await _statisticsService.FetchStatistics(balancer.Id); } catch(Exception ex) { diff --git a/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs b/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs index f6512ae..b7c05fe 100644 --- a/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs +++ b/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs @@ -1,8 +1,11 @@ +using System.Reflection; +using Tilework.Core.Attributes; + namespace Tilework.LoadBalancing.Models; public class LoadBalancingStatistics { - public TimeSpan Uptime { get; init; } + public TimeSpan Uptime { get; set; } // public int? CurrentSessions { get; init; } // public int? CurrentQueue { get; init; } @@ -17,34 +20,46 @@ public class LoadBalancingStatistics // public int? MaxSessions { get; init; } // public int? SessionLimit { get; init; } - public long? TotalSessions { get; init; } - - // // Traffic - // public long? BytesIn { get; init; } - // public long? BytesOut { get; init; } - - // // Errors / retries - // public long? DeniedRequests { get; init; } - // public long? DeniedResponses { get; init; } - // public long? RequestErrors { get; init; } - // public long? ConnectionErrors { get; init; } - // public long? ResponseErrors { get; init; } + [Cumulative] + public long? TotalSessions { get; set; } + + [Cumulative] + public long? BytesIn { get; set; } + [Cumulative] + public long? BytesOut { get; set; } + + + [Cumulative] + public long? DeniedRequests { get; init; } + [Cumulative] + public long? DeniedResponses { get; init; } + [Cumulative] + public long? RequestErrors { get; init; } + [Cumulative] + public long? ConnectionErrors { get; init; } + [Cumulative] + public long? ResponseErrors { get; init; } // public long? Retries { get; init; } // public long? Redispatches { get; init; } // // HTTP responses - // public long? Responses1xx { get; init; } - // public long? Responses2xx { get; init; } - // public long? Responses3xx { get; init; } - // public long? Responses4xx { get; init; } - // public long? Responses5xx { get; init; } + [Cumulative] + public long? Responses1xx { get; set; } + [Cumulative] + public long? Responses2xx { get; set; } + [Cumulative] + public long? Responses3xx { get; set; } + [Cumulative] + public long? Responses4xx { get; set; } + [Cumulative] + public long? Responses5xx { get; set; } // public long? ResponsesOther { get; init; } - // // Timings - // public int? AvgQueueTimeMs { get; init; } - // public int? AvgConnectTimeMs { get; init; } - // public int? AvgResponseTimeMs { get; init; } - // public int? AvgTotalTimeMs { get; init; } + + public int? AvgQueueTimeMs { get; set; } + public int? AvgConnectTimeMs { get; set; } + public int? AvgResponseTimeMs { get; set; } + public int? AvgTotalTimeMs { get; set; } // // Status / health // public string? Status { get; init; } @@ -64,4 +79,43 @@ public class LoadBalancingStatistics // // Cache // public long? CacheHits { get; init; } // public long? CacheMisses { get; init; } + + public static LoadBalancingStatistics operator -(LoadBalancingStatistics a, LoadBalancingStatistics b) + { + var result = new LoadBalancingStatistics(); + + foreach (var prop in typeof(LoadBalancingStatistics).GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!prop.CanRead || !prop.CanWrite) continue; + + var aVal = prop.GetValue(a); + var bVal = prop.GetValue(b); + + if (prop.IsDefined(typeof(CumulativeAttribute), inherit: true)) + { + object? diff = null; + + if (prop.PropertyType == typeof(TimeSpan)) + diff = (TimeSpan)aVal - (TimeSpan)bVal; + else if (prop.PropertyType == typeof(long?)) + diff = (aVal is long la && bVal is long lb) ? la - lb : null; + else if (prop.PropertyType == typeof(int?)) + diff = (aVal is int ia && bVal is int ib) ? ia - ib : null; + else if (prop.PropertyType == typeof(double?)) + diff = (aVal is double da && bVal is double db) ? da - db : null; + else if (prop.PropertyType == typeof(decimal?)) + diff = (aVal is decimal dca && bVal is decimal dcb) ? dca - dcb : null; + else + throw new InvalidOperationException($"Cannot process data type {prop.PropertyType}"); + + prop.SetValue(result, diff); + } + else + { + prop.SetValue(result, aVal); + } + } + + return result; + } } \ No newline at end of file diff --git a/tilework.core/ServiceCollectionExtensions.cs b/tilework.core/ServiceCollectionExtensions.cs index aa60010..a5efa83 100644 --- a/tilework.core/ServiceCollectionExtensions.cs +++ b/tilework.core/ServiceCollectionExtensions.cs @@ -48,6 +48,7 @@ public static IServiceCollection AddLoadBalancing(this IServiceCollection servic services.AddAutoMapper(typeof(HAProxyMonitoringProfile)); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs index 5d73987..d26719f 100644 --- a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs +++ b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs @@ -26,7 +26,6 @@ public class LoadBalancerService : ILoadBalancerService private readonly TileworkContext _dbContext; private readonly LoadBalancerConfiguration _settings; private readonly ILoadBalancingConfigurator _configurator; - private readonly ILoadBalancingMonitor _monitor; private readonly ILogger _logger; private readonly IMapper _mapper; @@ -41,7 +40,6 @@ public LoadBalancerService(IServiceProvider serviceProvider, _logger = logger; _settings = settings.Value; _configurator = LoadConfigurator(serviceProvider, _settings); - _monitor = LoadMonitor(serviceProvider, _settings); _mapper = mapper; } @@ -55,14 +53,7 @@ private ILoadBalancingConfigurator LoadConfigurator(IServiceProvider serviceProv }; } - private ILoadBalancingMonitor LoadMonitor(IServiceProvider serviceProvider, LoadBalancerConfiguration settings) - { - return settings.Backend switch - { - "haproxy" => serviceProvider.GetRequiredService(), - _ => throw new ArgumentException($"Invalid monitor in load balancing tile: {_settings.Backend}") - }; - } + private BaseLoadBalancerDTO MapBalancerToDto(BaseLoadBalancer entity) { @@ -335,82 +326,5 @@ public async Task Shutdown() await _configurator.Shutdown(); } - public async Task> GetStatistics(Guid Id, DateTimeOffset start, DateTimeOffset end) - { - return await _dbContext.LoadBalancerStatistics - .AsNoTracking() - .Where(lbs => lbs.LoadBalancerId == Id && lbs.Timestamp >= start && lbs.Timestamp <= end) - .Select(s => new LoadBalancerStatisticsDTO() - { - Timestamp = s.Timestamp, - Statistics = s.Statistics - }).ToListAsync(); - } - - public async Task FetchStatistics(Guid Id) - { - var entity = await _dbContext.LoadBalancers.FindAsync(Id); - if (entity == null) - throw new ArgumentNullException("Non-existent load balancer"); - - var statistics = await _monitor.GetRealtimeStatistics(entity); - - var last_statistics = await _dbContext.LoadBalancerStatistics.Where(s => s.LoadBalancerId == Id) - .OrderByDescending(s => s.Timestamp) - .FirstOrDefaultAsync(); - - TimeSpan duration = TimeSpan.Zero; - string? msg = null; - - var current_timestamp = DateTimeOffset.UtcNow; - - if (last_statistics != null) - { - // If duration between timestamps is bigger than monitoring interval, assume that - // monitoring stopped for some time. Restart monitoring - if (current_timestamp - last_statistics.Timestamp > TimeSpan.FromSeconds(60 + 10)) - { - msg = "monitoring interrupted"; - duration = TimeSpan.Zero; - } - - duration = statistics.Uptime - last_statistics.Statistics.Uptime; - - // If current uptime is smaller than previous, there was a restart - if (duration < TimeSpan.Zero) - { - msg = "smaller uptime"; - duration = TimeSpan.Zero; - } - - // If duration between uptimes and duration between timestamps have a variation of more than +-10s - // there was a restart - if (Math.Abs((last_statistics.Timestamp + duration - current_timestamp).TotalSeconds) > 10) - { - msg = "duration variation"; - duration = TimeSpan.Zero; - } - } - else - { - msg = "no prev statistics"; - } - - if (duration == TimeSpan.Zero && msg != null) - { - _logger.LogDebug($"Detected load balancer {Id} restart during collection of statistics ({msg}). Restarting statistics collection"); - } - - - var statisticsPoint = new LoadBalancerStatistics() - { - Duration = duration, - LoadBalancer = entity, - Timestamp = current_timestamp, - Statistics = statistics - }; - - await _dbContext.LoadBalancerStatistics.AddAsync(statisticsPoint); - await _dbContext.SaveChangesAsync(); - } + } diff --git a/tilework.core/Services/LoadBalancing/LoadBalancerStatisticsService.cs b/tilework.core/Services/LoadBalancing/LoadBalancerStatisticsService.cs new file mode 100644 index 0000000..c09cbb5 --- /dev/null +++ b/tilework.core/Services/LoadBalancing/LoadBalancerStatisticsService.cs @@ -0,0 +1,119 @@ +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using Tilework.LoadBalancing.Interfaces; +using Tilework.LoadBalancing.Models; +using Tilework.Core.Persistence; +using Tilework.Persistence.LoadBalancing.Models; +using Tilework.LoadBalancing.Haproxy; + +namespace Tilework.LoadBalancing.Services; + +public class LoadBalancerStatisticsService : ILoadBalancerStatisticsService +{ + private readonly TileworkContext _dbContext; + private readonly LoadBalancerConfiguration _settings; + private readonly ILoadBalancingMonitor _monitor; + private readonly ILogger _logger; + + public LoadBalancerStatisticsService(IServiceProvider serviceProvider, + TileworkContext dbContext, + IOptions settings, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + _settings = settings.Value; + _monitor = LoadMonitor(serviceProvider, _settings); + } + + private ILoadBalancingMonitor LoadMonitor(IServiceProvider serviceProvider, LoadBalancerConfiguration settings) + { + return settings.Backend switch + { + "haproxy" => serviceProvider.GetRequiredService(), + _ => throw new ArgumentException($"Invalid monitor in load balancing tile: {settings.Backend}") + }; + } + + public async Task> GetStatistics(Guid Id, DateTimeOffset start, DateTimeOffset end) + { + return await _dbContext.LoadBalancerStatistics + .AsNoTracking() + .Where(lbs => lbs.LoadBalancerId == Id && lbs.Timestamp >= start && lbs.Timestamp <= end) + .Select(s => new LoadBalancerStatisticsDTO() + { + Timestamp = s.Timestamp, + Statistics = s.Statistics + }).ToListAsync(); + } + + public async Task FetchStatistics(Guid Id) + { + var entity = await _dbContext.LoadBalancers.FindAsync(Id); + if (entity == null) + throw new ArgumentNullException("Non-existent load balancer"); + + var statistics = await _monitor.GetRealtimeStatistics(entity); + + var last_statistics = await _dbContext.LoadBalancerStatistics.Where(s => s.LoadBalancerId == Id) + .OrderByDescending(s => s.Timestamp) + .FirstOrDefaultAsync(); + + TimeSpan duration = TimeSpan.Zero; + string? msg = null; + + var current_timestamp = DateTimeOffset.UtcNow; + + if (last_statistics != null) + { + // If duration between timestamps is bigger than monitoring interval, assume that + // monitoring stopped for some time. Restart monitoring + if (current_timestamp - last_statistics.Timestamp > TimeSpan.FromSeconds(60 + 10)) + { + msg = "monitoring interrupted"; + duration = TimeSpan.Zero; + } + + duration = statistics.Uptime - last_statistics.Statistics.Uptime; + + // If current uptime is smaller than previous, there was a restart + if (duration < TimeSpan.Zero) + { + msg = "smaller uptime"; + duration = TimeSpan.Zero; + } + + // If duration between uptimes and duration between timestamps have a variation of more than +-10s + // there was a restart + if (Math.Abs((last_statistics.Timestamp + duration - current_timestamp).TotalSeconds) > 10) + { + msg = "duration variation"; + duration = TimeSpan.Zero; + } + } + else + { + msg = "no prev statistics"; + } + + if (duration == TimeSpan.Zero && msg != null) + { + _logger.LogDebug($"Detected load balancer {Id} restart during collection of statistics ({msg}). Restarting statistics collection"); + } + + var statisticsPoint = new LoadBalancerStatistics() + { + Duration = duration, + LoadBalancer = entity, + Timestamp = current_timestamp, + Statistics = (duration != TimeSpan.Zero && last_statistics != null) ? statistics - last_statistics.Statistics : statistics + }; + + await _dbContext.LoadBalancerStatistics.AddAsync(statisticsPoint); + await _dbContext.SaveChangesAsync(); + } +} + diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index cde34fd..a4b25bb 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -9,6 +9,7 @@ @namespace Tilework.Ui.Components.Pages @inject ILoadBalancerService _loadBalancerService +@inject ILoadBalancerStatisticsService _statisticsService @inject IDialogService _dialogService @inject NavigationManager _navigationManager @inject ISnackbar _snackbar @@ -450,7 +451,7 @@ _statsLoading = true; StateHasChanged(); - var stats = await _loadBalancerService.GetStatistics(_item.Id, DateTimeOffset.UtcNow - TimeSpan.FromHours(1), DateTimeOffset.UtcNow); + var stats = await _statisticsService.GetStatistics(_item.Id, DateTimeOffset.UtcNow - TimeSpan.FromHours(1), DateTimeOffset.UtcNow); // Order by timestamp to ensure correct sequence var statistics = stats.OrderBy(s => s.Timestamp).ToList(); From 47f1b229f8562b8f805e54d2e886efb5a68e92ec Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 31 Aug 2025 17:47:04 +0000 Subject: [PATCH 018/104] cleanup --- tilework.core/Enums/{ => Core}/ContainerState.cs | 0 tilework.core/Enums/{ => Core}/Extensions.cs | 0 tilework.core/Enums/{ => Core}/PortType.cs | 0 tilework.core/Enums/{ => Core}/ServiceManagerAction.cs | 0 tilework.core/Enums/{ => Core}/UnixSignal.cs | 0 tilework.core/Models/{ => Core}/Container.cs | 0 tilework.core/Models/{ => Core}/ContainerNetwork.cs | 0 tilework.core/Models/{ => Core}/ContainerPort.cs | 0 tilework.core/Models/{ => Core}/Host.cs | 0 9 files changed, 0 insertions(+), 0 deletions(-) rename tilework.core/Enums/{ => Core}/ContainerState.cs (100%) rename tilework.core/Enums/{ => Core}/Extensions.cs (100%) rename tilework.core/Enums/{ => Core}/PortType.cs (100%) rename tilework.core/Enums/{ => Core}/ServiceManagerAction.cs (100%) rename tilework.core/Enums/{ => Core}/UnixSignal.cs (100%) rename tilework.core/Models/{ => Core}/Container.cs (100%) rename tilework.core/Models/{ => Core}/ContainerNetwork.cs (100%) rename tilework.core/Models/{ => Core}/ContainerPort.cs (100%) rename tilework.core/Models/{ => Core}/Host.cs (100%) diff --git a/tilework.core/Enums/ContainerState.cs b/tilework.core/Enums/Core/ContainerState.cs similarity index 100% rename from tilework.core/Enums/ContainerState.cs rename to tilework.core/Enums/Core/ContainerState.cs diff --git a/tilework.core/Enums/Extensions.cs b/tilework.core/Enums/Core/Extensions.cs similarity index 100% rename from tilework.core/Enums/Extensions.cs rename to tilework.core/Enums/Core/Extensions.cs diff --git a/tilework.core/Enums/PortType.cs b/tilework.core/Enums/Core/PortType.cs similarity index 100% rename from tilework.core/Enums/PortType.cs rename to tilework.core/Enums/Core/PortType.cs diff --git a/tilework.core/Enums/ServiceManagerAction.cs b/tilework.core/Enums/Core/ServiceManagerAction.cs similarity index 100% rename from tilework.core/Enums/ServiceManagerAction.cs rename to tilework.core/Enums/Core/ServiceManagerAction.cs diff --git a/tilework.core/Enums/UnixSignal.cs b/tilework.core/Enums/Core/UnixSignal.cs similarity index 100% rename from tilework.core/Enums/UnixSignal.cs rename to tilework.core/Enums/Core/UnixSignal.cs diff --git a/tilework.core/Models/Container.cs b/tilework.core/Models/Core/Container.cs similarity index 100% rename from tilework.core/Models/Container.cs rename to tilework.core/Models/Core/Container.cs diff --git a/tilework.core/Models/ContainerNetwork.cs b/tilework.core/Models/Core/ContainerNetwork.cs similarity index 100% rename from tilework.core/Models/ContainerNetwork.cs rename to tilework.core/Models/Core/ContainerNetwork.cs diff --git a/tilework.core/Models/ContainerPort.cs b/tilework.core/Models/Core/ContainerPort.cs similarity index 100% rename from tilework.core/Models/ContainerPort.cs rename to tilework.core/Models/Core/ContainerPort.cs diff --git a/tilework.core/Models/Host.cs b/tilework.core/Models/Core/Host.cs similarity index 100% rename from tilework.core/Models/Host.cs rename to tilework.core/Models/Core/Host.cs From 6ba4fb9b83acef2b7924afb8d9f79ae4a2e5c2b1 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 7 Sep 2025 11:57:49 +0000 Subject: [PATCH 019/104] Initial setup for monitoring with collectd --- .../Enums/Monitoring/MonitoringType.cs | 6 + .../Initializers/MonitoringInitializer.cs | 41 +++++++ .../Monitoring/IDataCollectorConfigurator.cs | 11 ++ .../Monitoring/DataCollectorConfiguration.cs | 7 ++ .../Models/Monitoring/MonitoringSource.cs | 14 +++ .../HAProxy/HAProxyConfigurator.cs | 21 ++++ .../Collectd/CollectdDataCollector.cs | 113 ++++++++++++++++++ tilework.core/Resources/collectd.conf | 10 ++ tilework.core/ServiceCollectionExtensions.cs | 18 +++ .../Monitoring/DataCollectorService.cs | 69 +++++++++++ tilework.core/tilework.core.csproj | 2 +- tilework.ui/Program.cs | 1 + tilework.ui/appsettings.json | 4 + 13 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 tilework.core/Enums/Monitoring/MonitoringType.cs create mode 100644 tilework.core/Initializers/MonitoringInitializer.cs create mode 100644 tilework.core/Interfaces/Monitoring/IDataCollectorConfigurator.cs create mode 100644 tilework.core/Models/Monitoring/DataCollectorConfiguration.cs create mode 100644 tilework.core/Models/Monitoring/MonitoringSource.cs create mode 100644 tilework.core/Providers/MonitoringProviders/Collectd/CollectdDataCollector.cs create mode 100644 tilework.core/Resources/collectd.conf create mode 100644 tilework.core/Services/Monitoring/DataCollectorService.cs diff --git a/tilework.core/Enums/Monitoring/MonitoringType.cs b/tilework.core/Enums/Monitoring/MonitoringType.cs new file mode 100644 index 0000000..2b8fa4c --- /dev/null +++ b/tilework.core/Enums/Monitoring/MonitoringType.cs @@ -0,0 +1,6 @@ +namespace Tilework.Monitoring.Enums; + +public enum MonitoringSourceType +{ + HAPROXY +} \ No newline at end of file diff --git a/tilework.core/Initializers/MonitoringInitializer.cs b/tilework.core/Initializers/MonitoringInitializer.cs new file mode 100644 index 0000000..0e01594 --- /dev/null +++ b/tilework.core/Initializers/MonitoringInitializer.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; + +using Coravel; + +using Tilework.LoadBalancing.Interfaces; +using Tilework.Core.Jobs.LoadBalancing; + +namespace Tilework.LoadBalancing.Services; + +public sealed class MonitoringInitializer : IHostedService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public MonitoringInitializer(ILogger logger, + IServiceProvider serviceProvider) + { + _logger = logger; + _serviceProvider = serviceProvider; + } + + public async Task StartAsync(CancellationToken ct) + { + _logger.LogInformation($"Initiating startup for module: Monitoring"); + await using var scope = _serviceProvider.CreateAsyncScope(); + + var dataCollectorService = scope.ServiceProvider.GetRequiredService(); + await dataCollectorService.ApplyConfiguration(); + } + + public async Task StopAsync(CancellationToken ct) + { + _logger.LogInformation($"Initiating shutdown for module: Monitoring"); + await using var scope = _serviceProvider.CreateAsyncScope(); + var dataCollectorService = scope.ServiceProvider.GetRequiredService(); + + await dataCollectorService.Shutdown(); + } +} diff --git a/tilework.core/Interfaces/Monitoring/IDataCollectorConfigurator.cs b/tilework.core/Interfaces/Monitoring/IDataCollectorConfigurator.cs new file mode 100644 index 0000000..751c3c6 --- /dev/null +++ b/tilework.core/Interfaces/Monitoring/IDataCollectorConfigurator.cs @@ -0,0 +1,11 @@ +using Tilework.Persistence.LoadBalancing.Models; + +namespace Tilework.Monitoring.Interfaces; + +public interface IDataCollectorConfigurator +{ + string ServiceName { get; } + + Task ApplyConfiguration(List sources); + Task Shutdown(); +} \ No newline at end of file diff --git a/tilework.core/Models/Monitoring/DataCollectorConfiguration.cs b/tilework.core/Models/Monitoring/DataCollectorConfiguration.cs new file mode 100644 index 0000000..188d7f1 --- /dev/null +++ b/tilework.core/Models/Monitoring/DataCollectorConfiguration.cs @@ -0,0 +1,7 @@ +namespace Tilework.Monitoring.Models; + +public class DataCollectorConfiguration +{ + public string Backend { get; set; } + public string BackendImage { get; set; } +} \ No newline at end of file diff --git a/tilework.core/Models/Monitoring/MonitoringSource.cs b/tilework.core/Models/Monitoring/MonitoringSource.cs new file mode 100644 index 0000000..5714ed1 --- /dev/null +++ b/tilework.core/Models/Monitoring/MonitoringSource.cs @@ -0,0 +1,14 @@ +using Tilework.Core.Models; +using Tilework.Monitoring.Enums; + +public class MonitoringSource +{ + public string Name { get; set; } + public MonitoringSourceType Type { get; set; } + + public Host Host { get; set; } + public int Port { get; set; } + + public string? Username { get; set; } + public string? Password { get; set; } +} \ No newline at end of file diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs index 513073c..41cbf0d 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs @@ -14,7 +14,9 @@ using Tilework.CertificateManagement.Interfaces; using Tilework.CertificateManagement.Enums; +using Tilework.Monitoring.Enums; using Tilework.Persistence.LoadBalancing.Models; +using Tilework.LoadBalancing.Services; namespace Tilework.LoadBalancing.Haproxy; @@ -25,18 +27,21 @@ public class HAProxyConfigurator : ILoadBalancingConfigurator private readonly IContainerManager _containerManager; private readonly LoadBalancerConfiguration _settings; private readonly ICertificateManagementService _certificateManagementService; + private readonly DataCollectorService _dataCollectorService; private readonly ILogger _logger; private readonly IMapper _mapper; public HAProxyConfigurator(IOptions settings, IContainerManager containerManager, ICertificateManagementService certificateManagementService, + DataCollectorService dataCollectorService, ILogger logger, IMapper mapper) { _logger = logger; _settings = settings.Value; _certificateManagementService = certificateManagementService; + _dataCollectorService = dataCollectorService; _containerManager = containerManager; _mapper = mapper; } @@ -200,6 +205,22 @@ await _containerManager.CopyFileToContainer( await _containerManager.StopContainer(container.Id); } } + + + if (lb.Enabled == true && _dataCollectorService.IsMonitored(lb.Id.ToString()) == false) + { + var monitoringSource = new MonitoringSource() + { + Type = MonitoringSourceType.HAPROXY, + Host = Host.Parse((await _containerManager.GetContainerAddress(container.Id)).ToString()), + Port = 4380 + }; + await _dataCollectorService.StartMonitoring(monitoringSource); + } + else if (lb.Enabled == false && _dataCollectorService.IsMonitored(lb.Id.ToString()) == true) + { + await _dataCollectorService.StopMonitoring(lb.Id.ToString()); + } } var containersToDelete = containers.Where(cnt => !config.Any(lb => lb.Id.ToString() == cnt.Name)).ToList(); diff --git a/tilework.core/Providers/MonitoringProviders/Collectd/CollectdDataCollector.cs b/tilework.core/Providers/MonitoringProviders/Collectd/CollectdDataCollector.cs new file mode 100644 index 0000000..6784bdd --- /dev/null +++ b/tilework.core/Providers/MonitoringProviders/Collectd/CollectdDataCollector.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; +using AutoMapper; + +using Tilework.Core.Interfaces; +using Tilework.Core.Models; +using Tilework.Core.Enums; +using Tilework.Monitoring.Interfaces; +using Tilework.Monitoring.Models; + +namespace Tilework.Monitoring.Collectd; + +public class CollectdConfigurator : IDataCollectorConfigurator +{ + public string ServiceName => "Collectd"; + + private readonly IContainerManager _containerManager; + private readonly DataCollectorConfiguration _settings; + private readonly ILogger _logger; + private readonly IMapper _mapper; + + public CollectdConfigurator(IOptions settings, + IContainerManager containerManager, + ILogger logger, + IMapper mapper) + { + _logger = logger; + _settings = settings.Value; + _containerManager = containerManager; + _mapper = mapper; + } + + private async Task GetContainer() + { + var containers = await _containerManager.ListContainers("monitoring.tile"); + + return containers.FirstOrDefault(c => c.Name == "DataCollector-collectd"); + } + + private async Task CreateContainer() + { + try + { + var container = await _containerManager.CreateContainer( + "DataCollector-collectd", + _settings.BackendImage, + "monitoring.tile", + new List() { } + ); + + return container; + } + catch (Exception ex) + { + _logger.LogCritical($"Failed to create container for collectd data collector: {ex.ToString()}"); + throw; + } + } + + private void UpdateConfigFile(string path, List sources) + { + } + + + + public async Task ApplyConfiguration(List sources) + { + var container = await GetContainer(); + if (container == null) + container = await CreateContainer(); + + var localConfigPath = Path.GetTempFileName(); + var configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "collectd.conf"); + + if (!File.Exists(configPath)) + throw new InvalidOperationException($"No default collectd configuration file found at {configPath}"); + + try + { + File.Copy(configPath, localConfigPath, overwrite: true); + UpdateConfigFile(localConfigPath, sources); + await _containerManager.CopyFileToContainer(container.Id, localConfigPath, "/etc/collectd/collectd.conf"); + } + finally + { + if (File.Exists(localConfigPath)) + File.Delete(localConfigPath); + } + + if (container.State != ContainerState.Running) + { + _logger.LogInformation($"Starting container for data collector"); + await _containerManager.StartContainer(container.Id); + } + else + { + _logger.LogInformation($"Signaling container for data collector of configuration changes"); + await _containerManager.KillContainer(container.Id, UnixSignal.SIGHUP); + } + } + + public async Task Shutdown() + { + var container = await GetContainer(); + if (container != null) + { + _logger.LogInformation($"Stopping and deleting collectd data collector"); + if (container.State == ContainerState.Running) + await _containerManager.StopContainer(container.Id); + await _containerManager.DeleteContainer(container.Id); + } + } +} \ No newline at end of file diff --git a/tilework.core/Resources/collectd.conf b/tilework.core/Resources/collectd.conf new file mode 100644 index 0000000..c9ae832 --- /dev/null +++ b/tilework.core/Resources/collectd.conf @@ -0,0 +1,10 @@ +Hostname "collectd" +BaseDir "/var/lib/collectd" +PIDFile "/var/run/collectd.pid" +PluginDir "/usr/lib/collectd" +TypesDB "/usr/share/collectd/types.db" + +Interval 10 +ReadThreads 5 + +LoadPlugin python \ No newline at end of file diff --git a/tilework.core/ServiceCollectionExtensions.cs b/tilework.core/ServiceCollectionExtensions.cs index a5efa83..f718831 100644 --- a/tilework.core/ServiceCollectionExtensions.cs +++ b/tilework.core/ServiceCollectionExtensions.cs @@ -19,6 +19,9 @@ using Tilework.Core.Persistence; using Tilework.Core.Jobs.LoadBalancing; +using Tilework.Monitoring.Interfaces; +using Tilework.Monitoring.Collectd; +using Tilework.Monitoring.Models; namespace Tilework.Core.Services; @@ -38,6 +41,20 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service return services; } + public static IServiceCollection AddMonitoring(this IServiceCollection services, + IConfiguration configuration, + Action dbContextOptions) + { + services.Configure(configuration); + + services.AddScoped(); + services.AddScoped(); + + services.AddHostedService(); + + return services; + } + public static IServiceCollection AddLoadBalancing(this IServiceCollection services, IConfiguration configuration, Action dbContextOptions) @@ -83,4 +100,5 @@ public static IServiceCollection AddCertificateManagement(this IServiceCollectio return services; } + } diff --git a/tilework.core/Services/Monitoring/DataCollectorService.cs b/tilework.core/Services/Monitoring/DataCollectorService.cs new file mode 100644 index 0000000..6d08df3 --- /dev/null +++ b/tilework.core/Services/Monitoring/DataCollectorService.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; + + +using Tilework.Monitoring.Models; +using Tilework.Core.Persistence; +using Tilework.Monitoring.Interfaces; + +namespace Tilework.LoadBalancing.Services; + +public class DataCollectorService +{ + private readonly IDataCollectorConfigurator _configurator; + private readonly TileworkContext _dbContext; + private readonly DataCollectorConfiguration _settings; + private readonly ILogger _logger; + + private List _sources = new(); + + public DataCollectorService(IDataCollectorConfigurator configurator, + TileworkContext dbContext, + IOptions settings, + ILogger logger) + { + _configurator = configurator; + _dbContext = dbContext; + _logger = logger; + _settings = settings.Value; + } + + public bool IsMonitored(string name) + { + return _sources.Any(s => s.Name == name); + } + + public List GetMonitoredSources() + { + return _sources; + } + + public async Task StartMonitoring(MonitoringSource source) + { + if (!IsMonitored(source.Name)) + _sources.Add(source); + + await ApplyConfiguration(); + } + + public async Task StopMonitoring(string name) + { + if (IsMonitored(name)) + { + var source = _sources.First(s => s.Name == name); + _sources.Remove(source); + } + + await ApplyConfiguration(); + } + + public async Task ApplyConfiguration() + { + await _configurator.ApplyConfiguration(_sources); + } + + public async Task Shutdown() + { + await _configurator.Shutdown(); + } +} \ No newline at end of file diff --git a/tilework.core/tilework.core.csproj b/tilework.core/tilework.core.csproj index 3866366..864144e 100644 --- a/tilework.core/tilework.core.csproj +++ b/tilework.core/tilework.core.csproj @@ -24,7 +24,7 @@ - + PreserveNewest diff --git a/tilework.ui/Program.cs b/tilework.ui/Program.cs index 12bc4e8..a5fcf26 100644 --- a/tilework.ui/Program.cs +++ b/tilework.ui/Program.cs @@ -27,6 +27,7 @@ builder.Services.AddCoreServices(); +builder.Services.AddMonitoring(builder.Configuration.GetSection("Monitoring"), dbContextOptions); builder.Services.AddLoadBalancing(builder.Configuration.GetSection("LoadBalancing"), dbContextOptions); builder.Services.AddCertificateManagement(builder.Configuration.GetSection("CertificateManagement"), dbContextOptions); diff --git a/tilework.ui/appsettings.json b/tilework.ui/appsettings.json index a3a0168..32b36e1 100644 --- a/tilework.ui/appsettings.json +++ b/tilework.ui/appsettings.json @@ -13,6 +13,10 @@ "CertificateManagement": { "AcmeVerificationImage" : "tilework/acmevalidator:latest" }, + "Monitoring": { + "Backend" : "collectd", + "BackendImage" : "tilework/monitoring-collectd:latest" + }, "ConnectionStrings": { "DefaultConnection" : "Data Source=/var/lib/tilework/data.db" }, From fd927ed5f5d791b69b11512544ea914cfbc567f0 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 26 Oct 2025 15:31:30 +0000 Subject: [PATCH 020/104] Kinda working collectd --- .../StatementAttribute.cs | 0 .../Collectd/CollectdDataCollector.cs | 26 ++- .../Collectd/Configuration.cs | 165 ++++++++++++++++++ .../Collectd/Models/Sections/ConfigSection.cs | 148 ++++++++++++++++ .../Models/Sections/GenericSection.cs | 8 + .../Models/Sections/HaproxyModuleSection.cs | 14 ++ .../Collectd/Models/Sections/ModuleSection.cs | 11 ++ .../Collectd/Models/Sections/PluginSection.cs | 13 ++ .../Collectd/Models/Sections/RootSection.cs | 24 +++ .../Models/Statements/StatementAttribute.cs | 14 ++ 10 files changed, 421 insertions(+), 2 deletions(-) rename tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/{Sections => Statements}/StatementAttribute.cs (100%) create mode 100644 tilework.core/Providers/MonitoringProviders/Collectd/Configuration.cs create mode 100644 tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/ConfigSection.cs create mode 100644 tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/GenericSection.cs create mode 100644 tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/HaproxyModuleSection.cs create mode 100644 tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/ModuleSection.cs create mode 100644 tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/PluginSection.cs create mode 100644 tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/RootSection.cs create mode 100644 tilework.core/Providers/MonitoringProviders/Collectd/Models/Statements/StatementAttribute.cs diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/StatementAttribute.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/StatementAttribute.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/StatementAttribute.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/StatementAttribute.cs diff --git a/tilework.core/Providers/MonitoringProviders/Collectd/CollectdDataCollector.cs b/tilework.core/Providers/MonitoringProviders/Collectd/CollectdDataCollector.cs index 6784bdd..67b1dff 100644 --- a/tilework.core/Providers/MonitoringProviders/Collectd/CollectdDataCollector.cs +++ b/tilework.core/Providers/MonitoringProviders/Collectd/CollectdDataCollector.cs @@ -7,6 +7,7 @@ using Tilework.Core.Enums; using Tilework.Monitoring.Interfaces; using Tilework.Monitoring.Models; +using Tilework.Monitoring.Enums; namespace Tilework.Monitoring.Collectd; @@ -59,6 +60,26 @@ private async Task CreateContainer() private void UpdateConfigFile(string path, List sources) { + var config = new Configuration(path); + config.Load(); + + var plugin = new PluginSection() + { + Name = "python", + Imports = ["collectd_haproxy"], + Modules = sources.Where(s => s.Type == MonitoringSourceType.HAPROXY) + .Select(s => (ModuleSection) new HaproxyModuleSection() + { + Name = "haproxy", + Instance = s.Name, + Endpoint = $"{s.Host.Value}:{s.Port}" + }).ToList() + }; + + config.Plugins.Clear(); + config.Plugins.Add(plugin); + + config.Save(); } @@ -94,8 +115,9 @@ public async Task ApplyConfiguration(List sources) } else { - _logger.LogInformation($"Signaling container for data collector of configuration changes"); - await _containerManager.KillContainer(container.Id, UnixSignal.SIGHUP); + _logger.LogInformation($"Restarting container for data collector"); + await _containerManager.StopContainer(container.Id); + await _containerManager.StartContainer(container.Id); } } diff --git a/tilework.core/Providers/MonitoringProviders/Collectd/Configuration.cs b/tilework.core/Providers/MonitoringProviders/Collectd/Configuration.cs new file mode 100644 index 0000000..331886a --- /dev/null +++ b/tilework.core/Providers/MonitoringProviders/Collectd/Configuration.cs @@ -0,0 +1,165 @@ +using System.IO; +using System.Linq; +using System.Collections.Generic; + +namespace Tilework.Monitoring.Collectd; + +public class Configuration +{ + public RootSection Root { get; set; } = new RootSection(); + public List Plugins { get; set; } = new(); + + private readonly string _filename; + + public Configuration(string filename) + { + _filename = filename; + } + + public void Load() + { + var lines = File.ReadAllLines(_filename); + + var cleanedLines = lines.Select(line => + { + int hashIndex = line.IndexOf('#'); + return hashIndex != -1 ? line.Substring(0, hashIndex) : line; + }) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .ToArray(); + + var rootStatements = new List(); + var sectionStack = new Stack<(ConfigSection section, List statements)>(); + + foreach (var rawLine in cleanedLines) + { + var line = rawLine.Trim(); + if (line.StartsWith("<") && line.EndsWith(">")) + { + var inner = line.Substring(1, line.Length - 2).Trim(); + if (inner.StartsWith("/")) + { + if (sectionStack.Count > 0) + { + var (closedSection, statements) = sectionStack.Pop(); + closedSection.Statements = statements; + + if (sectionStack.Count > 0) + { + // Attach to parent (generic children collection) + var parent = sectionStack.Peek().section; + parent.Children.Add(closedSection); + + // Attach to a typed parent if applicable + if (parent is PluginSection plugin && closedSection is ModuleSection module) + { + plugin.Modules.Add(module); + } + } + else + { + // Only keep top-level Plugin sections + if (closedSection is PluginSection plugin) + Plugins.Add(plugin); + // Ignore other top-level sections for now + } + } + } + else + { + var parts = inner.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + var sectionName = parts[0]; + var name = parts.Length > 1 ? parts[1] : null; + var newSection = CreateSection(sectionName, name); + sectionStack.Push((newSection, new List())); + } + } + else + { + var statement = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + if (sectionStack.Count > 0) + { + sectionStack.Peek().statements.Add(statement); + } + else + { + rootStatements.Add(statement); + } + } + } + + Root.Statements = rootStatements; + } + + private static string? Unquote(string? s) + { + if (string.IsNullOrEmpty(s)) return s; + if (s!.Length >= 2 && s.StartsWith("\"") && s.EndsWith("\"")) + return s.Substring(1, s.Length - 2).Replace("\\\"", "\""); + return s; + } + + private ConfigSection CreateSection(string section, string? name) + { + switch (section) + { + case "Plugin": + return new PluginSection() { Name = Unquote(name) }; + case "Module": + return new ModuleSection() { Name = Unquote(name) }; + default: + return new GenericSection(section) { Name = Unquote(name) }; + } + } + + public void Save() + { + var configLines = new List(); + + foreach (var statement in Root.Statements) + configLines.Add(string.Join(" ", statement)); + + foreach (var plugin in Plugins) + WriteSection(configLines, plugin, indentLevel: 0); + + File.WriteAllLines(_filename, configLines); + } + + private void WriteSection(List lines, ConfigSection section, int indentLevel) + { + string indent = new string('\t', indentLevel); + + if (section.Name != null) + { + if(!section.QuoteName) + lines.Add($"{indent}<{section.Section} {section.Name}>"); + else + lines.Add($"{indent}<{section.Section} \"{section.Name}\">"); + } + else + lines.Add($"{indent}<{section.Section}>"); + + foreach (var statement in section.Statements) + lines.Add($"{indent}\t{string.Join(" ", statement)}"); + + // Write children (generic + typed, without duplicates) + var written = new HashSet(); + + void writeChild(ConfigSection child) + { + if (written.Add(child)) + WriteSection(lines, child, indentLevel + 1); + } + + foreach (var child in section.Children) + writeChild(child); + + if (section is PluginSection plugin) + { + foreach (var module in plugin.Modules) + writeChild(module); + } + + lines.Add($"{indent}"); + } +} diff --git a/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/ConfigSection.cs b/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/ConfigSection.cs new file mode 100644 index 0000000..3ab285c --- /dev/null +++ b/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/ConfigSection.cs @@ -0,0 +1,148 @@ +using System.Reflection; +using System.Linq; +using System.Collections; + +using Tilework.Core.Enums; + +namespace Tilework.Monitoring.Collectd; + +public abstract class ConfigSection +{ + public string Section { get; set; } + public string? Name { get; set; } + public bool QuoteName { get; set; } = true; + public List Children { get; } = new(); + + private List _statements = new(); + public List Statements + { + get + { + return _statements.Concat(GetStatements()).ToList(); + } + set + { + _statements = new List(); + foreach (var statement in value) + SetStatement(statement); + } + } + + protected ConfigSection(string section, bool quoteName = true) + { + Section = section; + QuoteName = quoteName; + } + + private List GetStatements() + { + var statements = new List(); + + PropertyInfo[] properties = GetType().GetProperties(); + + foreach (var property in properties) + { + var attribute = property.GetCustomAttribute(typeof(StatementAttribute)) as StatementAttribute; + if (attribute != null) + { + var value = property.GetValue(this); + if (value != null) + { + if (value is IEnumerable && value is not string) + { + var enumerable = (IEnumerable)value; + foreach (var item in enumerable) + { + statements.Add(new[] { attribute.Name, PropToString(item) }); + } + } + else + { + statements.Add(new[] { attribute.Name, PropToString(value) }); + } + } + } + } + + return statements; + } + + private void SetStatement(string[] statement) + { + PropertyInfo[] properties = GetType().GetProperties(); + foreach (var property in properties) + { + var attribute = property.GetCustomAttribute(typeof(StatementAttribute)) as StatementAttribute; + if (attribute != null && attribute.Name == statement[0]) + { + if (typeof(IList).IsAssignableFrom(property.PropertyType)) + { + var currentValue = (IList?)property.GetValue(this); + var elementType = property.PropertyType.GetGenericArguments()[0]; + + if (currentValue == null) + { + var listType = typeof(List<>).MakeGenericType(elementType); + currentValue = (IList)Activator.CreateInstance(listType); + } + + currentValue.Add(StringToProp(elementType, statement)); + property.SetValue(this, currentValue); + } + else + { + property.SetValue(this, StringToProp(property.PropertyType, statement)); + } + + return; + } + } + + _statements.Add(statement); + } + + private string PropToString(object value) + { + if (value.GetType().IsEnum) + { + return (value as Enum).GetDescription(); + } + else if (value is string s) + { + var escaped = s.Replace("\"", "\\\""); + return $"\"{escaped}\""; + } + else + { + return value.ToString(); + } + } + + private object StringToProp(Type type, string[] value) + { + var constructor = type.GetConstructor(new[] { typeof(string[]) }); + if (constructor != null) + { + object propertyValue = constructor.Invoke(new object[] { value }); + return propertyValue; + } + else if (type.IsEnum) + { + object enumValue = Enum.Parse(type, value[1], ignoreCase: true); + return enumValue; + } + else if (type == typeof(string)) + { + var token = value[1]; + if (token.Length >= 2 && token.StartsWith("\"") && token.EndsWith("\"")) + { + token = token.Substring(1, token.Length - 2).Replace("\\\"", "\""); + } + return token; + } + else + { + return value[1]; + } + } +} diff --git a/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/GenericSection.cs b/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/GenericSection.cs new file mode 100644 index 0000000..2fcbba7 --- /dev/null +++ b/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/GenericSection.cs @@ -0,0 +1,8 @@ +namespace Tilework.Monitoring.Collectd; + +public class GenericSection : ConfigSection +{ + public GenericSection(string section) : base(section) + { + } +} \ No newline at end of file diff --git a/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/HaproxyModuleSection.cs b/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/HaproxyModuleSection.cs new file mode 100644 index 0000000..0b69d23 --- /dev/null +++ b/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/HaproxyModuleSection.cs @@ -0,0 +1,14 @@ +namespace Tilework.Monitoring.Collectd; + +public class HaproxyModuleSection : ModuleSection +{ + public HaproxyModuleSection() + { + } + + [Statement("Socket")] + public string? Socket { get; set; } + + [Statement("Endpoint")] + public string? Endpoint { get; set; } +} \ No newline at end of file diff --git a/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/ModuleSection.cs b/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/ModuleSection.cs new file mode 100644 index 0000000..710cbea --- /dev/null +++ b/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/ModuleSection.cs @@ -0,0 +1,11 @@ +namespace Tilework.Monitoring.Collectd; + +public class ModuleSection : ConfigSection +{ + public ModuleSection() : base("Module", false) + { + } + + [Statement("Instance")] + public string Instance { get; set; } +} \ No newline at end of file diff --git a/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/PluginSection.cs b/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/PluginSection.cs new file mode 100644 index 0000000..862664b --- /dev/null +++ b/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/PluginSection.cs @@ -0,0 +1,13 @@ +namespace Tilework.Monitoring.Collectd; + +public class PluginSection : ConfigSection +{ + public PluginSection() : base("Plugin") + { + } + + public List Modules { get; set; } = new(); + + [Statement("Import")] + public List Imports { get; set; } +} diff --git a/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/RootSection.cs b/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/RootSection.cs new file mode 100644 index 0000000..5046ba8 --- /dev/null +++ b/tilework.core/Providers/MonitoringProviders/Collectd/Models/Sections/RootSection.cs @@ -0,0 +1,24 @@ +namespace Tilework.Monitoring.Collectd; + +public class RootSection : ConfigSection +{ + [Statement("Hostname")] + public string Hostname { get; set; } + + [Statement("BaseDir")] + public string BaseDir { get; set; } + + [Statement("PIDFile")] + public string PidFile { get; set; } + + [Statement("PluginDir")] + public string PluginDir { get; set; } + + [Statement("TypesDB")] + public string TypesDb { get; set; } + + public RootSection() : base("") + { + } +} + diff --git a/tilework.core/Providers/MonitoringProviders/Collectd/Models/Statements/StatementAttribute.cs b/tilework.core/Providers/MonitoringProviders/Collectd/Models/Statements/StatementAttribute.cs new file mode 100644 index 0000000..40bffaf --- /dev/null +++ b/tilework.core/Providers/MonitoringProviders/Collectd/Models/Statements/StatementAttribute.cs @@ -0,0 +1,14 @@ +using System; + +namespace Tilework.Monitoring.Collectd; + +[AttributeUsage(AttributeTargets.Property)] +public class StatementAttribute : Attribute +{ + public string Name { get; private set; } + + public StatementAttribute(string name) + { + Name = name; + } +} \ No newline at end of file From 6b8b38069328bb657d5cce1a6dc37ef599df77ff Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Mon, 3 Nov 2025 17:53:00 +0000 Subject: [PATCH 021/104] Doing stuff with influxdb --- .../Monitoring/MonitoringPersistenceType.cs | 6 + ...itoringType.cs => MonitoringSourceType.cs} | 0 .../Interfaces/Core/IContainerManager.cs | 2 + .../Monitoring/IDataCollectorConfigurator.cs | 4 +- .../IDataPersistenceConfigurator.cs | 11 ++ .../Models/Core/ContainerCommandResult.cs | 8 + .../DataPersistenceConfiguration.cs | 7 + tilework.core/Models/Monitoring/Monitor.cs | 7 + .../Models/Monitoring/MonitoringSource.cs | 1 + .../Models/Monitoring/MonitoringTarget.cs | 16 ++ .../HAProxy/HAProxyConfigurator.cs | 2 + ...figuration.cs => CollectdConfiguration.cs} | 4 +- .../Collectd/CollectdDataCollector.cs | 16 +- .../Influxdb/InfluxdbDataPersistence.cs | 124 ++++++++++++ .../Telegraf/TelegrafConfiguration.cs | 28 +++ .../Telegraf/TelegrafDataCollector.cs | 178 ++++++++++++++++++ tilework.core/Resources/telegraf.conf | 2 + tilework.core/ServiceCollectionExtensions.cs | 8 +- .../Services/Core/DockerServiceManager.cs | 46 ++++- .../Monitoring/DataCollectorService.cs | 23 ++- tilework.core/tilework.core.csproj | 1 + tilework.ui/appsettings.json | 10 +- 22 files changed, 483 insertions(+), 21 deletions(-) create mode 100644 tilework.core/Enums/Monitoring/MonitoringPersistenceType.cs rename tilework.core/Enums/Monitoring/{MonitoringType.cs => MonitoringSourceType.cs} (100%) create mode 100644 tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs create mode 100644 tilework.core/Models/Core/ContainerCommandResult.cs create mode 100644 tilework.core/Models/Monitoring/DataPersistenceConfiguration.cs create mode 100644 tilework.core/Models/Monitoring/Monitor.cs create mode 100644 tilework.core/Models/Monitoring/MonitoringTarget.cs rename tilework.core/Providers/MonitoringProviders/Collectd/{Configuration.cs => CollectdConfiguration.cs} (98%) create mode 100644 tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs create mode 100644 tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafConfiguration.cs create mode 100644 tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs create mode 100644 tilework.core/Resources/telegraf.conf diff --git a/tilework.core/Enums/Monitoring/MonitoringPersistenceType.cs b/tilework.core/Enums/Monitoring/MonitoringPersistenceType.cs new file mode 100644 index 0000000..9898581 --- /dev/null +++ b/tilework.core/Enums/Monitoring/MonitoringPersistenceType.cs @@ -0,0 +1,6 @@ +namespace Tilework.Monitoring.Enums; + +public enum MonitoringPersistenceType +{ + INFLUXDB +} \ No newline at end of file diff --git a/tilework.core/Enums/Monitoring/MonitoringType.cs b/tilework.core/Enums/Monitoring/MonitoringSourceType.cs similarity index 100% rename from tilework.core/Enums/Monitoring/MonitoringType.cs rename to tilework.core/Enums/Monitoring/MonitoringSourceType.cs diff --git a/tilework.core/Interfaces/Core/IContainerManager.cs b/tilework.core/Interfaces/Core/IContainerManager.cs index 9941cc4..d21e018 100644 --- a/tilework.core/Interfaces/Core/IContainerManager.cs +++ b/tilework.core/Interfaces/Core/IContainerManager.cs @@ -21,4 +21,6 @@ public interface IContainerManager public Task StartContainer(string id); public Task StopContainer(string id); public Task KillContainer(string id, UnixSignal signal); + + public Task ExecuteContainerCommand(string id, string command); } \ No newline at end of file diff --git a/tilework.core/Interfaces/Monitoring/IDataCollectorConfigurator.cs b/tilework.core/Interfaces/Monitoring/IDataCollectorConfigurator.cs index 751c3c6..9bd6567 100644 --- a/tilework.core/Interfaces/Monitoring/IDataCollectorConfigurator.cs +++ b/tilework.core/Interfaces/Monitoring/IDataCollectorConfigurator.cs @@ -1,11 +1,9 @@ -using Tilework.Persistence.LoadBalancing.Models; - namespace Tilework.Monitoring.Interfaces; public interface IDataCollectorConfigurator { string ServiceName { get; } - Task ApplyConfiguration(List sources); + Task ApplyConfiguration(List monitors); Task Shutdown(); } \ No newline at end of file diff --git a/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs b/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs new file mode 100644 index 0000000..2cda0e3 --- /dev/null +++ b/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs @@ -0,0 +1,11 @@ +using Tilework.Monitoring.Models; + +namespace Tilework.Monitoring.Interfaces; + +public interface IDataPersistenceConfigurator +{ + string ServiceName { get; } + Task GetTarget(MonitoringSource source); + Task ApplyConfiguration(); + Task Shutdown(); +} \ No newline at end of file diff --git a/tilework.core/Models/Core/ContainerCommandResult.cs b/tilework.core/Models/Core/ContainerCommandResult.cs new file mode 100644 index 0000000..b83ebd9 --- /dev/null +++ b/tilework.core/Models/Core/ContainerCommandResult.cs @@ -0,0 +1,8 @@ +namespace Tilework.Core.Models; + +public class ContainerCommandResult +{ + public int ExitCode { get; set; } + public string Stdout { get; set; } + public string Stderr { get; set; } +} \ No newline at end of file diff --git a/tilework.core/Models/Monitoring/DataPersistenceConfiguration.cs b/tilework.core/Models/Monitoring/DataPersistenceConfiguration.cs new file mode 100644 index 0000000..eb6d619 --- /dev/null +++ b/tilework.core/Models/Monitoring/DataPersistenceConfiguration.cs @@ -0,0 +1,7 @@ +namespace Tilework.Monitoring.Models; + +public class DataPersistenceConfiguration +{ + public string Backend { get; set; } + public string BackendImage { get; set; } +} \ No newline at end of file diff --git a/tilework.core/Models/Monitoring/Monitor.cs b/tilework.core/Models/Monitoring/Monitor.cs new file mode 100644 index 0000000..9829e77 --- /dev/null +++ b/tilework.core/Models/Monitoring/Monitor.cs @@ -0,0 +1,7 @@ +namespace Tilework.Monitoring.Models; + +public class Monitor +{ + public MonitoringSource Source { get; set; } + public MonitoringTarget Target { get; set; } +} \ No newline at end of file diff --git a/tilework.core/Models/Monitoring/MonitoringSource.cs b/tilework.core/Models/Monitoring/MonitoringSource.cs index 5714ed1..8e9cfd8 100644 --- a/tilework.core/Models/Monitoring/MonitoringSource.cs +++ b/tilework.core/Models/Monitoring/MonitoringSource.cs @@ -1,6 +1,7 @@ using Tilework.Core.Models; using Tilework.Monitoring.Enums; +namespace Tilework.Monitoring.Models; public class MonitoringSource { public string Name { get; set; } diff --git a/tilework.core/Models/Monitoring/MonitoringTarget.cs b/tilework.core/Models/Monitoring/MonitoringTarget.cs new file mode 100644 index 0000000..4f48cac --- /dev/null +++ b/tilework.core/Models/Monitoring/MonitoringTarget.cs @@ -0,0 +1,16 @@ +using Tilework.Core.Models; +using Tilework.Monitoring.Enums; + +namespace Tilework.Monitoring.Models; + +public class MonitoringTarget +{ + public string Name { get; set; } + public MonitoringPersistenceType Type { get; set; } + + public Host Host { get; set; } + public int Port { get; set; } + + public string? Username { get; set; } + public string? Password { get; set; } +} \ No newline at end of file diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs index 41cbf0d..7dc9a9f 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs @@ -15,6 +15,7 @@ using Tilework.CertificateManagement.Interfaces; using Tilework.CertificateManagement.Enums; using Tilework.Monitoring.Enums; +using Tilework.Monitoring.Models; using Tilework.Persistence.LoadBalancing.Models; using Tilework.LoadBalancing.Services; @@ -211,6 +212,7 @@ await _containerManager.CopyFileToContainer( { var monitoringSource = new MonitoringSource() { + Name = lb.Name, Type = MonitoringSourceType.HAPROXY, Host = Host.Parse((await _containerManager.GetContainerAddress(container.Id)).ToString()), Port = 4380 diff --git a/tilework.core/Providers/MonitoringProviders/Collectd/Configuration.cs b/tilework.core/Providers/MonitoringProviders/Collectd/CollectdConfiguration.cs similarity index 98% rename from tilework.core/Providers/MonitoringProviders/Collectd/Configuration.cs rename to tilework.core/Providers/MonitoringProviders/Collectd/CollectdConfiguration.cs index 331886a..36108ea 100644 --- a/tilework.core/Providers/MonitoringProviders/Collectd/Configuration.cs +++ b/tilework.core/Providers/MonitoringProviders/Collectd/CollectdConfiguration.cs @@ -4,14 +4,14 @@ namespace Tilework.Monitoring.Collectd; -public class Configuration +public class CollectdConfiguration { public RootSection Root { get; set; } = new RootSection(); public List Plugins { get; set; } = new(); private readonly string _filename; - public Configuration(string filename) + public CollectdConfiguration(string filename) { _filename = filename; } diff --git a/tilework.core/Providers/MonitoringProviders/Collectd/CollectdDataCollector.cs b/tilework.core/Providers/MonitoringProviders/Collectd/CollectdDataCollector.cs index 67b1dff..22a2de3 100644 --- a/tilework.core/Providers/MonitoringProviders/Collectd/CollectdDataCollector.cs +++ b/tilework.core/Providers/MonitoringProviders/Collectd/CollectdDataCollector.cs @@ -15,6 +15,8 @@ public class CollectdConfigurator : IDataCollectorConfigurator { public string ServiceName => "Collectd"; + private string ContainerName => $"DataCollector-{ServiceName}"; + private readonly IContainerManager _containerManager; private readonly DataCollectorConfiguration _settings; private readonly ILogger _logger; @@ -35,7 +37,7 @@ public CollectdConfigurator(IOptions settings, { var containers = await _containerManager.ListContainers("monitoring.tile"); - return containers.FirstOrDefault(c => c.Name == "DataCollector-collectd"); + return containers.FirstOrDefault(c => c.Name == ContainerName); } private async Task CreateContainer() @@ -43,7 +45,7 @@ private async Task CreateContainer() try { var container = await _containerManager.CreateContainer( - "DataCollector-collectd", + ContainerName, _settings.BackendImage, "monitoring.tile", new List() { } @@ -58,11 +60,13 @@ private async Task CreateContainer() } } - private void UpdateConfigFile(string path, List sources) + private void UpdateConfigFile(string path, List monitors) { - var config = new Configuration(path); + var config = new CollectdConfiguration(path); config.Load(); + var sources = monitors.Select(m => m.Source).ToList(); + var plugin = new PluginSection() { Name = "python", @@ -84,7 +88,7 @@ private void UpdateConfigFile(string path, List sources) - public async Task ApplyConfiguration(List sources) + public async Task ApplyConfiguration(List monitors) { var container = await GetContainer(); if (container == null) @@ -99,7 +103,7 @@ public async Task ApplyConfiguration(List sources) try { File.Copy(configPath, localConfigPath, overwrite: true); - UpdateConfigFile(localConfigPath, sources); + UpdateConfigFile(localConfigPath, monitors); await _containerManager.CopyFileToContainer(container.Id, localConfigPath, "/etc/collectd/collectd.conf"); } finally diff --git a/tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs b/tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs new file mode 100644 index 0000000..1e58dec --- /dev/null +++ b/tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs @@ -0,0 +1,124 @@ +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; +using AutoMapper; + +using Tilework.Core.Interfaces; +using Tilework.Core.Models; +using Tilework.Core.Enums; + +using Tilework.Monitoring.Interfaces; +using Tilework.Monitoring.Models; + +namespace Tilework.Monitoring.Influxdb; + +public class InfluxdbConfigurator : IDataPersistenceConfigurator +{ + public string ServiceName => "Influxdb"; + + private string ContainerName => $"DataPersistence-{ServiceName}"; + + private readonly IContainerManager _containerManager; + private readonly DataPersistenceConfiguration _settings; + private readonly ILogger _logger; + private readonly IMapper _mapper; + + public InfluxdbConfigurator(IOptions settings, + IContainerManager containerManager, + ILogger logger, + IMapper mapper) + { + _logger = logger; + _settings = settings.Value; + _containerManager = containerManager; + _mapper = mapper; + } + + private async Task GetContainer() + { + var containers = await _containerManager.ListContainers("monitoring.tile"); + + return containers.FirstOrDefault(c => c.Name == ContainerName); + } + + private async Task CreateContainer() + { + try + { + var container = await _containerManager.CreateContainer( + ContainerName, + _settings.BackendImage, + "monitoring.tile", + new List() + { + new ContainerPort() + { + Port = 8181, + HostPort = 8181, + Type = PortType.TCP + } + } + ); + + return container; + } + catch (Exception ex) + { + _logger.LogCritical($"Failed to create container for influxdb data persistence: {ex.ToString()}"); + throw; + } + } + + public async Task GetTarget(MonitoringSource source) + { + var container = await GetContainer(); + + return new MonitoringTarget() + { + Name = ServiceName, + Type = Enums.MonitoringPersistenceType.INFLUXDB, + Host = Host.Parse((await _containerManager.GetContainerAddress(container.Id)).ToString()), + Port = 8181 + }; + } + + public async Task ApplyConfiguration() + { + var container = await GetContainer(); + if (container == null) + { + _logger.LogInformation($"Creating container for influxdb data persistence"); + container = await CreateContainer(); + _logger.LogInformation($"Starting container for influxdb data persistence"); + await _containerManager.StartContainer(container.Id); + + await _containerManager.ExecuteContainerCommand(container.Id, "influxdb3 show tokens --format json"); + } + + + if (container.State != ContainerState.Running) + { + _logger.LogInformation($"Starting container for influxdb data persistence"); + await _containerManager.StartContainer(container.Id); + } + else + { + _logger.LogInformation($"Restarting container for influxdb data persistence"); + await _containerManager.StopContainer(container.Id); + await _containerManager.StartContainer(container.Id); + } + } + + public async Task Shutdown() + { + var container = await GetContainer(); + if (container != null) + { + _logger.LogInformation($"Stopping and deleting influxdb data persistence"); + if (container.State == ContainerState.Running) + await _containerManager.StopContainer(container.Id); + await _containerManager.DeleteContainer(container.Id); + } + } + + // private +} \ No newline at end of file diff --git a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafConfiguration.cs b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafConfiguration.cs new file mode 100644 index 0000000..fbec5ad --- /dev/null +++ b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafConfiguration.cs @@ -0,0 +1,28 @@ +using Tomlyn; +using Tomlyn.Model; + +namespace Tilework.Monitoring.Telegraf; + +public class TelegrafConfiguration +{ + private readonly string _filename; + private TomlTable _config; + + + public TelegrafConfiguration(string filename) + { + _filename = filename; + } + + public void Load() + { + var text = File.ReadAllText(_filename); + _config = Toml.ToModel(text); + } + + public void Save() + { + var text = Toml.FromModel(_config); + File.WriteAllText(_filename, text); + } +} diff --git a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs new file mode 100644 index 0000000..9ac08ef --- /dev/null +++ b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs @@ -0,0 +1,178 @@ +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; +using AutoMapper; + +using Tomlyn; +using Tomlyn.Model; + +using Tilework.Core.Interfaces; +using Tilework.Core.Models; +using Tilework.Core.Enums; +using Tilework.Monitoring.Interfaces; +using Tilework.Monitoring.Models; +using Tilework.Monitoring.Enums; + +namespace Tilework.Monitoring.Telegraf; + +public class TelegrafConfigurator : IDataCollectorConfigurator +{ + public string ServiceName => "Telegraf"; + + private string ContainerName => $"DataCollector-{ServiceName}"; + + private readonly IContainerManager _containerManager; + private readonly DataCollectorConfiguration _settings; + private readonly ILogger _logger; + private readonly IMapper _mapper; + + public TelegrafConfigurator(IOptions settings, + IContainerManager containerManager, + ILogger logger, + IMapper mapper) + { + _logger = logger; + _settings = settings.Value; + _containerManager = containerManager; + _mapper = mapper; + } + + private async Task GetContainer() + { + var containers = await _containerManager.ListContainers("monitoring.tile"); + + return containers.FirstOrDefault(c => c.Name == ContainerName); + } + + private async Task CreateContainer() + { + try + { + var container = await _containerManager.CreateContainer( + ContainerName, + _settings.BackendImage, + "monitoring.tile", + new List() { } + ); + + return container; + } + catch (Exception ex) + { + _logger.LogCritical($"Failed to create container for telegraf data collector: {ex.ToString()}"); + throw; + } + } + + private static T GetOrCreate(TomlTable parent, string name) + where T : class, new() + { + if (parent.TryGetValue(name, out var obj) && obj is T t) + return t; + + var created = new T(); + parent[name] = created; + return created; + } + + private void UpdateConfigFile(string path, List monitors) + { + var text = File.ReadAllText(path); + var config = Toml.ToModel(text); + + if (monitors.Count() > 0) + { + var inputs = GetOrCreate(config, "inputs"); + + foreach (var monitor in monitors) + { + var source = monitor.Source; + + switch (source.Type) + { + case MonitoringSourceType.HAPROXY: + { + var array = GetOrCreate(inputs, "inputs"); + + array.Add(new TomlTable + { + ["servers"] = new TomlArray { $"tcp://{source.Host}:{source.Port}" }, + ["interval"] = "60s", + ["tags"] = new TomlTable { ["instance"] = source.Name } + }); + + break; + } + + default: + break; + } + } + } + + + text = Toml.FromModel(config); + File.WriteAllText(path, text); + } + + TomlTableArray EnsureArray(TomlTable parent, string key) + { + if (!parent.TryGetValue(key, out var obj) || obj is not TomlTableArray arr) + { + arr = new TomlTableArray(); + parent[key] = arr; + } + return arr; + } + + + + + public async Task ApplyConfiguration(List monitors) + { + var container = await GetContainer(); + if (container == null) + container = await CreateContainer(); + + var localConfigPath = Path.GetTempFileName(); + var configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "telegraf.conf"); + + if (!File.Exists(configPath)) + throw new InvalidOperationException($"No default telegraf configuration file found at {configPath}"); + + try + { + File.Copy(configPath, localConfigPath, overwrite: true); + UpdateConfigFile(localConfigPath, monitors); + await _containerManager.CopyFileToContainer(container.Id, localConfigPath, "/etc/telegraf/telegraf.conf"); + } + finally + { + if (File.Exists(localConfigPath)) + File.Delete(localConfigPath); + } + + if (container.State != ContainerState.Running) + { + _logger.LogInformation($"Starting container for data collector"); + await _containerManager.StartContainer(container.Id); + } + else + { + _logger.LogInformation($"Restarting container for data collector"); + await _containerManager.StopContainer(container.Id); + await _containerManager.StartContainer(container.Id); + } + } + + public async Task Shutdown() + { + var container = await GetContainer(); + if (container != null) + { + _logger.LogInformation($"Stopping and deleting telegraf data collector"); + if (container.State == ContainerState.Running) + await _containerManager.StopContainer(container.Id); + await _containerManager.DeleteContainer(container.Id); + } + } +} \ No newline at end of file diff --git a/tilework.core/Resources/telegraf.conf b/tilework.core/Resources/telegraf.conf new file mode 100644 index 0000000..8845a31 --- /dev/null +++ b/tilework.core/Resources/telegraf.conf @@ -0,0 +1,2 @@ +[[outputs.file]] + files = ["stdout"] \ No newline at end of file diff --git a/tilework.core/ServiceCollectionExtensions.cs b/tilework.core/ServiceCollectionExtensions.cs index f718831..0821953 100644 --- a/tilework.core/ServiceCollectionExtensions.cs +++ b/tilework.core/ServiceCollectionExtensions.cs @@ -21,7 +21,9 @@ using Tilework.Core.Jobs.LoadBalancing; using Tilework.Monitoring.Interfaces; using Tilework.Monitoring.Collectd; +using Tilework.Monitoring.Telegraf; using Tilework.Monitoring.Models; +using Tilework.Monitoring.Influxdb; namespace Tilework.Core.Services; @@ -45,9 +47,11 @@ public static IServiceCollection AddMonitoring(this IServiceCollection services, IConfiguration configuration, Action dbContextOptions) { - services.Configure(configuration); + services.Configure(configuration.GetSection("DataCollector")); + services.Configure(configuration.GetSection("DataPersistence")); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddHostedService(); diff --git a/tilework.core/Services/Core/DockerServiceManager.cs b/tilework.core/Services/Core/DockerServiceManager.cs index e2f1a55..0953009 100644 --- a/tilework.core/Services/Core/DockerServiceManager.cs +++ b/tilework.core/Services/Core/DockerServiceManager.cs @@ -1,3 +1,6 @@ +using System.Net; +using System.Text; + using Microsoft.Extensions.Logging; using Docker.DotNet; @@ -9,7 +12,7 @@ using Tilework.Core.Interfaces; using Tilework.Core.Models; using Tilework.Exceptions.Core; -using System.Net; + namespace Tilework.Core.Services; @@ -296,4 +299,45 @@ public async Task KillContainer(string id, UnixSignal signal) Signal = DockerSignalMapper.MapUnixSignalToDockerSignal(signal) }); } + + + public async Task ExecuteContainerCommand(string id, string command) + { + var execCreate = await _client.Exec.ExecCreateContainerAsync(id, new ContainerExecCreateParameters + { + AttachStdout = true, + AttachStderr = true, + Cmd = ["sh", "-c", command] + }); + + + using var stream = await _client.Exec.StartAndAttachContainerExecAsync(execCreate.ID, false); + + + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + var buffer = new byte[8192]; + MultiplexedStream.ReadResult result; + do + { + result = await stream.ReadOutputAsync(buffer, 0, buffer.Length, default); + if (result.EOF) break; + + var text = Encoding.UTF8.GetString(buffer, 0, result.Count); + if (result.Target == MultiplexedStream.TargetStream.StandardOut) + stdout.Append(text); + else + stderr.Append(text); + } + while (!result.EOF); + + var execInspect = await _client.Exec.InspectContainerExecAsync(execCreate.ID); + + return new ContainerCommandResult() + { + ExitCode = (int)execInspect.ExitCode, + Stdout = stdout.ToString(), + Stderr = stderr.ToString() + }; + } } \ No newline at end of file diff --git a/tilework.core/Services/Monitoring/DataCollectorService.cs b/tilework.core/Services/Monitoring/DataCollectorService.cs index 6d08df3..37c9d57 100644 --- a/tilework.core/Services/Monitoring/DataCollectorService.cs +++ b/tilework.core/Services/Monitoring/DataCollectorService.cs @@ -10,19 +10,22 @@ namespace Tilework.LoadBalancing.Services; public class DataCollectorService { - private readonly IDataCollectorConfigurator _configurator; + private readonly IDataCollectorConfigurator _collectorConfigurator; + private readonly IDataPersistenceConfigurator _persistenceConfigurator; private readonly TileworkContext _dbContext; private readonly DataCollectorConfiguration _settings; private readonly ILogger _logger; private List _sources = new(); - public DataCollectorService(IDataCollectorConfigurator configurator, + public DataCollectorService(IDataCollectorConfigurator collectorConfigurator, + IDataPersistenceConfigurator persistenceConfigurator, TileworkContext dbContext, IOptions settings, ILogger logger) { - _configurator = configurator; + _collectorConfigurator = collectorConfigurator; + _persistenceConfigurator = persistenceConfigurator; _dbContext = dbContext; _logger = logger; _settings = settings.Value; @@ -59,11 +62,21 @@ public async Task StopMonitoring(string name) public async Task ApplyConfiguration() { - await _configurator.ApplyConfiguration(_sources); + await _persistenceConfigurator.ApplyConfiguration(); + + var monitors = (await Task.WhenAll( + _sources.Select(async s => new Monitoring.Models.Monitor + { + Source = s, + Target = await _persistenceConfigurator.GetTarget(s) + }) + )).ToList(); + + await _collectorConfigurator.ApplyConfiguration(monitors); } public async Task Shutdown() { - await _configurator.Shutdown(); + await _collectorConfigurator.Shutdown(); } } \ No newline at end of file diff --git a/tilework.core/tilework.core.csproj b/tilework.core/tilework.core.csproj index 864144e..cba9ebc 100644 --- a/tilework.core/tilework.core.csproj +++ b/tilework.core/tilework.core.csproj @@ -21,6 +21,7 @@ + diff --git a/tilework.ui/appsettings.json b/tilework.ui/appsettings.json index 32b36e1..44a75c6 100644 --- a/tilework.ui/appsettings.json +++ b/tilework.ui/appsettings.json @@ -14,8 +14,14 @@ "AcmeVerificationImage" : "tilework/acmevalidator:latest" }, "Monitoring": { - "Backend" : "collectd", - "BackendImage" : "tilework/monitoring-collectd:latest" + "DataCollector" : { + "Backend" : "telegraf", + "BackendImage" : "telegraf:1.36" + }, + "DataPersistence" : { + "Backend" : "influxdb", + "BackendImage" : "tilework/monitoring-influxdb:latest" + } }, "ConnectionStrings": { "DefaultConnection" : "Data Source=/var/lib/tilework/data.db" From 5762cc1956da45011c241a51ecc67c9732989080 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Mon, 3 Nov 2025 17:53:48 +0000 Subject: [PATCH 022/104] added x-forwarded-for --- tilework.core/Resources/haproxy.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/tilework.core/Resources/haproxy.cfg b/tilework.core/Resources/haproxy.cfg index 16d1472..70c1e95 100644 --- a/tilework.core/Resources/haproxy.cfg +++ b/tilework.core/Resources/haproxy.cfg @@ -19,6 +19,7 @@ defaults mode http option httplog option dontlognull + option forwardfor timeout connect 5000 timeout client 50000 timeout server 50000 From 4c467dd5aa045902856d05c52b5f53c3974bc9ae Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Mon, 3 Nov 2025 18:18:39 +0000 Subject: [PATCH 023/104] Added default headers --- .../Mappers/HAProxyConfigurationProfile.cs | 15 +++++++++++++++ .../Models/Sections/FrontendSection.cs | 3 +++ .../HAProxy/Models/Statements/HttpHeader.cs | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/HttpHeader.cs diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs index 5d884b3..408b9e5 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs @@ -19,7 +19,22 @@ public HAProxyConfigurationProfile() if (src is ApplicationLoadBalancer alb) { if (alb.Protocol == AlbProtocol.HTTPS) + { dest.Bind.EnableTls = true; + } + + dest.AddHeaders.Add(new HttpHeader() + { + Name = "X-Forwarded-Proto", + Value = alb.Protocol == AlbProtocol.HTTPS ? "https" : "http" + }); + + dest.AddHeaders.Add(new HttpHeader() + { + Name = "X-Forwarded-Port", + Value = src.Port.ToString() + }); + dest.Mode = Mode.HTTP; if (alb.Rules != null) diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/FrontendSection.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/FrontendSection.cs index aecb018..e653e36 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/FrontendSection.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/FrontendSection.cs @@ -17,6 +17,9 @@ public class FrontendSection : ConfigSection [Statement("default_backend")] public string DefaultBackend { get; set; } + [Statement("http-request add-header")] + public List AddHeaders { get; set; } = new List(); + public FrontendSection() : base("frontend") { } diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/HttpHeader.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/HttpHeader.cs new file mode 100644 index 0000000..cc7c714 --- /dev/null +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/HttpHeader.cs @@ -0,0 +1,19 @@ +namespace Tilework.LoadBalancing.Haproxy; + +public class HttpHeader +{ + public string Name { get; set; } + public string Value { get; set; } + + public HttpHeader() {} + + public HttpHeader(string [] parameters) + { + + } + + public override string ToString() + { + return $"{Name} {Value}"; + } +} \ No newline at end of file From 161ecbedcb7b7f9ae7da0f9edbf7d4b439052c7f Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 9 Nov 2025 10:24:17 +0000 Subject: [PATCH 024/104] add status in detail page --- .../Pages/CertificateManagement/CertificateDetail.razor | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor index 594748d..ba8af86 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor @@ -24,6 +24,10 @@ Authority @_certificateManagementService.GeCertificateAuthority(_item.Authority).Result?.Name + + Status + @_item.Status + From 9657693afd7cc872d1743c1bef8b27daa096426b Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 9 Nov 2025 10:40:55 +0000 Subject: [PATCH 025/104] fixibug --- .../CertificateManagement/CertificateManagementService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tilework.core/Services/CertificateManagement/CertificateManagementService.cs b/tilework.core/Services/CertificateManagement/CertificateManagementService.cs index 0fb07ae..785fcae 100644 --- a/tilework.core/Services/CertificateManagement/CertificateManagementService.cs +++ b/tilework.core/Services/CertificateManagement/CertificateManagementService.cs @@ -191,7 +191,7 @@ private async Task RevokeCertificate(Certificate certificate) public async Task RevokeCertificate(Guid Id) { var certificate = await _dbContext.Certificates.FindAsync(Id); - RevokeCertificate(certificate); + await RevokeCertificate(certificate); } public async Task DeleteCertificate(Guid Id) From 38fa1e5931c073840a6be6280526f4268f8b7304 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 9 Nov 2025 10:42:36 +0000 Subject: [PATCH 026/104] Proper item refreshing --- .../CertificateDetail.razor | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor index ba8af86..4d9527e 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor @@ -46,10 +46,23 @@ private List _actions = new List(); - protected override async Task OnInitializedAsync() + private async Task RefreshItem() { _item = await _certificateManagementService.GetCertificate(Id); - if (_item == null) + if(_item == null) + throw new InvalidOperationException(); + + SetActions(); + StateHasChanged(); + } + + protected override async Task OnInitializedAsync() + { + try + { + await RefreshItem(); + } + catch(InvalidOperationException) { _navigationManager.NavigateTo("/cm/certificates"); _snackbar.Add($"Certificate {Id} not found", Severity.Error); @@ -57,9 +70,6 @@ } _breadcrumbs.Add(new BreadcrumbItem(_item.Name, href: null, disabled: true)); - - - SetActions(); } private async Task ConfirmRevoke() @@ -77,7 +87,7 @@ { try { await _certificateManagementService.RevokeCertificate(_item.Id); - SetActions(); + await RefreshItem(); _snackbar.Add("Certificate revoked successfully!", Severity.Success); } catch(Exception ex) From af99c7c0e4670c98603defbc291eba19cf814141 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 9 Nov 2025 11:15:04 +0000 Subject: [PATCH 027/104] Fixes #34 --- tilework.core/Models/Core/Host.cs | 29 ++++--------------- .../CertificateManagementService.cs | 23 ++++++++++----- tilework.core/Utils/HostnameUtils.cs | 28 ++++++++++++++++++ .../Validators/HostnameAttribute.cs | 27 +++++++++++++++++ .../NewCertificateForm.cs | 13 +++++++-- 5 files changed, 86 insertions(+), 34 deletions(-) create mode 100644 tilework.core/Utils/HostnameUtils.cs create mode 100644 tilework.ui/Components/Validators/HostnameAttribute.cs diff --git a/tilework.core/Models/Core/Host.cs b/tilework.core/Models/Core/Host.cs index 2043d7c..e0af6e0 100644 --- a/tilework.core/Models/Core/Host.cs +++ b/tilework.core/Models/Core/Host.cs @@ -1,3 +1,5 @@ +using Tilework.Core.Utils; + namespace Tilework.Core.Models; public readonly record struct Host(string Value) @@ -11,8 +13,8 @@ public static bool TryParse(string input, out Host result) { result = new Host(NormalizeIp(input)); return true; } // DNS name? - var normalized = NormalizeHost(input); - if (IsValidHostname(normalized)) + var normalized = HostnameUtils.NormalizeHost(input); + if (HostnameUtils.IsValidHostname(normalized)) { result = new Host(normalized); return true; } result = default; return false; @@ -22,25 +24,4 @@ public static Host Parse(string input) => TryParse(input, out var v) ? v : throw new FormatException("Invalid host/IP."); static string NormalizeIp(string s) => s; - static string NormalizeHost(string s) - { - s = s.Trim().TrimEnd('.').ToLowerInvariant(); - // IDN -> punycode - var idn = new System.Globalization.IdnMapping(); - var labels = s.Split('.'); - for (int i = 0; i < labels.Length; i++) labels[i] = idn.GetAscii(labels[i]); - return string.Join('.', labels); - } - static bool IsValidHostname(string h) - { - if (h.Length is 0 or > 253) return false; - foreach (var label in h.Split('.')) - { - if (label.Length is 0 or > 63) return false; - if (label.StartsWith('-') || label.EndsWith('-')) return false; - foreach (var ch in label) - if (!(ch is >= 'a' and <= 'z' || ch is >= '0' and <= '9' || ch == '-')) return false; - } - return true; - } -} \ No newline at end of file +} diff --git a/tilework.core/Services/CertificateManagement/CertificateManagementService.cs b/tilework.core/Services/CertificateManagement/CertificateManagementService.cs index 785fcae..2b7d460 100644 --- a/tilework.core/Services/CertificateManagement/CertificateManagementService.cs +++ b/tilework.core/Services/CertificateManagement/CertificateManagementService.cs @@ -90,14 +90,21 @@ public async Task AddCertificate(string name, string fqdn, KeyAl PrivateKey = key }; - _dbContext.Certificates.Add(certificate); - - // TODO: Currently, the process is synchronous so either everything succeeds or nothing. - // Eventually, the signing process should be done in the background and we could save the - // thing here - // await _dbContext.SaveChangesAsync(); + try + { + _dbContext.Certificates.Add(certificate); + // TODO: Currently, the process is synchronous so either everything succeeds or nothing. + // Eventually, the signing process should be done in the background and we could save the + // thing here + // await _dbContext.SaveChangesAsync(); - await SignCertificate(certificate); + await SignCertificate(certificate); + } + catch + { + _dbContext.ChangeTracker.Clear(); + throw; + } return _mapper.Map(certificate); } @@ -118,7 +125,7 @@ private PrivateKey GenerateKey(KeyAlgorithm algorithm) { Algorithm = algorithm, KeyData = keyAlg - }; + }; } private CertificateRequest GenerateCsr(Certificate certificate) diff --git a/tilework.core/Utils/HostnameUtils.cs b/tilework.core/Utils/HostnameUtils.cs new file mode 100644 index 0000000..ec75a11 --- /dev/null +++ b/tilework.core/Utils/HostnameUtils.cs @@ -0,0 +1,28 @@ +using System.Globalization; + +namespace Tilework.Core.Utils; + +public static class HostnameUtils +{ + public static string NormalizeHost(string s) + { + s = s.Trim().TrimEnd('.').ToLowerInvariant(); + var idn = new IdnMapping(); + var labels = s.Split('.'); + for (int i = 0; i < labels.Length; i++) labels[i] = idn.GetAscii(labels[i]); + return string.Join('.', labels); + } + + public static bool IsValidHostname(string h) + { + if (h.Length is 0 or > 253) return false; + foreach (var label in h.Split('.')) + { + if (label.Length is 0 or > 63) return false; + if (label.StartsWith('-') || label.EndsWith('-')) return false; + foreach (var ch in label) + if (!(ch is >= 'a' and <= 'z' || ch is >= '0' and <= '9' || ch == '-')) return false; + } + return true; + } +} diff --git a/tilework.ui/Components/Validators/HostnameAttribute.cs b/tilework.ui/Components/Validators/HostnameAttribute.cs new file mode 100644 index 0000000..ade0b17 --- /dev/null +++ b/tilework.ui/Components/Validators/HostnameAttribute.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using Tilework.Core.Utils; + +namespace Tilework.Ui.Components.Validators; + +public sealed class HostnameAttribute : ValidationAttribute +{ + public HostnameAttribute() : base("Value must be a valid hostname") { } + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + if (value is not string s || string.IsNullOrWhiteSpace(s)) + return ValidationResult.Success; + + try + { + var normalized = HostnameUtils.NormalizeHost(s); + return HostnameUtils.IsValidHostname(normalized) + ? ValidationResult.Success + : new ValidationResult(ErrorMessageString); + } + catch (ArgumentException) + { + return new ValidationResult(ErrorMessageString); + } + } +} diff --git a/tilework.ui/Models/CertificateManagement/NewCertificateForm.cs b/tilework.ui/Models/CertificateManagement/NewCertificateForm.cs index 3b82d82..e234513 100644 --- a/tilework.ui/Models/CertificateManagement/NewCertificateForm.cs +++ b/tilework.ui/Models/CertificateManagement/NewCertificateForm.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; using Tilework.CertificateManagement.Enums; +using Tilework.Core.Utils; +using Tilework.Ui.Components.Validators; namespace Tilework.Ui.Models; @@ -9,12 +11,19 @@ public class NewCertificateForm : BaseForm [Required] public string Name { get; set; } + private string? _fqdn; + [Required] - public string Fqdn { get; set; } + [Hostname] + public string? Fqdn + { + get => _fqdn; + set => _fqdn = value != null ? HostnameUtils.NormalizeHost(value) : null; + } [Required] public KeyAlgorithm Algorithm { get; set; } [Required] public Guid? Authority { get; set; } -} \ No newline at end of file +} From 896d22fc49f6c9d2f7b872df642be6ff8ddf2a5e Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 9 Nov 2025 11:20:14 +0000 Subject: [PATCH 028/104] Fixes #36 --- .../Pages/LoadBalancing/LoadBalancerDetail.razor | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index a4b25bb..e9c9f35 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -253,9 +253,17 @@ { if(_item is ApplicationLoadBalancerDTO appBalancer) { - await _loadBalancerService.AddRule(appBalancer, rule); - await GetRules(); - await _loadBalancerService.ApplyConfiguration(); + try + { + await _loadBalancerService.AddRule(appBalancer, rule); + await GetRules(); + await _loadBalancerService.ApplyConfiguration(); + _snackbar.Add("Rule added successfully!", Severity.Success); + } + catch(Exception ex) + { + _snackbar.Add($"Failed to add rule: {ex.Message}", Severity.Error); + } } else { From 28e5ebd2c0b80049e19a447fd38624d9b87f879d Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 9 Nov 2025 15:22:26 +0000 Subject: [PATCH 029/104] Fixes #37 --- .../LoadBalancing/LoadBalancerService.cs | 112 +++++++++++++++++- .../LoadBalancing/LoadBalancerDetail.razor | 32 ++++- 2 files changed, 135 insertions(+), 9 deletions(-) diff --git a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs index d26719f..10f7274 100644 --- a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs +++ b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs @@ -53,6 +53,20 @@ private ILoadBalancingConfigurator LoadConfigurator(IServiceProvider serviceProv }; } + private static void ValidateRulePriority(ICollection rules, int newPriority) + { + if (newPriority < 0) + { + throw new ArgumentOutOfRangeException(nameof(newPriority), newPriority, "Rule priority cannot be negative."); + } + + var maxPriority = rules.Count == 0 ? -1 : rules.Max(r => r.Priority); + if (newPriority > maxPriority + 1) + { + throw new ArgumentOutOfRangeException(nameof(newPriority), newPriority, $"Rule priority cannot be greater than {maxPriority + 1}."); + } + } + private BaseLoadBalancerDTO MapBalancerToDto(BaseLoadBalancer entity) @@ -142,6 +156,7 @@ public async Task EnableLoadBalancer(Guid Id) public async Task DisableLoadBalancer(Guid Id) { var entity = await _dbContext.LoadBalancers.FindAsync(Id); + entity.Enabled = false; _dbContext.LoadBalancers.Update(entity); @@ -170,6 +185,16 @@ public async Task> GetRules(ApplicationLoadBalancerDTO balancer) public async Task AddRule(ApplicationLoadBalancerDTO balancer, RuleDTO rule) { var entity = (ApplicationLoadBalancer?)await _dbContext.LoadBalancers.FindAsync(balancer.Id); + if (entity == null) + throw new ArgumentNullException(nameof(balancer)); + + ValidateRulePriority(entity.Rules, rule.Priority); + + foreach (var existingRule in entity.Rules.Where(r => r.Priority >= rule.Priority)) + { + existingRule.Priority += 1; + } + entity.Rules.Add(_mapper.Map(rule)); _dbContext.LoadBalancers.Update(entity); await _dbContext.SaveChangesAsync(); @@ -178,20 +203,95 @@ public async Task AddRule(ApplicationLoadBalancerDTO balancer, RuleDTO rule) public async Task UpdateRule(ApplicationLoadBalancerDTO balancer, RuleDTO rule) { var entity = (ApplicationLoadBalancer?)await _dbContext.LoadBalancers.FindAsync(balancer.Id); - var r = entity.Rules.FirstOrDefault(t => t.Id == rule.Id); - if (r != null) + if (entity == null) + throw new ArgumentNullException(nameof(balancer)); + + var ruleEntity = entity.Rules.FirstOrDefault(t => t.Id == rule.Id); + if (ruleEntity == null) + return; + + ValidateRulePriority(entity.Rules, rule.Priority); + + await using var tx = await _dbContext.Database.BeginTransactionAsync(); + + try { - _mapper.Map(rule, r); - _dbContext.LoadBalancers.Update(entity); + var newPriority = rule.Priority; + var originalPriority = ruleEntity.Priority; + + if (newPriority != originalPriority) + { + // first move the original rule out of the way in order to not trip a constraint error + ruleEntity.Priority = -1; + _dbContext.Entry(ruleEntity).State = EntityState.Modified; + await _dbContext.SaveChangesAsync(); + + + if (newPriority < originalPriority) + { + // rule moved up. Bump down rules from new priority to old priority + var rulesToAdjust = entity.Rules.Where(r => r.Id != rule.Id && + r.Priority >= newPriority && + r.Priority < originalPriority) + .OrderByDescending(r => r.Priority); + + foreach (var ruleToAdjust in rulesToAdjust) + { + ruleToAdjust.Priority += 1; + _dbContext.Entry(ruleToAdjust).State = EntityState.Modified; + await _dbContext.SaveChangesAsync(); + } + } + else + { + // rule moved down. Bump up rules from new priority to old priority + var rulesToAdjust = entity.Rules.Where(r => r.Id != rule.Id && + r.Priority <= newPriority && + r.Priority > originalPriority) + .OrderBy(r => r.Priority); + + foreach (var ruleToAdjust in rulesToAdjust) + { + ruleToAdjust.Priority -= 1; + _dbContext.Entry(ruleToAdjust).State = EntityState.Modified; + await _dbContext.SaveChangesAsync(); + } + } + } + + var moddedRules = entity.Rules.OrderBy(r => r.Priority); + + _mapper.Map(rule, ruleEntity); + _dbContext.Entry(ruleEntity).State = EntityState.Modified; await _dbContext.SaveChangesAsync(); + await tx.CommitAsync(); + } + catch + { + await tx.RollbackAsync(); + _dbContext.ChangeTracker.Clear(); + throw; } } public async Task RemoveRule(ApplicationLoadBalancerDTO balancer, RuleDTO rule) { var entity = (ApplicationLoadBalancer?)await _dbContext.LoadBalancers.FindAsync(balancer.Id); - var r = entity.Rules.FirstOrDefault(t => t.Id == rule.Id); - entity.Rules.Remove(r); + if (entity == null) + throw new ArgumentNullException(); + + var ruleEntity = entity.Rules.FirstOrDefault(t => t.Id == rule.Id); + if (ruleEntity == null) + return; + + var pri = ruleEntity.Priority; + entity.Rules.Remove(ruleEntity); + + // bump up lower rules + foreach (var lowerRule in entity.Rules.Where(r => r.Priority > pri)) + { + lowerRule.Priority -= 1; + } _dbContext.LoadBalancers.Update(entity); await _dbContext.SaveChangesAsync(); } diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index e9c9f35..fa7cae2 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -242,6 +242,12 @@ { var rule = new RuleDTO(); var parameters = new DialogParameters(); + + var maxPriorty = _rules.Max(p => p.Priority) + 1; + + // By default the new rule should have the last priority + rule.Priority = maxPriorty; + parameters.Add(x => x.Rule, rule); parameters.Add(x => x.TargetGroups, await GetAlbTargetGroups()); @@ -255,6 +261,11 @@ { try { + if(rule.Priority < 0) + rule.Priority = 0; + if(rule.Priority > maxPriorty) + rule.Priority = maxPriorty; + await _loadBalancerService.AddRule(appBalancer, rule); await GetRules(); await _loadBalancerService.ApplyConfiguration(); @@ -274,6 +285,8 @@ private async Task EditRule(RuleDTO rule) { + var maxPriorty = _rules.Max(p => p.Priority) + 1; + var ruleEdit = new RuleDTO { Id = rule.Id, @@ -302,9 +315,22 @@ { if (_item is ApplicationLoadBalancerDTO appBalancer) { - await _loadBalancerService.UpdateRule(appBalancer, ruleEdit); - await GetRules(); - await _loadBalancerService.ApplyConfiguration(); + try + { + if(ruleEdit.Priority < 0) + ruleEdit.Priority = 0; + if(ruleEdit.Priority > maxPriorty) + ruleEdit.Priority = maxPriorty; + + await _loadBalancerService.UpdateRule(appBalancer, ruleEdit); + await GetRules(); + await _loadBalancerService.ApplyConfiguration(); + _snackbar.Add("Rule updated successfully!", Severity.Success); + } + catch(Exception ex) + { + _snackbar.Add($"Failed to update rule: {ex.Message}", Severity.Error); + } } else { From 8cc4ac918ed7780f22801900fdb8c183bd217797 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 9 Nov 2025 15:29:39 +0000 Subject: [PATCH 030/104] fixing issue with negative priorities --- .../Services/CertificateManagement/AcmeVerificationService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tilework.core/Services/CertificateManagement/AcmeVerificationService.cs b/tilework.core/Services/CertificateManagement/AcmeVerificationService.cs index a80f148..4f70e49 100644 --- a/tilework.core/Services/CertificateManagement/AcmeVerificationService.cs +++ b/tilework.core/Services/CertificateManagement/AcmeVerificationService.cs @@ -115,7 +115,7 @@ private async Task AddLoadBalancerTarget(string id, ApplicationLoadBalancerDTO b var rule = new RuleDTO() { TargetGroup = tg.Id, - Priority = lowestPriority <= 0 ? lowestPriority - 1 : -1, + Priority = 0, Conditions = new List() { new Condition() { From fb045fecc45b8fe423037f350684f8674e9c07a7 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 9 Nov 2025 16:11:14 +0000 Subject: [PATCH 031/104] Fixes #31 --- .../HAProxy/HAProxyConfigurator.cs | 59 +++++++++---------- .../Mappers/HAProxyConfigurationProfile.cs | 4 ++ .../HAProxy/Models/Statements/Bind.cs | 3 +- 3 files changed, 35 insertions(+), 31 deletions(-) diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs index 513073c..fb24ec0 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Text; using Microsoft.Extensions.Options; using Microsoft.Extensions.Logging; @@ -140,45 +141,43 @@ public async Task ApplyConfiguration(List config) File.Delete(localConfigPath); } - // FIXME: If there are existing certificates in the container, they are not deleted - foreach (var certificate in lb.Certificates) - { - if (certificate.Status != CertificateStatus.ACTIVE) - { - _logger.LogError($"Ignoring LB certificate {certificate.Id}: Invalid status {certificate.Status}"); - continue; - } - if (certificate.PrivateKey == null) - { - _logger.LogError($"Ignoring LB certificate {certificate.Id}: Cannot find private key"); - continue; - } + var certs = lb.Certificates.Where(c => c.Status == CertificateStatus.ACTIVE && c.PrivateKey != null); + + var sb = new StringBuilder(); + + foreach (var certificate in lb.Certificates) + { var certData = string.Join("\n", certificate.CertificateData.Select(c => GetCertPem(c))); var keyData = GetPrivateKeyPem(certificate.PrivateKey.KeyData); - var keyType = certificate.PrivateKey.KeyData is RSA ? "rsa" : "ecdsa"; - - var certFilePath = Path.GetTempFileName(); + sb.Append(certData); + sb.Append("\n"); + sb.Append(keyData); + sb.Append("\n"); + } + + var certFilePath = Path.GetTempFileName(); - try - { - File.WriteAllText(certFilePath, $"{keyData}\n{certData}"); - await _containerManager.CopyFileToContainer( - container.Id, - certFilePath, - $"/usr/local/etc/haproxy/certs/{certificate.Fqdn}.{keyType}.pem" - ); - } - finally - { - if (File.Exists(certFilePath)) - File.Delete(certFilePath); - } + try + { + File.WriteAllText(certFilePath, sb.ToString()); + await _containerManager.CopyFileToContainer( + container.Id, + certFilePath, + $"/usr/local/etc/haproxy/certs/{name}.pem" + ); + } + finally + { + if (File.Exists(certFilePath)) + File.Delete(certFilePath); } + + if (container.State != ContainerState.Running) { if (lb.Enabled == true) diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs index 408b9e5..bd728f2 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs @@ -21,6 +21,7 @@ public HAProxyConfigurationProfile() if (alb.Protocol == AlbProtocol.HTTPS) { dest.Bind.EnableTls = true; + dest.Bind.CertFile = $"{src.Id}.pem"; } dest.AddHeaders.Add(new HttpHeader() @@ -70,7 +71,10 @@ public HAProxyConfigurationProfile() else if (src is NetworkLoadBalancer nlb) { if (nlb.Protocol == NlbProtocol.TLS) + { dest.Bind.EnableTls = true; + dest.Bind.CertFile = $"{src.Id}.pem"; + } dest.Mode = Mode.TCP; dest.DefaultBackend = nlb.TargetGroup.Id.ToString(); diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Bind.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Bind.cs index 737ff19..19d4a1d 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Bind.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Bind.cs @@ -6,6 +6,7 @@ public class Bind public int Port { get; set; } public bool EnableTls { get; set; } + public string CertFile { get; set; } public Bind() { } @@ -17,7 +18,7 @@ public Bind(string [] parameters) public override string ToString() { if(EnableTls) - return $"{Address}:{Port} ssl crt /usr/local/etc/haproxy/certs"; + return $"{Address}:{Port} ssl crt /usr/local/etc/haproxy/certs/{CertFile}"; else return $"{Address}:{Port}"; } From f1a75d50f5e21e5d4726430c5d87d6851e54f79b Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 9 Nov 2025 16:51:12 +0000 Subject: [PATCH 032/104] Fixes #31 but better --- .../HAProxy/HAProxyConfigurator.cs | 91 ++++++++++++------- .../Mappers/HAProxyConfigurationProfile.cs | 4 - .../HAProxy/Models/Statements/Bind.cs | 3 +- 3 files changed, 57 insertions(+), 41 deletions(-) diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs index fb24ec0..6932259 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs @@ -86,6 +86,61 @@ private void UpdateConfigFile(string path, BaseLoadBalancer balancer) haproxyConfig.Save(); } + private async Task SaveCertificates(Container container, BaseLoadBalancer loadBalancer) + { + var certlist = new StringBuilder(); + + var activeCertificates = loadBalancer.Certificates.Where( + c => c.Status == CertificateStatus.ACTIVE && + c.PrivateKey != null); + + foreach (var cert in activeCertificates) + { + var certData = string.Join("\n", cert.CertificateData.Select(c => GetCertPem(c))); + var keyData = GetPrivateKeyPem(cert.PrivateKey.KeyData); + + var keyType = cert.PrivateKey.KeyData is RSA ? "rsa" : "ecdsa"; + + var certFilePath = Path.GetTempFileName(); + + var containerFilePath = $"/usr/local/etc/haproxy/certs/{cert.Fqdn}.{keyType}.pem"; + + try + { + File.WriteAllText(certFilePath, $"{keyData}\n{certData}"); + await _containerManager.CopyFileToContainer( + container.Id, + certFilePath, + containerFilePath + ); + } + finally + { + if (File.Exists(certFilePath)) + File.Delete(certFilePath); + } + + certlist.Append($"{containerFilePath}\n"); + } + + var certListFilePath = Path.GetTempFileName(); + + try + { + File.WriteAllText(certListFilePath, certlist.ToString()); + await _containerManager.CopyFileToContainer( + container.Id, + certListFilePath, + "/usr/local/etc/haproxy/certs/certlist.txt" + ); + } + finally + { + if (File.Exists(certListFilePath)) + File.Delete(certListFilePath); + } + } + public async Task ApplyConfiguration(List config) { if (string.IsNullOrEmpty(_settings.BackendImage)) @@ -141,41 +196,7 @@ public async Task ApplyConfiguration(List config) File.Delete(localConfigPath); } - - - var certs = lb.Certificates.Where(c => c.Status == CertificateStatus.ACTIVE && c.PrivateKey != null); - - var sb = new StringBuilder(); - - foreach (var certificate in lb.Certificates) - { - var certData = string.Join("\n", certificate.CertificateData.Select(c => GetCertPem(c))); - var keyData = GetPrivateKeyPem(certificate.PrivateKey.KeyData); - - sb.Append(certData); - sb.Append("\n"); - sb.Append(keyData); - sb.Append("\n"); - } - - var certFilePath = Path.GetTempFileName(); - - try - { - File.WriteAllText(certFilePath, sb.ToString()); - await _containerManager.CopyFileToContainer( - container.Id, - certFilePath, - $"/usr/local/etc/haproxy/certs/{name}.pem" - ); - } - finally - { - if (File.Exists(certFilePath)) - File.Delete(certFilePath); - } - - + await SaveCertificates(container, lb); if (container.State != ContainerState.Running) diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs index bd728f2..408b9e5 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs @@ -21,7 +21,6 @@ public HAProxyConfigurationProfile() if (alb.Protocol == AlbProtocol.HTTPS) { dest.Bind.EnableTls = true; - dest.Bind.CertFile = $"{src.Id}.pem"; } dest.AddHeaders.Add(new HttpHeader() @@ -71,10 +70,7 @@ public HAProxyConfigurationProfile() else if (src is NetworkLoadBalancer nlb) { if (nlb.Protocol == NlbProtocol.TLS) - { dest.Bind.EnableTls = true; - dest.Bind.CertFile = $"{src.Id}.pem"; - } dest.Mode = Mode.TCP; dest.DefaultBackend = nlb.TargetGroup.Id.ToString(); diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Bind.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Bind.cs index 19d4a1d..7629458 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Bind.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Bind.cs @@ -6,7 +6,6 @@ public class Bind public int Port { get; set; } public bool EnableTls { get; set; } - public string CertFile { get; set; } public Bind() { } @@ -18,7 +17,7 @@ public Bind(string [] parameters) public override string ToString() { if(EnableTls) - return $"{Address}:{Port} ssl crt /usr/local/etc/haproxy/certs/{CertFile}"; + return $"{Address}:{Port} ssl crt-list /usr/local/etc/haproxy/certs/certlist.txt"; else return $"{Address}:{Port}"; } From 94144e23459170148d3d30fce8579228e8d0a5a2 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 9 Nov 2025 16:58:55 +0000 Subject: [PATCH 033/104] Added handling of tls load balancer without certificates --- .../LoadBalancing/LoadBalancerService.cs | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs index 10f7274..8b34d4e 100644 --- a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs +++ b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs @@ -65,11 +65,29 @@ private static void ValidateRulePriority(ICollection rules, int newPriorit { throw new ArgumentOutOfRangeException(nameof(newPriority), newPriority, $"Rule priority cannot be greater than {maxPriority + 1}."); } - } - - - - private BaseLoadBalancerDTO MapBalancerToDto(BaseLoadBalancer entity) + } + + private static bool RequiresCertificate(BaseLoadBalancer balancer) + { + return balancer switch + { + ApplicationLoadBalancer appBalancer => appBalancer.Protocol == AlbProtocol.HTTPS, + NetworkLoadBalancer netBalancer => netBalancer.Protocol == NlbProtocol.TLS, + _ => false + }; + } + + private static void EnsureCertificatesPresentIfRequired(BaseLoadBalancer balancer) + { + if (RequiresCertificate(balancer) && (balancer.Certificates == null || balancer.Certificates.Count == 0)) + { + throw new InvalidOperationException($"Load balancer {balancer.Name} requires at least one certificate before it can be enabled."); + } + } + + + + private BaseLoadBalancerDTO MapBalancerToDto(BaseLoadBalancer entity) { return entity switch { @@ -132,11 +150,18 @@ public async Task DeleteLoadBalancer(Guid Id) await _dbContext.SaveChangesAsync(); } - public async Task EnableLoadBalancer(Guid Id) - { - var entity = await _dbContext.LoadBalancers.FindAsync(Id); - entity.Enabled = true; - _dbContext.LoadBalancers.Update(entity); + public async Task EnableLoadBalancer(Guid Id) + { + var entity = await _dbContext.LoadBalancers + .Include(lb => lb.Certificates) + .FirstOrDefaultAsync(lb => lb.Id == Id); + + if (entity == null) + throw new ArgumentException($"Load balancer {Id} not found."); + + EnsureCertificatesPresentIfRequired(entity); + entity.Enabled = true; + _dbContext.LoadBalancers.Update(entity); await using var tx = await _dbContext.Database.BeginTransactionAsync(); try From ae1554fb14452c03da33158645b95b869dad18bc Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 9 Nov 2025 17:15:40 +0000 Subject: [PATCH 034/104] Fixed bug with maxpriority when there are no rules --- .../Pages/LoadBalancing/LoadBalancerDetail.razor | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index fa7cae2..a7c24cc 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -187,6 +187,8 @@ private List _series = new(); private bool _statsLoading = false; + private int GetMaxRulePriority() => _rules.Count() > 0 ? _rules.Max(p => p.Priority) + 1 : 0; + protected override async Task OnInitializedAsync() { _item = await _loadBalancerService.GetLoadBalancer(Id); @@ -243,10 +245,10 @@ var rule = new RuleDTO(); var parameters = new DialogParameters(); - var maxPriorty = _rules.Max(p => p.Priority) + 1; + var maxPriorty = GetMaxRulePriority(); // By default the new rule should have the last priority - rule.Priority = maxPriorty; + rule.Priority = GetMaxRulePriority(); parameters.Add(x => x.Rule, rule); parameters.Add(x => x.TargetGroups, await GetAlbTargetGroups()); @@ -285,7 +287,7 @@ private async Task EditRule(RuleDTO rule) { - var maxPriorty = _rules.Max(p => p.Priority) + 1; + var maxPriorty = GetMaxRulePriority(); var ruleEdit = new RuleDTO { From c335466113049f94e92640af88b17ec143f9e28b Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 16 Nov 2025 13:43:05 +0000 Subject: [PATCH 035/104] Fixing telegraf --- .../Telegraf/TelegrafDataCollector.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs index 9ac08ef..e59ddfc 100644 --- a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs +++ b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs @@ -91,11 +91,11 @@ private void UpdateConfigFile(string path, List monit { case MonitoringSourceType.HAPROXY: { - var array = GetOrCreate(inputs, "inputs"); + var array = GetOrCreate(inputs, "haproxy"); array.Add(new TomlTable { - ["servers"] = new TomlArray { $"tcp://{source.Host}:{source.Port}" }, + ["servers"] = new TomlArray { $"tcp://{source.Host.Value}:{source.Port}" }, ["interval"] = "60s", ["tags"] = new TomlTable { ["instance"] = source.Name } }); @@ -130,6 +130,19 @@ TomlTableArray EnsureArray(TomlTable parent, string key) public async Task ApplyConfiguration(List monitors) { var container = await GetContainer(); + + if (monitors.Count() == 0) + { + _logger.LogInformation("No active monitors found. Deferring configuration for data collector"); + if (container != null) + { + _logger.LogInformation($"Stopping container for data collector"); + await _containerManager.StopContainer(container.Id); + } + return; + } + + if (container == null) container = await CreateContainer(); From 5df459d5397f3352c1a677d75c57650aed5048fd Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 16 Nov 2025 13:58:02 +0000 Subject: [PATCH 036/104] Added functionality for http apis --- tilework.core/ServiceCollectionExtensions.cs | 1 + .../Services/Core/HttpApiFactoryService.cs | 19 +++++ tilework.core/Services/Core/HttpApiService.cs | 82 +++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 tilework.core/Services/Core/HttpApiFactoryService.cs create mode 100644 tilework.core/Services/Core/HttpApiService.cs diff --git a/tilework.core/ServiceCollectionExtensions.cs b/tilework.core/ServiceCollectionExtensions.cs index 0821953..315c6dc 100644 --- a/tilework.core/ServiceCollectionExtensions.cs +++ b/tilework.core/ServiceCollectionExtensions.cs @@ -33,6 +33,7 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service { services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddHostedService(); diff --git a/tilework.core/Services/Core/HttpApiFactoryService.cs b/tilework.core/Services/Core/HttpApiFactoryService.cs new file mode 100644 index 0000000..7326f4a --- /dev/null +++ b/tilework.core/Services/Core/HttpApiFactoryService.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Logging; + +namespace Tilework.Core.Services; + +public class HttpApiFactoryService +{ + private readonly ILoggerFactory _loggerFactory; + + public HttpApiFactoryService(ILoggerFactory loggerFactory) + { + _loggerFactory = loggerFactory; + } + + public HttpApiService GetApiService(string baseUrl) + { + var logger = _loggerFactory.CreateLogger(); + return new HttpApiService(logger, baseUrl); + } +} diff --git a/tilework.core/Services/Core/HttpApiService.cs b/tilework.core/Services/Core/HttpApiService.cs new file mode 100644 index 0000000..ef2ad25 --- /dev/null +++ b/tilework.core/Services/Core/HttpApiService.cs @@ -0,0 +1,82 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +using Microsoft.Extensions.Logging; + + +namespace Tilework.Core.Services; + +public class HttpApiService +{ + private readonly ILogger _logger; + + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + + public HttpApiService(ILogger logger, string baseUrl) + { + _logger = logger; + _httpClient = new HttpClient(); + _baseUrl = baseUrl; + } + + public async Task ApiGet(string url, + Dictionary? headers = null, + Dictionary? query = null) where T : class + { + return await ApiCall(HttpMethod.Get, url, headers, query); + } + + public async Task ApiPost(string url, + Dictionary? headers = null, + Dictionary? query = null, + object? requestData = null) where T : class + { + return await ApiCall(HttpMethod.Post, url, headers, query, requestData); + } + + + private async Task ApiCall(HttpMethod method, string url, + Dictionary? headers = null, + Dictionary? query = null, + object? requestData = null) where T : class + { + var fullUrl = $"{_baseUrl.TrimEnd('/')}/{url.TrimStart('/')}"; + + using var request = new HttpRequestMessage(method, fullUrl); + + if (query is not null && query.Count > 0) + { + var q = string.Join("&", query.Select(kv => + $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); + fullUrl = $"{fullUrl}?{q}"; + } + + if (headers is not null) + { + foreach (var h in headers) + { + request.Headers.TryAddWithoutValidation(h.Key, h.Value); + } + } + + if(requestData != null) + { + var jsonData = JsonSerializer.Serialize(requestData); + request.Content = new StringContent(jsonData, Encoding.UTF8, "application/json"); + } + + HttpResponseMessage response = await _httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + string responseBody = await response.Content.ReadAsStringAsync(); + + var deserializedResponse = JsonSerializer.Deserialize(responseBody, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return deserializedResponse!; + } +} \ No newline at end of file From 6b4da9c6d8ba6c13bdd3bd0dc2b5d0d9c131951d Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 16 Nov 2025 13:59:47 +0000 Subject: [PATCH 037/104] First working prototype of influxdb working? --- .../Influxdb/InfluxdbDataPersistence.cs | 39 +++++++++++++++++-- .../Telegraf/TelegrafDataCollector.cs | 39 +++++++++++++------ tilework.core/Resources/telegraf.conf | 2 - tilework.core/ServiceCollectionExtensions.cs | 2 +- 4 files changed, 63 insertions(+), 19 deletions(-) diff --git a/tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs b/tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs index 1e58dec..b03c905 100644 --- a/tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs +++ b/tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs @@ -1,3 +1,5 @@ +using System; +using System.Linq; using Microsoft.Extensions.Options; using Microsoft.Extensions.Logging; using AutoMapper; @@ -8,6 +10,8 @@ using Tilework.Monitoring.Interfaces; using Tilework.Monitoring.Models; +using Tilework.Core.Services; +using Tilework.Monitoring.Influxdb.Models; namespace Tilework.Monitoring.Influxdb; @@ -21,16 +25,21 @@ public class InfluxdbConfigurator : IDataPersistenceConfigurator private readonly DataPersistenceConfiguration _settings; private readonly ILogger _logger; private readonly IMapper _mapper; + private readonly HttpApiFactoryService _apiFactory; + + private string? _adminToken = null; public InfluxdbConfigurator(IOptions settings, IContainerManager containerManager, ILogger logger, + HttpApiFactoryService httpApiFactoryService, IMapper mapper) { _logger = logger; _settings = settings.Value; _containerManager = containerManager; _mapper = mapper; + _apiFactory = httpApiFactoryService; } private async Task GetContainer() @@ -77,7 +86,8 @@ public async Task GetTarget(MonitoringSource source) Name = ServiceName, Type = Enums.MonitoringPersistenceType.INFLUXDB, Host = Host.Parse((await _containerManager.GetContainerAddress(container.Id)).ToString()), - Port = 8181 + Port = 8181, + Password = await GetAdminToken() }; } @@ -93,7 +103,7 @@ public async Task ApplyConfiguration() await _containerManager.ExecuteContainerCommand(container.Id, "influxdb3 show tokens --format json"); } - + if (container.State != ContainerState.Running) { @@ -106,6 +116,8 @@ public async Task ApplyConfiguration() await _containerManager.StopContainer(container.Id); await _containerManager.StartContainer(container.Id); } + + await GetAdminToken(); } public async Task Shutdown() @@ -120,5 +132,24 @@ public async Task Shutdown() } } - // private -} \ No newline at end of file + private async Task GetApiService() + { + var container = await GetContainer(); + var host = Host.Parse((await _containerManager.GetContainerAddress(container.Id)).ToString()); + + return _apiFactory.GetApiService($"http://{host.Value}:8181/api/v3/"); + } + + private async Task GetAdminToken() + { + if (_adminToken != null) + return _adminToken; + + var container = await GetContainer(); + + var result = await _containerManager.ExecuteContainerCommand(container.Id, "get_token.sh"); + + _adminToken = result.Stdout; + return _adminToken; + } +} diff --git a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs index e59ddfc..e6c81e9 100644 --- a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs +++ b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs @@ -82,6 +82,7 @@ private void UpdateConfigFile(string path, List monit if (monitors.Count() > 0) { var inputs = GetOrCreate(config, "inputs"); + var outputs = GetOrCreate(config, "outputs"); foreach (var monitor in monitors) { @@ -106,6 +107,32 @@ private void UpdateConfigFile(string path, List monit default: break; } + + var target = monitor.Target; + + switch(target.Type) + { + case MonitoringPersistenceType.INFLUXDB: + { + var array = GetOrCreate(outputs, "influxdb_v2"); + + array.Add(new TomlTable + { + ["urls"] = new TomlArray { $"http://{target.Host.Value}:{target.Port}" }, + ["token"] = target.Password, + ["bucket"] = source.Name, + ["tagpass"] = new TomlTable + { + ["instance"] = new TomlArray { source.Name } + } + }); + + break; + } + + default: + break; + } } } @@ -113,18 +140,6 @@ private void UpdateConfigFile(string path, List monit text = Toml.FromModel(config); File.WriteAllText(path, text); } - - TomlTableArray EnsureArray(TomlTable parent, string key) - { - if (!parent.TryGetValue(key, out var obj) || obj is not TomlTableArray arr) - { - arr = new TomlTableArray(); - parent[key] = arr; - } - return arr; - } - - public async Task ApplyConfiguration(List monitors) diff --git a/tilework.core/Resources/telegraf.conf b/tilework.core/Resources/telegraf.conf index 8845a31..e69de29 100644 --- a/tilework.core/Resources/telegraf.conf +++ b/tilework.core/Resources/telegraf.conf @@ -1,2 +0,0 @@ -[[outputs.file]] - files = ["stdout"] \ No newline at end of file diff --git a/tilework.core/ServiceCollectionExtensions.cs b/tilework.core/ServiceCollectionExtensions.cs index 315c6dc..069274b 100644 --- a/tilework.core/ServiceCollectionExtensions.cs +++ b/tilework.core/ServiceCollectionExtensions.cs @@ -52,7 +52,7 @@ public static IServiceCollection AddMonitoring(this IServiceCollection services, services.Configure(configuration.GetSection("DataPersistence")); services.AddScoped(); - services.AddScoped(); + services.AddSingleton(); services.AddScoped(); services.AddHostedService(); From 4b1aff269633d5d5f14f81939eab2073687cd9c2 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 16 Nov 2025 14:20:54 +0000 Subject: [PATCH 038/104] fixing stuff --- .../MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs | 1 - tilework.core/Services/Monitoring/DataCollectorService.cs | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs b/tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs index b03c905..54e51ca 100644 --- a/tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs +++ b/tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs @@ -11,7 +11,6 @@ using Tilework.Monitoring.Interfaces; using Tilework.Monitoring.Models; using Tilework.Core.Services; -using Tilework.Monitoring.Influxdb.Models; namespace Tilework.Monitoring.Influxdb; diff --git a/tilework.core/Services/Monitoring/DataCollectorService.cs b/tilework.core/Services/Monitoring/DataCollectorService.cs index 37c9d57..081546b 100644 --- a/tilework.core/Services/Monitoring/DataCollectorService.cs +++ b/tilework.core/Services/Monitoring/DataCollectorService.cs @@ -77,6 +77,7 @@ public async Task ApplyConfiguration() public async Task Shutdown() { + await _persistenceConfigurator.Shutdown(); await _collectorConfigurator.Shutdown(); } } \ No newline at end of file From ae32a1070ca336f77426972a091ee950d71dd8d5 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Mon, 17 Nov 2025 12:22:06 +0000 Subject: [PATCH 039/104] Fix proper expiration marking --- .../CertificateManagementService.cs | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tilework.core/Services/CertificateManagement/CertificateManagementService.cs b/tilework.core/Services/CertificateManagement/CertificateManagementService.cs index 2b7d460..8d35180 100644 --- a/tilework.core/Services/CertificateManagement/CertificateManagementService.cs +++ b/tilework.core/Services/CertificateManagement/CertificateManagementService.cs @@ -64,16 +64,52 @@ private string DeserializeConfig(ICAConfiguration config) }; } + private bool TryMarkCertificateExpired(Certificate? certificate) + { + if (certificate == null) + return false; + + var expiresAt = certificate.ExpiresAtUtc; + + if (!expiresAt.HasValue || certificate.Status == CertificateStatus.EXPIRED) + return false; + + if (expiresAt.Value > DateTimeOffset.UtcNow) + return false; + + certificate.Status = CertificateStatus.EXPIRED; + return true; + } + public async Task> GetCertificates() { var entities = await _dbContext.Certificates.ToListAsync(); + var changed = false; + + foreach (var certificate in entities) + { + changed |= TryMarkCertificateExpired(certificate); + } + + if (changed) + { + await _dbContext.SaveChangesAsync(); + } + return _mapper.Map>(entities); } public async Task GetCertificate(Guid Id) { var entity = await _dbContext.Certificates.FindAsync(Id); + + if (entity == null) + return null; + + if (TryMarkCertificateExpired(entity)) + await _dbContext.SaveChangesAsync(); + return _mapper.Map(entity); } @@ -247,4 +283,4 @@ public async Task DeleteCertificateAuthority(Guid Id) _dbContext.CertificateAuthorities.Remove(authority); await _dbContext.SaveChangesAsync(); } -} \ No newline at end of file +} From fab357624cba951d2504455d3b0a065120f7e296 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Mon, 17 Nov 2025 20:29:35 +0000 Subject: [PATCH 040/104] Implemented automated certificate renewal --- .../Events/Events/CertificateRenewed.cs | 12 ++++ .../LoadBalancerCertificateListener.cs | 26 +++++++++ .../CertificateManagementInitializer.cs | 18 +++++- tilework.core/Initializers/CoreInitializer.cs | 4 ++ .../Initializers/LoadBalancingInitializer.cs | 5 ++ .../ICertificateManagementService.cs | 2 + .../CertificateRenewalJob.cs | 24 ++++++++ .../CertificateManagementConfiguration.cs | 1 + tilework.core/ServiceCollectionExtensions.cs | 6 ++ .../CertificateManagementService.cs | 56 ++++++++++++++++++- tilework.ui/appsettings.json | 3 +- 11 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 tilework.core/Events/Events/CertificateRenewed.cs create mode 100644 tilework.core/Events/Listeners/LoadBalancerCertificateListener.cs create mode 100644 tilework.core/Jobs/CertificateManagement/CertificateRenewalJob.cs diff --git a/tilework.core/Events/Events/CertificateRenewed.cs b/tilework.core/Events/Events/CertificateRenewed.cs new file mode 100644 index 0000000..6be72a0 --- /dev/null +++ b/tilework.core/Events/Events/CertificateRenewed.cs @@ -0,0 +1,12 @@ +using Coravel.Events.Interfaces; + +using Tilework.CertificateManagement.Models; + +namespace Tilework.Events; + +public class CertificateRenewed : IEvent +{ + public CertificateDTO Certificate { get; set; } + + public CertificateRenewed(CertificateDTO certificate) => Certificate = certificate; +} diff --git a/tilework.core/Events/Listeners/LoadBalancerCertificateListener.cs b/tilework.core/Events/Listeners/LoadBalancerCertificateListener.cs new file mode 100644 index 0000000..e16228d --- /dev/null +++ b/tilework.core/Events/Listeners/LoadBalancerCertificateListener.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Logging; + +using Coravel.Events.Interfaces; + +using Tilework.LoadBalancing.Interfaces; + +namespace Tilework.Events; + +public class LoadBalancerCertificateListener : IListener +{ + private readonly ILoadBalancerService _loadBalancer; + private readonly ILogger _logger; + + public LoadBalancerCertificateListener(ILoadBalancerService loadBalancer, + ILogger logger) + { + _loadBalancer = loadBalancer; + _logger = logger; + } + + public async Task HandleAsync(CertificateRenewed evt) + { + _logger.LogInformation($"Applying load balancer configuration due to renewal of certificate {evt.Certificate.Name}"); + await _loadBalancer.ApplyConfiguration(); + } +} \ No newline at end of file diff --git a/tilework.core/Initializers/CertificateManagementInitializer.cs b/tilework.core/Initializers/CertificateManagementInitializer.cs index 5f6e009..7bcd11b 100644 --- a/tilework.core/Initializers/CertificateManagementInitializer.cs +++ b/tilework.core/Initializers/CertificateManagementInitializer.cs @@ -1,8 +1,9 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Hosting; - using Microsoft.Extensions.DependencyInjection; +using Coravel; +using Tilework.Core.Jobs.CertificateManagement; namespace Tilework.CertificateManagement.Services; @@ -18,11 +19,22 @@ public CertificateManagementInitializer(ILogger Task.CompletedTask; + public async Task StartAsync(CancellationToken ct) + { + _logger.LogInformation($"Initiating startup for module: CertificateManagement"); + await using var scope = _serviceProvider.CreateAsyncScope(); + + scope.ServiceProvider.UseScheduler(s => + { + s.Schedule() + .Hourly() + .PreventOverlapping("CertificateRenewalJob"); + }); + } public async Task StopAsync(CancellationToken ct) { - _logger.LogInformation($"Initiating shutdown for module: LoadBalancing"); + _logger.LogInformation($"Initiating shutdown for module: CertificateManagement"); await using var scope = _serviceProvider.CreateAsyncScope(); var loadBalancerService = scope.ServiceProvider.GetRequiredService(); diff --git a/tilework.core/Initializers/CoreInitializer.cs b/tilework.core/Initializers/CoreInitializer.cs index 2ffad1c..537b6e0 100644 --- a/tilework.core/Initializers/CoreInitializer.cs +++ b/tilework.core/Initializers/CoreInitializer.cs @@ -3,6 +3,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.EntityFrameworkCore; +using Coravel; + using Tilework.Core.Persistence; namespace Tilework.Core.Services; @@ -27,6 +29,8 @@ public async Task StartAsync(CancellationToken ct) _logger.LogInformation($"Running migrations for context: TileworkContext"); var dbContext = scope.ServiceProvider.GetRequiredService(); await dbContext.Database.MigrateAsync(ct); + + scope.ServiceProvider.ConfigureEvents(); } diff --git a/tilework.core/Initializers/LoadBalancingInitializer.cs b/tilework.core/Initializers/LoadBalancingInitializer.cs index 5e4a6cc..94d88cc 100644 --- a/tilework.core/Initializers/LoadBalancingInitializer.cs +++ b/tilework.core/Initializers/LoadBalancingInitializer.cs @@ -6,6 +6,7 @@ using Tilework.LoadBalancing.Interfaces; using Tilework.Core.Jobs.LoadBalancing; +using Tilework.Events; namespace Tilework.LoadBalancing.Services; @@ -35,6 +36,10 @@ public async Task StartAsync(CancellationToken ct) .EveryMinute() .PreventOverlapping("LoadBalancerMonitoringJob"); }); + + var events = scope.ServiceProvider.ConfigureEvents(); + events.Register() + .Subscribe(); } public async Task StopAsync(CancellationToken ct) diff --git a/tilework.core/Interfaces/CertificateManagement/ICertificateManagementService.cs b/tilework.core/Interfaces/CertificateManagement/ICertificateManagementService.cs index 147e74b..5d0bac7 100644 --- a/tilework.core/Interfaces/CertificateManagement/ICertificateManagementService.cs +++ b/tilework.core/Interfaces/CertificateManagement/ICertificateManagementService.cs @@ -14,6 +14,8 @@ public interface ICertificateManagementService public Task> GetCertificates(); public Task GetCertificate(Guid Id); public Task AddCertificate(string name, string fqdn, KeyAlgorithm algorithm, Guid authorityId); + public Task RenewCertificate(Guid Id); + public Task RenewExpiringCertificates(); public Task RevokeCertificate(Guid Id); public Task DeleteCertificate(Guid Id); diff --git a/tilework.core/Jobs/CertificateManagement/CertificateRenewalJob.cs b/tilework.core/Jobs/CertificateManagement/CertificateRenewalJob.cs new file mode 100644 index 0000000..613f6dd --- /dev/null +++ b/tilework.core/Jobs/CertificateManagement/CertificateRenewalJob.cs @@ -0,0 +1,24 @@ +using Coravel.Invocable; +using Microsoft.Extensions.Logging; +using Tilework.CertificateManagement.Interfaces; +using Tilework.LoadBalancing.Interfaces; + +namespace Tilework.Core.Jobs.CertificateManagement; + +public class CertificateRenewalJob : IInvocable +{ + private readonly ICertificateManagementService _certificateManagementService; + private readonly ILogger _logger; + public CertificateRenewalJob(ICertificateManagementService certificateManagementService, + ILogger logger) + { + _certificateManagementService = certificateManagementService; + _logger = logger; + } + + public async Task Invoke() + { + _logger.LogInformation("Running scheduled certificate renewal check"); + await _certificateManagementService.RenewExpiringCertificates(); + } +} diff --git a/tilework.core/Models/CertificateManagement/Configuration/CertificateManagementConfiguration.cs b/tilework.core/Models/CertificateManagement/Configuration/CertificateManagementConfiguration.cs index 906c463..8e1be18 100644 --- a/tilework.core/Models/CertificateManagement/Configuration/CertificateManagementConfiguration.cs +++ b/tilework.core/Models/CertificateManagement/Configuration/CertificateManagementConfiguration.cs @@ -3,4 +3,5 @@ namespace Tilework.CertificateManagement.Models; public class CertificateManagementConfiguration { public string AcmeVerificationImage { get; set; } + public TimeSpan CertRenewalLeadTime { get; set; } } \ No newline at end of file diff --git a/tilework.core/ServiceCollectionExtensions.cs b/tilework.core/ServiceCollectionExtensions.cs index a5efa83..02942c0 100644 --- a/tilework.core/ServiceCollectionExtensions.cs +++ b/tilework.core/ServiceCollectionExtensions.cs @@ -19,6 +19,9 @@ using Tilework.Core.Persistence; using Tilework.Core.Jobs.LoadBalancing; +using Tilework.Core.Jobs.CertificateManagement; + +using Tilework.Events; namespace Tilework.Core.Services; @@ -59,6 +62,7 @@ public static IServiceCollection AddLoadBalancing(this IServiceCollection servic services.AddAutoMapper(typeof(LoadBalancingMappingProfile)); services.AddTransient(); + services.AddTransient(); return services; } @@ -80,6 +84,8 @@ public static IServiceCollection AddCertificateManagement(this IServiceCollectio services.AddHostedService(); services.AddAutoMapper(typeof(CertificateManagementMappingProfile)); + + services.AddTransient(); return services; } diff --git a/tilework.core/Services/CertificateManagement/CertificateManagementService.cs b/tilework.core/Services/CertificateManagement/CertificateManagementService.cs index 8d35180..c5dd2bc 100644 --- a/tilework.core/Services/CertificateManagement/CertificateManagementService.cs +++ b/tilework.core/Services/CertificateManagement/CertificateManagementService.cs @@ -7,12 +7,12 @@ using System.Text.Json; using AutoMapper; +using Coravel.Events.Interfaces; - +using Tilework.Events; using Tilework.Persistence.CertificateManagement.Models; using Tilework.CertificateManagement.Models; using Tilework.CertificateManagement.Interfaces; -using Tilework.Core.Interfaces; using Tilework.CertificateManagement.Enums; using Tilework.Core.Persistence; @@ -25,8 +25,10 @@ public class CertificateManagementService : ICertificateManagementService private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; private readonly IMapper _mapper; + private readonly IDispatcher _dispatcher; public CertificateManagementService(TileworkContext dbContext, + IDispatcher dispatcher, IMapper mapper, IOptions settings, ILogger logger, @@ -36,6 +38,7 @@ public CertificateManagementService(TileworkContext dbContext, _logger = logger; _settings = settings.Value; _serviceProvider = serviceProvider; + _dispatcher = dispatcher; _mapper = mapper; } @@ -234,9 +237,58 @@ private async Task RevokeCertificate(Certificate certificate) public async Task RevokeCertificate(Guid Id) { var certificate = await _dbContext.Certificates.FindAsync(Id); + if(certificate == null) + throw new ArgumentException($"Certificate {Id} not found"); + await RevokeCertificate(certificate); } + private async Task RenewCertificate(Certificate certificate) + { + try + { + certificate.Status = CertificateStatus.NEW; + certificate.PrivateKey = GenerateKey(certificate.PrivateKey.Algorithm); + await SignCertificate(certificate); + } + catch + { + _dbContext.ChangeTracker.Clear(); + throw; + } + + var cert = _mapper.Map(certificate); + + var evt = new CertificateRenewed(cert); + await _dispatcher.Broadcast(evt); + + return cert; + } + + public async Task RenewCertificate(Guid Id) + { + var certificate = await _dbContext.Certificates.FindAsync(Id); + if(certificate == null) + throw new ArgumentException($"Certificate {Id} not found"); + + return await RenewCertificate(certificate); + } + + public async Task RenewExpiringCertificates() + { + var renewalThreshold = DateTimeOffset.UtcNow.Add(_settings.CertRenewalLeadTime); + + var certs = await _dbContext.Certificates + .Where(c => c.ExpiresAtUtc.HasValue && c.ExpiresAtUtc <= renewalThreshold) + .ToListAsync(); + + foreach(var cert in certs) + { + _logger.LogInformation($"Certificate [{cert.Name}] needs renewal. Renewing"); + await RenewCertificate(cert.Id); + } + } + public async Task DeleteCertificate(Guid Id) { var certificate = await _dbContext.Certificates.FindAsync(Id); diff --git a/tilework.ui/appsettings.json b/tilework.ui/appsettings.json index a3a0168..db5a6dd 100644 --- a/tilework.ui/appsettings.json +++ b/tilework.ui/appsettings.json @@ -11,7 +11,8 @@ "BackendImage" : "tilework/loadbalancer-haproxy:latest" }, "CertificateManagement": { - "AcmeVerificationImage" : "tilework/acmevalidator:latest" + "AcmeVerificationImage" : "tilework/acmevalidator:latest", + "CertRenewalLeadTime" : "7.00:00:00" }, "ConnectionStrings": { "DefaultConnection" : "Data Source=/var/lib/tilework/data.db" From 26c40a83c84fb8592621e2c0ea1bc39f16dfb70c Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Mon, 17 Nov 2025 20:40:35 +0000 Subject: [PATCH 041/104] Added ui method to renew cert --- .../CertificateDetail.razor | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor index 4d9527e..0768f08 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor @@ -122,16 +122,45 @@ } } + private async Task ConfirmRenew() + { + var parameters = new DialogParameters(); + parameters.Add(x => x.ContentText, "Do you want to renew the certificate?"); + parameters.Add(x => x.ButtonText, "Renew"); + parameters.Add(x => x.Color, Color.Primary); + + var options = new DialogOptions() { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + + var dialog = _dialogService.Show("Renew", parameters, options); + var result = await dialog.Result; + if (!result.Canceled) + { + try { + await _certificateManagementService.RenewCertificate(_item.Id); + await RefreshItem(); + _snackbar.Add("Certificate renewed successfully!", Severity.Success); + } + catch(Exception ex) + { + _snackbar.Add($"Failed to renew certificate: {ex.Message}", Severity.Error); + } + } + } + private void SetActions() { - _actions = new List + _actions = new List(); + + if(_item.Status != CertificateStatus.REVOKED) { - new ActionItem() { Name = "Delete", OnClick = ConfirmDelete } - }; + _actions.Add(new ActionItem() { Name = "Renew", OnClick = ConfirmRenew }); + } if(_item.Status == CertificateStatus.ACTIVE) { _actions.Add(new ActionItem() { Name = "Revoke", OnClick = ConfirmRevoke }); } + + _actions.Add(new ActionItem() { Name = "Delete", OnClick = ConfirmDelete }); } } From d1f2b78989ae8953b4ef015c80d62a4d8357e13b Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Tue, 18 Nov 2025 18:10:31 +0000 Subject: [PATCH 042/104] Removed legacy background monitoring job --- .../Initializers/LoadBalancingInitializer.cs | 8 ---- .../Initializers/MonitoringInitializer.cs | 1 - .../LoadBalancerMonitoringJob.cs | 38 ------------------- tilework.core/ServiceCollectionExtensions.cs | 2 - 4 files changed, 49 deletions(-) delete mode 100644 tilework.core/Jobs/LoadBalancing/LoadBalancerMonitoringJob.cs diff --git a/tilework.core/Initializers/LoadBalancingInitializer.cs b/tilework.core/Initializers/LoadBalancingInitializer.cs index 94d88cc..41fa28e 100644 --- a/tilework.core/Initializers/LoadBalancingInitializer.cs +++ b/tilework.core/Initializers/LoadBalancingInitializer.cs @@ -5,7 +5,6 @@ using Coravel; using Tilework.LoadBalancing.Interfaces; -using Tilework.Core.Jobs.LoadBalancing; using Tilework.Events; namespace Tilework.LoadBalancing.Services; @@ -30,13 +29,6 @@ public async Task StartAsync(CancellationToken ct) var loadBalancerService = scope.ServiceProvider.GetRequiredService(); await loadBalancerService.ApplyConfiguration(); - scope.ServiceProvider.UseScheduler(s => - { - s.Schedule() - .EveryMinute() - .PreventOverlapping("LoadBalancerMonitoringJob"); - }); - var events = scope.ServiceProvider.ConfigureEvents(); events.Register() .Subscribe(); diff --git a/tilework.core/Initializers/MonitoringInitializer.cs b/tilework.core/Initializers/MonitoringInitializer.cs index 0e01594..878f56d 100644 --- a/tilework.core/Initializers/MonitoringInitializer.cs +++ b/tilework.core/Initializers/MonitoringInitializer.cs @@ -5,7 +5,6 @@ using Coravel; using Tilework.LoadBalancing.Interfaces; -using Tilework.Core.Jobs.LoadBalancing; namespace Tilework.LoadBalancing.Services; diff --git a/tilework.core/Jobs/LoadBalancing/LoadBalancerMonitoringJob.cs b/tilework.core/Jobs/LoadBalancing/LoadBalancerMonitoringJob.cs deleted file mode 100644 index 74d459e..0000000 --- a/tilework.core/Jobs/LoadBalancing/LoadBalancerMonitoringJob.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Coravel.Invocable; -using Microsoft.Extensions.Logging; -using Tilework.LoadBalancing.Interfaces; - -namespace Tilework.Core.Jobs.LoadBalancing; - -public class LoadBalancerMonitoringJob : IInvocable -{ - private readonly ILoadBalancerService _loadBalancerService; - private readonly ILoadBalancerStatisticsService _statisticsService; - private readonly ILogger _logger; - public LoadBalancerMonitoringJob(ILoadBalancerService loadBalancerService, - ILoadBalancerStatisticsService statisticsService, - ILogger logger) - { - _loadBalancerService = loadBalancerService; - _statisticsService = statisticsService; - _logger = logger; - } - - public async Task Invoke() - { - var balancers = await _loadBalancerService.GetLoadBalancers(); - - _logger.LogInformation($"Fetching load balancer statistics"); - foreach (var balancer in balancers.Where(b => b.Enabled == true)) - { - try - { - await _statisticsService.FetchStatistics(balancer.Id); - } - catch(Exception ex) - { - _logger.LogError($"Failed to fetch statistics for balancer {balancer.Id}: {ex.Message}"); - } - } - } -} diff --git a/tilework.core/ServiceCollectionExtensions.cs b/tilework.core/ServiceCollectionExtensions.cs index ba4856f..6367f0b 100644 --- a/tilework.core/ServiceCollectionExtensions.cs +++ b/tilework.core/ServiceCollectionExtensions.cs @@ -18,7 +18,6 @@ using Tilework.CertificateManagement.Models; using Tilework.Core.Persistence; -using Tilework.Core.Jobs.LoadBalancing; using Tilework.Monitoring.Interfaces; using Tilework.Monitoring.Telegraf; @@ -83,7 +82,6 @@ public static IServiceCollection AddLoadBalancing(this IServiceCollection servic services.AddAutoMapper(typeof(LoadBalancingMappingProfile)); - services.AddTransient(); services.AddTransient(); return services; From ed8ce689945ca69a7c9d0b1e147fe19ddded7cb6 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Tue, 18 Nov 2025 18:52:24 +0000 Subject: [PATCH 043/104] Cleanup of previous monitoring implementation --- .../ILoadBalancerStatisticsService.cs | 10 - .../20250828180030_LbStatistics.Designer.cs | 432 ----------------- .../Migrations/20250828180030_LbStatistics.cs | 47 -- ...830103524_LbStatisticsDuration.Designer.cs | 441 ------------------ .../20250830103524_LbStatisticsDuration.cs | 30 -- ....cs => 20251118184947_Initial.Designer.cs} | 2 +- ...1_Initial.cs => 20251118184947_Initial.cs} | 0 .../TileworkContextModelSnapshot.cs | 63 --- .../LoadBalancingStatisticsDTO.cs | 7 - tilework.core/Persistence/DbContext.cs | 16 - .../LoadBalancing/LoadBalancerStatistics.cs | 19 - tilework.core/ServiceCollectionExtensions.cs | 1 - .../LoadBalancerStatisticsService.cs | 119 ----- .../LoadBalancing/LoadBalancerDetail.razor | 29 -- 14 files changed, 1 insertion(+), 1215 deletions(-) delete mode 100644 tilework.core/Interfaces/LoadBalancing/ILoadBalancerStatisticsService.cs delete mode 100644 tilework.core/Migrations/20250828180030_LbStatistics.Designer.cs delete mode 100644 tilework.core/Migrations/20250828180030_LbStatistics.cs delete mode 100644 tilework.core/Migrations/20250830103524_LbStatisticsDuration.Designer.cs delete mode 100644 tilework.core/Migrations/20250830103524_LbStatisticsDuration.cs rename tilework.core/Migrations/{20250818162411_Initial.Designer.cs => 20251118184947_Initial.Designer.cs} (99%) rename tilework.core/Migrations/{20250818162411_Initial.cs => 20251118184947_Initial.cs} (100%) delete mode 100644 tilework.core/Models/LoadBalancing/LoadBalancingStatisticsDTO.cs delete mode 100644 tilework.core/Persistence/Entities/LoadBalancing/LoadBalancerStatistics.cs delete mode 100644 tilework.core/Services/LoadBalancing/LoadBalancerStatisticsService.cs diff --git a/tilework.core/Interfaces/LoadBalancing/ILoadBalancerStatisticsService.cs b/tilework.core/Interfaces/LoadBalancing/ILoadBalancerStatisticsService.cs deleted file mode 100644 index 6840cce..0000000 --- a/tilework.core/Interfaces/LoadBalancing/ILoadBalancerStatisticsService.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Tilework.LoadBalancing.Models; - -namespace Tilework.LoadBalancing.Interfaces; - -public interface ILoadBalancerStatisticsService -{ - public Task> GetStatistics(Guid Id, DateTimeOffset start, DateTimeOffset end); - public Task FetchStatistics(Guid Id); -} - diff --git a/tilework.core/Migrations/20250828180030_LbStatistics.Designer.cs b/tilework.core/Migrations/20250828180030_LbStatistics.Designer.cs deleted file mode 100644 index 586c4b5..0000000 --- a/tilework.core/Migrations/20250828180030_LbStatistics.Designer.cs +++ /dev/null @@ -1,432 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Tilework.Core.Persistence; - -#nullable disable - -namespace tilework.core.Migrations -{ - [DbContext(typeof(TileworkContext))] - [Migration("20250828180030_LbStatistics")] - partial class LbStatistics - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.8") - .HasAnnotation("Proxies:ChangeTracking", false) - .HasAnnotation("Proxies:CheckEquality", false) - .HasAnnotation("Proxies:LazyLoading", true); - - modelBuilder.Entity("LoadBalancerCertificates", b => - { - b.Property("BalancerId") - .HasColumnType("TEXT"); - - b.Property("CertificateId") - .HasColumnType("TEXT"); - - b.HasKey("BalancerId", "CertificateId"); - - b.HasIndex("CertificateId"); - - b.ToTable("LoadBalancerCertificates"); - }); - - modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.Certificate", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("AuthorityId") - .HasColumnType("TEXT"); - - b.Property("CertificateDataString") - .HasColumnType("TEXT"); - - b.Property("ExpiresAtUtc") - .HasColumnType("INTEGER"); - - b.Property("Fqdn") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("PrivateKeyId") - .HasColumnType("TEXT"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("AuthorityId"); - - b.HasIndex("Name") - .IsUnique(); - - b.HasIndex("PrivateKeyId"); - - b.ToTable("Certificates"); - }); - - modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.CertificateAuthority", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Parameters") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Type") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("CertificateAuthorities"); - }); - - modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.PrivateKey", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Algorithm") - .HasColumnType("INTEGER"); - - b.Property("KeyDataString") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("PrivateKeys"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Discriminator") - .IsRequired() - .HasMaxLength(34) - .HasColumnType("TEXT"); - - b.Property("Enabled") - .HasColumnType("INTEGER"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Port") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("LoadBalancers"); - - b.HasDiscriminator().HasValue("BaseLoadBalancer"); - - b.UseTphMappingStrategy(); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.LoadBalancerStatistics", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("LoadBalancerId") - .HasColumnType("TEXT"); - - b.Property("Timestamp") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("LoadBalancerId"); - - b.ToTable("LoadBalancerStatistics"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Rule", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("LoadBalancerId") - .HasColumnType("TEXT"); - - b.Property("Priority") - .HasColumnType("INTEGER"); - - b.Property("TargetGroupId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("LoadBalancerId"); - - b.HasIndex("TargetGroupId"); - - b.HasIndex("Priority", "LoadBalancerId") - .IsUnique(); - - b.ToTable("Rules"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Target", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Host") - .IsRequired() - .HasMaxLength(253) - .HasColumnType("TEXT"); - - b.Property("Port") - .HasColumnType("INTEGER"); - - b.Property("TargetGroupId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("TargetGroupId", "Host", "Port") - .IsUnique(); - - b.ToTable("Targets"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.TargetGroup", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Protocol") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("TargetGroups"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", b => - { - b.HasBaseType("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer"); - - b.Property("Protocol") - .HasColumnType("INTEGER"); - - b.HasDiscriminator().HasValue("ApplicationLoadBalancer"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.NetworkLoadBalancer", b => - { - b.HasBaseType("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer"); - - b.Property("Protocol") - .HasColumnType("INTEGER"); - - b.Property("TargetGroupId") - .HasColumnType("TEXT"); - - b.HasIndex("TargetGroupId"); - - b.ToTable("LoadBalancers", t => - { - t.Property("Protocol") - .HasColumnName("NetworkLoadBalancer_Protocol"); - }); - - b.HasDiscriminator().HasValue("NetworkLoadBalancer"); - }); - - modelBuilder.Entity("LoadBalancerCertificates", b => - { - b.HasOne("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer", null) - .WithMany() - .HasForeignKey("BalancerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Tilework.Persistence.CertificateManagement.Models.Certificate", null) - .WithMany() - .HasForeignKey("CertificateId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.Certificate", b => - { - b.HasOne("Tilework.Persistence.CertificateManagement.Models.CertificateAuthority", "Authority") - .WithMany() - .HasForeignKey("AuthorityId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("Tilework.Persistence.CertificateManagement.Models.PrivateKey", "PrivateKey") - .WithMany() - .HasForeignKey("PrivateKeyId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Authority"); - - b.Navigation("PrivateKey"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.LoadBalancerStatistics", b => - { - b.HasOne("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer", "LoadBalancer") - .WithMany() - .HasForeignKey("LoadBalancerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.OwnsOne("Tilework.LoadBalancing.Models.LoadBalancingStatistics", "Statistics", b1 => - { - b1.Property("LoadBalancerStatisticsId") - .HasColumnType("TEXT"); - - b1.Property("CurrentQueue") - .HasColumnType("INTEGER"); - - b1.Property("CurrentSessions") - .HasColumnType("INTEGER"); - - b1.HasKey("LoadBalancerStatisticsId"); - - b1.ToTable("LoadBalancerStatistics"); - - b1.ToJson("Statistics"); - - b1.WithOwner() - .HasForeignKey("LoadBalancerStatisticsId"); - }); - - b.Navigation("LoadBalancer"); - - b.Navigation("Statistics") - .IsRequired(); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Rule", b => - { - b.HasOne("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", "LoadBalancer") - .WithMany("Rules") - .HasForeignKey("LoadBalancerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") - .WithMany() - .HasForeignKey("TargetGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.OwnsMany("Tilework.LoadBalancing.Models.Condition", "Conditions", b1 => - { - b1.Property("RuleId") - .HasColumnType("TEXT"); - - b1.Property("__synthesizedOrdinal") - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("INTEGER"); - - b1.Property("Type") - .HasColumnType("INTEGER"); - - b1.PrimitiveCollection("Values") - .IsRequired() - .HasColumnType("TEXT"); - - b1.HasKey("RuleId", "__synthesizedOrdinal"); - - b1.ToTable("Rules"); - - b1.ToJson("Conditions"); - - b1.WithOwner() - .HasForeignKey("RuleId"); - }); - - b.Navigation("Conditions"); - - b.Navigation("LoadBalancer"); - - b.Navigation("TargetGroup"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Target", b => - { - b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") - .WithMany("Targets") - .HasForeignKey("TargetGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("TargetGroup"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.NetworkLoadBalancer", b => - { - b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") - .WithMany() - .HasForeignKey("TargetGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("TargetGroup"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.TargetGroup", b => - { - b.Navigation("Targets"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", b => - { - b.Navigation("Rules"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/tilework.core/Migrations/20250828180030_LbStatistics.cs b/tilework.core/Migrations/20250828180030_LbStatistics.cs deleted file mode 100644 index b8d5a1b..0000000 --- a/tilework.core/Migrations/20250828180030_LbStatistics.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace tilework.core.Migrations -{ - /// - public partial class LbStatistics : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "LoadBalancerStatistics", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - LoadBalancerId = table.Column(type: "TEXT", nullable: false), - Timestamp = table.Column(type: "INTEGER", nullable: false), - Statistics = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_LoadBalancerStatistics", x => x.Id); - table.ForeignKey( - name: "FK_LoadBalancerStatistics_LoadBalancers_LoadBalancerId", - column: x => x.LoadBalancerId, - principalTable: "LoadBalancers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_LoadBalancerStatistics_LoadBalancerId", - table: "LoadBalancerStatistics", - column: "LoadBalancerId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "LoadBalancerStatistics"); - } - } -} diff --git a/tilework.core/Migrations/20250830103524_LbStatisticsDuration.Designer.cs b/tilework.core/Migrations/20250830103524_LbStatisticsDuration.Designer.cs deleted file mode 100644 index 1fef90a..0000000 --- a/tilework.core/Migrations/20250830103524_LbStatisticsDuration.Designer.cs +++ /dev/null @@ -1,441 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Tilework.Core.Persistence; - -#nullable disable - -namespace tilework.core.Migrations -{ - [DbContext(typeof(TileworkContext))] - [Migration("20250830103524_LbStatisticsDuration")] - partial class LbStatisticsDuration - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.8") - .HasAnnotation("Proxies:ChangeTracking", false) - .HasAnnotation("Proxies:CheckEquality", false) - .HasAnnotation("Proxies:LazyLoading", true); - - modelBuilder.Entity("LoadBalancerCertificates", b => - { - b.Property("BalancerId") - .HasColumnType("TEXT"); - - b.Property("CertificateId") - .HasColumnType("TEXT"); - - b.HasKey("BalancerId", "CertificateId"); - - b.HasIndex("CertificateId"); - - b.ToTable("LoadBalancerCertificates"); - }); - - modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.Certificate", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("AuthorityId") - .HasColumnType("TEXT"); - - b.Property("CertificateDataString") - .HasColumnType("TEXT"); - - b.Property("ExpiresAtUtc") - .HasColumnType("INTEGER"); - - b.Property("Fqdn") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("PrivateKeyId") - .HasColumnType("TEXT"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("AuthorityId"); - - b.HasIndex("Name") - .IsUnique(); - - b.HasIndex("PrivateKeyId"); - - b.ToTable("Certificates"); - }); - - modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.CertificateAuthority", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Parameters") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Type") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("CertificateAuthorities"); - }); - - modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.PrivateKey", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Algorithm") - .HasColumnType("INTEGER"); - - b.Property("KeyDataString") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("PrivateKeys"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Discriminator") - .IsRequired() - .HasMaxLength(34) - .HasColumnType("TEXT"); - - b.Property("Enabled") - .HasColumnType("INTEGER"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Port") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("LoadBalancers"); - - b.HasDiscriminator().HasValue("BaseLoadBalancer"); - - b.UseTphMappingStrategy(); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.LoadBalancerStatistics", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Duration") - .HasColumnType("TEXT"); - - b.Property("LoadBalancerId") - .HasColumnType("TEXT"); - - b.Property("Timestamp") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("LoadBalancerId"); - - b.ToTable("LoadBalancerStatistics"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Rule", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("LoadBalancerId") - .HasColumnType("TEXT"); - - b.Property("Priority") - .HasColumnType("INTEGER"); - - b.Property("TargetGroupId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("LoadBalancerId"); - - b.HasIndex("TargetGroupId"); - - b.HasIndex("Priority", "LoadBalancerId") - .IsUnique(); - - b.ToTable("Rules"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Target", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Host") - .IsRequired() - .HasMaxLength(253) - .HasColumnType("TEXT"); - - b.Property("Port") - .HasColumnType("INTEGER"); - - b.Property("TargetGroupId") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("TargetGroupId", "Host", "Port") - .IsUnique(); - - b.ToTable("Targets"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.TargetGroup", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Protocol") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("TargetGroups"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", b => - { - b.HasBaseType("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer"); - - b.Property("Protocol") - .HasColumnType("INTEGER"); - - b.HasDiscriminator().HasValue("ApplicationLoadBalancer"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.NetworkLoadBalancer", b => - { - b.HasBaseType("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer"); - - b.Property("Protocol") - .HasColumnType("INTEGER"); - - b.Property("TargetGroupId") - .HasColumnType("TEXT"); - - b.HasIndex("TargetGroupId"); - - b.ToTable("LoadBalancers", t => - { - t.Property("Protocol") - .HasColumnName("NetworkLoadBalancer_Protocol"); - }); - - b.HasDiscriminator().HasValue("NetworkLoadBalancer"); - }); - - modelBuilder.Entity("LoadBalancerCertificates", b => - { - b.HasOne("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer", null) - .WithMany() - .HasForeignKey("BalancerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Tilework.Persistence.CertificateManagement.Models.Certificate", null) - .WithMany() - .HasForeignKey("CertificateId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.Certificate", b => - { - b.HasOne("Tilework.Persistence.CertificateManagement.Models.CertificateAuthority", "Authority") - .WithMany() - .HasForeignKey("AuthorityId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("Tilework.Persistence.CertificateManagement.Models.PrivateKey", "PrivateKey") - .WithMany() - .HasForeignKey("PrivateKeyId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Authority"); - - b.Navigation("PrivateKey"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.LoadBalancerStatistics", b => - { - b.HasOne("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer", "LoadBalancer") - .WithMany() - .HasForeignKey("LoadBalancerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.OwnsOne("Tilework.LoadBalancing.Models.LoadBalancingStatistics", "Statistics", b1 => - { - b1.Property("LoadBalancerStatisticsId") - .HasColumnType("TEXT"); - - b1.Property("CurrentQueue") - .HasColumnType("INTEGER"); - - b1.Property("CurrentSessions") - .HasColumnType("INTEGER"); - - b1.Property("TotalSessions") - .HasColumnType("INTEGER"); - - b1.Property("Uptime") - .HasColumnType("TEXT"); - - b1.HasKey("LoadBalancerStatisticsId"); - - b1.ToTable("LoadBalancerStatistics"); - - b1.ToJson("Statistics"); - - b1.WithOwner() - .HasForeignKey("LoadBalancerStatisticsId"); - }); - - b.Navigation("LoadBalancer"); - - b.Navigation("Statistics") - .IsRequired(); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Rule", b => - { - b.HasOne("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", "LoadBalancer") - .WithMany("Rules") - .HasForeignKey("LoadBalancerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") - .WithMany() - .HasForeignKey("TargetGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.OwnsMany("Tilework.LoadBalancing.Models.Condition", "Conditions", b1 => - { - b1.Property("RuleId") - .HasColumnType("TEXT"); - - b1.Property("__synthesizedOrdinal") - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("INTEGER"); - - b1.Property("Type") - .HasColumnType("INTEGER"); - - b1.PrimitiveCollection("Values") - .IsRequired() - .HasColumnType("TEXT"); - - b1.HasKey("RuleId", "__synthesizedOrdinal"); - - b1.ToTable("Rules"); - - b1.ToJson("Conditions"); - - b1.WithOwner() - .HasForeignKey("RuleId"); - }); - - b.Navigation("Conditions"); - - b.Navigation("LoadBalancer"); - - b.Navigation("TargetGroup"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Target", b => - { - b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") - .WithMany("Targets") - .HasForeignKey("TargetGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("TargetGroup"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.NetworkLoadBalancer", b => - { - b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") - .WithMany() - .HasForeignKey("TargetGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("TargetGroup"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.TargetGroup", b => - { - b.Navigation("Targets"); - }); - - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", b => - { - b.Navigation("Rules"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/tilework.core/Migrations/20250830103524_LbStatisticsDuration.cs b/tilework.core/Migrations/20250830103524_LbStatisticsDuration.cs deleted file mode 100644 index ed8ef26..0000000 --- a/tilework.core/Migrations/20250830103524_LbStatisticsDuration.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace tilework.core.Migrations -{ - /// - public partial class LbStatisticsDuration : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Duration", - table: "LoadBalancerStatistics", - type: "TEXT", - nullable: false, - defaultValue: new TimeSpan(0, 0, 0, 0, 0)); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Duration", - table: "LoadBalancerStatistics"); - } - } -} diff --git a/tilework.core/Migrations/20250818162411_Initial.Designer.cs b/tilework.core/Migrations/20251118184947_Initial.Designer.cs similarity index 99% rename from tilework.core/Migrations/20250818162411_Initial.Designer.cs rename to tilework.core/Migrations/20251118184947_Initial.Designer.cs index 85b4ed8..16ec862 100644 --- a/tilework.core/Migrations/20250818162411_Initial.Designer.cs +++ b/tilework.core/Migrations/20251118184947_Initial.Designer.cs @@ -11,7 +11,7 @@ namespace tilework.core.Migrations { [DbContext(typeof(TileworkContext))] - [Migration("20250818162411_Initial")] + [Migration("20251118184947_Initial")] partial class Initial { /// diff --git a/tilework.core/Migrations/20250818162411_Initial.cs b/tilework.core/Migrations/20251118184947_Initial.cs similarity index 100% rename from tilework.core/Migrations/20250818162411_Initial.cs rename to tilework.core/Migrations/20251118184947_Initial.cs diff --git a/tilework.core/Migrations/TileworkContextModelSnapshot.cs b/tilework.core/Migrations/TileworkContextModelSnapshot.cs index bf1954a..5f15fd1 100644 --- a/tilework.core/Migrations/TileworkContextModelSnapshot.cs +++ b/tilework.core/Migrations/TileworkContextModelSnapshot.cs @@ -153,28 +153,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.UseTphMappingStrategy(); }); - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.LoadBalancerStatistics", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Duration") - .HasColumnType("TEXT"); - - b.Property("LoadBalancerId") - .HasColumnType("TEXT"); - - b.Property("Timestamp") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("LoadBalancerId"); - - b.ToTable("LoadBalancerStatistics"); - }); - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Rule", b => { b.Property("Id") @@ -313,47 +291,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("PrivateKey"); }); - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.LoadBalancerStatistics", b => - { - b.HasOne("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer", "LoadBalancer") - .WithMany() - .HasForeignKey("LoadBalancerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.OwnsOne("Tilework.LoadBalancing.Models.LoadBalancingStatistics", "Statistics", b1 => - { - b1.Property("LoadBalancerStatisticsId") - .HasColumnType("TEXT"); - - b1.Property("CurrentQueue") - .HasColumnType("INTEGER"); - - b1.Property("CurrentSessions") - .HasColumnType("INTEGER"); - - b1.Property("TotalSessions") - .HasColumnType("INTEGER"); - - b1.Property("Uptime") - .HasColumnType("TEXT"); - - b1.HasKey("LoadBalancerStatisticsId"); - - b1.ToTable("LoadBalancerStatistics"); - - b1.ToJson("Statistics"); - - b1.WithOwner() - .HasForeignKey("LoadBalancerStatisticsId"); - }); - - b.Navigation("LoadBalancer"); - - b.Navigation("Statistics") - .IsRequired(); - }); - modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Rule", b => { b.HasOne("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", "LoadBalancer") diff --git a/tilework.core/Models/LoadBalancing/LoadBalancingStatisticsDTO.cs b/tilework.core/Models/LoadBalancing/LoadBalancingStatisticsDTO.cs deleted file mode 100644 index c52a695..0000000 --- a/tilework.core/Models/LoadBalancing/LoadBalancingStatisticsDTO.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Tilework.LoadBalancing.Models; - -public class LoadBalancerStatisticsDTO -{ - public DateTimeOffset Timestamp { get; set; } - public LoadBalancingStatistics Statistics { get; set; } -} \ No newline at end of file diff --git a/tilework.core/Persistence/DbContext.cs b/tilework.core/Persistence/DbContext.cs index 07cba2d..9e4f269 100644 --- a/tilework.core/Persistence/DbContext.cs +++ b/tilework.core/Persistence/DbContext.cs @@ -25,7 +25,6 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) public DbSet Rules { get; set; } public DbSet TargetGroups { get; set; } public DbSet Targets { get; set; } - public DbSet LoadBalancerStatistics { get; set; } // Certificate management @@ -75,21 +74,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ); - modelBuilder.Entity() - .Property(x => x.Timestamp) - .HasConversion( - v => v.ToUnixTimeSeconds(), - v => DateTimeOffset.FromUnixTimeSeconds(v) - ) - .HasColumnType("INTEGER"); - - modelBuilder.Entity() - .OwnsOne(o => o.Statistics, b => - { - b.ToJson(); - }); - - // Certificate management modelBuilder.Entity() .Property(x => x.ExpiresAtUtc) diff --git a/tilework.core/Persistence/Entities/LoadBalancing/LoadBalancerStatistics.cs b/tilework.core/Persistence/Entities/LoadBalancing/LoadBalancerStatistics.cs deleted file mode 100644 index 3e4ca30..0000000 --- a/tilework.core/Persistence/Entities/LoadBalancing/LoadBalancerStatistics.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using System.ComponentModel.DataAnnotations; - -using Tilework.LoadBalancing.Models; - -namespace Tilework.Persistence.LoadBalancing.Models; - -public class LoadBalancerStatistics -{ - public Guid Id { get; set; } - - public Guid LoadBalancerId { get; set; } - public virtual BaseLoadBalancer LoadBalancer { get; set; } - - public DateTimeOffset Timestamp { get; set; } - public TimeSpan Duration { get; set; } - - public LoadBalancingStatistics Statistics { get; set; } -} \ No newline at end of file diff --git a/tilework.core/ServiceCollectionExtensions.cs b/tilework.core/ServiceCollectionExtensions.cs index 6367f0b..14f9331 100644 --- a/tilework.core/ServiceCollectionExtensions.cs +++ b/tilework.core/ServiceCollectionExtensions.cs @@ -72,7 +72,6 @@ public static IServiceCollection AddLoadBalancing(this IServiceCollection servic services.AddAutoMapper(typeof(HAProxyMonitoringProfile)); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/tilework.core/Services/LoadBalancing/LoadBalancerStatisticsService.cs b/tilework.core/Services/LoadBalancing/LoadBalancerStatisticsService.cs deleted file mode 100644 index c09cbb5..0000000 --- a/tilework.core/Services/LoadBalancing/LoadBalancerStatisticsService.cs +++ /dev/null @@ -1,119 +0,0 @@ -using Microsoft.Extensions.Options; -using Microsoft.Extensions.Logging; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; - -using Tilework.LoadBalancing.Interfaces; -using Tilework.LoadBalancing.Models; -using Tilework.Core.Persistence; -using Tilework.Persistence.LoadBalancing.Models; -using Tilework.LoadBalancing.Haproxy; - -namespace Tilework.LoadBalancing.Services; - -public class LoadBalancerStatisticsService : ILoadBalancerStatisticsService -{ - private readonly TileworkContext _dbContext; - private readonly LoadBalancerConfiguration _settings; - private readonly ILoadBalancingMonitor _monitor; - private readonly ILogger _logger; - - public LoadBalancerStatisticsService(IServiceProvider serviceProvider, - TileworkContext dbContext, - IOptions settings, - ILogger logger) - { - _dbContext = dbContext; - _logger = logger; - _settings = settings.Value; - _monitor = LoadMonitor(serviceProvider, _settings); - } - - private ILoadBalancingMonitor LoadMonitor(IServiceProvider serviceProvider, LoadBalancerConfiguration settings) - { - return settings.Backend switch - { - "haproxy" => serviceProvider.GetRequiredService(), - _ => throw new ArgumentException($"Invalid monitor in load balancing tile: {settings.Backend}") - }; - } - - public async Task> GetStatistics(Guid Id, DateTimeOffset start, DateTimeOffset end) - { - return await _dbContext.LoadBalancerStatistics - .AsNoTracking() - .Where(lbs => lbs.LoadBalancerId == Id && lbs.Timestamp >= start && lbs.Timestamp <= end) - .Select(s => new LoadBalancerStatisticsDTO() - { - Timestamp = s.Timestamp, - Statistics = s.Statistics - }).ToListAsync(); - } - - public async Task FetchStatistics(Guid Id) - { - var entity = await _dbContext.LoadBalancers.FindAsync(Id); - if (entity == null) - throw new ArgumentNullException("Non-existent load balancer"); - - var statistics = await _monitor.GetRealtimeStatistics(entity); - - var last_statistics = await _dbContext.LoadBalancerStatistics.Where(s => s.LoadBalancerId == Id) - .OrderByDescending(s => s.Timestamp) - .FirstOrDefaultAsync(); - - TimeSpan duration = TimeSpan.Zero; - string? msg = null; - - var current_timestamp = DateTimeOffset.UtcNow; - - if (last_statistics != null) - { - // If duration between timestamps is bigger than monitoring interval, assume that - // monitoring stopped for some time. Restart monitoring - if (current_timestamp - last_statistics.Timestamp > TimeSpan.FromSeconds(60 + 10)) - { - msg = "monitoring interrupted"; - duration = TimeSpan.Zero; - } - - duration = statistics.Uptime - last_statistics.Statistics.Uptime; - - // If current uptime is smaller than previous, there was a restart - if (duration < TimeSpan.Zero) - { - msg = "smaller uptime"; - duration = TimeSpan.Zero; - } - - // If duration between uptimes and duration between timestamps have a variation of more than +-10s - // there was a restart - if (Math.Abs((last_statistics.Timestamp + duration - current_timestamp).TotalSeconds) > 10) - { - msg = "duration variation"; - duration = TimeSpan.Zero; - } - } - else - { - msg = "no prev statistics"; - } - - if (duration == TimeSpan.Zero && msg != null) - { - _logger.LogDebug($"Detected load balancer {Id} restart during collection of statistics ({msg}). Restarting statistics collection"); - } - - var statisticsPoint = new LoadBalancerStatistics() - { - Duration = duration, - LoadBalancer = entity, - Timestamp = current_timestamp, - Statistics = (duration != TimeSpan.Zero && last_statistics != null) ? statistics - last_statistics.Statistics : statistics - }; - - await _dbContext.LoadBalancerStatistics.AddAsync(statisticsPoint); - await _dbContext.SaveChangesAsync(); - } -} - diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index a7c24cc..6992e4f 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -9,7 +9,6 @@ @namespace Tilework.Ui.Components.Pages @inject ILoadBalancerService _loadBalancerService -@inject ILoadBalancerStatisticsService _statisticsService @inject IDialogService _dialogService @inject NavigationManager _navigationManager @inject ISnackbar _snackbar @@ -484,34 +483,6 @@ { if(_item != null && _item.Enabled == true && index == GetMonitoringTabIndex()) { - _statsLoading = true; - StateHasChanged(); - - var stats = await _statisticsService.GetStatistics(_item.Id, DateTimeOffset.UtcNow - TimeSpan.FromHours(1), DateTimeOffset.UtcNow); - - // Order by timestamp to ensure correct sequence - var statistics = stats.OrderBy(s => s.Timestamp).ToList(); - - _chartLabels = statistics - .Select(s => - { - var t = s.Timestamp.ToLocalTime(); - return t.Minute % 5 == 0 ? t.ToString("HH:mm") : ""; - }) - .ToArray(); - - var dataset = statistics.Select(s => (double)(s.Statistics?.TotalSessions ?? 0)).ToArray(); - - _series = new List() - { - new ChartSeries() { - Name = "Sessions", - Data = dataset - } - }; - - _statsLoading = false; - StateHasChanged(); } } } From 308a72ab317ce5d408a5c4530651dc58f21117f3 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Tue, 18 Nov 2025 18:53:01 +0000 Subject: [PATCH 044/104] cleaner name for monitoring name --- .../LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs index d1253b8..d21cbd3 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs @@ -232,7 +232,7 @@ public async Task ApplyConfiguration(List config) { var monitoringSource = new MonitoringSource() { - Name = lb.Name, + Name = $"LoadBalancing-{lb.Id}", Type = MonitoringSourceType.HAPROXY, Host = Host.Parse((await _containerManager.GetContainerAddress(container.Id)).ToString()), Port = 4380 From 621df2275d8eccc990d7ddcd6f5440101db2680c Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 23 Nov 2025 11:22:32 +0000 Subject: [PATCH 045/104] Refactoring statistics --- .../Monitoring/LoadBalancingStatistics.cs | 115 +----------------- .../Mappers/HAProxyMonitoringProfile.cs | 6 +- .../{ => Config}/HAProxyStatisticsRow.cs | 0 .../{ => Config}/Sections/BackendSection.cs | 0 .../{ => Config}/Sections/ConfigSection.cs | 0 .../Sections/ConfigSectionUtils.cs | 0 .../{ => Config}/Sections/DefaultsSection.cs | 0 .../{ => Config}/Sections/FrontendSection.cs | 0 .../{ => Config}/Sections/GlobalSection.cs | 0 .../Models/{ => Config}/Statements/Acl.cs | 0 .../Models/{ => Config}/Statements/Bind.cs | 0 .../{ => Config}/Statements/HttpHeader.cs | 0 .../Models/{ => Config}/Statements/Server.cs | 0 .../Statements/StatementAttribute.cs | 0 .../{ => Config}/Statements/UseBackend.cs | 0 .../Models/{ => Monitoring}/HAProxyInfo.cs | 0 16 files changed, 4 insertions(+), 117 deletions(-) rename tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/{ => Config}/HAProxyStatisticsRow.cs (100%) rename tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/{ => Config}/Sections/BackendSection.cs (100%) rename tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/{ => Config}/Sections/ConfigSection.cs (100%) rename tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/{ => Config}/Sections/ConfigSectionUtils.cs (100%) rename tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/{ => Config}/Sections/DefaultsSection.cs (100%) rename tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/{ => Config}/Sections/FrontendSection.cs (100%) rename tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/{ => Config}/Sections/GlobalSection.cs (100%) rename tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/{ => Config}/Statements/Acl.cs (100%) rename tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/{ => Config}/Statements/Bind.cs (100%) rename tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/{ => Config}/Statements/HttpHeader.cs (100%) rename tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/{ => Config}/Statements/Server.cs (100%) rename tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/{ => Config}/Statements/StatementAttribute.cs (100%) rename tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/{ => Config}/Statements/UseBackend.cs (100%) rename tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/{ => Monitoring}/HAProxyInfo.cs (100%) diff --git a/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs b/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs index b7c05fe..30ac167 100644 --- a/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs +++ b/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs @@ -3,119 +3,6 @@ namespace Tilework.LoadBalancing.Models; -public class LoadBalancingStatistics +public class LoadBalancingMonitoringData { - public TimeSpan Uptime { get; set; } - // public int? CurrentSessions { get; init; } - // public int? CurrentQueue { get; init; } - - - - // // Queue - - // public int? MaxQueue { get; init; } - // public int? QueueLimit { get; init; } - - // // Sessions / connections - - // public int? MaxSessions { get; init; } - // public int? SessionLimit { get; init; } - [Cumulative] - public long? TotalSessions { get; set; } - - [Cumulative] - public long? BytesIn { get; set; } - [Cumulative] - public long? BytesOut { get; set; } - - - [Cumulative] - public long? DeniedRequests { get; init; } - [Cumulative] - public long? DeniedResponses { get; init; } - [Cumulative] - public long? RequestErrors { get; init; } - [Cumulative] - public long? ConnectionErrors { get; init; } - [Cumulative] - public long? ResponseErrors { get; init; } - // public long? Retries { get; init; } - // public long? Redispatches { get; init; } - - // // HTTP responses - [Cumulative] - public long? Responses1xx { get; set; } - [Cumulative] - public long? Responses2xx { get; set; } - [Cumulative] - public long? Responses3xx { get; set; } - [Cumulative] - public long? Responses4xx { get; set; } - [Cumulative] - public long? Responses5xx { get; set; } - // public long? ResponsesOther { get; init; } - - - public int? AvgQueueTimeMs { get; set; } - public int? AvgConnectTimeMs { get; set; } - public int? AvgResponseTimeMs { get; set; } - public int? AvgTotalTimeMs { get; set; } - - // // Status / health - // public string? Status { get; init; } - // public int? Weight { get; init; } - // public int? ActiveServers { get; init; } - // public int? BackupServers { get; init; } - // public long? FailedChecks { get; init; } - // public long? DowntimeTransitions { get; init; } - // public int? SecondsSinceLastChange { get; init; } - // public long? TotalDowntime { get; init; } - - // // Compression - // public long? CompressedIn { get; init; } - // public long? CompressedOut { get; init; } - // public long? CompressionBypassed { get; init; } - - // // Cache - // public long? CacheHits { get; init; } - // public long? CacheMisses { get; init; } - - public static LoadBalancingStatistics operator -(LoadBalancingStatistics a, LoadBalancingStatistics b) - { - var result = new LoadBalancingStatistics(); - - foreach (var prop in typeof(LoadBalancingStatistics).GetProperties(BindingFlags.Public | BindingFlags.Instance)) - { - if (!prop.CanRead || !prop.CanWrite) continue; - - var aVal = prop.GetValue(a); - var bVal = prop.GetValue(b); - - if (prop.IsDefined(typeof(CumulativeAttribute), inherit: true)) - { - object? diff = null; - - if (prop.PropertyType == typeof(TimeSpan)) - diff = (TimeSpan)aVal - (TimeSpan)bVal; - else if (prop.PropertyType == typeof(long?)) - diff = (aVal is long la && bVal is long lb) ? la - lb : null; - else if (prop.PropertyType == typeof(int?)) - diff = (aVal is int ia && bVal is int ib) ? ia - ib : null; - else if (prop.PropertyType == typeof(double?)) - diff = (aVal is double da && bVal is double db) ? da - db : null; - else if (prop.PropertyType == typeof(decimal?)) - diff = (aVal is decimal dca && bVal is decimal dcb) ? dca - dcb : null; - else - throw new InvalidOperationException($"Cannot process data type {prop.PropertyType}"); - - prop.SetValue(result, diff); - } - else - { - prop.SetValue(result, aVal); - } - } - - return result; - } } \ No newline at end of file diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyMonitoringProfile.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyMonitoringProfile.cs index 709c621..9b069a7 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyMonitoringProfile.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyMonitoringProfile.cs @@ -7,9 +7,9 @@ public class HAProxyMonitoringProfile : Profile { public HAProxyMonitoringProfile() { - CreateMap<(HAProxyInfo, HAProxyStatisticsRow), LoadBalancingStatistics>() - .ForMember(dest => dest.Uptime, opt => opt.MapFrom(src => TimeSpan.FromSeconds(Int32.Parse(src.Item1.Uptime_sec)))) - .ForMember(dest => dest.TotalSessions, opt => opt.MapFrom(src => src.Item2.stot)); + CreateMap<(HAProxyInfo, HAProxyStatisticsRow), LoadBalancingMonitoringData>(); + // .ForMember(dest => dest.Uptime, opt => opt.MapFrom(src => TimeSpan.FromSeconds(Int32.Parse(src.Item1.Uptime_sec)))) + // .ForMember(dest => dest.TotalSessions, opt => opt.MapFrom(src => src.Item2.stot)); // .ForMember(dest => dest.CurrentSessions, opt => opt.MapFrom(src => src.scur)) // .ForPath(dest => dest.CurrentQueue, opt => opt.MapFrom(src => src.qcur)); } diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/HAProxyStatisticsRow.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/HAProxyStatisticsRow.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/HAProxyStatisticsRow.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/HAProxyStatisticsRow.cs diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/BackendSection.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/BackendSection.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/BackendSection.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/BackendSection.cs diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/ConfigSection.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/ConfigSection.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/ConfigSection.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/ConfigSection.cs diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/ConfigSectionUtils.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/ConfigSectionUtils.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/ConfigSectionUtils.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/ConfigSectionUtils.cs diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/DefaultsSection.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/DefaultsSection.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/DefaultsSection.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/DefaultsSection.cs diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/FrontendSection.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/FrontendSection.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/FrontendSection.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/FrontendSection.cs diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/GlobalSection.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/GlobalSection.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Sections/GlobalSection.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/GlobalSection.cs diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Acl.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/Acl.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Acl.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/Acl.cs diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Bind.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/Bind.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Bind.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/Bind.cs diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/HttpHeader.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/HttpHeader.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/HttpHeader.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/HttpHeader.cs diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Server.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/Server.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/Server.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/Server.cs diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/StatementAttribute.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/StatementAttribute.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/StatementAttribute.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/StatementAttribute.cs diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/UseBackend.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/UseBackend.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Statements/UseBackend.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/UseBackend.cs diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/HAProxyInfo.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Monitoring/HAProxyInfo.cs similarity index 100% rename from tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/HAProxyInfo.cs rename to tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Monitoring/HAProxyInfo.cs From f676ed97f675d46234d612702c5cc33c16bcf45e Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Wed, 26 Nov 2025 19:36:19 +0000 Subject: [PATCH 046/104] First kinda working monitoring implementation --- .../LoadBalancing/ILoadBalancerService.cs | 2 + .../LoadBalancing/ILoadBalancingMonitor.cs | 9 -- .../IDataPersistenceConfigurator.cs | 2 + .../Monitoring/LoadBalancingMonitorData.cs | 10 ++ .../Monitoring/LoadBalancingStatistics.cs | 8 -- .../Models/Monitoring/BaseMonitorData.cs | 4 + .../HAProxy/HAProxymonitor.cs | 6 +- .../Mappers/HAProxyMonitoringProfile.cs | 2 +- .../Influxdb/InfluxdbDataPersistence.cs | 96 ++++++++++++++++++- .../Telegraf/TelegrafDataCollector.cs | 2 +- tilework.core/Resources/telegraf.conf | 59 ++++++++++++ tilework.core/ServiceCollectionExtensions.cs | 1 + .../LoadBalancing/LoadBalancerService.cs | 85 ++++++++-------- .../Services/Monitoring/MonitoringService.cs | 23 +++++ tilework.core/tilework.core.csproj | 1 + .../LoadBalancing/LoadBalancerDetail.razor | 28 ++++++ 16 files changed, 274 insertions(+), 64 deletions(-) delete mode 100644 tilework.core/Interfaces/LoadBalancing/ILoadBalancingMonitor.cs create mode 100644 tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingMonitorData.cs delete mode 100644 tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs create mode 100644 tilework.core/Models/Monitoring/BaseMonitorData.cs create mode 100644 tilework.core/Services/Monitoring/MonitoringService.cs diff --git a/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs b/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs index ed9a9b1..c37eed0 100644 --- a/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs +++ b/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs @@ -42,6 +42,8 @@ public interface ILoadBalancerService public Task UpdateTarget(TargetGroupDTO group, TargetDTO target); public Task RemoveTarget(TargetGroupDTO group, TargetDTO target); + public Task> GetMonitoringData(Guid Id, DateTimeOffset start, DateTimeOffset end); + public Task ApplyConfiguration(); public Task Shutdown(); } diff --git a/tilework.core/Interfaces/LoadBalancing/ILoadBalancingMonitor.cs b/tilework.core/Interfaces/LoadBalancing/ILoadBalancingMonitor.cs deleted file mode 100644 index c37fd82..0000000 --- a/tilework.core/Interfaces/LoadBalancing/ILoadBalancingMonitor.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Tilework.LoadBalancing.Models; -using Tilework.Persistence.LoadBalancing.Models; - -namespace Tilework.LoadBalancing.Interfaces; - -public interface ILoadBalancingMonitor -{ - public Task GetRealtimeStatistics(BaseLoadBalancer balancer); -} \ No newline at end of file diff --git a/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs b/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs index 2cda0e3..09e378b 100644 --- a/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs +++ b/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs @@ -6,6 +6,8 @@ public interface IDataPersistenceConfigurator { string ServiceName { get; } Task GetTarget(MonitoringSource source); + Task> GetData(string name, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new(); + Task ApplyConfiguration(); Task Shutdown(); } \ No newline at end of file diff --git a/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingMonitorData.cs b/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingMonitorData.cs new file mode 100644 index 0000000..8c116ba --- /dev/null +++ b/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingMonitorData.cs @@ -0,0 +1,10 @@ +using System.Reflection; +using Tilework.Core.Attributes; + +namespace Tilework.LoadBalancing.Models; + +public class LoadBalancingMonitorData : BaseMonitorData +{ + public int Sessions { get; set; } // stot + // public int Requests { get; set; } // req_tot +} \ No newline at end of file diff --git a/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs b/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs deleted file mode 100644 index 30ac167..0000000 --- a/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingStatistics.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Reflection; -using Tilework.Core.Attributes; - -namespace Tilework.LoadBalancing.Models; - -public class LoadBalancingMonitoringData -{ -} \ No newline at end of file diff --git a/tilework.core/Models/Monitoring/BaseMonitorData.cs b/tilework.core/Models/Monitoring/BaseMonitorData.cs new file mode 100644 index 0000000..8fdeab5 --- /dev/null +++ b/tilework.core/Models/Monitoring/BaseMonitorData.cs @@ -0,0 +1,4 @@ +public class BaseMonitorData +{ + public DateTimeOffset Timestamp { get; set; } +} \ No newline at end of file diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxymonitor.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxymonitor.cs index 5f2adc9..3d812a4 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxymonitor.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxymonitor.cs @@ -15,7 +15,7 @@ namespace Tilework.LoadBalancing.Haproxy; -public class HAProxyMonitor : ILoadBalancingMonitor +public class HAProxyMonitor { private readonly IContainerManager _containerManager; private readonly HAProxyConfigurator _configurator; @@ -108,7 +108,7 @@ private async Task SendReceiveCommandKv(string hostname, int port, string return JsonSerializer.Deserialize(jsonString); } - public async Task GetRealtimeStatistics(BaseLoadBalancer balancer) + public async Task GetRealtimeStatistics(BaseLoadBalancer balancer) { if (await _configurator.CheckLoadBalancerStatus(balancer) == false) throw new ArgumentOutOfRangeException($"Cannot get statistics for balancer {balancer}: Balancer is not running"); @@ -121,6 +121,6 @@ public async Task GetRealtimeStatistics(BaseLoadBalance var balancerStats = stats.First(r => r.svname == "FRONTEND" && r.pxname == balancer.Id.ToString()); - return _mapper.Map((info, balancerStats)); + // TODO: Should return here... } } diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyMonitoringProfile.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyMonitoringProfile.cs index 9b069a7..7bee896 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyMonitoringProfile.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyMonitoringProfile.cs @@ -7,7 +7,7 @@ public class HAProxyMonitoringProfile : Profile { public HAProxyMonitoringProfile() { - CreateMap<(HAProxyInfo, HAProxyStatisticsRow), LoadBalancingMonitoringData>(); + CreateMap<(HAProxyInfo, HAProxyStatisticsRow), LoadBalancingMonitorData>(); // .ForMember(dest => dest.Uptime, opt => opt.MapFrom(src => TimeSpan.FromSeconds(Int32.Parse(src.Item1.Uptime_sec)))) // .ForMember(dest => dest.TotalSessions, opt => opt.MapFrom(src => src.Item2.stot)); // .ForMember(dest => dest.CurrentSessions, opt => opt.MapFrom(src => src.scur)) diff --git a/tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs b/tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs index 54e51ca..f11b77f 100644 --- a/tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs +++ b/tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs @@ -1,8 +1,13 @@ -using System; -using System.Linq; +using System.Globalization; +using System.Reflection; + using Microsoft.Extensions.Options; using Microsoft.Extensions.Logging; + using AutoMapper; +using InfluxDB3.Client; +using InfluxDB3.Client.Query; +using InfluxDB3.Client.Write; using Tilework.Core.Interfaces; using Tilework.Core.Models; @@ -132,23 +137,106 @@ public async Task Shutdown() } private async Task GetApiService() + { + var host = GetHost(); + return _apiFactory.GetApiService($"{host}/api/v3/"); + } + + private async Task GetHost() { var container = await GetContainer(); var host = Host.Parse((await _containerManager.GetContainerAddress(container.Id)).ToString()); - - return _apiFactory.GetApiService($"http://{host.Value}:8181/api/v3/"); + return $"http://{host.Value}:8181"; } private async Task GetAdminToken() { if (_adminToken != null) + { + _logger.LogInformation($"Admin token: {_adminToken}"); return _adminToken; + } var container = await GetContainer(); var result = await _containerManager.ExecuteContainerCommand(container.Id, "get_token.sh"); _adminToken = result.Stdout; + _logger.LogInformation($"Admin token: {_adminToken}"); return _adminToken; } + + public async Task> GetData(string name, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new() + { + using var client = new InfluxDBClient(await GetHost(), token: await GetAdminToken(), database: name); + + + var measurementNames = new List(); + await foreach (var row in client.Query(query: "SHOW MEASUREMENTS", queryType: QueryType.InfluxQL)) + { + measurementNames.Add((string) row[1]); + } + + var query = $"select * from {measurementNames[0]} WHERE time >= $min_time AND time < $max_time"; + + var parameters = new Dictionary + { + ["min_time"] = start.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ"), + ["max_time"] = end.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ") + }; + + var entryProperties = typeof(T) + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(p => p.CanWrite && p.Name != nameof(BaseMonitorData.Timestamp)) + .ToArray(); + + var data = new List(); + await foreach (PointDataValues point in client.QueryPoints(query: query, queryType: QueryType.InfluxQL, namedParameters: parameters)) + { + var entry = new T(); + entry.Timestamp = DateTimeOffset.FromUnixTimeMilliseconds((long) point.GetTimestamp() / 1000000); + foreach (var property in entryProperties) + { + var fieldValue = point.GetField(property.Name.ToLower()); + if (fieldValue == null) + continue; + + if (TryConvertFieldValue(fieldValue, property.PropertyType, out var convertedValue)) + { + property.SetValue(entry, convertedValue); + } + } + + data.Add(entry); + } + + return data; + } + + private static bool TryConvertFieldValue(object value, Type targetType, out object? convertedValue) + { + var destinationType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (destinationType.IsInstanceOfType(value)) + { + convertedValue = value; + return true; + } + + if (value is IConvertible) + { + try + { + convertedValue = Convert.ChangeType(value, destinationType, CultureInfo.InvariantCulture); + return true; + } + catch + { + // Ignore conversion failures so other fields can still be processed. + } + } + + convertedValue = null; + return false; + } } diff --git a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs index e6c81e9..8427bf4 100644 --- a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs +++ b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs @@ -97,7 +97,7 @@ private void UpdateConfigFile(string path, List monit array.Add(new TomlTable { ["servers"] = new TomlArray { $"tcp://{source.Host.Value}:{source.Port}" }, - ["interval"] = "60s", + ["interval"] = "30s", ["tags"] = new TomlTable { ["instance"] = source.Name } }); diff --git a/tilework.core/Resources/telegraf.conf b/tilework.core/Resources/telegraf.conf index e69de29..d169995 100644 --- a/tilework.core/Resources/telegraf.conf +++ b/tilework.core/Resources/telegraf.conf @@ -0,0 +1,59 @@ +[agent] + debug = true + + +[[processors.starlark]] + namepass = ["haproxy"] + fieldinclude = ["stot"] + + source = ''' +state = {} + +def apply(metric): + # key per series: measurement + tags + key = metric.name + "|" + str(sorted(metric.tags.items())) + + last = state.get(key) + # store current metric for next time + state[key] = deepcopy(metric) + + # First time we see this series: zero all numeric fields + if last == None: + for fname, val in metric.fields.items(): + t = type(val) + if t == "int" or t == "float": + metric.fields[fname] = 0 + return metric + + # Subsequent points: convert numeric fields to delta + for fname, val in metric.fields.items(): + t = type(val) + if not (t == "int" or t == "float"): + continue + + # no previous value -> treat as first sample + if fname not in last.fields: + metric.fields[fname] = 0 + continue + + prev = last.fields[fname] + pt = type(prev) + if not (pt == "int" or pt == "float"): + metric.fields[fname] = 0 + continue + + d = val - prev + if d < 0: + d = 0 + + metric.fields[fname] = d + + return metric +''' + +[[processors.rename]] + namepass = ["haproxy"] + + [[processors.rename.replace]] + field = "stot" + dest = "sessions" diff --git a/tilework.core/ServiceCollectionExtensions.cs b/tilework.core/ServiceCollectionExtensions.cs index 14f9331..1f129f6 100644 --- a/tilework.core/ServiceCollectionExtensions.cs +++ b/tilework.core/ServiceCollectionExtensions.cs @@ -56,6 +56,7 @@ public static IServiceCollection AddMonitoring(this IServiceCollection services, services.AddScoped(); services.AddSingleton(); services.AddScoped(); + services.AddScoped(); services.AddHostedService(); diff --git a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs index 8b34d4e..27c78f1 100644 --- a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs +++ b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs @@ -28,19 +28,21 @@ public class LoadBalancerService : ILoadBalancerService private readonly ILoadBalancingConfigurator _configurator; private readonly ILogger _logger; private readonly IMapper _mapper; + private readonly MonitoringService _monitoringService; public LoadBalancerService(IServiceProvider serviceProvider, TileworkContext dbContext, IMapper mapper, IOptions settings, - ILogger logger) + ILogger logger, + MonitoringService monitoringService) { _dbContext = dbContext; _logger = logger; _settings = settings.Value; _configurator = LoadConfigurator(serviceProvider, _settings); - + _monitoringService = monitoringService; _mapper = mapper; } @@ -65,29 +67,29 @@ private static void ValidateRulePriority(ICollection rules, int newPriorit { throw new ArgumentOutOfRangeException(nameof(newPriority), newPriority, $"Rule priority cannot be greater than {maxPriority + 1}."); } - } - - private static bool RequiresCertificate(BaseLoadBalancer balancer) - { - return balancer switch - { - ApplicationLoadBalancer appBalancer => appBalancer.Protocol == AlbProtocol.HTTPS, - NetworkLoadBalancer netBalancer => netBalancer.Protocol == NlbProtocol.TLS, - _ => false - }; - } - - private static void EnsureCertificatesPresentIfRequired(BaseLoadBalancer balancer) - { - if (RequiresCertificate(balancer) && (balancer.Certificates == null || balancer.Certificates.Count == 0)) - { - throw new InvalidOperationException($"Load balancer {balancer.Name} requires at least one certificate before it can be enabled."); - } - } - - - - private BaseLoadBalancerDTO MapBalancerToDto(BaseLoadBalancer entity) + } + + private static bool RequiresCertificate(BaseLoadBalancer balancer) + { + return balancer switch + { + ApplicationLoadBalancer appBalancer => appBalancer.Protocol == AlbProtocol.HTTPS, + NetworkLoadBalancer netBalancer => netBalancer.Protocol == NlbProtocol.TLS, + _ => false + }; + } + + private static void EnsureCertificatesPresentIfRequired(BaseLoadBalancer balancer) + { + if (RequiresCertificate(balancer) && (balancer.Certificates == null || balancer.Certificates.Count == 0)) + { + throw new InvalidOperationException($"Load balancer {balancer.Name} requires at least one certificate before it can be enabled."); + } + } + + + + private BaseLoadBalancerDTO MapBalancerToDto(BaseLoadBalancer entity) { return entity switch { @@ -150,18 +152,18 @@ public async Task DeleteLoadBalancer(Guid Id) await _dbContext.SaveChangesAsync(); } - public async Task EnableLoadBalancer(Guid Id) - { - var entity = await _dbContext.LoadBalancers - .Include(lb => lb.Certificates) - .FirstOrDefaultAsync(lb => lb.Id == Id); - - if (entity == null) - throw new ArgumentException($"Load balancer {Id} not found."); - - EnsureCertificatesPresentIfRequired(entity); - entity.Enabled = true; - _dbContext.LoadBalancers.Update(entity); + public async Task EnableLoadBalancer(Guid Id) + { + var entity = await _dbContext.LoadBalancers + .Include(lb => lb.Certificates) + .FirstOrDefaultAsync(lb => lb.Id == Id); + + if (entity == null) + throw new ArgumentException($"Load balancer {Id} not found."); + + EnsureCertificatesPresentIfRequired(entity); + entity.Enabled = true; + _dbContext.LoadBalancers.Update(entity); await using var tx = await _dbContext.Database.BeginTransactionAsync(); try @@ -451,5 +453,12 @@ public async Task Shutdown() await _configurator.Shutdown(); } - + public async Task> GetMonitoringData(Guid id, DateTimeOffset start, DateTimeOffset end) + { + var lb = await GetLoadBalancer(id); + if(lb == null) + throw new ArgumentException("Invalid load balancer id"); + + return await _monitoringService.GetMonitoringData($"LoadBalancing-{lb.Id}", start, end); + } } diff --git a/tilework.core/Services/Monitoring/MonitoringService.cs b/tilework.core/Services/Monitoring/MonitoringService.cs new file mode 100644 index 0000000..9c5860d --- /dev/null +++ b/tilework.core/Services/Monitoring/MonitoringService.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Logging; + +using Tilework.Monitoring.Interfaces; + +public class MonitoringService +{ + private readonly IDataPersistenceConfigurator _persistenceConfigurator; + + private readonly ILogger _logger; + + + public MonitoringService(IDataPersistenceConfigurator persistenceConfigurator, + ILogger logger) + { + _persistenceConfigurator = persistenceConfigurator; + _logger = logger; + } + + public async Task> GetMonitoringData(string monitorName, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new() + { + return await _persistenceConfigurator.GetData(monitorName, start, end); + } +} \ No newline at end of file diff --git a/tilework.core/tilework.core.csproj b/tilework.core/tilework.core.csproj index cba9ebc..bf7cd5a 100644 --- a/tilework.core/tilework.core.csproj +++ b/tilework.core/tilework.core.csproj @@ -12,6 +12,7 @@ + diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index 6992e4f..629be87 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -481,8 +481,36 @@ private async Task OnTabChanged(int index) { + if(_item != null && _item.Enabled == true && index == GetMonitoringTabIndex()) { + await RefreshMonitoringData(); } } + + private async Task RefreshMonitoringData() + { + var end = DateTimeOffset.UtcNow; + var start = end.AddHours(-1); + var data = await _loadBalancerService.GetMonitoringData(_item.Id, start, end) ?? new List(); + + var orderedData = data.OrderBy(d => d.Timestamp).ToList(); + _chartLabels = orderedData + .Select(d => d.Timestamp.ToLocalTime().ToString("HH:mm")) + .ToArray(); + + _series = new List(); + if (orderedData.Count == 0) + { + return; + } + + _series.Add(new ChartSeries + { + Name = "Sessions", + Data = orderedData + .Select(d => (double)d.Sessions) + .ToArray() + }); + } } From b2658dddfa3e16b38675601706b439ad71e13e32 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Wed, 26 Nov 2025 19:52:24 +0000 Subject: [PATCH 047/104] Clean-ish ui for chart --- .../LoadBalancing/LoadBalancerDetail.razor | 51 +++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index 629be87..dca4ed0 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -495,16 +495,40 @@ var data = await _loadBalancerService.GetMonitoringData(_item.Id, start, end) ?? new List(); var orderedData = data.OrderBy(d => d.Timestamp).ToList(); - _chartLabels = orderedData - .Select(d => d.Timestamp.ToLocalTime().ToString("HH:mm")) - .ToArray(); - _series = new List(); if (orderedData.Count == 0) { + _chartLabels = Array.Empty(); return; } + const int targetLabelCount = 6; + var localTimes = orderedData.Select(d => d.Timestamp.ToLocalTime().DateTime).ToList(); + var duration = localTimes.Last() - localTimes.First(); + var approxIntervalMinutes = Math.Max(1, duration.TotalMinutes / targetLabelCount); + var intervalMinutes = GetRoundedIntervalMinutes(approxIntervalMinutes); + var interval = TimeSpan.FromMinutes(intervalMinutes); + var nextLabelTime = AlignToNextInterval(localTimes.First(), interval); + + _chartLabels = new string[orderedData.Count]; + for (var i = 0; i < orderedData.Count; i++) + { + var current = localTimes[i]; + var label = string.Empty; + + if (current >= nextLabelTime) + { + while (current >= nextLabelTime) + { + nextLabelTime = nextLabelTime.Add(interval); + } + + label = nextLabelTime.Subtract(interval).ToString("HH:mm"); + } + + _chartLabels[i] = label; + } + _series.Add(new ChartSeries { Name = "Sessions", @@ -513,4 +537,23 @@ .ToArray() }); } + + private static int GetRoundedIntervalMinutes(double approxMinutes) + { + int[] allowed = { 1, 2, 5, 10, 15, 30, 60, 120, 240, 720, 1440 }; + foreach (var candidate in allowed) + { + if (approxMinutes <= candidate) + { + return candidate; + } + } + return allowed.Last(); + } + + private static DateTime AlignToNextInterval(DateTime time, TimeSpan interval) + { + var ticks = ((time.Ticks + interval.Ticks - 1) / interval.Ticks) * interval.Ticks; + return new DateTime(ticks, time.Kind); + } } From d6c825cca6f16b46d9e37c116632138304cfdeba Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Thu, 27 Nov 2025 19:35:03 +0000 Subject: [PATCH 048/104] Had to finally implement a simple token vault --- .../Initializers/MonitoringInitializer.cs | 2 +- .../20251127185719_TokenVault.Designer.cs | 400 ++++++++++++++++++ .../Migrations/20251127185719_TokenVault.cs | 41 ++ .../TileworkContextModelSnapshot.cs | 22 + tilework.core/Persistence/DbContext.cs | 5 +- .../Persistence/Entities/TokenVault/Token.cs | 13 + .../HAProxy/HAProxyConfigurator.cs | 2 +- tilework.core/ServiceCollectionExtensions.cs | 5 + .../Monitoring/DataCollectorService.cs | 2 +- .../Services/TokenVault/TokenService.cs | 67 +++ 10 files changed, 555 insertions(+), 4 deletions(-) create mode 100644 tilework.core/Migrations/20251127185719_TokenVault.Designer.cs create mode 100644 tilework.core/Migrations/20251127185719_TokenVault.cs create mode 100644 tilework.core/Persistence/Entities/TokenVault/Token.cs create mode 100644 tilework.core/Services/TokenVault/TokenService.cs diff --git a/tilework.core/Initializers/MonitoringInitializer.cs b/tilework.core/Initializers/MonitoringInitializer.cs index 878f56d..02d3d18 100644 --- a/tilework.core/Initializers/MonitoringInitializer.cs +++ b/tilework.core/Initializers/MonitoringInitializer.cs @@ -4,7 +4,7 @@ using Coravel; -using Tilework.LoadBalancing.Interfaces; +using Tilework.Monitoring.Services; namespace Tilework.LoadBalancing.Services; diff --git a/tilework.core/Migrations/20251127185719_TokenVault.Designer.cs b/tilework.core/Migrations/20251127185719_TokenVault.Designer.cs new file mode 100644 index 0000000..6b728d6 --- /dev/null +++ b/tilework.core/Migrations/20251127185719_TokenVault.Designer.cs @@ -0,0 +1,400 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Tilework.Core.Persistence; + +#nullable disable + +namespace tilework.core.Migrations +{ + [DbContext(typeof(TileworkContext))] + [Migration("20251127185719_TokenVault")] + partial class TokenVault + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true); + + modelBuilder.Entity("LoadBalancerCertificates", b => + { + b.Property("BalancerId") + .HasColumnType("TEXT"); + + b.Property("CertificateId") + .HasColumnType("TEXT"); + + b.HasKey("BalancerId", "CertificateId"); + + b.HasIndex("CertificateId"); + + b.ToTable("LoadBalancerCertificates"); + }); + + modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.Certificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorityId") + .HasColumnType("TEXT"); + + b.Property("CertificateDataString") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("INTEGER"); + + b.Property("Fqdn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrivateKeyId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuthorityId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("PrivateKeyId"); + + b.ToTable("Certificates"); + }); + + modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.CertificateAuthority", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CertificateAuthorities"); + }); + + modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.PrivateKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Algorithm") + .HasColumnType("INTEGER"); + + b.Property("KeyDataString") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PrivateKeys"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("LoadBalancers"); + + b.HasDiscriminator().HasValue("BaseLoadBalancer"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Rule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("LoadBalancerId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("TargetGroupId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LoadBalancerId"); + + b.HasIndex("TargetGroupId"); + + b.HasIndex("Priority", "LoadBalancerId") + .IsUnique(); + + b.ToTable("Rules"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Target", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("TargetGroupId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TargetGroupId", "Host", "Port") + .IsUnique(); + + b.ToTable("Targets"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.TargetGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("TargetGroups"); + }); + + modelBuilder.Entity("Tilework.Persistence.TokenVault.Models.Token", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Tokens"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", b => + { + b.HasBaseType("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.HasDiscriminator().HasValue("ApplicationLoadBalancer"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.NetworkLoadBalancer", b => + { + b.HasBaseType("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.Property("TargetGroupId") + .HasColumnType("TEXT"); + + b.HasIndex("TargetGroupId"); + + b.ToTable("LoadBalancers", t => + { + t.Property("Protocol") + .HasColumnName("NetworkLoadBalancer_Protocol"); + }); + + b.HasDiscriminator().HasValue("NetworkLoadBalancer"); + }); + + modelBuilder.Entity("LoadBalancerCertificates", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer", null) + .WithMany() + .HasForeignKey("BalancerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tilework.Persistence.CertificateManagement.Models.Certificate", null) + .WithMany() + .HasForeignKey("CertificateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.Certificate", b => + { + b.HasOne("Tilework.Persistence.CertificateManagement.Models.CertificateAuthority", "Authority") + .WithMany() + .HasForeignKey("AuthorityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tilework.Persistence.CertificateManagement.Models.PrivateKey", "PrivateKey") + .WithMany() + .HasForeignKey("PrivateKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Authority"); + + b.Navigation("PrivateKey"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Rule", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", "LoadBalancer") + .WithMany("Rules") + .HasForeignKey("LoadBalancerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") + .WithMany() + .HasForeignKey("TargetGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Tilework.LoadBalancing.Models.Condition", "Conditions", b1 => + { + b1.Property("RuleId") + .HasColumnType("TEXT"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("INTEGER"); + + b1.Property("Type") + .HasColumnType("INTEGER"); + + b1.PrimitiveCollection("Values") + .IsRequired() + .HasColumnType("TEXT"); + + b1.HasKey("RuleId", "__synthesizedOrdinal"); + + b1.ToTable("Rules"); + + b1.ToJson("Conditions"); + + b1.WithOwner() + .HasForeignKey("RuleId"); + }); + + b.Navigation("Conditions"); + + b.Navigation("LoadBalancer"); + + b.Navigation("TargetGroup"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Target", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") + .WithMany("Targets") + .HasForeignKey("TargetGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TargetGroup"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.NetworkLoadBalancer", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") + .WithMany() + .HasForeignKey("TargetGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TargetGroup"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.TargetGroup", b => + { + b.Navigation("Targets"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", b => + { + b.Navigation("Rules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/tilework.core/Migrations/20251127185719_TokenVault.cs b/tilework.core/Migrations/20251127185719_TokenVault.cs new file mode 100644 index 0000000..06e9bf8 --- /dev/null +++ b/tilework.core/Migrations/20251127185719_TokenVault.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace tilework.core.Migrations +{ + /// + public partial class TokenVault : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Tokens", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Key = table.Column(type: "TEXT", nullable: false), + Value = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tokens", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Tokens_Key", + table: "Tokens", + column: "Key", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Tokens"); + } + } +} diff --git a/tilework.core/Migrations/TileworkContextModelSnapshot.cs b/tilework.core/Migrations/TileworkContextModelSnapshot.cs index 5f15fd1..ce1c2a6 100644 --- a/tilework.core/Migrations/TileworkContextModelSnapshot.cs +++ b/tilework.core/Migrations/TileworkContextModelSnapshot.cs @@ -226,6 +226,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("TargetGroups"); }); + modelBuilder.Entity("Tilework.Persistence.TokenVault.Models.Token", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Tokens"); + }); + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.ApplicationLoadBalancer", b => { b.HasBaseType("Tilework.Persistence.LoadBalancing.Models.BaseLoadBalancer"); diff --git a/tilework.core/Persistence/DbContext.cs b/tilework.core/Persistence/DbContext.cs index 9e4f269..83a15b0 100644 --- a/tilework.core/Persistence/DbContext.cs +++ b/tilework.core/Persistence/DbContext.cs @@ -1,11 +1,11 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -using System.Text.Json; using Tilework.Core.Models; using Tilework.Persistence.LoadBalancing.Models; using Tilework.Persistence.CertificateManagement.Models; +using Tilework.Persistence.TokenVault.Models; namespace Tilework.Core.Persistence; @@ -32,6 +32,9 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) public DbSet PrivateKeys { get; set; } public DbSet CertificateAuthorities { get; set; } + // Token vault + public DbSet Tokens { get; set; } + protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/tilework.core/Persistence/Entities/TokenVault/Token.cs b/tilework.core/Persistence/Entities/TokenVault/Token.cs new file mode 100644 index 0000000..16f40e7 --- /dev/null +++ b/tilework.core/Persistence/Entities/TokenVault/Token.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; + + +namespace Tilework.Persistence.TokenVault.Models; + +[Index(nameof(Key), IsUnique = true)] +public class Token +{ + public Guid Id { get; set; } + + public string Key { get; set; } = default!; + public string Value { get; set; } = default!; +} \ No newline at end of file diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs index d21cbd3..a1ed065 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs @@ -18,7 +18,7 @@ using Tilework.Monitoring.Enums; using Tilework.Monitoring.Models; using Tilework.Persistence.LoadBalancing.Models; -using Tilework.LoadBalancing.Services; +using Tilework.Monitoring.Services; namespace Tilework.LoadBalancing.Haproxy; diff --git a/tilework.core/ServiceCollectionExtensions.cs b/tilework.core/ServiceCollectionExtensions.cs index 1f129f6..de0246d 100644 --- a/tilework.core/ServiceCollectionExtensions.cs +++ b/tilework.core/ServiceCollectionExtensions.cs @@ -22,8 +22,11 @@ using Tilework.Monitoring.Interfaces; using Tilework.Monitoring.Telegraf; using Tilework.Monitoring.Models; +using Tilework.Monitoring.Services; using Tilework.Monitoring.Influxdb; +using Tilework.TokenVault.Services; + using Tilework.Core.Jobs.CertificateManagement; using Tilework.Events; @@ -38,6 +41,8 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service services.AddSingleton(); services.AddHostedService(); + services.AddScoped(); + services.AddScheduler(); services.AddQueue(); diff --git a/tilework.core/Services/Monitoring/DataCollectorService.cs b/tilework.core/Services/Monitoring/DataCollectorService.cs index 081546b..aadc425 100644 --- a/tilework.core/Services/Monitoring/DataCollectorService.cs +++ b/tilework.core/Services/Monitoring/DataCollectorService.cs @@ -6,7 +6,7 @@ using Tilework.Core.Persistence; using Tilework.Monitoring.Interfaces; -namespace Tilework.LoadBalancing.Services; +namespace Tilework.Monitoring.Services; public class DataCollectorService { diff --git a/tilework.core/Services/TokenVault/TokenService.cs b/tilework.core/Services/TokenVault/TokenService.cs new file mode 100644 index 0000000..612bb74 --- /dev/null +++ b/tilework.core/Services/TokenVault/TokenService.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; +using System.Security.Cryptography; + +using Tilework.Core.Persistence; +using Tilework.Persistence.TokenVault.Models; + +namespace Tilework.TokenVault.Services; + +public class TokenService +{ + private readonly TileworkContext _dbContext; + private readonly ILogger _logger; + + public TokenService(TileworkContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + public async Task GetToken(string key) + { + var token = await _dbContext.Tokens.FirstOrDefaultAsync(t => t.Key == key); + return token?.Value; + } + + public async Task SetToken(string key, string value) + { + var token = await _dbContext.Tokens.FirstOrDefaultAsync(t => t.Key == key); + if(token == null) + { + token = new Token() { Key = key, Value = value }; + await _dbContext.Tokens.AddAsync(token); + } + else + token.Value = value; + + await _dbContext.SaveChangesAsync(); + } + + public async Task DeleteToken(string key, string value) + { + var token = await _dbContext.Tokens.FirstOrDefaultAsync(t => t.Key == key); + if(token != null) + { + _dbContext.Tokens.Remove(token); + await _dbContext.SaveChangesAsync(); + } + } + + + public static string GenerateToken(int length) + { + const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+[]{}<>?"; + var bytes = new byte[length]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(bytes); + + var result = new char[length]; + for (int i = 0; i < length; i++) + result[i] = chars[bytes[i] % chars.Length]; + + return new string(result); + } + +} \ No newline at end of file From 41473900f8efdc8ed952c8606a9096fe9a757899 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sat, 29 Nov 2025 18:29:34 +0000 Subject: [PATCH 049/104] Implemented influxdb2 support --- .../IDataPersistenceConfigurator.cs | 1 - .../Providers/BaseContainerProvider.cs | 99 +++++++ .../Influxdb/Influxdb2DataPersistence.cs | 261 ++++++++++++++++++ ...istence.cs => Influxdb3DataPersistence.cs} | 136 +++------ .../Influxdb/Models/SetupResponse.cs | 6 + .../Telegraf/TelegrafDataCollector.cs | 1 + tilework.core/ServiceCollectionExtensions.cs | 2 +- .../Services/TokenVault/TokenService.cs | 2 +- tilework.core/tilework.core.csproj | 1 + tilework.ui/appsettings.json | 2 +- 10 files changed, 411 insertions(+), 100 deletions(-) create mode 100644 tilework.core/Providers/BaseContainerProvider.cs create mode 100644 tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs rename tilework.core/Providers/MonitoringProviders/Influxdb/{InfluxdbDataPersistence.cs => Influxdb3DataPersistence.cs} (56%) create mode 100644 tilework.core/Providers/MonitoringProviders/Influxdb/Models/SetupResponse.cs diff --git a/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs b/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs index 09e378b..8b80ac9 100644 --- a/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs +++ b/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs @@ -4,7 +4,6 @@ namespace Tilework.Monitoring.Interfaces; public interface IDataPersistenceConfigurator { - string ServiceName { get; } Task GetTarget(MonitoringSource source); Task> GetData(string name, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new(); diff --git a/tilework.core/Providers/BaseContainerProvider.cs b/tilework.core/Providers/BaseContainerProvider.cs new file mode 100644 index 0000000..ddb376a --- /dev/null +++ b/tilework.core/Providers/BaseContainerProvider.cs @@ -0,0 +1,99 @@ +using Microsoft.Extensions.Logging; + + +using Tilework.Core.Interfaces; +using Tilework.Core.Models; +using Tilework.Core.Enums; + + +using Tilework.Core.Models; + +public abstract class BaseContainerProvider +{ + private readonly ILogger _logger; + private readonly IContainerManager _containerManager; + + private readonly string _module; + private readonly string _name; + private readonly string _imageName; + private readonly List _ports; + + private string _fullName => $"{_module}.{_name}"; + private string _fullModule => $"{_module}.tile"; + + + public BaseContainerProvider(IContainerManager containerManager, + ILogger logger, + string module, + string name, + string imageName, + List ports) + { + _containerManager = containerManager; + _logger = logger; + + _name = name; + _module = module; + _imageName = imageName; + _ports = ports; + } + + protected async Task GetContainer() + { + var containers = await _containerManager.ListContainers(_fullModule); + + return containers.FirstOrDefault(c => c.Name == _fullName); + } + + private async Task CreateContainer() + { + try + { + var container = await _containerManager.CreateContainer( + _fullName, _imageName, _fullModule, _ports + ); + + return container; + } + catch (Exception ex) + { + _logger.LogCritical($"Failed to create container {_fullName}: {ex}"); + throw; + } + } + + protected async Task StartUp() + { + var container = await GetContainer(); + if (container == null) + { + _logger.LogInformation($"Creating container {_fullName}"); + container = await CreateContainer(); + } + + + if (container.State != ContainerState.Running) + { + _logger.LogInformation($"Starting container {_fullName}"); + await _containerManager.StartContainer(container.Id); + } + else + { + _logger.LogInformation($"Restarting container {_fullName}"); + await _containerManager.StopContainer(container.Id); + await _containerManager.StartContainer(container.Id); + } + } + + public async Task Shutdown() + { + var container = await GetContainer(); + if (container != null) + { + _logger.LogInformation($"Stopping and deleting container {_fullName}"); + if (container.State == ContainerState.Running) + await _containerManager.StopContainer(container.Id); + await _containerManager.DeleteContainer(container.Id); + } + } +} \ No newline at end of file diff --git a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs new file mode 100644 index 0000000..4472602 --- /dev/null +++ b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs @@ -0,0 +1,261 @@ +using System.Globalization; +using System.Linq; +using System.Reflection; + +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; + +using AutoMapper; +using InfluxDB.Client; +using InfluxDB.Client.Api.Domain; +using InfluxDB.Client.Core; +using InfluxDB.Client.Writes; + +using Tilework.Core.Interfaces; +using Tilework.Core.Models; +using Tilework.Core.Enums; + +using Tilework.Monitoring.Interfaces; +using Tilework.Monitoring.Models; +using Tilework.Core.Services; +using Tilework.TokenVault.Services; + +namespace Tilework.Monitoring.Influxdb; + +public class Influxdb2Configurator : BaseContainerProvider, IDataPersistenceConfigurator +{ + protected static string _serviceName = "influxdb"; + protected static string _moduleName = "monitoring"; + + private static string _orgName = "tilework"; + + protected static List _ports = new List() + { + new ContainerPort() + { + Port = 8086, + HostPort = 8086, + Type = PortType.TCP + } + }; + + + private readonly IContainerManager _containerManager; + private readonly DataPersistenceConfiguration _settings; + private readonly ILogger _logger; + private readonly IMapper _mapper; + private readonly HttpApiFactoryService _apiFactory; + private readonly TokenService _tokenService; + + public Influxdb2Configurator(IOptions settings, + IContainerManager containerManager, + ILogger logger, + TokenService tokenService, + HttpApiFactoryService httpApiFactoryService, + IMapper mapper) : base(containerManager, logger, _moduleName, _serviceName, settings.Value.BackendImage, _ports) + { + _logger = logger; + _settings = settings.Value; + _containerManager = containerManager; + _mapper = mapper; + _apiFactory = httpApiFactoryService; + _tokenService = tokenService; + } + + + public async Task GetTarget(MonitoringSource source) + { + var container = await GetContainer(); + + await CheckCreateBucket(_orgName, source.Name); + + return new MonitoringTarget() + { + Name = _serviceName, + Type = Enums.MonitoringPersistenceType.INFLUXDB, + Host = Host.Parse((await _containerManager.GetContainerAddress(container.Id)).ToString()), + Port = 8086, + Password = await GetAdminToken() + }; + } + + public async Task ApplyConfiguration() + { + await StartUp(); + await CheckRunSetup(); + } + + private async Task CheckRunSetup() + { + await Task.Delay(2000); + var service = await GetApiService(); + var resp = await service.ApiGet("/setup"); + if(resp.Allowed == true) + { + var container = await GetContainer(); + var tokenKey = $"influxdb.{container.Id}"; + + await _tokenService.DeleteToken(tokenKey); + + await GetAdminToken(); + } + } + + + + private async Task GetApiService() + { + var host = await GetHost(); + return _apiFactory.GetApiService($"{host}/api/v2/"); + } + + private async Task GetHost() + { + var container = await GetContainer(); + var host = Host.Parse((await _containerManager.GetContainerAddress(container.Id)).ToString()); + return $"http://{host.Value}:8086"; + } + + private async Task GetAdminToken() + { + var container = await GetContainer(); + + var tokenKey = $"influxdb.{container.Id}"; + + var token = await _tokenService.GetToken(tokenKey); + + if(token == null) + { + _logger.LogInformation("Generating a new admin token for influxdb2"); + token = TokenService.GenerateToken(16); + + var result = await _containerManager.ExecuteContainerCommand( + container.Id, + $"influx setup --username admin --password \"{token}\" --org \"{_orgName}\" --bucket tilework --token \"{token}\" --force"); + + await _tokenService.SetToken(tokenKey, token); + } + _logger.LogInformation($"Admin token ---> {token}"); + return token; + } + + private async Task CheckCreateBucket(string orgName, string bucketName) + { + using var client = new InfluxDBClient(await GetHost(), token: await GetAdminToken()); + var api = client.GetBucketsApi(); + + var buckets = await api.FindBucketsByOrgNameAsync(orgName); + var bucket = buckets.FirstOrDefault(b => b.Name == bucketName); + + if (bucket == null) + { + var orgId = await GetOrgId(orgName); + + await api.CreateBucketAsync( + name: bucketName, + orgId: orgId, + bucketRetentionRules: new BucketRetentionRules( + type: BucketRetentionRules.TypeEnum.Expire, + everySeconds: 30 * 24 * 3600 + ) + ); + } + } + + private async Task GetOrgId(string orgName) + { + using var client = new InfluxDBClient(await GetHost(), token: await GetAdminToken()); + + var orgsApi = client.GetOrganizationsApi(); + var org = await orgsApi.FindOrganizationsAsync(org:orgName); + if(org.Count() == 0) + throw new ArgumentException("Invalid organisation name"); + + return org[0].Id; + } + + public async Task> GetData(string name, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new() + { + using var client = new InfluxDBClient(await GetHost(), token: await GetAdminToken()); + + var queryApi = client.GetQueryApi(); + + var startStr = start.UtcDateTime.ToString("o", CultureInfo.InvariantCulture); + var stopStr = end.UtcDateTime.ToString("o", CultureInfo.InvariantCulture); + + var query = $"from(bucket: \"{name}\")\n |> range(start: {startStr}, stop: {stopStr})"; + + var fluxTables = await queryApi.QueryAsync(query, _orgName); + + var entryProperties = typeof(T) + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(p => p.CanWrite && p.Name != nameof(BaseMonitorData.Timestamp)) + .ToArray(); + + var entryPropertyNames = entryProperties + .Select(property => property.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var records = fluxTables[0].Records + .Where(record => record.GetField() is string fieldName && entryPropertyNames.Contains(fieldName)) + .ToList(); + + var groups = records + .Where(record => record.GetTimeInDateTime().HasValue) + .GroupBy(record => record.GetTimeInDateTime()!.Value) + .OrderBy(group => group.Key) + .ToList(); + + var data = new List(); + + foreach(var group in groups) + { + var entry = new T(); + entry.Timestamp = new DateTimeOffset(DateTime.SpecifyKind(group.Key, DateTimeKind.Utc)); + + foreach (var property in entryProperties) + { + var fieldValue = group.FirstOrDefault(r => r.GetField().ToLower() == property.Name.ToLower())?.GetValue(); + if (fieldValue == null) + continue; + + if (TryConvertFieldValue(fieldValue, property.PropertyType, out var convertedValue)) + { + property.SetValue(entry, convertedValue); + } + } + + data.Add(entry); + } + + + return data; + } + + private static bool TryConvertFieldValue(object value, Type targetType, out object? convertedValue) + { + var destinationType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (destinationType.IsInstanceOfType(value)) + { + convertedValue = value; + return true; + } + + if (value is IConvertible) + { + try + { + convertedValue = Convert.ChangeType(value, destinationType, CultureInfo.InvariantCulture); + return true; + } + catch + { + // Ignore conversion failures so other fields can still be processed. + } + } + + convertedValue = null; + return false; + } +} diff --git a/tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb3DataPersistence.cs similarity index 56% rename from tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs rename to tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb3DataPersistence.cs index f11b77f..4031667 100644 --- a/tilework.core/Providers/MonitoringProviders/Influxdb/InfluxdbDataPersistence.cs +++ b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb3DataPersistence.cs @@ -9,6 +9,7 @@ using InfluxDB3.Client.Query; using InfluxDB3.Client.Write; + using Tilework.Core.Interfaces; using Tilework.Core.Models; using Tilework.Core.Enums; @@ -16,69 +17,45 @@ using Tilework.Monitoring.Interfaces; using Tilework.Monitoring.Models; using Tilework.Core.Services; +using Tilework.TokenVault.Services; namespace Tilework.Monitoring.Influxdb; -public class InfluxdbConfigurator : IDataPersistenceConfigurator +public class Influxdb3Configurator : BaseContainerProvider, IDataPersistenceConfigurator { - public string ServiceName => "Influxdb"; + protected static string _serviceName = "influxdb"; + protected static string _moduleName = "monitoring"; - private string ContainerName => $"DataPersistence-{ServiceName}"; + protected static List _ports = new List() + { + new ContainerPort() + { + Port = 8181, + HostPort = 8181, + Type = PortType.TCP + } + }; private readonly IContainerManager _containerManager; private readonly DataPersistenceConfiguration _settings; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IMapper _mapper; private readonly HttpApiFactoryService _apiFactory; - - private string? _adminToken = null; - - public InfluxdbConfigurator(IOptions settings, - IContainerManager containerManager, - ILogger logger, - HttpApiFactoryService httpApiFactoryService, - IMapper mapper) + private readonly TokenService _tokenService; + + public Influxdb3Configurator(IOptions settings, + IContainerManager containerManager, + ILogger logger, + TokenService tokenService, + HttpApiFactoryService httpApiFactoryService, + IMapper mapper) : base(containerManager, logger, _moduleName, _serviceName, settings.Value.BackendImage, _ports) { _logger = logger; _settings = settings.Value; _containerManager = containerManager; _mapper = mapper; _apiFactory = httpApiFactoryService; - } - - private async Task GetContainer() - { - var containers = await _containerManager.ListContainers("monitoring.tile"); - - return containers.FirstOrDefault(c => c.Name == ContainerName); - } - - private async Task CreateContainer() - { - try - { - var container = await _containerManager.CreateContainer( - ContainerName, - _settings.BackendImage, - "monitoring.tile", - new List() - { - new ContainerPort() - { - Port = 8181, - HostPort = 8181, - Type = PortType.TCP - } - } - ); - - return container; - } - catch (Exception ex) - { - _logger.LogCritical($"Failed to create container for influxdb data persistence: {ex.ToString()}"); - throw; - } + _tokenService = tokenService; } public async Task GetTarget(MonitoringSource source) @@ -87,7 +64,7 @@ public async Task GetTarget(MonitoringSource source) return new MonitoringTarget() { - Name = ServiceName, + Name = _serviceName, Type = Enums.MonitoringPersistenceType.INFLUXDB, Host = Host.Parse((await _containerManager.GetContainerAddress(container.Id)).ToString()), Port = 8181, @@ -97,48 +74,12 @@ public async Task GetTarget(MonitoringSource source) public async Task ApplyConfiguration() { - var container = await GetContainer(); - if (container == null) - { - _logger.LogInformation($"Creating container for influxdb data persistence"); - container = await CreateContainer(); - _logger.LogInformation($"Starting container for influxdb data persistence"); - await _containerManager.StartContainer(container.Id); - - await _containerManager.ExecuteContainerCommand(container.Id, "influxdb3 show tokens --format json"); - } - - - if (container.State != ContainerState.Running) - { - _logger.LogInformation($"Starting container for influxdb data persistence"); - await _containerManager.StartContainer(container.Id); - } - else - { - _logger.LogInformation($"Restarting container for influxdb data persistence"); - await _containerManager.StopContainer(container.Id); - await _containerManager.StartContainer(container.Id); - } - - await GetAdminToken(); - } - - public async Task Shutdown() - { - var container = await GetContainer(); - if (container != null) - { - _logger.LogInformation($"Stopping and deleting influxdb data persistence"); - if (container.State == ContainerState.Running) - await _containerManager.StopContainer(container.Id); - await _containerManager.DeleteContainer(container.Id); - } + await StartUp(); } private async Task GetApiService() { - var host = GetHost(); + var host = await GetHost(); return _apiFactory.GetApiService($"{host}/api/v3/"); } @@ -151,19 +92,22 @@ private async Task GetHost() private async Task GetAdminToken() { - if (_adminToken != null) - { - _logger.LogInformation($"Admin token: {_adminToken}"); - return _adminToken; - } - var container = await GetContainer(); - var result = await _containerManager.ExecuteContainerCommand(container.Id, "get_token.sh"); + var tokenKey = $"influxdb.{container.Id}"; - _adminToken = result.Stdout; - _logger.LogInformation($"Admin token: {_adminToken}"); - return _adminToken; + var token = await _tokenService.GetToken(tokenKey); + + if(token == null) + { + _logger.LogInformation("Generating a new admin token for influxdb3"); + + var result = await _containerManager.ExecuteContainerCommand(container.Id, $"influxdb3 create token --admin"); + token = result.Stdout; + + await _tokenService.SetToken(tokenKey, token); + } + return token; } public async Task> GetData(string name, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new() diff --git a/tilework.core/Providers/MonitoringProviders/Influxdb/Models/SetupResponse.cs b/tilework.core/Providers/MonitoringProviders/Influxdb/Models/SetupResponse.cs new file mode 100644 index 0000000..f0fe259 --- /dev/null +++ b/tilework.core/Providers/MonitoringProviders/Influxdb/Models/SetupResponse.cs @@ -0,0 +1,6 @@ +namespace Tilework.Monitoring.Influxdb; + +public class SetupResponse() +{ + public bool Allowed { get; set; } +} \ No newline at end of file diff --git a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs index 8427bf4..2ac204f 100644 --- a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs +++ b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs @@ -121,6 +121,7 @@ private void UpdateConfigFile(string path, List monit ["urls"] = new TomlArray { $"http://{target.Host.Value}:{target.Port}" }, ["token"] = target.Password, ["bucket"] = source.Name, + ["organization"] = "tilework", ["tagpass"] = new TomlTable { ["instance"] = new TomlArray { source.Name } diff --git a/tilework.core/ServiceCollectionExtensions.cs b/tilework.core/ServiceCollectionExtensions.cs index de0246d..fa206f6 100644 --- a/tilework.core/ServiceCollectionExtensions.cs +++ b/tilework.core/ServiceCollectionExtensions.cs @@ -59,7 +59,7 @@ public static IServiceCollection AddMonitoring(this IServiceCollection services, services.Configure(configuration.GetSection("DataPersistence")); services.AddScoped(); - services.AddSingleton(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/tilework.core/Services/TokenVault/TokenService.cs b/tilework.core/Services/TokenVault/TokenService.cs index 612bb74..2f30bda 100644 --- a/tilework.core/Services/TokenVault/TokenService.cs +++ b/tilework.core/Services/TokenVault/TokenService.cs @@ -39,7 +39,7 @@ public async Task SetToken(string key, string value) await _dbContext.SaveChangesAsync(); } - public async Task DeleteToken(string key, string value) + public async Task DeleteToken(string key) { var token = await _dbContext.Tokens.FirstOrDefaultAsync(t => t.Key == key); if(token != null) diff --git a/tilework.core/tilework.core.csproj b/tilework.core/tilework.core.csproj index bf7cd5a..fc2b127 100644 --- a/tilework.core/tilework.core.csproj +++ b/tilework.core/tilework.core.csproj @@ -12,6 +12,7 @@ + diff --git a/tilework.ui/appsettings.json b/tilework.ui/appsettings.json index 8a66e3a..ed92a72 100644 --- a/tilework.ui/appsettings.json +++ b/tilework.ui/appsettings.json @@ -21,7 +21,7 @@ }, "DataPersistence" : { "Backend" : "influxdb", - "BackendImage" : "tilework/monitoring-influxdb:latest" + "BackendImage" : "influxdb:2.7" } }, "ConnectionStrings": { From 15fb9e440e091f879388f5115583909aa72cfad1 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 30 Nov 2025 08:42:22 +0000 Subject: [PATCH 050/104] Added better timeout --- tilework.core/Services/Core/HttpApiService.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tilework.core/Services/Core/HttpApiService.cs b/tilework.core/Services/Core/HttpApiService.cs index ef2ad25..a032572 100644 --- a/tilework.core/Services/Core/HttpApiService.cs +++ b/tilework.core/Services/Core/HttpApiService.cs @@ -1,3 +1,4 @@ +using System; using System.Net; using System.Text; using System.Text.Json; @@ -14,10 +15,13 @@ public class HttpApiService private readonly HttpClient _httpClient; private readonly string _baseUrl; - public HttpApiService(ILogger logger, string baseUrl) + public HttpApiService(ILogger logger, string baseUrl, TimeSpan? timeout = null) { _logger = logger; - _httpClient = new HttpClient(); + _httpClient = new HttpClient + { + Timeout = timeout ?? TimeSpan.FromSeconds(10) + }; _baseUrl = baseUrl; } @@ -79,4 +83,4 @@ private async Task ApiCall(HttpMethod method, string url, return deserializedResponse!; } -} \ No newline at end of file +} From ca410dd8081c1b1b7dc581904a20f727754e1311 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 30 Nov 2025 08:50:14 +0000 Subject: [PATCH 051/104] Added logging to the HTTP api --- tilework.core/Services/Core/HttpApiService.cs | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/tilework.core/Services/Core/HttpApiService.cs b/tilework.core/Services/Core/HttpApiService.cs index a032572..635c87e 100644 --- a/tilework.core/Services/Core/HttpApiService.cs +++ b/tilework.core/Services/Core/HttpApiService.cs @@ -46,17 +46,17 @@ private async Task ApiCall(HttpMethod method, string url, Dictionary? query = null, object? requestData = null) where T : class { - var fullUrl = $"{_baseUrl.TrimEnd('/')}/{url.TrimStart('/')}"; - - using var request = new HttpRequestMessage(method, fullUrl); + var requestUrl = $"{_baseUrl.TrimEnd('/')}/{url.TrimStart('/')}"; if (query is not null && query.Count > 0) { var q = string.Join("&", query.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); - fullUrl = $"{fullUrl}?{q}"; + requestUrl = $"{requestUrl}?{q}"; } + using var request = new HttpRequestMessage(method, requestUrl); + if (headers is not null) { foreach (var h in headers) @@ -65,22 +65,43 @@ private async Task ApiCall(HttpMethod method, string url, } } + string? serializedRequestData = null; if(requestData != null) { - var jsonData = JsonSerializer.Serialize(requestData); - request.Content = new StringContent(jsonData, Encoding.UTF8, "application/json"); + serializedRequestData = JsonSerializer.Serialize(requestData); + request.Content = new StringContent(serializedRequestData, Encoding.UTF8, "application/json"); } - HttpResponseMessage response = await _httpClient.SendAsync(request); - response.EnsureSuccessStatusCode(); + var headerLog = headers is not null && headers.Count > 0 + ? string.Join(", ", headers.Select(kv => $"{kv.Key}={kv.Value}")) + : ""; + var bodyLog = serializedRequestData ?? ""; - string responseBody = await response.Content.ReadAsStringAsync(); + _logger.LogDebug("Sending HTTP {Method} {Url}\nHeaders: {Headers}\nBody: {Body}", + method, requestUrl, headerLog, bodyLog); - var deserializedResponse = JsonSerializer.Deserialize(responseBody, new JsonSerializerOptions + try { - PropertyNameCaseInsensitive = true - }); + HttpResponseMessage response = await _httpClient.SendAsync(request); + + string responseBody = await response.Content.ReadAsStringAsync(); + + _logger.LogDebug("Received HTTP {StatusCode} response to {Method} {Url}\nBody: {Body}", + (int) response.StatusCode, method, requestUrl, responseBody); + + response.EnsureSuccessStatusCode(); - return deserializedResponse!; + var deserializedResponse = JsonSerializer.Deserialize(responseBody, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return deserializedResponse!; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "HTTP {Method} {Url} failed.", method, requestUrl); + throw; + } } } From ce44fd336a7621635b2f5af3d07599d1d37b62d3 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 30 Nov 2025 08:54:13 +0000 Subject: [PATCH 052/104] dumb test --- tilework.core/Services/Core/HttpApiService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tilework.core/Services/Core/HttpApiService.cs b/tilework.core/Services/Core/HttpApiService.cs index 635c87e..96ade77 100644 --- a/tilework.core/Services/Core/HttpApiService.cs +++ b/tilework.core/Services/Core/HttpApiService.cs @@ -20,7 +20,7 @@ public HttpApiService(ILogger logger, string baseUrl, TimeSpan? _logger = logger; _httpClient = new HttpClient { - Timeout = timeout ?? TimeSpan.FromSeconds(10) + Timeout = timeout ?? TimeSpan.FromSeconds(1000) }; _baseUrl = baseUrl; } From e9501e43171135bdd270782ac62c2ef8ad46a40f Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 30 Nov 2025 09:53:00 +0000 Subject: [PATCH 053/104] In case tilework is containerized, add it to the default network --- .../Services/Core/DockerServiceManager.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tilework.core/Services/Core/DockerServiceManager.cs b/tilework.core/Services/Core/DockerServiceManager.cs index 0953009..ad2b7a4 100644 --- a/tilework.core/Services/Core/DockerServiceManager.cs +++ b/tilework.core/Services/Core/DockerServiceManager.cs @@ -37,6 +37,36 @@ public static Enums.ContainerState ParseState(string state) throw new ArgumentException($"Invalid container state: {state}"); } + private async Task AddMeToDefaultNetwork(ContainerNetwork network) + { + var containerId = Dns.GetHostName(); + + ContainerInspectResponse container; + try { + container = await _client.Containers.InspectContainerAsync(containerId); + } + catch (DockerContainerNotFoundException) + { + _logger.LogInformation("Not adding tilework to default network: Not containerized"); + return; + } + + var attachedNetworks = container.NetworkSettings?.Networks ?? new Dictionary(); + + var alreadyInNetwork = attachedNetworks.Any(n => + string.Equals(n.Key, network.Name, StringComparison.OrdinalIgnoreCase) || + string.Equals(n.Value.NetworkID, network.Id, StringComparison.OrdinalIgnoreCase)); + + + if(!alreadyInNetwork) + { + await _client.Networks.ConnectNetworkAsync(network.Id, new NetworkConnectParameters + { + Container = containerId + }); + } + } + private async Task GetOrCreateDefaultNetwork() { var networks = await ListNetworks(); @@ -45,6 +75,8 @@ private async Task GetOrCreateDefaultNetwork() if (network == null) network = await CreateNetwork(defaultNetworkName); + await AddMeToDefaultNetwork(network); + return network; } From 8ef76b7d38667ad07555ff06d3f1d05ca5f3f2b7 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 30 Nov 2025 10:25:20 +0000 Subject: [PATCH 054/104] Correct way to initialize docker --- tilework.core/Initializers/CoreInitializer.cs | 4 ++++ tilework.core/Interfaces/Core/IContainerManager.cs | 2 ++ tilework.core/Services/Core/DockerServiceManager.cs | 8 ++++++-- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/tilework.core/Initializers/CoreInitializer.cs b/tilework.core/Initializers/CoreInitializer.cs index 537b6e0..6dc4de6 100644 --- a/tilework.core/Initializers/CoreInitializer.cs +++ b/tilework.core/Initializers/CoreInitializer.cs @@ -6,6 +6,7 @@ using Coravel; using Tilework.Core.Persistence; +using Tilework.Core.Interfaces; namespace Tilework.Core.Services; @@ -31,6 +32,9 @@ public async Task StartAsync(CancellationToken ct) await dbContext.Database.MigrateAsync(ct); scope.ServiceProvider.ConfigureEvents(); + + var containerManager = scope.ServiceProvider.GetRequiredService(); + containerManager.Initialize(); } diff --git a/tilework.core/Interfaces/Core/IContainerManager.cs b/tilework.core/Interfaces/Core/IContainerManager.cs index d21e018..793ca96 100644 --- a/tilework.core/Interfaces/Core/IContainerManager.cs +++ b/tilework.core/Interfaces/Core/IContainerManager.cs @@ -7,6 +7,8 @@ namespace Tilework.Core.Interfaces; public interface IContainerManager { + public Task Initialize(); + public Task> ListNetworks(); public Task CreateNetwork(string name); public Task DeleteNetwork(string id); diff --git a/tilework.core/Services/Core/DockerServiceManager.cs b/tilework.core/Services/Core/DockerServiceManager.cs index ad2b7a4..0e2e775 100644 --- a/tilework.core/Services/Core/DockerServiceManager.cs +++ b/tilework.core/Services/Core/DockerServiceManager.cs @@ -37,6 +37,12 @@ public static Enums.ContainerState ParseState(string state) throw new ArgumentException($"Invalid container state: {state}"); } + public async Task Initialize() + { + var network = await GetOrCreateDefaultNetwork(); + await AddMeToDefaultNetwork(network); + } + private async Task AddMeToDefaultNetwork(ContainerNetwork network) { var containerId = Dns.GetHostName(); @@ -75,8 +81,6 @@ private async Task GetOrCreateDefaultNetwork() if (network == null) network = await CreateNetwork(defaultNetworkName); - await AddMeToDefaultNetwork(network); - return network; } From 7fc399495b839b1ad50f803f80b893af5c3bad22 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 30 Nov 2025 10:41:05 +0000 Subject: [PATCH 055/104] Better wait for influx to start. Also don't needlessly restart it --- .../Influxdb/Influxdb2DataPersistence.cs | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs index 4472602..0927f46 100644 --- a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs +++ b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs @@ -81,23 +81,38 @@ public async Task GetTarget(MonitoringSource source) public async Task ApplyConfiguration() { - await StartUp(); + var container = await GetContainer(); + if(container == null || container.State != ContainerState.Running) + await StartUp(); + await CheckRunSetup(); } private async Task CheckRunSetup() { - await Task.Delay(2000); var service = await GetApiService(); - var resp = await service.ApiGet("/setup"); - if(resp.Allowed == true) + for(int i=0; i<5; i++) { - var container = await GetContainer(); - var tokenKey = $"influxdb.{container.Id}"; + try + { + var resp = await service.ApiGet("/setup"); + + if(resp.Allowed == true) + { + var container = await GetContainer(); + var tokenKey = $"influxdb.{container.Id}"; - await _tokenService.DeleteToken(tokenKey); + await _tokenService.DeleteToken(tokenKey); - await GetAdminToken(); + await GetAdminToken(); + } + break; + } + catch + { + await Task.Delay(2000); + continue; + } } } From 2d228678fd8e56c5da8517769cb1230d0b472d81 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sat, 6 Dec 2025 15:16:36 +0000 Subject: [PATCH 056/104] Reorganised the way monitoring data is saved and queried --- .../LoadBalancing/ILoadBalancerService.cs | 2 +- .../IDataPersistenceConfigurator.cs | 2 +- .../Models/Monitoring/MonitoringSource.cs | 1 + .../HAProxy/HAProxyConfigurator.cs | 3 ++- .../Influxdb/Influxdb2DataPersistence.cs | 21 ++++++++++++++++--- .../Influxdb/Influxdb3DataPersistence.cs | 8 +++++-- .../Telegraf/TelegrafDataCollector.cs | 10 ++++++--- .../LoadBalancing/LoadBalancerService.cs | 9 ++++++-- .../Services/Monitoring/MonitoringService.cs | 4 ++-- .../Components/Layout/MainLayout.razor | 2 +- .../LoadBalancing/LoadBalancerDetail.razor | 2 +- 11 files changed, 47 insertions(+), 17 deletions(-) diff --git a/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs b/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs index c37eed0..64a9daf 100644 --- a/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs +++ b/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs @@ -42,7 +42,7 @@ public interface ILoadBalancerService public Task UpdateTarget(TargetGroupDTO group, TargetDTO target); public Task RemoveTarget(TargetGroupDTO group, TargetDTO target); - public Task> GetMonitoringData(Guid Id, DateTimeOffset start, DateTimeOffset end); + public Task> GetLoadBalancerMonitoringData(Guid Id, DateTimeOffset start, DateTimeOffset end); public Task ApplyConfiguration(); public Task Shutdown(); diff --git a/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs b/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs index 8b80ac9..302a50e 100644 --- a/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs +++ b/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs @@ -5,7 +5,7 @@ namespace Tilework.Monitoring.Interfaces; public interface IDataPersistenceConfigurator { Task GetTarget(MonitoringSource source); - Task> GetData(string name, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new(); + Task> GetData(string module, Dictionary filters, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new(); Task ApplyConfiguration(); Task Shutdown(); diff --git a/tilework.core/Models/Monitoring/MonitoringSource.cs b/tilework.core/Models/Monitoring/MonitoringSource.cs index 8e9cfd8..96c84a2 100644 --- a/tilework.core/Models/Monitoring/MonitoringSource.cs +++ b/tilework.core/Models/Monitoring/MonitoringSource.cs @@ -4,6 +4,7 @@ namespace Tilework.Monitoring.Models; public class MonitoringSource { + public string Module { get; set; } public string Name { get; set; } public MonitoringSourceType Type { get; set; } diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs index a1ed065..5dc8e28 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs @@ -232,7 +232,8 @@ public async Task ApplyConfiguration(List config) { var monitoringSource = new MonitoringSource() { - Name = $"LoadBalancing-{lb.Id}", + Module = "LoadBalancing", + Name = lb.Id.ToString(), Type = MonitoringSourceType.HAPROXY, Host = Host.Parse((await _containerManager.GetContainerAddress(container.Id)).ToString()), Port = 4380 diff --git a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs index 0927f46..8dc8db2 100644 --- a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs +++ b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs @@ -67,7 +67,7 @@ public async Task GetTarget(MonitoringSource source) { var container = await GetContainer(); - await CheckCreateBucket(_orgName, source.Name); + await CheckCreateBucket(_orgName, source.Module); return new MonitoringTarget() { @@ -189,16 +189,31 @@ private async Task GetOrgId(string orgName) return org[0].Id; } - public async Task> GetData(string name, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new() + public async Task> GetData(string module, Dictionary filters, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new() { using var client = new InfluxDBClient(await GetHost(), token: await GetAdminToken()); + // using var client = new InfluxDBClient("http://ifu.wh:8086", token: "{Nc97#*%4DggeR3w"); + // name = "LoadBalancing-4283cffc-903d-4360-be0f-861f77fd2318"; var queryApi = client.GetQueryApi(); var startStr = start.UtcDateTime.ToString("o", CultureInfo.InvariantCulture); var stopStr = end.UtcDateTime.ToString("o", CultureInfo.InvariantCulture); - var query = $"from(bucket: \"{name}\")\n |> range(start: {startStr}, stop: {stopStr})"; + var query = $"from(bucket: \"{module}\")\n |> range(start: {startStr}, stop: {stopStr})"; + + if (filters is { Count: > 0 }) + { + var filterExpressions = filters.Select(filter => + { + var key = (filter.Key ?? string.Empty).Replace("\"", "\\\""); + var value = (filter.Value ?? string.Empty).Replace("\"", "\\\""); + return $"r[\"{key}\"] == \"{value}\""; + }); + + var filtersCombined = string.Join(" and ", filterExpressions); + query += $"\n |> filter(fn: (r) => {filtersCombined})"; + } var fluxTables = await queryApi.QueryAsync(query, _orgName); diff --git a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb3DataPersistence.cs b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb3DataPersistence.cs index 4031667..c66fd93 100644 --- a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb3DataPersistence.cs +++ b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb3DataPersistence.cs @@ -110,9 +110,13 @@ private async Task GetAdminToken() return token; } - public async Task> GetData(string name, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new() + public async Task> GetData(string module, Dictionary filters, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new() { - using var client = new InfluxDBClient(await GetHost(), token: await GetAdminToken(), database: name); + // This method has not been maintained and tested. Disable it for now + throw new NotImplementedException(); + + + using var client = new InfluxDBClient(await GetHost(), token: await GetAdminToken(), database: module); var measurementNames = new List(); diff --git a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs index 2ac204f..f7c9771 100644 --- a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs +++ b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs @@ -96,9 +96,13 @@ private void UpdateConfigFile(string path, List monit array.Add(new TomlTable { - ["servers"] = new TomlArray { $"tcp://{source.Host.Value}:{source.Port}" }, + ["servers"] = new TomlArray { + $"tcp://{source.Host.Value}:{source.Port}" + }, ["interval"] = "30s", - ["tags"] = new TomlTable { ["instance"] = source.Name } + ["tags"] = new TomlTable { + ["instance"] = source.Name + } }); break; @@ -120,7 +124,7 @@ private void UpdateConfigFile(string path, List monit { ["urls"] = new TomlArray { $"http://{target.Host.Value}:{target.Port}" }, ["token"] = target.Password, - ["bucket"] = source.Name, + ["bucket"] = source.Module, ["organization"] = "tilework", ["tagpass"] = new TomlTable { diff --git a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs index 27c78f1..e3fe78f 100644 --- a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs +++ b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs @@ -453,12 +453,17 @@ public async Task Shutdown() await _configurator.Shutdown(); } - public async Task> GetMonitoringData(Guid id, DateTimeOffset start, DateTimeOffset end) + public async Task> GetLoadBalancerMonitoringData(Guid id, DateTimeOffset start, DateTimeOffset end) { var lb = await GetLoadBalancer(id); if(lb == null) throw new ArgumentException("Invalid load balancer id"); - return await _monitoringService.GetMonitoringData($"LoadBalancing-{lb.Id}", start, end); + var filters = new Dictionary(); + filters["instance"] = lb.Id.ToString(); + filters["type"] = "frontend"; + filters["proxy"] = lb.Id.ToString(); + + return await _monitoringService.GetMonitoringData("LoadBalancing", filters, start, end); } } diff --git a/tilework.core/Services/Monitoring/MonitoringService.cs b/tilework.core/Services/Monitoring/MonitoringService.cs index 9c5860d..42c2dd7 100644 --- a/tilework.core/Services/Monitoring/MonitoringService.cs +++ b/tilework.core/Services/Monitoring/MonitoringService.cs @@ -16,8 +16,8 @@ public MonitoringService(IDataPersistenceConfigurator persistenceConfigurator, _logger = logger; } - public async Task> GetMonitoringData(string monitorName, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new() + public async Task> GetMonitoringData(string module, Dictionary filters, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new() { - return await _persistenceConfigurator.GetData(monitorName, start, end); + return await _persistenceConfigurator.GetData(module, filters, start, end); } } \ No newline at end of file diff --git a/tilework.ui/Components/Layout/MainLayout.razor b/tilework.ui/Components/Layout/MainLayout.razor index c29362b..e2c128e 100644 --- a/tilework.ui/Components/Layout/MainLayout.razor +++ b/tilework.ui/Components/Layout/MainLayout.razor @@ -45,7 +45,7 @@ { Default = new DefaultTypography() { - FontFamily = new[] { "Inter", "Arial", "sans-serif" } + FontFamily = new[] { "Inter", "Helvetica", "Arial", "sans-serif" } } } }; diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index dca4ed0..a49b1d6 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -492,7 +492,7 @@ { var end = DateTimeOffset.UtcNow; var start = end.AddHours(-1); - var data = await _loadBalancerService.GetMonitoringData(_item.Id, start, end) ?? new List(); + var data = await _loadBalancerService.GetLoadBalancerMonitoringData(_item.Id, start, end) ?? new List(); var orderedData = data.OrderBy(d => d.Timestamp).ToList(); _series = new List(); From e99aabead96b208bd15124213a447730659a6a3d Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sat, 6 Dec 2025 15:54:48 +0000 Subject: [PATCH 057/104] Added requests too --- .../LoadBalancing/Monitoring/LoadBalancingMonitorData.cs | 2 +- tilework.core/Resources/telegraf.conf | 6 +++++- .../Pages/LoadBalancing/LoadBalancerDetail.razor | 8 ++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingMonitorData.cs b/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingMonitorData.cs index 8c116ba..833a313 100644 --- a/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingMonitorData.cs +++ b/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingMonitorData.cs @@ -6,5 +6,5 @@ namespace Tilework.LoadBalancing.Models; public class LoadBalancingMonitorData : BaseMonitorData { public int Sessions { get; set; } // stot - // public int Requests { get; set; } // req_tot + public int Requests { get; set; } // req_tot } \ No newline at end of file diff --git a/tilework.core/Resources/telegraf.conf b/tilework.core/Resources/telegraf.conf index d169995..7ea9f45 100644 --- a/tilework.core/Resources/telegraf.conf +++ b/tilework.core/Resources/telegraf.conf @@ -4,7 +4,7 @@ [[processors.starlark]] namepass = ["haproxy"] - fieldinclude = ["stot"] + fieldinclude = ["stot", "req_tot"] source = ''' state = {} @@ -57,3 +57,7 @@ def apply(metric): [[processors.rename.replace]] field = "stot" dest = "sessions" + + [[processors.rename.replace]] + field = "req_tot" + dest = "requests" diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index a49b1d6..1172374 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -536,6 +536,14 @@ .Select(d => (double)d.Sessions) .ToArray() }); + + _series.Add(new ChartSeries + { + Name = "Requests", + Data = orderedData + .Select(d => (double)d.Requests) + .ToArray() + }); } private static int GetRoundedIntervalMinutes(double approxMinutes) From 8e3596dbb3674209cd47585d8065e2645507fd6c Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sat, 6 Dec 2025 17:03:47 +0000 Subject: [PATCH 058/104] We have 2 mostly working graphs --- .../LoadBalancing/LoadBalancerDetail.razor | 119 +++++------------- .../Components/Shared/TimeseriesChart.razor | 97 ++++++++++++++ tilework.ui/Components/_Imports.razor | 3 +- 3 files changed, 133 insertions(+), 86 deletions(-) create mode 100644 tilework.ui/Components/Shared/TimeseriesChart.razor diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index 1172374..8fd0efe 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -1,3 +1,4 @@ +@using System @using Tilework.LoadBalancing.Enums @using Tilework.Core.Enums @using Tilework.LoadBalancing.Interfaces @@ -5,6 +6,7 @@ @using Tilework.CertificateManagement.Models @using Tilework.LoadBalancing.Models @using System.Linq +@using Tilework.Ui.Components.Shared @namespace Tilework.Ui.Components.Pages @@ -114,7 +116,7 @@ { } - else if (_series?.Any() == true) + else if (_monitoringData?.Any() == true) { @@ -126,23 +128,22 @@ - - - - - - + + + + + + + + + + + - + @* - - - - - + *@ @@ -176,15 +177,8 @@ private List _actions = new List(); - private ChartOptions _options = new ChartOptions() - { - ShowLegend=false - }; - private AxisChartOptions _axisChartOptions = new AxisChartOptions(); - - private string[] _chartLabels = Array.Empty(); - private List _series = new(); private bool _statsLoading = false; + private List _monitoringData = new(); private int GetMaxRulePriority() => _rules.Count() > 0 ? _rules.Max(p => p.Priority) + 1 : 0; @@ -490,78 +484,33 @@ private async Task RefreshMonitoringData() { - var end = DateTimeOffset.UtcNow; - var start = end.AddHours(-1); - var data = await _loadBalancerService.GetLoadBalancerMonitoringData(_item.Id, start, end) ?? new List(); - - var orderedData = data.OrderBy(d => d.Timestamp).ToList(); - _series = new List(); - if (orderedData.Count == 0) - { - _chartLabels = Array.Empty(); - return; - } - - const int targetLabelCount = 6; - var localTimes = orderedData.Select(d => d.Timestamp.ToLocalTime().DateTime).ToList(); - var duration = localTimes.Last() - localTimes.First(); - var approxIntervalMinutes = Math.Max(1, duration.TotalMinutes / targetLabelCount); - var intervalMinutes = GetRoundedIntervalMinutes(approxIntervalMinutes); - var interval = TimeSpan.FromMinutes(intervalMinutes); - var nextLabelTime = AlignToNextInterval(localTimes.First(), interval); + _statsLoading = true; + StateHasChanged(); - _chartLabels = new string[orderedData.Count]; - for (var i = 0; i < orderedData.Count; i++) + try { - var current = localTimes[i]; - var label = string.Empty; + var end = DateTimeOffset.UtcNow; + var start = end.AddHours(-1); + var data = await _loadBalancerService.GetLoadBalancerMonitoringData(_item.Id, start, end) + ?? new List(); - if (current >= nextLabelTime) - { - while (current >= nextLabelTime) - { - nextLabelTime = nextLabelTime.Add(interval); - } - - label = nextLabelTime.Subtract(interval).ToString("HH:mm"); - } - - _chartLabels[i] = label; + _monitoringData = data; } - - _series.Add(new ChartSeries + finally { - Name = "Sessions", - Data = orderedData - .Select(d => (double)d.Sessions) - .ToArray() - }); - - _series.Add(new ChartSeries - { - Name = "Requests", - Data = orderedData - .Select(d => (double)d.Requests) - .ToArray() - }); + _statsLoading = false; + } } - private static int GetRoundedIntervalMinutes(double approxMinutes) + private Dictionary BuildSeries(Func valueSelector) { - int[] allowed = { 1, 2, 5, 10, 15, 30, 60, 120, 240, 720, 1440 }; - foreach (var candidate in allowed) + var series = new Dictionary(); + foreach (var entry in _monitoringData.OrderBy(d => d.Timestamp)) { - if (approxMinutes <= candidate) - { - return candidate; - } + var localTimestamp = entry.Timestamp.ToLocalTime().DateTime; + series[localTimestamp] = valueSelector(entry); } - return allowed.Last(); - } - private static DateTime AlignToNextInterval(DateTime time, TimeSpan interval) - { - var ticks = ((time.Ticks + interval.Ticks - 1) / interval.Ticks) * interval.Ticks; - return new DateTime(ticks, time.Kind); + return series; } } diff --git a/tilework.ui/Components/Shared/TimeseriesChart.razor b/tilework.ui/Components/Shared/TimeseriesChart.razor new file mode 100644 index 0000000..5123250 --- /dev/null +++ b/tilework.ui/Components/Shared/TimeseriesChart.razor @@ -0,0 +1,97 @@ +@using System.Linq +@using Tilework.LoadBalancing.Models + +
+ @Name + +
+ +@code { + [Parameter] + public string Name { get; set; } = string.Empty; + [Parameter] + public Dictionary Data { get; set; } = new(); + + private readonly ChartOptions _chartOptions = new ChartOptions { ShowLegend = false }; + private readonly AxisChartOptions _axisChartOptions = new AxisChartOptions(); + private string[] _chartLabels = Array.Empty(); + private List _series = new(); + + protected override void OnParametersSet() + { + BuildChartData(); + } + + private void BuildChartData() + { + var orderedData = Data.OrderBy(kv => kv.Key).ToList(); + if (orderedData.Count == 0) + { + _chartLabels = Array.Empty(); + _series = new List(); + return; + } + + const int targetLabelCount = 6; + + var times = orderedData.Select(d => d.Key).ToList(); + var duration = times.Last() - times.First(); + var approxIntervalMinutes = Math.Max(1, duration.TotalMinutes / targetLabelCount); + var intervalMinutes = GetRoundedIntervalMinutes(approxIntervalMinutes); + var interval = TimeSpan.FromMinutes(intervalMinutes); + var nextLabelTime = AlignToNextInterval(times.First(), interval); + + _chartLabels = new string[orderedData.Count]; + for (var i = 0; i < orderedData.Count; i++) + { + var current = times[i]; + var label = string.Empty; + + if (current >= nextLabelTime) + { + while (current >= nextLabelTime) + { + nextLabelTime = nextLabelTime.Add(interval); + } + + label = nextLabelTime.Subtract(interval).ToString("HH:mm"); + } + + _chartLabels[i] = label; + } + + _series = new List + { + new ChartSeries + { + Name = Name, + Data = orderedData.Select(d => (double)d.Value).ToArray() + } + }; + } + + private static int GetRoundedIntervalMinutes(double approxMinutes) + { + int[] allowed = { 1, 2, 5, 10, 15, 30, 60, 120, 240, 720, 1440 }; + foreach (var candidate in allowed) + { + if (approxMinutes <= candidate) + { + return candidate; + } + } + return allowed.Last(); + } + + private static DateTime AlignToNextInterval(DateTime time, TimeSpan interval) + { + var ticks = ((time.Ticks + interval.Ticks - 1) / interval.Ticks) * interval.Ticks; + return new DateTime(ticks, time.Kind); + } +} diff --git a/tilework.ui/Components/_Imports.razor b/tilework.ui/Components/_Imports.razor index 4daebb2..2676ec0 100644 --- a/tilework.ui/Components/_Imports.razor +++ b/tilework.ui/Components/_Imports.razor @@ -14,5 +14,6 @@ @using Tilework.Ui.Components.Layout @using Tilework.Ui.Components.Dialogs @using Tilework.Ui.Components.Forms +@using Tilework.Ui.Components.Shared @using Tilework.Ui.Models -@using Tilework.Ui.ViewModels \ No newline at end of file +@using Tilework.Ui.ViewModels From e70310e9a3418630e7470581774c4cbdb2983ac4 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sat, 6 Dec 2025 17:20:18 +0000 Subject: [PATCH 059/104] Minor prettification --- tilework.ui/Components/Shared/TimeseriesChart.razor | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tilework.ui/Components/Shared/TimeseriesChart.razor b/tilework.ui/Components/Shared/TimeseriesChart.razor index 5123250..96854de 100644 --- a/tilework.ui/Components/Shared/TimeseriesChart.razor +++ b/tilework.ui/Components/Shared/TimeseriesChart.razor @@ -18,7 +18,11 @@ [Parameter] public Dictionary Data { get; set; } = new(); - private readonly ChartOptions _chartOptions = new ChartOptions { ShowLegend = false }; + private readonly ChartOptions _chartOptions = new ChartOptions { + ShowLegend = false, + LineStrokeWidth = 2, + ChartPalette = [Colors.Green.Darken4] + }; private readonly AxisChartOptions _axisChartOptions = new AxisChartOptions(); private string[] _chartLabels = Array.Empty(); private List _series = new(); From 136e3fccbc8d0519eaca22065799e1b25c920d27 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sat, 6 Dec 2025 17:49:30 +0000 Subject: [PATCH 060/104] added refresh button --- .../LoadBalancing/LoadBalancerDetail.razor | 80 ++++++++++--------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index 8fd0efe..f9f28ca 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -112,46 +112,52 @@ } - @if (_statsLoading) - { - - } - else if (_monitoringData?.Any() == true) - { - - - - Monitoring - - - - - - - - - - - - - - - - - + + + + + Monitoring + + + + 1h + 3h + 12h + 1d + 3d + 1w + + + + + + + + @if (_statsLoading) + { + + } + else if (_monitoringData?.Any() == true) + { + + + + + - @* - + + + - *@ + + @* + + + *@ - - - } - else - { - No statistics available. - } + } + + From 3275791b201d1321677a2521f0df4dc8698805f9 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 7 Dec 2025 09:05:03 +0000 Subject: [PATCH 061/104] cleanup --- .../Components/Pages/LoadBalancing/LoadBalancerDetail.razor | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index f9f28ca..e9a4038 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -119,7 +119,8 @@ Monitoring - +
+ 1h 3h 12h @@ -130,6 +131,7 @@ +
From 58e395940d49d98f91daa9cb4005549f4b3d2383 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 7 Dec 2025 09:29:54 +0000 Subject: [PATCH 062/104] cleanup --- tilework.ui/tilework.ui.csproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/tilework.ui/tilework.ui.csproj b/tilework.ui/tilework.ui.csproj index df0e653..b067493 100644 --- a/tilework.ui/tilework.ui.csproj +++ b/tilework.ui/tilework.ui.csproj @@ -22,8 +22,6 @@ - - From dcb4b6f1bf42551972beb7a2bd085a1490a193c7 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 7 Dec 2025 09:31:49 +0000 Subject: [PATCH 063/104] update --- tilework.ui/tilework.ui.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tilework.ui/tilework.ui.csproj b/tilework.ui/tilework.ui.csproj index b067493..7d0d819 100644 --- a/tilework.ui/tilework.ui.csproj +++ b/tilework.ui/tilework.ui.csproj @@ -16,7 +16,7 @@ all
- +
From a50e3ce69837aac2d2f025051975bc4c49d76fd9 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 7 Dec 2025 10:15:59 +0000 Subject: [PATCH 064/104] Initial implementation of time range selection --- .../LoadBalancing/LoadBalancerDetail.razor | 60 ++++++++++++++----- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index e9a4038..c5e6b8c 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -120,17 +120,19 @@
- - 1h - 3h - 12h - 1d - 3d - 1w - - - - + + + + + + + + +
+ + + +
@@ -176,6 +178,8 @@ private List _certificates = new(); private bool _showCertificates; + private string _selectedRange = "1h"; + private List _breadcrumbs = new List { new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), @@ -486,19 +490,47 @@ if(_item != null && _item.Enabled == true && index == GetMonitoringTabIndex()) { - await RefreshMonitoringData(); + await RefreshMonitoringData(_selectedRange); } } - private async Task RefreshMonitoringData() + private async Task RefreshMonitoringData(string range) { _statsLoading = true; StateHasChanged(); + _selectedRange = range; + try { var end = DateTimeOffset.UtcNow; - var start = end.AddHours(-1); + DateTimeOffset start; + switch(range) + { + case "1h": + start = end.AddHours(-1); + break; + case "3h": + start = end.AddHours(-3); + break; + case "12h": + start = end.AddHours(-12); + break; + case "1d": + start = end.AddDays(-1); + break; + case "3d": + start = end.AddDays(-3); + break; + case "1w": + start = end.AddDays(-7); + break; + default: + start = end.AddHours(-1); + break; + } + + var data = await _loadBalancerService.GetLoadBalancerMonitoringData(_item.Id, start, end) ?? new List(); From 9b93f0a117c368268afe8a21115c4c7745c40eba Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 7 Dec 2025 12:48:15 +0000 Subject: [PATCH 065/104] Default charts will be in local browser timezone --- tilework.ui/Components/App.razor | 1 + .../LoadBalancing/LoadBalancerDetail.razor | 44 +++++++++-------- tilework.ui/ServiceCollectionExtensions.cs | 2 + .../Services/BrowserTimeZoneProvider.cs | 49 +++++++++++++++++++ .../Services/IBrowserTimeZoneProvider.cs | 6 +++ tilework.ui/wwwroot/js/browserTimeZone.js | 3 ++ 6 files changed, 85 insertions(+), 20 deletions(-) create mode 100644 tilework.ui/Services/BrowserTimeZoneProvider.cs create mode 100644 tilework.ui/Services/IBrowserTimeZoneProvider.cs create mode 100644 tilework.ui/wwwroot/js/browserTimeZone.js diff --git a/tilework.ui/Components/App.razor b/tilework.ui/Components/App.razor index 34e7fe1..69f413c 100644 --- a/tilework.ui/Components/App.razor +++ b/tilework.ui/Components/App.razor @@ -14,6 +14,7 @@ + diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index c5e6b8c..1241557 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -1,11 +1,16 @@ @using System -@using Tilework.LoadBalancing.Enums +@using System.Linq + @using Tilework.Core.Enums +@using Tilework.LoadBalancing.Enums @using Tilework.LoadBalancing.Interfaces +@using Tilework.LoadBalancing.Models + @using Tilework.CertificateManagement.Interfaces @using Tilework.CertificateManagement.Models -@using Tilework.LoadBalancing.Models -@using System.Linq + + +@using Tilework.Ui.Services @using Tilework.Ui.Components.Shared @namespace Tilework.Ui.Components.Pages @@ -15,6 +20,7 @@ @inject NavigationManager _navigationManager @inject ISnackbar _snackbar @inject ICertificateManagementService _certificateService +@inject IBrowserTimeZoneProvider _browserTimezoneProvider @page "/lb/loadbalancers/{Id:guid}" @@ -146,12 +152,12 @@ - + - + @* @@ -190,7 +196,7 @@ private bool _statsLoading = false; - private List _monitoringData = new(); + private Dictionary> _monitoringData = new(); private int GetMaxRulePriority() => _rules.Count() > 0 ? _rules.Max(p => p.Priority) + 1 : 0; @@ -531,26 +537,24 @@ } - var data = await _loadBalancerService.GetLoadBalancerMonitoringData(_item.Id, start, end) + List? data = await _loadBalancerService.GetLoadBalancerMonitoringData(_item.Id, start, end) ?? new List(); - _monitoringData = data; + var tz = await _browserTimezoneProvider.GetTimeZoneAsync(); + + _monitoringData["sessions"] = new(); + _monitoringData["requests"] = new(); + + foreach (var entry in data.OrderBy(d => d.Timestamp)) + { + var localTimestamp = TimeZoneInfo.ConvertTime(entry.Timestamp.ToUniversalTime(), tz).DateTime; + _monitoringData["sessions"][localTimestamp] = entry.Sessions; + _monitoringData["requests"][localTimestamp] = entry.Requests; + } } finally { _statsLoading = false; } } - - private Dictionary BuildSeries(Func valueSelector) - { - var series = new Dictionary(); - foreach (var entry in _monitoringData.OrderBy(d => d.Timestamp)) - { - var localTimestamp = entry.Timestamp.ToLocalTime().DateTime; - series[localTimestamp] = valueSelector(entry); - } - - return series; - } } diff --git a/tilework.ui/ServiceCollectionExtensions.cs b/tilework.ui/ServiceCollectionExtensions.cs index d2461be..777cb3b 100644 --- a/tilework.ui/ServiceCollectionExtensions.cs +++ b/tilework.ui/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Tilework.Ui.Mappers; +using Tilework.Ui.Services; namespace Tilework.Ui.ViewModels; @@ -7,6 +8,7 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddUserInterface(this IServiceCollection services) { services.AddAutoMapper(typeof(FormMappingProfile)); + services.AddScoped(); return services; } diff --git a/tilework.ui/Services/BrowserTimeZoneProvider.cs b/tilework.ui/Services/BrowserTimeZoneProvider.cs new file mode 100644 index 0000000..f7a1ebc --- /dev/null +++ b/tilework.ui/Services/BrowserTimeZoneProvider.cs @@ -0,0 +1,49 @@ +using Microsoft.JSInterop; + +namespace Tilework.Ui.Services; + +public sealed class BrowserTimeZoneProvider : IBrowserTimeZoneProvider +{ + private readonly IJSRuntime _jsRuntime; + private TimeZoneInfo? _cachedTimeZone; + + public BrowserTimeZoneProvider(IJSRuntime jsRuntime) + { + _jsRuntime = jsRuntime; + } + + public async ValueTask GetTimeZoneAsync(CancellationToken cancellationToken = default) + { + if (_cachedTimeZone is not null) + { + return _cachedTimeZone; + } + + var timeZoneId = await _jsRuntime.InvokeAsync( + identifier: "timeZoneInterop.getTimeZone", + cancellationToken: cancellationToken); + + _cachedTimeZone = ResolveTimeZone(timeZoneId); + return _cachedTimeZone; + } + + private static TimeZoneInfo ResolveTimeZone(string? timeZoneId) + { + if (!string.IsNullOrWhiteSpace(timeZoneId)) + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + } + catch (TimeZoneNotFoundException) + { + } + catch (InvalidTimeZoneException) + { + } + } + + // Fall back to UTC + return TimeZoneInfo.Utc; + } +} diff --git a/tilework.ui/Services/IBrowserTimeZoneProvider.cs b/tilework.ui/Services/IBrowserTimeZoneProvider.cs new file mode 100644 index 0000000..6fb8bcd --- /dev/null +++ b/tilework.ui/Services/IBrowserTimeZoneProvider.cs @@ -0,0 +1,6 @@ +namespace Tilework.Ui.Services; + +public interface IBrowserTimeZoneProvider +{ + ValueTask GetTimeZoneAsync(CancellationToken cancellationToken = default); +} diff --git a/tilework.ui/wwwroot/js/browserTimeZone.js b/tilework.ui/wwwroot/js/browserTimeZone.js new file mode 100644 index 0000000..7a7f8aa --- /dev/null +++ b/tilework.ui/wwwroot/js/browserTimeZone.js @@ -0,0 +1,3 @@ +window.timeZoneInterop = window.timeZoneInterop || { + getTimeZone: () => Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC" +}; From dba10a41f7ce05c081e88c1aab372527dc555077 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 7 Dec 2025 14:38:29 +0000 Subject: [PATCH 066/104] Added fill of missing data + interval implementation --- .../LoadBalancing/ILoadBalancerService.cs | 2 +- .../IDataPersistenceConfigurator.cs | 2 +- .../Influxdb/Influxdb2DataPersistence.cs | 23 +++++++++-- .../Influxdb/Influxdb3DataPersistence.cs | 2 +- .../LoadBalancing/LoadBalancerService.cs | 4 +- .../Services/Monitoring/MonitoringService.cs | 4 +- .../LoadBalancing/LoadBalancerDetail.razor | 6 +-- .../Components/Shared/TimeseriesChart.razor | 38 +++++++++++++++---- 8 files changed, 61 insertions(+), 20 deletions(-) diff --git a/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs b/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs index 64a9daf..d852ab3 100644 --- a/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs +++ b/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs @@ -42,7 +42,7 @@ public interface ILoadBalancerService public Task UpdateTarget(TargetGroupDTO group, TargetDTO target); public Task RemoveTarget(TargetGroupDTO group, TargetDTO target); - public Task> GetLoadBalancerMonitoringData(Guid Id, DateTimeOffset start, DateTimeOffset end); + public Task> GetLoadBalancerMonitoringData(Guid Id, TimeSpan interval, DateTimeOffset start, DateTimeOffset end); public Task ApplyConfiguration(); public Task Shutdown(); diff --git a/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs b/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs index 302a50e..475cbff 100644 --- a/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs +++ b/tilework.core/Interfaces/Monitoring/IDataPersistenceConfigurator.cs @@ -5,7 +5,7 @@ namespace Tilework.Monitoring.Interfaces; public interface IDataPersistenceConfigurator { Task GetTarget(MonitoringSource source); - Task> GetData(string module, Dictionary filters, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new(); + Task> GetData(string module, Dictionary filters, TimeSpan interval, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new(); Task ApplyConfiguration(); Task Shutdown(); diff --git a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs index 8dc8db2..4aacbfc 100644 --- a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs +++ b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs @@ -189,11 +189,9 @@ private async Task GetOrgId(string orgName) return org[0].Id; } - public async Task> GetData(string module, Dictionary filters, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new() + public async Task> GetData(string module, Dictionary filters, TimeSpan interval, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new() { using var client = new InfluxDBClient(await GetHost(), token: await GetAdminToken()); - // using var client = new InfluxDBClient("http://ifu.wh:8086", token: "{Nc97#*%4DggeR3w"); - // name = "LoadBalancing-4283cffc-903d-4360-be0f-861f77fd2318"; var queryApi = client.GetQueryApi(); @@ -215,6 +213,10 @@ private async Task GetOrgId(string orgName) query += $"\n |> filter(fn: (r) => {filtersCombined})"; } + query += $" |> aggregateWindow(every: {ToFluxDuration(interval)}, fn: sum, createEmpty: true)"; + + + var fluxTables = await queryApi.QueryAsync(query, _orgName); var entryProperties = typeof(T) @@ -262,6 +264,21 @@ private async Task GetOrgId(string orgName) return data; } + private string ToFluxDuration(TimeSpan ts) + { + var parts = new List(); + + if (ts.Days > 0) parts.Add($"{ts.Days}d"); + if (ts.Hours > 0) parts.Add($"{ts.Hours}h"); + if (ts.Minutes > 0) parts.Add($"{ts.Minutes}m"); + if (ts.Seconds > 0) parts.Add($"{ts.Seconds}s"); + if (ts.Milliseconds > 0) parts.Add($"{ts.Milliseconds}ms"); + + // If everything is zero, return "0s" + return parts.Count > 0 ? string.Join("", parts) : "0s"; + } + + private static bool TryConvertFieldValue(object value, Type targetType, out object? convertedValue) { var destinationType = Nullable.GetUnderlyingType(targetType) ?? targetType; diff --git a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb3DataPersistence.cs b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb3DataPersistence.cs index c66fd93..d18b3bd 100644 --- a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb3DataPersistence.cs +++ b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb3DataPersistence.cs @@ -110,7 +110,7 @@ private async Task GetAdminToken() return token; } - public async Task> GetData(string module, Dictionary filters, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new() + public async Task> GetData(string module, Dictionary filters, TimeSpan interval, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new() { // This method has not been maintained and tested. Disable it for now throw new NotImplementedException(); diff --git a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs index e3fe78f..eab892c 100644 --- a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs +++ b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs @@ -453,7 +453,7 @@ public async Task Shutdown() await _configurator.Shutdown(); } - public async Task> GetLoadBalancerMonitoringData(Guid id, DateTimeOffset start, DateTimeOffset end) + public async Task> GetLoadBalancerMonitoringData(Guid id, TimeSpan interval, DateTimeOffset start, DateTimeOffset end) { var lb = await GetLoadBalancer(id); if(lb == null) @@ -464,6 +464,6 @@ public async Task> GetLoadBalancerMonitoringData( filters["type"] = "frontend"; filters["proxy"] = lb.Id.ToString(); - return await _monitoringService.GetMonitoringData("LoadBalancing", filters, start, end); + return await _monitoringService.GetMonitoringData("LoadBalancing", filters, interval, start, end); } } diff --git a/tilework.core/Services/Monitoring/MonitoringService.cs b/tilework.core/Services/Monitoring/MonitoringService.cs index 42c2dd7..e026e57 100644 --- a/tilework.core/Services/Monitoring/MonitoringService.cs +++ b/tilework.core/Services/Monitoring/MonitoringService.cs @@ -16,8 +16,8 @@ public MonitoringService(IDataPersistenceConfigurator persistenceConfigurator, _logger = logger; } - public async Task> GetMonitoringData(string module, Dictionary filters, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new() + public async Task> GetMonitoringData(string module, Dictionary filters, TimeSpan interval, DateTimeOffset start, DateTimeOffset end) where T : BaseMonitorData, new() { - return await _persistenceConfigurator.GetData(module, filters, start, end); + return await _persistenceConfigurator.GetData(module, filters, interval, start, end); } } \ No newline at end of file diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index 1241557..c9c9852 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -535,9 +535,9 @@ start = end.AddHours(-1); break; } - - - List? data = await _loadBalancerService.GetLoadBalancerMonitoringData(_item.Id, start, end) + + var interval = new TimeSpan(hours: 0, minutes: 1, seconds: 0); + List? data = await _loadBalancerService.GetLoadBalancerMonitoringData(_item.Id, interval, start, end) ?? new List(); var tz = await _browserTimezoneProvider.GetTimeZoneAsync(); diff --git a/tilework.ui/Components/Shared/TimeseriesChart.razor b/tilework.ui/Components/Shared/TimeseriesChart.razor index 96854de..2b5b857 100644 --- a/tilework.ui/Components/Shared/TimeseriesChart.razor +++ b/tilework.ui/Components/Shared/TimeseriesChart.razor @@ -42,14 +42,25 @@ return; } - const int targetLabelCount = 6; - var times = orderedData.Select(d => d.Key).ToList(); var duration = times.Last() - times.First(); - var approxIntervalMinutes = Math.Max(1, duration.TotalMinutes / targetLabelCount); - var intervalMinutes = GetRoundedIntervalMinutes(approxIntervalMinutes); - var interval = TimeSpan.FromMinutes(intervalMinutes); - var nextLabelTime = AlignToNextInterval(times.First(), interval); + + const int targetLabelCount = 6; + var useDayLabels = duration >= TimeSpan.FromHours(48); + TimeSpan interval; + DateTime nextLabelTime; + if (useDayLabels) + { + interval = TimeSpan.FromDays(1); + nextLabelTime = AlignToNextMidday(times.First()); + } + else + { + var approxIntervalMinutes = Math.Max(1, duration.TotalMinutes / targetLabelCount); + var intervalMinutes = GetRoundedIntervalMinutes(approxIntervalMinutes); + interval = TimeSpan.FromMinutes(intervalMinutes); + nextLabelTime = AlignToNextInterval(times.First(), interval); + } _chartLabels = new string[orderedData.Count]; for (var i = 0; i < orderedData.Count; i++) @@ -64,7 +75,7 @@ nextLabelTime = nextLabelTime.Add(interval); } - label = nextLabelTime.Subtract(interval).ToString("HH:mm"); + label = FormatLabel(nextLabelTime.Subtract(interval), useDayLabels); } _chartLabels[i] = label; @@ -98,4 +109,17 @@ var ticks = ((time.Ticks + interval.Ticks - 1) / interval.Ticks) * interval.Ticks; return new DateTime(ticks, time.Kind); } + + private static DateTime AlignToNextMidday(DateTime time) + { + var midday = time.Date.AddHours(12); + return time <= midday ? midday : midday.AddDays(1); + } + + private static string FormatLabel(DateTime labelTime, bool useDayLabels) + { + return useDayLabels + ? labelTime.ToString("ddd dd") + : labelTime.ToString("HH:mm"); + } } From cb8744ffe576a215758b67f92cd7fd37f72358ea Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 7 Dec 2025 15:40:17 +0000 Subject: [PATCH 067/104] Simplified certificate signing. Fixes #45 --- .../CertificateManagementService.cs | 35 +++++-------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/tilework.core/Services/CertificateManagement/CertificateManagementService.cs b/tilework.core/Services/CertificateManagement/CertificateManagementService.cs index c5dd2bc..50a2a06 100644 --- a/tilework.core/Services/CertificateManagement/CertificateManagementService.cs +++ b/tilework.core/Services/CertificateManagement/CertificateManagementService.cs @@ -129,21 +129,11 @@ public async Task AddCertificate(string name, string fqdn, KeyAl PrivateKey = key }; - try - { - _dbContext.Certificates.Add(certificate); - // TODO: Currently, the process is synchronous so either everything succeeds or nothing. - // Eventually, the signing process should be done in the background and we could save the - // thing here - // await _dbContext.SaveChangesAsync(); - await SignCertificate(certificate); - } - catch - { - _dbContext.ChangeTracker.Clear(); - throw; - } + await SignCertificate(certificate); + + _dbContext.Certificates.Add(certificate); + await _dbContext.SaveChangesAsync(); return _mapper.Map(certificate); } @@ -215,8 +205,6 @@ private async Task SignCertificate(Certificate certificate) certificate.Status = CertificateStatus.ACTIVE; certificate.Authority.Parameters = DeserializeConfig(config); - - await _dbContext.SaveChangesAsync(); } private async Task RevokeCertificate(Certificate certificate) @@ -245,17 +233,10 @@ public async Task RevokeCertificate(Guid Id) private async Task RenewCertificate(Certificate certificate) { - try - { - certificate.Status = CertificateStatus.NEW; - certificate.PrivateKey = GenerateKey(certificate.PrivateKey.Algorithm); - await SignCertificate(certificate); - } - catch - { - _dbContext.ChangeTracker.Clear(); - throw; - } + certificate.Status = CertificateStatus.NEW; + certificate.PrivateKey = GenerateKey(certificate.PrivateKey.Algorithm); + await SignCertificate(certificate); + await _dbContext.SaveChangesAsync(); var cert = _mapper.Map(certificate); From 63136baae988c126019c6c20ae1efc7b2b911b12 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 7 Dec 2025 16:02:49 +0000 Subject: [PATCH 068/104] Fixes #38 --- .../CertificateManagement/CertificateManagementService.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tilework.core/Services/CertificateManagement/CertificateManagementService.cs b/tilework.core/Services/CertificateManagement/CertificateManagementService.cs index 50a2a06..7c55b44 100644 --- a/tilework.core/Services/CertificateManagement/CertificateManagementService.cs +++ b/tilework.core/Services/CertificateManagement/CertificateManagementService.cs @@ -118,6 +118,10 @@ public async Task> GetCertificates() public async Task AddCertificate(string name, string fqdn, KeyAlgorithm algorithm, Guid authorityId) { + var nameExists = await _dbContext.Certificates.AnyAsync(c => c.Name == name); + if (nameExists) + throw new ArgumentException($"Certificate with name '{name}' already exists.", nameof(name)); + var authority = await _dbContext.CertificateAuthorities.FindAsync(authorityId); var key = GenerateKey(algorithm); From d84c40eeb43c94dc1cc66dae094b136704ba5b2b Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Thu, 25 Dec 2025 18:59:42 +0000 Subject: [PATCH 069/104] Cleaned up container code --- .../Enums/Core/ContainerRestartType.cs | 7 + .../ILoadBalancingConfigurator.cs | 2 - .../Monitoring/IDataCollectorConfigurator.cs | 2 - tilework.core/Models/Core/ContainerFile.cs | 7 + tilework.core/Models/Core/ContainerPort.cs | 2 +- .../Providers/BaseContainerProvider.cs | 99 -------------- .../Influxdb/Influxdb2DataPersistence.cs | 19 ++- .../Influxdb/Influxdb3DataPersistence.cs | 15 ++- .../Telegraf/TelegrafDataCollector.cs | 77 +++-------- .../Providers/Shared/BaseContainerProvider.cs | 126 ++++++++++++++++++ .../Services/Core/DockerServiceManager.cs | 1 - 11 files changed, 178 insertions(+), 179 deletions(-) create mode 100644 tilework.core/Enums/Core/ContainerRestartType.cs create mode 100644 tilework.core/Models/Core/ContainerFile.cs delete mode 100644 tilework.core/Providers/BaseContainerProvider.cs create mode 100644 tilework.core/Providers/Shared/BaseContainerProvider.cs diff --git a/tilework.core/Enums/Core/ContainerRestartType.cs b/tilework.core/Enums/Core/ContainerRestartType.cs new file mode 100644 index 0000000..5167f5e --- /dev/null +++ b/tilework.core/Enums/Core/ContainerRestartType.cs @@ -0,0 +1,7 @@ +namespace Tilework.Core.Enums; +public enum ContainerRestartType +{ + SIGNAL, + RESTART, + RECREATE +} \ No newline at end of file diff --git a/tilework.core/Interfaces/LoadBalancing/ILoadBalancingConfigurator.cs b/tilework.core/Interfaces/LoadBalancing/ILoadBalancingConfigurator.cs index dbea1ab..d1de7b3 100644 --- a/tilework.core/Interfaces/LoadBalancing/ILoadBalancingConfigurator.cs +++ b/tilework.core/Interfaces/LoadBalancing/ILoadBalancingConfigurator.cs @@ -4,8 +4,6 @@ namespace Tilework.LoadBalancing.Interfaces; public interface ILoadBalancingConfigurator { - string ServiceName { get; } - List LoadConfiguration(); Task ApplyConfiguration(List config); Task Shutdown(); diff --git a/tilework.core/Interfaces/Monitoring/IDataCollectorConfigurator.cs b/tilework.core/Interfaces/Monitoring/IDataCollectorConfigurator.cs index 9bd6567..4cf295a 100644 --- a/tilework.core/Interfaces/Monitoring/IDataCollectorConfigurator.cs +++ b/tilework.core/Interfaces/Monitoring/IDataCollectorConfigurator.cs @@ -2,8 +2,6 @@ namespace Tilework.Monitoring.Interfaces; public interface IDataCollectorConfigurator { - string ServiceName { get; } - Task ApplyConfiguration(List monitors); Task Shutdown(); } \ No newline at end of file diff --git a/tilework.core/Models/Core/ContainerFile.cs b/tilework.core/Models/Core/ContainerFile.cs new file mode 100644 index 0000000..ad813be --- /dev/null +++ b/tilework.core/Models/Core/ContainerFile.cs @@ -0,0 +1,7 @@ +namespace Tilework.Core.Models; + +public class ContainerFile +{ + public string LocalPath { get; set; } + public string ContainerPath { get; set; } +} \ No newline at end of file diff --git a/tilework.core/Models/Core/ContainerPort.cs b/tilework.core/Models/Core/ContainerPort.cs index 7000401..b2ca21a 100644 --- a/tilework.core/Models/Core/ContainerPort.cs +++ b/tilework.core/Models/Core/ContainerPort.cs @@ -1,6 +1,6 @@ using Tilework.Core.Enums; - namespace Tilework.Core.Models; +namespace Tilework.Core.Models; public class ContainerPort { diff --git a/tilework.core/Providers/BaseContainerProvider.cs b/tilework.core/Providers/BaseContainerProvider.cs deleted file mode 100644 index ddb376a..0000000 --- a/tilework.core/Providers/BaseContainerProvider.cs +++ /dev/null @@ -1,99 +0,0 @@ -using Microsoft.Extensions.Logging; - - -using Tilework.Core.Interfaces; -using Tilework.Core.Models; -using Tilework.Core.Enums; - - -using Tilework.Core.Models; - -public abstract class BaseContainerProvider -{ - private readonly ILogger _logger; - private readonly IContainerManager _containerManager; - - private readonly string _module; - private readonly string _name; - private readonly string _imageName; - private readonly List _ports; - - private string _fullName => $"{_module}.{_name}"; - private string _fullModule => $"{_module}.tile"; - - - public BaseContainerProvider(IContainerManager containerManager, - ILogger logger, - string module, - string name, - string imageName, - List ports) - { - _containerManager = containerManager; - _logger = logger; - - _name = name; - _module = module; - _imageName = imageName; - _ports = ports; - } - - protected async Task GetContainer() - { - var containers = await _containerManager.ListContainers(_fullModule); - - return containers.FirstOrDefault(c => c.Name == _fullName); - } - - private async Task CreateContainer() - { - try - { - var container = await _containerManager.CreateContainer( - _fullName, _imageName, _fullModule, _ports - ); - - return container; - } - catch (Exception ex) - { - _logger.LogCritical($"Failed to create container {_fullName}: {ex}"); - throw; - } - } - - protected async Task StartUp() - { - var container = await GetContainer(); - if (container == null) - { - _logger.LogInformation($"Creating container {_fullName}"); - container = await CreateContainer(); - } - - - if (container.State != ContainerState.Running) - { - _logger.LogInformation($"Starting container {_fullName}"); - await _containerManager.StartContainer(container.Id); - } - else - { - _logger.LogInformation($"Restarting container {_fullName}"); - await _containerManager.StopContainer(container.Id); - await _containerManager.StartContainer(container.Id); - } - } - - public async Task Shutdown() - { - var container = await GetContainer(); - if (container != null) - { - _logger.LogInformation($"Stopping and deleting container {_fullName}"); - if (container.State == ContainerState.Running) - await _containerManager.StopContainer(container.Id); - await _containerManager.DeleteContainer(container.Id); - } - } -} \ No newline at end of file diff --git a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs index 4aacbfc..9550860 100644 --- a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs +++ b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs @@ -52,7 +52,7 @@ public Influxdb2Configurator(IOptions settings, ILogger logger, TokenService tokenService, HttpApiFactoryService httpApiFactoryService, - IMapper mapper) : base(containerManager, logger, _moduleName, _serviceName, settings.Value.BackendImage, _ports) + IMapper mapper) : base(containerManager, logger, _moduleName, _serviceName, settings.Value.BackendImage) { _logger = logger; _settings = settings.Value; @@ -65,7 +65,7 @@ public Influxdb2Configurator(IOptions settings, public async Task GetTarget(MonitoringSource source) { - var container = await GetContainer(); + var container = await GetContainer(_serviceName); await CheckCreateBucket(_orgName, source.Module); @@ -81,13 +81,18 @@ public async Task GetTarget(MonitoringSource source) public async Task ApplyConfiguration() { - var container = await GetContainer(); + var container = await GetContainer(_serviceName); if(container == null || container.State != ContainerState.Running) - await StartUp(); + await StartUp(_serviceName, _ports, new(), ContainerRestartType.RESTART); await CheckRunSetup(); } + public async Task Shutdown() + { + await Shutdown(_serviceName); + } + private async Task CheckRunSetup() { var service = await GetApiService(); @@ -99,7 +104,7 @@ private async Task CheckRunSetup() if(resp.Allowed == true) { - var container = await GetContainer(); + var container = await GetContainer(_serviceName); var tokenKey = $"influxdb.{container.Id}"; await _tokenService.DeleteToken(tokenKey); @@ -126,14 +131,14 @@ private async Task GetApiService() private async Task GetHost() { - var container = await GetContainer(); + var container = await GetContainer(_serviceName); var host = Host.Parse((await _containerManager.GetContainerAddress(container.Id)).ToString()); return $"http://{host.Value}:8086"; } private async Task GetAdminToken() { - var container = await GetContainer(); + var container = await GetContainer(_serviceName); var tokenKey = $"influxdb.{container.Id}"; diff --git a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb3DataPersistence.cs b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb3DataPersistence.cs index d18b3bd..1597b54 100644 --- a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb3DataPersistence.cs +++ b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb3DataPersistence.cs @@ -48,7 +48,7 @@ public Influxdb3Configurator(IOptions settings, ILogger logger, TokenService tokenService, HttpApiFactoryService httpApiFactoryService, - IMapper mapper) : base(containerManager, logger, _moduleName, _serviceName, settings.Value.BackendImage, _ports) + IMapper mapper) : base(containerManager, logger, _moduleName, _serviceName, settings.Value.BackendImage) { _logger = logger; _settings = settings.Value; @@ -60,7 +60,7 @@ public Influxdb3Configurator(IOptions settings, public async Task GetTarget(MonitoringSource source) { - var container = await GetContainer(); + var container = await GetContainer(_serviceName); return new MonitoringTarget() { @@ -74,7 +74,12 @@ public async Task GetTarget(MonitoringSource source) public async Task ApplyConfiguration() { - await StartUp(); + await StartUp(_serviceName, _ports, new(), ContainerRestartType.RESTART); + } + + public async Task Shutdown() + { + await Shutdown(_serviceName); } private async Task GetApiService() @@ -85,14 +90,14 @@ private async Task GetApiService() private async Task GetHost() { - var container = await GetContainer(); + var container = await GetContainer(_serviceName); var host = Host.Parse((await _containerManager.GetContainerAddress(container.Id)).ToString()); return $"http://{host.Value}:8181"; } private async Task GetAdminToken() { - var container = await GetContainer(); + var container = await GetContainer(_serviceName); var tokenKey = $"influxdb.{container.Id}"; diff --git a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs index f7c9771..1c50fd1 100644 --- a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs +++ b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs @@ -14,11 +14,10 @@ namespace Tilework.Monitoring.Telegraf; -public class TelegrafConfigurator : IDataCollectorConfigurator +public class TelegrafConfigurator : BaseContainerProvider, IDataCollectorConfigurator { - public string ServiceName => "Telegraf"; - - private string ContainerName => $"DataCollector-{ServiceName}"; + protected static string _serviceName = "influxdb"; + protected static string _moduleName = "telegraf"; private readonly IContainerManager _containerManager; private readonly DataCollectorConfiguration _settings; @@ -28,7 +27,7 @@ public class TelegrafConfigurator : IDataCollectorConfigurator public TelegrafConfigurator(IOptions settings, IContainerManager containerManager, ILogger logger, - IMapper mapper) + IMapper mapper) : base(containerManager, logger, _moduleName, _serviceName, settings.Value.BackendImage) { _logger = logger; _settings = settings.Value; @@ -36,33 +35,6 @@ public TelegrafConfigurator(IOptions settings, _mapper = mapper; } - private async Task GetContainer() - { - var containers = await _containerManager.ListContainers("monitoring.tile"); - - return containers.FirstOrDefault(c => c.Name == ContainerName); - } - - private async Task CreateContainer() - { - try - { - var container = await _containerManager.CreateContainer( - ContainerName, - _settings.BackendImage, - "monitoring.tile", - new List() { } - ); - - return container; - } - catch (Exception ex) - { - _logger.LogCritical($"Failed to create container for telegraf data collector: {ex.ToString()}"); - throw; - } - } - private static T GetOrCreate(TomlTable parent, string name) where T : class, new() { @@ -149,22 +121,13 @@ private void UpdateConfigFile(string path, List monit public async Task ApplyConfiguration(List monitors) { - var container = await GetContainer(); - if (monitors.Count() == 0) { _logger.LogInformation("No active monitors found. Deferring configuration for data collector"); - if (container != null) - { - _logger.LogInformation($"Stopping container for data collector"); - await _containerManager.StopContainer(container.Id); - } + await Shutdown(_serviceName); return; } - - if (container == null) - container = await CreateContainer(); var localConfigPath = Path.GetTempFileName(); var configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "telegraf.conf"); @@ -176,7 +139,14 @@ public async Task ApplyConfiguration(List monitors) { File.Copy(configPath, localConfigPath, overwrite: true); UpdateConfigFile(localConfigPath, monitors); - await _containerManager.CopyFileToContainer(container.Id, localConfigPath, "/etc/telegraf/telegraf.conf"); + + var containerFile = new ContainerFile() + { + LocalPath = localConfigPath, + ContainerPath = "/etc/telegraf/telegraf.conf" + }; + + await StartUp(_serviceName, new(), new() { containerFile }, ContainerRestartType.RESTART); } finally { @@ -184,28 +154,11 @@ public async Task ApplyConfiguration(List monitors) File.Delete(localConfigPath); } - if (container.State != ContainerState.Running) - { - _logger.LogInformation($"Starting container for data collector"); - await _containerManager.StartContainer(container.Id); - } - else - { - _logger.LogInformation($"Restarting container for data collector"); - await _containerManager.StopContainer(container.Id); - await _containerManager.StartContainer(container.Id); - } + } public async Task Shutdown() { - var container = await GetContainer(); - if (container != null) - { - _logger.LogInformation($"Stopping and deleting telegraf data collector"); - if (container.State == ContainerState.Running) - await _containerManager.StopContainer(container.Id); - await _containerManager.DeleteContainer(container.Id); - } + await Shutdown(_serviceName); } } \ No newline at end of file diff --git a/tilework.core/Providers/Shared/BaseContainerProvider.cs b/tilework.core/Providers/Shared/BaseContainerProvider.cs new file mode 100644 index 0000000..db20a5b --- /dev/null +++ b/tilework.core/Providers/Shared/BaseContainerProvider.cs @@ -0,0 +1,126 @@ +using Microsoft.Extensions.Logging; + + +using Tilework.Core.Interfaces; +using Tilework.Core.Models; +using Tilework.Core.Enums; + + +public abstract class BaseContainerProvider +{ + private readonly ILogger _logger; + private readonly IContainerManager _containerManager; + + private readonly string _module; + private readonly string _service; + private readonly string _imageName; + + private string _fullModule => $"{_module}.tile"; + + + public BaseContainerProvider(IContainerManager containerManager, + ILogger logger, + string module, + string service, + string imageName) + { + _containerManager = containerManager; + _logger = logger; + + _module = module; + _service = service; + _imageName = imageName; + + if (string.IsNullOrEmpty(_imageName)) + throw new ArgumentException($"No image setting supplied for {_module}.{_service}"); + } + + private string getFullName(string name) + { + return $"{_module}.{_service}.{name}"; + } + + protected async Task GetContainer(string name) + { + var containers = await _containerManager.ListContainers(_fullModule); + + return containers.FirstOrDefault(c => c.Name == getFullName(name)); + } + + private async Task CreateContainer(string name, List ports) + { + try + { + var container = await _containerManager.CreateContainer( + getFullName(name), _imageName, _fullModule, ports + ); + + return container; + } + catch (Exception ex) + { + _logger.LogCritical($"Failed to create container {getFullName(name)}: {ex}"); + throw; + } + } + + protected async Task StartUp(string name, List ports, List files, ContainerRestartType restartType) + { + var container = await GetContainer(name); + + if(container != null && restartType == ContainerRestartType.RECREATE) + { + await DeleteContainer(name); + container = null; + } + + if (container == null) + { + _logger.LogInformation($"Creating container {getFullName(name)}"); + container = await CreateContainer(name, ports); + } + + foreach(var file in files) + { + await _containerManager.CopyFileToContainer(container.Id, file.LocalPath, file.ContainerPath); + } + + + if (container.State != ContainerState.Running) + { + _logger.LogInformation($"Starting container {getFullName(name)}"); + await _containerManager.StartContainer(container.Id); + } + else + { + if(restartType == ContainerRestartType.RESTART) + { + _logger.LogInformation($"Restarting container {getFullName(name)}"); + await _containerManager.StopContainer(container.Id); + await _containerManager.StartContainer(container.Id); + } + else + { + _logger.LogInformation($"Signaling container {getFullName(name)} of configuration changes"); + await _containerManager.KillContainer(container.Id, UnixSignal.SIGHUP); + } + } + } + + private async Task DeleteContainer(string name) + { + var container = await GetContainer(name); + if (container != null) + { + _logger.LogInformation($"Stopping and deleting container {getFullName(name)}"); + if (container.State == ContainerState.Running) + await _containerManager.StopContainer(container.Id); + await _containerManager.DeleteContainer(container.Id); + } + } + + public async Task Shutdown(string name) + { + await DeleteContainer(name); + } +} \ No newline at end of file diff --git a/tilework.core/Services/Core/DockerServiceManager.cs b/tilework.core/Services/Core/DockerServiceManager.cs index 0e2e775..2cd78a1 100644 --- a/tilework.core/Services/Core/DockerServiceManager.cs +++ b/tilework.core/Services/Core/DockerServiceManager.cs @@ -320,7 +320,6 @@ public async Task StartContainer(string id) { throw new DockerException(ex.ResponseBody); } - } public async Task StopContainer(string id) From 2c8fe1a0508f235c2b0c1a01555b5ff0bd23dd06 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Thu, 25 Dec 2025 19:27:26 +0000 Subject: [PATCH 070/104] Toned down the pills --- .../Pages/LoadBalancing/LoadBalancerDetail.razor | 12 +++++++++--- .../Pages/LoadBalancing/LoadBalancerList.razor | 10 ++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index c9c9852..5fcd237 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -29,7 +29,7 @@ - + Type @(_item is ApplicationLoadBalancerDTO appBalancer ? "Application" : "Network") @@ -38,11 +38,17 @@ Status @if (_item.Enabled) { - Enabled + + + Enabled + } else { - Disabled + + + Disabled + } diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerList.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerList.razor index f5b7882..6bb475d 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerList.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerList.razor @@ -27,11 +27,17 @@ @if (context.Enabled) { - Enabled + + + Enabled + } else { - Disabled + + + Disabled + } From b0e277dd5d6582f72c482c65095c8f4bb363f7db Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Thu, 25 Dec 2025 19:29:10 +0000 Subject: [PATCH 071/104] UI tweaks --- .../CertificateManagement/CertificateAuthorityDetail.razor | 2 +- .../Pages/CertificateManagement/CertificateDetail.razor | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor index 43da60a..a58510e 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor @@ -15,7 +15,7 @@ - + Type @_item.Type.GetDescription() diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor index 0768f08..539e663 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor @@ -15,7 +15,7 @@ - + FQDN @_item.Fqdn From 95919287ba2b9a3e3f4ef040ee4f9a08ec0e6dee Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Thu, 25 Dec 2025 19:39:35 +0000 Subject: [PATCH 072/104] Ui tweaks --- .../CertificateDetail.razor | 23 +++++++++++++++- .../CertificateList.razor | 27 ++++++++++++++++++- .../LoadBalancing/LoadBalancerDetail.razor | 14 +++++----- .../LoadBalancing/LoadBalancerList.razor | 14 +++++----- 4 files changed, 60 insertions(+), 18 deletions(-) diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor index 539e663..ba14d7a 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor @@ -26,7 +26,28 @@ Status - @_item.Status + + @if (_item.Status == CertificateStatus.NEW) + { + + New + } + else if(_item.Status == CertificateStatus.ACTIVE) + { + + Active + } + else if(_item.Status == CertificateStatus.EXPIRED) + { + + Expired + } + else if(_item.Status == CertificateStatus.REVOKED) + { + + Revoked + } + diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor index 59da021..3252941 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor @@ -1,7 +1,9 @@ @using Tilework.CertificateManagement.Models @using Tilework.CertificateManagement.Interfaces +@using Tilework.CertificateManagement.Enums @using Tilework.Core.Enums + @namespace Tilework.Ui.Components.Pages @inject ICertificateManagementService _certificateManagementService @@ -25,7 +27,30 @@ @context.Name @context.Fqdn - @context.Status + + + @if (context.Status == CertificateStatus.NEW) + { + + New + } + else if(context.Status == CertificateStatus.ACTIVE) + { + + Active + } + else if(context.Status == CertificateStatus.EXPIRED) + { + + Expired + } + else if(context.Status == CertificateStatus.REVOKED) + { + + Revoked + } + + @_certificateManagementService.GetPrivateKey(context.PrivateKey).Result?.Algorithm.GetDescription() @context.ExpiresAtUtc @(context.CertificateData.Count > 0 ? context.CertificateData.Count - 1 : 0) diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index 5fcd237..af642f1 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -36,20 +36,18 @@ Status + @if (_item.Enabled) { - - - Enabled - + + Enabled } else { - - - Disabled - + + Disabled } + diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerList.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerList.razor index 6bb475d..b6650f3 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerList.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerList.razor @@ -25,20 +25,18 @@ @(context is ApplicationLoadBalancerDTO ? ((ApplicationLoadBalancerDTO) context).Protocol : ((NetworkLoadBalancerDTO) context).Protocol) + @if (context.Enabled) { - - - Enabled - + + Enabled } else { - - - Disabled - + + Disabled } + From a7a05f06195869a0a7393a55ca1fb0dde94f81e4 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Thu, 25 Dec 2025 21:15:10 +0000 Subject: [PATCH 073/104] UI tweaks --- tilework.ui/Components/Layout/MainLayout.razor | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tilework.ui/Components/Layout/MainLayout.razor b/tilework.ui/Components/Layout/MainLayout.razor index e2c128e..ba7e527 100644 --- a/tilework.ui/Components/Layout/MainLayout.razor +++ b/tilework.ui/Components/Layout/MainLayout.razor @@ -4,7 +4,7 @@ - + Tilework @@ -12,10 +12,10 @@ @* *@ - + - + @Body @@ -65,7 +65,7 @@ { Black = "#110e2d", AppbarText = "#424242", - AppbarBackground = "rgba(255,255,255,0.8)", + AppbarBackground = Colors.Gray.Lighten4, DrawerBackground = "#ffffff", GrayLight = "#e8e8e8", GrayLighter = "#f9f9f9", From 970bfadc98e0f50bf70ac09ce4e6e293375ebd6c Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Fri, 26 Dec 2025 08:43:05 +0000 Subject: [PATCH 074/104] Fix concurrency issue --- .../Services/Monitoring/DataCollectorService.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tilework.core/Services/Monitoring/DataCollectorService.cs b/tilework.core/Services/Monitoring/DataCollectorService.cs index aadc425..73d30f1 100644 --- a/tilework.core/Services/Monitoring/DataCollectorService.cs +++ b/tilework.core/Services/Monitoring/DataCollectorService.cs @@ -64,13 +64,16 @@ public async Task ApplyConfiguration() { await _persistenceConfigurator.ApplyConfiguration(); - var monitors = (await Task.WhenAll( - _sources.Select(async s => new Monitoring.Models.Monitor + var monitors = new List(_sources.Count); + foreach (var source in _sources) + { + var target = await _persistenceConfigurator.GetTarget(source); + monitors.Add(new Monitoring.Models.Monitor { - Source = s, - Target = await _persistenceConfigurator.GetTarget(s) - }) - )).ToList(); + Source = source, + Target = target + }); + } await _collectorConfigurator.ApplyConfiguration(monitors); } @@ -80,4 +83,4 @@ public async Task Shutdown() await _persistenceConfigurator.Shutdown(); await _collectorConfigurator.Shutdown(); } -} \ No newline at end of file +} From eb938f4ca43d912ed709e057e956f1561ebcd9d1 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Fri, 26 Dec 2025 08:49:39 +0000 Subject: [PATCH 075/104] removed debug log --- .../MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs index 9550860..f34e746 100644 --- a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs +++ b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs @@ -155,7 +155,6 @@ private async Task GetAdminToken() await _tokenService.SetToken(tokenKey, token); } - _logger.LogInformation($"Admin token ---> {token}"); return token; } From c4ead49e96615be17e74045e2bdc3971eab694b3 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Fri, 26 Dec 2025 10:12:08 +0000 Subject: [PATCH 076/104] Refactored haproxy configurator --- .../HAProxy/HAProxyConfigurator.cs | 230 +++++++----------- .../Telegraf/TelegrafDataCollector.cs | 2 +- .../Providers/Shared/BaseContainerProvider.cs | 8 +- 3 files changed, 100 insertions(+), 140 deletions(-) diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs index 5dc8e28..d4b2020 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs @@ -22,9 +22,10 @@ namespace Tilework.LoadBalancing.Haproxy; -public class HAProxyConfigurator : ILoadBalancingConfigurator +public class HAProxyConfigurator : BaseContainerProvider, ILoadBalancingConfigurator { - public string ServiceName => "HAProxy"; + protected static string _serviceName = "haproxy"; + protected static string _moduleName = "loadbalancing"; private readonly IContainerManager _containerManager; private readonly LoadBalancerConfiguration _settings; @@ -38,7 +39,7 @@ public HAProxyConfigurator(IOptions settings, ICertificateManagementService certificateManagementService, DataCollectorService dataCollectorService, ILogger logger, - IMapper mapper) + IMapper mapper) : base(containerManager, logger, _moduleName, _serviceName, settings.Value.BackendImage) { _logger = logger; _settings = settings.Value; @@ -53,11 +54,6 @@ public List LoadConfiguration() return null; } - private async Task> GetLoadBalancerContainers() - { - return await _containerManager.ListContainers("loadbalancing.tile"); - } - private void UpdateConfigFile(string path, BaseLoadBalancer balancer) { var haproxyConfig = new Configuration(path); @@ -92,13 +88,13 @@ private void UpdateConfigFile(string path, BaseLoadBalancer balancer) haproxyConfig.Save(); } - private async Task SaveCertificates(Container container, BaseLoadBalancer loadBalancer) + private async Task> GetCertificateFiles(BaseLoadBalancer loadBalancer) { + var containerFiles = new List(); + var certlist = new StringBuilder(); - var activeCertificates = loadBalancer.Certificates.Where( - c => c.Status == CertificateStatus.ACTIVE && - c.PrivateKey != null); + var activeCertificates = loadBalancer.Certificates.Where(c => c.PrivateKey != null); foreach (var cert in activeCertificates) { @@ -109,58 +105,74 @@ private async Task SaveCertificates(Container container, BaseLoadBalancer loadBa var certFilePath = Path.GetTempFileName(); - var containerFilePath = $"/usr/local/etc/haproxy/certs/{cert.Fqdn}.{keyType}.pem"; + File.WriteAllText(certFilePath, $"{keyData}\n{certData}"); - try - { - File.WriteAllText(certFilePath, $"{keyData}\n{certData}"); - await _containerManager.CopyFileToContainer( - container.Id, - certFilePath, - containerFilePath - ); - } - finally + var containerFile = new ContainerFile() { - if (File.Exists(certFilePath)) - File.Delete(certFilePath); - } + LocalPath = certFilePath, + ContainerPath = $"/usr/local/etc/haproxy/certs/{cert.Fqdn}.{keyType}.pem" + }; + + containerFiles.Add(containerFile); - certlist.Append($"{containerFilePath}\n"); + certlist.Append($"{containerFile.ContainerPath}\n"); } + var certListFilePath = Path.GetTempFileName(); - - try + File.WriteAllText(certListFilePath, certlist.ToString()); + + containerFiles.Add(new ContainerFile() + { + LocalPath = certListFilePath, + ContainerPath = "/usr/local/etc/haproxy/certs/certlist.txt" + }); + + return containerFiles; + } + + + private static string GetPrivateKeyPem(AsymmetricAlgorithm key) + { + var pkcs8 = key.ExportPkcs8PrivateKey(); + var pem = PemEncoding.Write("PRIVATE KEY", pkcs8); + return new string(pem); + } + + private static string GetCertPem(X509Certificate2 cert) + { + var der = cert.Export(X509ContentType.Cert); + var pem = PemEncoding.Write("CERTIFICATE", der); + return new string(pem); + } + + + public async Task ConfigureMonitoring(BaseLoadBalancer loadBalancer) + { + if (loadBalancer.Enabled == true && _dataCollectorService.IsMonitored(loadBalancer.Id.ToString()) == false) { - File.WriteAllText(certListFilePath, certlist.ToString()); - await _containerManager.CopyFileToContainer( - container.Id, - certListFilePath, - "/usr/local/etc/haproxy/certs/certlist.txt" - ); + var monitoringSource = new MonitoringSource() + { + Module = "LoadBalancing", + Name = loadBalancer.Id.ToString(), + Type = MonitoringSourceType.HAPROXY, + Host = Host.Parse(await GetLoadBalancerHostname(loadBalancer)), + Port = 4380 + }; + await _dataCollectorService.StartMonitoring(monitoringSource); } - finally + else if (loadBalancer.Enabled == false && _dataCollectorService.IsMonitored(loadBalancer.Id.ToString()) == true) { - if (File.Exists(certListFilePath)) - File.Delete(certListFilePath); + await _dataCollectorService.StopMonitoring(loadBalancer.Id.ToString()); } } public async Task ApplyConfiguration(List config) { - if (string.IsNullOrEmpty(_settings.BackendImage)) - throw new ArgumentException("No image setting supplied for load balancing tile"); - - var containers = await GetLoadBalancerContainers(); - foreach (var lb in config) + foreach(var lb in config) { - var name = lb.Id.ToString(); - var container = containers.FirstOrDefault(cnt => cnt.Name == name); - - if (container == null) + if(lb.Enabled == true) { - _logger.LogInformation($"Creating new container for load balancer {lb.Name}"); var port = new ContainerPort() { Port = lb.Port, @@ -168,112 +180,59 @@ public async Task ApplyConfiguration(List config) Type = _mapper.Map(lb) }; - try - { - container = await _containerManager.CreateContainer( - name, - _settings.BackendImage, - "loadbalancing.tile", - new List() { port } - ); - } - catch (Exception ex) - { - _logger.LogCritical($"Failed to create container for load balancer {lb.Name}: {ex.ToString()}"); - throw; - } - } - var localConfigPath = Path.GetTempFileName(); - var configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "haproxy.cfg"); + var localConfigPath = Path.GetTempFileName(); + var configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "haproxy.cfg"); - if (!File.Exists(configPath)) - throw new InvalidOperationException($"No default haproxy configuration file found at {configPath}"); + if (!File.Exists(configPath)) + throw new InvalidOperationException($"No default haproxy configuration file found at {configPath}"); - try - { - File.Copy(configPath, localConfigPath, overwrite: true); - UpdateConfigFile(localConfigPath, lb); - await _containerManager.CopyFileToContainer(container.Id, localConfigPath, "/usr/local/etc/haproxy/haproxy.cfg"); - } - finally - { - if (File.Exists(localConfigPath)) - File.Delete(localConfigPath); - } + var containerFiles = new List(); - await SaveCertificates(container, lb); + try + { + File.Copy(configPath, localConfigPath, overwrite: true); + UpdateConfigFile(localConfigPath, lb); + containerFiles.Add(new ContainerFile() + { + LocalPath = localConfigPath, + ContainerPath = "/usr/local/etc/haproxy/haproxy.cfg" + }); - if (container.State != ContainerState.Running) - { - if (lb.Enabled == true) + containerFiles.AddRange(await GetCertificateFiles(lb)); + + await StartUp(lb.Name, new() { port }, containerFiles, ContainerRestartType.SIGNAL); + } + finally { - _logger.LogInformation($"Starting container for load balancer {lb.Name}"); - await _containerManager.StartContainer(container.Id); + foreach(var file in containerFiles) + { + if (File.Exists(file.LocalPath)) + File.Delete(file.LocalPath); + } } } else { - if (lb.Enabled == true) - { - _logger.LogInformation($"Signaling container for load balancer {lb.Name} of configuration changes"); - await _containerManager.KillContainer(container.Id, UnixSignal.SIGHUP); - } - else - { - _logger.LogInformation($"Stopping container for load balancer {lb.Name}"); - await _containerManager.StopContainer(container.Id); - } + await Shutdown(lb.Name); } - - if (lb.Enabled == true && _dataCollectorService.IsMonitored(lb.Id.ToString()) == false) - { - var monitoringSource = new MonitoringSource() - { - Module = "LoadBalancing", - Name = lb.Id.ToString(), - Type = MonitoringSourceType.HAPROXY, - Host = Host.Parse((await _containerManager.GetContainerAddress(container.Id)).ToString()), - Port = 4380 - }; - await _dataCollectorService.StartMonitoring(monitoringSource); - } - else if (lb.Enabled == false && _dataCollectorService.IsMonitored(lb.Id.ToString()) == true) - { - await _dataCollectorService.StopMonitoring(lb.Id.ToString()); - } + + await ConfigureMonitoring(lb); } - var containersToDelete = containers.Where(cnt => !config.Any(lb => lb.Id.ToString() == cnt.Name)).ToList(); + var containersToDelete = (await GetContainers()).Where(cnt => !config.Any(lb => lb.Id.ToString() == cnt.Name)).ToList(); + foreach (var cnt in containersToDelete) { - _logger.LogInformation($"Deleting load balancer {cnt.Name}"); - if (cnt.State == ContainerState.Running) - await _containerManager.StopContainer(cnt.Id); - await _containerManager.DeleteContainer(cnt.Id); + await Shutdown(cnt.Name); } } - private static string GetPrivateKeyPem(AsymmetricAlgorithm key) - { - var pkcs8 = key.ExportPkcs8PrivateKey(); - var pem = PemEncoding.Write("PRIVATE KEY", pkcs8); - return new string(pem); - } - - private static string GetCertPem(X509Certificate2 cert) - { - var der = cert.Export(X509ContentType.Cert); - var pem = PemEncoding.Write("CERTIFICATE", der); - return new string(pem); - } - private async Task GetContainer(BaseLoadBalancer balancer) { - var containers = await GetLoadBalancerContainers(); - var container = containers.FirstOrDefault(cnt => cnt.Name == balancer.Id.ToString()); + var container = await GetContainer(balancer.Name); if (container == null) throw new ArgumentException($"Container for balancer {balancer.Id} not found"); return container; @@ -294,13 +253,10 @@ public async Task CheckLoadBalancerStatus(BaseLoadBalancer balancer) public async Task Shutdown() { - var containers = await GetLoadBalancerContainers(); + var containers = await GetContainers(); foreach (var cnt in containers) { - _logger.LogInformation($"Stopping and deleting load balancer {cnt.Name}"); - if (cnt.State == ContainerState.Running) - await _containerManager.StopContainer(cnt.Id); - await _containerManager.DeleteContainer(cnt.Id); + await Shutdown(cnt.Name); } } } \ No newline at end of file diff --git a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs index 1c50fd1..e33935a 100644 --- a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs +++ b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs @@ -146,7 +146,7 @@ public async Task ApplyConfiguration(List monitors) ContainerPath = "/etc/telegraf/telegraf.conf" }; - await StartUp(_serviceName, new(), new() { containerFile }, ContainerRestartType.RESTART); + await StartUp("main", new(), new() { containerFile }, ContainerRestartType.RESTART); } finally { diff --git a/tilework.core/Providers/Shared/BaseContainerProvider.cs b/tilework.core/Providers/Shared/BaseContainerProvider.cs index db20a5b..7d0513d 100644 --- a/tilework.core/Providers/Shared/BaseContainerProvider.cs +++ b/tilework.core/Providers/Shared/BaseContainerProvider.cs @@ -40,10 +40,14 @@ private string getFullName(string name) return $"{_module}.{_service}.{name}"; } - protected async Task GetContainer(string name) + protected async Task> GetContainers() { - var containers = await _containerManager.ListContainers(_fullModule); + return await _containerManager.ListContainers(_fullModule); + } + protected async Task GetContainer(string name) + { + var containers = await GetContainers(); return containers.FirstOrDefault(c => c.Name == getFullName(name)); } From 330dfb47b593ae2fdce1afe709e5900c6a85cea1 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Fri, 26 Dec 2025 10:43:28 +0000 Subject: [PATCH 077/104] Fixes #30 --- .../Interfaces/Core/IContainerManager.cs | 1 + .../Providers/Shared/BaseContainerProvider.cs | 51 ++++++++++++++++++- .../Services/Core/DockerServiceManager.cs | 51 ++++++++++++++++++- 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/tilework.core/Interfaces/Core/IContainerManager.cs b/tilework.core/Interfaces/Core/IContainerManager.cs index 793ca96..d9219fb 100644 --- a/tilework.core/Interfaces/Core/IContainerManager.cs +++ b/tilework.core/Interfaces/Core/IContainerManager.cs @@ -14,6 +14,7 @@ public interface IContainerManager public Task DeleteNetwork(string id); public Task GetContainerAddress(string id); + public Task> GetContainerPorts(string id); public Task> ListContainers(string? module); public Task CreateContainer(string name, string image, string module, List? ports); diff --git a/tilework.core/Providers/Shared/BaseContainerProvider.cs b/tilework.core/Providers/Shared/BaseContainerProvider.cs index 7d0513d..35f9a3b 100644 --- a/tilework.core/Providers/Shared/BaseContainerProvider.cs +++ b/tilework.core/Providers/Shared/BaseContainerProvider.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using System.Linq; using Tilework.Core.Interfaces; @@ -72,6 +73,18 @@ protected async Task StartUp(string name, List ports, List ports, List? existingPorts, List? desiredPorts) + { + var normalizedExisting = NormalizePorts(existingPorts); + var normalizedDesired = NormalizePorts(desiredPorts); + + if (normalizedExisting.Count != normalizedDesired.Count) + return true; + + for (int i = 0; i < normalizedExisting.Count; i++) + { + var existing = normalizedExisting[i]; + var desired = normalizedDesired[i]; + + if (existing.Port != desired.Port || + existing.HostPort != desired.HostPort || + existing.Type != desired.Type) + { + return true; + } + } + + return false; + } + + private static List NormalizePorts(List? ports) + { + if (ports == null) + return new List(); + + return ports + .OrderBy(p => p.Port) + .ThenBy(p => p.HostPort ?? -1) + .ThenBy(p => p.Type) + .ToList(); + } + private async Task DeleteContainer(string name) { var container = await GetContainer(name); @@ -127,4 +176,4 @@ public async Task Shutdown(string name) { await DeleteContainer(name); } -} \ No newline at end of file +} diff --git a/tilework.core/Services/Core/DockerServiceManager.cs b/tilework.core/Services/Core/DockerServiceManager.cs index 2cd78a1..8408f17 100644 --- a/tilework.core/Services/Core/DockerServiceManager.cs +++ b/tilework.core/Services/Core/DockerServiceManager.cs @@ -147,6 +147,55 @@ public async Task DeleteNetwork(string id) return IPAddress.Parse(network.Value.IPAddress); } + public async Task> GetContainerPorts(string id) + { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("Container ID cannot be null or empty.", nameof(id)); + + var info = await _client.Containers.InspectContainerAsync(id); + var ports = info.NetworkSettings?.Ports; + + var containerPorts = new List(); + + if (ports == null || ports.Count == 0) + return containerPorts; + + foreach (var portEntry in ports) + { + if (string.IsNullOrWhiteSpace(portEntry.Key)) + continue; + + var parts = portEntry.Key.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0 || !int.TryParse(parts[0], out int containerPort)) + continue; + + if (!Enum.TryParse(parts.Length > 1 ? parts[1] : nameof(PortType.TCP), true, out PortType portType)) + portType = PortType.TCP; + + var bindings = portEntry.Value; + if (bindings == null || bindings.Count == 0) + continue; + + foreach (var binding in bindings) + { + int? hostPort = null; + if (!string.IsNullOrEmpty(binding.HostPort) && int.TryParse(binding.HostPort, out var parsedHostPort)) + hostPort = parsedHostPort; + else + continue; + + containerPorts.Add(new ContainerPort + { + Port = containerPort, + HostPort = hostPort, + Type = portType + }); + } + } + + return containerPorts; + } + public async Task> ListContainers(string? module = null) { @@ -375,4 +424,4 @@ public async Task ExecuteContainerCommand(string id, str Stderr = stderr.ToString() }; } -} \ No newline at end of file +} From 21481348f18c18db09506c263ecf214f3fc6e2d9 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Fri, 26 Dec 2025 11:01:57 +0000 Subject: [PATCH 078/104] deduplicate ports --- tilework.core/Services/Core/DockerServiceManager.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tilework.core/Services/Core/DockerServiceManager.cs b/tilework.core/Services/Core/DockerServiceManager.cs index 8408f17..4e5ca2a 100644 --- a/tilework.core/Services/Core/DockerServiceManager.cs +++ b/tilework.core/Services/Core/DockerServiceManager.cs @@ -1,5 +1,6 @@ using System.Net; using System.Text; +using System.Linq; using Microsoft.Extensions.Logging; @@ -193,7 +194,10 @@ public async Task> GetContainerPorts(string id) } } - return containerPorts; + return containerPorts + .GroupBy(p => new { p.Port, p.HostPort, p.Type }) + .Select(g => g.First()) + .ToList(); } From 265417d398f12c6d8120512578f8e1c03f730260 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Fri, 26 Dec 2025 19:17:46 +0000 Subject: [PATCH 079/104] Fixes #13 --- .../Enums/Core/ContainerExceptionType.cs | 5 ++ .../Exceptions/Core/DockerException.cs | 30 +++++++++++- .../Exceptions/Core/PortConflictException.cs | 12 +++++ .../ILoadBalancingConfigurator.cs | 3 +- .../HAProxy/HAProxyConfigurator.cs | 47 ++++++++++++++----- .../LoadBalancing/LoadBalancerService.cs | 11 ++++- 6 files changed, 90 insertions(+), 18 deletions(-) create mode 100644 tilework.core/Enums/Core/ContainerExceptionType.cs create mode 100644 tilework.core/Exceptions/Core/PortConflictException.cs diff --git a/tilework.core/Enums/Core/ContainerExceptionType.cs b/tilework.core/Enums/Core/ContainerExceptionType.cs new file mode 100644 index 0000000..6952ddd --- /dev/null +++ b/tilework.core/Enums/Core/ContainerExceptionType.cs @@ -0,0 +1,5 @@ +namespace Tilework.Core.Enums; +public enum ContainerExceptionType +{ + PORT_CONFLICT +} \ No newline at end of file diff --git a/tilework.core/Exceptions/Core/DockerException.cs b/tilework.core/Exceptions/Core/DockerException.cs index 498315c..2d43159 100644 --- a/tilework.core/Exceptions/Core/DockerException.cs +++ b/tilework.core/Exceptions/Core/DockerException.cs @@ -1,11 +1,37 @@ using System.Text.Json; +using System.Text.RegularExpressions; +using Tilework.Core.Enums; namespace Tilework.Exceptions.Core; public class DockerException : Exception { - public DockerException(string message) : base(ParseResponseBody(message)) + public ContainerExceptionType? Type { get; set; } = null; + + private static readonly Regex PortConflictRegex = new( + @"failed\s+to\s+bind\s+host\s+port\s+[^\s:]+:\d+(?:/\w+)?:\s+address\s+already\s+in\s+use", + RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public DockerException(string message) : base(BuildExceptionMessage(message, out var parsedMessage)) { + Type = DetectExceptionType(parsedMessage); + } + + private static ContainerExceptionType? DetectExceptionType(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + return null; + + if (PortConflictRegex.IsMatch(message)) + return ContainerExceptionType.PORT_CONFLICT; + + return null; + } + + private static string BuildExceptionMessage(string message, out string? parsedMessage) + { + parsedMessage = ParseResponseBody(message); + return parsedMessage ?? message; } private static string? ParseResponseBody(string message) @@ -19,4 +45,4 @@ public DockerException(string message) : base(ParseResponseBody(message)) public class ResponseBody { public string message { get; set; } -} \ No newline at end of file +} diff --git a/tilework.core/Exceptions/Core/PortConflictException.cs b/tilework.core/Exceptions/Core/PortConflictException.cs new file mode 100644 index 0000000..31efa5d --- /dev/null +++ b/tilework.core/Exceptions/Core/PortConflictException.cs @@ -0,0 +1,12 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Tilework.Core.Enums; + +namespace Tilework.Exceptions.Core; + +public class PortConfictException : Exception +{ + public PortConfictException(string? message = null) : base(message) + { + } +} diff --git a/tilework.core/Interfaces/LoadBalancing/ILoadBalancingConfigurator.cs b/tilework.core/Interfaces/LoadBalancing/ILoadBalancingConfigurator.cs index d1de7b3..52f5837 100644 --- a/tilework.core/Interfaces/LoadBalancing/ILoadBalancingConfigurator.cs +++ b/tilework.core/Interfaces/LoadBalancing/ILoadBalancingConfigurator.cs @@ -5,6 +5,7 @@ namespace Tilework.LoadBalancing.Interfaces; public interface ILoadBalancingConfigurator { List LoadConfiguration(); - Task ApplyConfiguration(List config); + Task ApplyConfiguration(List loadBalancers); + Task ApplyConfiguration(BaseLoadBalancer loadBalancer); Task Shutdown(); } \ No newline at end of file diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs index d4b2020..977225d 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs @@ -19,6 +19,7 @@ using Tilework.Monitoring.Models; using Tilework.Persistence.LoadBalancing.Models; using Tilework.Monitoring.Services; +using Tilework.Exceptions.Core; namespace Tilework.LoadBalancing.Haproxy; @@ -167,17 +168,15 @@ public async Task ConfigureMonitoring(BaseLoadBalancer loadBalancer) } } - public async Task ApplyConfiguration(List config) + public async Task ApplyConfiguration(BaseLoadBalancer loadBalancer) { - foreach(var lb in config) - { - if(lb.Enabled == true) + if(loadBalancer.Enabled == true) { var port = new ContainerPort() { - Port = lb.Port, - HostPort = lb.Port, - Type = _mapper.Map(lb) + Port = loadBalancer.Port, + HostPort = loadBalancer.Port, + Type = _mapper.Map(loadBalancer) }; @@ -192,7 +191,7 @@ public async Task ApplyConfiguration(List config) try { File.Copy(configPath, localConfigPath, overwrite: true); - UpdateConfigFile(localConfigPath, lb); + UpdateConfigFile(localConfigPath, loadBalancer); containerFiles.Add(new ContainerFile() { @@ -200,9 +199,17 @@ public async Task ApplyConfiguration(List config) ContainerPath = "/usr/local/etc/haproxy/haproxy.cfg" }); - containerFiles.AddRange(await GetCertificateFiles(lb)); + containerFiles.AddRange(await GetCertificateFiles(loadBalancer)); - await StartUp(lb.Name, new() { port }, containerFiles, ContainerRestartType.SIGNAL); + await StartUp(loadBalancer.Name, new() { port }, containerFiles, ContainerRestartType.SIGNAL); + } + catch(DockerException ex) + { + if(ex.Type == ContainerExceptionType.PORT_CONFLICT) + { + throw new PortConfictException("Port is already in use"); + } + throw; } finally { @@ -215,14 +222,28 @@ public async Task ApplyConfiguration(List config) } else { - await Shutdown(lb.Name); + await Shutdown(loadBalancer.Name); } - await ConfigureMonitoring(lb); + await ConfigureMonitoring(loadBalancer); + } + + public async Task ApplyConfiguration(List loadBalancers) + { + foreach(var lb in loadBalancers) + { + try + { + await ApplyConfiguration(lb); + } + catch(Exception ex) + { + _logger.LogCritical(ex, $"Failed to configure load balancer [{lb.Name}]"); + } } - var containersToDelete = (await GetContainers()).Where(cnt => !config.Any(lb => lb.Id.ToString() == cnt.Name)).ToList(); + var containersToDelete = (await GetContainers()).Where(cnt => !loadBalancers.Any(lb => lb.Id.ToString() == cnt.Name)).ToList(); foreach (var cnt in containersToDelete) { diff --git a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs index eab892c..c19c2e9 100644 --- a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs +++ b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs @@ -169,7 +169,7 @@ public async Task EnableLoadBalancer(Guid Id) try { await _dbContext.SaveChangesAsync(); - await ApplyConfiguration(); + await ApplyConfiguration(Id); await tx.CommitAsync(); } catch @@ -191,7 +191,7 @@ public async Task DisableLoadBalancer(Guid Id) try { await _dbContext.SaveChangesAsync(); - await ApplyConfiguration(); + await ApplyConfiguration(Id); await tx.CommitAsync(); } catch @@ -448,6 +448,13 @@ public async Task ApplyConfiguration() await _configurator.ApplyConfiguration(balancers); } + public async Task ApplyConfiguration(Guid Id) + { + var balancer = await _dbContext.LoadBalancers.FindAsync(Id); + if(balancer != null) + await _configurator.ApplyConfiguration(balancer); + } + public async Task Shutdown() { await _configurator.Shutdown(); From 1c8aee10189639e5d54c6917da56e914a90758cc Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Fri, 26 Dec 2025 19:56:37 +0000 Subject: [PATCH 080/104] Better certificate details --- .../CertificateDetail.razor | 27 +++++++++++++++---- .../CertificateList.razor | 2 +- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor index ba14d7a..52f0987 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor @@ -1,6 +1,7 @@ @using Tilework.CertificateManagement.Models @using Tilework.CertificateManagement.Interfaces @using Tilework.CertificateManagement.Enums +@using Tilework.Core.Enums @namespace Tilework.Ui.Components.Pages @@ -15,16 +16,32 @@ - - + + FQDN @_item.Fqdn - - Authority + + Not before + @_item.CertificateData[0].NotBefore + + + Key algorithm + @_certificateManagementService.GetPrivateKey(_item.PrivateKey).Result?.Algorithm.GetDescription() + + + Certificate authority @_certificateManagementService.GeCertificateAuthority(_item.Authority).Result?.Name - + + Expires at + @_item.ExpiresAtUtc + + + Serial number + @_item.CertificateData[0].SerialNumber + + Status @if (_item.Status == CertificateStatus.NEW) diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor index 3252941..5dc6c49 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor @@ -22,7 +22,7 @@ Key algorithm Expiration Intermediate certificates - CA + Certificate authority @context.Name From baab90ab221e684454c7a16d6356969151b5d9dd Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Fri, 26 Dec 2025 19:59:18 +0000 Subject: [PATCH 081/104] Allow renewal of revoked certificates --- .../Pages/CertificateManagement/CertificateDetail.razor | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor index 52f0987..6a6979c 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor @@ -189,10 +189,7 @@ { _actions = new List(); - if(_item.Status != CertificateStatus.REVOKED) - { - _actions.Add(new ActionItem() { Name = "Renew", OnClick = ConfirmRenew }); - } + _actions.Add(new ActionItem() { Name = "Renew", OnClick = ConfirmRenew }); if(_item.Status == CertificateStatus.ACTIVE) { From f00f3b8370d595a5721f53199831406a9e6a8b49 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sat, 27 Dec 2025 08:42:25 +0000 Subject: [PATCH 082/104] Refactored certificate authority to have bring more data to the UI --- .../CertificateManagementMappingProfile.cs | 3 +- .../CertificateAuthorityDTO.cs | 3 +- .../CertificateAuthority.cs | 48 +++++++++++++++++-- .../CertificateManagementService.cs | 44 +++++++---------- .../CertificateAuthorityDetail.razor | 26 +++++++--- .../LoadBalancing/LoadBalancerDetail.razor | 6 +-- tilework.ui/Mappers/FormMappingProfile.cs | 28 +++++------ 7 files changed, 100 insertions(+), 58 deletions(-) diff --git a/tilework.core/Mappers/CertificateManagementMappingProfile.cs b/tilework.core/Mappers/CertificateManagementMappingProfile.cs index b6d8f7a..0c008ca 100644 --- a/tilework.core/Mappers/CertificateManagementMappingProfile.cs +++ b/tilework.core/Mappers/CertificateManagementMappingProfile.cs @@ -10,7 +10,8 @@ public class CertificateManagementMappingProfile : Profile { public CertificateManagementMappingProfile() { - CreateMap(); + CreateMap() + .ForMember(dest => dest.ParametersString, opt => opt.Ignore()); CreateMap(); CreateMap() diff --git a/tilework.core/Models/CertificateManagement/CertificateAuthorityDTO.cs b/tilework.core/Models/CertificateManagement/CertificateAuthorityDTO.cs index 8a33111..a28375b 100644 --- a/tilework.core/Models/CertificateManagement/CertificateAuthorityDTO.cs +++ b/tilework.core/Models/CertificateManagement/CertificateAuthorityDTO.cs @@ -1,4 +1,5 @@ using Tilework.CertificateManagement.Enums; +using Tilework.CertificateManagement.Interfaces; namespace Tilework.CertificateManagement.Models; @@ -7,5 +8,5 @@ public class CertificateAuthorityDTO public Guid Id { get; set; } public string Name { get; set; } public CertificateAuthorityType Type { get; set; } - public string Parameters { get; set; } + public ICAConfiguration Parameters { get; set; } } \ No newline at end of file diff --git a/tilework.core/Persistence/Entities/CertificateManagement/CertificateAuthority.cs b/tilework.core/Persistence/Entities/CertificateManagement/CertificateAuthority.cs index 46a7c0d..ec4a78b 100644 --- a/tilework.core/Persistence/Entities/CertificateManagement/CertificateAuthority.cs +++ b/tilework.core/Persistence/Entities/CertificateManagement/CertificateAuthority.cs @@ -1,6 +1,12 @@ -using Tilework.CertificateManagement.Enums; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; + using Microsoft.EntityFrameworkCore; +using Tilework.CertificateManagement.Enums; +using Tilework.CertificateManagement.Interfaces; +using Tilework.CertificateManagement.Models; + namespace Tilework.Persistence.CertificateManagement.Models; [Index(nameof(Name), IsUnique = true)] @@ -9,5 +15,41 @@ public class CertificateAuthority public Guid Id { get; set; } public string Name { get; set; } public CertificateAuthorityType Type { get; set; } - public string Parameters { get; set; } -} \ No newline at end of file + + [Column("Parameters")] + public string ParametersString { get; set; } = string.Empty; + + + [NotMapped] + public ICAConfiguration Parameters + { + get + { + var configType = GetConfigurationType(); + + if (string.IsNullOrWhiteSpace(ParametersString)) + return (ICAConfiguration)Activator.CreateInstance(configType)!; + + var deserialized = JsonSerializer.Deserialize(ParametersString, configType) as ICAConfiguration; + if (deserialized == null) + throw new InvalidOperationException($"Failed to deserialize certificate authority configuration for type {Type}"); + + return deserialized; + } + set + { + var type = GetConfigurationType(); + ParametersString = JsonSerializer.Serialize(value, type); + } + } + + private Type GetConfigurationType() + { + return Type switch + { + CertificateAuthorityType.ACME => typeof(AcmeConfiguration), + CertificateAuthorityType.LETSENCRYPT => typeof(LetsEncryptConfiguration), + _ => throw new ArgumentOutOfRangeException(nameof(Type), Type, "Unsupported certificate authority type") + }; + } +} diff --git a/tilework.core/Services/CertificateManagement/CertificateManagementService.cs b/tilework.core/Services/CertificateManagement/CertificateManagementService.cs index 7c55b44..89026d1 100644 --- a/tilework.core/Services/CertificateManagement/CertificateManagementService.cs +++ b/tilework.core/Services/CertificateManagement/CertificateManagementService.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.DependencyInjection; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -using System.Text.Json; using AutoMapper; using Coravel.Events.Interfaces; @@ -42,28 +41,13 @@ public CertificateManagementService(TileworkContext dbContext, _mapper = mapper; } - private (ICAProvider, ICAConfiguration) GetProvider(CertificateAuthority certificateAuthority) + private ICAProvider GetProvider(CertificateAuthorityType type) { - return certificateAuthority.Type switch + return type switch { - CertificateAuthorityType.ACME => ( - _serviceProvider.GetRequiredService(), - JsonSerializer.Deserialize(certificateAuthority.Parameters)! - ), - CertificateAuthorityType.LETSENCRYPT => ( - _serviceProvider.GetRequiredService(), - JsonSerializer.Deserialize(certificateAuthority.Parameters)! - ), - _ => throw new ArgumentException($"Invalid CA provider {certificateAuthority.Type}") - }; - } - - private string DeserializeConfig(ICAConfiguration config) - { - return config switch - { - AcmeConfiguration acme => JsonSerializer.Serialize(acme), - _ => throw new NotSupportedException(config.GetType().Name) + CertificateAuthorityType.ACME => _serviceProvider.GetRequiredService(), + CertificateAuthorityType.LETSENCRYPT => _serviceProvider.GetRequiredService(), + _ => throw new ArgumentException($"Invalid CA provider type {type}") }; } @@ -198,7 +182,8 @@ private async Task SignCertificate(Certificate certificate) if (certificate.Status != CertificateStatus.NEW) throw new InvalidOperationException($"Cannot issue certificate: status is {certificate.Status}"); - (var provider, var config) = GetProvider(certificate.Authority); + var provider = GetProvider(certificate.Authority.Type); + var config = certificate.Authority.Parameters; var csr = GenerateCsr(certificate); @@ -208,7 +193,7 @@ private async Task SignCertificate(Certificate certificate) certificate.ExpiresAtUtc = new DateTimeOffset(certificate.CertificateData.First().NotAfter.ToUniversalTime()); certificate.Status = CertificateStatus.ACTIVE; - certificate.Authority.Parameters = DeserializeConfig(config); + certificate.Authority.Parameters = config; } private async Task RevokeCertificate(Certificate certificate) @@ -219,9 +204,11 @@ private async Task RevokeCertificate(Certificate certificate) throw new InvalidOperationException($"Cannot revoke certificate: no certificate data found"); - (var provider, var config) = GetProvider(certificate.Authority); + var provider = GetProvider(certificate.Authority.Type); + var config = certificate.Authority.Parameters; + config = await provider.RevokeCertificate(certificate.CertificateData.First(), config); - certificate.Authority.Parameters = DeserializeConfig(config); + certificate.Authority.Parameters = config; certificate.Status = CertificateStatus.REVOKED; await _dbContext.SaveChangesAsync(); } @@ -306,9 +293,12 @@ public async Task> GetCertificateAuthorities() public async Task AddCertificateAuthority(CertificateAuthorityDTO authority) { var entity = _mapper.Map(authority); - (var provider, var config) = GetProvider(entity); + + var provider = GetProvider(entity.Type); + var config = entity.Parameters; + config = await provider.Register(config); - entity.Parameters = DeserializeConfig(config); + entity.Parameters = config; _dbContext.CertificateAuthorities.Add(entity); await _dbContext.SaveChangesAsync(); diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor index a58510e..ad3770a 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor @@ -1,5 +1,6 @@ @using Tilework.CertificateManagement.Models @using Tilework.CertificateManagement.Interfaces +@using Tilework.CertificateManagement.Enums @using Tilework.Core.Enums @namespace Tilework.Ui.Components.Pages @@ -15,15 +16,28 @@ - - + + Type @_item.Type.GetDescription() - @* - Directory URL - @_item.DirectoryUrl - *@ + @if(_item.Type == CertificateAuthorityType.ACME || _item.Type == CertificateAuthorityType.LETSENCRYPT) + { + var config = (AcmeConfiguration) _item.Parameters; + + Registration email + @config.Email + + + Directory URL + @config.DirectoryUrl + + + KID + @config.Kid + + } + diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index af642f1..b08e4d9 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -29,12 +29,12 @@ - - + + Type @(_item is ApplicationLoadBalancerDTO appBalancer ? "Application" : "Network") - + Status @if (_item.Enabled) diff --git a/tilework.ui/Mappers/FormMappingProfile.cs b/tilework.ui/Mappers/FormMappingProfile.cs index dbb7b9a..e9acc32 100644 --- a/tilework.ui/Mappers/FormMappingProfile.cs +++ b/tilework.ui/Mappers/FormMappingProfile.cs @@ -40,26 +40,20 @@ public FormMappingProfile() // Certificate authorities CreateMap() .ForMember(dest => dest.Parameters, opt => opt.MapFrom(src => - JsonSerializer.Serialize( - new LetsEncryptConfiguration() - { - Email = src.Email!, - AcceptTos = src.AcceptTos - }, - (JsonSerializerOptions?)null - ) + new LetsEncryptConfiguration() + { + Email = src.Email!, + AcceptTos = src.AcceptTos + } )); CreateMap() .ForMember(dest => dest.Parameters, opt => opt.MapFrom(src => - JsonSerializer.Serialize( - new AcmeConfiguration() - { - DirectoryUrl = src.DirectoryUrl!, - Email = src.Email!, - AcceptTos = src.AcceptTos - }, - (JsonSerializerOptions?)null - ) + new AcmeConfiguration() + { + DirectoryUrl = src.DirectoryUrl!, + Email = src.Email!, + AcceptTos = src.AcceptTos + } )); CreateMap(); From 9be568f72b1adb6827fe015fe1c7e5c1f0fc856f Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sat, 27 Dec 2025 09:13:53 +0000 Subject: [PATCH 083/104] Fixed bugs in container deletion --- .../HAProxy/HAProxyConfigurator.cs | 3 +- .../Providers/Shared/BaseContainerProvider.cs | 31 +++++++++++++------ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs index 977225d..f903488 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs @@ -243,7 +243,8 @@ public async Task ApplyConfiguration(List loadBalancers) } } - var containersToDelete = (await GetContainers()).Where(cnt => !loadBalancers.Any(lb => lb.Id.ToString() == cnt.Name)).ToList(); + var containers = await GetContainers(); + var containersToDelete = containers.Where(cnt => !loadBalancers.Any(lb => GetFullName(lb.Name) == cnt.Name)).ToList(); foreach (var cnt in containersToDelete) { diff --git a/tilework.core/Providers/Shared/BaseContainerProvider.cs b/tilework.core/Providers/Shared/BaseContainerProvider.cs index 35f9a3b..c7f47fd 100644 --- a/tilework.core/Providers/Shared/BaseContainerProvider.cs +++ b/tilework.core/Providers/Shared/BaseContainerProvider.cs @@ -36,11 +36,19 @@ public BaseContainerProvider(IContainerManager containerManager, throw new ArgumentException($"No image setting supplied for {_module}.{_service}"); } - private string getFullName(string name) + protected string GetFullName(string name) { return $"{_module}.{_service}.{name}"; } + private bool IsFullName(string name) + { + var parts = name.Split('.'); + return parts.Length == 3 && + parts[0] == _module && + parts[1] == _service; + } + protected async Task> GetContainers() { return await _containerManager.ListContainers(_fullModule); @@ -49,7 +57,7 @@ protected async Task> GetContainers() protected async Task GetContainer(string name) { var containers = await GetContainers(); - return containers.FirstOrDefault(c => c.Name == getFullName(name)); + return containers.FirstOrDefault(c => c.Name == (IsFullName(name) ? name : GetFullName(name))); } private async Task CreateContainer(string name, List ports) @@ -57,14 +65,14 @@ private async Task CreateContainer(string name, List p try { var container = await _containerManager.CreateContainer( - getFullName(name), _imageName, _fullModule, ports + GetFullName(name), _imageName, _fullModule, ports ); return container; } catch (Exception ex) { - _logger.LogCritical($"Failed to create container {getFullName(name)}: {ex}"); + _logger.LogCritical($"Failed to create container {GetFullName(name)}: {ex}"); throw; } } @@ -79,7 +87,7 @@ protected async Task StartUp(string name, List ports, List ports, List ports, List Date: Sat, 27 Dec 2025 09:19:07 +0000 Subject: [PATCH 084/104] cleanup --- .../HAProxy/HAProxyConfigurator.cs | 84 +++++++++---------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs index f903488..696a711 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs @@ -170,63 +170,63 @@ public async Task ConfigureMonitoring(BaseLoadBalancer loadBalancer) public async Task ApplyConfiguration(BaseLoadBalancer loadBalancer) { - if(loadBalancer.Enabled == true) + if(loadBalancer.Enabled == true) + { + var port = new ContainerPort() { - var port = new ContainerPort() - { - Port = loadBalancer.Port, - HostPort = loadBalancer.Port, - Type = _mapper.Map(loadBalancer) - }; + Port = loadBalancer.Port, + HostPort = loadBalancer.Port, + Type = _mapper.Map(loadBalancer) + }; - var localConfigPath = Path.GetTempFileName(); - var configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "haproxy.cfg"); + var localConfigPath = Path.GetTempFileName(); + var configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "haproxy.cfg"); - if (!File.Exists(configPath)) - throw new InvalidOperationException($"No default haproxy configuration file found at {configPath}"); + if (!File.Exists(configPath)) + throw new InvalidOperationException($"No default haproxy configuration file found at {configPath}"); - var containerFiles = new List(); + var containerFiles = new List(); - try - { - File.Copy(configPath, localConfigPath, overwrite: true); - UpdateConfigFile(localConfigPath, loadBalancer); + try + { + File.Copy(configPath, localConfigPath, overwrite: true); + UpdateConfigFile(localConfigPath, loadBalancer); - containerFiles.Add(new ContainerFile() - { - LocalPath = localConfigPath, - ContainerPath = "/usr/local/etc/haproxy/haproxy.cfg" - }); + containerFiles.Add(new ContainerFile() + { + LocalPath = localConfigPath, + ContainerPath = "/usr/local/etc/haproxy/haproxy.cfg" + }); - containerFiles.AddRange(await GetCertificateFiles(loadBalancer)); + containerFiles.AddRange(await GetCertificateFiles(loadBalancer)); - await StartUp(loadBalancer.Name, new() { port }, containerFiles, ContainerRestartType.SIGNAL); - } - catch(DockerException ex) - { - if(ex.Type == ContainerExceptionType.PORT_CONFLICT) - { - throw new PortConfictException("Port is already in use"); - } - throw; - } - finally + await StartUp(loadBalancer.Name, new() { port }, containerFiles, ContainerRestartType.SIGNAL); + } + catch(DockerException ex) + { + if(ex.Type == ContainerExceptionType.PORT_CONFLICT) { - foreach(var file in containerFiles) - { - if (File.Exists(file.LocalPath)) - File.Delete(file.LocalPath); - } + throw new PortConfictException("Port is already in use"); } + throw; } - else + finally { - await Shutdown(loadBalancer.Name); + foreach(var file in containerFiles) + { + if (File.Exists(file.LocalPath)) + File.Delete(file.LocalPath); + } } + } + else + { + await Shutdown(loadBalancer.Name); + } - - await ConfigureMonitoring(loadBalancer); + + await ConfigureMonitoring(loadBalancer); } public async Task ApplyConfiguration(List loadBalancers) From c2f1d6c503d5337771385a6ccf1ce7869681211e Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sat, 27 Dec 2025 09:20:46 +0000 Subject: [PATCH 085/104] minor fix --- .../CertificateManagement/CertificateAuthorityDetail.razor | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor index ad3770a..0748e04 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor @@ -32,6 +32,8 @@ Directory URL @config.DirectoryUrl + + KID @config.Kid From 344182caa550e5d2d941bb489c7b79969ff95083 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sat, 27 Dec 2025 09:59:49 +0000 Subject: [PATCH 086/104] Scoped configuration application to lb --- .../Interfaces/LoadBalancing/ILoadBalancerService.cs | 1 + .../Pages/LoadBalancing/LoadBalancerDetail.razor | 8 ++++---- .../Components/Pages/LoadBalancing/LoadBalancerEdit.razor | 2 +- .../Components/Pages/LoadBalancing/LoadBalancerNew.razor | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs b/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs index d852ab3..0084ddb 100644 --- a/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs +++ b/tilework.core/Interfaces/LoadBalancing/ILoadBalancerService.cs @@ -44,6 +44,7 @@ public interface ILoadBalancerService public Task> GetLoadBalancerMonitoringData(Guid Id, TimeSpan interval, DateTimeOffset start, DateTimeOffset end); + public Task ApplyConfiguration(Guid Id); public Task ApplyConfiguration(); public Task Shutdown(); } diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index b08e4d9..b07a074 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -285,7 +285,7 @@ await _loadBalancerService.AddRule(appBalancer, rule); await GetRules(); - await _loadBalancerService.ApplyConfiguration(); + await _loadBalancerService.ApplyConfiguration(appBalancer.Id); _snackbar.Add("Rule added successfully!", Severity.Success); } catch(Exception ex) @@ -341,7 +341,7 @@ await _loadBalancerService.UpdateRule(appBalancer, ruleEdit); await GetRules(); - await _loadBalancerService.ApplyConfiguration(); + await _loadBalancerService.ApplyConfiguration(appBalancer.Id); _snackbar.Add("Rule updated successfully!", Severity.Success); } catch(Exception ex) @@ -371,7 +371,7 @@ { await _loadBalancerService.RemoveRule(appBalancer, rule); await GetRules(); - await _loadBalancerService.ApplyConfiguration(); + await _loadBalancerService.ApplyConfiguration(appBalancer.Id); } } @@ -389,7 +389,7 @@ var cert = (Guid) result.Data; await _loadBalancerService.AddCertificate(_item, cert); await GetCertificates(); - await _loadBalancerService.ApplyConfiguration(); + await _loadBalancerService.ApplyConfiguration(_item.Id); } } diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerEdit.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerEdit.razor index 955c2b4..0efc14b 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerEdit.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerEdit.razor @@ -97,7 +97,7 @@ else if(form is EditNetworkLoadBalancerForm nlbForm) { _item = _mapper.Map(form, _item); await _loadBalancerService.UpdateLoadBalancer(_item); - await _loadBalancerService.ApplyConfiguration(); + await _loadBalancerService.ApplyConfiguration(_item.Id); NavigationManager.NavigateTo("/lb/loadbalancers"); Snackbar.Add("Load balancer saved successfully!", Severity.Success); diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerNew.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerNew.razor index d860411..3425ce9 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerNew.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerNew.razor @@ -85,8 +85,8 @@ else if(form is NewNetworkLoadBalancerForm nlbForm) else throw new ArgumentException(); - await _loadBalancerService.AddLoadBalancer(item); - await _loadBalancerService.ApplyConfiguration(); + item = await _loadBalancerService.AddLoadBalancer(item); + await _loadBalancerService.ApplyConfiguration(item.Id); NavigationManager.NavigateTo("/lb/loadbalancers"); Snackbar.Add("Load balancer added successfully!", Severity.Success); From 82626aa3128e0c5f5abea7dedd929c7d21bbd010 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sat, 27 Dec 2025 10:35:50 +0000 Subject: [PATCH 087/104] Refactored chart panel --- .../LoadBalancing/LoadBalancerDetail.razor | 142 +++--------------- .../Components/Shared/TimeSeriesChartData.cs | 10 ++ .../Shared/TimeSeriesChartPanel.razor | 110 ++++++++++++++ 3 files changed, 137 insertions(+), 125 deletions(-) create mode 100644 tilework.ui/Components/Shared/TimeSeriesChartData.cs create mode 100644 tilework.ui/Components/Shared/TimeSeriesChartPanel.razor diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index b07a074..617f136 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -27,7 +27,7 @@ Load balancer details - + @@ -123,55 +123,7 @@ } - - - - Monitoring - - -
- - - - - - - - -
- - - -
-
-
-
- - @if (_statsLoading) - { - - } - else if (_monitoringData?.Any() == true) - { - - - - - - - - - - - - @* - - - *@ - - } - -
+
@@ -188,8 +140,6 @@ private List _certificates = new(); private bool _showCertificates; - private string _selectedRange = "1h"; - private List _breadcrumbs = new List { new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), @@ -198,10 +148,6 @@ private List _actions = new List(); - - private bool _statsLoading = false; - private Dictionary> _monitoringData = new(); - private int GetMaxRulePriority() => _rules.Count() > 0 ? _rules.Max(p => p.Priority) + 1 : 0; protected override async Task OnInitializedAsync() @@ -484,81 +430,27 @@ _certificates = await _loadBalancerService.GetCertificates(_item); } - private int GetMonitoringTabIndex() - { - // Count how many tabs are before Monitoring - var before = 0; - if (_item is ApplicationLoadBalancerDTO) - before += 1; // Rules - if (_showCertificates) - before += 1; // Certificates - return before; // Monitoring index - } - - private async Task OnTabChanged(int index) + private async Task> LoadMonitoringCharts(DateTimeOffset from, DateTimeOffset to, TimeSpan interval) { - - if(_item != null && _item.Enabled == true && index == GetMonitoringTabIndex()) - { - await RefreshMonitoringData(_selectedRange); - } - } + var charts = new List(); + List? data = await _loadBalancerService.GetLoadBalancerMonitoringData(_item.Id, interval, from, to) + ?? new List(); - private async Task RefreshMonitoringData(string range) - { - _statsLoading = true; - StateHasChanged(); + var tz = await _browserTimezoneProvider.GetTimeZoneAsync(); - _selectedRange = range; + var sessions = new Dictionary(); + var requests = new Dictionary(); - try + foreach (var entry in data.OrderBy(d => d.Timestamp)) { - var end = DateTimeOffset.UtcNow; - DateTimeOffset start; - switch(range) - { - case "1h": - start = end.AddHours(-1); - break; - case "3h": - start = end.AddHours(-3); - break; - case "12h": - start = end.AddHours(-12); - break; - case "1d": - start = end.AddDays(-1); - break; - case "3d": - start = end.AddDays(-3); - break; - case "1w": - start = end.AddDays(-7); - break; - default: - start = end.AddHours(-1); - break; - } - - var interval = new TimeSpan(hours: 0, minutes: 1, seconds: 0); - List? data = await _loadBalancerService.GetLoadBalancerMonitoringData(_item.Id, interval, start, end) - ?? new List(); - - var tz = await _browserTimezoneProvider.GetTimeZoneAsync(); + var localTimestamp = TimeZoneInfo.ConvertTime(entry.Timestamp.ToUniversalTime(), tz).DateTime; + sessions[localTimestamp] = entry.Sessions; + requests[localTimestamp] = entry.Requests; + } - _monitoringData["sessions"] = new(); - _monitoringData["requests"] = new(); + charts.Add(new TimeSeriesChartData { Name = "Sessions", Data = sessions }); + charts.Add(new TimeSeriesChartData { Name = "Requests", Data = requests }); - foreach (var entry in data.OrderBy(d => d.Timestamp)) - { - var localTimestamp = TimeZoneInfo.ConvertTime(entry.Timestamp.ToUniversalTime(), tz).DateTime; - _monitoringData["sessions"][localTimestamp] = entry.Sessions; - _monitoringData["requests"][localTimestamp] = entry.Requests; - } - } - finally - { - _statsLoading = false; - } + return charts; } } diff --git a/tilework.ui/Components/Shared/TimeSeriesChartData.cs b/tilework.ui/Components/Shared/TimeSeriesChartData.cs new file mode 100644 index 0000000..893f492 --- /dev/null +++ b/tilework.ui/Components/Shared/TimeSeriesChartData.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; + +namespace Tilework.Ui.Components.Shared; + +public class TimeSeriesChartData +{ + public string Name { get; set; } = string.Empty; + public Dictionary Data { get; set; } = new(); +} diff --git a/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor b/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor new file mode 100644 index 0000000..d11e3f6 --- /dev/null +++ b/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor @@ -0,0 +1,110 @@ +@using Tilework.Ui.Components.Shared + + + + + @Title + + +
+ + + + + + + + +
+ + + +
+
+
+
+ + @if (_loading) + { + + } + else if (_chartData?.Any() == true) + { + + @foreach (var chart in _chartData) + { + + + + + + } + + } + +
+ +@code { + [Parameter] + public string Title { get; set; } = string.Empty; + + [Parameter] + public Func>> LoadData { get; set; } + + private bool _loading; + private string _selectedRange = "1h"; + private List _chartData = new(); + private bool _initialized; + + public async Task RefreshAsync() + { + if (LoadData == null) + return; + + _loading = true; + StateHasChanged(); + + try + { + var to = DateTimeOffset.UtcNow; + var from = GetRangeStart(_selectedRange, to); + var interval = TimeSpan.FromMinutes(1); + + _chartData = await LoadData(from, to, interval) ?? new List(); + } + finally + { + _loading = false; + await InvokeAsync(StateHasChanged); + } + } + + private Task OnRangeChanged(string range) + { + _selectedRange = range; + return RefreshAsync(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && !_initialized) + { + _initialized = true; + await RefreshAsync(); + } + } + + private static DateTimeOffset GetRangeStart(string range, DateTimeOffset to) + { + return range switch + { + "1h" => to.AddHours(-1), + "3h" => to.AddHours(-3), + "12h" => to.AddHours(-12), + "1d" => to.AddDays(-1), + "3d" => to.AddDays(-3), + "1w" => to.AddDays(-7), + _ => to.AddHours(-1) + }; + } +} From c15628df0c6cde609843d6fdb5e710d1a847442b Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sat, 27 Dec 2025 17:43:55 +0000 Subject: [PATCH 088/104] Further seperation of charts to a distinct component --- .../LoadBalancing/LoadBalancerDetail.razor | 13 ++++------ .../Components/Shared/TimeSeriesChartData.cs | 2 +- .../Shared/TimeSeriesChartPanel.razor | 24 ++++++++++++++++++- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index 617f136..000c561 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -20,7 +20,6 @@ @inject NavigationManager _navigationManager @inject ISnackbar _snackbar @inject ICertificateManagementService _certificateService -@inject IBrowserTimeZoneProvider _browserTimezoneProvider @page "/lb/loadbalancers/{Id:guid}" @@ -436,16 +435,14 @@ List? data = await _loadBalancerService.GetLoadBalancerMonitoringData(_item.Id, interval, from, to) ?? new List(); - var tz = await _browserTimezoneProvider.GetTimeZoneAsync(); - - var sessions = new Dictionary(); - var requests = new Dictionary(); + var sessions = new Dictionary(); + var requests = new Dictionary(); foreach (var entry in data.OrderBy(d => d.Timestamp)) { - var localTimestamp = TimeZoneInfo.ConvertTime(entry.Timestamp.ToUniversalTime(), tz).DateTime; - sessions[localTimestamp] = entry.Sessions; - requests[localTimestamp] = entry.Requests; + var timestamp = entry.Timestamp.ToUniversalTime(); + sessions[timestamp] = entry.Sessions; + requests[timestamp] = entry.Requests; } charts.Add(new TimeSeriesChartData { Name = "Sessions", Data = sessions }); diff --git a/tilework.ui/Components/Shared/TimeSeriesChartData.cs b/tilework.ui/Components/Shared/TimeSeriesChartData.cs index 893f492..723dc2a 100644 --- a/tilework.ui/Components/Shared/TimeSeriesChartData.cs +++ b/tilework.ui/Components/Shared/TimeSeriesChartData.cs @@ -6,5 +6,5 @@ namespace Tilework.Ui.Components.Shared; public class TimeSeriesChartData { public string Name { get; set; } = string.Empty; - public Dictionary Data { get; set; } = new(); + public Dictionary Data { get; set; } = new(); } diff --git a/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor b/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor index d11e3f6..d182493 100644 --- a/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor +++ b/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor @@ -1,4 +1,5 @@ @using Tilework.Ui.Components.Shared +@using Tilework.Ui.Services @@ -35,7 +36,7 @@ { - + } @@ -55,6 +56,10 @@ private string _selectedRange = "1h"; private List _chartData = new(); private bool _initialized; + private TimeZoneInfo? _browserTimeZone; + + [Inject] + private IBrowserTimeZoneProvider _browserTimezoneProvider { get; set; } public async Task RefreshAsync() { @@ -71,6 +76,7 @@ var interval = TimeSpan.FromMinutes(1); _chartData = await LoadData(from, to, interval) ?? new List(); + _browserTimeZone ??= await _browserTimezoneProvider.GetTimeZoneAsync(); } finally { @@ -107,4 +113,20 @@ _ => to.AddHours(-1) }; } + + private Dictionary Localize(Dictionary data) + { + var localData = new Dictionary(); + foreach(var point in data.OrderBy(p => p.Key)) + { + DateTime localTime; + if(_browserTimeZone != null) + localTime = TimeZoneInfo.ConvertTime(point.Key, _browserTimeZone).DateTime; + else + localTime = point.Key.LocalDateTime; + + localData[localTime] = point.Value; + } + return localData; + } } From e3338e84216e7aa53dc3f93ce3d3f1979ff83e0c Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sat, 27 Dec 2025 18:59:08 +0000 Subject: [PATCH 089/104] fixin bugs --- .../Influxdb/Influxdb2DataPersistence.cs | 26 +++++++++---------- .../Telegraf/TelegrafDataCollector.cs | 7 ++--- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs index f34e746..90770b5 100644 --- a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs +++ b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs @@ -26,6 +26,7 @@ public class Influxdb2Configurator : BaseContainerProvider, IDataPersistenceConf { protected static string _serviceName = "influxdb"; protected static string _moduleName = "monitoring"; + private static string _defaultName = "default"; private static string _orgName = "tilework"; @@ -65,13 +66,13 @@ public Influxdb2Configurator(IOptions settings, public async Task GetTarget(MonitoringSource source) { - var container = await GetContainer(_serviceName); + var container = await GetContainer(_defaultName); await CheckCreateBucket(_orgName, source.Module); return new MonitoringTarget() { - Name = _serviceName, + Name = _defaultName, Type = Enums.MonitoringPersistenceType.INFLUXDB, Host = Host.Parse((await _containerManager.GetContainerAddress(container.Id)).ToString()), Port = 8086, @@ -81,16 +82,16 @@ public async Task GetTarget(MonitoringSource source) public async Task ApplyConfiguration() { - var container = await GetContainer(_serviceName); + var container = await GetContainer(_defaultName); if(container == null || container.State != ContainerState.Running) - await StartUp(_serviceName, _ports, new(), ContainerRestartType.RESTART); + await StartUp(_defaultName, _ports, new(), ContainerRestartType.RESTART); await CheckRunSetup(); } public async Task Shutdown() { - await Shutdown(_serviceName); + await Shutdown(_defaultName); } private async Task CheckRunSetup() @@ -104,10 +105,9 @@ private async Task CheckRunSetup() if(resp.Allowed == true) { - var container = await GetContainer(_serviceName); - var tokenKey = $"influxdb.{container.Id}"; + var container = await GetContainer(_defaultName); - await _tokenService.DeleteToken(tokenKey); + await _tokenService.DeleteToken(GetFullName(_defaultName)); await GetAdminToken(); } @@ -131,18 +131,16 @@ private async Task GetApiService() private async Task GetHost() { - var container = await GetContainer(_serviceName); + var container = await GetContainer(_defaultName); var host = Host.Parse((await _containerManager.GetContainerAddress(container.Id)).ToString()); return $"http://{host.Value}:8086"; } private async Task GetAdminToken() { - var container = await GetContainer(_serviceName); + var container = await GetContainer(_defaultName); - var tokenKey = $"influxdb.{container.Id}"; - - var token = await _tokenService.GetToken(tokenKey); + var token = await _tokenService.GetToken(GetFullName(_defaultName)); if(token == null) { @@ -153,7 +151,7 @@ private async Task GetAdminToken() container.Id, $"influx setup --username admin --password \"{token}\" --org \"{_orgName}\" --bucket tilework --token \"{token}\" --force"); - await _tokenService.SetToken(tokenKey, token); + await _tokenService.SetToken(GetFullName(_defaultName), token); } return token; } diff --git a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs index e33935a..15bca32 100644 --- a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs +++ b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs @@ -16,8 +16,9 @@ namespace Tilework.Monitoring.Telegraf; public class TelegrafConfigurator : BaseContainerProvider, IDataCollectorConfigurator { - protected static string _serviceName = "influxdb"; - protected static string _moduleName = "telegraf"; + protected static string _serviceName = "telegraf"; + protected static string _moduleName = "monitoring"; + protected static string _defaultName = "default"; private readonly IContainerManager _containerManager; private readonly DataCollectorConfiguration _settings; @@ -146,7 +147,7 @@ public async Task ApplyConfiguration(List monitors) ContainerPath = "/etc/telegraf/telegraf.conf" }; - await StartUp("main", new(), new() { containerFile }, ContainerRestartType.RESTART); + await StartUp(_defaultName, new(), new() { containerFile }, ContainerRestartType.RESTART); } finally { From 8c280486667a3f3064f3a69fa04d3eae7cd9c7f3 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 28 Dec 2025 08:29:24 +0000 Subject: [PATCH 090/104] Added interval selection --- .../Shared/TimeSeriesChartPanel.razor | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor b/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor index d182493..68d107d 100644 --- a/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor +++ b/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor @@ -8,7 +8,14 @@
- + + @foreach (var option in _intervalOptions) + { + @option.Label + } + + @@ -16,11 +23,9 @@ -
- - - -
+ + +
@@ -52,8 +57,21 @@ [Parameter] public Func>> LoadData { get; set; } + private readonly List _intervalOptions = new() + { + new IntervalOption("1 minute", "1m", TimeSpan.FromMinutes(1)), + new IntervalOption("5 minutes", "5m", TimeSpan.FromMinutes(5)), + new IntervalOption("15 minutes", "15m", TimeSpan.FromMinutes(15)), + new IntervalOption("1 hour", "1h", TimeSpan.FromHours(1)), + new IntervalOption("6 hours", "6h", TimeSpan.FromHours(6)), + new IntervalOption("1 day", "1d", TimeSpan.FromDays(1)), + new IntervalOption("7 days", "7d", TimeSpan.FromDays(7)), + new IntervalOption("30 days", "30d", TimeSpan.FromDays(30)) + }; + private bool _loading; private string _selectedRange = "1h"; + private string _selectedInterval = "1m"; private List _chartData = new(); private bool _initialized; private TimeZoneInfo? _browserTimeZone; @@ -73,7 +91,7 @@ { var to = DateTimeOffset.UtcNow; var from = GetRangeStart(_selectedRange, to); - var interval = TimeSpan.FromMinutes(1); + var interval = _intervalOptions.FirstOrDefault(o => o.Value == _selectedInterval)?.Span ?? TimeSpan.FromMinutes(1); _chartData = await LoadData(from, to, interval) ?? new List(); _browserTimeZone ??= await _browserTimezoneProvider.GetTimeZoneAsync(); @@ -91,6 +109,12 @@ return RefreshAsync(); } + private Task OnIntervalChanged(string interval) + { + _selectedInterval = interval; + return RefreshAsync(); + } + protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender && !_initialized) @@ -129,4 +153,18 @@ } return localData; } + + private class IntervalOption + { + public string Label { get; } + public string Value { get; } + public TimeSpan Span { get; } + + public IntervalOption(string label, string value, TimeSpan span) + { + Label = label; + Value = value; + Span = span; + } + } } From aafb0e59a3991bc2017b43a56ff33c5ad4dcfa4a Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 28 Dec 2025 08:33:41 +0000 Subject: [PATCH 091/104] ui tweak --- tilework.ui/Components/Shared/TimeSeriesChartPanel.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor b/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor index 68d107d..2dc9041 100644 --- a/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor +++ b/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor @@ -9,7 +9,7 @@
+ ValueChanged="OnIntervalChanged" Dense="true" Variant="Variant.Outlined" Class="mr-3 my-0" Style="width: 9rem"> @foreach (var option in _intervalOptions) { @option.Label From 640004372a3a54b5fb10d9edb30d8cbc95e4dd78 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 28 Dec 2025 09:47:28 +0000 Subject: [PATCH 092/104] Added tab in the url --- .../Components/Layout/GenericDetailview.razor | 88 ++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/tilework.ui/Components/Layout/GenericDetailview.razor b/tilework.ui/Components/Layout/GenericDetailview.razor index c4d8ef2..8a263b2 100644 --- a/tilework.ui/Components/Layout/GenericDetailview.razor +++ b/tilework.ui/Components/Layout/GenericDetailview.razor @@ -1,4 +1,5 @@ @namespace Tilework.Ui.Components.Layout +@using System.Linq @inject NavigationManager NavigationManager @@ -34,7 +35,7 @@ @if(TabContent != null) { - + @TabContent } @@ -51,9 +52,92 @@ [Parameter] public EventCallback OnTabChanged { get; set; } + private MudTabs? _tabs; + private int _activeTabIndex; + private string? _requestedTab; + private bool _tabInitialized; + + protected override void OnInitialized() + { + var currentUri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + _requestedTab = GetTabNameFromUri(currentUri); + } + + protected override Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || _tabInitialized) + return Task.CompletedTask; + + _tabInitialized = true; + + if (_tabs?.Panels == null || string.IsNullOrWhiteSpace(_requestedTab)) + return Task.CompletedTask; + + var panels = _tabs.Panels.ToList(); + var matchingIndex = panels.FindIndex(panel => string.Equals(panel.Text, _requestedTab, StringComparison.OrdinalIgnoreCase)); + + if (matchingIndex >= 0 && matchingIndex != _activeTabIndex) + { + _activeTabIndex = matchingIndex; + StateHasChanged(); + } + + return Task.CompletedTask; + } private async Task TabChanged(int index) { - await OnTabChanged.InvokeAsync(index); + _activeTabIndex = index; + + var tabText = GetTabText(index); + if (!string.IsNullOrWhiteSpace(tabText)) + { + var targetUri = BuildTabAwareUri(tabText); + NavigationManager.NavigateTo(targetUri, replace: true); + } + + if (OnTabChanged.HasDelegate) + await OnTabChanged.InvokeAsync(index); + } + + private string? GetTabText(int index) + { + return _tabs?.Panels?.ElementAtOrDefault(index)?.Text; + } + + private string BuildTabAwareUri(string tabText) + { + var uriWithoutFragment = NavigationManager.Uri.Split('#')[0]; + + return $"{uriWithoutFragment}#tab={Uri.EscapeDataString(tabText)}"; + } + + private static string? GetTabNameFromUri(Uri uri) + { + var fragment = uri.Fragment.TrimStart('#'); + if (fragment.StartsWith("tab=", StringComparison.OrdinalIgnoreCase)) + return Uri.UnescapeDataString(fragment.Substring("tab=".Length)); + + var tabFromQuery = GetQueryValue(uri.Query, "tab"); + if (!string.IsNullOrWhiteSpace(tabFromQuery)) + return tabFromQuery; + + return null; + } + + private static string? GetQueryValue(string query, string key) + { + if (string.IsNullOrWhiteSpace(query)) + return null; + + var parameters = query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries); + foreach (var parameter in parameters) + { + var pair = parameter.Split('=', 2); + if (pair.Length == 2 && pair[0].Equals(key, StringComparison.OrdinalIgnoreCase)) + return Uri.UnescapeDataString(pair[1]); + } + + return null; } } From 007c8c73e337c4b43473b20693f48f3f85a904cb Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Sun, 28 Dec 2025 11:40:44 +0000 Subject: [PATCH 093/104] monitoring bugfixes --- .../Influxdb/Influxdb2DataPersistence.cs | 10 +++++++--- .../Telegraf/TelegrafDataCollector.cs | 2 +- tilework.core/Resources/telegraf.conf | 9 +++++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs index 90770b5..eabe49b 100644 --- a/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs +++ b/tilework.core/Providers/MonitoringProviders/Influxdb/Influxdb2DataPersistence.cs @@ -221,17 +221,21 @@ private async Task GetOrgId(string orgName) var fluxTables = await queryApi.QueryAsync(query, _orgName); + if (fluxTables is null || fluxTables.Count == 0) + return new List(); + var entryProperties = typeof(T) .GetProperties(BindingFlags.Instance | BindingFlags.Public) .Where(p => p.CanWrite && p.Name != nameof(BaseMonitorData.Timestamp)) .ToArray(); var entryPropertyNames = entryProperties - .Select(property => property.Name) + .Select(property => property.Name.ToLower()) .ToHashSet(StringComparer.OrdinalIgnoreCase); - var records = fluxTables[0].Records - .Where(record => record.GetField() is string fieldName && entryPropertyNames.Contains(fieldName)) + var records = fluxTables + .SelectMany(table => table.Records) + .Where(record => record.GetField().ToLower() is string fieldName && entryPropertyNames.Contains(fieldName)) .ToList(); var groups = records diff --git a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs index 15bca32..53a627d 100644 --- a/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs +++ b/tilework.core/Providers/MonitoringProviders/Telegraf/TelegrafDataCollector.cs @@ -72,7 +72,7 @@ private void UpdateConfigFile(string path, List monit ["servers"] = new TomlArray { $"tcp://{source.Host.Value}:{source.Port}" }, - ["interval"] = "30s", + ["interval"] = "60s", ["tags"] = new TomlTable { ["instance"] = source.Name } diff --git a/tilework.core/Resources/telegraf.conf b/tilework.core/Resources/telegraf.conf index 7ea9f45..dd5f6da 100644 --- a/tilework.core/Resources/telegraf.conf +++ b/tilework.core/Resources/telegraf.conf @@ -1,6 +1,15 @@ [agent] debug = true +[[processors.starlark]] + namepass = ["haproxy"] + + source = ''' +def apply(metric): + metric.tags.pop("server", None) + return metric +''' + [[processors.starlark]] namepass = ["haproxy"] From 6c1562a28bbb049db52ea56ce836d80b8351cf84 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Mon, 29 Dec 2025 15:20:46 +0000 Subject: [PATCH 094/104] further cleanup of haproxy monitoring tags --- tilework.core/Resources/telegraf.conf | 8 +++++++- .../Services/LoadBalancing/LoadBalancerService.cs | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tilework.core/Resources/telegraf.conf b/tilework.core/Resources/telegraf.conf index dd5f6da..733a458 100644 --- a/tilework.core/Resources/telegraf.conf +++ b/tilework.core/Resources/telegraf.conf @@ -1,16 +1,22 @@ [agent] debug = true + + [[processors.starlark]] namepass = ["haproxy"] source = ''' +TAGS_TO_DROP = ["server", "host", "proxy", "sv"] + def apply(metric): - metric.tags.pop("server", None) + for k in TAGS_TO_DROP: + metric.tags.pop(k, None) return metric ''' + [[processors.starlark]] namepass = ["haproxy"] fieldinclude = ["stot", "req_tot"] diff --git a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs index c19c2e9..8583138 100644 --- a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs +++ b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs @@ -469,7 +469,7 @@ public async Task> GetLoadBalancerMonitoringData( var filters = new Dictionary(); filters["instance"] = lb.Id.ToString(); filters["type"] = "frontend"; - filters["proxy"] = lb.Id.ToString(); + filters["instance"] = lb.Id.ToString(); return await _monitoringService.GetMonitoringData("LoadBalancing", filters, interval, start, end); } From e974ceefbbf3d15eb4f5a9c786969f043884dbae Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Wed, 31 Dec 2025 14:05:29 +0000 Subject: [PATCH 095/104] Initial support for more metrics --- .../Monitoring/LoadBalancingMonitorData.cs | 10 +++++-- tilework.core/Resources/telegraf.conf | 30 ++++++++++++++++++- .../LoadBalancing/LoadBalancerDetail.razor | 30 +++++++++++++++---- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingMonitorData.cs b/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingMonitorData.cs index 833a313..a40741a 100644 --- a/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingMonitorData.cs +++ b/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingMonitorData.cs @@ -5,6 +5,12 @@ namespace Tilework.LoadBalancing.Models; public class LoadBalancingMonitorData : BaseMonitorData { - public int Sessions { get; set; } // stot - public int Requests { get; set; } // req_tot + public int Sessions { get; set; } + public int Requests { get; set; } + public int HttpResponses1xx { get; set; } + public int HttpResponses2xx { get; set; } + public int HttpResponses3xx { get; set; } + public int HttpResponses4xx { get; set; } + public int HttpResponses5xx { get; set; } + public int HttpResponsesOther { get; set; } } \ No newline at end of file diff --git a/tilework.core/Resources/telegraf.conf b/tilework.core/Resources/telegraf.conf index 733a458..a1558db 100644 --- a/tilework.core/Resources/telegraf.conf +++ b/tilework.core/Resources/telegraf.conf @@ -19,7 +19,11 @@ def apply(metric): [[processors.starlark]] namepass = ["haproxy"] - fieldinclude = ["stot", "req_tot"] + fieldinclude = [ + "stot", "req_tot", "http_response.1xx", "http_response.2xx", + "http_response.3xx", "http_response.4xx", "http_response.5xx", + "http_response.other" + ] source = ''' state = {} @@ -76,3 +80,27 @@ def apply(metric): [[processors.rename.replace]] field = "req_tot" dest = "requests" + + [[processors.rename.replace]] + field = "http_response.1xx" + dest = "httpresponses1xx" + + [[processors.rename.replace]] + field = "http_response.2xx" + dest = "httpresponses2xx" + + [[processors.rename.replace]] + field = "http_response.3xx" + dest = "httpresponses3xx" + + [[processors.rename.replace]] + field = "http_response.4xx" + dest = "httpresponses4xx" + + [[processors.rename.replace]] + field = "http_response.5xx" + dest = "httpresponses5xx" + + [[processors.rename.replace]] + field = "http_response.other" + dest = "httpresponsesother" diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index 000c561..2a63771 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -435,18 +435,36 @@ List? data = await _loadBalancerService.GetLoadBalancerMonitoringData(_item.Id, interval, from, to) ?? new List(); - var sessions = new Dictionary(); - var requests = new Dictionary(); + Dictionary> chartData = new() + { + ["Sessions"] = new Dictionary(), + ["Requests"] = new Dictionary(), + ["HTTP 1xx"] = new Dictionary(), + ["HTTP 2xx"] = new Dictionary(), + ["HTTP 3xx"] = new Dictionary(), + ["HTTP 4xx"] = new Dictionary(), + ["HTTP 5xx"] = new Dictionary(), + ["HTTP Other"] = new Dictionary(), + }; foreach (var entry in data.OrderBy(d => d.Timestamp)) { var timestamp = entry.Timestamp.ToUniversalTime(); - sessions[timestamp] = entry.Sessions; - requests[timestamp] = entry.Requests; + + chartData["Sessions"][timestamp] = entry.Sessions; + chartData["Requests"][timestamp] = entry.Requests; + chartData["HTTP 1xx"][timestamp] = entry.HttpResponses1xx; + chartData["HTTP 2xx"][timestamp] = entry.HttpResponses2xx; + chartData["HTTP 3xx"][timestamp] = entry.HttpResponses3xx; + chartData["HTTP 4xx"][timestamp] = entry.HttpResponses4xx; + chartData["HTTP 5xx"][timestamp] = entry.HttpResponses5xx; + chartData["HTTP Other"][timestamp] = entry.HttpResponsesOther; } - charts.Add(new TimeSeriesChartData { Name = "Sessions", Data = sessions }); - charts.Add(new TimeSeriesChartData { Name = "Requests", Data = requests }); + foreach(var datapoint in chartData) + { + charts.Add(new TimeSeriesChartData { Name = datapoint.Key, Data = datapoint.Value }); + } return charts; } From 624b0b2d3b4f50b4a6d5bc9cf485ad394a13c64b Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Wed, 31 Dec 2025 14:11:32 +0000 Subject: [PATCH 096/104] more responsive charts --- tilework.ui/Components/Shared/TimeSeriesChartPanel.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor b/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor index 2dc9041..c6c2c6c 100644 --- a/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor +++ b/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor @@ -39,7 +39,7 @@ @foreach (var chart in _chartData) { - + From 7c8502cf40608740e5db3aa69575b05c0de193e5 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Wed, 31 Dec 2025 14:20:35 +0000 Subject: [PATCH 097/104] tweaking --- tilework.ui/Components/Shared/TimeSeriesChartPanel.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor b/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor index c6c2c6c..401ebc3 100644 --- a/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor +++ b/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor @@ -39,7 +39,7 @@ @foreach (var chart in _chartData) { - + From af644346a825d12711731aa2c0f6f8ef1faa54ad Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Wed, 31 Dec 2025 14:26:26 +0000 Subject: [PATCH 098/104] more tweaking --- tilework.ui/Components/Shared/TimeSeriesChartPanel.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor b/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor index 401ebc3..6111f80 100644 --- a/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor +++ b/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor @@ -36,7 +36,7 @@ } else if (_chartData?.Any() == true) { - + @foreach (var chart in _chartData) { From ff90f4b022c68b2a13c02c5cb452f1c0384ecb4c Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Wed, 31 Dec 2025 14:28:52 +0000 Subject: [PATCH 099/104] tweaking --- tilework.ui/Components/Shared/TimeSeriesChartPanel.razor | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor b/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor index 6111f80..de88913 100644 --- a/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor +++ b/tilework.ui/Components/Shared/TimeSeriesChartPanel.razor @@ -36,11 +36,11 @@ } else if (_chartData?.Any() == true) { - + @foreach (var chart in _chartData) { - + From ac1eb4b57abecb22cf3970a8b7c8aa123cdae1c3 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Thu, 1 Jan 2026 12:12:40 +0000 Subject: [PATCH 100/104] Tweaks --- tilework.ui/Components/App.razor | 1 + tilework.ui/Components/Shared/TimeseriesChart.razor | 2 +- tilework.ui/wwwroot/css/custom.css | 6 ++++++ 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 tilework.ui/wwwroot/css/custom.css diff --git a/tilework.ui/Components/App.razor b/tilework.ui/Components/App.razor index 69f413c..4395dfa 100644 --- a/tilework.ui/Components/App.razor +++ b/tilework.ui/Components/App.razor @@ -6,6 +6,7 @@ + diff --git a/tilework.ui/Components/Shared/TimeseriesChart.razor b/tilework.ui/Components/Shared/TimeseriesChart.razor index 2b5b857..3d16765 100644 --- a/tilework.ui/Components/Shared/TimeseriesChart.razor +++ b/tilework.ui/Components/Shared/TimeseriesChart.razor @@ -45,7 +45,7 @@ var times = orderedData.Select(d => d.Key).ToList(); var duration = times.Last() - times.First(); - const int targetLabelCount = 6; + const int targetLabelCount = 4; var useDayLabels = duration >= TimeSpan.FromHours(48); TimeSpan interval; DateTime nextLabelTime; diff --git a/tilework.ui/wwwroot/css/custom.css b/tilework.ui/wwwroot/css/custom.css new file mode 100644 index 0000000..f8b1978 --- /dev/null +++ b/tilework.ui/wwwroot/css/custom.css @@ -0,0 +1,6 @@ + g.mud-charts-yaxis text { + font-size: 16px; + } + g.mud-charts-xaxis text { + font-size: 16px; + } \ No newline at end of file From 47af6d09df7864068b42cf0a4afd0f0a35ab1205 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Thu, 1 Jan 2026 14:52:40 +0200 Subject: [PATCH 101/104] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 01454f1..fec7ba2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## About -Tilework is a service delivery platform for tools and services of the modern software landscape. It is designed with the main objective of being simple and fast to configure rather than being in the way. +Tilework is a fully integrated reverse proxying and load balancing platform. It is designed with the main objective of being simple and fast to configure rather than being in the way. ## Features - Deployment of HTTP/TCP/UDP load balancers with multiple backends @@ -45,4 +45,4 @@ docker-compose up -d docker compose up -d ``` -4. Navigate your browser to http://\:5180 \ No newline at end of file +4. Navigate your browser to http://\:5180 From b0c6d27af86ee25c19d73539206a7f71267c531a Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Thu, 1 Jan 2026 14:53:58 +0200 Subject: [PATCH 102/104] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index fec7ba2..e4ed2ee 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Tilework is a fully integrated reverse proxying and load balancing platform. It - Deployment of HTTP/TCP/UDP load balancers with multiple backends - HTTP rules based routing, including hostname, URL path, query string - Certificate issuing via popular services, lifecycle management, auto-renewal +- Realtime and historical service statistics - Docker based service deployment - no disruption of the host environement From 0226024c941558dbb0046dacecf223c565923ba6 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Thu, 1 Jan 2026 14:54:34 +0200 Subject: [PATCH 103/104] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e4ed2ee..44e9b04 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Tilework is a fully integrated reverse proxying and load balancing platform. It ## Install -1. Install the [docker engine](https://docs.docker.com/engine/install/) or [docker desktop](https://docs.docker.com/get-started/get-docker/). Be sure that docker compose is also installed. +1. Install [docker engine](https://docs.docker.com/engine/install/) or [docker desktop](https://docs.docker.com/get-started/get-docker/). Be sure that docker compose is also installed. From dd770b9aff8b772b088650d59d21f0b7c9bdbd39 Mon Sep 17 00:00:00 2001 From: Alexandros Nikolopoulos Date: Thu, 1 Jan 2026 13:28:14 +0000 Subject: [PATCH 104/104] Switched to chart.js --- tilework.ui/Components/App.razor | 1 + .../Components/Shared/TimeseriesChart.razor | 79 +++++++++---- tilework.ui/wwwroot/css/custom.css | 17 ++- .../wwwroot/js/chartjs/chart.umd.min.js | 14 +++ tilework.ui/wwwroot/js/timeseriesChart.js | 104 ++++++++++++++++++ 5 files changed, 186 insertions(+), 29 deletions(-) create mode 100644 tilework.ui/wwwroot/js/chartjs/chart.umd.min.js create mode 100644 tilework.ui/wwwroot/js/timeseriesChart.js diff --git a/tilework.ui/Components/App.razor b/tilework.ui/Components/App.razor index 4395dfa..fefa5b6 100644 --- a/tilework.ui/Components/App.razor +++ b/tilework.ui/Components/App.razor @@ -18,6 +18,7 @@ + diff --git a/tilework.ui/Components/Shared/TimeseriesChart.razor b/tilework.ui/Components/Shared/TimeseriesChart.razor index 3d16765..0bddfd1 100644 --- a/tilework.ui/Components/Shared/TimeseriesChart.razor +++ b/tilework.ui/Components/Shared/TimeseriesChart.razor @@ -1,15 +1,14 @@ @using System.Linq -@using Tilework.LoadBalancing.Models +@using Microsoft.JSInterop +@using MudBlazor +@implements IAsyncDisposable +@inject IJSRuntime JsRuntime
@Name - +
+ +
@code { @@ -18,18 +17,17 @@ [Parameter] public Dictionary Data { get; set; } = new(); - private readonly ChartOptions _chartOptions = new ChartOptions { - ShowLegend = false, - LineStrokeWidth = 2, - ChartPalette = [Colors.Green.Darken4] - }; - private readonly AxisChartOptions _axisChartOptions = new AxisChartOptions(); + private static readonly string ChartColor = Colors.Green.Darken4; private string[] _chartLabels = Array.Empty(); - private List _series = new(); + private double[] _chartData = Array.Empty(); + private bool _renderPending; + private ElementReference _chartCanvas; + private IJSObjectReference? _chartModule; protected override void OnParametersSet() { BuildChartData(); + _renderPending = true; } private void BuildChartData() @@ -38,7 +36,7 @@ if (orderedData.Count == 0) { _chartLabels = Array.Empty(); - _series = new List(); + _chartData = Array.Empty(); return; } @@ -81,14 +79,49 @@ _chartLabels[i] = label; } - _series = new List + _chartData = orderedData.Select(d => (double)d.Value).ToArray(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!_renderPending) { - new ChartSeries - { - Name = Name, - Data = orderedData.Select(d => (double)d.Value).ToArray() - } - }; + return; + } + + _chartModule ??= await JsRuntime.InvokeAsync("import", "./js/timeseriesChart.js"); + if (_chartModule is null) + { + return; + } + + await _chartModule.InvokeVoidAsync("renderTimeseriesChart", _chartCanvas, new + { + labels = _chartLabels, + data = _chartData, + color = ChartColor, + name = Name + }); + + _renderPending = false; + } + + public async ValueTask DisposeAsync() + { + if (_chartModule is null) + { + return; + } + + try + { + await _chartModule.InvokeVoidAsync("disposeTimeseriesChart", _chartCanvas); + await _chartModule.DisposeAsync(); + } + catch (JSDisconnectedException) + { + // JS runtime already disposed + } } private static int GetRoundedIntervalMinutes(double approxMinutes) diff --git a/tilework.ui/wwwroot/css/custom.css b/tilework.ui/wwwroot/css/custom.css index f8b1978..90c8bd0 100644 --- a/tilework.ui/wwwroot/css/custom.css +++ b/tilework.ui/wwwroot/css/custom.css @@ -1,6 +1,11 @@ - g.mud-charts-yaxis text { - font-size: 16px; - } - g.mud-charts-xaxis text { - font-size: 16px; - } \ No newline at end of file + +.timeseries-chart-container { + position: relative; + width: 100%; + height: 220px; +} + +.timeseries-chart-container canvas { + width: 100% !important; + height: 100% !important; +} diff --git a/tilework.ui/wwwroot/js/chartjs/chart.umd.min.js b/tilework.ui/wwwroot/js/chartjs/chart.umd.min.js new file mode 100644 index 0000000..008464f --- /dev/null +++ b/tilework.ui/wwwroot/js/chartjs/chart.umd.min.js @@ -0,0 +1,14 @@ +/*! + * Chart.js v4.5.1 + * https://www.chartjs.org + * (c) 2025 Chart.js Contributors + * Released under the MIT License + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Jo},get Decimation(){return ta},get Filler(){return ba},get Legend(){return Ma},get SubTitle(){return Pa},get Title(){return ka},get Tooltip(){return Na}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)t-e)).pop(),e}function N(t){return!function(t){return"symbol"==typeof t||"object"==typeof t&&null!==t&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;sl&&h=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return nt[s][e]et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;ss&&t[n-1]>i;)n--;return s>0||n{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(ot.forEach((e=>{delete t[e]})),delete t._chartjs)}function lt(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const ht="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function ct(t,e){let i=[],s=!1;return function(...n){i=n,s||(s=!0,ht.call(window,(()=>{s=!1,t.apply(e,i)})))}}function dt(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const ut=t=>"start"===t?"left":"end"===t?"right":"center",ft=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,gt=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function pt(t,e,i){const n=e.length;let o=0,a=n;if(t._sorted){const{iScale:r,vScale:l,_parsed:h}=t,c=t.dataset&&t.dataset.options?t.dataset.options.spanGaps:null,d=r.axis,{min:u,max:f,minDefined:g,maxDefined:p}=r.getUserBounds();if(g){if(o=Math.min(it(h,d,u).lo,i?n:it(e,d,r.getPixelForValue(u)).lo),c){const t=h.slice(0,o+1).reverse().findIndex((t=>!s(t[l.axis])));o-=Math.max(0,t)}o=Z(o,0,n-1)}if(p){let t=Math.max(it(h,r.axis,f,!0).hi+1,i?0:it(e,d,r.getPixelForValue(f),!0).hi+1);if(c){const e=h.slice(t-1).findIndex((t=>!s(t[l.axis])));t+=Math.max(0,e)}a=Z(t,o,n)-o}else a=n-o}return{start:o,count:a}}function mt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}class xt{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=ht.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var bt=new xt; +/*! + * @kurkle/color v0.3.2 + * https://github.com/kurkle/color#readme + * (c) 2023 Jukka Kurkela + * Released under the MIT License + */function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=Ft(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function qt(t,e){return t?Object.assign(e||{},t):t}function Kt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Mt(t[3]))):(e=qt(t,{r:0,g:0,b:0,a:1})).a=Mt(e.a),e}function Gt(t){return"r"===t.charAt(0)?function(t){const e=$t.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?vt(t):yt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?vt(i):yt(i,0,255)),s=255&(e[4]?vt(s):yt(s,0,255)),n=255&(e[6]?vt(n):yt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Bt(t)}class Jt{constructor(t){if(t instanceof Jt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Kt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*St[s[1]],g:255&17*St[s[2]],b:255&17*St[s[3]],a:5===o?17*St[s[4]]:255}:7!==o&&9!==o||(n={r:St[s[1]]<<4|St[s[2]],g:St[s[3]]<<4|St[s[4]],b:St[s[5]]<<4|St[s[6]],a:9===o?St[s[7]]<<4|St[s[8]]:255})),i=n||jt(t)||Gt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=qt(this._rgb);return t&&(t.a=wt(t.a)),t}set rgb(t){this._rgb=Kt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${wt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?At(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=It(t),i=e[0],s=kt(e[1]),n=kt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${wt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=Ut(wt(t.r)),n=Ut(wt(t.g)),o=Ut(wt(t.b));return{r:Mt(Yt(s+i*(Ut(wt(e.r))-s))),g:Mt(Yt(n+i*(Ut(wt(e.g))-n))),b:Mt(Yt(o+i*(Ut(wt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Jt(this.rgb)}alpha(t){return this._rgb.a=Mt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=_t(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Xt(this._rgb,2,t),this}darken(t){return Xt(this._rgb,2,-t),this}saturate(t){return Xt(this._rgb,1,t),this}desaturate(t){return Xt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=It(t);i[0]=Vt(i[0]+e),i=Ft(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Zt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Qt(t){return Zt(t)?t:new Jt(t)}function te(t){return Zt(t)?t:new Jt(t).saturate(.5).darken(.1).hexString()}const ee=["x","y","borderWidth","radius","tension"],ie=["color","borderColor","backgroundColor"];const se=new Map;function ne(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=se.get(i);return s||(s=new Intl.NumberFormat(t,e),se.set(i,s)),s}(e,i).format(t)}const oe={values:t=>n(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=z(Math.abs(o)),r=isNaN(a)?1:Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ne(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=i[e].significand||t/Math.pow(10,Math.floor(z(t)));return[1,2,3,5,10,15].includes(s)||e>.8*i.length?oe.numeric.call(this,t,e,i):""}};var ae={formatters:oe};const re=Object.create(null),le=Object.create(null);function he(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;et.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>te(e.backgroundColor),this.hoverBorderColor=(t,e)=>te(e.borderColor),this.hoverColor=(t,e)=>te(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return ce(this,t,e)}get(t){return he(this,t)}describe(t,e){return ce(le,t,e)}override(t,e){return ce(re,t,e)}route(t,e,i,s){const n=he(this,t),a=he(this,i),r="_"+e;Object.defineProperties(n,{[r]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[r],e=a[s];return o(t)?Object.assign({},e,t):l(t,e)},set(t){this[r]=t}}})}apply(t){t.forEach((t=>t(this)))}}var ue=new de({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ie},numbers:{type:"number",properties:ee}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ae.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function fe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ge(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function pe(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const me=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function xe(t,e){return me(t).getPropertyValue(e)}const be=["top","right","bottom","left"];function _e(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=be[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}const ye=(t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot);function ve(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=me(i),o="border-box"===n.boxSizing,a=_e(n,"padding"),r=_e(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(ye(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const Me=t=>Math.round(10*t)/10;function we(t,e,i,s){const n=me(t),o=_e(n,"margin"),a=pe(n.maxWidth,t,"clientWidth")||T,r=pe(n.maxHeight,t,"clientHeight")||T,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=t&&ge(t);if(o){const t=o.getBoundingClientRect(),a=me(o),r=_e(a,"border","width"),l=_e(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=pe(a.maxWidth,o,"clientWidth"),n=pe(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||T,maxHeight:n||T}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=_e(n,"border","width"),e=_e(n,"padding");h-=e.width+t.width,c-=e.height+t.height}h=Math.max(0,h-o.width),c=Math.max(0,s?h/s:c-o.height),h=Me(Math.min(h,a,l.maxWidth)),c=Me(Math.min(c,r,l.maxHeight)),h&&!c&&(c=Me(h/2));return(void 0!==e||void 0!==i)&&s&&l.height&&c>l.height&&(c=l.height,h=Me(Math.floor(c*s))),{width:h,height:c}}function ke(t,e,i){const s=e||1,n=Me(t.height*s),o=Me(t.width*s);t.height=Me(t.height),t.width=Me(t.width);const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const Se=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};fe()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch(t){}return t}();function Pe(t,e){const i=xe(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function De(t){return!t||s(t.size)||s(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ce(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function Oe(t,e,i,s){let o=(s=s||{}).data=s.data||{},a=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},a=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;hi.length){for(h=0;h0&&t.stroke()}}function Re(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.xe.top-i&&t.y0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),s(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;ct[0])){const o=i||t;void 0===s&&(s=ti("_fallback",t));const a={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:s,_getTarget:n,override:i=>je([i,...t],e,o,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>qe(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=ti(Ue(o,t),i),void 0!==n)return Xe(t,n)?Ze(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(t,e,i,s){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ye(t,s),setContext:e=>$e(t,e,i,s),override:n=>$e(t.override(n),e,i,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>qe(t,e,(()=>function(t,e,i){const{_proxy:s,_context:a,_subProxy:r,_descriptors:l}=t;let h=s[e];S(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t);let l=e(o,a||s);r.delete(t),Xe(t,l)&&(l=Ze(n._scopes,n,t,l));return l}(e,h,t,i));n(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:n,_context:a,_subProxy:r,_descriptors:l}=i;if(void 0!==a.index&&s(t))return e[a.index%e.length];if(o(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const o of i){const i=Ze(s,n,t,o);e.push($e(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Xe(e,h)&&(h=$e(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ye(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:S(i)?i:()=>i,isIndexable:S(s)?s:()=>s}}const Ue=(t,e)=>t?t+w(e):e,Xe=(t,e)=>o(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function qe(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e)||"constructor"===e)return t[e];const s=i();return t[e]=s,s}function Ke(t,e,i){return S(t)?t(e,i):t}const Ge=(t,e)=>!0===t?e:"string"==typeof t?M(e,t):void 0;function Je(t,e,i,s,n){for(const o of e){const e=Ge(i,o);if(e){t.add(e);const o=Ke(e._fallback,i,n);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}return!1}function Ze(t,e,i,s){const a=e._rootScopes,r=Ke(e._fallback,i,s),l=[...t,...a],h=new Set;h.add(s);let c=Qe(h,l,i,r||i,s);return null!==c&&((void 0===r||r===i||(c=Qe(h,l,r,c,s),null!==c))&&je(Array.from(h),[""],a,r,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const a=s[e];if(n(a)&&o(i))return i;return a||{}}(e,i,s))))}function Qe(t,e,i,s,n){for(;i;)i=Je(t,e,i,s,n);return i}function ti(t,e){for(const i of e){if(!i)continue;const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function ii(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function ai(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=q(o,n),l=q(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function ri(t,e="x"){const i=oi(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=ni(t,0);for(a=0;a!t.skip))),"monotone"===e.cubicInterpolationMode)ri(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,fi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*E),easeOutSine:t=>Math.sin(t*E),easeInOutSine:t=>-.5*(Math.cos(C*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){const e=.1125;return ci(t)?t:t<.5?.5*di(2*t,e,.45):.5+.5*ui(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-fi.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*fi.easeInBounce(2*t):.5*fi.easeOutBounce(2*t-1)+.5};function gi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function mi(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=gi(t,n,i),r=gi(n,o,i),l=gi(o,e,i),h=gi(a,r,i),c=gi(r,l,i);return gi(h,c,i)}const xi=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/,bi=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function _i(t,e){const i=(""+t).match(xi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}const yi=t=>+t||0;function vi(t,e){const i={},s=o(e),n=s?Object.keys(e):e,a=o(t)?s?i=>l(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=yi(a(t));return i}function Mi(t){return vi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return vi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function ki(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Si(t,e){t=t||{},e=e||ue.font;let i=l(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=l(t.style,e.style);s&&!(""+s).match(bi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const n={family:l(t.family,e.family),lineHeight:_i(l(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:l(t.weight,e.weight),string:""};return n.string=De(n),n}function Pi(t,e,i,s){let o,a,r,l=!0;for(o=0,a=t.length;oi&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function Ci(t,e){return Object.assign(Object.create(t),e)}function Oi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function Ai(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ti(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Li(t){return"angle"===t?{between:J,compare:K,normalize:G}:{between:tt,compare:(t,e)=>t-e,normalize:t=>t}}function Ei({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Ri(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Li(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Li(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;hb||l(n,x,p)&&0!==r(n,x),v=()=>!b||0===r(o,p)||l(o,x,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==x&&(b=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(Ei({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,x=p));return null!==_&&g.push(Ei({start:_,end:d,loop:u,count:a,style:f})),g}function Ii(t,e){const i=[],s=t.segments;for(let n=0;nn&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Fi(t,[{start:a,end:r,loop:o}],i,e);return Fi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r!s(t[e.axis])));n.lo-=Math.max(0,a);const r=i.slice(n.hi).findIndex((t=>!s(t[e.axis])));n.hi+=Math.max(0,r)}return n}if(o._sharedOptions){const t=a[0],s="function"==typeof t.getRange&&t.getRange(e);if(s){const t=r(a,e,i-s),n=r(a,e,i+s);return{lo:t.lo,hi:n.hi}}}}return{lo:0,hi:a.length-1}}function $i(t,e,i,s,n){const o=t.getSortedVisibleDatasetMetas(),a=i[e];for(let t=0,i=o.length;t{t[a]&&t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Ki={evaluateInteractionItems:$i,modes:{index(t,e,i,s){const n=ve(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?Yi(t,n,o,s,a):Xi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?Yi(t,n,o,s,a):Xi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;tYi(t,ve(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Xi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>qi(t,ve(e,t),"x",i.intersect,s),y:(t,e,i,s)=>qi(t,ve(e,t),"y",i.intersect,s)}};const Gi=["left","top","right","bottom"];function Ji(t,e){return t.filter((t=>t.pos===e))}function Zi(t,e){return t.filter((t=>-1===Gi.indexOf(t.pos)&&t.box.axis===e))}function Qi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function ts(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!Gi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function os(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;ot.box.fullSize)),!0),s=Qi(Ji(e,"left"),!0),n=Qi(Ji(e,"right")),o=Qi(Ji(e,"top"),!0),a=Qi(Ji(e,"bottom")),r=Zi(e,"x"),l=Zi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ji(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;u(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);is(f,ki(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=ts(l.concat(h),d);os(r.fullSize,g,d,p),os(l,g,d,p),os(h,g,d,p)&&os(l,g,d,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),rs(r.leftAndTop,g,d,p),g.x+=g.w,g.y+=g.h,rs(r.rightAndBottom,g,d,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},u(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class hs{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class cs extends hs{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const ds="$chartjs",us={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},fs=t=>null===t||""===t;const gs=!!Se&&{passive:!0};function ps(t,e,i){t&&t.canvas&&t.canvas.removeEventListener(e,i,gs)}function ms(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function xs(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||ms(i.addedNodes,s),e=e&&!ms(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function bs(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||ms(i.removedNodes,s),e=e&&!ms(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const _s=new Map;let ys=0;function vs(){const t=window.devicePixelRatio;t!==ys&&(ys=t,_s.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function Ms(t,e,i){const s=t.canvas,n=s&&ge(s);if(!n)return;const o=ct(((t,e)=>{const s=n.clientWidth;i(t,e),s{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){_s.size||window.addEventListener("resize",vs),_s.set(t,e)}(t,o),a}function ws(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){_s.delete(t),_s.size||window.removeEventListener("resize",vs)}(t)}function ks(t,e,i){const s=t.canvas,n=ct((e=>{null!==t.ctx&&i(function(t,e){const i=us[t.type]||t.type,{x:s,y:n}=ve(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t);return function(t,e,i){t&&t.addEventListener(e,i,gs)}(s,e,n),n}class Ss extends hs{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t[ds]={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",fs(n)){const e=Pe(t,"width");void 0!==e&&(t.width=e)}if(fs(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=Pe(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[ds])return!1;const i=e[ds].initial;["height","width"].forEach((t=>{const n=i[t];s(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=i.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e[ds],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:xs,detach:bs,resize:Ms}[e]||ks;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:ws,detach:ws,resize:ws}[e]||ps)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return we(t,e,i,s)}isAttached(t){const e=t&&ge(t);return!(!e||!e.isConnected)}}function Ps(t){return!fe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?cs:Ss}var Ds=Object.freeze({__proto__:null,BasePlatform:hs,BasicPlatform:cs,DomPlatform:Ss,_detectPlatform:Ps});const Cs="transparent",Os={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Qt(t||Cs),n=s.valid&&Qt(e||Cs);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class As{constructor(t,e,i,s){const n=e[i];s=Pi([t.to,s,n,t.from]);const o=Pi([t.from,n,s]);this._active=!0,this._fn=t.fn||Os[t.type||typeof o],this._easing=fi[t.easing]||fi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=Pi([t.to,e,s,t.from]),this._from=Pi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const a=t[s];if(!o(a))return;const r={};for(const t of e)r[t]=a[t];(n(a.properties)&&a.properties||[s]).forEach((t=>{t!==s&&i.has(t)||i.set(t,r)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new As(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(bt.add(this._chart,i),!0):void 0}}function Ls(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function Es(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n0||!i&&e<0)return n.index}return null}function Vs(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;ti[t].axis===e)).shift()}function Ws(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Ns=t=>"reset"===t||"none"===t,Hs=(t,e)=>e?t:Object.assign({},t);class js{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Is(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Ws(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=l(i.xAxisID,Bs(t,"x")),o=e.yAxisID=l(i.yAxisID,Bs(t,"y")),a=e.rAxisID=l(i.rAxisID,Bs(t,"r")),r=e.indexAxis,h=e.iAxisID=s(r,n,o,a),c=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&rt(this._data,this),t._stacked&&Ws(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(o(e)){const t=this._cachedMeta;this._data=function(t,e){const{iScale:i,vScale:s}=e,n="x"===i.axis?"x":"y",o="x"===s.axis?"x":"y",a=Object.keys(t),r=new Array(a.length);let l,h,c;for(l=0,h=a.length;l0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,d=s;else{d=n(s[t])?this.parseArrayData(i,s,t,e):o(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const a=()=>null===c[l]||f&&c[l]t&&!e.hidden&&e._stacked&&{keys:Es(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!a(f[t.axis])||c>e||d=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s=0&&tthis.getContext(i,s,e)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Hs(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new Ts(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Ns(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){Ns(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Ns(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}function Ys(t,e){const i=t.options.ticks,n=function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=Math.min(i.maxTicksLimit||n,n),a=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;io)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;nn)return e}return Math.max(n,1)}(a,e,o);if(r>0){let t,i;const n=r>1?Math.round((h-l)/(r-1)):null;for(Us(e,c,d,s(n)?0:l-n,l),t=0,i=r-1;t"top"===e||"left"===e?t[e]+i:t[e]-i,qs=(t,e)=>Math.min(e||t,t);function Ks(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;oa+r)))return h}function Js(t){return t.drawTicks?t.tickLength:0}function Zs(t,e){if(!t.display)return 0;const i=Si(t.font,e),s=ki(t.padding);return(n(t.text)?t.text.length:1)*i.lineHeight+s.height}function Qs(t,e,i){let s=ut(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class tn extends $s{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=r(t,Number.POSITIVE_INFINITY),e=r(e,Number.NEGATIVE_INFINITY),i=r(i,Number.POSITIVE_INFINITY),s=r(s,Number.NEGATIVE_INFINITY),{min:r(t,i),max:r(e,s),minDefined:a(t),maxDefined:a(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;rs?s:i,s=n&&i>s?i:s,{min:r(i,r(s,i)),max:r(s,r(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Di(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=Z(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Js(t.grid)-e.padding-Zs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Y(Math.min(Math.asin(Z((h.highest.height+6)/o,-1,1)),Math.asin(Z(a/r,-1,1))-Math.asin(Z(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){d(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){d(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Zs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Js(n)+o):(t.height=this.maxHeight,t.width=Js(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=$(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){d(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n({width:r[t]||0,height:l[t]||0});return{first:P(0),last:P(e-1),widest:P(k),highest:P(S),widths:r,heights:l}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return Q(this._alignToPixels?Ae(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*s?a/i:r/s:r*s0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:a,border:r}=s,h=n.offset,c=this.isHorizontal(),d=this.ticks.length+(h?1:0),u=Js(n),f=[],g=r.setContext(this.getContext()),p=g.display?g.width:0,m=p/2,x=function(t){return Ae(i,t,p)};let b,_,y,v,M,w,k,S,P,D,C,O;if("top"===a)b=x(this.bottom),w=this.bottom-u,S=b-m,D=x(t.top)+m,O=t.bottom;else if("bottom"===a)b=x(this.top),D=t.top,O=x(t.bottom)-m,w=b+m,S=this.top+u;else if("left"===a)b=x(this.right),M=this.right-u,k=b-m,P=x(t.left)+m,C=t.right;else if("right"===a)b=x(this.left),P=t.left,C=x(t.right)-m,M=b+m,k=this.left+u;else if("x"===e){if("center"===a)b=x((t.top+t.bottom)/2+.5);else if(o(a)){const t=Object.keys(a)[0],e=a[t];b=x(this.chart.scales[t].getPixelForValue(e))}D=t.top,O=t.bottom,w=b+m,S=w+u}else if("y"===e){if("center"===a)b=x((t.left+t.right)/2);else if(o(a)){const t=Object.keys(a)[0],e=a[t];b=x(this.chart.scales[t].getPixelForValue(e))}M=b-m,k=M-u,P=t.left,C=t.right}const A=l(s.ticks.maxTicksLimit,d),T=Math.max(1,Math.ceil(d/A));for(_=0;_0&&(o-=s/2)}d={left:o,top:n,width:s+e.width,height:i+e.height,color:t.backdropColor}}x.push({label:v,font:P,textOffset:O,options:{rotation:m,color:i,strokeColor:o,strokeWidth:h,textAlign:f,textBaseline:A,translation:[M,w],backdrop:d}})}return x}_getXAxisLabelAlignment(){const{position:t,ticks:e}=this.options;if(-$(this.labelRotation))return"top"===t?"left":"right";let i="center";return"start"===e.align?i="left":"end"===e.align?i="right":"inner"===e.align&&(i="inner"),i}_getYAxisLabelAlignment(t){const{position:e,ticks:{crossAlign:i,mirror:s,padding:n}}=this.options,o=t+n,a=this._getLabelSizes().widest.width;let r,l;return"left"===e?s?(l=this.right+n,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l+=a)):(l=this.right-o,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l=this.left)):"right"===e?s?(l=this.left+n,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l-=a)):(l=this.left+o,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l=this.right)):r="right",{textAlign:r,x:l}}_computeLabelArea(){if(this.options.ticks.mirror)return;const t=this.chart,e=this.options.position;return"left"===e||"right"===e?{top:0,left:this.left,bottom:t.height,right:this.right}:"top"===e||"bottom"===e?{top:this.top,left:0,bottom:this.bottom,right:t.width}:void 0}drawBackground(){const{ctx:t,options:{backgroundColor:e},left:i,top:s,width:n,height:o}=this;e&&(t.save(),t.fillStyle=e,t.fillRect(i,s,n,o),t.restore())}getLineWidthForValue(t){const e=this.options.grid;if(!this._isVisible()||!e.display)return 0;const i=this.ticks.findIndex((e=>e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ue.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ue.describe(e,t.descriptors)}(t,o,i),this.override&&ue.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ue[s]&&(delete ue[s][i],this.override&&delete re[i])}}class sn{constructor(){this.controllers=new en(js,"datasets",!0),this.elements=new en($s,"elements"),this.plugins=new en(Object,"plugins"),this.scales=new en(tn,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):u(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function an(t,e){return e||!1!==t?!0===t?{}:t:null}function rn(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function ln(t,e){const i=ue.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function hn(t){if("x"===t||"y"===t||"r"===t)return t}function cn(t,...e){if(hn(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.length>1&&hn(t[0].toLowerCase());if(e)return e}var i;throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function dn(t,e,i){if(i[e+"AxisID"]===t)return{axis:e}}function un(t,e){const i=re[t.type]||{scales:{}},s=e.scales||{},n=ln(t.type,e),a=Object.create(null);return Object.keys(s).forEach((e=>{const r=s[e];if(!o(r))return console.error(`Invalid scale configuration for scale: ${e}`);if(r._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=cn(e,r,function(t,e){if(e.data&&e.data.datasets){const i=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(i.length)return dn(t,"x",i[0])||dn(t,"y",i[0])}return{}}(e,t),ue.scales[r.type]),h=function(t,e){return t===e?"_index_":"_value_"}(l,n),c=i.scales||{};a[e]=b(Object.create(null),[{axis:l},r,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||ln(n,e),r=(re[n]||{}).scales||{};Object.keys(r).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||e;a[n]=a[n]||Object.create(null),b(a[n],[{axis:e},s[n],r[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];b(e,[ue.scales[e.type],ue.scale])})),a}function fn(t){const e=t.options||(t.options={});e.plugins=l(e.plugins,{}),e.scales=un(t,e)}function gn(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const pn=new Map,mn=new Set;function xn(t,e){let i=pn.get(t);return i||(i=e(),pn.set(t,i),mn.add(i)),i}const bn=(t,e,i)=>{const s=M(e,i);void 0!==s&&t.add(s)};class _n{constructor(t){this._config=function(t){return(t=t||{}).data=gn(t.data),fn(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=gn(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),fn(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return xn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return xn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return xn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return xn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>bn(r,t,e)))),e.forEach((t=>bn(r,s,t))),e.forEach((t=>bn(r,re[n]||{},t))),e.forEach((t=>bn(r,ue,t))),e.forEach((t=>bn(r,le,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),mn.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,re[e]||{},ue.datasets[e]||{},{type:e},ue,le]}resolveNamedOptions(t,e,i,s=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=yn(this._resolverCache,t,s);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:s}=Ye(t);for(const o of e){const e=i(o),a=s(o),r=(a||e)&&t[o];if(e&&(S(r)||vn(r))||a&&n(r))return!0}return!1}(a,e)){o.$shared=!1;l=$e(a,i=S(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:n}=yn(this._resolverCache,t,i);return o(e)?$e(n,e,void 0,s):n}}function yn(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:je(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const vn=t=>o(t)&&Object.getOwnPropertyNames(t).some((e=>S(t[e])));const Mn=["top","bottom","left","right","chartArea"];function wn(t,e){return"top"===t||"bottom"===t||-1===Mn.indexOf(t)&&"x"===e}function kn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function Sn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function Pn(t){const e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function Dn(t){return fe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Cn={},On=t=>{const e=Dn(t);return Object.values(Cn).filter((t=>t.canvas===e)).pop()};function An(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}class Tn{static defaults=ue;static instances=Cn;static overrides=re;static registry=nn;static version="4.5.1";static getChart=On;static register(...t){nn.add(...t),Ln()}static unregister(...t){nn.remove(...t),Ln()}constructor(t,e){const s=this.config=new _n(e),n=Dn(t),o=On(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||Ps(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=i(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new on,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=dt((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Cn[this.id]=this,r&&l?(bt.listen(this,"complete",Sn),bt.listen(this,"progress",Pn),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return s(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return nn}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ke(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Te(this.canvas,this.ctx),this}stop(){return bt.stop(this),this}resize(t,e){bt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ke(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),d(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){u(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=cn(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),u(n,(e=>{const n=e.options,o=n.id,a=cn(o,n),r=l(n.type,e.dtype);void 0!==n.position&&wn(n.position,a)===wn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===r)h=i[o];else{h=new(nn.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),u(s,((t,e)=>{t||delete i[e]})),u(i,(t=>{ls.configure(this,t,t.options),ls.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(kn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){u(this.scales,(t=>{ls.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);P(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){An(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;tt.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;ls.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],u(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i={meta:t,index:t.index,cancelable:!0},s=Ni(this,t);!1!==this.notifyPlugins("beforeDatasetDraw",i)&&(s&&Ie(e,s),t.controller.draw(),s&&ze(e),i.cancelable=!1,this.notifyPlugins("afterDatasetDraw",i))}isPointInArea(t){return Re(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Ki.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Ci(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);k(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),bt.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};u(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){u(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},u(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!f(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=D(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,d(n.onHover,[t,a,this],this),r&&d(n.onClick,[t,a,this],this));const h=!f(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}function Ln(){return u(Tn.instances,(t=>t._plugins.invalidate()))}function En(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class Rn{static override(t){Object.assign(Rn.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return En()}parse(){return En()}format(){return En()}add(){return En()}diff(){return En()}startOf(){return En()}endOf(){return En()}}var In={_date:Rn};function zn(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;et-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(k(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;sMath.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function Vn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;ht.x,i="left",s="right"):(e=t.base"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data,{labels:{pointStyle:i,textAlign:s,color:n,useBorderRadius:o,borderRadius:a}}=t.legend.options;return e.labels.length&&e.datasets.length?e.labels.map(((e,r)=>{const l=t.getDatasetMeta(0).controller.getStyle(r);return{text:e,fillStyle:l.backgroundColor,fontColor:n,hidden:!t.getDataVisibility(r),lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:l.borderWidth,strokeStyle:l.borderColor,textAlign:s,pointStyle:i,borderRadius:o&&(a||l.borderRadius),index:r}})):[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,a,r=t=>+i[t];if(o(i[t])){const{key:t="value"}=this._parsing;r=e=>+M(i[e],t)}for(n=t,a=t+e;nJ(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>J(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(E,c,u),x=g(C,h,d),b=g(C+E,c,u);s=(p-x)/2,n=(m-b)/2,o=-(p+x)/2,a=-(m+b)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),x=(i.width-o)/f,b=(i.height-o)/g,_=Math.max(Math.min(x,b)/2,0),y=c(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(se.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*C;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?$(this.resolveDataElementOptions(t,e).angle||i):0}}var Un=Object.freeze({__proto__:null,BarController:class extends js{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return Vn(t,e,i,s)}parseArrayData(t,e,i,s){return Vn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;dt.controller.options.grouped)),o=i.options.stacked,a=[],r=this._cachedMeta.controller.getParsed(e),l=r&&r[i.axis],h=t=>{const e=t._parsed.find((t=>t[i.axis]===l)),n=e&&e[t.vScale.axis];if(s(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!h(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getAxisCount(){return this._getAxis().length}getFirstScaleIdForIndexAxis(){const t=this.chart.scales,e=this.chart.options.indexAxis;return Object.keys(t).filter((i=>t[i].axis===e)).shift()}_getAxis(){const t={},e=this.getFirstScaleIdForIndexAxis();for(const i of this.chart.data.datasets)t[l("x"===this.chart.options.indexAxis?i.xAxisID:i.yAxisID,e)]=!0;return Object.keys(t)}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n=i?1:-1)}(u,e,r)*a,f===r&&(x-=u/2);const t=e.getPixelForDecimal(0),s=e.getPixelForDecimal(1),o=Math.min(t,s),h=Math.max(t,s);x=Math.max(Math.min(x,h),o),d=x+u,i&&!c&&(l._stacks[e.axis]._visualValues[n]=e.getValueForPixel(d)-e.getValueForPixel(x))}if(x===e.getPixelForValue(r)){const t=F(u)*e.getLineWidthForValue(r)/2;x+=t,u-=t}return{size:u,base:x,head:d,center:d+u/2}}_calculateBarIndexPixels(t,e){const i=e.scale,n=this.options,o=n.skipNull,a=l(n.maxBarThickness,1/0);let r,h;const c=this._getAxisCount();if(e.grouped){const i=o?this._getStackCount(t):e.stackCount,d="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y),l=o._custom;return{label:i[t]||"",value:"("+a+", "+r+(l?", "+l:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d0&&this.getParsed(e-1);for(let i=0;i<_;++i){const g=t[i],_=x?g:{};if(i=b){_.skip=!0;continue}const v=this.getParsed(i),M=s(v[f]),w=_[u]=a.getPixelForValue(v[u],i),k=_[f]=o||M?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,v,l):v[f],i);_.skip=isNaN(w)||isNaN(k)||M,_.stop=i>0&&Math.abs(v[u]-y[u])>m,p&&(_.parsed=v,_.raw=h.data[i]),d&&(_.options=c||this.resolveDataElementOptions(i,g.active?"active":n)),x||this.updateElement(g,i,_,n),y=v}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends $n{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:Yn,RadarController:class extends js{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a0&&this.getParsed(e-1);for(let c=e;c0&&Math.abs(i[f]-_[f])>x,m&&(p.parsed=i,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),b||this.updateElement(e,c,p,n),_=i}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}});function Xn(t,e,i,s){const n=vi(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return Z(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:Z(n.innerStart,0,a),innerEnd:Z(n.innerEnd,0,a)}}function qn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function Kn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/C)/d)/2,m=l+p+f,x=n-p-f,{outerStart:b,outerEnd:_,innerStart:y,innerEnd:v}=Xn(e,u,d,x-m),M=d-b,w=d-_,k=m+b/M,S=x-_/w,P=u+y,D=u+v,O=m+y/P,A=x-v/D;if(t.beginPath(),o){const e=(k+S)/2;if(t.arc(a,r,d,k,e),t.arc(a,r,d,e,S),_>0){const e=qn(w,S,a,r);t.arc(e.x,e.y,_,S,x+E)}const i=qn(D,x,a,r);if(t.lineTo(i.x,i.y),v>0){const e=qn(D,A,a,r);t.arc(e.x,e.y,v,x+E,A+Math.PI)}const s=(x-v/u+(m+y/u))/2;if(t.arc(a,r,u,x-v/u,s,!0),t.arc(a,r,u,s,m+y/u,!0),y>0){const e=qn(P,O,a,r);t.arc(e.x,e.y,y,O+Math.PI,m-E)}const n=qn(M,m,a,r);if(t.lineTo(n.x,n.y),b>0){const e=qn(M,k,a,r);t.arc(e.x,e.y,b,m-E,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function Gn(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r,options:l}=e,{borderWidth:h,borderJoinStyle:c,borderDash:d,borderDashOffset:u,borderRadius:f}=l,g="inner"===l.borderAlign;if(!h)return;t.setLineDash(d||[]),t.lineDashOffset=u,g?(t.lineWidth=2*h,t.lineJoin=c||"round"):(t.lineWidth=h,t.lineJoin=c||"bevel");let p=e.endAngle;if(o){Kn(t,e,i,s,p,n);for(let e=0;en?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+E,s-E),t.closePath(),t.clip()}(t,e,p),l.selfJoin&&p-a>=C&&0===f&&"miter"!==c&&function(t,e,i){const{startAngle:s,x:n,y:o,outerRadius:a,innerRadius:r,options:l}=e,{borderWidth:h,borderJoinStyle:c}=l,d=Math.min(h/a,G(s-i));if(t.beginPath(),t.arc(n,o,a-h/2,s+d/2,i-d/2),r>0){const e=Math.min(h/r,G(s-i));t.arc(n,o,r+h/2,i-e/2,s+e/2,!0)}else{const e=Math.min(h/2,a*G(s-i));if("round"===c)t.arc(n,o,e,i-C/2,s+C/2,!0);else if("bevel"===c){const a=2*e*e,r=-a*Math.cos(i+C/2)+n,l=-a*Math.sin(i+C/2)+o,h=a*Math.cos(s+C/2)+n,c=a*Math.sin(s+C/2)+o;t.lineTo(r,l),t.lineTo(h,c)}}t.closePath(),t.moveTo(0,0),t.rect(0,0,t.canvas.width,t.canvas.height),t.clip("evenodd")}(t,e,p),o||(Kn(t,e,i,s,p,n),t.stroke())}function Jn(t,e,i=e){t.lineCap=l(i.borderCapStyle,e.borderCapStyle),t.setLineDash(l(i.borderDash,e.borderDash)),t.lineDashOffset=l(i.borderDashOffset,e.borderDashOffset),t.lineJoin=l(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=l(i.borderWidth,e.borderWidth),t.strokeStyle=l(i.borderColor,e.borderColor)}function Zn(t,e,i){t.lineTo(i.x,i.y)}function Qn(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=nr&&o>r;return{count:s,start:l,loop:e.loop,ilen:h(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[b(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[b(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(ig&&(g=i),m=(x*m+e)/++x):(_(),t.lineTo(e,i),u=s,x=0,f=g=i),p=i}_()}function io(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?eo:to}const so="function"==typeof Path2D;function no(t,e,i,s){so&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Jn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=io(e);for(const r of n)Jn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class oo extends $s{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;hi(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=zi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Ii(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?pi:t.tension||"monotone"===t.cubicInterpolationMode?mi:gi}(i);let l,h;for(l=0,h=o.length;l"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=X(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:h,outerRadius:c,circumference:d}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),u=(this.options.spacing+this.options.borderWidth)/2,f=l(d,r-a),g=J(n,a,r)&&a!==r,p=f>=O||g,m=tt(o,h+u,c+u);return p&&m}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/4,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();const a=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(a)*s,Math.sin(a)*s);const r=s*(1-Math.sin(Math.min(C,i||0)));t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor,function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){Kn(t,e,i,s,l,n);for(let e=0;e("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}function mo(t){const e=this.getLabels();return t>=0&&ts=e?s:t,a=t=>n=i?n:t;if(t){const t=F(s),e=F(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=0===n?1:Math.abs(.05*n);a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n=function(t,e){const i=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,x=!s(a),b=!s(r),_=!s(h),y=(m-p)/(d+1);let v,M,w,k,S=B((m-p)/g/f)*f;if(S<1e-14&&!x&&!b)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=B(k*S/g/f)*f),s(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(M=Math.floor(p/S)*S,w=Math.ceil(m/S)*S):(M=p,w=m),x&&b&&o&&H((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,M=a,w=r):_?(M=x?a:M,w=b?r:w,k=h-1,S=(w-M)/k):(k=(w-M)/S,k=V(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(U(S),U(M));v=Math.pow(10,s(l)?P:l),M=Math.round(M*v)/v,w=Math.round(w*v)/v;let D=0;for(x&&(u&&M!==a?(i.push({value:a}),Mr)break;i.push({value:t})}return b&&u&&w!==r?i.length&&V(i[i.length-1].value,r,xo(r,y,t))?i[i.length-1].value=r:i.push({value:r}):b&&w!==r||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&j(n,this,"value"),t.reverse?(n.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),n}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ne(t,this.chart.options.locale,this.options.ticks.format)}}class _o extends bo{static id="linear";static defaults={ticks:{callback:ae.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?t:0,this.max=a(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=$(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const yo=t=>Math.floor(z(t)),vo=(t,e)=>Math.pow(10,yo(t)+e);function Mo(t){return 1===t/Math.pow(10,yo(t))}function wo(t,e,i){const s=Math.pow(10,i),n=Math.floor(t/s);return Math.ceil(e/s)-n}function ko(t,{min:e,max:i}){e=r(t.min,e);const s=[],n=yo(e);let o=function(t,e){let i=yo(e-t);for(;wo(t,e,i)>10;)i++;for(;wo(t,e,i)<10;)i--;return Math.min(i,yo(t))}(e,i),a=o<0?Math.pow(10,Math.abs(o)):1;const l=Math.pow(10,o),h=n>o?Math.pow(10,n):0,c=Math.round((e-h)*a)/a,d=Math.floor((e-h)/l/10)*l*10;let u=Math.floor((c-d)/Math.pow(10,o)),f=r(t.min,Math.round((h+d+u*Math.pow(10,o))*a)/a);for(;f=10?u=u<15?15:20:u++,u>=20&&(o++,u=2,a=o>=0?1:a),f=Math.round((h+d+u*Math.pow(10,o))*a)/a;const g=r(t.max,f);return s.push({value:g,major:Mo(g),significand:u}),s}class So extends tn{static id="logarithmic";static defaults={ticks:{callback:ae.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=bo.prototype.parse.apply(this,[t,e]);if(0!==i)return a(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?Math.max(0,t):null,this.max=a(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!a(this._userMin)&&(this.min=t===vo(this.min,0)?vo(this.min,-1):vo(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t;i===s&&(i<=0?(n(1),o(10)):(n(vo(i,-1)),o(vo(s,1)))),i<=0&&n(vo(s,-1)),s<=0&&o(vo(i,1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=ko({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&j(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ne(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=z(t),this._valueRange=z(this.max)-z(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(z(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function Po(t){const e=t.ticks;if(e.display&&t.display){const t=ki(e.backdropPadding);return l(e.font&&e.font.size,ue.font.size)+t.height}return 0}function Do(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:tn?{start:e-i,end:e}:{start:e,end:e+i}}function Co(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?C/a:0;for(let u=0;ue.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.starte.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function Ao(t,e,i){const s=t.drawingArea,{extra:n,additionalAngle:o,padding:a,size:r}=i,l=t.getPointPosition(e,s+n+a,o),h=Math.round(Y(G(l.angle+E))),c=function(t,e,i){90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e);return t}(l.y,r.h,h),d=function(t){if(0===t||180===t)return"center";if(t<180)return"left";return"right"}(h),u=function(t,e,i){"right"===i?t-=e:"center"===i&&(t-=e/2);return t}(l.x,r.w,d);return{visible:!0,x:l.x,y:c,textAlign:d,left:u,top:c,right:u+r.w,bottom:c+r.h}}function To(t,e){if(!e)return!0;const{left:i,top:s,right:n,bottom:o}=t;return!(Re({x:i,y:s},e)||Re({x:i,y:o},e)||Re({x:n,y:s},e)||Re({x:n,y:o},e))}function Lo(t,e,i){const{left:n,top:o,right:a,bottom:r}=i,{backdropColor:l}=e;if(!s(l)){const i=wi(e.borderRadius),s=ki(e.backdropPadding);t.fillStyle=l;const h=n-s.left,c=o-s.top,d=a-n+s.width,u=r-o+s.height;Object.values(i).some((t=>0!==t))?(t.beginPath(),He(t,{x:h,y:c,w:d,h:u,radius:i}),t.fill()):t.fillRect(h,c,d,u)}}function Eo(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;ot,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=ki(Po(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=a(t)&&!isNaN(t)?t:0,this.max=a(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/Po(this.options))}generateTickLabels(t){bo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=d(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?Co(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return G(t*(O/(this._pointLabels.length||1))+$(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(s(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(s(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t=0;n--){const e=t._pointLabelItems[n];if(!e.visible)continue;const o=s.setContext(t.getPointLabelContext(n));Lo(i,o,e);const a=Si(o.font),{x:r,y:l,textAlign:h}=e;Ne(i,t._pointLabels[n],r,l+a.lineHeight/2,a,{color:o.color,textAlign:h,textBaseline:"middle"})}}(this,o),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e||0===e&&this.min<0){r=this.getDistanceFromCenterForValue(t.value);const i=this.getContext(e),a=s.setContext(i),l=n.setContext(i);!function(t,e,i,s,n){const o=t.ctx,a=e.circular,{color:r,lineWidth:l}=e;!a&&!s||!r||!l||i<0||(o.save(),o.strokeStyle=r,o.lineWidth=l,o.setLineDash(n.dash||[]),o.lineDashOffset=n.dashOffset,o.beginPath(),Eo(t,i,a,s),o.closePath(),o.stroke(),o.restore())}(this,a,r,o,l)}})),i.display){for(t.save(),a=o-1;a>=0;a--){const s=i.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=s;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,r=this.getDistanceFromCenterForValue(e.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&this.min>=0&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=Si(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=ki(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ne(t,s.label,0,-n,l,{color:r.color,strokeColor:r.textStrokeColor,strokeWidth:r.textStrokeWidth})})),t.restore()}drawTitle(){}}const Io={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},zo=Object.keys(Io);function Fo(t,e){return t-e}function Vo(t,e){if(s(e))return null;const i=t._adapter,{parser:n,round:o,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),a(l)||(l="string"==typeof n?i.parse(l,n):i.parse(l)),null===l?null:(o&&(l="week"!==o||!N(r)&&!0!==r?i.startOf(l,o):i.startOf(l,"isoWeek",r)),+l)}function Bo(t,e,i,s){const n=zo.length;for(let o=zo.indexOf(t);o=e?i[s]:i[n]]=!0}}else t[e]=!0}function No(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a=0&&(e[l].major=!0);return e}(t,s,n,i):s}class Ho extends tn{static id="time";static defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const i=t.time||(t.time={}),s=this._adapter=new In._date(t.adapters.date);s.init(e),b(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Vo(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:r}=this.getUserBounds();function l(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}o&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=a(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=a(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=nt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?Bo(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=zo.length-1;o>=zo.indexOf(i);o--){const i=zo[o];if(Io[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return zo[i?zo.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=zo.indexOf(t)+1,i=zo.length;e+t.value)))}initOffsets(t=[]){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=Z(s,0,o),n=Z(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||Bo(n.minUnit,e,i,this._getLabelCapacity(e)),a=l(s.ticks.stepSize,1),r="week"===o&&n.isoWeekday,h=N(r)||!0===r,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",r)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;d+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){const i=this.options.time.displayFormats,s=this._unit,n=e||i[s];return this._adapter.format(t,n)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.ticks.callback;if(o)return d(o,[t,e,i],this);const a=n.time.displayFormats,r=this._unit,l=this._majorUnit,h=r&&a[r],c=l&&a[l],u=i[e],f=l&&c&&u&&u.major;return this._adapter.format(t,s||(f?c:h))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=it(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=it(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}var $o=Object.freeze({__proto__:null,CategoryScale:class extends tn{static id="category";static defaults={ticks:{callback:mo}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(s(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:Z(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:po(i,t,l(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){return mo.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:_o,LogarithmicScale:So,RadialLinearScale:Ro,TimeScale:Ho,TimeSeriesScale:class extends Ho{static id="timeseries";static defaults=Ho.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=jo(e,this.min),this._tableRange=jo(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;ot-e))}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(jo(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return jo(this._table,i*this._tableRange+this._minPos,!0)}}});const Yo=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],Uo=Yo.map((t=>t.replace("rgb(","rgba(").replace(")",", 0.5)")));function Xo(t){return Yo[t%Yo.length]}function qo(t){return Uo[t%Uo.length]}function Ko(t){let e=0;return(i,s)=>{const n=t.getDatasetMeta(s).controller;n instanceof $n?e=function(t,e){return t.backgroundColor=t.data.map((()=>Xo(e++))),e}(i,e):n instanceof Yn?e=function(t,e){return t.backgroundColor=t.data.map((()=>qo(e++))),e}(i,e):n&&(e=function(t,e){return t.borderColor=Xo(e),t.backgroundColor=qo(e),++e}(i,e))}}function Go(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}var Jo={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(!i.enabled)return;const{data:{datasets:s},options:n}=t.config,{elements:o}=n,a=Go(s)||(r=n)&&(r.borderColor||r.backgroundColor)||o&&Go(o)||"rgba(0,0,0,0.1)"!==ue.borderColor||"rgba(0,0,0,0.1)"!==ue.backgroundColor;var r;if(!i.forceOverride&&a)return;const l=Ko(t);s.forEach(l)}};function Zo(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function Qo(t){t.data.datasets.forEach((t=>{Zo(t)}))}var ta={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void Qo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===Pi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=Z(it(e,o.axis,a).lo,0,i-1)),s=h?Z(it(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(i.threshold||4*n))return void Zo(e);let f;switch(s(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;cu&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,i);break;case"min-max":f=function(t,e,i,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const x=[],b=e+i-1,_=t[e].x,y=t[b].x-_;for(o=e;og&&(g=l,d=o),p=(m*p+a.x)/++m;else{const i=o-1;if(!s(c)&&!s(d)){const e=Math.min(c,d),s=Math.max(c,d);e!==u&&e!==i&&x.push({...t[e],x:p}),s!==u&&s!==i&&x.push({...t[s],x:p})}o>0&&i!==u&&x.push(t[i]),x.push(a),h=e,m=0,f=g=l,c=d=u=o}}return x}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=f}))},destroy(t){Qo(t)}};function ea(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=G(n),o=G(o)),{property:t,start:n,end:o}}function ia(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function sa(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function na(t,e){let i=[],s=!1;return n(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=ia(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new oo({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function oa(t){return t&&!1!==t.fill}function aa(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!a(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function ra(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=l(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(o(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return a(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function la(t,e,i){const s=[];for(let n=0;n=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&ua(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;oa(i)&&ua(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;oa(s)&&"beforeDatasetDraw"===i.drawTime&&ua(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const _a=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class ya extends $s{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=d(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=Si(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=_a(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,s,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:p,itemHeight:m}=function(t,e,i,s,n){const o=function(t,e,i,s){let n=t.text;n&&"string"!=typeof n&&(n=n.reduce(((t,e)=>t.length>e.length?t:e)));return e+i.size/2+s.measureText(n).width}(s,t,e,i),a=function(t,e,i){let s=t;"string"!=typeof e.text&&(s=va(e,i));return s}(n,s,e.lineHeight);return{itemWidth:o,itemHeight:a}}(i,e,n,t,s);o>0&&u+m+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:m},d=Math.max(d,p),u+=m+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=Oi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ft(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ft(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Ie(t,this),this._draw(),ze(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ue.color,r=Oi(t.rtl,this.left,this.width),h=Si(o.font),{padding:c}=o,d=h.size,u=d/2;let f;this.drawTitle(),s.textAlign=r.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:g,boxHeight:p,itemHeight:m}=_a(o,d),x=this.isHorizontal(),b=this._computeTitleHeight();f=x?{x:ft(n,this.left+c,this.right-i[0]),y:this.top+c+b,line:0}:{x:this.left+c,y:ft(n,this.top+b+c,this.bottom-e[0].height),line:0},Ai(this.ctx,t.textDirection);const _=m+c;this.legendItems.forEach(((y,v)=>{s.strokeStyle=y.fontColor,s.fillStyle=y.fontColor;const M=s.measureText(y.text).width,w=r.textAlign(y.textAlign||(y.textAlign=o.textAlign)),k=g+u+M;let S=f.x,P=f.y;r.setWidth(this.width),x?v>0&&S+k+c>this.right&&(P=f.y+=_,f.line++,S=f.x=ft(n,this.left+c,this.right-i[f.line])):v>0&&P+_>this.bottom&&(S=f.x=S+e[f.line].width+c,f.line++,P=f.y=ft(n,this.top+b+c,this.bottom-e[f.line].height));if(function(t,e,i){if(isNaN(g)||g<=0||isNaN(p)||p<0)return;s.save();const n=l(i.lineWidth,1);if(s.fillStyle=l(i.fillStyle,a),s.lineCap=l(i.lineCap,"butt"),s.lineDashOffset=l(i.lineDashOffset,0),s.lineJoin=l(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=l(i.strokeStyle,a),s.setLineDash(l(i.lineDash,[])),o.usePointStyle){const a={radius:p*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},l=r.xPlus(t,g/2);Ee(s,a,l,e+u,o.pointStyleWidth&&g)}else{const o=e+Math.max((d-p)/2,0),a=r.leftForLtr(t,g),l=wi(i.borderRadius);s.beginPath(),Object.values(l).some((t=>0!==t))?He(s,{x:a,y:o,w:g,h:p,radius:l}):s.rect(a,o,g,p),s.fill(),0!==n&&s.stroke()}s.restore()}(r.x(S),P,y),S=gt(w,S+g+u,x?S+k:this.right,t.rtl),function(t,e,i){Ne(s,i.text,t,e+m/2,h,{strikethrough:i.hidden,textAlign:r.textAlign(i.textAlign)})}(r.x(S),P,y),x)f.x+=k+c;else if("string"!=typeof y.text){const t=h.lineHeight;f.y+=va(y,t)+c}else f.y+=_})),Ti(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=Si(e.font),s=ki(e.padding);if(!e.display)return;const n=Oi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ft(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ft(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ft(a,c,c+d);o.textAlign=n.textAlign(ut(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ne(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=Si(t.font),i=ki(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(tt(t,this.left,this.right)&&tt(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o,useBorderRadius:a,borderRadius:r}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const l=t.controller.getStyle(i?0:void 0),h=ki(l.borderWidth);return{text:e[t.index].label,fillStyle:l.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:l.borderCapStyle,lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:(h.width+h.height)/4,strokeStyle:l.borderColor,pointStyle:s||l.pointStyle,rotation:l.rotation,textAlign:n||l.textAlign,borderRadius:a&&(r||l.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class wa extends $s{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=n(i.text)?i.text.length:1;this._padding=ki(i.padding);const o=s*Si(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ft(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ft(a,s,e),c=-.5*C):(l=n-t,h=ft(a,e,s),c=.5*C),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=Si(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ne(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:ut(e.align),textBaseline:"middle",translation:[n,o]})}}var ka={id:"title",_element:wa,start(t,e,i){!function(t,e){const i=new wa({ctx:t.ctx,options:e,chart:t});ls.configure(t,i,e),ls.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;ls.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;ls.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Sa=new WeakMap;var Pa={id:"subtitle",start(t,e,i){const s=new wa({ctx:t.ctx,options:i,chart:t});ls.configure(t,s,i),ls.addBox(t,s),Sa.set(t,s)},stop(t){ls.removeBox(t,Sa.get(t)),Sa.delete(t)},beforeUpdate(t,e,i){const s=Sa.get(t);ls.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Da={average(t){if(!t.length)return!1;let e,i,s=new Set,n=0,o=0;for(e=0,i=t.length;et+e))/s.size,y:n/o}},nearest(t,e){if(!t.length)return!1;let i,s,n,o=e.x,a=e.y,r=Number.POSITIVE_INFINITY;for(i=0,s=t.length;i-1?t.split("\n"):t}function Aa(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Ta(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=Si(e.bodyFont),h=Si(e.titleFont),c=Si(e.footerFont),d=o.length,f=n.length,g=s.length,p=ki(e.padding);let m=p.height,x=0,b=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(b+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),b){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(b-g)*l.lineHeight+(b-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){x=Math.max(x,i.measureText(t).width+_)};return i.save(),i.font=h.string,u(t.title,y),i.font=l.string,u(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,u(s,(t=>{u(t.before,y),u(t.lines,y),u(t.after,y)})),_=0,i.font=c.string,u(t.footer,y),i.restore(),x+=p.width,{width:x,height:m}}function La(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Ea(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return it.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||La(t,e,i,s),yAlign:s}}function Ra(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:Z(g,0,s.width-e.width),y:Z(p,0,s.height-e.height)}}function Ia(t,e,i){const s=ki(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function za(t){return Ca([],Oa(t))}function Fa(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}const Va={beforeTitle:e,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex{const e={before:[],lines:[],after:[]},n=Fa(i,t);Ca(e.before,Oa(Ba(n,"beforeLabel",this,t))),Ca(e.lines,Ba(n,"label",this,t)),Ca(e.after,Oa(Ba(n,"afterLabel",this,t))),s.push(e)})),s}getAfterBody(t,e){return za(Ba(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:i}=e,s=Ba(i,"beforeFooter",this,t),n=Ba(i,"footer",this,t),o=Ba(i,"afterFooter",this,t);let a=[];return a=Ca(a,Oa(s)),a=Ca(a,Oa(n)),a=Ca(a,Oa(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;at.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),u(l,(e=>{const i=Fa(t.callbacks,e);s.push(Ba(i,"labelColor",this,e)),n.push(Ba(i,"labelPointStyle",this,e)),o.push(Ba(i,"labelTextColor",this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Da[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Ta(this,i),a=Object.assign({},t,e),r=Ea(this.chart,i,a),l=Ra(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=wi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,x,b,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,b=_+o,y=_-o):(p=d+f,m=p+o,b=_-o,y=_+o),x=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(b=u,_=b-o,p=m-o,x=m+o):(b=u+g,_=b+o,p=m+o,x=m-o),y=b),{x1:p,x2:m,x3:x,y1:b,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=Oi(i.rtl,this.x,this.width);for(t.x=Ia(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=Si(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,He(t,{x:e,y:g,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),He(t,{x:i,y:g+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,h,l),t.strokeRect(e,g,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,g+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=Si(i.bodyFont);let d=c.lineHeight,f=0;const g=Oi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+d/2),t.y+=d+n},m=g.textAlign(o);let x,b,_,y,v,M,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ia(this,m,i),e.fillStyle=i.bodyColor,u(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,M=s.length;y0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Da[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Ta(this,t),a=Object.assign({},i,this._size),r=Ea(e,t,a),l=Ra(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=ki(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),Ai(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),Ti(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!f(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!f(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e.filter((t=>this.chart.data.datasets[t.datasetIndex]&&void 0!==this.chart.getDatasetMeta(t.datasetIndex).controller.getParsed(t.index)));const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Da[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}var Na={id:"tooltip",_element:Wa,positioners:Da,afterInit(t,e,i){i&&(t.tooltip=new Wa({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:Va},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return Tn.register(Un,$o,go,t),Tn.helpers={...Hi},Tn._adapters=In,Tn.Animation=As,Tn.Animations=Ts,Tn.animator=bt,Tn.controllers=nn.controllers.items,Tn.DatasetController=js,Tn.Element=$s,Tn.elements=go,Tn.Interaction=Ki,Tn.layouts=ls,Tn.platforms=Ds,Tn.Scale=tn,Tn.Ticks=ae,Object.assign(Tn,Un,$o,go,t,Ds),Tn.Chart=Tn,"undefined"!=typeof window&&(window.Chart=Tn),Tn})); +//# sourceMappingURL=chart.umd.min.js.map diff --git a/tilework.ui/wwwroot/js/timeseriesChart.js b/tilework.ui/wwwroot/js/timeseriesChart.js new file mode 100644 index 0000000..e68e34b --- /dev/null +++ b/tilework.ui/wwwroot/js/timeseriesChart.js @@ -0,0 +1,104 @@ +const chartInstances = new WeakMap(); + +function ensureChartJs() { + if (typeof Chart === "undefined") { + console.warn("Chart.js is not available on the page."); + return false; + } + return true; +} + +function buildOptions(labels, color) { + return { + type: "line", + data: { + labels, + datasets: [ + { + label: "", + data: [], + borderColor: color, + backgroundColor: color, + borderWidth: 1.5, + tension: 0.25, + pointRadius: 0, + pointHoverRadius: 4, + pointHitRadius: 8, + fill: false + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { mode: "nearest", intersect: false } + }, + scales: { + x: { + ticks: { + autoSkip: false, + maxRotation: 0, + callback: (_, index) => labels[index] ?? "", + font: { size: 12 } + }, + grid: { display: false } + }, + y: { + ticks: { + font: { size: 12 }, + maxTicksLimit: 6 + }, + grid: { color: "rgba(0, 0, 0, 0.05)" }, + beginAtZero: true + } + }, + elements: { + line: { borderWidth: 1.5, tension: 0.25 }, + point: { + radius: 0, + hoverRadius: 4, + hitRadius: 8 + } + } + } + }; +} + +export function renderTimeseriesChart(canvas, config) { + if (!canvas || !ensureChartJs()) { + return; + } + + const { labels = [], data = [], color = "#1B5E20", name = "" } = config ?? {}; + const existing = chartInstances.get(canvas); + + if (existing) { + existing.data.labels = labels; + existing.data.datasets[0].label = name; + existing.data.datasets[0].data = data; + existing.update(); + return; + } + + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + + const chartConfig = buildOptions(labels, color); + chartConfig.data.datasets[0].data = data; + chartConfig.data.datasets[0].label = name; + + const chart = new Chart(ctx, chartConfig); + chartInstances.set(canvas, chart); +} + +export function disposeTimeseriesChart(canvas) { + const chart = chartInstances.get(canvas); + if (chart) { + chart.destroy(); + chartInstances.delete(canvas); + } +}