());
}
+ [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')}
+