feat: add notification center and reliable client presence

This commit is contained in:
2026-07-30 20:43:06 +03:00
parent 3e60abed50
commit c7aa286dde
27 changed files with 1625 additions and 67 deletions
+6 -6
View File
@@ -36,13 +36,13 @@
## Следующая разработка
- [ ] Добавить подтверждение e-mail и безопасную привязку Telegram-чата перед включением канала.
- [ ] Сделать встроенный центр уведомлений: unread/read, счётчик, переход к роутеру и обновление через SSE.
- [ ] Настройки по типам событий, maintenance/snooze и временное подавление алертов на период работ.
- [x] Добавить подтверждение e-mail и безопасную привязку Telegram-чата перед включением канала.
- [x] Сделать встроенный центр уведомлений: unread/read, счётчик, переход к роутеру и обновление через SSE.
- [x] Настройки по типам событий, maintenance/snooze и временное подавление алертов на период работ.
- [ ] Добавить операционные метрики уведомлений: queued/sent/failed, возраст очереди и последняя ошибка канала.
- [ ] Активная проверка проводных клиентов и `last_seen`, чтобы flow offload не оставлял реально работающий ПК в состоянии `STALE`.
- [ ] Подписанный webhook channel.
- [ ] Quiet hours/timezone и per-device notification overrides.
- [x] Активная проверка проводных клиентов и `last_seen`, чтобы flow offload не оставлял реально работающий ПК в состоянии `STALE`.
- [x] Подписанный webhook channel.
- [x] Quiet hours/timezone и per-device notification overrides.
- [ ] Конфигурационные backup artifacts и retention.
- [x] CI для тестов, Docker, текущей multi-architecture APK/IPK-матрицы и отдельной
legacy-сборки без блокировки основного релиза.
+4 -4
View File
@@ -29,7 +29,7 @@ production upgrade even when the release is marked compatible.
## Agent releases
Tags use `agent-vMAJOR.MINOR.PATCH`, for example `agent-v0.6.7`.
Tags use `agent-vMAJOR.MINOR.PATCH`, for example `agent-v0.6.8`.
An agent release contains the Go runtime, LuCI application and OpenWrt IPK/APK packages.
Before tagging, the tag version must match `agentVersion` in the Go source and
@@ -40,7 +40,7 @@ OpenWrt 21.02, 22.03 and 23.05 are a manual legacy tier that extends an existing
release without blocking current packages. Run it after the tagged workflow completes:
```sh
gh workflow run build-legacy.yml -f agent_tag=agent-v0.6.7
gh workflow run build-legacy.yml -f agent_tag=agent-v0.6.8
```
The LuCI application is shipped as part of the router bundle. It can retain its own package
@@ -66,8 +66,8 @@ transition period; it should not silently reuse `v1`.
git tag -a server-v0.8.1 -m "OpenWrt RMM Server 0.8.1"
git push origin server-v0.8.1
git tag -a agent-v0.6.7 -m "OpenWrt RMM Agent 0.6.7"
git push origin agent-v0.6.7
git tag -a agent-v0.6.8 -m "OpenWrt RMM Agent 0.6.8"
git push origin agent-v0.6.8
```
Pushing a server tag publishes the container image and creates a GitHub Release. Pushing
+5 -5
View File
@@ -61,14 +61,14 @@ OpenWrt через исходящее соединение агента. Пол
- [x] Lifecycle deduplication, повтор открытой проблемы и журнал доставки.
- [x] Тестовая отправка из профиля.
- [x] Перезапускаемая очередь с lease, exponential retry, dead-letter и retention терминальной истории.
- [ ] Webhook-канал с подписанным payload и ротацией секрета.
- [ ] Per-device override и расписание тишины.
- [ ] Группировка нескольких событий в incident.
- [x] Webhook-канал с подписанным payload и ротацией секрета.
- [x] Per-device override и расписание тишины.
- [x] Группировка нескольких событий в incident.
### Уточнение присутствия проводных клиентов
- [ ] Дополнить пассивный `ip neigh` безопасной активной проверкой LAN-клиентов.
- [ ] Хранить время последнего подтверждения и показывать `STALE` как «Недавно был в сети».
- [x] Дополнить пассивный `ip neigh` безопасной активной проверкой LAN-клиентов.
- [x] Хранить время последнего подтверждения и показывать `STALE` как «Недавно был в сети».
### Этап 2 — backup и безопасное восстановление
+1 -1
View File
@@ -1,6 +1,6 @@
# OpenWrt RMM Agent
Current stable Go agent: `0.6.7`. It reports runtime health, pending command results,
Current stable Go agent: `0.6.8`. It reports runtime health, pending command results,
and the last heartbeat transport error after connectivity is restored. Its OpenWrt
dependency uses the virtual `ip` provider, so either `ip-tiny` or `ip-full` can satisfy it.
Production package upgrades restart an already running agent so the new binary takes effect.
+47 -1
View File
@@ -20,11 +20,12 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
const agentVersion = "0.6.7"
const agentVersion = "0.6.8"
type agentRuntimeHealth struct {
StartedAt time.Time
@@ -345,6 +346,7 @@ func buildInventory(cfg config) map[string]any {
"dhcp_leases": dhcpLeases(),
"neighbors": neighbors(),
"wifi_clients": wifiClients(),
"client_probes": clientProbes(),
}
}
@@ -1573,6 +1575,50 @@ func neighbors() []map[string]string {
return parseIPNeighbors(commandOutput("ip", "neigh", "show"))
}
func clientProbes() []map[string]string {
leases := dhcpLeases()
type target struct {
ip string
mac string
}
targets := make([]target, 0, len(leases))
seen := map[string]bool{}
for _, lease := range leases {
ip := net.ParseIP(strings.TrimSpace(lease["ip"]))
if ip == nil || ip.To4() == nil || !ip.IsPrivate() || seen[ip.String()] {
continue
}
seen[ip.String()] = true
targets = append(targets, target{ip: ip.String(), mac: lease["mac"]})
if len(targets) == 32 {
break
}
}
results := make([]map[string]string, len(targets))
var wg sync.WaitGroup
slots := make(chan struct{}, 6)
for index, item := range targets {
wg.Add(1)
go func(index int, item target) {
defer wg.Done()
slots <- struct{}{}
defer func() { <-slots }()
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
defer cancel()
err := exec.CommandContext(ctx, "ping", "-c", "1", "-W", "1", item.ip).Run()
results[index] = map[string]string{
"ip": item.ip, "mac": item.mac, "reachable": strconv.FormatBool(err == nil),
"checked_at": time.Now().UTC().Format(time.RFC3339Nano),
}
}(index, item)
}
wg.Wait()
if results == nil {
return []map[string]string{}
}
return results
}
func parseIPNeighbors(output string) []map[string]string {
result := make([]map[string]string, 0)
for _, line := range strings.Split(output, "\n") {
+1 -1
View File
@@ -12,7 +12,7 @@ import (
)
func TestAgentVersionIsStable(t *testing.T) {
if agentVersion != "0.6.7" {
if agentVersion != "0.6.8" {
t.Fatalf("unexpected agent version %q", agentVersion)
}
}
+2 -2
View File
@@ -67,7 +67,7 @@ application to the router. Do not copy or install the shell runtime at the same
cd dist/rmm-openwrt-25.12.4-ramips-mt7621
sha256sum -c SHA256SUMS
scp \
rmm-agent-go-production-0.6.7-r1.apk \
rmm-agent-go-production-0.6.8-r1.apk \
luci-app-rmm-agent-0.2.1-r2.apk \
root@ROUTER_IP:/tmp/
```
@@ -76,7 +76,7 @@ Then install the locally built, unsigned packages over SSH:
```sh
apk add --allow-untrusted \
/tmp/rmm-agent-go-production-0.6.7-r1.apk \
/tmp/rmm-agent-go-production-0.6.8-r1.apk \
/tmp/luci-app-rmm-agent-0.2.1-r2.apk
/etc/init.d/rpcd restart
/etc/init.d/uhttpd restart
@@ -2,7 +2,7 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=rmm-agent-go-production
PKG_VERSION:=0.6.7
PKG_VERSION:=0.6.8
PKG_RELEASE:=1
PKG_MAINTAINER:=RMM OpenWrt
+1 -1
View File
@@ -2,7 +2,7 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=rmm-agent-go
PKG_VERSION:=0.6.7
PKG_VERSION:=0.6.8
PKG_RELEASE:=1
PKG_MAINTAINER:=RMM OpenWrt
+23
View File
@@ -690,3 +690,26 @@ Authenticated browser sessions can manage their own account without administrato
- `PATCH /api/auth/profile` accepts `display_name` and `email`.
- `POST /api/auth/change-password` accepts `current_password` and `new_password`; other sessions are revoked.
- `POST /api/auth/logout-all` revokes every session for the current user.
## Notification center and delivery policy
- `GET /api/notification-center?limit=50` returns inbox entries and the unread count.
- `POST /api/notification-center/{id}/read` marks one entry read.
- `POST /api/notification-center/read-all` marks the current user's inbox read.
- `POST /api/notifications/verify/email/request|confirm` verifies the profile e-mail with a six-digit code.
- `POST /api/notifications/verify/telegram/request|confirm` proves ownership of a Telegram chat before it can be enabled.
- `GET|PUT /api/notifications/settings` includes timezone, quiet hours, maintenance pause and signed webhook settings.
- `GET|PATCH /api/devices/{id}/notification-settings` controls per-router severity and pause overrides.
Webhook requests use `Content-Type: application/json`, `X-RMM-Timestamp` and
`X-RMM-Signature: sha256=<hex HMAC-SHA256>`. The signed bytes are
`timestamp + "." + raw_request_body`. Only public HTTPS endpoints are accepted.
## LAN client presence
`GET /api/devices/{id}/clients` returns persisted clients with `first_seen_at`,
`last_seen_at`, `last_checked_at` and one of:
- `online`: confirmed by Wi-Fi association, an active neighbour state or a successful safe ICMP probe;
- `recent`: confirmed within the recent-presence window;
- `unconfirmed`: known from DHCP or stale neighbour data but not actively confirmed.
+6
View File
@@ -66,6 +66,10 @@ Operator API:
- `GET|PUT /api/notifications/settings`
- `GET /api/notifications`
- `POST /api/notifications/test`
- `POST /api/notifications/verify/{email|telegram}/{request|confirm}`
- `GET /api/notification-center`
- `POST /api/notification-center/{id}/read`
- `POST /api/notification-center/read-all`
- `GET|POST /api/users` (administrator only)
- `PATCH /api/users/{id}` (administrator only)
- `POST /api/enrollment-grants`
@@ -73,6 +77,8 @@ Operator API:
- `GET /api/devices`
- `GET /api/events` (authenticated SSE stream; the client reloads user-scoped data on change)
- `GET /api/devices/{id}`
- `GET /api/devices/{id}/clients`
- `GET|PATCH /api/devices/{id}/notification-settings`
- `POST /api/devices/{id}/transfer`
- `POST /api/devices/{id}/commands`
- `GET /api/devices/{id}/commands`
+341 -11
View File
@@ -2,9 +2,11 @@ package httpapi
import (
"context"
"crypto/rand"
"errors"
"fmt"
"log"
"math/big"
"net/http"
"strconv"
"strings"
@@ -32,6 +34,190 @@ type notificationSettingsRequest struct {
PacketLossPercent int `json:"packet_loss_percent"`
LatencyThresholdMS int `json:"latency_threshold_ms"`
RepeatMinutes int `json:"repeat_minutes"`
Timezone string `json:"timezone"`
QuietHoursEnabled bool `json:"quiet_hours_enabled"`
QuietHoursStart string `json:"quiet_hours_start"`
QuietHoursEnd string `json:"quiet_hours_end"`
AlertsPausedUntil string `json:"alerts_paused_until"`
WebhookEnabled bool `json:"webhook_enabled"`
WebhookURL string `json:"webhook_url"`
WebhookSecret string `json:"webhook_secret"`
}
type deviceNotificationSettingsRequest struct {
Enabled bool `json:"enabled"`
NotifyWarning bool `json:"notify_warning"`
NotifyCritical bool `json:"notify_critical"`
NotifyResolved bool `json:"notify_resolved"`
PausedUntil string `json:"paused_until"`
}
type contactVerificationRequest struct {
Destination string `json:"destination"`
Code string `json:"code"`
}
func (a *App) handleRequestContactVerification(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
channel := parts[3]
if channel != "email" && channel != "telegram" {
writeError(w, http.StatusNotFound, "not found")
return
}
var req contactVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
principal, _ := principalFromContext(r.Context())
destination := strings.TrimSpace(req.Destination)
var sender NotificationSender
if channel == "email" {
destination = principal.User.Email
sender = a.alertEmailSender
if destination == "" || sender == nil {
writeError(w, http.StatusConflict, "email verification is unavailable")
return
}
} else {
sender = a.telegramSender
if sender == nil || !validTelegramChatID(destination) {
writeError(w, http.StatusBadRequest, "Telegram chat ID is invalid or unavailable")
return
}
}
randomValue, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create verification")
return
}
code := fmt.Sprintf("%06d", randomValue.Int64())
codeHash, err := store.VerificationCodeHash(code)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to secure verification")
return
}
if err := a.store.BeginContactVerification(r.Context(), principal.User.ID, channel, destination, codeHash, time.Now().UTC().Add(10*time.Minute)); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save verification")
return
}
if err := sender.SendNotification(r.Context(), destination, "OpenWrt RMM verification", "Verification code: "+code+"\n\nThe code expires in 10 minutes."); err != nil {
writeError(w, http.StatusBadGateway, "failed to deliver verification code")
return
}
writeJSON(w, http.StatusOK, map[string]any{"sent": true, "destination": maskVerificationDestination(channel, destination)})
}
func (a *App) handleConfirmContactVerification(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
channel := parts[3]
if channel != "email" && channel != "telegram" {
writeError(w, http.StatusNotFound, "not found")
return
}
var req contactVerificationRequest
if !decodeJSON(w, r, &req) {
return
}
if len(strings.TrimSpace(req.Code)) != 6 {
writeError(w, http.StatusBadRequest, "verification code is invalid")
return
}
principal, _ := principalFromContext(r.Context())
destination, confirmed, err := a.store.ConfirmContactVerification(r.Context(), principal.User.ID, channel, req.Code)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to confirm verification")
return
}
if !confirmed {
writeError(w, http.StatusBadRequest, "verification code is invalid or expired")
return
}
writeJSON(w, http.StatusOK, map[string]any{"verified": true, "destination": maskVerificationDestination(channel, destination)})
}
func maskVerificationDestination(channel, destination string) string {
if channel == "email" {
return maskEmail(destination)
}
return maskTelegramChatID(destination)
}
func (a *App) handleListInboxNotifications(w http.ResponseWriter, r *http.Request) {
principal, _ := principalFromContext(r.Context())
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, unread, err := a.store.ListInboxNotifications(r.Context(), principal.User.ID, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load notification center")
return
}
writeJSON(w, http.StatusOK, map[string]any{"notifications": items, "unread": unread})
}
func (a *App) handleInboxNotificationAction(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) != 4 || parts[3] != "read" {
writeError(w, http.StatusNotFound, "not found")
return
}
principal, _ := principalFromContext(r.Context())
found, err := a.store.MarkInboxNotificationRead(r.Context(), principal.User.ID, parts[2])
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to mark notification read")
return
}
if !found {
writeError(w, http.StatusNotFound, "notification not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"read": true})
}
func (a *App) handleMarkAllInboxNotificationsRead(w http.ResponseWriter, r *http.Request) {
principal, _ := principalFromContext(r.Context())
if err := a.store.MarkAllInboxNotificationsRead(r.Context(), principal.User.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to mark notifications read")
return
}
writeJSON(w, http.StatusOK, map[string]any{"read": true})
}
func (a *App) handleGetDeviceNotificationSettings(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
principal, _ := principalFromContext(r.Context())
settings, _, err := a.store.GetDeviceNotificationSettings(r.Context(), principal.User.ID, parts[2])
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load device notification settings")
return
}
writeJSON(w, http.StatusOK, map[string]any{"settings": settings})
}
func (a *App) handleUpdateDeviceNotificationSettings(w http.ResponseWriter, r *http.Request) {
var req deviceNotificationSettingsRequest
if !decodeJSON(w, r, &req) {
return
}
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
var pausedUntil *time.Time
if value := strings.TrimSpace(req.PausedUntil); value != "" {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil || parsed.After(time.Now().UTC().Add(30*24*time.Hour)) {
writeError(w, http.StatusBadRequest, "paused_until must be RFC3339 and no more than 30 days")
return
}
parsed = parsed.UTC()
pausedUntil = &parsed
}
principal, _ := principalFromContext(r.Context())
settings, err := a.store.UpsertDeviceNotificationSettings(r.Context(), principal.User.ID, model.DeviceNotificationSettings{
DeviceID: parts[2], Enabled: req.Enabled, NotifyWarning: req.NotifyWarning,
NotifyCritical: req.NotifyCritical, NotifyResolved: req.NotifyResolved, PausedUntil: pausedUntil,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to save device notification settings")
return
}
writeJSON(w, http.StatusOK, map[string]any{"settings": settings})
}
func (a *App) handleGetNotificationSettings(w http.ResponseWriter, r *http.Request) {
@@ -41,7 +227,7 @@ func (a *App) handleGetNotificationSettings(w http.ResponseWriter, r *http.Reque
writeError(w, http.StatusInternalServerError, "failed to load notification settings")
return
}
writeJSON(w, http.StatusOK, a.notificationSettingsResponse(settings, principal.User))
writeJSON(w, http.StatusOK, a.notificationSettingsResponse(r.Context(), settings, principal.User))
}
func (a *App) handleUpdateNotificationSettings(w http.ResponseWriter, r *http.Request) {
@@ -50,6 +236,16 @@ func (a *App) handleUpdateNotificationSettings(w http.ResponseWriter, r *http.Re
return
}
principal, _ := principalFromContext(r.Context())
var pausedUntil *time.Time
if value := strings.TrimSpace(req.AlertsPausedUntil); value != "" {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
writeError(w, http.StatusBadRequest, "alerts_paused_until must be RFC3339")
return
}
parsed = parsed.UTC()
pausedUntil = &parsed
}
settings := model.NotificationSettings{
UserID: principal.User.ID,
EmailEnabled: req.EmailEnabled,
@@ -63,8 +259,28 @@ func (a *App) handleUpdateNotificationSettings(w http.ResponseWriter, r *http.Re
PacketLossPercent: req.PacketLossPercent,
LatencyThresholdMS: req.LatencyThresholdMS,
RepeatMinutes: req.RepeatMinutes,
Timezone: strings.TrimSpace(req.Timezone),
QuietHoursEnabled: req.QuietHoursEnabled,
QuietHoursStart: strings.TrimSpace(req.QuietHoursStart),
QuietHoursEnd: strings.TrimSpace(req.QuietHoursEnd),
AlertsPausedUntil: pausedUntil,
WebhookEnabled: req.WebhookEnabled,
WebhookURL: strings.TrimSpace(req.WebhookURL),
WebhookSecret: strings.TrimSpace(req.WebhookSecret),
}
if message := a.validateNotificationSettings(settings, principal.User); message != "" {
if settings.Timezone == "" {
settings.Timezone = "UTC"
}
if settings.QuietHoursStart == "" {
settings.QuietHoursStart = "22:00"
}
if settings.QuietHoursEnd == "" {
settings.QuietHoursEnd = "08:00"
}
if current, _, currentErr := a.store.GetNotificationSettings(r.Context(), principal.User.ID); currentErr == nil {
settings.WebhookSecretConfigured = current.WebhookSecretConfigured
}
if message := a.validateNotificationSettings(r.Context(), settings, principal.User); message != "" {
writeError(w, http.StatusBadRequest, message)
return
}
@@ -78,7 +294,7 @@ func (a *App) handleUpdateNotificationSettings(w http.ResponseWriter, r *http.Re
"telegram_enabled": stored.TelegramEnabled,
"request_id": requestID(r.Context()),
}))
writeJSON(w, http.StatusOK, a.notificationSettingsResponse(stored, principal.User))
writeJSON(w, http.StatusOK, a.notificationSettingsResponse(r.Context(), stored, principal.User))
}
func (a *App) handleListNotifications(w http.ResponseWriter, r *http.Request) {
@@ -126,29 +342,44 @@ func (a *App) handleTestNotifications(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"notifications": results})
}
func (a *App) notificationSettingsResponse(settings model.NotificationSettings, user model.User) map[string]any {
func (a *App) notificationSettingsResponse(ctx context.Context, settings model.NotificationSettings, user model.User) map[string]any {
emailVerified, _ := a.store.ContactVerified(ctx, user.ID, "email", user.Email)
telegramVerified, _ := a.store.ContactVerified(ctx, user.ID, "telegram", settings.TelegramChatID)
return map[string]any{
"settings": settings,
"channels": map[string]any{
"email": map[string]any{"available": a.alertEmailSender != nil, "destination": maskEmail(user.Email), "profile_email_configured": user.Email != ""},
"telegram": map[string]any{"available": a.telegramSender != nil},
"email": map[string]any{"available": a.alertEmailSender != nil, "destination": maskEmail(user.Email), "profile_email_configured": user.Email != "", "verified": emailVerified},
"telegram": map[string]any{"available": a.telegramSender != nil, "verified": telegramVerified},
"webhook": map[string]any{"available": true},
},
}
}
func (a *App) validateNotificationSettings(settings model.NotificationSettings, user model.User) string {
func (a *App) validateNotificationSettings(ctx context.Context, settings model.NotificationSettings, user model.User) string {
if settings.EmailEnabled && a.alertEmailSender == nil {
return "email notifications are not configured on the server"
}
if settings.EmailEnabled && strings.TrimSpace(user.Email) == "" {
return "add an email address to the profile before enabling email notifications"
}
if settings.EmailEnabled {
verified, _ := a.store.ContactVerified(ctx, user.ID, "email", user.Email)
if !verified {
return "verify the profile email before enabling notifications"
}
}
if settings.TelegramEnabled && a.telegramSender == nil {
return "Telegram notifications are not configured on the server"
}
if settings.TelegramEnabled && !validTelegramChatID(settings.TelegramChatID) {
return "Telegram chat ID is invalid"
}
if settings.TelegramEnabled {
verified, _ := a.store.ContactVerified(ctx, user.ID, "telegram", settings.TelegramChatID)
if !verified {
return "verify Telegram ownership before enabling notifications"
}
}
if settings.MemoryThresholdPercent < 50 || settings.MemoryThresholdPercent > 99 || settings.DiskThresholdPercent < 50 || settings.DiskThresholdPercent > 99 {
return "memory and disk thresholds must be between 50 and 99 percent"
}
@@ -161,9 +392,76 @@ func (a *App) validateNotificationSettings(settings model.NotificationSettings,
if settings.RepeatMinutes != 0 && (settings.RepeatMinutes < 15 || settings.RepeatMinutes > 10080) {
return "repeat interval must be 0 or between 15 and 10080 minutes"
}
if settings.Timezone == "" {
settings.Timezone = "UTC"
}
if _, err := time.LoadLocation(settings.Timezone); err != nil {
return "timezone is invalid"
}
if !validClock(settings.QuietHoursStart) || !validClock(settings.QuietHoursEnd) {
return "quiet hours must use HH:MM"
}
if settings.AlertsPausedUntil != nil && settings.AlertsPausedUntil.After(time.Now().UTC().Add(30*24*time.Hour)) {
return "alerts can be paused for at most 30 days"
}
if settings.WebhookEnabled {
if message := validateWebhookEndpoint(settings.WebhookURL); message != "" {
return message
}
if !settings.WebhookSecretConfigured && len(settings.WebhookSecret) < 32 {
return "webhook secret must contain at least 32 characters"
}
}
return ""
}
func validClock(value string) bool {
_, err := time.Parse("15:04", value)
return err == nil
}
func notificationQuietNow(settings model.NotificationSettings, now time.Time) bool {
if settings.AlertsPausedUntil != nil && now.Before(*settings.AlertsPausedUntil) {
return true
}
if !settings.QuietHoursEnabled {
return false
}
location, err := time.LoadLocation(settings.Timezone)
if err != nil {
return false
}
local := now.In(location)
start, startErr := time.Parse("15:04", settings.QuietHoursStart)
end, endErr := time.Parse("15:04", settings.QuietHoursEnd)
if startErr != nil || endErr != nil {
return false
}
minute := local.Hour()*60 + local.Minute()
startMinute := start.Hour()*60 + start.Minute()
endMinute := end.Hour()*60 + end.Minute()
if startMinute == endMinute {
return true
}
if startMinute < endMinute {
return minute >= startMinute && minute < endMinute
}
return minute >= startMinute || minute < endMinute
}
func deviceNotificationEnabled(settings model.DeviceNotificationSettings, severity, event string, now time.Time) bool {
if !settings.Enabled || (settings.PausedUntil != nil && now.Before(*settings.PausedUntil)) {
return false
}
if event == "resolved" {
return settings.NotifyResolved
}
if severity == "critical" {
return settings.NotifyCritical
}
return settings.NotifyWarning
}
func validTelegramChatID(value string) bool {
value = strings.TrimSpace(value)
if value == "" || len(value) > 32 {
@@ -239,12 +537,30 @@ func (a *App) queueDeviceNotifications(ctx context.Context, deviceID string) ([]
continue
}
title, body := notificationCopy(device, alert, event, a.publicURL)
candidates := a.notificationDeliveriesForMessage(user, settings, event, device.ID, alert.ID, title, body)
for _, candidate := range candidates {
lifecycle := alert.FirstSeenAt.UTC().Format(time.RFC3339Nano)
if event == "resolved" && alert.ResolvedAt != nil {
lifecycle = alert.ResolvedAt.UTC().Format(time.RFC3339Nano)
}
incidentID := device.ID + ":" + alert.FirstSeenAt.UTC().Truncate(5*time.Minute).Format("20060102T1504")
_, inboxInserted, inboxErr := a.store.CreateInboxNotification(ctx, model.InboxNotification{
UserID: user.ID, DeviceID: device.ID, IncidentID: incidentID, Severity: alert.Severity,
Event: event, Title: title, Body: body,
}, "inbox:"+alert.ID+":"+lifecycle+":"+event)
if inboxErr != nil {
return nil, inboxErr
}
if inboxInserted {
a.events.publish("notifications")
}
deviceSettings, _, overrideErr := a.store.GetDeviceNotificationSettings(ctx, user.ID, device.ID)
if overrideErr != nil {
return nil, overrideErr
}
if !deviceNotificationEnabled(deviceSettings, alert.Severity, event, now) || notificationQuietNow(settings, now) {
continue
}
candidates := a.notificationDeliveriesForMessage(user, settings, event, device.ID, alert.ID, title, body)
for _, candidate := range candidates {
dedupeKey := alert.ID + ":" + lifecycle + ":" + event + ":" + candidate.Channel
created, inserted, createErr := a.store.CreateNotificationDelivery(ctx, candidate, dedupeKey)
if createErr != nil {
@@ -287,7 +603,7 @@ func notificationSeverityEnabled(settings model.NotificationSettings, severity s
}
func (a *App) notificationDeliveriesForMessage(user model.User, settings model.NotificationSettings, event, deviceID, alertID, title, body string) []model.NotificationDelivery {
deliveries := make([]model.NotificationDelivery, 0, 2)
deliveries := make([]model.NotificationDelivery, 0, 3)
base := model.NotificationDelivery{
UserID: user.ID, DeviceID: deviceID, AlertID: alertID, Event: event,
Title: title, Body: body, MaxAttempts: a.notificationMaxAttempts,
@@ -306,6 +622,13 @@ func (a *App) notificationDeliveriesForMessage(user model.User, settings model.N
delivery.DestinationMasked = maskTelegramChatID(settings.TelegramChatID)
deliveries = append(deliveries, delivery)
}
if settings.WebhookEnabled && settings.WebhookURL != "" && settings.WebhookSecret != "" {
delivery := base
delivery.Channel = "webhook"
delivery.Destination = settings.WebhookURL
delivery.DestinationMasked = maskWebhookURL(settings.WebhookURL)
deliveries = append(deliveries, delivery)
}
return deliveries
}
@@ -333,7 +656,14 @@ func (a *App) deliverNotification(ctx context.Context, delivery model.Notificati
sender = a.telegramSender
}
err := errors.New("notification channel is unavailable")
if sender != nil {
if delivery.Channel == "webhook" {
settings, _, settingsErr := a.store.GetNotificationSettings(ctx, delivery.UserID)
if settingsErr != nil {
err = settingsErr
} else {
err = sendSignedWebhook(ctx, settings.WebhookURL, settings.WebhookSecret, delivery)
}
} else if sender != nil {
err = sender.SendNotification(ctx, delivery.Destination, delivery.Title, delivery.Body)
}
if err != nil {
@@ -3,6 +3,8 @@ package httpapi
import (
"testing"
"time"
"rmm-openwrt/server/internal/model"
)
func TestNotificationRetryDelay(t *testing.T) {
@@ -16,3 +18,15 @@ func TestNotificationRetryDelay(t *testing.T) {
t.Fatalf("retry cap: got %s want 30m", got)
}
}
func TestNotificationQuietHoursAcrossMidnight(t *testing.T) {
settings := model.NotificationSettings{
Timezone: "UTC", QuietHoursEnabled: true, QuietHoursStart: "22:00", QuietHoursEnd: "08:00",
}
if !notificationQuietNow(settings, time.Date(2026, 7, 30, 23, 0, 0, 0, time.UTC)) {
t.Fatal("23:00 must be quiet")
}
if notificationQuietNow(settings, time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC)) {
t.Fatal("12:00 must not be quiet")
}
}
@@ -24,6 +24,24 @@ type capturedNotification struct {
body string
}
func verifyEmailForTest(t *testing.T, st *store.Store, username, email string) {
t.Helper()
user, _, found, err := st.GetUserByUsername(context.Background(), username)
if err != nil || !found {
t.Fatalf("load verification user: found=%v err=%v", found, err)
}
hash, err := store.VerificationCodeHash("123456")
if err != nil {
t.Fatalf("hash verification code: %v", err)
}
if err := st.BeginContactVerification(context.Background(), user.ID, "email", email, hash, time.Now().Add(time.Minute)); err != nil {
t.Fatal(err)
}
if _, confirmed, err := st.ConfirmContactVerification(context.Background(), user.ID, "email", "123456"); err != nil || !confirmed {
t.Fatalf("confirm test email: confirmed=%v err=%v", confirmed, err)
}
}
type captureNotificationSender struct {
messages chan capturedNotification
}
@@ -58,6 +76,7 @@ func TestNotificationSettingsTestAndAlertLifecycle(t *testing.T) {
authRequestJSON(t, client, http.MethodPatch, srv.URL+"/api/auth/profile", map[string]any{
"display_name": "Owner", "email": "owner@example.test",
}, http.StatusOK, nil)
verifyEmailForTest(t, st, "admin", "owner@example.test")
var defaults struct {
Settings model.NotificationSettings `json:"settings"`
@@ -199,6 +218,7 @@ func TestNotificationFailureIsRetriedWithoutLeakingProviderError(t *testing.T) {
authRequestJSON(t, client, http.MethodPatch, srv.URL+"/api/auth/profile", map[string]any{
"display_name": "Owner", "email": "owner@example.test",
}, http.StatusOK, nil)
verifyEmailForTest(t, st, "admin", "owner@example.test")
authRequestJSON(t, client, http.MethodPut, srv.URL+"/api/notifications/settings", map[string]any{
"email_enabled": true, "notify_warning": true, "notify_critical": true, "notify_resolved": true,
"memory_threshold_percent": 85, "disk_threshold_percent": 85, "packet_loss_percent": 20,
+41
View File
@@ -62,6 +62,7 @@ type Store interface {
UpdateDeviceFleet(ctx context.Context, deviceID, group string, tags []string) (model.Device, bool, error)
DeleteDevice(ctx context.Context, deviceID string) (bool, error)
ListMetricSamples(ctx context.Context, deviceID string, opts store.MetricHistoryOptions) ([]model.MetricSample, bool, error)
ListLANClients(ctx context.Context, deviceID string, recentFor time.Duration) ([]model.LANClient, bool, error)
SyncDeviceAlerts(ctx context.Context, deviceID string, active []model.Alert) ([]model.Alert, bool, error)
ListAlerts(ctx context.Context, opts store.AlertListOptions) ([]model.Alert, error)
AcknowledgeAlert(ctx context.Context, deviceID, alertID, actor string) (model.Alert, bool, error)
@@ -74,6 +75,15 @@ type Store interface {
CompleteNotificationDelivery(ctx context.Context, id, status, errorMessage string, nextAttemptAt *time.Time) error
LatestNotificationDelivery(ctx context.Context, userID, alertID, channel string) (model.NotificationDelivery, bool, error)
ListNotificationDeliveries(ctx context.Context, opts store.NotificationListOptions) ([]model.NotificationDelivery, error)
CreateInboxNotification(ctx context.Context, notification model.InboxNotification, dedupeKey string) (model.InboxNotification, bool, error)
ListInboxNotifications(ctx context.Context, userID string, limit int) ([]model.InboxNotification, int, error)
MarkInboxNotificationRead(ctx context.Context, userID, id string) (bool, error)
MarkAllInboxNotificationsRead(ctx context.Context, userID string) error
GetDeviceNotificationSettings(ctx context.Context, userID, deviceID string) (model.DeviceNotificationSettings, bool, error)
UpsertDeviceNotificationSettings(ctx context.Context, userID string, settings model.DeviceNotificationSettings) (model.DeviceNotificationSettings, error)
BeginContactVerification(ctx context.Context, userID, channel, destination, codeHash string, expiresAt time.Time) error
ConfirmContactVerification(ctx context.Context, userID, channel, codeHash string) (string, bool, error)
ContactVerified(ctx context.Context, userID, channel, destination string) (bool, error)
PurgeNotificationDeliveriesBefore(ctx context.Context, cutoff time.Time) (int64, error)
CreateCommand(ctx context.Context, deviceID, commandType string, args json.RawMessage) (model.Command, bool, error)
ListCommands(ctx context.Context, deviceID string, opts store.CommandListOptions) ([]model.Command, bool, error)
@@ -346,6 +356,11 @@ func NewHandler(s Store, cfg Config) http.Handler {
mux.Handle("PUT /api/notifications/settings", a.operatorAuth(http.HandlerFunc(a.handleUpdateNotificationSettings)))
mux.Handle("GET /api/notifications", a.operatorAuth(http.HandlerFunc(a.handleListNotifications)))
mux.Handle("POST /api/notifications/test", a.operatorAuth(http.HandlerFunc(a.handleTestNotifications)))
mux.Handle("POST /api/notifications/verify/{channel}/request", a.operatorAuth(http.HandlerFunc(a.handleRequestContactVerification)))
mux.Handle("POST /api/notifications/verify/{channel}/confirm", a.operatorAuth(http.HandlerFunc(a.handleConfirmContactVerification)))
mux.Handle("GET /api/notification-center", a.operatorAuth(http.HandlerFunc(a.handleListInboxNotifications)))
mux.Handle("POST /api/notification-center/read-all", a.operatorAuth(http.HandlerFunc(a.handleMarkAllInboxNotificationsRead)))
mux.Handle("POST /api/notification-center/", a.operatorAuth(http.HandlerFunc(a.handleInboxNotificationAction)))
mux.Handle("GET /api/users", a.operatorAuth(a.adminOnly(http.HandlerFunc(a.handleListUsers))))
mux.Handle("POST /api/users", a.operatorAuth(a.adminOnly(http.HandlerFunc(a.handleCreateUser))))
mux.Handle("PATCH /api/users/", a.operatorAuth(a.adminOnly(http.HandlerFunc(a.handleUpdateUser))))
@@ -626,6 +641,18 @@ func (a *App) handleDeviceSubtree(w http.ResponseWriter, r *http.Request) {
a.handleListMetricSamples(w, r)
return
}
if len(parts) == 4 && parts[3] == "clients" && r.Method == http.MethodGet {
a.handleListLANClients(w, r)
return
}
if len(parts) == 4 && parts[3] == "notification-settings" && r.Method == http.MethodGet {
a.handleGetDeviceNotificationSettings(w, r)
return
}
if len(parts) == 4 && parts[3] == "notification-settings" && r.Method == http.MethodPatch {
a.handleUpdateDeviceNotificationSettings(w, r)
return
}
if len(parts) == 4 && parts[3] == "alerts" && r.Method == http.MethodGet {
a.handleListAlerts(w, r)
return
@@ -685,6 +712,20 @@ func (a *App) handleDeviceSubtree(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not found")
}
func (a *App) handleListLANClients(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
clients, found, err := a.store.ListLANClients(r.Context(), parts[2], 30*time.Minute)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load LAN clients")
return
}
if !found {
writeError(w, http.StatusNotFound, "device not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"clients": clients})
}
func (a *App) handleUpdateDeviceFleet(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) != 4 || parts[0] != "api" || parts[1] != "devices" || parts[3] != "fleet" {
+114
View File
@@ -0,0 +1,114 @@
package httpapi
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"rmm-openwrt/server/internal/model"
)
func validateWebhookEndpoint(value string) string {
parsed, err := url.Parse(strings.TrimSpace(value))
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil {
return "webhook URL must be an absolute HTTPS URL without credentials"
}
if parsed.Fragment != "" {
return "webhook URL must not contain a fragment"
}
if ip := net.ParseIP(parsed.Hostname()); ip != nil && !publicWebhookIP(ip) {
return "webhook URL must not target a private or local address"
}
return ""
}
func sendSignedWebhook(ctx context.Context, endpoint, secret string, delivery model.NotificationDelivery) error {
if message := validateWebhookEndpoint(endpoint); message != "" {
return fmt.Errorf("%s", message)
}
parsed, _ := url.Parse(endpoint)
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, parsed.Hostname())
if err != nil || len(addresses) == 0 {
return fmt.Errorf("resolve webhook endpoint: %w", err)
}
for _, address := range addresses {
if !publicWebhookIP(address.IP) {
return fmt.Errorf("webhook endpoint resolved to a private or local address")
}
}
pinnedIP := addresses[0].IP.String()
payload, err := json.Marshal(map[string]any{
"id": delivery.ID, "device_id": delivery.DeviceID, "alert_id": delivery.AlertID,
"event": delivery.Event, "title": delivery.Title, "body": delivery.Body,
"created_at": delivery.CreatedAt,
})
if err != nil {
return err
}
timestamp := fmt.Sprintf("%d", time.Now().UTC().Unix())
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(timestamp))
_, _ = mac.Write([]byte("."))
_, _ = mac.Write(payload)
signature := hex.EncodeToString(mac.Sum(nil))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-RMM-Timestamp", timestamp)
req.Header.Set("X-RMM-Signature", "sha256="+signature)
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("parse webhook dial address: %w", err)
}
if !strings.EqualFold(strings.TrimSuffix(host, "."), strings.TrimSuffix(parsed.Hostname(), ".")) {
return nil, fmt.Errorf("webhook redirect to another host is not allowed")
}
return dialer.DialContext(ctx, network, net.JoinHostPort(pinnedIP, port))
}
client := &http.Client{
Transport: transport,
Timeout: 15 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
response, err := client.Do(req)
if err != nil {
return err
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 32<<10))
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("webhook returned HTTP %d", response.StatusCode)
}
return nil
}
func publicWebhookIP(ip net.IP) bool {
return ip != nil && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() &&
!ip.IsLinkLocalMulticast() && !ip.IsUnspecified() && !ip.IsMulticast()
}
func maskWebhookURL(value string) string {
parsed, err := url.Parse(value)
if err != nil {
return ""
}
return parsed.Scheme + "://" + parsed.Host
}
+47
View File
@@ -0,0 +1,47 @@
package httpapi
import (
"net"
"testing"
)
func TestValidateWebhookEndpoint(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
wantErr bool
}{
{name: "public HTTPS", value: "https://hooks.example.com/rmm"},
{name: "HTTP rejected", value: "http://hooks.example.com/rmm", wantErr: true},
{name: "credentials rejected", value: "https://user:pass@hooks.example.com/rmm", wantErr: true},
{name: "fragment rejected", value: "https://hooks.example.com/rmm#internal", wantErr: true},
{name: "loopback rejected", value: "https://127.0.0.1/rmm", wantErr: true},
{name: "private IPv4 rejected", value: "https://10.0.0.8/rmm", wantErr: true},
{name: "link local IPv6 rejected", value: "https://[fe80::1]/rmm", wantErr: true},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
if got := validateWebhookEndpoint(test.value); (got != "") != test.wantErr {
t.Fatalf("validateWebhookEndpoint(%q) = %q, wantErr=%v", test.value, got, test.wantErr)
}
})
}
}
func TestPublicWebhookIP(t *testing.T) {
t.Parallel()
if !publicWebhookIP(net.ParseIP("203.0.113.10")) {
t.Fatal("documentation-range public IP should pass local-address filtering")
}
for _, value := range []string{"127.0.0.1", "10.0.0.1", "169.254.1.1", "::1", "fe80::1"} {
if publicWebhookIP(net.ParseIP(value)) {
t.Fatalf("local address %s must be rejected", value)
}
}
}
+47
View File
@@ -109,10 +109,57 @@ type NotificationSettings struct {
PacketLossPercent int `json:"packet_loss_percent"`
LatencyThresholdMS int `json:"latency_threshold_ms"`
RepeatMinutes int `json:"repeat_minutes"`
Timezone string `json:"timezone"`
QuietHoursEnabled bool `json:"quiet_hours_enabled"`
QuietHoursStart string `json:"quiet_hours_start"`
QuietHoursEnd string `json:"quiet_hours_end"`
AlertsPausedUntil *time.Time `json:"alerts_paused_until,omitempty"`
WebhookEnabled bool `json:"webhook_enabled"`
WebhookURL string `json:"webhook_url,omitempty"`
WebhookSecret string `json:"-"`
WebhookSecretConfigured bool `json:"webhook_secret_configured"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
type DeviceNotificationSettings struct {
DeviceID string `json:"device_id"`
Enabled bool `json:"enabled"`
NotifyWarning bool `json:"notify_warning"`
NotifyCritical bool `json:"notify_critical"`
NotifyResolved bool `json:"notify_resolved"`
PausedUntil *time.Time `json:"paused_until,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
type InboxNotification struct {
ID string `json:"id"`
UserID string `json:"-"`
DeviceID string `json:"device_id,omitempty"`
IncidentID string `json:"incident_id,omitempty"`
Severity string `json:"severity"`
Event string `json:"event"`
Title string `json:"title"`
Body string `json:"body"`
ReadAt *time.Time `json:"read_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type LANClient struct {
DeviceID string `json:"device_id,omitempty"`
Key string `json:"key"`
MAC string `json:"mac,omitempty"`
IP string `json:"ip,omitempty"`
Hostname string `json:"hostname,omitempty"`
Interface string `json:"interface,omitempty"`
Connection string `json:"connection"`
Status string `json:"status"`
Confirmation string `json:"confirmation,omitempty"`
FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
LastCheckedAt time.Time `json:"last_checked_at"`
}
type NotificationDelivery struct {
ID string `json:"id"`
UserID string `json:"user_id,omitempty"`
+195
View File
@@ -0,0 +1,195 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"net"
"strings"
"time"
"rmm-openwrt/server/internal/model"
)
type clientInventory struct {
DHCPLeases []map[string]string `json:"dhcp_leases"`
Neighbors []map[string]string `json:"neighbors"`
WiFi []map[string]string `json:"wifi_clients"`
Probes []map[string]string `json:"client_probes"`
}
func (s *Store) SyncLANClients(ctx context.Context, deviceID string, inventory json.RawMessage, checkedAt time.Time) error {
var payload clientInventory
if err := json.Unmarshal(inventory, &payload); err != nil {
return nil
}
type observed struct {
key, mac, ip, hostname, iface, connection, confirmation string
confirmed bool
}
byKey := map[string]*observed{}
for _, lease := range payload.DHCPLeases {
mac := normalizeMAC(lease["mac"])
ip := strings.TrimSpace(lease["ip"])
key := clientKey(mac, ip)
if key == "" {
continue
}
byKey[key] = &observed{key: key, mac: mac, ip: ip, hostname: cleanHostname(lease["hostname"]), connection: "dhcp", confirmation: "lease"}
}
for _, neighbor := range payload.Neighbors {
mac := normalizeMAC(neighbor["mac"])
ip := strings.TrimSpace(neighbor["ip"])
key := clientKey(mac, ip)
if key == "" {
continue
}
item := byKey[key]
if item == nil {
item = &observed{key: key, mac: mac, ip: ip}
byKey[key] = item
}
item.iface = strings.TrimSpace(neighbor["interface"])
state := strings.ToUpper(strings.TrimSpace(neighbor["state"]))
if state == "REACHABLE" || state == "DELAY" || state == "PROBE" {
item.confirmed = true
item.confirmation = "neighbor:" + strings.ToLower(state)
item.connection = "wired"
}
}
for _, station := range payload.WiFi {
mac := normalizeMAC(station["mac"])
key := clientKey(mac, "")
if key == "" {
continue
}
item := byKey[key]
if item == nil {
item = &observed{key: key, mac: mac}
byKey[key] = item
}
item.iface = strings.TrimSpace(station["interface"])
item.connection = "wifi"
item.confirmation = "wifi"
item.confirmed = true
}
for _, probe := range payload.Probes {
if !strings.EqualFold(probe["reachable"], "true") {
continue
}
mac := normalizeMAC(probe["mac"])
ip := strings.TrimSpace(probe["ip"])
key := clientKey(mac, ip)
if key == "" {
continue
}
item := byKey[key]
if item == nil {
item = &observed{key: key, mac: mac, ip: ip}
byKey[key] = item
}
item.confirmed = true
item.confirmation = "active_probe"
if item.connection == "" || item.connection == "dhcp" {
item.connection = "wired"
}
}
now := notificationTimeText(checkedAt.UTC())
for _, item := range byKey {
var lastSeen any
if item.confirmed {
lastSeen = now
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO lan_clients (
device_id, client_key, mac, ip, hostname, interface, connection, confirmation,
first_seen_at, last_seen_at, last_checked_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(device_id, client_key) DO UPDATE SET
mac = CASE WHEN excluded.mac != '' THEN excluded.mac ELSE lan_clients.mac END,
ip = CASE WHEN excluded.ip != '' THEN excluded.ip ELSE lan_clients.ip END,
hostname = CASE WHEN excluded.hostname != '' THEN excluded.hostname ELSE lan_clients.hostname END,
interface = CASE WHEN excluded.interface != '' THEN excluded.interface ELSE lan_clients.interface END,
connection = CASE WHEN excluded.connection != '' THEN excluded.connection ELSE lan_clients.connection END,
confirmation = CASE WHEN excluded.confirmation != '' THEN excluded.confirmation ELSE lan_clients.confirmation END,
last_seen_at = COALESCE(excluded.last_seen_at, lan_clients.last_seen_at),
last_checked_at = excluded.last_checked_at
`, deviceID, item.key, item.mac, item.ip, item.hostname, item.iface, item.connection, item.confirmation, now, lastSeen, now)
if err != nil {
return err
}
}
return nil
}
func (s *Store) ListLANClients(ctx context.Context, deviceID string, recentFor time.Duration) ([]model.LANClient, bool, error) {
var exists bool
if err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM devices WHERE id = ?)`, deviceID).Scan(&exists); err != nil || !exists {
return nil, exists, err
}
rows, err := s.db.QueryContext(ctx, `
SELECT device_id, client_key, mac, ip, hostname, interface, connection, confirmation,
first_seen_at, last_seen_at, last_checked_at
FROM lan_clients WHERE device_id = ?
ORDER BY COALESCE(last_seen_at, '') DESC, hostname, ip
`, deviceID)
if err != nil {
return nil, true, err
}
defer rows.Close()
now := time.Now().UTC()
clients := make([]model.LANClient, 0)
for rows.Next() {
var client model.LANClient
var firstSeen, checked string
var lastSeen sql.NullString
if err := rows.Scan(&client.DeviceID, &client.Key, &client.MAC, &client.IP, &client.Hostname, &client.Interface,
&client.Connection, &client.Confirmation, &firstSeen, &lastSeen, &checked); err != nil {
return nil, true, err
}
client.FirstSeenAt = parseTime(firstSeen)
client.LastCheckedAt = parseTime(checked)
client.Status = "unconfirmed"
if lastSeen.Valid && lastSeen.String != "" {
value := parseTime(lastSeen.String)
client.LastSeenAt = &value
if now.Sub(value) <= 2*time.Minute {
client.Status = "online"
} else if now.Sub(value) <= recentFor {
client.Status = "recent"
}
}
clients = append(clients, client)
}
return clients, true, rows.Err()
}
func clientKey(mac, ip string) string {
if mac != "" {
return "mac:" + strings.ToLower(mac)
}
parsed := net.ParseIP(ip)
if parsed != nil && parsed.To4() != nil {
return "ip:" + parsed.String()
}
return ""
}
func normalizeMAC(value string) string {
mac, err := net.ParseMAC(strings.TrimSpace(value))
if err != nil {
return ""
}
return strings.ToLower(mac.String())
}
func cleanHostname(value string) string {
value = strings.TrimSpace(value)
if value == "*" {
return ""
}
if len(value) > 253 {
return value[:253]
}
return value
}
+47
View File
@@ -0,0 +1,47 @@
package store
import (
"context"
"encoding/json"
"path/filepath"
"testing"
"time"
)
func TestLANClientPresencePersistsLastSeen(t *testing.T) {
ctx := context.Background()
st, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "clients.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
device, err := st.EnrollDevice(ctx, "router", "OpenWrt")
if err != nil {
t.Fatal(err)
}
inventory, _ := json.Marshal(map[string]any{
"dhcp_leases": []map[string]string{{"mac": "10:ff:e0:21:bc:b9", "ip": "10.10.10.2", "hostname": "desktop"}},
"client_probes": []map[string]string{{"mac": "10:ff:e0:21:bc:b9", "ip": "10.10.10.2", "reachable": "true"}},
})
if _, err := st.SaveHeartbeat(ctx, device.DeviceID, inventory, json.RawMessage(`{}`)); err != nil {
t.Fatal(err)
}
clients, found, err := st.ListLANClients(ctx, device.DeviceID, 30*time.Minute)
if err != nil || !found || len(clients) != 1 {
t.Fatalf("unexpected clients: found=%v clients=%#v err=%v", found, clients, err)
}
if clients[0].Status != "online" || clients[0].LastSeenAt == nil || clients[0].Confirmation != "active_probe" {
t.Fatalf("client was not confirmed: %#v", clients[0])
}
inventory, _ = json.Marshal(map[string]any{
"dhcp_leases": []map[string]string{{"mac": "10:ff:e0:21:bc:b9", "ip": "10.10.10.2", "hostname": "desktop"}},
"client_probes": []map[string]string{{"mac": "10:ff:e0:21:bc:b9", "ip": "10.10.10.2", "reachable": "false"}},
})
if _, err := st.SaveHeartbeat(ctx, device.DeviceID, inventory, json.RawMessage(`{}`)); err != nil {
t.Fatal(err)
}
clients, _, err = st.ListLANClients(ctx, device.DeviceID, 30*time.Minute)
if err != nil || clients[0].LastSeenAt == nil {
t.Fatalf("last_seen was lost: %#v err=%v", clients, err)
}
}
@@ -0,0 +1,136 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
"rmm-openwrt/server/internal/model"
)
func (s *Store) CreateInboxNotification(ctx context.Context, notification model.InboxNotification, dedupeKey string) (model.InboxNotification, bool, error) {
id, err := randomID("inb")
if err != nil {
return model.InboxNotification{}, false, err
}
now := nowText()
res, err := s.db.ExecContext(ctx, `
INSERT OR IGNORE INTO inbox_notifications
(id, user_id, device_id, incident_id, dedupe_key, severity, event, title, body, created_at)
VALUES (?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?)
`, id, notification.UserID, notification.DeviceID, notification.IncidentID, dedupeKey,
notification.Severity, notification.Event, notification.Title, notification.Body, now)
if err != nil {
return model.InboxNotification{}, false, err
}
inserted, _ := res.RowsAffected()
if inserted == 0 {
return model.InboxNotification{}, false, nil
}
notification.ID = id
notification.CreatedAt = parseTime(now)
return notification, true, nil
}
func (s *Store) ListInboxNotifications(ctx context.Context, userID string, limit int) ([]model.InboxNotification, int, error) {
if limit <= 0 || limit > 100 {
limit = 30
}
var unread int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM inbox_notifications WHERE user_id = ? AND read_at IS NULL`, userID).Scan(&unread); err != nil {
return nil, 0, err
}
rows, err := s.db.QueryContext(ctx, `
SELECT id, user_id, COALESCE(device_id, ''), incident_id, severity, event, title, body, read_at, created_at
FROM inbox_notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ?
`, userID, limit)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]model.InboxNotification, 0)
for rows.Next() {
var item model.InboxNotification
var readAt sql.NullString
var createdAt string
if err := rows.Scan(&item.ID, &item.UserID, &item.DeviceID, &item.IncidentID, &item.Severity,
&item.Event, &item.Title, &item.Body, &readAt, &createdAt); err != nil {
return nil, 0, err
}
item.CreatedAt = parseTime(createdAt)
if readAt.Valid {
value := parseTime(readAt.String)
item.ReadAt = &value
}
items = append(items, item)
}
return items, unread, rows.Err()
}
func (s *Store) MarkInboxNotificationRead(ctx context.Context, userID, id string) (bool, error) {
res, err := s.db.ExecContext(ctx, `
UPDATE inbox_notifications SET read_at = COALESCE(read_at, ?) WHERE id = ? AND user_id = ?
`, nowText(), id, userID)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
func (s *Store) MarkAllInboxNotificationsRead(ctx context.Context, userID string) error {
_, err := s.db.ExecContext(ctx, `UPDATE inbox_notifications SET read_at = COALESCE(read_at, ?) WHERE user_id = ?`, nowText(), userID)
return err
}
func (s *Store) GetDeviceNotificationSettings(ctx context.Context, userID, deviceID string) (model.DeviceNotificationSettings, bool, error) {
settings := model.DeviceNotificationSettings{
DeviceID: deviceID, Enabled: true, NotifyWarning: true, NotifyCritical: true, NotifyResolved: true,
}
var enabled, warning, critical, resolved int
var paused sql.NullString
var updated string
err := s.db.QueryRowContext(ctx, `
SELECT enabled, notify_warning, notify_critical, notify_resolved, paused_until, updated_at
FROM device_notification_settings WHERE user_id = ? AND device_id = ?
`, userID, deviceID).Scan(&enabled, &warning, &critical, &resolved, &paused, &updated)
if errors.Is(err, sql.ErrNoRows) {
return settings, false, nil
}
if err != nil {
return model.DeviceNotificationSettings{}, false, err
}
settings.Enabled = enabled != 0
settings.NotifyWarning = warning != 0
settings.NotifyCritical = critical != 0
settings.NotifyResolved = resolved != 0
settings.UpdatedAt = parseTime(updated)
if paused.Valid && paused.String != "" {
value := parseTime(paused.String)
settings.PausedUntil = &value
}
return settings, true, nil
}
func (s *Store) UpsertDeviceNotificationSettings(ctx context.Context, userID string, settings model.DeviceNotificationSettings) (model.DeviceNotificationSettings, error) {
_, err := s.db.ExecContext(ctx, `
INSERT INTO device_notification_settings
(user_id, device_id, enabled, notify_warning, notify_critical, notify_resolved, paused_until, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, device_id) DO UPDATE SET
enabled = excluded.enabled, notify_warning = excluded.notify_warning,
notify_critical = excluded.notify_critical, notify_resolved = excluded.notify_resolved,
paused_until = excluded.paused_until, updated_at = excluded.updated_at
`, userID, settings.DeviceID, boolInt(settings.Enabled), boolInt(settings.NotifyWarning),
boolInt(settings.NotifyCritical), boolInt(settings.NotifyResolved), nullableTime(settings.PausedUntil), nowText())
if err != nil {
return model.DeviceNotificationSettings{}, err
}
stored, _, err := s.GetDeviceNotificationSettings(ctx, userID, settings.DeviceID)
return stored, err
}
func inboxRetentionCutoff(days int) time.Time {
return time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour)
}
+38 -5
View File
@@ -20,22 +20,30 @@ func DefaultNotificationSettings(userID string) model.NotificationSettings {
DiskThresholdPercent: 85,
PacketLossPercent: 20,
LatencyThresholdMS: 200,
Timezone: "UTC",
QuietHoursStart: "22:00",
QuietHoursEnd: "08:00",
}
}
func (s *Store) GetNotificationSettings(ctx context.Context, userID string) (model.NotificationSettings, bool, error) {
settings := DefaultNotificationSettings(userID)
var emailEnabled, telegramEnabled, notifyWarning, notifyCritical, notifyResolved int
var quietHoursEnabled, webhookEnabled int
var createdAt, updatedAt string
var pausedUntil sql.NullString
err := s.db.QueryRowContext(ctx, `
SELECT email_enabled, telegram_enabled, telegram_chat_id, notify_warning, notify_critical, notify_resolved,
memory_threshold_percent, disk_threshold_percent, packet_loss_percent, latency_threshold_ms,
repeat_minutes, created_at, updated_at
repeat_minutes, timezone, quiet_hours_enabled, quiet_hours_start, quiet_hours_end, alerts_paused_until,
webhook_enabled, webhook_url, webhook_secret, created_at, updated_at
FROM notification_settings WHERE user_id = ?
`, userID).Scan(
&emailEnabled, &telegramEnabled, &settings.TelegramChatID, &notifyWarning, &notifyCritical, &notifyResolved,
&settings.MemoryThresholdPercent, &settings.DiskThresholdPercent, &settings.PacketLossPercent,
&settings.LatencyThresholdMS, &settings.RepeatMinutes, &createdAt, &updatedAt,
&settings.LatencyThresholdMS, &settings.RepeatMinutes, &settings.Timezone, &quietHoursEnabled,
&settings.QuietHoursStart, &settings.QuietHoursEnd, &pausedUntil, &webhookEnabled,
&settings.WebhookURL, &settings.WebhookSecret, &createdAt, &updatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return settings, false, nil
@@ -49,6 +57,13 @@ FROM notification_settings WHERE user_id = ?
settings.NotifyWarning = notifyWarning != 0
settings.NotifyCritical = notifyCritical != 0
settings.NotifyResolved = notifyResolved != 0
settings.QuietHoursEnabled = quietHoursEnabled != 0
settings.WebhookEnabled = webhookEnabled != 0
settings.WebhookSecretConfigured = settings.WebhookSecret != ""
if pausedUntil.Valid && pausedUntil.String != "" {
value := parseTime(pausedUntil.String)
settings.AlertsPausedUntil = &value
}
settings.CreatedAt = parseTime(createdAt)
settings.UpdatedAt = parseTime(updatedAt)
return settings, true, nil
@@ -60,8 +75,9 @@ func (s *Store) UpsertNotificationSettings(ctx context.Context, settings model.N
INSERT INTO notification_settings (
user_id, email_enabled, telegram_enabled, telegram_chat_id, notify_warning, notify_critical,
notify_resolved, memory_threshold_percent, disk_threshold_percent, packet_loss_percent,
latency_threshold_ms, repeat_minutes, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
latency_threshold_ms, repeat_minutes, timezone, quiet_hours_enabled, quiet_hours_start, quiet_hours_end,
alerts_paused_until, webhook_enabled, webhook_url, webhook_secret, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
email_enabled = excluded.email_enabled,
telegram_enabled = excluded.telegram_enabled,
@@ -74,11 +90,21 @@ ON CONFLICT(user_id) DO UPDATE SET
packet_loss_percent = excluded.packet_loss_percent,
latency_threshold_ms = excluded.latency_threshold_ms,
repeat_minutes = excluded.repeat_minutes,
timezone = excluded.timezone,
quiet_hours_enabled = excluded.quiet_hours_enabled,
quiet_hours_start = excluded.quiet_hours_start,
quiet_hours_end = excluded.quiet_hours_end,
alerts_paused_until = excluded.alerts_paused_until,
webhook_enabled = excluded.webhook_enabled,
webhook_url = excluded.webhook_url,
webhook_secret = CASE WHEN excluded.webhook_secret != '' THEN excluded.webhook_secret ELSE notification_settings.webhook_secret END,
updated_at = excluded.updated_at
`, settings.UserID, boolInt(settings.EmailEnabled), boolInt(settings.TelegramEnabled), strings.TrimSpace(settings.TelegramChatID),
boolInt(settings.NotifyWarning), boolInt(settings.NotifyCritical), boolInt(settings.NotifyResolved),
settings.MemoryThresholdPercent, settings.DiskThresholdPercent, settings.PacketLossPercent,
settings.LatencyThresholdMS, settings.RepeatMinutes, now, now)
settings.LatencyThresholdMS, settings.RepeatMinutes, settings.Timezone, boolInt(settings.QuietHoursEnabled),
settings.QuietHoursStart, settings.QuietHoursEnd, nullableTime(settings.AlertsPausedUntil),
boolInt(settings.WebhookEnabled), strings.TrimSpace(settings.WebhookURL), strings.TrimSpace(settings.WebhookSecret), now, now)
if err != nil {
return model.NotificationSettings{}, err
}
@@ -355,3 +381,10 @@ func boolInt(value bool) int {
func notificationTimeText(value time.Time) string {
return value.UTC().Format(time.RFC3339Nano)
}
func nullableTime(value *time.Time) any {
if value == nil || value.IsZero() {
return nil
}
return notificationTimeText(value.UTC())
}
+72
View File
@@ -213,6 +213,64 @@ CREATE TABLE IF NOT EXISTS notification_deliveries (
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_user_created ON notification_deliveries(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_status_created ON notification_deliveries(status, created_at);
CREATE TABLE IF NOT EXISTS device_notification_settings (
user_id TEXT NOT NULL,
device_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
notify_warning INTEGER NOT NULL DEFAULT 1,
notify_critical INTEGER NOT NULL DEFAULT 1,
notify_resolved INTEGER NOT NULL DEFAULT 1,
paused_until TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, device_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(device_id) REFERENCES devices(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS inbox_notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
device_id TEXT,
incident_id TEXT NOT NULL DEFAULT '',
dedupe_key TEXT NOT NULL UNIQUE,
severity TEXT NOT NULL,
event TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
read_at TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(device_id) REFERENCES devices(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS contact_verifications (
user_id TEXT NOT NULL,
channel TEXT NOT NULL,
destination TEXT NOT NULL,
code_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
verified_at TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY(user_id, channel),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS lan_clients (
device_id TEXT NOT NULL,
client_key TEXT NOT NULL,
mac TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
interface TEXT NOT NULL DEFAULT '',
connection TEXT NOT NULL DEFAULT '',
confirmation TEXT NOT NULL DEFAULT '',
first_seen_at TEXT NOT NULL,
last_seen_at TEXT,
last_checked_at TEXT NOT NULL,
PRIMARY KEY(device_id, client_key),
FOREIGN KEY(device_id) REFERENCES devices(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS remote_sessions (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
@@ -368,6 +426,14 @@ CREATE TABLE IF NOT EXISTS device_access_sessions (
`ALTER TABLE notification_deliveries ADD COLUMN last_attempt_at TEXT`,
`ALTER TABLE notification_deliveries ADD COLUMN next_attempt_at TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE notification_deliveries ADD COLUMN lease_expires_at TEXT`,
`ALTER TABLE notification_settings ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'`,
`ALTER TABLE notification_settings ADD COLUMN quiet_hours_enabled INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE notification_settings ADD COLUMN quiet_hours_start TEXT NOT NULL DEFAULT '22:00'`,
`ALTER TABLE notification_settings ADD COLUMN quiet_hours_end TEXT NOT NULL DEFAULT '08:00'`,
`ALTER TABLE notification_settings ADD COLUMN alerts_paused_until TEXT`,
`ALTER TABLE notification_settings ADD COLUMN webhook_enabled INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE notification_settings ADD COLUMN webhook_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE notification_settings ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT ''`,
} {
if _, err := s.db.ExecContext(ctx, stmt); err != nil && !isDuplicateColumnError(err) {
return err
@@ -387,6 +453,9 @@ CREATE TABLE IF NOT EXISTS device_access_sessions (
`CREATE INDEX IF NOT EXISTS idx_notification_deliveries_user_created ON notification_deliveries(user_id, created_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_notification_deliveries_status_created ON notification_deliveries(status, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_notification_deliveries_ready ON notification_deliveries(status, next_attempt_at, lease_expires_at)`,
`CREATE INDEX IF NOT EXISTS idx_inbox_notifications_user_created ON inbox_notifications(user_id, created_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_inbox_notifications_user_unread ON inbox_notifications(user_id, read_at)`,
`CREATE INDEX IF NOT EXISTS idx_lan_clients_device_seen ON lan_clients(device_id, last_seen_at DESC)`,
} {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
return err
@@ -487,6 +556,9 @@ WHERE id = ?
if err := s.AddMetricSample(ctx, deviceID, inventory, metrics, now); err != nil {
return nil, err
}
if err := s.SyncLANClients(ctx, deviceID, inventory, parseTime(now)); err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, `
SELECT id, device_id, type, args_json, status, result_json, output, exit_code, attempt_count, max_attempts, created_at, expires_at, claimed_at, completed_at, cancelled_at, expired_at
+59
View File
@@ -0,0 +1,59 @@
package store
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"rmm-openwrt/server/internal/authn"
)
func VerificationCodeHash(code string) (string, error) {
return authn.HashPassword("verification:" + strings.TrimSpace(code))
}
func (s *Store) BeginContactVerification(ctx context.Context, userID, channel, destination, codeHash string, expiresAt time.Time) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO contact_verifications (user_id, channel, destination, code_hash, expires_at, verified_at, created_at)
VALUES (?, ?, ?, ?, ?, NULL, ?)
ON CONFLICT(user_id, channel) DO UPDATE SET
destination = excluded.destination, code_hash = excluded.code_hash,
expires_at = excluded.expires_at, verified_at = NULL, created_at = excluded.created_at
`, userID, channel, destination, codeHash, notificationTimeText(expiresAt), nowText())
return err
}
func (s *Store) ConfirmContactVerification(ctx context.Context, userID, channel, code string) (string, bool, error) {
var destination, expected, expires string
err := s.db.QueryRowContext(ctx, `
SELECT destination, code_hash, expires_at FROM contact_verifications
WHERE user_id = ? AND channel = ? AND verified_at IS NULL
`, userID, channel).Scan(&destination, &expected, &expires)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
if time.Now().UTC().After(parseTime(expires)) ||
!authn.VerifyPassword(expected, "verification:"+strings.TrimSpace(code)) {
return "", false, nil
}
_, err = s.db.ExecContext(ctx, `
UPDATE contact_verifications SET verified_at = ? WHERE user_id = ? AND channel = ?
`, nowText(), userID, channel)
return destination, err == nil, err
}
func (s *Store) ContactVerified(ctx context.Context, userID, channel, destination string) (bool, error) {
var verified bool
err := s.db.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM contact_verifications
WHERE user_id = ? AND channel = ? AND destination = ? AND verified_at IS NOT NULL
)
`, userID, channel, strings.TrimSpace(destination)).Scan(&verified)
return verified, err
}
+247 -11
View File
@@ -25,6 +25,9 @@ const state = {
notificationSettings: null,
notificationChannels: {},
notifications: [],
inboxNotifications: [],
notificationUnread: 0,
lanClients: [],
remoteSessions: [],
selectedCommand: null,
presetReview: null,
@@ -38,7 +41,7 @@ const state = {
let eventSource = null;
let liveRefreshTimer = null;
const EXPECTED_AGENT_VERSION = "0.6.7";
const EXPECTED_AGENT_VERSION = "0.6.8";
const els = {
loginView: document.querySelector("#loginView"),
@@ -235,6 +238,12 @@ const els = {
notificationTelegramHint: document.querySelector("#notificationTelegramHint"),
notificationTelegramChatRow: document.querySelector("#notificationTelegramChatRow"),
notificationTelegramChatId: document.querySelector("#notificationTelegramChatId"),
verifyEmailBtn: document.querySelector("#verifyEmailBtn"),
verifyTelegramBtn: document.querySelector("#verifyTelegramBtn"),
emailVerificationCode: document.querySelector("#emailVerificationCode"),
telegramVerificationCode: document.querySelector("#telegramVerificationCode"),
confirmEmailBtn: document.querySelector("#confirmEmailBtn"),
confirmTelegramBtn: document.querySelector("#confirmTelegramBtn"),
notificationWarningEnabled: document.querySelector("#notificationWarningEnabled"),
notificationCriticalEnabled: document.querySelector("#notificationCriticalEnabled"),
notificationResolvedEnabled: document.querySelector("#notificationResolvedEnabled"),
@@ -243,10 +252,33 @@ const els = {
notificationPacketLossThreshold: document.querySelector("#notificationPacketLossThreshold"),
notificationLatencyThreshold: document.querySelector("#notificationLatencyThreshold"),
notificationRepeatMinutes: document.querySelector("#notificationRepeatMinutes"),
notificationTimezone: document.querySelector("#notificationTimezone"),
notificationPausedUntil: document.querySelector("#notificationPausedUntil"),
notificationQuietEnabled: document.querySelector("#notificationQuietEnabled"),
notificationQuietStart: document.querySelector("#notificationQuietStart"),
notificationQuietEnd: document.querySelector("#notificationQuietEnd"),
notificationWebhookEnabled: document.querySelector("#notificationWebhookEnabled"),
notificationWebhookUrl: document.querySelector("#notificationWebhookUrl"),
notificationWebhookSecret: document.querySelector("#notificationWebhookSecret"),
notificationDeviceSelect: document.querySelector("#notificationDeviceSelect"),
notificationDeviceEnabled: document.querySelector("#notificationDeviceEnabled"),
notificationDeviceCritical: document.querySelector("#notificationDeviceCritical"),
notificationDeviceWarning: document.querySelector("#notificationDeviceWarning"),
notificationDeviceResolved: document.querySelector("#notificationDeviceResolved"),
notificationDevicePausedUntil: document.querySelector("#notificationDevicePausedUntil"),
saveDeviceNotificationSettingsBtn: document.querySelector("#saveDeviceNotificationSettingsBtn"),
notificationSettingsMessage: document.querySelector("#notificationSettingsMessage"),
testNotificationsBtn: document.querySelector("#testNotificationsBtn"),
refreshNotificationsBtn: document.querySelector("#refreshNotificationsBtn"),
notificationHistory: document.querySelector("#notificationHistory"),
notificationCenterBtn: document.querySelector("#notificationCenterBtn"),
notificationUnreadCount: document.querySelector("#notificationUnreadCount"),
mobileNotificationUnreadCount: document.querySelector("#mobileNotificationUnreadCount"),
notificationCenterDialog: document.querySelector("#notificationCenterDialog"),
closeNotificationCenterBtn: document.querySelector("#closeNotificationCenterBtn"),
markAllNotificationsReadBtn: document.querySelector("#markAllNotificationsReadBtn"),
notificationCenterSummary: document.querySelector("#notificationCenterSummary"),
notificationCenterList: document.querySelector("#notificationCenterList"),
logoutAllBtn: document.querySelector("#logoutAllBtn"),
userManagementSection: document.querySelector("#userManagementSection"),
userList: document.querySelector("#userList"),
@@ -356,6 +388,9 @@ function connectLiveUpdates() {
if (document.visibilityState !== "visible") return;
scheduleLiveRefresh();
});
eventSource.addEventListener("notifications", () => {
if (document.visibilityState === "visible") loadNotificationCenter().catch(() => {});
});
eventSource.onerror = () => {
state.liveConnected = false;
setLiveState("polling", "Polling · 30 сек.");
@@ -463,6 +498,7 @@ function showApp(user) {
els.loginError.textContent = "";
if (location.pathname !== "/app") history.replaceState(null, "", `/app${location.hash}`);
connectLiveUpdates();
loadNotificationCenter().catch(() => {});
}
function formatLoadAverage(value) {
@@ -1094,6 +1130,18 @@ function formatBytes(value) {
}
function normalizedClients(device) {
if (device && device.id === state.selectedDeviceId && state.lanClients.length) {
return state.lanClients.map((client) => ({
name: client.hostname || "",
ip: client.ip || "-",
mac: client.mac || "-",
connection: client.connection === "wifi" ? `Wi-Fi ${client.interface || ""}`.trim() : `LAN ${client.interface || ""}`.trim(),
type: client.connection === "wifi" ? "wifi" : "wired",
online: client.status === "online",
presence: client.status || "unconfirmed",
lastSeenAt: client.last_seen_at || "",
}));
}
const leases = Array.isArray(device.inventory && device.inventory.dhcp_leases) ? device.inventory.dhcp_leases : [];
const wifi = Array.isArray(device.inventory && device.inventory.wifi_clients) ? device.inventory.wifi_clients : [];
const neighbors = Array.isArray(device.inventory && device.inventory.neighbors) ? device.inventory.neighbors : [];
@@ -1135,7 +1183,7 @@ function normalizedClients(device) {
presence: "online",
});
}
const presenceRank = { online: 0, stale: 1, reserved: 2 };
const presenceRank = { online: 0, recent: 1, stale: 1, unconfirmed: 2, reserved: 2 };
return [...byMac.values()].sort((left, right) => {
const rank = (presenceRank[left.presence] ?? 3) - (presenceRank[right.presence] ?? 3);
if (rank !== 0) return rank;
@@ -1162,7 +1210,7 @@ function renderClients(device) {
}
for (const client of filtered) {
const presence = client.presence || (client.online ? "online" : "reserved");
const presenceLabel = presence === "online" ? "В сети" : (presence === "stale" ? "Не подтверждён" : "Нет в сети");
const presenceLabel = presence === "online" ? "В сети" : (presence === "recent" || presence === "stale" ? "Недавно был в сети" : "Не подтверждён");
const row = document.createElement("div");
row.className = "client-row";
row.innerHTML = `
@@ -1170,7 +1218,7 @@ function renderClients(device) {
<span>${escapeHtml(client.ip)}</span>
<code>${escapeHtml(client.mac)}</code>
<span>${escapeHtml(client.connection)}</span>
<span>${escapeHtml([client.signal, client.rate].filter(Boolean).join(" · ") || "-")}</span>
<span>${escapeHtml([client.signal, client.rate, client.lastSeenAt ? `был ${formatDate(client.lastSeenAt)}` : ""].filter(Boolean).join(" · ") || "-")}</span>
<span class="client-online ${presence}"><i></i>${presenceLabel}</span>
`;
els.clientList.appendChild(row);
@@ -1341,7 +1389,7 @@ async function loadDevices() {
renderDevices();
renderDeviceDetail(currentDevice());
if (state.selectedDeviceId) {
await Promise.all([loadCommands(), loadAudit(), loadMetricsHistory(), loadAlerts(), loadRemoteSessions()]);
await Promise.all([loadCommands(), loadAudit(), loadMetricsHistory(), loadAlerts(), loadRemoteSessions(), loadLANClients()]);
}
state.lastUpdatedAt = new Date();
updateLiveStateLabel();
@@ -1356,11 +1404,19 @@ async function selectDevice(id) {
state.auditOffset = 0;
state.commands = [];
state.auditEvents = [];
state.lanClients = [];
renderDevices();
renderDeviceDetail(currentDevice());
setMobileRoute("fleet");
window.scrollTo({ top: 0, behavior: "auto" });
await Promise.all([loadCommands(), loadAudit(), loadMetricsHistory(), loadAlerts(), loadRemoteSessions()]);
await Promise.all([loadCommands(), loadAudit(), loadMetricsHistory(), loadAlerts(), loadRemoteSessions(), loadLANClients()]);
}
async function loadLANClients() {
if (!state.selectedDeviceId) return;
const response = await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/clients`);
state.lanClients = response.clients || [];
renderClients(currentDevice());
}
function showFleet() {
@@ -1505,7 +1561,7 @@ function showProfile() {
setFormMessage(els.passwordMessage, "");
els.passwordForm.reset();
if (!els.profileDialog.open) els.profileDialog.showModal();
loadNotificationCenter().catch((error) => {
loadNotificationPreferences().catch((error) => {
setFormMessage(els.notificationSettingsMessage, `Не удалось загрузить уведомления: ${error.message}`, "error");
});
if (state.user && state.user.role === "admin") {
@@ -1535,11 +1591,11 @@ async function saveProfile() {
}),
});
showApp(response.user);
await loadNotificationCenter();
await loadNotificationPreferences();
setFormMessage(els.profileMessage, "Профиль сохранён", "success");
}
async function loadNotificationCenter() {
async function loadNotificationPreferences() {
setFormMessage(els.notificationSettingsMessage, "Загружаем настройки…");
const [settingsResponse, historyResponse] = await Promise.all([
api("/api/notifications/settings"),
@@ -1559,14 +1615,18 @@ function renderNotificationSettings() {
const telegram = state.notificationChannels.telegram || {};
els.notificationEmailEnabled.checked = Boolean(settings.email_enabled);
els.notificationEmailEnabled.disabled = !email.available || !email.profile_email_configured;
els.verifyEmailBtn.disabled = !email.available || !email.profile_email_configured;
els.confirmEmailBtn.disabled = !email.available || !email.profile_email_configured;
els.notificationEmailHint.textContent = !email.available
? "SMTP не настроен на сервере"
: !email.profile_email_configured
? "Сначала добавьте e-mail в профиль"
: `Получатель: ${email.destination || "e-mail профиля"}`;
: `${email.verified ? "Подтверждён" : "Требуется подтверждение"} · ${email.destination || "e-mail профиля"}`;
els.notificationTelegramEnabled.checked = Boolean(settings.telegram_enabled);
els.notificationTelegramEnabled.disabled = !telegram.available;
els.notificationTelegramHint.textContent = telegram.available ? "Сообщения от RMM-бота" : "Bot token не настроен на сервере";
els.verifyTelegramBtn.disabled = !telegram.available;
els.confirmTelegramBtn.disabled = !telegram.available;
els.notificationTelegramHint.textContent = telegram.available ? (telegram.verified ? "Telegram подтверждён" : "Подтвердите кодом из сообщения") : "Bot token не настроен на сервере";
els.notificationTelegramChatId.value = settings.telegram_chat_id || "";
els.notificationTelegramChatId.disabled = !telegram.available;
els.notificationTelegramChatRow.classList.toggle("is-disabled", !telegram.available);
@@ -1578,6 +1638,16 @@ function renderNotificationSettings() {
els.notificationPacketLossThreshold.value = settings.packet_loss_percent || 20;
els.notificationLatencyThreshold.value = settings.latency_threshold_ms || 200;
els.notificationRepeatMinutes.value = String(settings.repeat_minutes || 0);
els.notificationTimezone.value = settings.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
els.notificationPausedUntil.value = dateTimeLocalValue(settings.alerts_paused_until);
els.notificationQuietEnabled.checked = Boolean(settings.quiet_hours_enabled);
els.notificationQuietStart.value = settings.quiet_hours_start || "22:00";
els.notificationQuietEnd.value = settings.quiet_hours_end || "08:00";
els.notificationWebhookEnabled.checked = Boolean(settings.webhook_enabled);
els.notificationWebhookUrl.value = settings.webhook_url || "";
els.notificationWebhookSecret.value = "";
els.notificationWebhookSecret.placeholder = settings.webhook_secret_configured ? "Секрет уже настроен" : "Не менее 32 символов";
renderNotificationDeviceOptions();
}
async function saveNotificationSettings() {
@@ -1596,6 +1666,14 @@ async function saveNotificationSettings() {
packet_loss_percent: Number(els.notificationPacketLossThreshold.value),
latency_threshold_ms: Number(els.notificationLatencyThreshold.value),
repeat_minutes: Number(els.notificationRepeatMinutes.value),
timezone: els.notificationTimezone.value.trim() || "UTC",
alerts_paused_until: isoFromDateTimeLocal(els.notificationPausedUntil.value),
quiet_hours_enabled: els.notificationQuietEnabled.checked,
quiet_hours_start: els.notificationQuietStart.value || "22:00",
quiet_hours_end: els.notificationQuietEnd.value || "08:00",
webhook_enabled: els.notificationWebhookEnabled.checked,
webhook_url: els.notificationWebhookUrl.value.trim(),
webhook_secret: els.notificationWebhookSecret.value,
}),
});
state.notificationSettings = response.settings || {};
@@ -1652,6 +1730,150 @@ function renderNotificationHistory() {
}
}
function dateTimeLocalValue(value) {
if (!value) return "";
const date = new Date(value);
const offset = date.getTimezoneOffset() * 60000;
return new Date(date.getTime() - offset).toISOString().slice(0, 16);
}
async function requestContactVerification(channel) {
const destination = channel === "telegram" ? els.notificationTelegramChatId.value.trim() : "";
await api(`/api/notifications/verify/${channel}/request`, {
method: "POST",
body: JSON.stringify({ destination }),
});
setFormMessage(els.notificationSettingsMessage, `Код отправлен в ${channel === "email" ? "e-mail" : "Telegram"}`, "success");
}
async function confirmContactVerification(channel) {
const input = channel === "email" ? els.emailVerificationCode : els.telegramVerificationCode;
await api(`/api/notifications/verify/${channel}/confirm`, {
method: "POST",
body: JSON.stringify({ code: input.value.trim() }),
});
input.value = "";
await loadNotificationPreferences();
setFormMessage(els.notificationSettingsMessage, `${channel === "email" ? "E-mail" : "Telegram"} подтверждён`, "success");
}
function isoFromDateTimeLocal(value) {
return value ? new Date(value).toISOString() : "";
}
function renderNotificationDeviceOptions() {
const current = els.notificationDeviceSelect.value;
els.notificationDeviceSelect.innerHTML = '<option value="">Выберите роутер</option>';
for (const device of state.devices) {
const option = document.createElement("option");
option.value = device.id;
option.textContent = deviceDisplayName(device);
els.notificationDeviceSelect.appendChild(option);
}
if (state.devices.some((device) => device.id === current)) els.notificationDeviceSelect.value = current;
setDeviceNotificationControlsDisabled(!els.notificationDeviceSelect.value);
}
function setDeviceNotificationControlsDisabled(disabled) {
els.notificationDeviceEnabled.disabled = disabled;
els.notificationDeviceWarning.disabled = disabled;
els.notificationDeviceCritical.disabled = disabled;
els.notificationDeviceResolved.disabled = disabled;
els.notificationDevicePausedUntil.disabled = disabled;
els.saveDeviceNotificationSettingsBtn.disabled = disabled;
}
async function loadDeviceNotificationSettings() {
const deviceId = els.notificationDeviceSelect.value;
setDeviceNotificationControlsDisabled(!deviceId);
if (!deviceId) return;
const response = await api(`/api/devices/${encodeURIComponent(deviceId)}/notification-settings`);
const settings = response.settings || {};
els.notificationDeviceEnabled.checked = settings.enabled !== false;
els.notificationDeviceWarning.checked = settings.notify_warning !== false;
els.notificationDeviceCritical.checked = settings.notify_critical !== false;
els.notificationDeviceResolved.checked = settings.notify_resolved !== false;
els.notificationDevicePausedUntil.value = dateTimeLocalValue(settings.paused_until);
}
async function saveDeviceNotificationSettings() {
const deviceId = els.notificationDeviceSelect.value;
if (!deviceId) {
setFormMessage(els.notificationSettingsMessage, "Выберите роутер", "error");
return;
}
await api(`/api/devices/${encodeURIComponent(deviceId)}/notification-settings`, {
method: "PATCH",
body: JSON.stringify({
enabled: els.notificationDeviceEnabled.checked,
notify_warning: els.notificationDeviceWarning.checked,
notify_critical: els.notificationDeviceCritical.checked,
notify_resolved: els.notificationDeviceResolved.checked,
paused_until: isoFromDateTimeLocal(els.notificationDevicePausedUntil.value),
}),
});
setFormMessage(els.notificationSettingsMessage, "Настройки роутера сохранены", "success");
}
async function loadNotificationCenter() {
const response = await api("/api/notification-center?limit=50");
state.inboxNotifications = response.notifications || [];
state.notificationUnread = Number(response.unread || 0);
renderNotificationCenter();
}
function renderNotificationCenter() {
const unread = state.notificationUnread;
els.notificationUnreadCount.textContent = unread > 99 ? "99+" : String(unread);
els.notificationUnreadCount.classList.toggle("is-hidden", unread === 0);
els.mobileNotificationUnreadCount.textContent = unread > 99 ? "99+" : String(unread);
els.mobileNotificationUnreadCount.classList.toggle("is-hidden", unread === 0);
els.notificationCenterSummary.textContent = unread ? `${unread} непрочитанных` : "Новых событий нет";
els.notificationCenterList.innerHTML = "";
if (!state.inboxNotifications.length) {
els.notificationCenterList.innerHTML = inlineStateMarkup("Уведомлений пока нет", "Новые проблемы и восстановления появятся здесь.");
return;
}
const groups = new Map();
for (const item of state.inboxNotifications) {
const key = item.incident_id || item.id;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(item);
}
for (const items of groups.values()) {
const item = items[0];
const button = document.createElement("button");
button.type = "button";
button.className = `notification-center-item severity-${item.severity || "warning"} ${item.read_at ? "" : "is-unread"}`;
button.innerHTML = `
<span class="notification-center-dot"></span>
<span><strong>${escapeHtml(item.title)}</strong><small>${escapeHtml(items.length > 1 ? `${items.length} связанных событий · ${item.body}` : item.body)}</small></span>
<time>${escapeHtml(formatDate(item.created_at))}</time>
`;
button.addEventListener("click", async () => {
for (const current of items.filter((entry) => !entry.read_at)) {
await api(`/api/notification-center/${encodeURIComponent(current.id)}/read`, { method: "POST" });
}
if (item.device_id) {
els.notificationCenterDialog.close();
await selectDevice(item.device_id);
}
await loadNotificationCenter();
});
els.notificationCenterList.appendChild(button);
}
}
async function showNotificationCenter() {
await loadNotificationCenter();
if (!els.notificationCenterDialog.open) els.notificationCenterDialog.showModal();
}
async function markAllNotificationsRead() {
await api("/api/notification-center/read-all", { method: "POST" });
await loadNotificationCenter();
}
function notificationStatusLabel(status) {
return {
queued: "В очереди",
@@ -2616,6 +2838,13 @@ els.fleetNavBtn.addEventListener("click", showFleet);
els.problemsNavBtn.addEventListener("click", () => openDeviceArea("overview", "#alertList", "problems").catch(reportError));
els.operationsNavBtn.addEventListener("click", () => openDeviceArea("operations", "#remoteAccessPanel", "operations").catch(reportError));
els.profileBtn.addEventListener("click", showProfile);
els.notificationCenterBtn.addEventListener("click", () => showNotificationCenter().catch(reportError));
els.closeNotificationCenterBtn.addEventListener("click", () => els.notificationCenterDialog.close());
els.notificationCenterDialog.addEventListener("cancel", (event) => {
event.preventDefault();
els.notificationCenterDialog.close();
});
els.markAllNotificationsReadBtn.addEventListener("click", () => markAllNotificationsRead().catch(reportError));
for (const button of document.querySelectorAll(".mobile-nav-item")) {
button.addEventListener("click", () => {
@@ -2623,6 +2852,7 @@ for (const button of document.querySelectorAll(".mobile-nav-item")) {
if (route === "fleet") showFleet();
if (route === "problems") openDeviceArea("overview", "#alertList", "problems").catch(reportError);
if (route === "operations") openDeviceArea("operations", "#remoteAccessPanel", "operations").catch(reportError);
if (route === "notifications") showNotificationCenter().catch(reportError);
if (route === "profile") showProfile();
});
}
@@ -2654,6 +2884,12 @@ els.testNotificationsBtn.addEventListener("click", () => {
els.refreshNotificationsBtn.addEventListener("click", () => {
loadNotificationHistory().catch((error) => setFormMessage(els.notificationSettingsMessage, notificationErrorMessage(error), "error"));
});
els.verifyEmailBtn.addEventListener("click", () => requestContactVerification("email").catch(reportError));
els.verifyTelegramBtn.addEventListener("click", () => requestContactVerification("telegram").catch(reportError));
els.confirmEmailBtn.addEventListener("click", () => confirmContactVerification("email").catch(reportError));
els.confirmTelegramBtn.addEventListener("click", () => confirmContactVerification("telegram").catch(reportError));
els.notificationDeviceSelect.addEventListener("change", () => loadDeviceNotificationSettings().catch(reportError));
els.saveDeviceNotificationSettingsBtn.addEventListener("click", () => saveDeviceNotificationSettings().catch(reportError));
els.logoutAllBtn.addEventListener("click", () => logoutAll().catch(reportError));
els.closeLuciStateBtn.addEventListener("click", () => els.luciStateDialog.close());
+58
View File
@@ -127,6 +127,12 @@
<a class="sidebar-legal-link" href="/legal.html">Лицензия · Исходный код</a>
<div class="operator-row">
<button id="notificationCenterBtn" class="icon-button notification-bell" type="button" title="Уведомления" aria-label="Открыть уведомления">
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/>
</svg>
<span id="notificationUnreadCount" class="notification-unread is-hidden">0</span>
</button>
<button id="profileBtn" class="operator-profile" type="button" aria-label="Открыть личный кабинет">
<span class="operator-avatar">О</span>
<span class="operator-copy">
@@ -781,6 +787,11 @@
<button class="mobile-nav-item is-active" data-mobile-route="fleet" type="button"><span></span>Объекты</button>
<button class="mobile-nav-item" data-mobile-route="problems" type="button"><span>!</span>Проблемы</button>
<button class="mobile-nav-item" data-mobile-route="operations" type="button"><span></span>Операции</button>
<button class="mobile-nav-item mobile-notification-button" data-mobile-route="notifications" type="button">
<span aria-hidden="true">🔔</span>
Уведомления
<span id="mobileNotificationUnreadCount" class="notification-unread is-hidden">0</span>
</button>
<button class="mobile-nav-item" data-mobile-route="profile" type="button"><span></span>Профиль</button>
</nav>
</div>
@@ -826,6 +837,19 @@
</form>
</dialog>
<dialog id="notificationCenterDialog" class="notification-center-dialog">
<section class="notification-center-sheet">
<header class="dialog-heading">
<div><span class="eyebrow">События</span><h2>Уведомления</h2><span id="notificationCenterSummary">Нет новых событий</span></div>
<button id="closeNotificationCenterBtn" class="icon-button" type="button" aria-label="Закрыть">×</button>
</header>
<div class="notification-center-actions">
<button id="markAllNotificationsReadBtn" class="text-button" type="button">Прочитать все</button>
</div>
<div id="notificationCenterList" class="notification-center-list"></div>
</section>
</dialog>
<dialog id="profileDialog" class="profile-dialog">
<div class="profile-sheet">
<div class="dialog-heading">
@@ -864,6 +888,18 @@
<label class="notification-toggle"><input id="notificationTelegramEnabled" type="checkbox"><span><strong>Telegram</strong><small id="notificationTelegramHint">Нужен Chat ID</small></span></label>
</div>
<label id="notificationTelegramChatRow">Telegram Chat ID<input id="notificationTelegramChatId" type="text" maxlength="32" inputmode="numeric" placeholder="Например, 123456789"></label>
<div class="notification-form-actions">
<button id="verifyEmailBtn" type="button">Подтвердить e-mail</button>
<button id="verifyTelegramBtn" type="button">Привязать Telegram</button>
</div>
<div class="notification-form-actions">
<label>Код e-mail<input id="emailVerificationCode" type="text" inputmode="numeric" maxlength="6"></label>
<label>Код Telegram<input id="telegramVerificationCode" type="text" inputmode="numeric" maxlength="6"></label>
</div>
<div class="notification-form-actions">
<button id="confirmEmailBtn" type="button">Проверить e-mail код</button>
<button id="confirmTelegramBtn" type="button">Проверить Telegram код</button>
</div>
<div class="notification-toggle-grid">
<label class="notification-toggle compact"><input id="notificationWarningEnabled" type="checkbox"><span>Предупреждения</span></label>
<label class="notification-toggle compact"><input id="notificationCriticalEnabled" type="checkbox"><span>Критические</span></label>
@@ -876,6 +912,28 @@
<label>Задержка, мс<input id="notificationLatencyThreshold" type="number" min="10" max="5000" required></label>
</div>
<label>Повторять активную проблему<select id="notificationRepeatMinutes"><option value="0">Не повторять</option><option value="15">Каждые 15 минут</option><option value="60">Каждый час</option><option value="360">Каждые 6 часов</option><option value="1440">Раз в сутки</option></select></label>
<div class="notification-threshold-grid">
<label>Часовой пояс<input id="notificationTimezone" type="text" maxlength="64" placeholder="Europe/Moscow"></label>
<label>Отключить до<input id="notificationPausedUntil" type="datetime-local"></label>
</div>
<label class="notification-toggle compact"><input id="notificationQuietEnabled" type="checkbox"><span>Тихие часы</span></label>
<div class="notification-threshold-grid">
<label>Начало<input id="notificationQuietStart" type="time"></label>
<label>Окончание<input id="notificationQuietEnd" type="time"></label>
</div>
<label class="notification-toggle compact"><input id="notificationWebhookEnabled" type="checkbox"><span>Подписанный webhook</span></label>
<label>Webhook URL<input id="notificationWebhookUrl" type="url" maxlength="2048" placeholder="https://example.com/rmm-events"></label>
<label>Webhook secret<input id="notificationWebhookSecret" type="password" minlength="32" autocomplete="new-password" placeholder="Оставьте пустым, чтобы не менять"></label>
<div class="profile-section-heading"><strong>Настройки роутера</strong><span>Переопределяют общие настройки</span></div>
<label>Роутер<select id="notificationDeviceSelect"><option value="">Выберите роутер</option></select></label>
<div class="notification-toggle-grid">
<label class="notification-toggle compact"><input id="notificationDeviceEnabled" type="checkbox"><span>Уведомлять</span></label>
<label class="notification-toggle compact"><input id="notificationDeviceCritical" type="checkbox"><span>Критические</span></label>
<label class="notification-toggle compact"><input id="notificationDeviceWarning" type="checkbox"><span>Предупреждения</span></label>
</div>
<label class="notification-toggle compact"><input id="notificationDeviceResolved" type="checkbox"><span>Восстановление</span></label>
<label>Отключить для роутера до<input id="notificationDevicePausedUntil" type="datetime-local"></label>
<button id="saveDeviceNotificationSettingsBtn" type="button">Сохранить настройки роутера</button>
<div class="notification-form-actions"><button class="primary" type="submit">Сохранить уведомления</button><button id="testNotificationsBtn" type="button">Отправить тест</button></div>
<p id="notificationSettingsMessage" class="form-message" role="status"></p>
<div class="notification-history-heading"><strong>Последние отправки</strong><button id="refreshNotificationsBtn" class="text-button" type="button">Обновить</button></div>
+35 -1
View File
@@ -382,6 +382,34 @@
.notification-history-row.is-failed .notification-delivery-status,
.notification-history-row.is-dead_letter .notification-delivery-status { color: var(--bad); }
.notification-history-row.is-sending .notification-delivery-status { color: var(--accent); }
.notification-bell { position: relative; flex: 0 0 42px; }
.notification-bell svg {
width: 19px; height: 19px; fill: none; stroke: currentColor; stroke-width: 1.8;
stroke-linecap: round; stroke-linejoin: round;
}
.notification-unread {
position: absolute; top: -5px; right: -5px; display: grid; min-width: 19px; height: 19px;
place-items: center; border: 2px solid var(--surface); border-radius: 999px; background: var(--bad);
color: #fff; padding: 0 4px; font-size: 10px; font-weight: 800;
}
.notification-center-dialog { width: min(520px, calc(100vw - 24px)); max-height: min(760px, calc(100vh - 24px)); padding: 0; }
.notification-center-sheet { display: grid; gap: 12px; padding: 20px; }
.notification-center-actions { display: flex; justify-content: flex-end; }
.notification-center-list { display: grid; gap: 8px; overflow: auto; max-height: min(580px, 70vh); }
.notification-center-item {
display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; gap: 10px; align-items: start;
width: 100%; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-raised);
color: var(--text); padding: 13px; text-align: left; transition: transform .18s ease, border-color .18s ease, background .18s ease;
}
.notification-center-item:hover { transform: translateY(-1px); border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); }
.notification-center-item.is-unread { background: color-mix(in srgb, var(--accent) 8%, var(--surface-raised)); }
.notification-center-item > span:nth-child(2) { display: grid; min-width: 0; gap: 4px; }
.notification-center-item small { color: var(--muted); line-height: 1.4; }
.notification-center-item time { color: var(--muted); font-size: 11px; white-space: nowrap; }
.notification-center-dot { width: 8px; height: 8px; margin-top: 5px; border-radius: 50%; background: var(--warn); }
.notification-center-item.severity-critical .notification-center-dot { background: var(--bad); box-shadow: 0 0 0 4px color-mix(in srgb, var(--bad) 15%, transparent); }
.client-online.recent i, .client-online.stale i { background: var(--warn); }
.client-online.unconfirmed i, .client-online.reserved i { background: var(--muted); }
@media (max-width: 900px) {
.landing-header nav a:not(.landing-login-link) { display: none; }
@@ -3481,7 +3509,7 @@ select:focus-visible,
bottom: max(10px, env(safe-area-inset-bottom));
left: 10px;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(5, 1fr);
gap: 4px;
border: 1px solid rgb(58 82 106 / 86%);
border-radius: 18px;
@@ -3515,6 +3543,12 @@ select:focus-visible,
color: var(--accent);
}
.mobile-notification-button { position: relative; }
.mobile-notification-button .notification-unread {
top: 2px;
right: 7px;
}
.profile-dialog,
.luci-state-dialog {
width: calc(100vw - 20px);