diff --git a/ThingConnect.Pulse.Server/Data/Entities.cs b/ThingConnect.Pulse.Server/Data/Entities.cs index 29a344d..4e588a2 100644 --- a/ThingConnect.Pulse.Server/Data/Entities.cs +++ b/ThingConnect.Pulse.Server/Data/Entities.cs @@ -1,9 +1,27 @@ -// ThingConnect Pulse - EF Core Entities (v1) +// ThingConnect Pulse - EF Core Entities (v2) +// Updated for ICMP Fallback + Outage Classification namespace ThingConnect.Pulse.Server.Data; public enum ProbeType { icmp, tcp, http } public enum UpDown { up, down } +/// +/// Status classification for failed probe analysis. +/// +public enum Classification +{ + None = -1, // Explicitly healthy, no outage detected + Unknown = 0, // Not enough information to classify + Network = 1, // Host unreachable (ICMP + service fail) + Service = 2, // Service down, host reachable via ICMP + Intermittent = 3, // Flapping / unstable + Performance = 4, // RTT above threshold + PartialService = 5, // HTTP error, TCP works + DnsResolution = 6, // DNS fails, IP works + Congestion = 7, // Correlated latency + Maintenance = 8 // Planned downtime +} + public record GroupVm(string Id, string Name, string? ParentId, string? Color); public record EndpointVm(Guid Id, string Name, GroupVm Group, ProbeType Type, string Host, int? Port, string? HttpPath, string? HttpMatch, @@ -50,6 +68,13 @@ public sealed class CheckResultRaw public UpDown Status { get; set; } public double? RttMs { get; set; } public string? Error { get; set; } + + // ๐Ÿ”น New fields for fallback probe + public bool? FallbackAttempted { get; set; } + public UpDown? FallbackStatus { get; set; } + public double? FallbackRttMs { get; set; } + public string? FallbackError { get; set; } + public Classification? Classification { get; set; } } public sealed class Outage @@ -61,6 +86,7 @@ public sealed class Outage public long? EndedTs { get; set; } public int? DurationSeconds { get; set; } public string? LastError { get; set; } + public Classification? Classification { get; set; } /// /// Gets or sets timestamp when monitoring was lost during this outage (service downtime). diff --git a/ThingConnect.Pulse.Server/Data/PulseDbContext.cs b/ThingConnect.Pulse.Server/Data/PulseDbContext.cs index ebbaf7c..f21ad85 100644 --- a/ThingConnect.Pulse.Server/Data/PulseDbContext.cs +++ b/ThingConnect.Pulse.Server/Data/PulseDbContext.cs @@ -65,6 +65,17 @@ protected override void OnModelCreating(ModelBuilder b) e.HasKey(x => x.Id); e.Property(x => x.Status).HasConversion().IsRequired(); e.Property(x => x.RttMs).HasColumnType("double precision"); + + // New Fallback fields + e.Property(x => x.FallbackAttempted); + e.Property(x => x.FallbackStatus).HasConversion(); + e.Property(x => x.FallbackRttMs).HasColumnType("double precision"); + e.Property(x => x.FallbackError); + + // Classification field + e.Property(x => x.Classification) + .HasConversion(); + e.HasIndex(x => new { x.EndpointId, x.Ts }); }); @@ -74,6 +85,10 @@ protected override void OnModelCreating(ModelBuilder b) e.HasKey(x => x.Id); e.HasIndex(x => new { x.EndpointId, x.StartedTs }); e.HasIndex(x => new { x.EndpointId, x.EndedTs }); + + // New Classification field + e.Property(x => x.Classification) + .HasConversion(); }); b.Entity(e => diff --git a/ThingConnect.Pulse.Server/Migrations/20250926070803_AddFallbackAndOutageClassification.Designer.cs b/ThingConnect.Pulse.Server/Migrations/20250926070803_AddFallbackAndOutageClassification.Designer.cs new file mode 100644 index 0000000..ae19086 --- /dev/null +++ b/ThingConnect.Pulse.Server/Migrations/20250926070803_AddFallbackAndOutageClassification.Designer.cs @@ -0,0 +1,768 @@ +๏ปฟ// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ThingConnect.Pulse.Server.Data; + +#nullable disable + +namespace ThingConnect.Pulse.Server.Migrations +{ + [DbContext(typeof(PulseDbContext))] + [Migration("20250926070803_AddFallbackAndOutageClassification")] + partial class AddFallbackAndOutageClassification + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("Role"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.CheckResultRaw", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Classification") + .HasColumnType("INTEGER"); + + b.Property("EndpointId") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("FallbackAttempted") + .HasColumnType("INTEGER"); + + b.Property("FallbackError") + .HasColumnType("TEXT"); + + b.Property("FallbackRttMs") + .HasColumnType("double precision"); + + b.Property("FallbackStatus") + .HasColumnType("TEXT"); + + b.Property("RttMs") + .HasColumnType("double precision"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Ts") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EndpointId", "Ts"); + + b.ToTable("check_result_raw", (string)null); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.ConfigVersion", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Actor") + .HasColumnType("TEXT"); + + b.Property("AppliedTs") + .HasColumnType("INTEGER"); + + b.Property("FileHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppliedTs"); + + b.ToTable("config_version", (string)null); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.Endpoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ExpectedRttMs") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("HttpMatch") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("HttpPath") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("IntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("LastChangeTs") + .HasColumnType("INTEGER"); + + b.Property("LastRttMs") + .HasColumnType("REAL"); + + b.Property("LastStatus") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Retries") + .HasColumnType("INTEGER"); + + b.Property("TimeoutMs") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Host"); + + b.HasIndex("GroupId", "Name"); + + b.ToTable("endpoint", (string)null); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.Group", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Color") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("group", (string)null); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.MonitoringSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EndedTs") + .HasColumnType("INTEGER"); + + b.Property("LastActivityTs") + .HasColumnType("INTEGER"); + + b.Property("ShutdownReason") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("StartedTs") + .HasColumnType("INTEGER"); + + b.Property("Version") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EndedTs"); + + b.HasIndex("StartedTs"); + + b.ToTable("monitoring_session", (string)null); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.Notification", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ActionText") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ActionUrl") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("CreatedTs") + .HasColumnType("INTEGER"); + + b.Property("IsRead") + .HasColumnType("INTEGER"); + + b.Property("IsShown") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ReadTs") + .HasColumnType("INTEGER"); + + b.Property("ShowOnce") + .HasColumnType("INTEGER"); + + b.Property("ShownTs") + .HasColumnType("INTEGER"); + + b.Property("TargetVersions") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ValidFromTs") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilTs") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ValidFromTs"); + + b.HasIndex("ValidUntilTs"); + + b.HasIndex("IsRead", "ValidFromTs"); + + b.HasIndex("Priority", "ValidFromTs"); + + b.ToTable("notification", (string)null); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.NotificationFetch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("FetchTs") + .HasColumnType("INTEGER"); + + b.Property("NotificationCount") + .HasColumnType("INTEGER"); + + b.Property("RemoteLastUpdated") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RemoteVersion") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Success") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("FetchTs"); + + b.HasIndex("Success"); + + b.ToTable("notification_fetch", (string)null); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.Outage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Classification") + .HasColumnType("INTEGER"); + + b.Property("DurationSeconds") + .HasColumnType("INTEGER"); + + b.Property("EndedTs") + .HasColumnType("INTEGER"); + + b.Property("EndpointId") + .HasColumnType("TEXT"); + + b.Property("HasMonitoringGap") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("MonitoringStoppedTs") + .HasColumnType("INTEGER"); + + b.Property("StartedTs") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EndpointId", "EndedTs"); + + b.HasIndex("EndpointId", "StartedTs"); + + b.ToTable("outage", (string)null); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.Rollup15m", b => + { + b.Property("EndpointId") + .HasColumnType("TEXT"); + + b.Property("BucketTs") + .HasColumnType("INTEGER"); + + b.Property("AvgRttMs") + .HasColumnType("double precision"); + + b.Property("DownEvents") + .HasColumnType("INTEGER"); + + b.Property("UpPct") + .HasColumnType("REAL"); + + b.HasKey("EndpointId", "BucketTs"); + + b.HasIndex("BucketTs"); + + b.ToTable("rollup_15m", (string)null); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.RollupDaily", b => + { + b.Property("EndpointId") + .HasColumnType("TEXT"); + + b.Property("BucketDate") + .HasColumnType("INTEGER"); + + b.Property("AvgRttMs") + .HasColumnType("double precision"); + + b.Property("DownEvents") + .HasColumnType("INTEGER"); + + b.Property("UpPct") + .HasColumnType("REAL"); + + b.HasKey("EndpointId", "BucketDate"); + + b.HasIndex("BucketDate"); + + b.ToTable("rollup_daily", (string)null); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.Setting", b => + { + b.Property("K") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("V") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("K"); + + b.ToTable("setting", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ThingConnect.Pulse.Server.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ThingConnect.Pulse.Server.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ThingConnect.Pulse.Server.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ThingConnect.Pulse.Server.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.CheckResultRaw", b => + { + b.HasOne("ThingConnect.Pulse.Server.Data.Endpoint", "Endpoint") + .WithMany() + .HasForeignKey("EndpointId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Endpoint"); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.Endpoint", b => + { + b.HasOne("ThingConnect.Pulse.Server.Data.Group", "Group") + .WithMany("Endpoints") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.Outage", b => + { + b.HasOne("ThingConnect.Pulse.Server.Data.Endpoint", "Endpoint") + .WithMany() + .HasForeignKey("EndpointId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Endpoint"); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.Rollup15m", b => + { + b.HasOne("ThingConnect.Pulse.Server.Data.Endpoint", "Endpoint") + .WithMany() + .HasForeignKey("EndpointId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Endpoint"); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.RollupDaily", b => + { + b.HasOne("ThingConnect.Pulse.Server.Data.Endpoint", "Endpoint") + .WithMany() + .HasForeignKey("EndpointId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Endpoint"); + }); + + modelBuilder.Entity("ThingConnect.Pulse.Server.Data.Group", b => + { + b.Navigation("Endpoints"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ThingConnect.Pulse.Server/Migrations/20250926070803_AddFallbackAndOutageClassification.cs b/ThingConnect.Pulse.Server/Migrations/20250926070803_AddFallbackAndOutageClassification.cs new file mode 100644 index 0000000..c8b60c8 --- /dev/null +++ b/ThingConnect.Pulse.Server/Migrations/20250926070803_AddFallbackAndOutageClassification.cs @@ -0,0 +1,78 @@ +๏ปฟusing Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ThingConnect.Pulse.Server.Migrations +{ + /// + public partial class AddFallbackAndOutageClassification : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Classification", + table: "outage", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "Classification", + table: "check_result_raw", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "FallbackAttempted", + table: "check_result_raw", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "FallbackError", + table: "check_result_raw", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "FallbackRttMs", + table: "check_result_raw", + type: "double precision", + nullable: true); + + migrationBuilder.AddColumn( + name: "FallbackStatus", + table: "check_result_raw", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Classification", + table: "outage"); + + migrationBuilder.DropColumn( + name: "Classification", + table: "check_result_raw"); + + migrationBuilder.DropColumn( + name: "FallbackAttempted", + table: "check_result_raw"); + + migrationBuilder.DropColumn( + name: "FallbackError", + table: "check_result_raw"); + + migrationBuilder.DropColumn( + name: "FallbackRttMs", + table: "check_result_raw"); + + migrationBuilder.DropColumn( + name: "FallbackStatus", + table: "check_result_raw"); + } + } +} diff --git a/ThingConnect.Pulse.Server/Migrations/PulseDbContextModelSnapshot.cs b/ThingConnect.Pulse.Server/Migrations/PulseDbContextModelSnapshot.cs index 51bbbb1..aeccc31 100644 --- a/ThingConnect.Pulse.Server/Migrations/PulseDbContextModelSnapshot.cs +++ b/ThingConnect.Pulse.Server/Migrations/PulseDbContextModelSnapshot.cs @@ -238,12 +238,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("Classification") + .HasColumnType("INTEGER"); + b.Property("EndpointId") .HasColumnType("TEXT"); b.Property("Error") .HasColumnType("TEXT"); + b.Property("FallbackAttempted") + .HasColumnType("INTEGER"); + + b.Property("FallbackError") + .HasColumnType("TEXT"); + + b.Property("FallbackRttMs") + .HasColumnType("double precision"); + + b.Property("FallbackStatus") + .HasColumnType("TEXT"); + b.Property("RttMs") .HasColumnType("double precision"); @@ -538,6 +553,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("Classification") + .HasColumnType("INTEGER"); + b.Property("DurationSeconds") .HasColumnType("INTEGER"); diff --git a/ThingConnect.Pulse.Server/Models/CheckResult.cs b/ThingConnect.Pulse.Server/Models/CheckResult.cs index 6a9639e..1c81674 100644 --- a/ThingConnect.Pulse.Server/Models/CheckResult.cs +++ b/ThingConnect.Pulse.Server/Models/CheckResult.cs @@ -2,40 +2,35 @@ namespace ThingConnect.Pulse.Server.Models; +public enum StatusType +{ + Up, + Down, + Service, + Flapping +} + /// /// Result of a single probe check (ICMP, TCP, or HTTP). /// public sealed class CheckResult { - /// - /// Gets or sets the endpoint that was checked. - /// public Guid EndpointId { get; set; } - - /// - /// Gets or sets timestamp when the check was performed. - /// public DateTimeOffset Timestamp { get; set; } - - /// - /// Gets or sets result status: UP or DOWN. - /// public UpDown Status { get; set; } - - /// - /// Gets or sets round-trip time in milliseconds. Null if not applicable or failed. - /// public double? RttMs { get; set; } - - /// - /// Gets or sets error message if the check failed. Null if successful. - /// public string? Error { get; set; } + // ๐Ÿ”น Fallback probe info + public bool FallbackAttempted { get; set; } = false; + public UpDown? FallbackStatus { get; set; } + public double? FallbackRttMs { get; set; } + public string? FallbackError { get; set; } + public Classification? Classification { get; set; } + /// /// Creates a successful check result. /// - /// public static CheckResult Success(Guid endpointId, DateTimeOffset timestamp, double? rttMs = null) { return new CheckResult @@ -44,14 +39,18 @@ public static CheckResult Success(Guid endpointId, DateTimeOffset timestamp, dou Timestamp = timestamp, Status = UpDown.up, RttMs = rttMs, - Error = null + Error = null, + FallbackAttempted = false, + FallbackStatus = null, + FallbackRttMs = null, + FallbackError = null, + Classification = Data.Classification.None }; } /// /// Creates a failed check result. /// - /// public static CheckResult Failure(Guid endpointId, DateTimeOffset timestamp, string error) { return new CheckResult @@ -60,7 +59,161 @@ public static CheckResult Failure(Guid endpointId, DateTimeOffset timestamp, str Timestamp = timestamp, Status = UpDown.down, RttMs = null, - Error = error + Error = error, + FallbackAttempted = false, + FallbackStatus = null, + FallbackRttMs = null, + FallbackError = null, + Classification = Data.Classification.Unknown // ๐Ÿ”น FIXED: Set to unknown + }; + } + + /// + /// Updates the current CheckResult with fallback info. + /// + public void ApplyFallback(CheckResult fallback) + { + if (fallback == null) return; + + FallbackAttempted = true; + FallbackStatus = fallback.Status; + FallbackRttMs = fallback.RttMs; + FallbackError = fallback.Error; + Classification = DetermineClassification(); + } + + /// + /// ๐Ÿ”น Helper to calculate effective status + /// + public UpDown GetEffectiveStatus() + { + // Primary DOWN + Fallback UP = Effective UP (service issue) + if (Status == UpDown.down && FallbackAttempted && FallbackStatus == UpDown.up) + { + return UpDown.up; + } + return Status; + } + + /// + /// ๐Ÿ”น Helper to get effective RTT + /// + public double? GetEffectiveRtt() + { + // Priority 1: Primary RTT if successful + if (Status == UpDown.up && RttMs.HasValue) + { + return RttMs; + } + // Priority 2: Fallback RTT if primary failed but fallback succeeded + if (Status == UpDown.down && FallbackAttempted && FallbackStatus == UpDown.up && FallbackRttMs.HasValue) + { + return FallbackRttMs; + } + return null; + } + + /// + /// ๐Ÿ”น Auto-classification based on probe results + /// + public Classification DetermineClassification() + { + if (Status == UpDown.up) + { + return Data.Classification.None; // Healthy + } + + if (FallbackAttempted) + { + if (FallbackStatus == UpDown.up) + { + return Data.Classification.Service; // Service down, host up + } + + return Data.Classification.Network; // Both down + } + + return Data.Classification.Unknown; // No fallback info + } + + // ๐Ÿ”น StatusType logic (Up / Down / Service / Flapping) + public StatusType DetermineStatusType(List recentChecks, TimeSpan interval) + { + if (recentChecks == null || recentChecks.Count == 0) + { + return StatusType.Down; + } + + // Flapping overrides all + if (IsFlapping(recentChecks)) + { + return StatusType.Flapping; + } + + // Effective UP + if (GetEffectiveStatus() == UpDown.up) + { + if (Status == UpDown.down && FallbackAttempted && FallbackStatus == UpDown.up) + { + return StatusType.Service; + } + + return StatusType.Up; + } + + return StatusType.Down; + } + + // ๐Ÿ”น Flapping detection (>= 4 samples, >3 changes in 5 min window) + public static bool IsFlapping(List recent) + { + if (recent == null || recent.Count < 4) + { + return false; + } + + var effectiveStatuses = recent + .OrderBy(c => c.Timestamp) + .Select(c => c.GetEffectiveStatus()) + .ToList(); + + int stateChanges = 0; + for (int i = 1; i < effectiveStatuses.Count; i++) + { + if (effectiveStatuses[i] != effectiveStatuses[i - 1]) + { + stateChanges++; + } + } + + return stateChanges > 3; + } + + /// + /// ๐Ÿ”น Map endpoint entity to EndpointDto (call anywhere you need) + /// + public static EndpointDto MapToEndpointDto(Data.Endpoint endpoint) + { + return new EndpointDto + { + Id = endpoint.Id, + Name = endpoint.Name, + Group = new GroupDto + { + Id = endpoint.Group.Id, + Name = endpoint.Group.Name, + ParentId = endpoint.Group.ParentId, + Color = endpoint.Group.Color + }, + Type = endpoint.Type.ToString().ToLower(), + Host = endpoint.Host, + Port = endpoint.Port, + HttpPath = endpoint.HttpPath, + HttpMatch = endpoint.HttpMatch, + IntervalSeconds = endpoint.IntervalSeconds, + TimeoutMs = endpoint.TimeoutMs, + Retries = endpoint.Retries, + Enabled = endpoint.Enabled }; } } diff --git a/ThingConnect.Pulse.Server/Models/EndpointDetailDto.cs b/ThingConnect.Pulse.Server/Models/EndpointDetailDto.cs index 6309039..300cd56 100644 --- a/ThingConnect.Pulse.Server/Models/EndpointDetailDto.cs +++ b/ThingConnect.Pulse.Server/Models/EndpointDetailDto.cs @@ -1,3 +1,4 @@ +using ThingConnect.Pulse.Server.Data; using ThingConnect.Pulse.Server.Models; public sealed class EndpointDetailDto @@ -6,3 +7,31 @@ public sealed class EndpointDetailDto public List Recent { get; set; } = []; public List Outages { get; set; } = []; } + +public sealed class RawCheckDto +{ + public DateTimeOffset Ts { get; set; } + public Classification Classification { get; set; } + public PrimaryResultDto Primary { get; set; } = default!; + public FallbackResultDto Fallback { get; set; } = default!; + public CurrentStateDto CurrentState { get; set; } = default!; +} + +public sealed class PrimaryResultDto +{ + public string Type { get; set; } = default!; + public string Target { get; set; } = default!; + public string Status { get; set; } = default!; + public double? RttMs { get; set; } + public string? Error { get; set; } +} + +public sealed class FallbackResultDto +{ + public bool Attempted { get; set; } + public string? Type { get; set; } + public string? Target { get; set; } + public string? Status { get; set; } + public double? RttMs { get; set; } + public string? Error { get; set; } +} diff --git a/ThingConnect.Pulse.Server/Models/HistoryDtos.cs b/ThingConnect.Pulse.Server/Models/HistoryDtos.cs index 55109c7..364dc07 100644 --- a/ThingConnect.Pulse.Server/Models/HistoryDtos.cs +++ b/ThingConnect.Pulse.Server/Models/HistoryDtos.cs @@ -1,12 +1,7 @@ +using ThingConnect.Pulse.Server.Data; + namespace ThingConnect.Pulse.Server.Models; -public sealed class RawCheckDto -{ - public DateTimeOffset Ts { get; set; } - public string Status { get; set; } = default!; - public double? RttMs { get; set; } - public string? Error { get; set; } -} public sealed class RollupBucketDto { @@ -30,6 +25,7 @@ public sealed class OutageDto public DateTimeOffset? EndedTs { get; set; } public int? DurationS { get; set; } public string? LastError { get; set; } + public Classification? Classification { get; set; } } public sealed class HistoryResponseDto diff --git a/ThingConnect.Pulse.Server/Models/StatusDtos.cs b/ThingConnect.Pulse.Server/Models/StatusDtos.cs index 11df3f8..72fce72 100644 --- a/ThingConnect.Pulse.Server/Models/StatusDtos.cs +++ b/ThingConnect.Pulse.Server/Models/StatusDtos.cs @@ -3,8 +3,7 @@ namespace ThingConnect.Pulse.Server.Models; public sealed class LiveStatusItemDto { public EndpointDto Endpoint { get; set; } = default!; - public string Status { get; set; } = default!; - public double? RttMs { get; set; } + public CurrentStateDto CurrentState { get; set; } = default!; public DateTimeOffset LastChangeTs { get; set; } public List Sparkline { get; set; } = new(); } @@ -51,3 +50,12 @@ public sealed class PagedLiveDto public PageMetaDto Meta { get; set; } = default!; public List Items { get; set; } = new(); } + +public sealed class CurrentStateDto +{ + public string Type { get; set; } = default!; + public string Target { get; set; } = default!; + public string Status { get; set; } = default!; // "up" or "down" + public double? RttMs { get; set; } // Priority-based RTT + public int Classification { get; set; } // Classification enum value +} diff --git a/ThingConnect.Pulse.Server/Services/EndpointService.cs b/ThingConnect.Pulse.Server/Services/EndpointService.cs index 48bdd71..9f7783d 100644 --- a/ThingConnect.Pulse.Server/Services/EndpointService.cs +++ b/ThingConnect.Pulse.Server/Services/EndpointService.cs @@ -38,16 +38,61 @@ public EndpointService(PulseDbContext context) .Take(RecentFetchLimit) .ToListAsync(); - var recent = rawChecks - .Select(c => new RawCheckDto + // Map to CheckResult objects for easier processing + var checks = rawChecks + .Select(c => new CheckResult { - Ts = ConvertToDateTimeOffset(c.Ts), - Status = c.Status.ToString().ToLower(), + EndpointId = c.EndpointId, + Timestamp = ConvertToDateTimeOffset(c.Ts), + Status = c.Status, RttMs = c.RttMs, - Error = c.Error + Error = c.Error, + FallbackAttempted = (bool)c.FallbackAttempted, + FallbackStatus = c.FallbackStatus, + FallbackRttMs = c.FallbackRttMs, + FallbackError = c.FallbackError, + Classification = c.Classification + }).ToList(); + + var recentForEndpoint = checks + .Where(x => x.Timestamp >= windowStart) + .OrderBy(x => x.Timestamp) + .ToList(); + + // --- Map RawCheckDto including EffectiveState --- + var recent = checks + .Where(c => c.Timestamp >= windowStart) + .OrderByDescending(c => c.Timestamp) + .Select(c => new RawCheckDto + { + Ts = c.Timestamp, + Classification = c.DetermineClassification(), + Primary = new PrimaryResultDto + { + Type = endpoint.Type.ToString().ToLower(), + Target = endpoint.Host, + Status = c.Status.ToString().ToLower(), + RttMs = c.RttMs, + Error = c.Error + }, + Fallback = new FallbackResultDto + { + Attempted = c.FallbackAttempted, + Type = "icmp", + Target = endpoint.Host, + Status = c.FallbackStatus?.ToString().ToLower(), + RttMs = c.FallbackRttMs, + Error = c.FallbackError + }, + CurrentState = new CurrentStateDto + { + Type = c.FallbackAttempted && c.FallbackStatus != null ? "icmp" : endpoint.Type.ToString().ToLower(), + Target = endpoint.Host, + Status = c.DetermineStatusType(recentForEndpoint, TimeSpan.FromSeconds(endpoint.IntervalSeconds * 2)).ToString().ToLower(), + RttMs = c.GetEffectiveRtt(), + Classification = (int)c.DetermineClassification(), + } }) - .Where(r => r.Ts >= windowStart) - .OrderByDescending(r => r.Ts) .ToList(); // --- Fetch outages within window --- @@ -73,7 +118,7 @@ public EndpointService(PulseDbContext context) .ToList(); // --- Map endpoint DTO --- - var endpointDto = MapToEndpointDto(endpoint); + var endpointDto = CheckResult.MapToEndpointDto(endpoint); return new EndpointDetailDto { @@ -83,31 +128,6 @@ public EndpointService(PulseDbContext context) }; } - private EndpointDto MapToEndpointDto(Data.Endpoint endpoint) - { - return new EndpointDto - { - Id = endpoint.Id, - Name = endpoint.Name, - Group = new GroupDto - { - Id = endpoint.Group.Id, - Name = endpoint.Group.Name, - ParentId = endpoint.Group.ParentId, - Color = endpoint.Group.Color - }, - Type = endpoint.Type.ToString().ToLower(), - Host = endpoint.Host, - Port = endpoint.Port, - HttpPath = endpoint.HttpPath, - HttpMatch = endpoint.HttpMatch, - IntervalSeconds = endpoint.IntervalSeconds, - TimeoutMs = endpoint.TimeoutMs, - Retries = endpoint.Retries, - Enabled = endpoint.Enabled - }; - } - // --- Helper to convert timestamp to DateTimeOffset --- private static DateTimeOffset ConvertToDateTimeOffset(T value) { diff --git a/ThingConnect.Pulse.Server/Services/HistoryService.cs b/ThingConnect.Pulse.Server/Services/HistoryService.cs index 6c1c224..365cc4a 100644 --- a/ThingConnect.Pulse.Server/Services/HistoryService.cs +++ b/ThingConnect.Pulse.Server/Services/HistoryService.cs @@ -51,14 +51,14 @@ public HistoryService(PulseDbContext context, ILogger logger) var response = new HistoryResponseDto { - Endpoint = MapToEndpointDto(endpoint) + Endpoint = CheckResult.MapToEndpointDto(endpoint) }; // Fetch data based on bucket type switch (bucket.ToLower()) { case "raw": - response.Raw = await GetRawDataAsync(endpointId, from, to); + response.Raw = await GetRawDataAsync(endpoint, from, to); break; case "15m": @@ -73,32 +73,69 @@ public HistoryService(PulseDbContext context, ILogger logger) throw new ArgumentException($"Invalid bucket type: {bucket}. Valid values: raw, 15m, daily"); } - // Always include outages for the time range + // Always include outages response.Outages = await GetOutagesAsync(endpointId, from, to); return response; } - private async Task> GetRawDataAsync(Guid endpointId, DateTimeOffset from, DateTimeOffset to) + private async Task> GetRawDataAsync(Data.Endpoint endpoint, DateTimeOffset from, DateTimeOffset to) { long fromUnix = UnixTimestamp.ToUnixSeconds(from); long toUnix = UnixTimestamp.ToUnixSeconds(to); - // SQLite limitation: fetch all data and filter in memory var rawData = await _context.CheckResultsRaw - .Where(c => c.EndpointId == endpointId) - .Select(c => new { c.Ts, c.Status, c.RttMs, c.Error }) + .Where(c => c.EndpointId == endpoint.Id && c.Ts >= fromUnix && c.Ts <= toUnix) + .OrderBy(c => c.Ts) .ToListAsync(); - return rawData - .Where(c => c.Ts >= fromUnix && c.Ts <= toUnix) - .OrderBy(c => c.Ts) - .Select(c => new RawCheckDto + // Convert DB rows -> CheckResult -> RawCheckDto + var checks = rawData + .Select(c => new CheckResult { - Ts = UnixTimestamp.FromUnixSeconds(c.Ts), - Status = c.Status == UpDown.up ? "up" : "down", + EndpointId = c.EndpointId, + Timestamp = UnixTimestamp.FromUnixSeconds(c.Ts), + Status = c.Status, RttMs = c.RttMs, - Error = c.Error + Error = c.Error, + FallbackAttempted = (bool)c.FallbackAttempted, + FallbackStatus = c.FallbackStatus, + FallbackRttMs = c.FallbackRttMs, + FallbackError = c.FallbackError, + Classification = c.Classification + }) + .ToList(); + + return checks + .Select(c => new RawCheckDto + { + Ts = c.Timestamp, + Classification = c.DetermineClassification(), + Primary = new PrimaryResultDto + { + Type = endpoint.Type.ToString().ToLower(), + Target = endpoint.Host, + Status = c.Status.ToString().ToLower(), + RttMs = c.RttMs, + Error = c.Error + }, + Fallback = new FallbackResultDto + { + Attempted = c.FallbackAttempted, + Type = "icmp", + Target = endpoint.Host, + Status = c.FallbackStatus?.ToString().ToLower(), + RttMs = c.FallbackRttMs, + Error = c.FallbackError + }, + CurrentState = new CurrentStateDto + { + Type = c.FallbackAttempted && c.FallbackStatus != null ? "icmp" : endpoint.Type.ToString().ToLower(), + Target = endpoint.Host, + Status = c.GetEffectiveStatus().ToString().ToLower(), + RttMs = c.GetEffectiveRtt(), + Classification = (int)c.DetermineClassification(), + } }) .ToList(); } @@ -175,29 +212,4 @@ private async Task> GetOutagesAsync(Guid endpointId, DateTimeOff }) .ToList(); } - - private EndpointDto MapToEndpointDto(Data.Endpoint endpoint) - { - return new EndpointDto - { - Id = endpoint.Id, - Name = endpoint.Name, - Group = new GroupDto - { - Id = endpoint.Group.Id, - Name = endpoint.Group.Name, - ParentId = endpoint.Group.ParentId, - Color = endpoint.Group.Color - }, - Type = endpoint.Type.ToString().ToLower(), - Host = endpoint.Host, - Port = endpoint.Port, - HttpPath = endpoint.HttpPath, - HttpMatch = endpoint.HttpMatch, - IntervalSeconds = endpoint.IntervalSeconds, - TimeoutMs = endpoint.TimeoutMs, - Retries = endpoint.Retries, - Enabled = endpoint.Enabled - }; - } } diff --git a/ThingConnect.Pulse.Server/Services/Monitoring/OutageDetectionService.cs b/ThingConnect.Pulse.Server/Services/Monitoring/OutageDetectionService.cs index d2d6a67..4471a30 100644 --- a/ThingConnect.Pulse.Server/Services/Monitoring/OutageDetectionService.cs +++ b/ThingConnect.Pulse.Server/Services/Monitoring/OutageDetectionService.cs @@ -22,6 +22,10 @@ public OutageDetectionService(IServiceProvider serviceProvider, ILogger + /// Processes a single check result: updates streaks, transitions UP/DOWN with flap damping, + /// and persists the raw result including fallback details and classification. + /// public async Task ProcessCheckResultAsync(CheckResult result, CancellationToken cancellationToken = default) { MonitorState state = _states.GetOrAdd(result.EndpointId, _ => new MonitorState()); @@ -33,30 +37,36 @@ public async Task ProcessCheckResultAsync(CheckResult result, Cancellation try { - // Update streak counters based on result - if (result.Status == UpDown.up) + var effectiveStatus = result.GetEffectiveStatus(); + if (effectiveStatus == UpDown.up) { state.RecordSuccess(); _logger.LogDebug( - "RecordSuccess called for endpoint {EndpointId}. SuccessStreak={SuccessStreak}, FailStreak={FailStreak}", - result.EndpointId, state.SuccessStreak, state.FailStreak + "RecordSuccess called for endpoint {EndpointId}. EffectiveStatus={EffectiveStatus}, SuccessStreak={SuccessStreak}, FailStreak={FailStreak}", + result.EndpointId, effectiveStatus, state.SuccessStreak, state.FailStreak ); } else { state.RecordFailure(); _logger.LogDebug( - "RecordFailure called for endpoint {EndpointId}. SuccessStreak={SuccessStreak}, FailStreak={FailStreak}, Error={Error}", - result.EndpointId, state.SuccessStreak, state.FailStreak, result.Error + "RecordFailure called for endpoint {EndpointId}. EffectiveStatus={EffectiveStatus}, SuccessStreak={SuccessStreak}, FailStreak={FailStreak}, Error={Error}", + result.EndpointId, effectiveStatus, state.SuccessStreak, state.FailStreak, result.Error ); } // Check for DOWN transition if (state.ShouldTransitionToDown()) { - await TransitionToDownAsync(result.EndpointId, state, UnixTimestamp.ToUnixSeconds(result.Timestamp), result.Error, cancellationToken); + await TransitionToDownAsync( + result.EndpointId, + state, + UnixTimestamp.ToUnixSeconds(result.Timestamp), + result.Error, + result.Classification, + cancellationToken); stateChanged = true; - _logger.LogWarning("Endpoint {EndpointId} transitioned to DOWN after {FailStreak} consecutive failures", + _logger.LogWarning("Endpoint {EndpointId} transitioned to DOWN after {FailStreak} consecutive effective failures", result.EndpointId, state.FailStreak); } @@ -65,7 +75,7 @@ public async Task ProcessCheckResultAsync(CheckResult result, Cancellation { await TransitionToUpAsync(result.EndpointId, state, UnixTimestamp.ToUnixSeconds(result.Timestamp), cancellationToken); stateChanged = true; - _logger.LogInformation("Endpoint {EndpointId} transitioned to UP after {SuccessStreak} consecutive successes", + _logger.LogInformation("Endpoint {EndpointId} transitioned to UP after {SuccessStreak} consecutive effective successes", result.EndpointId, state.SuccessStreak); } @@ -207,10 +217,6 @@ public async Task InitializeStatesFromDatabaseAsync(CancellationToken cancellati if (inconsistenciesFixed > 0) { await context.SaveChangesAsync(cancellationToken); - } - - if (inconsistenciesFixed > 0) - { _logger.LogInformation("Started monitoring session {SessionId}, initialized {Count} states, fixed {InconsistencyCount} state inconsistencies", newSession.Id, initializedCount, inconsistenciesFixed); } @@ -248,7 +254,6 @@ public async Task InitializeStatesFromDatabaseAsync(CancellationToken cancellati "{GapDuration}s gap > {Threshold}s threshold ({IntervalSeconds}s interval), " + "missed ~{MissedChecks} checks", endpoint.Id, endpoint.Name, gapDuration, gapThreshold, endpoint.IntervalSeconds, missedChecks); - affectedEndpoints.Add(endpoint); } } @@ -265,8 +270,8 @@ private async Task HandleMonitoringGapAsync(PulseDbContext context, long lastMon // Handle open outages only for affected endpoints List outagesForAffectedEndpoints = await context.Outages .Where(o => o.EndedTs == null && - o.StartedTs < lastMonitoringTime && - affectedEndpointIds.Contains(o.EndpointId)) + o.StartedTs < lastMonitoringTime && + affectedEndpointIds.Contains(o.EndpointId)) .ToListAsync(cancellationToken); foreach (Outage? outage in outagesForAffectedEndpoints) @@ -349,8 +354,7 @@ public async Task HandleGracefulShutdownAsync(string? shutdownReason = null, Can } } - private async Task TransitionToDownAsync(Guid endpointId, MonitorState state, long timestamp, - string? error, CancellationToken cancellationToken) + private async Task TransitionToDownAsync(Guid endpointId, MonitorState state, long timestamp, string? error, Classification? classification, CancellationToken cancellationToken) { using IServiceScope scope = _serviceProvider.CreateScope(); PulseDbContext context = scope.ServiceProvider.GetRequiredService(); @@ -365,7 +369,8 @@ private async Task TransitionToDownAsync(Guid endpointId, MonitorState state, lo { EndpointId = endpointId, StartedTs = timestamp, - LastError = error + LastError = error, + Classification = classification }; context.Outages.Add(outage); @@ -515,18 +520,29 @@ private async Task UpdateEndpointStatusAsync(PulseDbContext context, Guid endpoi return (endpointStatus, openOutageId, false); } + /// + /// Persists the raw check result including fallback probe fields and classification. + /// Also updates endpoint's LastRttMs for successful probes. + /// private async Task SaveCheckResultAsync(CheckResult result, CancellationToken cancellationToken) { using IServiceScope scope = _serviceProvider.CreateScope(); PulseDbContext context = scope.ServiceProvider.GetRequiredService(); - CheckResultRaw rawResult = new CheckResultRaw + var rawResult = new CheckResultRaw { EndpointId = result.EndpointId, Ts = UnixTimestamp.ToUnixSeconds(result.Timestamp), Status = result.Status, RttMs = result.RttMs, - Error = result.Error + Error = result.Error, + + // New fallback fields + FallbackAttempted = result.FallbackAttempted, + FallbackStatus = result.FallbackStatus, + FallbackRttMs = result.FallbackRttMs, + FallbackError = result.FallbackError, + Classification = result.Classification }; context.CheckResultsRaw.Add(rawResult); diff --git a/ThingConnect.Pulse.Server/Services/Monitoring/ProbeService.cs b/ThingConnect.Pulse.Server/Services/Monitoring/ProbeService.cs index 33f3aab..dec82d2 100644 --- a/ThingConnect.Pulse.Server/Services/Monitoring/ProbeService.cs +++ b/ThingConnect.Pulse.Server/Services/Monitoring/ProbeService.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Net.NetworkInformation; using System.Net.Sockets; +using System.Threading; using ThingConnect.Pulse.Server.Data; using ThingConnect.Pulse.Server.Models; @@ -24,25 +25,45 @@ public ProbeService(ILogger logger, IHttpClientFactory httpClientF public async Task ProbeAsync(Data.Endpoint endpoint, CancellationToken cancellationToken = default) { DateTimeOffset timestamp = DateTimeOffset.UtcNow; + CheckResult probeResult; try { - return endpoint.Type switch + probeResult = endpoint.Type switch { ProbeType.icmp => await PingAsync(endpoint.Id, endpoint.Host, endpoint.TimeoutMs, cancellationToken), - ProbeType.tcp => await TcpConnectAsync(endpoint.Id, endpoint.Host, - endpoint.Port ?? 80, endpoint.TimeoutMs, cancellationToken), - ProbeType.http => await HttpCheckAsync(endpoint.Id, endpoint.Host, - endpoint.Port ?? (endpoint.Host.StartsWith("https://") ? 443 : 80), + ProbeType.tcp => await TcpConnectAsync(endpoint.Id, endpoint.Host, endpoint.Port ?? 80, endpoint.TimeoutMs, cancellationToken), + ProbeType.http => await HttpCheckAsync(endpoint.Id, endpoint.Host, endpoint.Port ?? (endpoint.Host.StartsWith("https://") ? 443 : 80), endpoint.HttpPath, endpoint.HttpMatch, endpoint.TimeoutMs, cancellationToken), _ => CheckResult.Failure(endpoint.Id, timestamp, $"Unknown probe type: {endpoint.Type}") }; } catch (Exception ex) { - _logger.LogError(ex, "Probe failed for endpoint {EndpointId} ({Host})", endpoint.Id, endpoint.Host); - return CheckResult.Failure(endpoint.Id, timestamp, ex.Message); + _logger.LogError(ex, "Primary probe failed for endpoint {EndpointId} ({Host})", endpoint.Id, endpoint.Host); + probeResult = CheckResult.Failure(endpoint.Id, timestamp, ex.Message); } + + // TCP/HTTP fallback to ICMP if primary failed + if (probeResult.Status == UpDown.down && endpoint.Type != ProbeType.icmp) + { + try + { + int fallbackTimeout = Math.Max(endpoint.TimeoutMs / 2, 1000); + CheckResult fallbackResult = await PingAsync(endpoint.Id, endpoint.Host, fallbackTimeout, cancellationToken); + + // ApplyFallback automatically sets classification + probeResult.ApplyFallback(fallbackResult); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Fallback ICMP probe failed for endpoint {EndpointId} ({Host})", endpoint.Id, endpoint.Host); + CheckResult fallbackResult = CheckResult.Failure(endpoint.Id, DateTimeOffset.UtcNow, $"Fallback ping failed: {ex.Message}"); + probeResult.ApplyFallback(fallbackResult); + } + } + + return probeResult; } public async Task PingAsync(Guid endpointId, string host, int timeoutMs, CancellationToken cancellationToken = default) diff --git a/ThingConnect.Pulse.Server/Services/Monitoring/StatusClassifier.cs b/ThingConnect.Pulse.Server/Services/Monitoring/StatusClassifier.cs new file mode 100644 index 0000000..b50bbc3 --- /dev/null +++ b/ThingConnect.Pulse.Server/Services/Monitoring/StatusClassifier.cs @@ -0,0 +1,113 @@ +using System.Net; +using ThingConnect.Pulse.Server.Data; +using ThingConnect.Pulse.Server.Models; + +namespace ThingConnect.Pulse.Server.Services.Monitoring; + +/// +// Complex logic with history, performance, DNS, etc. +// Save for Phase 2 +/// +public static class StatusClassifier +{ + public static Classification ClassifyStatus( + CheckResult primaryResult, + CheckResult fallbackResult, + Data.Endpoint endpoint, + IEnumerable recentHistory) + { + // 1. ICMP probes โ†’ always Network on failure + if (endpoint.Type == ProbeType.icmp && primaryResult.Status == UpDown.down) + { + return Classification.Network; + } + + // 2. Successful probes โ†’ check performance + if (primaryResult.Status == UpDown.up) + { + if (IsPerformanceDegraded(primaryResult, endpoint)) + { + return Classification.Performance; + } + + return Classification.None; // explicitly healthy + } + + // 3. Failed TCP/HTTP probes โ†’ use fallback + if (fallbackResult != null) + { + var baseClassification = fallbackResult.Status == UpDown.up + ? Classification.Service + : Classification.Network; + + // 4. Advanced patterns + if (IsIntermittent(recentHistory)) + { + return Classification.Intermittent; + } + + if (IsPartialService(primaryResult, fallbackResult, endpoint)) + { + return Classification.PartialService; + } + + if (IsDnsIssue(endpoint, fallbackResult)) + { + return Classification.DnsResolution; + } + + return baseClassification; + } + + // 5. Fallback missing or failed โ†’ default + return Classification.Unknown; + } + + private static bool IsPerformanceDegraded(CheckResult result, Data.Endpoint endpoint) + { + if (!result.RttMs.HasValue) return false; + + double threshold = endpoint.Type == ProbeType.icmp ? 2.0 : 3.0; + double baselineRtt = endpoint.ExpectedRttMs ?? 100; // default baseline + + return result.RttMs > baselineRtt * threshold; + } + + private static bool IsIntermittent(IEnumerable recentHistory) + { + var last15Min = recentHistory + .Where(r => r.Timestamp > DateTimeOffset.UtcNow.AddMinutes(-15)) + .OrderBy(r => r.Timestamp) + .ToList(); + + if (last15Min.Count < 4) return false; + + int transitions = 0; + for (int i = 1; i < last15Min.Count; i++) + { + if (last15Min[i].Status != last15Min[i - 1].Status) + transitions++; + } + + return transitions >= 4; + } + + private static bool IsPartialService(CheckResult primary, CheckResult fallback, Data.Endpoint endpoint) + { + return endpoint.Type == ProbeType.http && + primary.Error?.Contains("50") == true && // HTTP 5xx + fallback.Status == UpDown.up; + } + + private static bool IsDnsIssue(Data.Endpoint endpoint, CheckResult fallbackResult) + { + return IsHostname(endpoint.Host) && + fallbackResult.Status == UpDown.down && + (fallbackResult.Error?.Contains("resolve", StringComparison.OrdinalIgnoreCase) ?? false); + } + + private static bool IsHostname(string host) + { + return !IPAddress.TryParse(host, out _); + } +} diff --git a/ThingConnect.Pulse.Server/Services/Rollup/RollupService.cs b/ThingConnect.Pulse.Server/Services/Rollup/RollupService.cs index c49e9b1..ed2287c 100644 --- a/ThingConnect.Pulse.Server/Services/Rollup/RollupService.cs +++ b/ThingConnect.Pulse.Server/Services/Rollup/RollupService.cs @@ -26,48 +26,33 @@ public async Task ProcessRollup15mAsync(CancellationToken cancellationToken = de try { - // Get last watermark DateTimeOffset? lastWatermark = await _settingsService.GetLastRollup15mTimestampAsync(); - long fromTs = lastWatermark.HasValue ? UnixTimestamp.ToUnixSeconds(lastWatermark.Value) : UnixTimestamp.Subtract(UnixTimestamp.Now(), TimeSpan.FromDays(7)); // Default: 7 days back + long fromTs = lastWatermark.HasValue + ? UnixTimestamp.ToUnixSeconds(lastWatermark.Value) + : UnixTimestamp.Subtract(UnixTimestamp.Now(), TimeSpan.FromDays(7)); long toTs = UnixTimestamp.Now(); - _logger.LogDebug("Processing 15m rollups from {FromTs} to {ToTs}", UnixTimestamp.FromUnixSeconds(fromTs), UnixTimestamp.FromUnixSeconds(toTs)); - - // Get all raw checks in the time window - // SQLite has issues with DateTimeOffset comparisons in LINQ, so fetch all and filter in memory List allChecks = await _context.CheckResultsRaw.ToListAsync(cancellationToken); var rawChecks = allChecks .Where(c => c.Ts > fromTs && c.Ts <= toTs) .OrderBy(c => c.EndpointId) .ThenBy(c => c.Ts) + .Select(c => new WrappedCheck(c)) .ToList(); - if (!rawChecks.Any()) - { - _logger.LogDebug("No raw checks found for rollup processing"); - return; - } - - _logger.LogInformation("Processing {Count} raw checks", rawChecks.Count); + if (!rawChecks.Any()) return; - // Group by endpoint and calculate rollups - IEnumerable> endpointGroups = rawChecks.GroupBy(c => c.EndpointId); + var endpointGroups = rawChecks.GroupBy(c => c.EndpointId); List rollupsToUpsert = new(); - foreach (IGrouping endpointGroup in endpointGroups) + foreach (var endpointGroup in endpointGroups) { var checks = endpointGroup.OrderBy(c => c.Ts).ToList(); - List endpointRollups = CalculateRollups15m(endpointGroup.Key, checks); - rollupsToUpsert.AddRange(endpointRollups); + rollupsToUpsert.AddRange(CalculateRollups15m(endpointGroup.Key, checks)); } - // Upsert rollups in batches await UpsertRollups15mAsync(rollupsToUpsert, cancellationToken); - - // Update watermark await _settingsService.SetLastRollup15mTimestampAsync(UnixTimestamp.FromUnixSeconds(toTs)); - - _logger.LogInformation("Completed 15m rollup processing. Generated {Count} rollup records", rollupsToUpsert.Count); } catch (Exception ex) { @@ -82,57 +67,36 @@ public async Task ProcessRollupDailyAsync(CancellationToken cancellationToken = try { - // Get last watermark DateOnly? lastWatermark = await _settingsService.GetLastRollupDailyDateAsync(); DateOnly fromDate = lastWatermark?.AddDays(1) ?? DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-7)); - var toDate = DateOnly.FromDateTime(DateTime.UtcNow.Date); + DateOnly toDate = DateOnly.FromDateTime(DateTime.UtcNow.Date); - if (fromDate >= toDate) - { - _logger.LogDebug("No new days to process for daily rollup"); - return; - } - - _logger.LogDebug("Processing daily rollups from {FromDate} to {ToDate}", fromDate, toDate); + if (fromDate >= toDate) return; - // Get all raw checks in the date range long fromTs = UnixTimestamp.ToUnixDate(fromDate); long toTs = UnixTimestamp.ToUnixDate(toDate); - // SQLite has issues with DateTimeOffset comparisons in LINQ, so fetch all and filter in memory List allChecks = await _context.CheckResultsRaw.ToListAsync(cancellationToken); var rawChecks = allChecks .Where(c => c.Ts >= fromTs && c.Ts < toTs) .OrderBy(c => c.EndpointId) .ThenBy(c => c.Ts) + .Select(c => new WrappedCheck(c)) .ToList(); - if (!rawChecks.Any()) - { - _logger.LogDebug("No raw checks found for daily rollup processing"); - return; - } + if (!rawChecks.Any()) return; - _logger.LogInformation("Processing {Count} raw checks for daily rollup", rawChecks.Count); - - // Group by endpoint and calculate rollups - IEnumerable> endpointGroups = rawChecks.GroupBy(c => c.EndpointId); + var endpointGroups = rawChecks.GroupBy(c => c.EndpointId); List rollupsToUpsert = new(); - foreach (IGrouping endpointGroup in endpointGroups) + foreach (var endpointGroup in endpointGroups) { var checks = endpointGroup.OrderBy(c => c.Ts).ToList(); - List endpointRollups = CalculateRollupsDaily(endpointGroup.Key, checks, fromDate, toDate); - rollupsToUpsert.AddRange(endpointRollups); + rollupsToUpsert.AddRange(CalculateRollupsDaily(endpointGroup.Key, checks, fromDate, toDate)); } - // Upsert rollups in batches await UpsertRollupsDailyAsync(rollupsToUpsert, cancellationToken); - - // Update watermark await _settingsService.SetLastRollupDailyDateAsync(toDate.AddDays(-1)); - - _logger.LogInformation("Completed daily rollup processing. Generated {Count} rollup records", rollupsToUpsert.Count); } catch (Exception ex) { @@ -141,44 +105,35 @@ public async Task ProcessRollupDailyAsync(CancellationToken cancellationToken = } } - private List CalculateRollups15m(Guid endpointId, List checks) + // --- Private rollup calculation helpers --- + private List CalculateRollups15m(Guid endpointId, List checks) { var rollups = new List(); - // Group by 15-minute bucket var bucketGroups = checks - .Select(c => new - { - Check = c, - Bucket = GetBucketTimestamp15m(c.Ts) - }) - .GroupBy(x => x.Bucket); + .GroupBy(c => GetBucketTimestamp15m(c.Ts)); foreach (var bucketGroup in bucketGroups) { - var bucketChecks = bucketGroup.Select(x => x.Check).OrderBy(c => c.Ts).ToList(); - - if (!bucketChecks.Any()) - { - continue; - } + var bucketChecks = bucketGroup.OrderBy(c => c.Ts).ToList(); + if (!bucketChecks.Any()) continue; - // Calculate metrics int totalChecks = bucketChecks.Count; - int upChecks = bucketChecks.Count(c => c.Status == UpDown.up); + int upChecks = bucketChecks.Count(c => c.GetEffectiveStatus() == UpDown.up); double upPct = totalChecks > 0 ? (double)upChecks / totalChecks * 100.0 : 0.0; var rttValues = bucketChecks - .Where(c => c.RttMs.HasValue && c.RttMs > 0) - .Select(c => c.RttMs!.Value) + .Select(c => c.GetEffectiveRtt()) + .Where(rtt => rtt.HasValue && rtt > 0) + .Select(rtt => rtt!.Value) .ToList(); double? avgRttMs = rttValues.Any() ? rttValues.Average() : null; - // Count down events (upโ†’down transitions) int downEvents = 0; for (int i = 1; i < bucketChecks.Count; i++) { - if (bucketChecks[i - 1].Status == UpDown.up && bucketChecks[i].Status == UpDown.down) + if (bucketChecks[i - 1].GetEffectiveStatus() == UpDown.up && + bucketChecks[i].GetEffectiveStatus() == UpDown.down) { downEvents++; } @@ -187,7 +142,7 @@ public async Task ProcessRollupDailyAsync(CancellationToken cancellationToken = rollups.Add(new Data.Rollup15m { EndpointId = endpointId, - BucketTs = bucketGroup.Key, + BucketTs = GetBucketTimestamp15m(bucketChecks.First().Ts), UpPct = upPct, AvgRttMs = avgRttMs, DownEvents = downEvents @@ -197,45 +152,35 @@ public async Task ProcessRollupDailyAsync(CancellationToken cancellationToken = return rollups; } - private List CalculateRollupsDaily(Guid endpointId, List checks, DateOnly fromDate, DateOnly toDate) + private List CalculateRollupsDaily(Guid endpointId, List checks, DateOnly fromDate, DateOnly toDate) { var rollups = new List(); - // Group by date var dateGroups = checks - .Select(c => new - { - Check = c, - Date = DateOnly.FromDateTime(UnixTimestamp.FromUnixSeconds(c.Ts).Date) - }) - .Where(x => x.Date >= fromDate && x.Date < toDate) - .GroupBy(x => x.Date); + .GroupBy(c => DateOnly.FromDateTime(UnixTimestamp.FromUnixSeconds(c.Ts).Date)) + .Where(g => g.Key >= fromDate && g.Key < toDate); foreach (var dateGroup in dateGroups) { - var dayChecks = dateGroup.Select(x => x.Check).OrderBy(c => c.Ts).ToList(); - - if (!dayChecks.Any()) - { - continue; - } + var dayChecks = dateGroup.OrderBy(c => c.Ts).ToList(); + if (!dayChecks.Any()) continue; - // Calculate metrics int totalChecks = dayChecks.Count; - int upChecks = dayChecks.Count(c => c.Status == UpDown.up); + int upChecks = dayChecks.Count(c => c.GetEffectiveStatus() == UpDown.up); double upPct = totalChecks > 0 ? (double)upChecks / totalChecks * 100.0 : 0.0; var rttValues = dayChecks - .Where(c => c.RttMs.HasValue && c.RttMs > 0) - .Select(c => c.RttMs!.Value) + .Select(c => c.GetEffectiveRtt()) + .Where(rtt => rtt.HasValue && rtt > 0) + .Select(rtt => rtt!.Value) .ToList(); double? avgRttMs = rttValues.Any() ? rttValues.Average() : null; - // Count down events (upโ†’down transitions) int downEvents = 0; for (int i = 1; i < dayChecks.Count; i++) { - if (dayChecks[i - 1].Status == UpDown.up && dayChecks[i].Status == UpDown.down) + if (dayChecks[i - 1].GetEffectiveStatus() == UpDown.up && + dayChecks[i].GetEffectiveStatus() == UpDown.down) { downEvents++; } @@ -256,74 +201,89 @@ public async Task ProcessRollupDailyAsync(CancellationToken cancellationToken = private static long GetBucketTimestamp15m(long unixTs) { - // Round down to nearest 15-minute boundary DateTimeOffset ts = UnixTimestamp.FromUnixSeconds(unixTs); - int minute = ts.Minute; - int bucketMinute = (minute / 15) * 15; - - var bucketTime = new DateTimeOffset(ts.Year, ts.Month, ts.Day, ts.Hour, bucketMinute, 0, ts.Offset); - return UnixTimestamp.ToUnixSeconds(bucketTime); + int bucketMinute = (ts.Minute / 15) * 15; + return UnixTimestamp.ToUnixSeconds(new DateTimeOffset(ts.Year, ts.Month, ts.Day, ts.Hour, bucketMinute, 0, ts.Offset)); } private async Task UpsertRollups15mAsync(List rollups, CancellationToken cancellationToken) { - if (!rollups.Any()) - { - return; - } + if (!rollups.Any()) return; - // SQLite doesn't support MERGE/UPSERT in EF Core, so we'll do it manually - foreach (Data.Rollup15m rollup in rollups) + foreach (var rollup in rollups) { - Rollup15m? existing = await _context.Rollups15m + var existing = await _context.Rollups15m .FirstOrDefaultAsync(r => r.EndpointId == rollup.EndpointId && r.BucketTs == rollup.BucketTs, cancellationToken); if (existing != null) { - // Update existing existing.UpPct = rollup.UpPct; existing.AvgRttMs = rollup.AvgRttMs; existing.DownEvents = rollup.DownEvents; } else { - // Add new _context.Rollups15m.Add(rollup); } } await _context.SaveChangesAsync(cancellationToken); - _logger.LogDebug("Upserted {Count} 15m rollup records", rollups.Count); } private async Task UpsertRollupsDailyAsync(List rollups, CancellationToken cancellationToken) { - if (!rollups.Any()) - { - return; - } + if (!rollups.Any()) return; - // SQLite doesn't support MERGE/UPSERT in EF Core, so we'll do it manually - foreach (Data.RollupDaily rollup in rollups) + foreach (var rollup in rollups) { - RollupDaily? existing = await _context.RollupsDaily + var existing = await _context.RollupsDaily .FirstOrDefaultAsync(r => r.EndpointId == rollup.EndpointId && r.BucketDate == rollup.BucketDate, cancellationToken); if (existing != null) { - // Update existing existing.UpPct = rollup.UpPct; existing.AvgRttMs = rollup.AvgRttMs; existing.DownEvents = rollup.DownEvents; } else { - // Add new _context.RollupsDaily.Add(rollup); } } await _context.SaveChangesAsync(cancellationToken); - _logger.LogDebug("Upserted {Count} daily rollup records", rollups.Count); + } + + // --- Private wrapper class for effective status & RTT --- + private class WrappedCheck + { + private readonly CheckResultRaw _check; + + public WrappedCheck(CheckResultRaw check) + { + _check = check; + Ts = check.Ts; + EndpointId = check.EndpointId; + } + + public long Ts { get; } + public Guid EndpointId { get; } + + public UpDown GetEffectiveStatus() + { + if (_check.Status == UpDown.down && _check.FallbackAttempted == true && _check.FallbackStatus == UpDown.up) + { + return UpDown.up; + } + return _check.Status; + } + + public double? GetEffectiveRtt() + { + if (_check.Status == UpDown.up && _check.RttMs.HasValue) return _check.RttMs; + if (_check.Status == UpDown.down && _check.FallbackAttempted == true && _check.FallbackStatus == UpDown.up && _check.FallbackRttMs.HasValue) + return _check.FallbackRttMs; + return null; + } } } diff --git a/ThingConnect.Pulse.Server/Services/StatusService.cs b/ThingConnect.Pulse.Server/Services/StatusService.cs index 3fade8c..b417a57 100644 --- a/ThingConnect.Pulse.Server/Services/StatusService.cs +++ b/ThingConnect.Pulse.Server/Services/StatusService.cs @@ -1,3 +1,4 @@ +using System.Net.NetworkInformation; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using ThingConnect.Pulse.Server.Data; @@ -55,37 +56,44 @@ public async Task> GetLiveStatusAsync(string? group, str // Apply pagination List endpoints = await query - .OrderBy(e => e.GroupId) - .ThenBy(e => e.Name) - .ToListAsync(); - - // Get live status for each endpoint + .OrderBy(e => e.GroupId) + .ThenBy(e => e.Name) + .ToListAsync(); var items = new List(); var endpointIds = endpoints.Select(e => e.Id).ToList(); - // Get latest checks for all endpoints - optimized query using window functions in SQLite - var latestChecks = await _context.CheckResultsRaw - .Where(c => endpointIds.Contains(c.EndpointId)) + // Fetch recent checks for all endpoints (last 5 minutes) + long cutoffTime = UnixTimestamp.Subtract(UnixTimestamp.Now(), TimeSpan.FromMinutes(5)); + var recentChecks = await _context.CheckResultsRaw + .Where(c => endpointIds.Contains(c.EndpointId) && c.Ts >= cutoffTime) .AsNoTracking() - .GroupBy(c => c.EndpointId) - .Select(g => new + .Select(c => new CheckResult { - EndpointId = g.Key, - LatestCheck = g.OrderByDescending(c => c.Ts).FirstOrDefault() + EndpointId = c.EndpointId, + Timestamp = UnixTimestamp.FromUnixSeconds(c.Ts), + Status = c.Status, + RttMs = c.RttMs, + FallbackAttempted = c.FallbackStatus.HasValue, + FallbackStatus = c.FallbackStatus, + FallbackRttMs = c.FallbackRttMs, + Classification = c.Classification, }) .ToListAsync(); - var latestCheckDict = latestChecks.ToDictionary(x => x.EndpointId, x => x.LatestCheck); + var checksGrouped = recentChecks.GroupBy(c => c.EndpointId).ToDictionary(g => g.Key, g => g.ToList()); - // Get sparkline data (last 20 checks per endpoint for mini chart) Dictionary> sparklineData = await GetSparklineDataAsync(endpointIds); foreach (Data.Endpoint? endpoint in endpoints) { - StatusType status = DetermineStatus(endpoint, latestCheckDict); + var recent = checksGrouped.ContainsKey(endpoint.Id) ? checksGrouped[endpoint.Id] : new List(); + StatusType status = recent.Any() + ? recent.Last().DetermineStatusType(recent, TimeSpan.FromSeconds(endpoint.IntervalSeconds * 2)) + : StatusType.Down; + List sparkline = sparklineData.ContainsKey(endpoint.Id) - ? sparklineData[endpoint.Id] - : new List(); + ? sparklineData[endpoint.Id] + : new List(); _logger.LogInformation( "Endpoint {EndpointName}: Status = {Status}, LastRttMs = {RttMs}, LastChangeTs = {LastChangeTs}", @@ -93,10 +101,18 @@ public async Task> GetLiveStatusAsync(string? group, str items.Add(new LiveStatusItemDto { - Endpoint = MapToEndpointDto(endpoint), - Status = status.ToString().ToLower(), - RttMs = endpoint.LastRttMs, - LastChangeTs = endpoint.LastChangeTs.HasValue ? UnixTimestamp.FromUnixSeconds(endpoint.LastChangeTs.Value) : DateTimeOffset.Now, + Endpoint = CheckResult.MapToEndpointDto(endpoint), + CurrentState = new CurrentStateDto + { + Type = recent.Any() && recent.Last().FallbackAttempted ? "icmp" : endpoint.Type.ToString().ToLower(), + Target = endpoint.Host, + Status = status.ToString().ToLower(), + RttMs = recent.Any() ? recent.Last().GetEffectiveRtt() : null, + Classification = recent.Any() ? (int)recent.Last().DetermineClassification() : 0 + }, + LastChangeTs = endpoint.LastChangeTs.HasValue + ? UnixTimestamp.FromUnixSeconds(endpoint.LastChangeTs.Value) + : DateTimeOffset.Now, Sparkline = sparkline }); } @@ -152,120 +168,40 @@ private async Task>> GetSparklineDataAsync var recentChecks = await _context.CheckResultsRaw .Where(c => endpointIds.Contains(c.EndpointId) && c.Ts >= cutoffTime) .AsNoTracking() - .Select(c => new { c.EndpointId, c.Ts, c.Status }) + .Select(c => new CheckResult + { + EndpointId = c.EndpointId, + Timestamp = UnixTimestamp.FromUnixSeconds(c.Ts), + Status = c.Status, + FallbackAttempted = c.FallbackStatus.HasValue, + FallbackStatus = c.FallbackStatus, + Classification = c.Classification, + }) .ToListAsync(); - recentChecks = recentChecks - .OrderBy(c => c.EndpointId) - .ThenByDescending(c => c.Ts) - .ToList(); - - var groupedChecks = recentChecks.GroupBy(c => c.EndpointId); - - foreach (var group in groupedChecks) + var groupedChecks = recentChecks + .GroupBy(c => c.EndpointId) + .ToDictionary(g => g.Key, g => g + .OrderByDescending(c => c.Timestamp) + .Take(20) + .OrderBy(c => c.Timestamp) // chronological order for sparkline + .ToList() + ); + + foreach (var kvp in groupedChecks) { - var points = group - .Take(20) // Maximum 20 points for sparkline - .OrderBy(c => c.Ts) // Order chronologically for display + var points = kvp.Value .Select(c => new SparklinePoint { - Ts = UnixTimestamp.FromUnixSeconds(c.Ts), - S = c.Status == UpDown.up ? "u" : "d" + Ts = c.Timestamp, + S = c.GetEffectiveStatus() == UpDown.up ? "u" : "d" // ๐Ÿ”น use effective status }) .ToList(); - sparklineData[group.Key] = points; + sparklineData[kvp.Key] = points; } return sparklineData; } - private StatusType DetermineStatus(Data.Endpoint endpoint, Dictionary latestChecks) - { - // Check if we have recent check data - if (!latestChecks.TryGetValue(endpoint.Id, out CheckResultRaw? latestCheck) || latestCheck == null) - { - return StatusType.Down; // No data means down - } - - // Check if the latest check is recent enough (within 2x interval) - var expectedInterval = TimeSpan.FromSeconds(endpoint.IntervalSeconds * 2); - if (UnixTimestamp.Now() - latestCheck.Ts > (long)expectedInterval.TotalSeconds) - { - return StatusType.Down; // Stale data means down - } - - // Check for flapping (multiple state changes in short period) - // This is simplified - in production you'd want more sophisticated flap detection - if (IsFlapping(endpoint.Id).Result) - { - return StatusType.Flapping; - } - - return latestCheck.Status == UpDown.up ? StatusType.Up : StatusType.Down; - } - - private async Task IsFlapping(Guid endpointId) - { - // Simple flap detection: check if there were > 3 state changes in last 5 minutes - long cutoffTime = UnixTimestamp.Subtract(UnixTimestamp.Now(), TimeSpan.FromMinutes(5)); - var checks = await _context.CheckResultsRaw - .Where(c => c.EndpointId == endpointId && c.Ts >= cutoffTime) - .AsNoTracking() - .Select(c => new { c.Ts, c.Status }) - .ToListAsync(); - - var recentChecks = checks - .OrderBy(c => c.Ts) - .Select(c => c.Status) - .ToList(); - - if (recentChecks.Count < 4) - { - return false; - } - - int stateChanges = 0; - for (int i = 1; i < recentChecks.Count; i++) - { - if (recentChecks[i] != recentChecks[i - 1]) - { - stateChanges++; - } - } - - return stateChanges > 3; - } - - private EndpointDto MapToEndpointDto(Data.Endpoint endpoint) - { - return new EndpointDto - { - Id = endpoint.Id, - Name = endpoint.Name, - Group = new GroupDto - { - Id = endpoint.Group.Id, - Name = endpoint.Group.Name, - ParentId = endpoint.Group.ParentId, - Color = endpoint.Group.Color - }, - Type = endpoint.Type.ToString().ToLower(), - Host = endpoint.Host, - Port = endpoint.Port, - HttpPath = endpoint.HttpPath, - HttpMatch = endpoint.HttpMatch, - IntervalSeconds = endpoint.IntervalSeconds, - TimeoutMs = endpoint.TimeoutMs, - Retries = endpoint.Retries, - Enabled = endpoint.Enabled - }; - } - - private enum StatusType - { - Up, - Down, - Flapping - } } diff --git a/docs/icmp-fallback-and-outage-classification.md b/docs/icmp-fallback-and-outage-classification.md new file mode 100644 index 0000000..44d6b8c --- /dev/null +++ b/docs/icmp-fallback-and-outage-classification.md @@ -0,0 +1,175 @@ +# ๐Ÿ“ก ICMP Fallback โ€“ ThingConnect Pulse + +## ๐Ÿ“ Description + +ICMP fallback enhances outage classification accuracy by automatically performing ICMP ping tests when TCP or HTTP probes fail. +This enables **precise root cause analysis** by distinguishing between: + +- ๐ŸŸ  **Service Outage** โ†’ Service down, host still reachable +- ๐Ÿ”ด **Network Outage** โ†’ Host completely unreachable +- ๐ŸŸก **Mixed Outage** โ†’ Flapping or unstable network + +Without fallback, failed HTTP probes are ambiguous (could mean web service down *or* full network isolation). +With ICMP fallback, ThingConnect Pulse intelligently classifies failures for **better diagnostics and reduced false positives**. + +--- + +## ๐ŸŽฏ Scope of Work + +### Core Implementation +- Add automatic ICMP fallback logic on TCP/HTTP failures +- Extend `CheckResult` model with fallback probe results +- Implement outage classification system (Network, Service, Mixed, Unknown) +- Update outage detection to consider fallback outcomes + +### Probe Enhancement +- Modify `ProbeService` to run ICMP fallback after failed TCP/HTTP +- Configurable fallback timeout (default **500ms**) +- Respect concurrency limits & jitter scheduling +- Preserve original error messages + add fallback context + +### Database Schema +- Extend **`CheckResultRaw`** with fallback probe fields +- Add **`OutageClassification`** enum +- Update **`Outage`** table with classification results +- Maintain backwards compatibility + +### UI Integration +- Dashboard shows classification badges (๐Ÿ”ด, ๐ŸŸ , ๐ŸŸก) +- Endpoint details display fallback probe results +- History view filter by outage type +- CSV exports include `OutageType` + `FallbackResult` + +--- + +## โœ… Acceptance Criteria + +### Functional +- TCP/HTTP failures trigger ICMP fallback within **2s** +- ICMP fallback timeout = **500ms** (configurable) +- Correct outage classification applied: + - **Network Outage** โ†’ Primary + ICMP fail + - **Service Outage** โ†’ Primary fail, ICMP succeed + - **Mixed Outage** โ†’ Inconsistent results over multiple checks + - **Unknown** โ†’ Errors/timeout in fallback +- Jitter & concurrency respected +- Original error messages preserved + +### Performance +- <1s overhead on failed probes +- No extra cost on successful probes +- +20B memory per endpoint for fallback tracking +- ~30% DB storage increase for failed probe records + +### UX +- Dashboard shows color-coded outage types +- Endpoint details: "Last seen via ICMP" timestamps +- History charts distinguish outage types +- CSV exports include new fields + +--- + + +# ๐Ÿ”ฅ Outage Classification Decision Logic + +## ๐ŸŒ 1. Primary Classification (Immediate Fallback) + +### For TCP/HTTP Probe Failures +TCP/HTTP Probe Fails +โ””โ”€โ”€ Execute ICMP Fallback +โ”œโ”€โ”€ ICMP Succeeds โ†’ Service (2) +โ”œโ”€โ”€ ICMP Fails โ†’ Network (1) +โ””โ”€โ”€ ICMP Timeout/Error โ†’ Unknown (0) + +### For ICMP Probe Failures +ICMP Probe Fails +โ””โ”€โ”€ No fallback needed โ†’ Network (1) + +### For Successful Probes +Probe Succeeds +โ”œโ”€โ”€ RTT > Performance Threshold โ†’ Performance (4) +โ””โ”€โ”€ RTT Normal โ†’ No Classification + +--- + +## โš™๏ธ 2. Advanced Classification (Historical / Contextual Analysis) + +These rules are applied **after primary classification**, and may **override** the immediate result. + +### ๐ŸŸก Intermittent (3) +- **Trigger**: โ‰ฅ4 UP/DOWN transitions in 15 minutes +- **Logic**: Analyze `CheckResultRaw` history +- **Override**: Reclassify `Network`/`Service` โ†’ `Intermittent` + +--- + +### ๐ŸŸ  PartialService (5) *(HTTP only)* +- **Trigger**: HTTP 5xx errors + TCP succeeds +- **Logic**: Parse HTTP error codes from primary failure +- **Classification**: `PartialService` + +--- + +### ๐Ÿ”ต DnsResolution (6) +- **Trigger**: Hostname probe fails, IP probe succeeds +- **Logic**: Compare DNS resolution with fallback reachability +- **Classification**: `DnsResolution` + +--- + +### ๐ŸŸฃ Congestion (7) +- **Trigger**: RTT increase >50% across multiple endpoints simultaneously +- **Logic**: Cross-endpoint RTT correlation +- **Classification**: `Congestion` + +--- + +### ๐ŸŸข Maintenance (8) +- **Trigger**: Outage overlaps with maintenance window +- **Logic**: Check YAML `maintenance` schedule +- **Classification**: `Maintenance` + +--- + +## ๐Ÿ“Œ 3. Classification Precedence + +When multiple conditions apply, the following precedence is used: + +1. **Maintenance (8)** โ€“ Highest priority +2. **DnsResolution (6)** / **PartialService (5)** โ€“ Specific service-layer issues +3. **Intermittent (3)** โ€“ Overrides unstable host classifications +4. **Congestion (7)** โ€“ Correlated cross-host slowdown +5. **Primary Classification (0, 1, 2, 4)** โ€“ Default result + +--- + +## ๐Ÿ—บ๏ธ 4. Mermaid Flowchart + +```mermaid +flowchart TD + A[Probe Result] -->|TCP/HTTP Fail| B[Run ICMP Fallback] + A -->|ICMP Fail| N[Network (1)] + A -->|Success| S[Check RTT] + + B -->|ICMP Success| Svc[Service (2)] + B -->|ICMP Fail| Net[Network (1)] + B -->|ICMP Timeout/Error| Unk[Unknown (0)] + + S -->|RTT > Threshold| Perf[Performance (4)] + S -->|RTT Normal| NoClass[No Classification] + + %% Advanced Overrides + subgraph Advanced + I[Intermittent (3)] + P[PartialService (5)] + D[DnsResolution (6)] + C[Congestion (7)] + M[Maintenance (8)] + end + + Net --> I + Svc --> I + A --> P + A --> D + A --> C + A --> M \ No newline at end of file diff --git a/outage-probe-flow-analysis.md b/docs/outage-probe-flow-analysis.md similarity index 100% rename from outage-probe-flow-analysis.md rename to docs/outage-probe-flow-analysis.md diff --git a/thingconnect.pulse.client/obj/Debug/package.g.props b/thingconnect.pulse.client/obj/Debug/package.g.props index 24d3f29..3bd503f 100644 --- a/thingconnect.pulse.client/obj/Debug/package.g.props +++ b/thingconnect.pulse.client/obj/Debug/package.g.props @@ -11,17 +11,13 @@ vite preview prettier --write . cd .. && husky ./thingconnect.pulse.client/.husky + ^3.27.0 ^3.24.2 ^11.14.0 ^5.2.1 ^4.7.0 ^10.11.0 ^5.84.2 - ^3.12.0 - ^3.12.0 - ^3.12.0 - ^3.12.0 - ^3.12.0 ^1.11.0 ^4.1.0 ^12.23.14 @@ -37,6 +33,7 @@ ^7.62.0 ^5.5.0 ^7.8.1 + ^3.2.1 ^4.1.9 ^3.24.0 ^9.33.0 diff --git a/thingconnect.pulse.client/package-lock.json b/thingconnect.pulse.client/package-lock.json index 381f9b2..bb93024 100644 --- a/thingconnect.pulse.client/package-lock.json +++ b/thingconnect.pulse.client/package-lock.json @@ -8,17 +8,13 @@ "name": "thingconnect.pulse.client", "version": "0.1.0", "dependencies": { + "@chakra-ui/charts": "^3.27.0", "@chakra-ui/react": "^3.24.2", "@emotion/react": "^11.14.0", "@hookform/resolvers": "^5.2.1", "@monaco-editor/react": "^4.7.0", "@sentry/react": "^10.11.0", "@tanstack/react-query": "^5.84.2", - "@visx/axis": "^3.12.0", - "@visx/responsive": "^3.12.0", - "@visx/scale": "^3.12.0", - "@visx/shape": "^3.12.0", - "@visx/tooltip": "^3.12.0", "axios": "^1.11.0", "date-fns": "^4.1.0", "framer-motion": "^12.23.14", @@ -34,6 +30,7 @@ "react-hook-form": "^7.62.0", "react-icons": "^5.5.0", "react-router-dom": "^7.8.1", + "recharts": "^3.2.1", "zod": "^4.1.9" }, "devDependencies": { @@ -263,6 +260,18 @@ "node": ">=6.9.0" } }, + "node_modules/@chakra-ui/charts": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@chakra-ui/charts/-/charts-3.27.0.tgz", + "integrity": "sha512-nCn4TbbQZIbnr89ynETD4rrW3Rh+it+w55q3QUc76GbqMTfcs4I148UsP/nb5YURQ9WAHwmMXhzlW9T62JqSvw==", + "license": "MIT", + "peerDependencies": { + "@chakra-ui/react": ">=3", + "react": ">=18", + "react-dom": ">=18", + "recharts": ">=2" + } + }, "node_modules/@chakra-ui/cli": { "version": "3.25.0", "resolved": "https://registry.npmjs.org/@chakra-ui/cli/-/cli-3.25.0.tgz", @@ -1467,6 +1476,32 @@ "node": ">=14" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz", + "integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.35", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz", @@ -1830,6 +1865,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -2135,81 +2176,66 @@ "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==" }, "node_modules/@types/d3-array": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", - "integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, "node_modules/@types/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", - "license": "MIT" - }, - "node_modules/@types/d3-delaunay": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz", - "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, - "node_modules/@types/d3-format": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", - "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "license": "MIT" }, - "node_modules/@types/d3-geo": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", - "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", - "license": "MIT", - "dependencies": { - "@types/geojson": "*" - } - }, "node_modules/@types/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", "license": "MIT", "dependencies": { "@types/d3-color": "*" } }, "node_modules/@types/d3-path": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", - "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "license": "MIT" }, "node_modules/@types/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", "license": "MIT", "dependencies": { "@types/d3-time": "*" } }, "node_modules/@types/d3-shape": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", - "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", "license": "MIT", "dependencies": { - "@types/d3-path": "^1" + "@types/d3-path": "*" } }, "node_modules/@types/d3-time": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", - "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, - "node_modules/@types/d3-time-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz", - "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==", + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, "node_modules/@types/debug": { @@ -2227,12 +2253,6 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, - "node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2242,7 +2262,8 @@ "node_modules/@types/lodash": { "version": "4.17.20", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==" + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "dev": true }, "node_modules/@types/luxon": { "version": "3.7.1", @@ -2282,6 +2303,7 @@ "version": "19.1.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", + "devOptional": true, "dependencies": { "csstype": "^3.0.2" } @@ -2290,10 +2312,17 @@ "version": "19.1.9", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", + "dev": true, "peerDependencies": { "@types/react": "^19.0.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.44.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.0.tgz", @@ -2551,178 +2580,6 @@ "node": ">=20.18 <=24.x" } }, - "node_modules/@visx/axis": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/axis/-/axis-3.12.0.tgz", - "integrity": "sha512-8MoWpfuaJkhm2Yg+HwyytK8nk+zDugCqTT/tRmQX7gW4LYrHYLXFUXOzbDyyBakCVaUbUaAhVFxpMASJiQKf7A==", - "license": "MIT", - "dependencies": { - "@types/react": "*", - "@visx/group": "3.12.0", - "@visx/point": "3.12.0", - "@visx/scale": "3.12.0", - "@visx/shape": "3.12.0", - "@visx/text": "3.12.0", - "classnames": "^2.3.1", - "prop-types": "^15.6.0" - }, - "peerDependencies": { - "react": "^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0" - } - }, - "node_modules/@visx/bounds": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/bounds/-/bounds-3.12.0.tgz", - "integrity": "sha512-peAlNCUbYaaZ0IO6c1lDdEAnZv2iGPDiLIM8a6gu7CaMhtXZJkqrTh+AjidNcIqITktrICpGxJE/Qo9D099dvQ==", - "license": "MIT", - "dependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "prop-types": "^15.5.10" - }, - "peerDependencies": { - "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0", - "react-dom": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0" - } - }, - "node_modules/@visx/curve": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/curve/-/curve-3.12.0.tgz", - "integrity": "sha512-Ng1mefXIzoIoAivw7dJ+ZZYYUbfuwXgZCgQynShr6ZIVw7P4q4HeQfJP3W24ON+1uCSrzoycHSXRelhR9SBPcw==", - "license": "MIT", - "dependencies": { - "@types/d3-shape": "^1.3.1", - "d3-shape": "^1.0.6" - } - }, - "node_modules/@visx/group": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/group/-/group-3.12.0.tgz", - "integrity": "sha512-Dye8iS1alVXPv7nj/7M37gJe6sSKqJLH7x6sEWAsRQ9clI0kFvjbKcKgF+U3aAVQr0NCohheFV+DtR8trfK/Ag==", - "license": "MIT", - "dependencies": { - "@types/react": "*", - "classnames": "^2.3.1", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0" - } - }, - "node_modules/@visx/point": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/point/-/point-3.12.0.tgz", - "integrity": "sha512-I6UrHoJAEVbx3RORQNupgTiX5EzjuZpiwLPxn8L2mI5nfERotPKi1Yus12Cq2WtXqEBR/WgqTnoImlqOXBykcA==", - "license": "MIT" - }, - "node_modules/@visx/responsive": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-3.12.0.tgz", - "integrity": "sha512-GV1BTYwAGlk/K5c9vH8lS2syPnTuIqEacI7L6LRPbsuaLscXMNi+i9fZyzo0BWvAdtRV8v6Urzglo++lvAXT1Q==", - "license": "MIT", - "dependencies": { - "@types/lodash": "^4.14.172", - "@types/react": "*", - "lodash": "^4.17.21", - "prop-types": "^15.6.1" - }, - "peerDependencies": { - "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0" - } - }, - "node_modules/@visx/scale": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-3.12.0.tgz", - "integrity": "sha512-+ubijrZ2AwWCsNey0HGLJ0YKNeC/XImEFsr9rM+Uef1CM3PNM43NDdNTrdBejSlzRq0lcfQPWYMYQFSlkLcPOg==", - "license": "MIT", - "dependencies": { - "@visx/vendor": "3.12.0" - } - }, - "node_modules/@visx/shape": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/shape/-/shape-3.12.0.tgz", - "integrity": "sha512-/1l0lrpX9tPic6SJEalryBKWjP/ilDRnQA+BGJTI1tj7i23mJ/J0t4nJHyA1GrL4QA/bM/qTJ35eyz5dEhJc4g==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "^1.0.8", - "@types/d3-shape": "^1.3.1", - "@types/lodash": "^4.14.172", - "@types/react": "*", - "@visx/curve": "3.12.0", - "@visx/group": "3.12.0", - "@visx/scale": "3.12.0", - "classnames": "^2.3.1", - "d3-path": "^1.0.5", - "d3-shape": "^1.2.0", - "lodash": "^4.17.21", - "prop-types": "^15.5.10" - }, - "peerDependencies": { - "react": "^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0" - } - }, - "node_modules/@visx/text": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/text/-/text-3.12.0.tgz", - "integrity": "sha512-0rbDYQlbuKPhBqXkkGYKFec1gQo05YxV45DORzr6hCyaizdJk1G+n9VkuKSHKBy1vVQhBA0W3u/WXd7tiODQPA==", - "license": "MIT", - "dependencies": { - "@types/lodash": "^4.14.172", - "@types/react": "*", - "classnames": "^2.3.1", - "lodash": "^4.17.21", - "prop-types": "^15.7.2", - "reduce-css-calc": "^1.3.0" - }, - "peerDependencies": { - "react": "^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0" - } - }, - "node_modules/@visx/tooltip": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/tooltip/-/tooltip-3.12.0.tgz", - "integrity": "sha512-pWhsYhgl0Shbeqf80qy4QCB6zpq6tQtMQQxKlh3UiKxzkkfl+Metaf9p0/S0HexNi4vewOPOo89xWx93hBeh3A==", - "license": "MIT", - "dependencies": { - "@types/react": "*", - "@visx/bounds": "3.12.0", - "classnames": "^2.3.1", - "prop-types": "^15.5.10", - "react-use-measure": "^2.0.4" - }, - "peerDependencies": { - "react": "^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0", - "react-dom": "^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0" - } - }, - "node_modules/@visx/vendor": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-3.12.0.tgz", - "integrity": "sha512-SVO+G0xtnL9dsNpGDcjCgoiCnlB3iLSM9KLz1sLbSrV7RaVXwY3/BTm2X9OWN1jH2a9M+eHt6DJ6sE6CXm4cUg==", - "license": "MIT and ISC", - "dependencies": { - "@types/d3-array": "3.0.3", - "@types/d3-color": "3.1.0", - "@types/d3-delaunay": "6.0.1", - "@types/d3-format": "3.0.1", - "@types/d3-geo": "3.1.0", - "@types/d3-interpolate": "3.0.1", - "@types/d3-scale": "4.0.2", - "@types/d3-time": "3.0.0", - "@types/d3-time-format": "2.1.0", - "d3-array": "3.2.1", - "d3-color": "3.1.0", - "d3-delaunay": "6.0.2", - "d3-format": "3.1.0", - "d3-geo": "3.1.0", - "d3-interpolate": "3.0.1", - "d3-scale": "4.0.2", - "d3-time": "3.1.0", - "d3-time-format": "4.1.0", - "internmap": "2.0.3" - } - }, "node_modules/@vitejs/plugin-react-swc": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.1.0.tgz", @@ -3674,7 +3531,8 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true }, "node_modules/base64-arraybuffer": { "version": "1.0.2", @@ -3793,12 +3651,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" - }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -3842,6 +3694,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3963,9 +3824,9 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", "license": "ISC", "dependencies": { "internmap": "1 - 2" @@ -3983,14 +3844,11 @@ "node": ">=12" } }, - "node_modules/d3-delaunay": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz", - "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==", - "license": "ISC", - "dependencies": { - "delaunator": "5" - }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", "engines": { "node": ">=12" } @@ -4004,18 +3862,6 @@ "node": ">=12" } }, - "node_modules/d3-geo": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz", - "integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==", - "license": "ISC", - "dependencies": { - "d3-array": "2.5.0 - 3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -4029,10 +3875,13 @@ } }, "node_modules/d3-path": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", - "license": "BSD-3-Clause" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } }, "node_modules/d3-scale": { "version": "4.0.2", @@ -4051,12 +3900,15 @@ } }, "node_modules/d3-shape": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", - "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", - "license": "BSD-3-Clause", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", "dependencies": { - "d3-path": "1" + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" } }, "node_modules/d3-time": { @@ -4083,6 +3935,15 @@ "node": ">=12" } }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -4117,21 +3978,18 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "node_modules/delaunator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", - "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", - "license": "ISC", - "dependencies": { - "robust-predicates": "^3.0.2" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -4226,6 +4084,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.39.10", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz", + "integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", @@ -4598,8 +4466,7 @@ "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" }, "node_modules/fast-deep-equal": { "version": "3.1.3", @@ -5092,6 +4959,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", + "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5483,18 +5360,6 @@ "integrity": "sha512-nMoGWW2HurtuJf6XAL56FWTDCWLOTSsanrgwOyaR5Y4e3zfG5N/0cU5xWZSEU3tBxhQugRbV1xL9jb+ug7yZww==", "dev": true }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -5518,12 +5383,6 @@ "node": ">=12" } }, - "node_modules/math-expression-evaluator": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.4.0.tgz", - "integrity": "sha512-4vRUvPyxdO8cWULGTh9dZWL2tZK6LDBvj+OGHBER7poH9Qdt7kXEoj20wiz4lQUbUXQZFjPbe5mVDo9nutizCw==", - "license": "MIT" - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5774,6 +5633,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -6030,17 +5890,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/proxy-compare": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz", @@ -6136,6 +5985,29 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz", @@ -6172,21 +6044,6 @@ "react-dom": ">=18" } }, - "node_modules/react-use-measure": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", - "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.13", - "react-dom": ">=16.13" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -6199,32 +6056,54 @@ "node": ">=8.10.0" } }, - "node_modules/reduce-css-calc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz", - "integrity": "sha512-0dVfwYVOlf/LBA2ec4OwQ6p3X9mYxn/wOl2xTcLwjnPYrkgEfPx3VI4eGCH3rQLlPISG5v9I9bkZosKsNRTRKA==", + "node_modules/recharts": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.1.tgz", + "integrity": "sha512-0JKwHRiFZdmLq/6nmilxEZl3pqb4T+aKkOkOi/ZISRZwfBhVMgInxzlYU9D4KnCH3KINScLy68m/OvMXoYGZUw==", "license": "MIT", "dependencies": { - "balanced-match": "^0.4.2", - "math-expression-evaluator": "^1.2.14", - "reduce-function-call": "^1.0.1" + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/reduce-css-calc/node_modules/balanced-match": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz", - "integrity": "sha512-STw03mQKnGUYtoNjmowo4F2cRmIIxYEGiMsjjwla/u5P1lxadj/05WkNaFjNiKTgJkj8KiXbgAiRTmcQRwQNtg==", + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "license": "MIT" }, - "node_modules/reduce-function-call": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz", - "integrity": "sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ==", + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "peerDependencies": { + "redux": "^5.0.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -6284,12 +6163,6 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "dev": true }, - "node_modules/robust-predicates": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", - "license": "Unlicense" - }, "node_modules/rollup": { "version": "4.48.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.48.0.tgz", @@ -6692,6 +6565,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6915,6 +6794,37 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.6.tgz", @@ -7360,6 +7270,12 @@ "@babel/helper-validator-identifier": "^7.27.1" } }, + "@chakra-ui/charts": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@chakra-ui/charts/-/charts-3.27.0.tgz", + "integrity": "sha512-nCn4TbbQZIbnr89ynETD4rrW3Rh+it+w55q3QUc76GbqMTfcs4I148UsP/nb5YURQ9WAHwmMXhzlW9T62JqSvw==", + "requires": {} + }, "@chakra-ui/cli": { "version": "3.25.0", "resolved": "https://registry.npmjs.org/@chakra-ui/cli/-/cli-3.25.0.tgz", @@ -8176,6 +8092,19 @@ "dev": true, "optional": true }, + "@reduxjs/toolkit": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz", + "integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==", + "requires": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + } + }, "@rolldown/pluginutils": { "version": "1.0.0-beta.35", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz", @@ -8389,6 +8318,11 @@ "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", "dev": true }, + "@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" + }, "@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -8556,71 +8490,58 @@ "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==" }, "@types/d3-array": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", - "integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==" + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" }, "@types/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==" - }, - "@types/d3-delaunay": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz", - "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==" - }, - "@types/d3-format": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", - "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" }, - "@types/d3-geo": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", - "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", - "requires": { - "@types/geojson": "*" - } + "@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" }, "@types/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", "requires": { "@types/d3-color": "*" } }, "@types/d3-path": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", - "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==" + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" }, "@types/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", "requires": { "@types/d3-time": "*" } }, "@types/d3-shape": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", - "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", "requires": { - "@types/d3-path": "^1" + "@types/d3-path": "*" } }, "@types/d3-time": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", - "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==" + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" }, - "@types/d3-time-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz", - "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==" + "@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" }, "@types/debug": { "version": "4.1.12", @@ -8637,11 +8558,6 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, - "@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==" - }, "@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -8651,7 +8567,8 @@ "@types/lodash": { "version": "4.17.20", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==" + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "dev": true }, "@types/luxon": { "version": "3.7.1", @@ -8689,6 +8606,7 @@ "version": "19.1.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", + "devOptional": true, "requires": { "csstype": "^3.0.2" } @@ -8697,8 +8615,14 @@ "version": "19.1.9", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", + "dev": true, "requires": {} }, + "@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" + }, "@typescript-eslint/eslint-plugin": { "version": "8.44.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.0.tgz", @@ -8838,144 +8762,6 @@ "integrity": "sha512-eqm/OzwETl1Zd5ehW5CUXhYf8tqb+seBCkHBKXh1rEMS94n+OhyCY0KAlZv/17qPoN73WT2nGDN9SdYlvoWbTQ==", "dev": true }, - "@visx/axis": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/axis/-/axis-3.12.0.tgz", - "integrity": "sha512-8MoWpfuaJkhm2Yg+HwyytK8nk+zDugCqTT/tRmQX7gW4LYrHYLXFUXOzbDyyBakCVaUbUaAhVFxpMASJiQKf7A==", - "requires": { - "@types/react": "*", - "@visx/group": "3.12.0", - "@visx/point": "3.12.0", - "@visx/scale": "3.12.0", - "@visx/shape": "3.12.0", - "@visx/text": "3.12.0", - "classnames": "^2.3.1", - "prop-types": "^15.6.0" - } - }, - "@visx/bounds": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/bounds/-/bounds-3.12.0.tgz", - "integrity": "sha512-peAlNCUbYaaZ0IO6c1lDdEAnZv2iGPDiLIM8a6gu7CaMhtXZJkqrTh+AjidNcIqITktrICpGxJE/Qo9D099dvQ==", - "requires": { - "@types/react": "*", - "@types/react-dom": "*", - "prop-types": "^15.5.10" - } - }, - "@visx/curve": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/curve/-/curve-3.12.0.tgz", - "integrity": "sha512-Ng1mefXIzoIoAivw7dJ+ZZYYUbfuwXgZCgQynShr6ZIVw7P4q4HeQfJP3W24ON+1uCSrzoycHSXRelhR9SBPcw==", - "requires": { - "@types/d3-shape": "^1.3.1", - "d3-shape": "^1.0.6" - } - }, - "@visx/group": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/group/-/group-3.12.0.tgz", - "integrity": "sha512-Dye8iS1alVXPv7nj/7M37gJe6sSKqJLH7x6sEWAsRQ9clI0kFvjbKcKgF+U3aAVQr0NCohheFV+DtR8trfK/Ag==", - "requires": { - "@types/react": "*", - "classnames": "^2.3.1", - "prop-types": "^15.6.2" - } - }, - "@visx/point": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/point/-/point-3.12.0.tgz", - "integrity": "sha512-I6UrHoJAEVbx3RORQNupgTiX5EzjuZpiwLPxn8L2mI5nfERotPKi1Yus12Cq2WtXqEBR/WgqTnoImlqOXBykcA==" - }, - "@visx/responsive": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-3.12.0.tgz", - "integrity": "sha512-GV1BTYwAGlk/K5c9vH8lS2syPnTuIqEacI7L6LRPbsuaLscXMNi+i9fZyzo0BWvAdtRV8v6Urzglo++lvAXT1Q==", - "requires": { - "@types/lodash": "^4.14.172", - "@types/react": "*", - "lodash": "^4.17.21", - "prop-types": "^15.6.1" - } - }, - "@visx/scale": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-3.12.0.tgz", - "integrity": "sha512-+ubijrZ2AwWCsNey0HGLJ0YKNeC/XImEFsr9rM+Uef1CM3PNM43NDdNTrdBejSlzRq0lcfQPWYMYQFSlkLcPOg==", - "requires": { - "@visx/vendor": "3.12.0" - } - }, - "@visx/shape": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/shape/-/shape-3.12.0.tgz", - "integrity": "sha512-/1l0lrpX9tPic6SJEalryBKWjP/ilDRnQA+BGJTI1tj7i23mJ/J0t4nJHyA1GrL4QA/bM/qTJ35eyz5dEhJc4g==", - "requires": { - "@types/d3-path": "^1.0.8", - "@types/d3-shape": "^1.3.1", - "@types/lodash": "^4.14.172", - "@types/react": "*", - "@visx/curve": "3.12.0", - "@visx/group": "3.12.0", - "@visx/scale": "3.12.0", - "classnames": "^2.3.1", - "d3-path": "^1.0.5", - "d3-shape": "^1.2.0", - "lodash": "^4.17.21", - "prop-types": "^15.5.10" - } - }, - "@visx/text": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/text/-/text-3.12.0.tgz", - "integrity": "sha512-0rbDYQlbuKPhBqXkkGYKFec1gQo05YxV45DORzr6hCyaizdJk1G+n9VkuKSHKBy1vVQhBA0W3u/WXd7tiODQPA==", - "requires": { - "@types/lodash": "^4.14.172", - "@types/react": "*", - "classnames": "^2.3.1", - "lodash": "^4.17.21", - "prop-types": "^15.7.2", - "reduce-css-calc": "^1.3.0" - } - }, - "@visx/tooltip": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/tooltip/-/tooltip-3.12.0.tgz", - "integrity": "sha512-pWhsYhgl0Shbeqf80qy4QCB6zpq6tQtMQQxKlh3UiKxzkkfl+Metaf9p0/S0HexNi4vewOPOo89xWx93hBeh3A==", - "requires": { - "@types/react": "*", - "@visx/bounds": "3.12.0", - "classnames": "^2.3.1", - "prop-types": "^15.5.10", - "react-use-measure": "^2.0.4" - } - }, - "@visx/vendor": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-3.12.0.tgz", - "integrity": "sha512-SVO+G0xtnL9dsNpGDcjCgoiCnlB3iLSM9KLz1sLbSrV7RaVXwY3/BTm2X9OWN1jH2a9M+eHt6DJ6sE6CXm4cUg==", - "requires": { - "@types/d3-array": "3.0.3", - "@types/d3-color": "3.1.0", - "@types/d3-delaunay": "6.0.1", - "@types/d3-format": "3.0.1", - "@types/d3-geo": "3.1.0", - "@types/d3-interpolate": "3.0.1", - "@types/d3-scale": "4.0.2", - "@types/d3-time": "3.0.0", - "@types/d3-time-format": "2.1.0", - "d3-array": "3.2.1", - "d3-color": "3.1.0", - "d3-delaunay": "6.0.2", - "d3-format": "3.1.0", - "d3-geo": "3.1.0", - "d3-interpolate": "3.0.1", - "d3-scale": "4.0.2", - "d3-time": "3.1.0", - "d3-time-format": "4.1.0", - "internmap": "2.0.3" - } - }, "@vitejs/plugin-react-swc": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.1.0.tgz", @@ -9868,7 +9654,8 @@ "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true }, "base64-arraybuffer": { "version": "1.0.2", @@ -9955,11 +9742,6 @@ "readdirp": "~3.6.0" } }, - "classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" - }, "cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -9988,6 +9770,11 @@ "string-width": "^7.0.0" } }, + "clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" + }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -10087,9 +9874,9 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", "requires": { "internmap": "1 - 2" } @@ -10099,27 +9886,16 @@ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" }, - "d3-delaunay": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz", - "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==", - "requires": { - "delaunator": "5" - } + "d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" }, "d3-format": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==" }, - "d3-geo": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz", - "integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==", - "requires": { - "d3-array": "2.5.0 - 3" - } - }, "d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -10129,9 +9905,9 @@ } }, "d3-path": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==" }, "d3-scale": { "version": "4.0.2", @@ -10146,11 +9922,11 @@ } }, "d3-shape": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", - "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", "requires": { - "d3-path": "1" + "d3-path": "^3.1.0" } }, "d3-time": { @@ -10169,6 +9945,11 @@ "d3-time": "1 - 3" } }, + "d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" + }, "data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -10188,20 +9969,17 @@ "ms": "^2.1.3" } }, + "decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "delaunator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", - "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", - "requires": { - "robust-predicates": "^3.0.2" - } - }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -10272,6 +10050,11 @@ "hasown": "^2.0.2" } }, + "es-toolkit": { + "version": "1.39.10", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz", + "integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==" + }, "esbuild": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", @@ -10521,8 +10304,7 @@ "eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" }, "fast-deep-equal": { "version": "3.1.3", @@ -10849,6 +10631,11 @@ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true }, + "immer": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", + "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==" + }, "import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -11129,14 +10916,6 @@ "integrity": "sha512-nMoGWW2HurtuJf6XAL56FWTDCWLOTSsanrgwOyaR5Y4e3zfG5N/0cU5xWZSEU3tBxhQugRbV1xL9jb+ug7yZww==", "dev": true }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, "lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -11154,11 +10933,6 @@ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==" }, - "math-expression-evaluator": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.4.0.tgz", - "integrity": "sha512-4vRUvPyxdO8cWULGTh9dZWL2tZK6LDBvj+OGHBER7poH9Qdt7kXEoj20wiz4lQUbUXQZFjPbe5mVDo9nutizCw==" - }, "math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -11324,7 +11098,8 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true }, "onetime": { "version": "7.0.0", @@ -11486,16 +11261,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true }, - "prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "proxy-compare": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz", @@ -11556,6 +11321,15 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "requires": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + } + }, "react-router": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz", @@ -11573,12 +11347,6 @@ "react-router": "7.8.2" } }, - "react-use-measure": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", - "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", - "requires": {} - }, "readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -11588,30 +11356,39 @@ "picomatch": "^2.2.1" } }, - "reduce-css-calc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz", - "integrity": "sha512-0dVfwYVOlf/LBA2ec4OwQ6p3X9mYxn/wOl2xTcLwjnPYrkgEfPx3VI4eGCH3rQLlPISG5v9I9bkZosKsNRTRKA==", + "recharts": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.1.tgz", + "integrity": "sha512-0JKwHRiFZdmLq/6nmilxEZl3pqb4T+aKkOkOi/ZISRZwfBhVMgInxzlYU9D4KnCH3KINScLy68m/OvMXoYGZUw==", "requires": { - "balanced-match": "^0.4.2", - "math-expression-evaluator": "^1.2.14", - "reduce-function-call": "^1.0.1" - }, - "dependencies": { - "balanced-match": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz", - "integrity": "sha512-STw03mQKnGUYtoNjmowo4F2cRmIIxYEGiMsjjwla/u5P1lxadj/05WkNaFjNiKTgJkj8KiXbgAiRTmcQRwQNtg==" - } + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" } }, - "reduce-function-call": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz", - "integrity": "sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ==", - "requires": { - "balanced-match": "^1.0.0" - } + "redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, + "redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "requires": {} + }, + "reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" }, "resolve": { "version": "1.22.10", @@ -11650,11 +11427,6 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "dev": true }, - "robust-predicates": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==" - }, "rollup": { "version": "4.48.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.48.0.tgz", @@ -11937,6 +11709,11 @@ "thenify": ">= 3.1.0 < 4" } }, + "tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, "tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -12072,6 +11849,33 @@ "punycode": "^2.1.0" } }, + "use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "requires": {} + }, + "victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "requires": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "vite": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.6.tgz", diff --git a/thingconnect.pulse.client/package.json b/thingconnect.pulse.client/package.json index 5ae7b42..250acbe 100644 --- a/thingconnect.pulse.client/package.json +++ b/thingconnect.pulse.client/package.json @@ -12,17 +12,13 @@ "prepare": "cd .. && husky ./thingconnect.pulse.client/.husky" }, "dependencies": { + "@chakra-ui/charts": "^3.27.0", "@chakra-ui/react": "^3.24.2", "@emotion/react": "^11.14.0", "@hookform/resolvers": "^5.2.1", "@monaco-editor/react": "^4.7.0", "@sentry/react": "^10.11.0", "@tanstack/react-query": "^5.84.2", - "@visx/axis": "^3.12.0", - "@visx/responsive": "^3.12.0", - "@visx/scale": "^3.12.0", - "@visx/shape": "^3.12.0", - "@visx/tooltip": "^3.12.0", "axios": "^1.11.0", "date-fns": "^4.1.0", "framer-motion": "^12.23.14", @@ -38,6 +34,7 @@ "react-hook-form": "^7.62.0", "react-icons": "^5.5.0", "react-router-dom": "^7.8.1", + "recharts": "^3.2.1", "zod": "^4.1.9" }, "devDependencies": { diff --git a/thingconnect.pulse.client/src/api/services/endpoint.service.ts b/thingconnect.pulse.client/src/api/services/endpoint.service.ts index 77f921f..3d749ba 100644 --- a/thingconnect.pulse.client/src/api/services/endpoint.service.ts +++ b/thingconnect.pulse.client/src/api/services/endpoint.service.ts @@ -17,18 +17,10 @@ export class EndpointService { if (!endpointItem) { throw new Error(`Endpoint ${id} not found`); } - // Convert live status to endpoint detail format return { endpoint: endpointItem.endpoint, - recent: [ - { - ts: endpointItem.lastChangeTs, - status: endpointItem.status === 'flapping' ? 'down' : endpointItem.status, - rttMs: endpointItem.rttMs, - error: null, - }, - ], + recent: [], outages: [], }; } diff --git a/thingconnect.pulse.client/src/api/services/history.service.ts b/thingconnect.pulse.client/src/api/services/history.service.ts index db50fa7..795b3c8 100644 --- a/thingconnect.pulse.client/src/api/services/history.service.ts +++ b/thingconnect.pulse.client/src/api/services/history.service.ts @@ -73,14 +73,19 @@ export class HistoryService { // Determine which data to export based on bucket if (bucket === 'raw' && data.raw.length > 0) { - lines.push('Timestamp,Status,Response Time (ms),Error'); + lines.push( + 'Timestamp,Primary Status,Primary RTT (ms),Primary Error,Fallback Status,Fallback RTT (ms),Fallback Error' + ); data.raw.forEach(check => { lines.push( [ check.ts, - check.status, - check.rttMs || '', - check.error ? `"${check.error.replace(/"/g, '""')}"` : '', + check.primary.status, + check.primary.rttMs || '', + check.primary.error ? `"${check.primary.error.replace(/"/g, '""')}"` : '', + check.fallback.status, + check.fallback.rttMs || '', + check.fallback.error ? `"${check.fallback.error.replace(/"/g, '""')}"` : '', ].join(',') ); }); diff --git a/thingconnect.pulse.client/src/api/types.ts b/thingconnect.pulse.client/src/api/types.ts index 67b2dc7..4309b3e 100644 --- a/thingconnect.pulse.client/src/api/types.ts +++ b/thingconnect.pulse.client/src/api/types.ts @@ -30,8 +30,7 @@ export interface SparklinePoint { export interface LiveStatusItem { endpoint: Endpoint; - status: 'up' | 'down' | 'flapping'; - rttMs?: number | null; + currentState: CurrentState; lastChangeTs: string; sparkline: SparklinePoint[]; } @@ -82,18 +81,57 @@ export interface StateChange { error?: string; } -export interface RawCheck { - ts: string; +export type Classification = + | -1 // None + | 0 // Unknown + | 1 // Network + | 2 // Service + | 3 // Intermittent + | 4 // Performance + | 5 // PartialService + | 6 // DnsResolution + | 7 // Congestion + | 8; // Maintenance + +export interface PrimaryResult { + type: string; // "icmp" | "tcp" | "http" + target: string; // hostname or IP status: 'up' | 'down'; rttMs?: number | null; error?: string | null; } +export interface FallbackResult { + attempted: boolean; + type?: 'icmp'| null; + target?: string | null; + status?: 'up' | 'down' | null; + rttMs?: number | null; + error?: string | null; +} + +export interface CurrentState { + type: 'icmp' | 'tcp' | 'http'; + target: string; + status: 'up' | 'down' | 'flapping' | 'service'; + rttMs?: number | null; + classification?: Classification | null; +} + +export interface RawCheck { + ts: string; + classification: Classification; + primary: PrimaryResult; + fallback: FallbackResult; + currentState: CurrentState; +} + export interface Outage { startedTs: string; endedTs?: string | null; durationS?: number | null; lastError?: string | null; + classification: Classification; } export interface EndpointDetail { diff --git a/thingconnect.pulse.client/src/components/AvailabilityChart.tsx b/thingconnect.pulse.client/src/components/AvailabilityChart.tsx index f57cdb0..2a1d75c 100644 --- a/thingconnect.pulse.client/src/components/AvailabilityChart.tsx +++ b/thingconnect.pulse.client/src/components/AvailabilityChart.tsx @@ -1,9 +1,7 @@ import { useMemo } from 'react'; import { Box, Text, VStack, Skeleton } from '@chakra-ui/react'; -import { ParentSize } from '@visx/responsive'; -import { Group } from '@visx/group'; -import { AxisLeft, AxisBottom } from '@visx/axis'; -import { scaleBand, scaleLinear } from '@visx/scale'; +import { Chart, useChart } from '@chakra-ui/charts'; +import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts'; import type { HistoryResponse } from '@/api/types'; import type { BucketType } from '@/types/bucket'; import { CloudOff } from 'lucide-react'; @@ -18,37 +16,42 @@ export interface AvailabilityChartProps { export function AvailabilityChart({ data, bucket, isLoading }: AvailabilityChartProps) { const chartData = useMemo(() => { - if (!data) return null; + if (!data) return []; switch (bucket) { case 'raw': return data.raw.map(check => ({ - xaxis: new Date(check.ts).toLocaleTimeString('en-US', { + label: new Date(check.ts).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', }), - yaxis: check.status === 'up' ? 100 : 0, + uptime: check.currentState.status === 'up' ? 100 : 0, })); case '15m': return data.rollup15m.map(bucket => ({ - xaxis: new Date(bucket.bucketTs).toLocaleTimeString('en-US', { + label: new Date(bucket.bucketTs).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', }), - yaxis: bucket.upPct, + uptime: bucket.upPct, })); case 'daily': return data.rollupDaily.map(bucket => ({ - xaxis: new Date(bucket.bucketDate).toLocaleDateString('en-US', { + label: new Date(bucket.bucketDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', }), - yaxis: bucket.upPct, + uptime: bucket.upPct, })); default: return []; } }, [data, bucket]); + const chart = useChart({ + data: chartData, + series: [{ name: 'uptime', color: 'blue.500' }], + }); + if (chartData?.length === 0) { return ( - - {({ width, height }) => { - const xMax = width - margin.left - margin.right; - const yMax = height - margin.top - margin.bottom; - - const xScale = scaleBand({ - range: [0, xMax], - domain: chartData?.map(d => d.xaxis) ?? [], - padding: 0.2, - }); - - const yScale = scaleLinear({ - range: [yMax, 0], - domain: [0, 100], - }); - - return ( - - - {chartData?.map((d, i) => { - const barWidth = xScale.bandwidth(); - const barHeight = yMax - (yScale(d.yaxis) ?? 0); - const x = xScale(d.xaxis) ?? 0; - const y = yMax - barHeight; - return ( - - ); - })} - - `${d}%`} - stroke='#718096' - tickStroke='transparent' - tickLabelProps={{ - fill: '#718096', - textAnchor: 'end', - dx: -4, - style: { - fontSize: '12px', - }, - }} - /> - - {/* Y-axis label */} - - Uptime % - - - ''} - tickLabelProps={{ - fill: '#718096', - textAnchor: 'middle', - style: { - fontSize: '12px', - }, - }} - /> - - - ); - }} - - + + + + + `${v}%`} + /> + { + if (active && payload && payload.length) { + const uptime = payload[0].payload.uptime; + return ( + + {`Time: ${label}`} + {`Uptime: ${uptime.toFixed(3)}%`} + + ); + } + return null; + }} + /> + {chart.series.map(s => ( + + ))} + + + ); } diff --git a/thingconnect.pulse.client/src/components/OutageList.tsx b/thingconnect.pulse.client/src/components/OutageList.tsx index cbdd921..cccd760 100644 --- a/thingconnect.pulse.client/src/components/OutageList.tsx +++ b/thingconnect.pulse.client/src/components/OutageList.tsx @@ -30,6 +30,9 @@ export function OutagesList({ outages, isLoading }: OutagesListProps) { gap={1} py={5} h='100%' + bg='gray.50' + _dark={{ bg: 'gray.800' }} + borderRadius='md' > diff --git a/thingconnect.pulse.client/src/components/RecentChecksTable.tsx b/thingconnect.pulse.client/src/components/RecentChecksTable.tsx index 4e78efa..13d9397 100644 --- a/thingconnect.pulse.client/src/components/RecentChecksTable.tsx +++ b/thingconnect.pulse.client/src/components/RecentChecksTable.tsx @@ -36,6 +36,9 @@ export function RecentChecksTable({ checks, pageSize = 10 }: RecentChecksTablePr gap={1} py={5} h='100%' + bg='gray.50' + _dark={{ bg: 'gray.800' }} + borderRadius='md' > @@ -51,35 +54,85 @@ export function RecentChecksTable({ checks, pageSize = 10 }: RecentChecksTablePr - Time - Status - RTT - Error + Time + Primary Status + Primary RTT (ms) + Primary Error + Fallback Status + Fallback RTT (ms) + Fallback Error + - {pagedChecks.map((check, index) => ( - + {pagedChecks.map((check, idx) => ( + {formatDistanceToNow(new Date(check.ts), { addSuffix: true })} - - {check.status.toUpperCase()} + + {check.primary.status.toUpperCase()} - {check.rttMs ? `${check.rttMs}ms` : '-'} + + {check.primary.rttMs ? `${check.primary.rttMs}ms` : '-'} + - - - - {check.error || '-'} + + + + {check.primary.error || '-'} + + {check.fallback.attempted ? ( + + {check.fallback.status?.toUpperCase() ?? '-'} + + ) : ( + + Not attempted + + )} + + + + {check.fallback.attempted && check.fallback.rttMs != null + ? `${check.fallback.rttMs}ms` + : '-'} + + + + {check.fallback.attempted ? ( + + + {check.fallback.error || '-'} + + + ) : ( + + Not attempted + + )} + ))} diff --git a/thingconnect.pulse.client/src/components/history/AvailabilityStats.tsx b/thingconnect.pulse.client/src/components/history/AvailabilityStats.tsx index 5e27c3a..916dbb7 100644 --- a/thingconnect.pulse.client/src/components/history/AvailabilityStats.tsx +++ b/thingconnect.pulse.client/src/components/history/AvailabilityStats.tsx @@ -35,9 +35,12 @@ export function AvailabilityStats({ switch (bucket) { case 'raw': { totalPoints = data.raw.length; - upPoints = data.raw.filter(check => check.status === 'up').length; - const validRttChecks = data.raw.filter(check => check.rttMs != null); - totalResponseTime = validRttChecks.reduce((sum, check) => sum + (check.rttMs || 0), 0); + upPoints = data.raw.filter(check => check.currentState.status === 'up').length; + const validRttChecks = data.raw.filter(check => check.currentState.rttMs != null); + totalResponseTime = validRttChecks.reduce( + (sum, check) => sum + (check.currentState.rttMs || 0), + 0 + ); responseTimeCount = validRttChecks.length; break; } diff --git a/thingconnect.pulse.client/src/components/history/HistoryTable.tsx b/thingconnect.pulse.client/src/components/history/HistoryTable.tsx index 2371b67..1789729 100644 --- a/thingconnect.pulse.client/src/components/history/HistoryTable.tsx +++ b/thingconnect.pulse.client/src/components/history/HistoryTable.tsx @@ -41,9 +41,8 @@ export function HistoryTable({ data, bucket, pageSize = 20, isLoading }: History .map(check => ({ timestamp: check.ts, displayTime: new Date(check.ts).toLocaleString(), - status: check.status, - responseTime: check.rttMs, - error: check.error, + primary: check.primary, + fallback: check.fallback, type: 'raw' as const, })) .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); @@ -88,7 +87,7 @@ export function HistoryTable({ data, bucket, pageSize = 20, isLoading }: History return tableData.slice(startIndex, startIndex + pageSize); }, [tableData, currentPage, pageSize]); - const getStatusBadge = (status?: string) => { + const getStatusBadge = (status?: string | null) => { if (!status) return null; const config = { up: { color: 'green', icon: CheckCircle }, @@ -150,9 +149,12 @@ export function HistoryTable({ data, bucket, pageSize = 20, isLoading }: History Timestamp {bucket === 'raw' ? ( <> - Status - Response Time - Error + Primary Status + Primary RTT + Primary Error + Fallback Status + Fallback RTT + Fallback Error ) : ( <> @@ -168,7 +170,7 @@ export function HistoryTable({ data, bucket, pageSize = 20, isLoading }: History {isLoading ? Array.from({ length: 8 }).map((_, i) => ( - {Array.from({ length: bucket === 'raw' ? 4 : 4 }).map((_, j) => ( + {Array.from({ length: bucket === 'raw' ? 7 : 4 }).map((_, j) => ( @@ -185,23 +187,43 @@ export function HistoryTable({ data, bucket, pageSize = 20, isLoading }: History {row.type === 'raw' ? ( <> - {getStatusBadge(row.status)} + {getStatusBadge(row.primary?.status)} - {formatResponseTime(row.responseTime)} + {formatResponseTime(row.primary?.rttMs)} + + + + + + {row.primary?.error || '-'} + + + + {getStatusBadge(row.fallback?.status)} + + + {formatResponseTime(row.fallback?.rttMs)} - + - {row.error || '-'} + {row.fallback?.error || '-'} diff --git a/thingconnect.pulse.client/src/components/status/StatusAccordion.tsx b/thingconnect.pulse.client/src/components/status/StatusAccordion.tsx index a2036d1..3cdb9c5 100644 --- a/thingconnect.pulse.client/src/components/status/StatusAccordion.tsx +++ b/thingconnect.pulse.client/src/components/status/StatusAccordion.tsx @@ -3,11 +3,18 @@ import type { LiveStatusItem } from '@/api/types'; import { StatusTable } from './StatusTable'; type Props = { - groupedEndpoints: Record<'up' | 'down' | 'flapping', LiveStatusItem[]>; + groupedEndpoints: Record<'up' | 'down' | 'flapping' | 'service', LiveStatusItem[]>; isLoading: boolean; }; export function StatusAccordion({ groupedEndpoints, isLoading }: Props) { + const statusColorMap: Record<'up' | 'down' | 'flapping' | 'service', string> = { + up: 'green', + down: 'red', + flapping: 'yellow', + service: 'orange', + } as const; + return ( {Object.entries(groupedEndpoints).map(([status, items]) => { @@ -16,21 +23,15 @@ export function StatusAccordion({ groupedEndpoints, isLoading }: Props) { ? typedItems : Object.values(typedItems).flat(); - const statusColorMap: Record<'up' | 'down' | 'flapping', string> = { - up: 'green', - down: 'red', - flapping: 'yellow', - } as const; - return ( @@ -38,11 +39,11 @@ export function StatusAccordion({ groupedEndpoints, isLoading }: Props) { {status} @@ -62,11 +63,11 @@ export function StatusAccordion({ groupedEndpoints, isLoading }: Props) { {itemsArray?.length - ? itemsArray?.length > 1 - ? `${itemsArray?.length} Endpoints` + ? itemsArray.length > 1 + ? `${itemsArray.length} Endpoints` : '1 Endpoint' : 'No Endpoints'} diff --git a/thingconnect.pulse.client/src/components/status/StatusCard.tsx b/thingconnect.pulse.client/src/components/status/StatusCard.tsx index 210df71..53eb350 100644 --- a/thingconnect.pulse.client/src/components/status/StatusCard.tsx +++ b/thingconnect.pulse.client/src/components/status/StatusCard.tsx @@ -19,6 +19,8 @@ export function StatusCard({ item }: StatusCardProps) { return 'red'; case 'flapping': return 'yellow'; + case 'service': + return 'yellow'; default: return 'gray'; } @@ -77,15 +79,15 @@ export function StatusCard({ item }: StatusCardProps) { - {item.status} + {item.currentState.status} @@ -105,10 +107,10 @@ export function StatusCard({ item }: StatusCardProps) { - {formatRTT(item.rttMs)} + {formatRTT(item.currentState.rttMs)} diff --git a/thingconnect.pulse.client/src/components/status/StatusGroupAccordion.tsx b/thingconnect.pulse.client/src/components/status/StatusGroupAccordion.tsx index dac70d2..ab220f3 100644 --- a/thingconnect.pulse.client/src/components/status/StatusGroupAccordion.tsx +++ b/thingconnect.pulse.client/src/components/status/StatusGroupAccordion.tsx @@ -3,11 +3,21 @@ import type { LiveStatusItem } from '@/api/types'; import { StatusTable } from './StatusTable'; type Props = { - groupedEndpoints: Record<'up' | 'down' | 'flapping', Record>; + groupedEndpoints: Record< + 'up' | 'down' | 'flapping' | 'service', + Record + >; isLoading: boolean; }; export function StatusGroupAccordion({ groupedEndpoints, isLoading }: Props) { + const statusColorMap: Record<'up' | 'down' | 'flapping' | 'service', string> = { + up: 'green', + down: 'red', + flapping: 'yellow', + service: 'orange', + } as const; + return ( {Object.entries(groupedEndpoints).map(([status, groupItems]) => { @@ -15,11 +25,6 @@ export function StatusGroupAccordion({ groupedEndpoints, isLoading }: Props) { (sum, group) => sum + (group?.length || 0), 0 ); - const statusColorMap: Record<'up' | 'down' | 'flapping', string> = { - up: 'green', - down: 'red', - flapping: 'yellow', - } as const; // Narrow the type for TypeScript const typedGroupItems = groupItems || {}; @@ -27,12 +32,12 @@ export function StatusGroupAccordion({ groupedEndpoints, isLoading }: Props) { return ( @@ -40,15 +45,15 @@ export function StatusGroupAccordion({ groupedEndpoints, isLoading }: Props) { {totalEndpoints ? `${totalEndpoints} Endpoints` : 'No Endpoints'} diff --git a/thingconnect.pulse.client/src/components/status/StatusTable.tsx b/thingconnect.pulse.client/src/components/status/StatusTable.tsx index a1e9303..777977e 100644 --- a/thingconnect.pulse.client/src/components/status/StatusTable.tsx +++ b/thingconnect.pulse.client/src/components/status/StatusTable.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'; import { useAnalytics } from '@/hooks/useAnalytics'; import type { LiveStatusItem } from '@/api/types'; import TrendBlocks from './TrendBlocks'; +import { Tooltip } from '../ui/tooltip'; interface StatusTableProps { items: LiveStatusItem[] | null | undefined; @@ -22,6 +23,8 @@ export function StatusTable({ items, isLoading }: StatusTableProps) { case 'down': return 'red'; case 'flapping': + return 'orange'; + case 'service': return 'yellow'; default: return 'gray'; @@ -116,16 +119,35 @@ export function StatusTable({ items, isLoading }: StatusTableProps) { onClick={() => handleRowClick(item.endpoint.id)} > - - {item.status.toUpperCase()} - + {item.currentState.status === 'service' ? ( + + + {item.currentState.status.toUpperCase()} + + + ) : ( + + {item.currentState.status.toUpperCase()} + + )} {item.endpoint.name} @@ -145,10 +167,10 @@ export function StatusTable({ items, isLoading }: StatusTableProps) { - {formatRTT(item.rttMs)} + {formatRTT(item.currentState.rttMs)} diff --git a/thingconnect.pulse.client/src/components/status/SystemOverviewStats.tsx b/thingconnect.pulse.client/src/components/status/SystemOverviewStats.tsx index 4f67781..530f389 100644 --- a/thingconnect.pulse.client/src/components/status/SystemOverviewStats.tsx +++ b/thingconnect.pulse.client/src/components/status/SystemOverviewStats.tsx @@ -19,6 +19,7 @@ type SystemOverviewStatsProps = { up: number; down: number; flapping: number; + service: number; }; }; @@ -68,10 +69,21 @@ export function SystemOverviewStats({ statusCounts }: SystemOverviewStatsProps) darkColor: 'yellow.200', darkBg: 'yellow.800', }, + { + icon: Activity, + title: 'SERVICE', + subtitle: 'TCP/HTTP down, ICMP reachable', + value: statusCounts.service, + textColor: 'purple.500', + color: 'purple.600', + bg: 'purple.100', + darkColor: 'purple.200', + darkBg: 'purple.800', + }, ]; return ( - + {stats.map(stat => ( diff --git a/thingconnect.pulse.client/src/icons/Discord.tsx b/thingconnect.pulse.client/src/icons/Discord.tsx new file mode 100644 index 0000000..abe2bc6 --- /dev/null +++ b/thingconnect.pulse.client/src/icons/Discord.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +export function Discord(props: React.SVGProps) { + return ( + + Discord + + + ); +} diff --git a/thingconnect.pulse.client/src/pages/About.tsx b/thingconnect.pulse.client/src/pages/About.tsx index 9ad68fc..af09136 100644 --- a/thingconnect.pulse.client/src/pages/About.tsx +++ b/thingconnect.pulse.client/src/pages/About.tsx @@ -30,6 +30,7 @@ import { import { PageHeader } from '@/components/layout/PageHeader'; import { useForceRefreshNotifications, useNotificationStats } from '@/hooks/useNotifications'; import thingConnectLogo from '@/assets/thingconnect-pulse-logo.svg'; +import { Discord } from '@/icons/Discord'; export default function About() { const { data: stats } = useNotificationStats(); @@ -122,7 +123,7 @@ export default function About() { {[ { - icon: MessageCircle, + icon: Discord, title: 'Discord', desc: 'Community support and real-time help', tags: ['Community Support', 'Q&A', 'General Chat', 'Networking'], @@ -133,7 +134,7 @@ export default function About() { title: 'Reddit', desc: 'Share questions and experiences', tags: ['Discussions', 'Tips', 'Troubleshooting'], - link: 'https://reddit.com', + link: 'https://www.reddit.com/r/thingconnectio/', }, { icon: Linkedin, @@ -340,7 +341,10 @@ export default function About() { Unread Count: - + {stats?.unreadNotifications || 0} @@ -350,7 +354,9 @@ export default function About() { Last Sync: - {stats?.lastFetch ? new Date(stats.lastFetch).toLocaleDateString() : 'Never'} + {stats?.lastFetch + ? new Date(stats.lastFetch).toLocaleDateString() + : 'Never'} @@ -389,7 +395,8 @@ export default function About() { - Notifications are automatically synced every 6 hours. Use the button below to trigger an immediate refresh. + Notifications are automatically synced every 6 hours. Use the button below to + trigger an immediate refresh. @@ -405,18 +412,33 @@ export default function About() { {refreshMutation.isSuccess && ( - + Notifications refreshed successfully! )} {refreshMutation.isError && ( - + Failed to refresh notifications. Please try again. )} - + Syncs from: thingconnect-pulse.s3.ap-south-1.amazonaws.com diff --git a/thingconnect.pulse.client/src/pages/Dashboard.tsx b/thingconnect.pulse.client/src/pages/Dashboard.tsx index c1d3950..dcb16e3 100644 --- a/thingconnect.pulse.client/src/pages/Dashboard.tsx +++ b/thingconnect.pulse.client/src/pages/Dashboard.tsx @@ -53,10 +53,10 @@ export default function Dashboard() { const statusCounts = data.items.reduce( (acc, item) => { acc.total++; - acc[item.status]++; + acc[item.currentState.status]++; return acc; }, - { total: 0, up: 0, down: 0, flapping: 0 } + { total: 0, up: 0, down: 0, flapping: 0, service: 0 } ); analytics.trackSystemMetrics({ @@ -99,17 +99,26 @@ export default function Dashboard() { if (isGroupByStatus && isGroupByGroup) { // Status โ†’ Group โ†’ Endpoints - const statusBuckets: Record<'up' | 'down' | 'flapping', Record> = { + const statusBuckets: Record< + 'up' | 'down' | 'flapping' | 'service', + Record + > = { up: {}, down: {}, flapping: {}, + service: {}, }; // Get unique groups from all endpoints const uniqueGroups = new Set(filteredItems.map(item => item.endpoint.group.name)); // Prepare status buckets with all groups, even if empty - const defaultStatuses: Array<'up' | 'down' | 'flapping'> = ['up', 'down', 'flapping']; + const defaultStatuses: Array<'up' | 'down' | 'flapping' | 'service'> = [ + 'up', + 'down', + 'flapping', + 'service', + ]; defaultStatuses.forEach(status => { statusBuckets[status] = {}; uniqueGroups.forEach(group => { @@ -119,7 +128,7 @@ export default function Dashboard() { // Populate the status buckets filteredItems.forEach(item => { - const status = item.status; + const status = item.currentState.status; const group = item.endpoint.group.name; statusBuckets[status][group].push(item); @@ -145,18 +154,24 @@ export default function Dashboard() { finalResult = groupBuckets; } else if (isGroupByStatus) { // Status โ†’ Endpoints - const statusBuckets: Record<'up' | 'down' | 'flapping', LiveStatusItem[]> = { + const statusBuckets: Record<'up' | 'down' | 'flapping' | 'service', LiveStatusItem[]> = { up: [], down: [], flapping: [], + service: [], }; filteredItems.forEach(item => { - statusBuckets[item.status].push(item); + statusBuckets[item.currentState.status].push(item); }); // Always include all statuses, even if empty - const defaultStatuses: Array<'up' | 'down' | 'flapping'> = ['up', 'down', 'flapping']; + const defaultStatuses: Array<'up' | 'down' | 'flapping' | 'service'> = [ + 'up', + 'down', + 'flapping', + 'service', + ]; defaultStatuses.forEach(status => { finalResult[status] = statusBuckets[status]; }); @@ -183,15 +198,15 @@ export default function Dashboard() { // Count status totals const statusCounts = useMemo(() => { - if (!data?.items) return { total: 0, up: 0, down: 0, flapping: 0 }; + if (!data?.items) return { total: 0, up: 0, down: 0, flapping: 0, service: 0 }; const counts = data.items.reduce( (acc, item) => { acc.total++; - acc[item.status]++; + acc[item.currentState.status]++; return acc; }, - { total: 0, up: 0, down: 0, flapping: 0 } + { total: 0, up: 0, down: 0, flapping: 0, service: 0 } ); return counts; diff --git a/thingconnect.pulse.client/src/pages/EndpointDetail.tsx b/thingconnect.pulse.client/src/pages/EndpointDetail.tsx index ab76fbf..199d8fa 100644 --- a/thingconnect.pulse.client/src/pages/EndpointDetail.tsx +++ b/thingconnect.pulse.client/src/pages/EndpointDetail.tsx @@ -38,6 +38,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { EmptyState } from '@/components/ui/empty-state'; import { RecentChecksTable } from '@/components/RecentChecksTable'; import { OutagesList } from '@/components/OutageList'; +import { PageSection } from '@/components/layout/PageSection'; function getStatusColor(status: string) { switch (status.toLowerCase()) { @@ -46,6 +47,8 @@ function getStatusColor(status: string) { case 'down': return 'red'; case 'flapping': + return 'yellow'; + case 'service': return 'orange'; default: return 'gray'; @@ -60,6 +63,8 @@ function getStatusIcon(status: string) { return ArrowDown; case 'flapping': return AlertTriangle; + case 'service': + return Activity; default: return Circle; } @@ -107,7 +112,7 @@ export default function EndpointDetail() { const backButton = ( - @@ -169,11 +174,13 @@ export default function EndpointDetail() { const { endpoint, recent, outages } = endpointDetail; // Calculate uptime percentage from recent checks - const upChecks = recent.filter(check => check.status === 'up').length; + const upChecks = recent.filter(check => check.currentState.status === 'up').length; const uptimePercentage = recent.length > 0 ? Math.round((upChecks / recent.length) * 100) : 0; // Calculate average RTT - const rttValues = recent.filter(check => check.rttMs != null).map(check => check.rttMs!); + const rttValues = recent + .filter(check => check.currentState.rttMs != null) + .map(check => check.currentState.rttMs!); const avgRtt = rttValues.length > 0 ? Math.round(rttValues.reduce((sum, rtt) => sum + rtt, 0) / rttValues.length) @@ -181,7 +188,8 @@ export default function EndpointDetail() { // Get current status from most recent check const latestCheck = recent.length > 0 ? recent[0] : null; - const currentStatus = latestCheck?.status || 'unknown'; + const currentStatus = latestCheck?.currentState.status || 'unknown'; + console.log(latestCheck?.currentState.rttMs); const stats = [ { @@ -217,7 +225,7 @@ export default function EndpointDetail() { value: latestCheck ? formatDistanceToNow(new Date(latestCheck.ts), { addSuffix: true }) : 'N/A', - help: latestCheck ? (latestCheck.rttMs ? `${latestCheck.rttMs}ms` : 'Failed') : '', + help: latestCheck ? `${latestCheck?.currentState.rttMs} ms` : '', icon: CircleCheckBig, color: 'purple', bg: 'purple', @@ -339,8 +347,7 @@ export default function EndpointDetail() { {/* Statistics */} - - Recent Performance + {stats.map(stat => ( @@ -367,29 +374,30 @@ export default function EndpointDetail() { ))} - + + {/* Recent Checks and Outages */} {/* Recent Checks */} - + Recent Checks - + {/* Recent Outages */} - + Recent Outages - + diff --git a/thingconnect.pulse.client/src/pages/History.tsx b/thingconnect.pulse.client/src/pages/History.tsx index c01e673..bd289e9 100644 --- a/thingconnect.pulse.client/src/pages/History.tsx +++ b/thingconnect.pulse.client/src/pages/History.tsx @@ -173,7 +173,7 @@ export default function History() { {/* History Data */} - + item.status === 'up').length; + const upEndpoints = statusItems.filter(item => item.currentState.status === 'up').length; // Count down and flapping endpoints for potential future use // const downEndpoints = statusItems.filter(item => item.status === 'down').length; // const flappingEndpoints = statusItems.filter(item => item.status === 'flapping').length; @@ -59,11 +59,12 @@ export function calculateManufacturingKPIs( ).size; // Identify critical endpoints (assumed to be those with custom names or specific groups) - const criticalEndpoints = statusItems.filter(item => - item.endpoint.group?.name?.toLowerCase().includes('critical') || - item.endpoint.name?.toLowerCase().includes('critical') || - item.endpoint.host.includes('prod') || - item.endpoint.host.includes('main') + const criticalEndpoints = statusItems.filter( + item => + item.endpoint.group?.name?.toLowerCase().includes('critical') || + item.endpoint.name?.toLowerCase().includes('critical') || + item.endpoint.host.includes('prod') || + item.endpoint.host.includes('main') ).length; return { @@ -74,7 +75,7 @@ export function calculateManufacturingKPIs( networkSegments, criticalEndpoints, availabilityScore: totalEndpoints > 0 ? (upEndpoints / totalEndpoints) * 100 : 0, - alertResponseTime: calculateAverageAlertResponseTime(historicalData) + alertResponseTime: calculateAverageAlertResponseTime(historicalData), }; } @@ -83,7 +84,7 @@ export function calculateManufacturingKPIs( */ export function analyzeConfigurationComplexity(configData: any): ConfigurationComplexity { const config = typeof configData === 'string' ? parseYAMLSafely(configData) : configData; - + if (!config || typeof config !== 'object') { return { totalRules: 0, @@ -91,7 +92,7 @@ export function analyzeConfigurationComplexity(configData: any): ConfigurationCo customIntervals: 0, advancedFeatures: [], configSizeKb: 0, - validationErrors: 0 + validationErrors: 0, }; } @@ -99,13 +100,13 @@ export function analyzeConfigurationComplexity(configData: any): ConfigurationCo const probeTypes = new Set(); const customIntervals = new Set(); const advancedFeatures: string[] = []; - + endpoints.forEach((endpoint: any) => { if (endpoint.type) probeTypes.add(endpoint.type); if (endpoint.interval && endpoint.interval !== 30) { customIntervals.add(endpoint.interval); } - + // Detect advanced features if (endpoint.authentication) advancedFeatures.push('authentication'); if (endpoint.headers) advancedFeatures.push('custom_headers'); @@ -114,9 +115,10 @@ export function analyzeConfigurationComplexity(configData: any): ConfigurationCo if (endpoint.ssl_verify === false) advancedFeatures.push('ssl_bypass'); }); - const configSize = typeof configData === 'string' ? - new Blob([configData]).size / 1024 : - JSON.stringify(config).length / 1024; + const configSize = + typeof configData === 'string' + ? new Blob([configData]).size / 1024 + : JSON.stringify(config).length / 1024; return { totalRules: endpoints.length, @@ -124,7 +126,7 @@ export function analyzeConfigurationComplexity(configData: any): ConfigurationCo customIntervals: customIntervals.size, advancedFeatures: [...new Set(advancedFeatures)], configSizeKb: Math.round(configSize * 100) / 100, - validationErrors: 0 // Would be populated by validation logic + validationErrors: 0, // Would be populated by validation logic }; } @@ -135,16 +137,15 @@ export function trackUserEfficiency(): UserEfficiencyMetrics { const sessionStart = sessionStorage.getItem('session_start_time'); const tasksCompleted = parseInt(sessionStorage.getItem('tasks_completed') || '0', 10); const helpUsage = parseInt(sessionStorage.getItem('help_usage_count') || '0', 10); - - const sessionDuration = sessionStart ? - (Date.now() - parseInt(sessionStart, 10)) / 1000 : 0; + + const sessionDuration = sessionStart ? (Date.now() - parseInt(sessionStart, 10)) / 1000 : 0; return { tasksCompleted, avgTaskDuration: tasksCompleted > 0 ? sessionDuration / tasksCompleted : 0, navigationDepth: getNavigationDepth(), helpUsage, - keyboardShortcuts: 0 // Would track keyboard usage + keyboardShortcuts: 0, // Would track keyboard usage }; } @@ -154,7 +155,9 @@ export function trackUserEfficiency(): UserEfficiencyMetrics { function calculateMTTD(statusItems: LiveStatusItem[], _historicalData?: any[]): number { // This would analyze historical data to determine detection times // For now, return a reasonable estimate based on probe intervals - const avgInterval = statusItems.reduce((sum, item) => sum + (item.endpoint.intervalSeconds || 30), 0) / statusItems.length; + const avgInterval = + statusItems.reduce((sum, item) => sum + (item.endpoint.intervalSeconds || 30), 0) / + statusItems.length; return avgInterval || 30; } @@ -170,9 +173,12 @@ function calculateMTTR(_statusItems: LiveStatusItem[], _historicalData?: any[]): /** * Calculate false positive rate */ -function calculateFalsePositiveRate(statusItems: LiveStatusItem[], _historicalData?: any[]): number { +function calculateFalsePositiveRate( + statusItems: LiveStatusItem[], + _historicalData?: any[] +): number { // This would analyze flapping endpoints and quick state changes - const flappingCount = statusItems.filter(item => item.status === 'flapping').length; + const flappingCount = statusItems.filter(item => item.currentState.status === 'flapping').length; return statusItems.length > 0 ? (flappingCount / statusItems.length) * 100 : 0; } @@ -228,4 +234,4 @@ export function initializeSessionTracking(): void { if (!sessionStorage.getItem('session_start_time')) { sessionStorage.setItem('session_start_time', Date.now().toString()); } -} \ No newline at end of file +}