feat: add notification center and reliable client presence
This commit is contained in:
+6
-6
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, ¬ifyWarning, ¬ifyCritical, ¬ifyResolved,
|
||||
&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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user