diff --git a/assets/components/Notification/DestinationForm.vue b/assets/components/Notification/DestinationForm.vue index 3e9617f3..f2b817eb 100644 --- a/assets/components/Notification/DestinationForm.vue +++ b/assets/components/Notification/DestinationForm.vue @@ -72,6 +72,38 @@ /> + +
+ {{ $t("notifications.destination-form.payload-format") }} +
+ +
+
+ + +
+ + {{ $t("notifications.destination-form.template") }} + {{ + $t("notifications.destination-form.template-hint") + }} + + +
+
{{ error }} @@ -97,6 +129,55 @@ import { useMutation } from "@urql/vue"; import { CreateDispatcherDocument, UpdateDispatcherDocument, type Dispatcher } from "@/types/graphql"; +type PayloadFormat = "slack" | "discord" | "ntfy" | "custom"; + +const PAYLOAD_TEMPLATES: Record = { + slack: `{ + "text": "{{ .Container.Name }}", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*{{ .Container.Name }}*\\n{{ .Log.Message }}" + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "Host: {{ .Container.Host }} | Image: {{ .Container.Image }}" + } + ] + } + ] +}`, + discord: `{ + "content": "{{ .Container.Name }}", + "embeds": [ + { + "title": "{{ .Container.Name }}", + "description": "{{ .Log.Message }}", + "fields": [ + { "name": "Host", "value": "{{ .Container.Host }}", "inline": true }, + { "name": "Image", "value": "{{ .Container.Image }}", "inline": true } + ] + } + ] +}`, + ntfy: `{ + "topic": "dozzle-{{ .Container.Host }}", + "title": "{{ .Container.Name }}", + "message": "{{ .Log.Message }}" +}`, + custom: `{ + "container": "{{ .Container.Name }}", + "level": "{{ .Log.Level }}", + "message": "{{ .Log.Message }}" +}`, +}; + const { close, onCreated, destination } = defineProps<{ close?: () => void; onCreated?: () => void; @@ -106,17 +187,24 @@ const { close, onCreated, destination } = defineProps<{ const createMutation = useMutation(CreateDispatcherDocument); const updateMutation = useMutation(UpdateDispatcherDocument); -const isEditing = computed(() => !!destination); +const isEditing = !!destination; const nameInput = ref(); const name = ref(destination?.name ?? ""); useFocus(nameInput, { initialValue: true }); const type = ref<"webhook" | "cloud">((destination?.type as "webhook" | "cloud") ?? "webhook"); const webhookUrl = ref(destination?.url ?? ""); +const payloadFormat = ref(isEditing ? "custom" : "slack"); +const template = ref(isEditing ? (destination?.template ?? "") : PAYLOAD_TEMPLATES[payloadFormat.value]); const isTesting = ref(false); const isSaving = ref(false); const error = ref(null); +function selectPayloadFormat(format: PayloadFormat) { + payloadFormat.value = format; + template.value = PAYLOAD_TEMPLATES[format]; +} + const canTest = computed(() => { if (type.value === "webhook") { return webhookUrl.value.trim().length > 0; @@ -158,9 +246,10 @@ async function saveDestination() { name: name.value.trim(), type: type.value, url: type.value === "webhook" ? webhookUrl.value.trim() : undefined, + template: type.value === "webhook" && template.value.trim() ? template.value.trim() : undefined, }; - const result = isEditing.value + const result = isEditing ? await updateMutation.executeMutation({ id: destination!.id, input }) : await createMutation.executeMutation({ input }); diff --git a/assets/graphql/notifications.graphql b/assets/graphql/notifications.graphql index ba2f2a8e..2f1c44fc 100644 --- a/assets/graphql/notifications.graphql +++ b/assets/graphql/notifications.graphql @@ -23,6 +23,7 @@ query GetDispatchers { name type url + template } } @@ -90,6 +91,7 @@ mutation UpdateDispatcher($id: Int!, $input: DispatcherInput!) { name type url + template } } diff --git a/assets/types/graphql.ts b/assets/types/graphql.ts index fbbb49f1..a8b33b99 100644 --- a/assets/types/graphql.ts +++ b/assets/types/graphql.ts @@ -38,12 +38,14 @@ export type Dispatcher = { __typename?: 'Dispatcher'; id: Scalars['Int']['output']; name: Scalars['String']['output']; + template?: Maybe; type: Scalars['String']['output']; url?: Maybe; }; export type DispatcherInput = { name: Scalars['String']['input']; + template?: InputMaybe; type: Scalars['String']['input']; url?: InputMaybe; }; @@ -198,7 +200,7 @@ export type GetNotificationRulesQuery = { __typename?: 'Query', notificationRule export type GetDispatchersQueryVariables = Exact<{ [key: string]: never; }>; -export type GetDispatchersQuery = { __typename?: 'Query', dispatchers: Array<{ __typename?: 'Dispatcher', id: number, name: string, type: string, url?: string | null }> }; +export type GetDispatchersQuery = { __typename?: 'Query', dispatchers: Array<{ __typename?: 'Dispatcher', id: number, name: string, type: string, url?: string | null, template?: string | null }> }; export type CreateNotificationRuleMutationVariables = Exact<{ input: NotificationRuleInput; @@ -243,7 +245,7 @@ export type UpdateDispatcherMutationVariables = Exact<{ }>; -export type UpdateDispatcherMutation = { __typename?: 'Mutation', updateDispatcher: { __typename?: 'Dispatcher', id: number, name: string, type: string, url?: string | null } }; +export type UpdateDispatcherMutation = { __typename?: 'Mutation', updateDispatcher: { __typename?: 'Dispatcher', id: number, name: string, type: string, url?: string | null, template?: string | null } }; export type DeleteDispatcherMutationVariables = Exact<{ id: Scalars['Int']['input']; @@ -266,13 +268,13 @@ export type GetReleasesQuery = { __typename?: 'Query', releases: Array<{ __typen export const GetNotificationRulesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetNotificationRules"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"notificationRules"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"containerExpression"}},{"kind":"Field","name":{"kind":"Name","value":"logExpression"}},{"kind":"Field","name":{"kind":"Name","value":"triggerCount"}},{"kind":"Field","name":{"kind":"Name","value":"triggeredContainers"}},{"kind":"Field","name":{"kind":"Name","value":"lastTriggeredAt"}},{"kind":"Field","name":{"kind":"Name","value":"dispatcher"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]}}]} as unknown as DocumentNode; -export const GetDispatchersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetDispatchers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dispatchers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; +export const GetDispatchersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetDispatchers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dispatchers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"template"}}]}}]}}]} as unknown as DocumentNode; export const CreateNotificationRuleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateNotificationRule"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationRuleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createNotificationRule"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"containerExpression"}},{"kind":"Field","name":{"kind":"Name","value":"logExpression"}},{"kind":"Field","name":{"kind":"Name","value":"dispatcher"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}}]}}]}}]} as unknown as DocumentNode; export const UpdateNotificationRuleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateNotificationRule"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationRuleUpdateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateNotificationRule"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"containerExpression"}},{"kind":"Field","name":{"kind":"Name","value":"logExpression"}},{"kind":"Field","name":{"kind":"Name","value":"dispatcher"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}}]}}]}}]} as unknown as DocumentNode; export const ReplaceNotificationRuleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ReplaceNotificationRule"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationRuleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"replaceNotificationRule"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"containerExpression"}},{"kind":"Field","name":{"kind":"Name","value":"logExpression"}},{"kind":"Field","name":{"kind":"Name","value":"dispatcher"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}}]}}]}}]} as unknown as DocumentNode; export const DeleteNotificationRuleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteNotificationRule"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteNotificationRule"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode; export const CreateDispatcherDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateDispatcher"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DispatcherInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createDispatcher"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; -export const UpdateDispatcherDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateDispatcher"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DispatcherInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateDispatcher"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; +export const UpdateDispatcherDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateDispatcher"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DispatcherInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateDispatcher"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"template"}}]}}]}}]} as unknown as DocumentNode; export const DeleteDispatcherDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteDispatcher"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteDispatcher"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode; export const PreviewExpressionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"PreviewExpression"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PreviewInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"previewExpression"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"containerError"}},{"kind":"Field","name":{"kind":"Name","value":"logError"}},{"kind":"Field","name":{"kind":"Name","value":"matchedContainers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"image"}},{"kind":"Field","name":{"kind":"Name","value":"host"}}]}},{"kind":"Field","name":{"kind":"Name","value":"matchedLogs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"level"}},{"kind":"Field","name":{"kind":"Name","value":"stream"}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalLogs"}}]}}]}}]} as unknown as DocumentNode; export const GetReleasesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetReleases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"releases"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"mentionsCount"}},{"kind":"Field","name":{"kind":"Name","value":"tag"}},{"kind":"Field","name":{"kind":"Name","value":"body"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"htmlUrl"}},{"kind":"Field","name":{"kind":"Name","value":"latest"}},{"kind":"Field","name":{"kind":"Name","value":"features"}},{"kind":"Field","name":{"kind":"Name","value":"bugFixes"}},{"kind":"Field","name":{"kind":"Name","value":"breaking"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/graph/generated.go b/graph/generated.go index 3dd73f56..251474b1 100644 --- a/graph/generated.go +++ b/graph/generated.go @@ -68,10 +68,11 @@ type ComplexityRoot struct { } Dispatcher struct { - ID func(childComplexity int) int - Name func(childComplexity int) int - Type func(childComplexity int) int - URL func(childComplexity int) int + ID func(childComplexity int) int + Name func(childComplexity int) int + Template func(childComplexity int) int + Type func(childComplexity int) int + URL func(childComplexity int) int } LogEvent struct { @@ -267,6 +268,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Dispatcher.Name(childComplexity), true + case "Dispatcher.template": + if e.complexity.Dispatcher.Template == nil { + break + } + + return e.complexity.Dispatcher.Template(childComplexity), true case "Dispatcher.type": if e.complexity.Dispatcher.Type == nil { break @@ -1351,6 +1358,35 @@ func (ec *executionContext) fieldContext_Dispatcher_url(_ context.Context, field return fc, nil } +func (ec *executionContext) _Dispatcher_template(ctx context.Context, field graphql.CollectedField, obj *model.Dispatcher) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Dispatcher_template, + func(ctx context.Context) (any, error) { + return obj.Template, nil + }, + nil, + ec.marshalOString2áš–string, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Dispatcher_template(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Dispatcher", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _LogEvent_id(ctx context.Context, field graphql.CollectedField, obj *container.LogEvent) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -1811,6 +1847,8 @@ func (ec *executionContext) fieldContext_Mutation_createDispatcher(ctx context.C return ec.fieldContext_Dispatcher_type(ctx, field) case "url": return ec.fieldContext_Dispatcher_url(ctx, field) + case "template": + return ec.fieldContext_Dispatcher_template(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Dispatcher", field.Name) }, @@ -1862,6 +1900,8 @@ func (ec *executionContext) fieldContext_Mutation_updateDispatcher(ctx context.C return ec.fieldContext_Dispatcher_type(ctx, field) case "url": return ec.fieldContext_Dispatcher_url(ctx, field) + case "template": + return ec.fieldContext_Dispatcher_template(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Dispatcher", field.Name) }, @@ -2093,6 +2133,8 @@ func (ec *executionContext) fieldContext_NotificationRule_dispatcher(_ context.C return ec.fieldContext_Dispatcher_type(ctx, field) case "url": return ec.fieldContext_Dispatcher_url(ctx, field) + case "template": + return ec.fieldContext_Dispatcher_template(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Dispatcher", field.Name) }, @@ -2572,6 +2614,8 @@ func (ec *executionContext) fieldContext_Query_dispatchers(_ context.Context, fi return ec.fieldContext_Dispatcher_type(ctx, field) case "url": return ec.fieldContext_Dispatcher_url(ctx, field) + case "template": + return ec.fieldContext_Dispatcher_template(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Dispatcher", field.Name) }, @@ -2612,6 +2656,8 @@ func (ec *executionContext) fieldContext_Query_dispatcher(ctx context.Context, f return ec.fieldContext_Dispatcher_type(ctx, field) case "url": return ec.fieldContext_Dispatcher_url(ctx, field) + case "template": + return ec.fieldContext_Dispatcher_template(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Dispatcher", field.Name) }, @@ -4532,7 +4578,7 @@ func (ec *executionContext) unmarshalInputDispatcherInput(ctx context.Context, o asMap[k] = v } - fieldsInOrder := [...]string{"name", "type", "url"} + fieldsInOrder := [...]string{"name", "type", "url", "template"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -4560,6 +4606,13 @@ func (ec *executionContext) unmarshalInputDispatcherInput(ctx context.Context, o return it, err } it.URL = data + case "template": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("template")) + data, err := ec.unmarshalOString2áš–string(ctx, v) + if err != nil { + return it, err + } + it.Template = data } } @@ -4854,6 +4907,8 @@ func (ec *executionContext) _Dispatcher(ctx context.Context, sel ast.SelectionSe } case "url": out.Values[i] = ec._Dispatcher_url(ctx, field, obj) + case "template": + out.Values[i] = ec._Dispatcher_template(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/graph/helpers.go b/graph/helpers.go index 52295fa3..9b2cae85 100644 --- a/graph/helpers.go +++ b/graph/helpers.go @@ -40,11 +40,16 @@ func dispatcherConfigToDispatcher(d *notification.DispatcherConfig) *model.Dispa if d.URL != "" { url = &d.URL } + var template *string + if d.Template != "" { + template = &d.Template + } return &model.Dispatcher{ - ID: int32(d.ID), - Name: d.Name, - Type: d.Type, - URL: url, + ID: int32(d.ID), + Name: d.Name, + Type: d.Type, + URL: url, + Template: template, } } diff --git a/graph/model/models_gen.go b/graph/model/models_gen.go index d852b4fd..0bc692d5 100644 --- a/graph/model/models_gen.go +++ b/graph/model/models_gen.go @@ -9,16 +9,18 @@ import ( ) type Dispatcher struct { - ID int32 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - URL *string `json:"url,omitempty"` + ID int32 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + URL *string `json:"url,omitempty"` + Template *string `json:"template,omitempty"` } type DispatcherInput struct { - Name string `json:"name"` - Type string `json:"type"` - URL *string `json:"url,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + URL *string `json:"url,omitempty"` + Template *string `json:"template,omitempty"` } type Mutation struct { diff --git a/graph/schema.graphqls b/graph/schema.graphqls index f9f72940..204b2e87 100644 --- a/graph/schema.graphqls +++ b/graph/schema.graphqls @@ -46,6 +46,7 @@ type Dispatcher { name: String! type: String! url: String + template: String } type PreviewResult { @@ -90,6 +91,7 @@ input DispatcherInput { name: String! type: String! url: String + template: String } input PreviewInput { diff --git a/graph/schema.resolvers.go b/graph/schema.resolvers.go index 4eef77e8..33785bc7 100644 --- a/graph/schema.resolvers.go +++ b/graph/schema.resolvers.go @@ -122,7 +122,15 @@ func (r *mutationResolver) CreateDispatcher(ctx context.Context, input model.Dis if input.URL != nil { url = *input.URL } - d = dispatcher.NewWebhookDispatcher(input.Name, url) + templateStr := "" + if input.Template != nil { + templateStr = *input.Template + } + webhook, err := dispatcher.NewWebhookDispatcher(input.Name, url, templateStr) + if err != nil { + return nil, &Error{Message: err.Error()} + } + d = webhook default: return nil, &Error{Message: "unknown dispatcher type"} } @@ -130,10 +138,11 @@ func (r *mutationResolver) CreateDispatcher(ctx context.Context, input model.Dis id := r.HostService.AddDispatcher(d) return &model.Dispatcher{ - ID: int32(id), - Name: input.Name, - Type: input.Type, - URL: input.URL, + ID: int32(id), + Name: input.Name, + Type: input.Type, + URL: input.URL, + Template: input.Template, }, nil } @@ -146,7 +155,15 @@ func (r *mutationResolver) UpdateDispatcher(ctx context.Context, id int32, input if input.URL != nil { url = *input.URL } - d = dispatcher.NewWebhookDispatcher(input.Name, url) + templateStr := "" + if input.Template != nil { + templateStr = *input.Template + } + webhook, err := dispatcher.NewWebhookDispatcher(input.Name, url, templateStr) + if err != nil { + return nil, &Error{Message: err.Error()} + } + d = webhook default: return nil, &Error{Message: "unknown dispatcher type"} } @@ -154,10 +171,11 @@ func (r *mutationResolver) UpdateDispatcher(ctx context.Context, id int32, input r.HostService.UpdateDispatcher(int(id), d) return &model.Dispatcher{ - ID: id, - Name: input.Name, - Type: input.Type, - URL: input.URL, + ID: id, + Name: input.Name, + Type: input.Type, + URL: input.URL, + Template: input.Template, }, nil } diff --git a/internal/notification/dispatcher/webhook.go b/internal/notification/dispatcher/webhook.go index 5e1fe6d4..d7c40803 100644 --- a/internal/notification/dispatcher/webhook.go +++ b/internal/notification/dispatcher/webhook.go @@ -5,33 +5,62 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" + "text/template" "time" + + "github.com/rs/zerolog/log" ) // WebhookDispatcher sends notifications to a webhook URL type WebhookDispatcher struct { - Name string - URL string - client *http.Client + Name string + URL string + Template *template.Template + TemplateText string // Original template string for serialization + client *http.Client } // NewWebhookDispatcher creates a new webhook dispatcher -func NewWebhookDispatcher(name, url string) *WebhookDispatcher { - return &WebhookDispatcher{ - Name: name, - URL: url, +// If templateStr is empty, the notification will be marshaled as JSON directly +func NewWebhookDispatcher(name, url, templateStr string) (*WebhookDispatcher, error) { + w := &WebhookDispatcher{ + Name: name, + URL: url, + TemplateText: templateStr, client: &http.Client{ Timeout: 10 * time.Second, }, } + + if templateStr != "" { + tmpl, err := template.New("webhook").Parse(templateStr) + if err != nil { + return nil, fmt.Errorf("failed to parse template: %w", err) + } + w.Template = tmpl + } + + return w, nil } // Send sends a notification to the webhook URL func (w *WebhookDispatcher) Send(ctx context.Context, notification any) error { - payload, err := json.Marshal(notification) - if err != nil { - return fmt.Errorf("failed to marshal notification: %w", err) + var payload []byte + var err error + + if w.Template != nil { + var buf bytes.Buffer + if err := w.Template.Execute(&buf, notification); err != nil { + return fmt.Errorf("failed to execute template: %w", err) + } + payload = buf.Bytes() + } else { + payload, err = json.Marshal(notification) + if err != nil { + return fmt.Errorf("failed to marshal notification: %w", err) + } } req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.URL, bytes.NewReader(payload)) @@ -48,6 +77,14 @@ func (w *WebhookDispatcher) Send(ctx context.Context, notification any) error { defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { + responseBody, _ := io.ReadAll(resp.Body) + log.Debug(). + Str("webhook", w.Name). + Str("url", w.URL). + Int("status_code", resp.StatusCode). + Str("payload", string(payload)). + Str("response_body", string(responseBody)). + Msg("webhook returned non-success status code") return fmt.Errorf("webhook returned non-success status code: %d", resp.StatusCode) } diff --git a/internal/notification/log_listener.go b/internal/notification/log_listener.go index cd56801a..d4adcaea 100644 --- a/internal/notification/log_listener.go +++ b/internal/notification/log_listener.go @@ -131,15 +131,9 @@ func (l *ContainerLogListener) startListening(c container.Container, client cont // startListeningByID finds the client for a container and starts listening func (l *ContainerLogListener) startListeningByID(c container.Container) { for _, client := range l.clients { - containers, err := client.ListContainers(l.ctx, nil) - if err != nil { - continue - } - for _, container := range containers { - if container.ID == c.ID { - l.startListening(c, client) - return - } + if found, err := client.FindContainer(l.ctx, c.ID, nil); err == nil { + l.startListening(found, client) + return } } log.Warn().Str("containerID", c.ID).Msg("Could not find client for container") @@ -150,7 +144,7 @@ func (l *ContainerLogListener) stopListening(containerID string) { if cancel, exists := l.activeStreams.LoadAndDelete(containerID); exists { cancel() l.containerClients.Delete(containerID) - log.Info().Str("containerID", containerID).Msg("Stopped listening to container") + log.Debug().Str("containerID", containerID).Msg("Stopped listening to container") } } diff --git a/internal/notification/manager.go b/internal/notification/manager.go index 341e90d9..c440eb4b 100644 --- a/internal/notification/manager.go +++ b/internal/notification/manager.go @@ -92,7 +92,7 @@ func (m *Manager) AddSubscription(sub *Subscription) error { } m.subscriptions.Store(sub.ID, sub) - log.Info().Str("name", sub.Name).Int("id", sub.ID).Msg("Added subscription") + log.Debug().Str("name", sub.Name).Int("id", sub.ID).Msg("Added subscription") // Update listener to start/stop streams based on new subscription if m.listener != nil { @@ -107,7 +107,7 @@ func (m *Manager) AddSubscription(sub *Subscription) error { // RemoveSubscription removes a subscription by ID func (m *Manager) RemoveSubscription(id int) { if sub, ok := m.subscriptions.LoadAndDelete(id); ok { - log.Info().Int("id", id).Str("name", sub.Name).Msg("Removed subscription") + log.Debug().Int("id", id).Str("name", sub.Name).Msg("Removed subscription") // Update listener to stop streams that are no longer needed if m.listener != nil { @@ -146,7 +146,7 @@ func (m *Manager) ReplaceSubscription(sub *Subscription) error { } m.subscriptions.Store(sub.ID, sub) - log.Info().Str("name", sub.Name).Int("id", sub.ID).Msg("Replaced subscription") + log.Debug().Str("name", sub.Name).Int("id", sub.ID).Msg("Replaced subscription") // Update listener to start/stop streams based on new subscription if m.listener != nil { @@ -246,20 +246,20 @@ func (m *Manager) UpdateSubscription(id int, updates map[string]any) error { func (m *Manager) AddDispatcher(d dispatcher.Dispatcher) int { id := int(m.dispatcherCounter.Add(1)) m.dispatchers.Store(id, d) - log.Info().Int("id", id).Msg("Added dispatcher") + log.Debug().Int("id", id).Msg("Added dispatcher") return id } // UpdateDispatcher updates a dispatcher by ID func (m *Manager) UpdateDispatcher(id int, d dispatcher.Dispatcher) { m.dispatchers.Store(id, d) - log.Info().Int("id", id).Msg("Updated dispatcher") + log.Debug().Int("id", id).Msg("Updated dispatcher") } // RemoveDispatcher removes a dispatcher by ID func (m *Manager) RemoveDispatcher(id int) { if _, ok := m.dispatchers.LoadAndDelete(id); ok { - log.Info().Int("id", id).Msg("Removed dispatcher") + log.Debug().Int("id", id).Msg("Removed dispatcher") } } @@ -283,10 +283,11 @@ func (m *Manager) Dispatchers() []DispatcherConfig { switch v := d.(type) { case *dispatcher.WebhookDispatcher: result = append(result, DispatcherConfig{ - ID: id, - Name: v.Name, - Type: "webhook", - URL: v.URL, + ID: id, + Name: v.Name, + Type: "webhook", + URL: v.URL, + Template: v.TemplateText, }) } return true @@ -427,12 +428,16 @@ func (m *Manager) LoadConfig(r io.Reader) error { var d dispatcher.Dispatcher switch dispatcherConfig.Type { case "webhook": - d = dispatcher.NewWebhookDispatcher(dispatcherConfig.Name, dispatcherConfig.URL) + webhook, err := dispatcher.NewWebhookDispatcher(dispatcherConfig.Name, dispatcherConfig.URL, dispatcherConfig.Template) + if err != nil { + return fmt.Errorf("failed to create webhook dispatcher %s: %w", dispatcherConfig.Name, err) + } + d = webhook default: return fmt.Errorf("unknown dispatcher type: %s", dispatcherConfig.Type) } m.dispatchers.Store(dispatcherConfig.ID, d) - log.Info().Int("id", dispatcherConfig.ID).Msg("Loaded dispatcher") + log.Debug().Int("id", dispatcherConfig.ID).Msg("Loaded dispatcher") } // Update listener to start streams for loaded subscriptions @@ -466,6 +471,6 @@ func (m *Manager) loadSubscription(sub *Subscription) error { } m.subscriptions.Store(sub.ID, sub) - log.Info().Str("name", sub.Name).Int("id", sub.ID).Msg("Loaded subscription") + log.Debug().Str("name", sub.Name).Int("id", sub.ID).Msg("Loaded subscription") return nil } diff --git a/internal/notification/types.go b/internal/notification/types.go index d947a4a2..696d7bbf 100644 --- a/internal/notification/types.go +++ b/internal/notification/types.go @@ -124,10 +124,11 @@ func (s *Subscription) AddTriggeredContainer(id string) { // DispatcherConfig represents a dispatcher configuration type DispatcherConfig struct { - ID int `json:"id" yaml:"id"` - Name string `json:"name" yaml:"name"` - Type string `json:"type" yaml:"type"` // "webhook", etc. - URL string `json:"url,omitempty" yaml:"url,omitempty"` + ID int `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + Type string `json:"type" yaml:"type"` // "webhook", etc. + URL string `json:"url,omitempty" yaml:"url,omitempty"` + Template string `json:"template,omitempty" yaml:"template,omitempty"` // Go template for custom payload format } // Config represents the persisted notification configuration diff --git a/locales/en.yml b/locales/en.yml index d5e14c31..28e2f103 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -224,6 +224,13 @@ notifications: cloud-description: Push, email, and dashboard webhook-url: Webhook URL webhook-url-placeholder: https://hooks.foo.com/services/... + payload-format: Payload Format + format-slack: Slack + format-discord: Discord + format-ntfy: ntfy + format-custom: Custom + template: Template + template-hint: Go template syntax test: Test cancel: Cancel save: Save