Compare commits

...

2 Commits

Author SHA1 Message Date
Jan Zípek b11140197e NTFY integration (#2)
ci/woodpecker/push/build Pipeline was successful Details
Reviewed-on: #2
Co-authored-by: Jan Zípek <jan@zipek.cz>
Co-committed-by: Jan Zípek <jan@zipek.cz>
2026-04-11 20:22:18 +02:00
Jan Zípek caa95f52a0
Optimization of last value call
ci/woodpecker/push/build Pipeline failed Details
2026-04-11 20:21:27 +02:00
8 changed files with 241 additions and 6 deletions

View File

@ -20,6 +20,8 @@ type FormValues = {
type: string
telegramApiKey?: string
telegramTargetChannel?: number
ntfyEndpoint?: string
ntfyAuthToken?: string
}
export const ContactPointFormModal = ({
@ -45,6 +47,13 @@ export const ContactPointFormModal = ({
}
}
if (v.type === 'ntfy') {
return {
endpoint: v.ntfyEndpoint,
authToken: v.ntfyAuthToken,
}
}
return {}
}
@ -56,6 +65,10 @@ export const ContactPointFormModal = ({
telegramApiKey: parsedTypeConfig.apiKey ?? '',
telegramTargetChannel: parsedTypeConfig.targetChannel,
}),
...(parsedTypeConfig?.type === 'ntfy' && {
ntfyEndpoint: parsedTypeConfig.endpoint ?? '',
ntfyAuthToken: parsedTypeConfig.authToken ?? '',
}),
}),
onSubmit: async (v) => {
if (isLoading) {
@ -105,6 +118,7 @@ export const ContactPointFormModal = ({
<label>Type</label>
<select required {...register('type')}>
<option value="telegram">Telegram</option>
<option value="ntfy">Ntfy</option>
</select>
</div>
@ -132,6 +146,26 @@ export const ContactPointFormModal = ({
</>
)}
{type === 'ntfy' && (
<>
<div className="input">
<label>Endpoint</label>
<input
type="text"
minLength={1}
required
placeholder="https://ntfy.sh"
{...register('ntfyEndpoint')}
/>
</div>
<div className="input">
<label>Auth Token (optional)</label>
<input type="text" {...register('ntfyAuthToken')} />
</div>
</>
)}
<div className="actions">
<button
className="test"

View File

@ -7,7 +7,15 @@ type ContactPointTelegramConfig = {
targetChannel: number
}
export type ParsedContactPointConfig = ContactPointTelegramConfig
type ContactPointNtfyConfig = {
type: 'ntfy'
endpoint: string
authToken?: string
}
export type ParsedContactPointConfig =
| ContactPointTelegramConfig
| ContactPointNtfyConfig
export const tryParseContactPointConfig = (
contactPoint: ContactPointInfo
@ -26,5 +34,13 @@ export const tryParseContactPointConfig = (
}
}
if (contactPoint.type === 'ntfy') {
return {
type: 'ntfy',
endpoint: data.endpoint,
authToken: data.authToken,
}
}
return null
}

158
server/integrations/ntfy.go Normal file
View File

@ -0,0 +1,158 @@
package integrations
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
type NtfyIntegration struct{}
var ntfyHTTPClient = &http.Client{
Timeout: 15 * time.Second,
}
type NtfyNotificationChannelConfig struct {
Endpoint string `json:"endpoint"`
AuthToken string `json:"authToken"`
}
func (s NtfyIntegration) ProcessEvent(evt *ContactPointEvent, rawConfig string) error {
config := NtfyNotificationChannelConfig{}
err := json.Unmarshal([]byte(rawConfig), &config)
if err != nil {
return fmt.Errorf("failed to parse ntfy integration config - %w", err)
}
if err := s.validate(config); err != nil {
return err
}
switch evt.Type {
case ContactPointEventAlertTriggered:
data := evt.AlertTriggeredEvent
if data.SensorValueCondition != nil {
text := fmt.Sprintf("%s is at {value}", data.Sensor.Name)
if data.Alert.CustomMessage != "" {
text = data.Alert.CustomMessage
}
text = strings.Replace(text, "{value}", strconv.FormatFloat(data.LastValue, 'f', -1, 64), -1)
return s.send(config, "Alert Triggered", text)
}
if data.SensorLastContactCondition != nil {
text := fmt.Sprintf(
"%s has not reported in for last %s %s",
data.Sensor.Name,
strconv.FormatFloat(data.SensorLastContactCondition.Value, 'f', -1, 64),
data.SensorLastContactCondition.ValueUnit,
)
if data.Alert.CustomMessage != "" {
text = data.Alert.CustomMessage
}
return s.send(config, "Alert Triggered", text)
}
return nil
case ContactPointEventAlertResolved:
data := evt.AlertResolvedEvent
if data.SensorValueCondition != nil {
text := fmt.Sprintf("%s is at {value}", data.Sensor.Name)
if data.Alert.CustomResolvedMessage != "" {
text = data.Alert.CustomResolvedMessage
}
text = strings.Replace(text, "{value}", strconv.FormatFloat(data.LastValue, 'f', -1, 64), -1)
return s.send(config, "Alert Resolved", text)
}
if data.SensorLastContactCondition != nil {
text := fmt.Sprintf(
"%s has reported in last %s %s",
data.Sensor.Name,
strconv.FormatFloat(data.SensorLastContactCondition.Value, 'f', -1, 64),
data.SensorLastContactCondition.ValueUnit,
)
if data.Alert.CustomResolvedMessage != "" {
text = data.Alert.CustomResolvedMessage
}
return s.send(config, "Alert Resolved", text)
}
return nil
case ContactPointEventTest:
return s.send(config, "Test", "Test message from Basic Sensor Receiver")
}
return nil
}
func (s NtfyIntegration) ValidateConfig(rawConfig string) error {
config := NtfyNotificationChannelConfig{}
err := json.Unmarshal([]byte(rawConfig), &config)
if err != nil {
return err
}
return s.validate(config)
}
func (s NtfyIntegration) validate(config NtfyNotificationChannelConfig) error {
if strings.TrimSpace(config.Endpoint) == "" {
return fmt.Errorf("ntfy endpoint is required")
}
return nil
}
func (s NtfyIntegration) send(config NtfyNotificationChannelConfig, title string, text string) error {
url := strings.TrimSpace(config.Endpoint)
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(text))
if err != nil {
return fmt.Errorf("failed to create ntfy request - %w", err)
}
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
req.Header.Set("Title", title)
if config.AuthToken != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.AuthToken))
}
resp, err := ntfyHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send ntfy notification - %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("ntfy returned status %d", resp.StatusCode)
}
return fmt.Errorf("ntfy returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}

View File

@ -9,12 +9,12 @@ import (
type postOrPutContactPointsBody struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required,oneof=telegram"`
Type string `json:"type" binding:"required,oneof=telegram ntfy"`
TypeConfig string `json:"typeConfig" binding:"required"`
}
type testContactPointBody struct {
Type string `json:"type" binding:"required,oneof=telegram"`
Type string `json:"type" binding:"required,oneof=telegram ntfy"`
TypeConfig string `json:"typeConfig" binding:"required"`
}

View File

@ -61,7 +61,7 @@ func unitToSeconds(unit string) float64 {
}
func (s *AlertsEvaluatorService) EvaluateAlert(alert *models.AlertItem) error {
condition := map[string]interface{}{}
condition := map[string]any{}
err := json.Unmarshal([]byte(alert.Condition), &condition)
if err != nil {
return err
@ -86,7 +86,7 @@ func (s *AlertsEvaluatorService) EvaluateAlert(alert *models.AlertItem) error {
sensorId = int64(sensorValueCondition.SensorId)
value, err := s.ctx.Services.SensorValues.GetLatest(sensorValueCondition.SensorId, time.Now().Unix())
value, err := s.ctx.Services.SensorValues.GetLatestToNow(sensorValueCondition.SensorId)
if err != nil {
if err == sql.ErrNoRows {
lastValue = float64(0)
@ -112,7 +112,7 @@ func (s *AlertsEvaluatorService) EvaluateAlert(alert *models.AlertItem) error {
sensorId = sensorLastContactCondition.SensorId
value, err := s.ctx.Services.SensorValues.GetLatest(sensorLastContactCondition.SensorId, time.Now().Unix())
value, err := s.ctx.Services.SensorValues.GetLatestToNow(sensorLastContactCondition.SensorId)
if err != nil {
if err == sql.ErrNoRows {

View File

@ -62,6 +62,14 @@ func (s *ContactPointsService) Create(name string, contactType string, contactTy
return nil, fmt.Errorf("failed to validate telegram config - %w", err)
}
}
case "ntfy":
{
err := s.ctx.Integrations.Ntfy.ValidateConfig(contactTypeConfig)
if err != nil {
return nil, fmt.Errorf("failed to validate ntfy config - %w", err)
}
}
}
item := ContactPointItem{
@ -144,6 +152,8 @@ func (channel *ContactPointItem) getService(ctx *Context) (integrations.ContactP
switch channel.Type {
case "telegram":
return ctx.Integrations.Telegram, nil
case "ntfy":
return ctx.Integrations.Ntfy, nil
}
return nil, errors.New("unknown channel type")
@ -153,6 +163,8 @@ func (channel *ContactPointItem) getTestService(ctx *Context) (integrations.Cont
switch channel.Type {
case "telegram":
return ctx.Integrations.Telegram, nil
case "ntfy":
return ctx.Integrations.Ntfy, nil
}
return nil, errors.New("unknown channel type")

View File

@ -48,6 +48,19 @@ func (s *SensorValuesService) GetLatest(sensorId int64, to int64) (*sensorValue,
return &value, nil
}
func (s *SensorValuesService) GetLatestToNow(sensorId int64) (*sensorValue, error) {
var value = sensorValue{}
row := s.ctx.DB.QueryRow("SELECT timestamp, value FROM sensor_values WHERE sensor_id = ? ORDER BY timestamp DESC LIMIT 1", sensorId)
err := row.Scan(&value.Timestamp, &value.Value)
if err != nil {
return nil, err
}
return &value, nil
}
func (s *SensorValuesService) Cleanup(retentionInDays int64) error {
if retentionInDays <= 0 {
return fmt.Errorf("retentionInDays must be greater than 0")

View File

@ -22,6 +22,7 @@ type Services struct {
type Integrations struct {
Telegram *integrations.TelegramIntegration
Ntfy *integrations.NtfyIntegration
MQTT *integrations.MQTTIntegration
}
@ -50,6 +51,7 @@ func InitializeServices(ctx *Context) *Services {
ctx.Integrations = &Integrations{}
ctx.Integrations.Telegram = &integrations.TelegramIntegration{}
ctx.Integrations.Ntfy = &integrations.NtfyIntegration{}
ctx.Integrations.MQTT = &integrations.MQTTIntegration{}
return &services