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.BaseAddress,
|
||||||
body.Location,
|
body.Location,
|
||||||
body.IsEnabled,
|
body.IsEnabled,
|
||||||
|
body.NotifyOnStatusChange,
|
||||||
body.Username,
|
body.Username,
|
||||||
body.Password
|
body.Password
|
||||||
);
|
);
|
||||||
@@ -98,6 +99,7 @@ public sealed record UpdateNodeBody(
|
|||||||
string BaseAddress,
|
string BaseAddress,
|
||||||
string? Location,
|
string? Location,
|
||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
|
bool NotifyOnStatusChange,
|
||||||
string? Username,
|
string? Username,
|
||||||
string? Password
|
string? Password
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Domain.Billing;
|
using PnvPanel.Domain.Billing;
|
||||||
|
using PnvPanel.Domain.Nodes;
|
||||||
using PnvPanel.Domain.Support;
|
using PnvPanel.Domain.Support;
|
||||||
using PnvPanel.Infrastructure.Telegram;
|
using PnvPanel.Infrastructure.Telegram;
|
||||||
using Telegram.Bot;
|
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) =>
|
private static string Escape(string text) =>
|
||||||
text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public sealed record NodeDto(
|
|||||||
string? Location,
|
string? Location,
|
||||||
NodeStatus Status,
|
NodeStatus Status,
|
||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
|
bool NotifyOnStatusChange,
|
||||||
DateTimeOffset? LastSyncAt
|
DateTimeOffset? LastSyncAt
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -23,6 +24,7 @@ public sealed record NodeDto(
|
|||||||
node.Location,
|
node.Location,
|
||||||
node.Status,
|
node.Status,
|
||||||
node.IsEnabled,
|
node.IsEnabled,
|
||||||
|
node.NotifyOnStatusChange,
|
||||||
node.LastSyncAt
|
node.LastSyncAt
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public sealed record UpdateNodeCommand(
|
|||||||
string BaseAddress,
|
string BaseAddress,
|
||||||
string? Location,
|
string? Location,
|
||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
|
bool NotifyOnStatusChange,
|
||||||
string? Username,
|
string? Username,
|
||||||
string? Password
|
string? Password
|
||||||
) : ICommand<Result<NodeDto>>;
|
) : ICommand<Result<NodeDto>>;
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ public sealed class UpdateNodeCommandHandler(
|
|||||||
else
|
else
|
||||||
node.Disable();
|
node.Disable();
|
||||||
|
|
||||||
|
node.SetNotifyOnStatusChange(command.NotifyOnStatusChange);
|
||||||
|
|
||||||
var credentialsChanged =
|
var credentialsChanged =
|
||||||
!string.IsNullOrWhiteSpace(command.Username)
|
!string.IsNullOrWhiteSpace(command.Username)
|
||||||
&& !string.IsNullOrWhiteSpace(command.Password);
|
&& !string.IsNullOrWhiteSpace(command.Password);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using PnvPanel.Domain.Billing;
|
using PnvPanel.Domain.Billing;
|
||||||
|
using PnvPanel.Domain.Nodes;
|
||||||
using PnvPanel.Domain.Support;
|
using PnvPanel.Domain.Support;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Common.Interfaces;
|
namespace PnvPanel.Application.Common.Interfaces;
|
||||||
@@ -74,4 +75,13 @@ public interface ITelegramNotifier
|
|||||||
string justification,
|
string justification,
|
||||||
CancellationToken cancellationToken
|
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 string? Location { get; private set; }
|
||||||
public NodeStatus Status { get; private set; }
|
public NodeStatus Status { get; private set; }
|
||||||
public bool IsEnabled { get; private set; }
|
public bool IsEnabled { get; private set; }
|
||||||
|
public bool NotifyOnStatusChange { get; private set; }
|
||||||
public DateTimeOffset? LastSyncAt { get; private set; }
|
public DateTimeOffset? LastSyncAt { get; private set; }
|
||||||
public DateTimeOffset CreatedAt { 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;
|
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 Enable() => IsEnabled = true;
|
||||||
|
|
||||||
public void Disable() => IsEnabled = false;
|
public void Disable() => IsEnabled = false;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public sealed class NodeHealthCheckService(
|
|||||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
var gateway = scope.ServiceProvider.GetRequiredService<IXuiPanelGateway>();
|
var gateway = scope.ServiceProvider.GetRequiredService<IXuiPanelGateway>();
|
||||||
var notifier = scope.ServiceProvider.GetRequiredService<IRealtimeNotifier>();
|
var notifier = scope.ServiceProvider.GetRequiredService<IRealtimeNotifier>();
|
||||||
|
var telegramNotifier = scope.ServiceProvider.GetRequiredService<ITelegramNotifier>();
|
||||||
|
|
||||||
var nodes = await dbContext.Nodes.Where(n => n.IsEnabled).ToListAsync(cancellationToken);
|
var nodes = await dbContext.Nodes.Where(n => n.IsEnabled).ToListAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -54,6 +55,16 @@ public sealed class NodeHealthCheckService(
|
|||||||
node.LastSyncAt,
|
node.LastSyncAt,
|
||||||
cancellationToken
|
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
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "10.0.9")
|
.HasAnnotation("ProductVersion", "10.0.10")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
@@ -577,6 +577,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
|||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("character varying(100)");
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<bool>("NotifyOnStatusChange")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<string>("Status")
|
b.Property<string>("Status")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
|
|||||||
+35
-1
@@ -38,6 +38,7 @@ public class UpdateNodeCommandHandlerTests
|
|||||||
"https://node1-new.example.com",
|
"https://node1-new.example.com",
|
||||||
null,
|
null,
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -66,6 +67,7 @@ public class UpdateNodeCommandHandlerTests
|
|||||||
"https://node1.example.com",
|
"https://node1.example.com",
|
||||||
"eu",
|
"eu",
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -88,7 +90,7 @@ public class UpdateNodeCommandHandlerTests
|
|||||||
|
|
||||||
var handler = new UpdateNodeCommandHandler(dbContext, _gateway, _secretProtector, _currentUser);
|
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);
|
var result = await handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
@@ -117,6 +119,7 @@ public class UpdateNodeCommandHandlerTests
|
|||||||
"http://node1-new.example.com",
|
"http://node1-new.example.com",
|
||||||
null,
|
null,
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -147,6 +150,7 @@ public class UpdateNodeCommandHandlerTests
|
|||||||
"https://node1.example.com",
|
"https://node1.example.com",
|
||||||
null,
|
null,
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
"new-admin",
|
"new-admin",
|
||||||
"new-password"
|
"new-password"
|
||||||
);
|
);
|
||||||
@@ -175,6 +179,7 @@ public class UpdateNodeCommandHandlerTests
|
|||||||
"https://node1.example.com",
|
"https://node1.example.com",
|
||||||
null,
|
null,
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
"new-admin",
|
"new-admin",
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -187,6 +192,34 @@ public class UpdateNodeCommandHandlerTests
|
|||||||
_gateway.DidNotReceive().InvalidateClient(Arg.Any<Guid>());
|
_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]
|
[Fact]
|
||||||
public async Task Handle_WhenNodeNotFound_ReturnsNotFound()
|
public async Task Handle_WhenNodeNotFound_ReturnsNotFound()
|
||||||
{
|
{
|
||||||
@@ -200,6 +233,7 @@ public class UpdateNodeCommandHandlerTests
|
|||||||
"https://node1.example.com",
|
"https://node1.example.com",
|
||||||
null,
|
null,
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -97,6 +97,19 @@ public class NodeTests
|
|||||||
Assert.True(node.IsEnabled);
|
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]
|
[Fact]
|
||||||
public void UpdateStatus_SetsStatus()
|
public void UpdateStatus_SetsStatus()
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -293,7 +293,7 @@ approve/reject над `ActivationRequest`.
|
|||||||
| ------ | ----------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- |
|
| ------ | ----------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- |
|
||||||
| GET | `/api/admin/nodes` | admin | — | `NodeDto[]` |
|
| GET | `/api/admin/nodes` | admin | — | `NodeDto[]` |
|
||||||
| POST | `/api/admin/nodes` | admin | `{ name, baseAddress, username, password, location? }` | `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` |
|
| DELETE | `/api/admin/nodes/{id}` | admin | — | `204 No Content` |
|
||||||
| POST | `/api/admin/nodes/{id}/sync` | admin | — | `{ inboundsSynced, status }` |
|
| POST | `/api/admin/nodes/{id}/sync` | admin | — | `{ inboundsSynced, status }` |
|
||||||
| POST | `/api/admin/nodes/{id}/probe` | admin | — | `{ isReachable, errorMessage, status }` |
|
| POST | `/api/admin/nodes/{id}/probe` | admin | — | `{ isReachable, errorMessage, status }` |
|
||||||
|
|||||||
@@ -221,7 +221,10 @@ POST /api/configs
|
|||||||
`TrafficSample`, шлёт `configTrafficUpdated`. Трафик используется только для отображения — лимиты
|
`TrafficSample`, шлёт `configTrafficUpdated`. Трафик используется только для отображения — лимиты
|
||||||
и автоотключение по превышению не реализованы (см. [domain-model.md](domain-model.md)).
|
и автоотключение по превышению не реализованы (см. [domain-model.md](domain-model.md)).
|
||||||
- **NodeHealthCheckService** — health-probe нод (`IXuiPanelGateway.ProbeAsync`), обновляет `NodeStatus`,
|
- **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-ретеншн истории трафика).
|
- **TrafficRetentionService** — чистит `TrafficSample` старше N дней (TTL-ретеншн истории трафика).
|
||||||
- **BillingService** — раз в час обходит пользователей с billing-ролью (`AppRole.BillingEnabled`):
|
- **BillingService** — раз в час обходит пользователей с billing-ролью (`AppRole.BillingEnabled`):
|
||||||
гасит конфиги при просрочке оплаты (`VpnConfig.Suspend()`), шлёт предупреждение за 3 дня до
|
гасит конфиги при просрочке оплаты (`VpnConfig.Suspend()`), шлёт предупреждение за 3 дня до
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ AppUser
|
|||||||
| `Location` | `string?` | Страна/город/тег для выбора пользователем |
|
| `Location` | `string?` | Страна/город/тег для выбора пользователем |
|
||||||
| `Status` | `NodeStatus` | `Online` / `Offline` / `Unknown` |
|
| `Status` | `NodeStatus` | `Online` / `Offline` / `Unknown` |
|
||||||
| `IsEnabled` | `bool` | Выключена админом → скрыта из самообслуживания |
|
| `IsEnabled` | `bool` | Выключена админом → скрыта из самообслуживания |
|
||||||
|
| `NotifyOnStatusChange` | `bool` | Слать админам в Telegram при каждом переходе Online↔Offline (см. NodeHealthCheckService); по умолчанию `false` |
|
||||||
| `LastSyncAt` | `DateTimeOffset?` | Последняя успешная синхронизация |
|
| `LastSyncAt` | `DateTimeOffset?` | Последняя успешная синхронизация |
|
||||||
| `CreatedAt` | `DateTimeOffset`| |
|
| `CreatedAt` | `DateTimeOffset`| |
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,22 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op
|
|||||||
const [baseAddress, setBaseAddress] = useState(node.baseAddress)
|
const [baseAddress, setBaseAddress] = useState(node.baseAddress)
|
||||||
const [location, setLocation] = useState(node.location ?? '')
|
const [location, setLocation] = useState(node.location ?? '')
|
||||||
const [isEnabled, setIsEnabled] = useState(node.isEnabled)
|
const [isEnabled, setIsEnabled] = useState(node.isEnabled)
|
||||||
|
const [notifyOnStatusChange, setNotifyOnStatusChange] = useState(node.notifyOnStatusChange)
|
||||||
const [username, setUsername] = useState(node.username)
|
const [username, setUsername] = useState(node.username)
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
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 () => {
|
onSuccess: async () => {
|
||||||
toast.success(t('admin.nodes.updated'))
|
toast.success(t('admin.nodes.updated'))
|
||||||
await queryClient.invalidateQueries({ queryKey: ['admin-nodes'] })
|
await queryClient.invalidateQueries({ queryKey: ['admin-nodes'] })
|
||||||
@@ -66,6 +76,14 @@ export function EditNodeDialog({ node, open, onOpenChange }: { node: NodeDto; op
|
|||||||
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
||||||
{t('admin.nodes.enabled')}
|
{t('admin.nodes.enabled')}
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notifyOnStatusChange}
|
||||||
|
onChange={(e) => setNotifyOnStatusChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('admin.nodes.notifyOnStatusChange')}
|
||||||
|
</label>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="editUsername">
|
<Label htmlFor="editUsername">
|
||||||
{t('admin.nodes.username')} ({t('admin.nodes.optional')})
|
{t('admin.nodes.username')} ({t('admin.nodes.optional')})
|
||||||
|
|||||||
@@ -15,12 +15,21 @@ export function updateNode(
|
|||||||
baseAddress: string,
|
baseAddress: string,
|
||||||
location: string | undefined,
|
location: string | undefined,
|
||||||
isEnabled: boolean,
|
isEnabled: boolean,
|
||||||
|
notifyOnStatusChange: boolean,
|
||||||
username: string | undefined,
|
username: string | undefined,
|
||||||
password: string | undefined,
|
password: string | undefined,
|
||||||
) {
|
) {
|
||||||
return apiRequest<NodeDto>(`/admin/nodes/${id}`, {
|
return apiRequest<NodeDto>(`/admin/nodes/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: { name, baseAddress, location: location ?? null, isEnabled, username: username ?? null, password: password ?? null },
|
body: {
|
||||||
|
name,
|
||||||
|
baseAddress,
|
||||||
|
location: location ?? null,
|
||||||
|
isEnabled,
|
||||||
|
notifyOnStatusChange,
|
||||||
|
username: username ?? null,
|
||||||
|
password: password ?? null,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ export type NodeDto = {
|
|||||||
location: string | null
|
location: string | null
|
||||||
status: NodeStatus
|
status: NodeStatus
|
||||||
isEnabled: boolean
|
isEnabled: boolean
|
||||||
|
notifyOnStatusChange: boolean
|
||||||
lastSyncAt: string | null
|
lastSyncAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -430,6 +430,7 @@ const resources = {
|
|||||||
},
|
},
|
||||||
enabled: 'Включена',
|
enabled: 'Включена',
|
||||||
disabled: 'Отключена',
|
disabled: 'Отключена',
|
||||||
|
notifyOnStatusChange: 'Уведомлять в Telegram при смене статуса (онлайн/офлайн)',
|
||||||
inbounds: 'Инбаунды',
|
inbounds: 'Инбаунды',
|
||||||
noInbounds: 'Инбаунды не найдены — нажмите «Синхронизировать».',
|
noInbounds: 'Инбаунды не найдены — нажмите «Синхронизировать».',
|
||||||
publish: 'Публикация',
|
publish: 'Публикация',
|
||||||
@@ -999,6 +1000,7 @@ const resources = {
|
|||||||
},
|
},
|
||||||
enabled: 'Enabled',
|
enabled: 'Enabled',
|
||||||
disabled: 'Disabled',
|
disabled: 'Disabled',
|
||||||
|
notifyOnStatusChange: 'Notify in Telegram on status change (online/offline)',
|
||||||
inbounds: 'Inbounds',
|
inbounds: 'Inbounds',
|
||||||
noInbounds: 'No inbounds found — click "Sync".',
|
noInbounds: 'No inbounds found — click "Sync".',
|
||||||
publish: 'Publishing',
|
publish: 'Publishing',
|
||||||
|
|||||||
Reference in New Issue
Block a user