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.
This commit is contained in:
@@ -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
|
||||
);
|
||||
|
||||
@@ -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} Нода <b>{Escape(nodeName)}</b> {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(">", ">");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ public sealed record UpdateNodeCommand(
|
||||
string BaseAddress,
|
||||
string? Location,
|
||||
bool IsEnabled,
|
||||
bool NotifyOnStatusChange,
|
||||
string? Username,
|
||||
string? Password
|
||||
) : ICommand<Result<NodeDto>>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
/// <summary>Нода с включённым Node.NotifyOnStatusChange перешла Online↔Offline (см.
|
||||
/// NodeHealthCheckService) — только информационное сообщение, без инлайн-действий.</summary>
|
||||
Task NotifyAdminsNodeStatusChangedAsync(
|
||||
Guid nodeId,
|
||||
string nodeName,
|
||||
NodeStatus status,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Слать админам в Telegram уведомление при каждом переходе Online↔Offline (см.
|
||||
/// NodeHealthCheckService) — по умолчанию выключено, чтобы не спамить теми, кому неинтересен
|
||||
/// конкретный узел.</summary>
|
||||
public void SetNotifyOnStatusChange(bool value) => NotifyOnStatusChange = value;
|
||||
|
||||
public void Enable() => IsEnabled = true;
|
||||
|
||||
public void Disable() => IsEnabled = false;
|
||||
|
||||
@@ -37,6 +37,7 @@ public sealed class NodeHealthCheckService(
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var gateway = scope.ServiceProvider.GetRequiredService<IXuiPanelGateway>();
|
||||
var notifier = scope.ServiceProvider.GetRequiredService<IRealtimeNotifier>();
|
||||
var telegramNotifier = scope.ServiceProvider.GetRequiredService<ITelegramNotifier>();
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1076
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNodeNotifyOnStatusChange : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "NotifyOnStatusChange",
|
||||
table: "Nodes",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NotifyOnStatusChange",
|
||||
table: "Nodes");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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<bool>("NotifyOnStatusChange")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
|
||||
+35
-1
@@ -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<Guid>());
|
||||
}
|
||||
|
||||
[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
|
||||
);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user