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 (
-
- );
- }}
-
-
+
+
+
+
+ `${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 (
+
+ );
+}
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 = (
-