init
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user