This commit is contained in:
2026-10-18 22:31:16 +01:00
commit ec38e680d6
64 changed files with 7222 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package actions
import (
"net/http"
"strings"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
"git.dcntrlized.org/Dcntrlized/Alarm/realtime"
)
// actionResponse is the trigger/disarm result.
type actionResponse struct {
EventID string `json:"eventId"`
Action string `json:"action"`
ServerTime string `json:"serverTime"`
AutoStopSeconds int `json:"autoStopSeconds"`
Delivered int `json:"delivered"`
}
type triggerRequest struct {
AlertID string `json:"alertId"`
AdminToken string `json:"adminToken"`
Message *string `json:"message"`
}
// TriggerAlert implements POST /action/triggerAlert.
//
// The state transition and the event row are written in one transaction, and
// the schema's NOTIFY trigger publishes the event on commit, so every service
// instance fans it out. This instance additionally broadcasts in-process for
// the fast path; the hub de-duplicates the two by event id.
func TriggerAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "triggerAlert"
alert, p, ok := requireAdminToken(w, r, action, payload)
if !ok {
return
}
var req triggerRequest
_ = decodePayload(payload, &req)
message := alert.Message
if req.Message != nil {
m, err := validateMessage(*req.Message)
if err != nil {
validationError(w, "%v", err)
return
}
if strings.TrimSpace(m) != "" {
message = &m
}
}
updated, event, err := db.TriggerAlert(r.Context(), alert.ID, p.ID, message)
if err != nil {
dbError(w, action, err)
return
}
delivered := 0
if hub := realtime.Current(); hub != nil {
delivered = hub.Broadcast(updated.ID, realtime.AlarmFrame{
Type: "alarm",
AlertID: updated.ID.String(),
EventID: event.ID.String(),
Action: alertstate.KindTrigger,
Message: deref(event.Message),
ServerTime: rfc3339(event.CreatedAt),
AutoStopSeconds: updated.AutoStopSeconds,
})
}
Debug(action, "POST", "alert=%s event=%s delivered=%d", updated.ID, event.ID, delivered)
writeJSON(w, http.StatusOK, actionResponse{
EventID: event.ID.String(),
Action: alertstate.KindTrigger,
ServerTime: rfc3339(event.CreatedAt),
AutoStopSeconds: updated.AutoStopSeconds,
Delivered: delivered,
})
}
// DisarmAlert implements POST /action/disarmAlert.
func DisarmAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "disarmAlert"
alert, p, ok := requireAdminToken(w, r, action, payload)
if !ok {
return
}
updated, event, err := db.DisarmAlert(r.Context(), alert.ID, p.ID, nil)
if err != nil {
dbError(w, action, err)
return
}
delivered := 0
if hub := realtime.Current(); hub != nil {
delivered = hub.Broadcast(updated.ID, realtime.AlarmFrame{
Type: "alarm",
AlertID: updated.ID.String(),
EventID: event.ID.String(),
Action: alertstate.KindDisarm,
Message: deref(event.Message),
ServerTime: rfc3339(event.CreatedAt),
AutoStopSeconds: updated.AutoStopSeconds,
})
}
Debug(action, "POST", "alert=%s event=%s delivered=%d", updated.ID, event.ID, delivered)
writeJSON(w, http.StatusOK, actionResponse{
EventID: event.ID.String(),
Action: alertstate.KindDisarm,
ServerTime: rfc3339(event.CreatedAt),
AutoStopSeconds: updated.AutoStopSeconds,
Delivered: delivered,
})
}
// statusView is the alarmStatus polling fallback payload (frozen field set).
type statusView struct {
Active bool `json:"active"`
EventID string `json:"eventId"`
Action string `json:"action"`
Message string `json:"message"`
ServerTime string `json:"serverTime"`
AutoStopSeconds int `json:"autoStopSeconds"`
}
// AlarmStatus implements GET/POST /action/alarmStatus: the cheap, indexed
// fallback a client polls when its socket is down or it was killed mid-alarm.
//
// `active` is derived (status + auto-stop window), never stored, so no
// background job is needed to expire an alarm.
func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
const action = "alarmStatus"
p, ok := principal(w, r, action)
if !ok {
return
}
alertID, err := alertIDFrom(r, payload)
if err != nil {
validationError(w, "%v", err)
return
}
res, err := db.GetAlertWithLatestEvent(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
allowed, err := isAdminOrSubscriber(r, res.Alert, p.ID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
now := time.Now().UTC()
active := alertstate.IsActive(res.Alert.Status, res.Alert.TriggeredAt, res.Alert.AutoStopSeconds, now)
if res.Event == nil || res.Event.Kind != alertstate.KindTrigger {
// A never-triggered alert, or one whose newest event is a disarm, is
// not live even if the stored status says "triggered".
active = false
}
view := statusView{
Active: active,
ServerTime: rfc3339(now),
AutoStopSeconds: res.Alert.AutoStopSeconds,
}
if res.Event != nil {
view.EventID = res.Event.ID.String()
view.Action = res.Event.Kind
view.Message = deref(res.Event.Message)
view.ServerTime = rfc3339(res.Event.CreatedAt)
}
writeJSON(w, http.StatusOK, view)
}