From fb97093bd60a222949c6586316c6414f9a919c7d Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 23 Jul 2026 11:04:38 +0300 Subject: [PATCH] Implement NotifyOnStatusChange feature for nodes and enhance Telegram notifications - Added `NotifyOnStatusChange` property to the `Node` class and related DTOs to allow individual node configuration for status change notifications. - Updated `NodeHealthCheckService` to send Telegram notifications to admins when a node's status changes, based on the new property. - Enhanced the `ITelegramNotifier` interface with a method for notifying admins about node status changes. - Modified the frontend to include a checkbox for `NotifyOnStatusChange` in the node editing dialog, allowing admins to easily configure this setting. - Updated API documentation to reflect the new `notifyOnStatusChange` parameter in the node update endpoint. - Added tests to ensure the correct behavior of the new feature and its integration with existing functionality. --- .../PnvPanel.Api/Endpoints/NodeEndpoints.cs | 2 + .../PnvPanel.Api/Telegram/TelegramNotifier.cs | 43 + .../Admin/Nodes/NodeDto.cs | 2 + .../Admin/Nodes/UpdateNodeCommand.cs | 1 + .../Admin/Nodes/UpdateNodeCommandHandler.cs | 2 + .../Common/Interfaces/ITelegramNotifier.cs | 10 + backend/src/PnvPanel.Domain/Nodes/Node.cs | 6 + .../BackgroundJobs/NodeHealthCheckService.cs | 11 + ...59_AddNodeNotifyOnStatusChange.Designer.cs | 1076 +++++++++++++++++ ...60723080059_AddNodeNotifyOnStatusChange.cs | 29 + .../Migrations/AppDbContextModelSnapshot.cs | 5 +- .../Nodes/UpdateNodeCommandHandlerTests.cs | 36 +- .../PnvPanel.Domain.Tests/Nodes/NodeTests.cs | 13 + docs/api-design.md | 2 +- docs/architecture.md | 5 +- docs/domain-model.md | 1 + .../features/admin/nodes/EditNodeDialog.tsx | 20 +- frontend/src/features/admin/nodes/api.ts | 11 +- frontend/src/shared/api/types.ts | 1 + frontend/src/shared/lib/i18n.ts | 2 + 20 files changed, 1272 insertions(+), 6 deletions(-) create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260723080059_AddNodeNotifyOnStatusChange.Designer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260723080059_AddNodeNotifyOnStatusChange.cs diff --git a/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs index 672edfe..fff9c80 100644 --- a/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs @@ -55,6 +55,7 @@ public static class NodeEndpoints body.BaseAddress, body.Location, body.IsEnabled, + body.NotifyOnStatusChange, body.Username, body.Password ); @@ -98,6 +99,7 @@ public sealed record UpdateNodeBody( string BaseAddress, string? Location, bool IsEnabled, + bool NotifyOnStatusChange, string? Username, string? Password ); diff --git a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs index 414bba2..4f4bb5a 100644 --- a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs +++ b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Options; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Domain.Billing; +using PnvPanel.Domain.Nodes; using PnvPanel.Domain.Support; using PnvPanel.Infrastructure.Telegram; using Telegram.Bot; @@ -358,6 +359,48 @@ internal sealed class TelegramNotifier( } } + public async Task NotifyAdminsNodeStatusChangedAsync( + Guid nodeId, + string nodeName, + NodeStatus status, + CancellationToken cancellationToken + ) + { + if (string.IsNullOrWhiteSpace(options.Value.BotToken)) + return; + + var emoji = status == NodeStatus.Online ? "✅" : "🔴"; + var statusLabel = status == NodeStatus.Online ? "снова в сети" : "недоступна"; + var text = $"{emoji} Нода {Escape(nodeName)} {statusLabel}."; + + InlineKeyboardMarkup? keyboard = null; + if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl)) + { + var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/nodes"; + keyboard = new InlineKeyboardMarkup( + new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) } + ); + } + + foreach (var adminId in options.Value.ParseAdminTelegramUserIds()) + { + try + { + await botClient.SendMessage( + adminId, + text, + parseMode: ParseMode.Html, + replyMarkup: keyboard, + cancellationToken: cancellationToken + ); + } + catch + { + // Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду. + } + } + } + private static string Escape(string text) => text.Replace("&", "&").Replace("<", "<").Replace(">", ">"); } diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/NodeDto.cs b/backend/src/PnvPanel.Application/Admin/Nodes/NodeDto.cs index e4aa487..d5dea8f 100644 --- a/backend/src/PnvPanel.Application/Admin/Nodes/NodeDto.cs +++ b/backend/src/PnvPanel.Application/Admin/Nodes/NodeDto.cs @@ -11,6 +11,7 @@ public sealed record NodeDto( string? Location, NodeStatus Status, bool IsEnabled, + bool NotifyOnStatusChange, DateTimeOffset? LastSyncAt ) { @@ -23,6 +24,7 @@ public sealed record NodeDto( node.Location, node.Status, node.IsEnabled, + node.NotifyOnStatusChange, node.LastSyncAt ); } diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs index d0f020f..9b92594 100644 --- a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs @@ -9,6 +9,7 @@ public sealed record UpdateNodeCommand( string BaseAddress, string? Location, bool IsEnabled, + bool NotifyOnStatusChange, string? Username, string? Password ) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs index b13a21a..897f083 100644 --- a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs @@ -47,6 +47,8 @@ public sealed class UpdateNodeCommandHandler( else node.Disable(); + node.SetNotifyOnStatusChange(command.NotifyOnStatusChange); + var credentialsChanged = !string.IsNullOrWhiteSpace(command.Username) && !string.IsNullOrWhiteSpace(command.Password); diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs index 7ff5e7b..ff722f1 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs @@ -1,4 +1,5 @@ using PnvPanel.Domain.Billing; +using PnvPanel.Domain.Nodes; using PnvPanel.Domain.Support; namespace PnvPanel.Application.Common.Interfaces; @@ -74,4 +75,13 @@ public interface ITelegramNotifier string justification, CancellationToken cancellationToken ); + + /// Нода с включённым Node.NotifyOnStatusChange перешла Online↔Offline (см. + /// NodeHealthCheckService) — только информационное сообщение, без инлайн-действий. + Task NotifyAdminsNodeStatusChangedAsync( + Guid nodeId, + string nodeName, + NodeStatus status, + CancellationToken cancellationToken + ); } diff --git a/backend/src/PnvPanel.Domain/Nodes/Node.cs b/backend/src/PnvPanel.Domain/Nodes/Node.cs index 0555a86..966defd 100644 --- a/backend/src/PnvPanel.Domain/Nodes/Node.cs +++ b/backend/src/PnvPanel.Domain/Nodes/Node.cs @@ -15,6 +15,7 @@ public sealed class Node : Entity public string? Location { get; private set; } public NodeStatus Status { get; private set; } public bool IsEnabled { get; private set; } + public bool NotifyOnStatusChange { get; private set; } public DateTimeOffset? LastSyncAt { get; private set; } public DateTimeOffset CreatedAt { get; private set; } @@ -68,6 +69,11 @@ public sealed class Node : Entity public void UpdateCredentials(NodeCredentials credentials) => Credentials = credentials; + /// Слать админам в Telegram уведомление при каждом переходе Online↔Offline (см. + /// NodeHealthCheckService) — по умолчанию выключено, чтобы не спамить теми, кому неинтересен + /// конкретный узел. + public void SetNotifyOnStatusChange(bool value) => NotifyOnStatusChange = value; + public void Enable() => IsEnabled = true; public void Disable() => IsEnabled = false; diff --git a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/NodeHealthCheckService.cs b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/NodeHealthCheckService.cs index 287f5c1..8be7fce 100644 --- a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/NodeHealthCheckService.cs +++ b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/NodeHealthCheckService.cs @@ -37,6 +37,7 @@ public sealed class NodeHealthCheckService( var dbContext = scope.ServiceProvider.GetRequiredService(); var gateway = scope.ServiceProvider.GetRequiredService(); var notifier = scope.ServiceProvider.GetRequiredService(); + var telegramNotifier = scope.ServiceProvider.GetRequiredService(); var nodes = await dbContext.Nodes.Where(n => n.IsEnabled).ToListAsync(cancellationToken); @@ -54,6 +55,16 @@ public sealed class NodeHealthCheckService( node.LastSyncAt, cancellationToken ); + + if (node.NotifyOnStatusChange) + { + await telegramNotifier.NotifyAdminsNodeStatusChangedAsync( + node.Id, + node.Name, + newStatus, + cancellationToken + ); + } } } diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260723080059_AddNodeNotifyOnStatusChange.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260723080059_AddNodeNotifyOnStatusChange.Designer.cs new file mode 100644 index 0000000..cea29af --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260723080059_AddNodeNotifyOnStatusChange.Designer.cs @@ -0,0 +1,1076 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260723080059_AddNodeNotifyOnStatusChange")] + partial class AddNodeNotifyOnStatusChange + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + 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") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + 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("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsRecommended") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Billing.BillingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultBillingEnabledForNewRoles") + .HasColumnType("boolean"); + + b.Property("GraceDays") + .HasColumnType("integer"); + + b.Property("RequisitesText") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("BillingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Billing.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AmountSnapshot") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Period") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("PaymentRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionIntro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("InstructionIntros", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("InstructionTabs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("NewsPosts", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NotifyOnStatusChange") + .HasColumnType("boolean"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingDiscountTier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DiscountPercent") + .HasColumnType("integer"); + + b.Property("MinConfigs") + .HasColumnType("integer"); + + b.Property("PricingSettingsId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PricingSettingsId", "MinConfigs") + .IsUnique(); + + b.ToTable("PricingDiscountTiers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PricePerConfigPerHalfYear") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerQuarter") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerYear") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PricingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProposedMaxConfigs") + .HasColumnType("integer"); + + b.Property("ProposedMaxIpLimit") + .HasColumnType("integer"); + + b.Property("ProposedRoleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestedDays") + .HasColumnType("integer"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Type", "Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("SupportTickets", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommentId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CommentId"); + + b.HasIndex("StoredFileName") + .IsUnique(); + + b.ToTable("TicketAttachments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("TicketComments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingEnabled") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("MaxIpLimit") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("BillingLastWarnedForPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingSuspended") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("TelegramUserId") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260723080059_AddNodeNotifyOnStatusChange.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260723080059_AddNodeNotifyOnStatusChange.cs new file mode 100644 index 0000000..e426c15 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260723080059_AddNodeNotifyOnStatusChange.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddNodeNotifyOnStatusChange : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "NotifyOnStatusChange", + table: "Nodes", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "NotifyOnStatusChange", + table: "Nodes"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index f10cc44..66cc427 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("ProductVersion", "10.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -577,6 +577,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("NotifyOnStatusChange") + .HasColumnType("boolean"); + b.Property("Status") .IsRequired() .HasMaxLength(32) diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/UpdateNodeCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/UpdateNodeCommandHandlerTests.cs index ef83d3d..2cde325 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/UpdateNodeCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/UpdateNodeCommandHandlerTests.cs @@ -38,6 +38,7 @@ public class UpdateNodeCommandHandlerTests "https://node1-new.example.com", null, true, + false, null, null ); @@ -66,6 +67,7 @@ public class UpdateNodeCommandHandlerTests "https://node1.example.com", "eu", true, + false, null, null ); @@ -88,7 +90,7 @@ public class UpdateNodeCommandHandlerTests var handler = new UpdateNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser); - var command = new UpdateNodeCommand(node.Id, "node-1", "not-a-uri", null, true, null, null); + var command = new UpdateNodeCommand(node.Id, "node-1", "not-a-uri", null, true, false, null, null); var result = await handler.Handle(command, CancellationToken.None); @@ -117,6 +119,7 @@ public class UpdateNodeCommandHandlerTests "http://node1-new.example.com", null, true, + false, null, null ); @@ -147,6 +150,7 @@ public class UpdateNodeCommandHandlerTests "https://node1.example.com", null, true, + false, "new-admin", "new-password" ); @@ -175,6 +179,7 @@ public class UpdateNodeCommandHandlerTests "https://node1.example.com", null, true, + false, "new-admin", null ); @@ -187,6 +192,34 @@ public class UpdateNodeCommandHandlerTests _gateway.DidNotReceive().InvalidateClient(Arg.Any()); } + [Fact] + public async Task Handle_SetsNotifyOnStatusChange() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var node = SeedNode(); + dbContext.Nodes.Add(node); + await dbContext.SaveChangesAsync(CancellationToken.None); + Assert.False(node.NotifyOnStatusChange); + + var handler = new UpdateNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser); + + var command = new UpdateNodeCommand( + node.Id, + "node-1", + "https://node1.example.com", + null, + true, + true, + null, + null + ); + + var result = await handler.Handle(command, CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(node.NotifyOnStatusChange); + } + [Fact] public async Task Handle_WhenNodeNotFound_ReturnsNotFound() { @@ -200,6 +233,7 @@ public class UpdateNodeCommandHandlerTests "https://node1.example.com", null, true, + false, null, null ); diff --git a/backend/tests/PnvPanel.Domain.Tests/Nodes/NodeTests.cs b/backend/tests/PnvPanel.Domain.Tests/Nodes/NodeTests.cs index 4c644fc..72a5d38 100644 --- a/backend/tests/PnvPanel.Domain.Tests/Nodes/NodeTests.cs +++ b/backend/tests/PnvPanel.Domain.Tests/Nodes/NodeTests.cs @@ -97,6 +97,19 @@ public class NodeTests Assert.True(node.IsEnabled); } + [Fact] + public void SetNotifyOnStatusChange_DefaultsFalse_AndCanBeToggled() + { + var node = Node.Register("Node", new Uri("https://example.com"), Credentials, null); + Assert.False(node.NotifyOnStatusChange); + + node.SetNotifyOnStatusChange(true); + Assert.True(node.NotifyOnStatusChange); + + node.SetNotifyOnStatusChange(false); + Assert.False(node.NotifyOnStatusChange); + } + [Fact] public void UpdateStatus_SetsStatus() { diff --git a/docs/api-design.md b/docs/api-design.md index 591eb3b..c8d7f84 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -293,7 +293,7 @@ approve/reject над `ActivationRequest`. | ------ | ----------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- | | GET | `/api/admin/nodes` | admin | — | `NodeDto[]` | | POST | `/api/admin/nodes` | admin | `{ name, baseAddress, username, password, location? }` | `NodeDto` | -| PUT | `/api/admin/nodes/{id}` | admin | `{ name, baseAddress, location?, isEnabled, username?, password? }` | `NodeDto` | +| PUT | `/api/admin/nodes/{id}` | admin | `{ name, baseAddress, location?, isEnabled, notifyOnStatusChange, username?, password? }` | `NodeDto` | | DELETE | `/api/admin/nodes/{id}` | admin | — | `204 No Content` | | POST | `/api/admin/nodes/{id}/sync` | admin | — | `{ inboundsSynced, status }` | | POST | `/api/admin/nodes/{id}/probe` | admin | — | `{ isReachable, errorMessage, status }` | diff --git a/docs/architecture.md b/docs/architecture.md index 34d6c46..620442d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -221,7 +221,10 @@ POST /api/configs `TrafficSample`, шлёт `configTrafficUpdated`. Трафик используется только для отображения — лимиты и автоотключение по превышению не реализованы (см. [domain-model.md](domain-model.md)). - **NodeHealthCheckService** — health-probe нод (`IXuiPanelGateway.ProbeAsync`), обновляет `NodeStatus`, - шлёт `nodeStatusChanged` группе `admins`. + шлёт `nodeStatusChanged` группе `admins`. Дополнительно, если у ноды `NotifyOnStatusChange = true`, + при каждом переходе Online↔Offline шлёт админам ещё и Telegram-уведомление + (`ITelegramNotifier.NotifyAdminsNodeStatusChangedAsync`) — опция включается индивидуально на ноду + (`PUT /api/admin/nodes/{id}`), по умолчанию выключена. - **TrafficRetentionService** — чистит `TrafficSample` старше N дней (TTL-ретеншн истории трафика). - **BillingService** — раз в час обходит пользователей с billing-ролью (`AppRole.BillingEnabled`): гасит конфиги при просрочке оплаты (`VpnConfig.Suspend()`), шлёт предупреждение за 3 дня до diff --git a/docs/domain-model.md b/docs/domain-model.md index b5ada7f..1e90028 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -48,6 +48,7 @@ AppUser | `Location` | `string?` | Страна/город/тег для выбора пользователем | | `Status` | `NodeStatus` | `Online` / `Offline` / `Unknown` | | `IsEnabled` | `bool` | Выключена админом → скрыта из самообслуживания | +| `NotifyOnStatusChange` | `bool` | Слать админам в Telegram при каждом переходе Online↔Offline (см. NodeHealthCheckService); по умолчанию `false` | | `LastSyncAt` | `DateTimeOffset?` | Последняя успешная синхронизация | | `CreatedAt` | `DateTimeOffset`| | diff --git a/frontend/src/features/admin/nodes/EditNodeDialog.tsx b/frontend/src/features/admin/nodes/EditNodeDialog.tsx index d5782ad..4a3783c 100644 --- a/frontend/src/features/admin/nodes/EditNodeDialog.tsx +++ b/frontend/src/features/admin/nodes/EditNodeDialog.tsx @@ -17,12 +17,22 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op const [baseAddress, setBaseAddress] = useState(node.baseAddress) const [location, setLocation] = useState(node.location ?? '') const [isEnabled, setIsEnabled] = useState(node.isEnabled) + const [notifyOnStatusChange, setNotifyOnStatusChange] = useState(node.notifyOnStatusChange) const [username, setUsername] = useState(node.username) const [password, setPassword] = useState('') const mutation = useMutation({ mutationFn: () => - updateNode(node.id, name.trim(), baseAddress.trim(), location.trim() || undefined, isEnabled, username.trim() || undefined, password || undefined), + updateNode( + node.id, + name.trim(), + baseAddress.trim(), + location.trim() || undefined, + isEnabled, + notifyOnStatusChange, + username.trim() || undefined, + password || undefined, + ), onSuccess: async () => { toast.success(t('admin.nodes.updated')) await queryClient.invalidateQueries({ queryKey: ['admin-nodes'] }) @@ -66,6 +76,14 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op setIsEnabled(e.target.checked)} /> {t('admin.nodes.enabled')} +