feat: supports go templates for webhooks (#4366)

This commit is contained in:
Amir Raminfar
2026-01-20 14:21:46 -08:00
committed by GitHub
parent 4954a020f9
commit abeb86d285
13 changed files with 288 additions and 69 deletions
@@ -72,6 +72,38 @@
/>
</fieldset>
<!-- Payload Format (only for webhook type) -->
<fieldset v-if="type === 'webhook'" class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.payload-format") }}</legend>
<div class="flex flex-wrap gap-2">
<button
v-for="format in ['slack', 'discord', 'ntfy', 'custom'] as const"
:key="format"
type="button"
class="btn btn-sm"
:class="payloadFormat === format ? 'btn-primary' : 'btn-ghost'"
@click="selectPayloadFormat(format)"
>
{{ $t(`notifications.destination-form.format-${format}`) }}
</button>
</div>
</fieldset>
<!-- Template (only for webhook type) -->
<fieldset v-if="type === 'webhook'" class="fieldset">
<legend class="fieldset-legend text-lg">
{{ $t("notifications.destination-form.template") }}
<span class="text-base-content/60 ml-2 text-sm font-normal">{{
$t("notifications.destination-form.template-hint")
}}</span>
</legend>
<textarea
v-model="template"
class="textarea focus:textarea-primary min-h-48 w-full font-mono text-sm"
:class="{ 'textarea-primary': template.trim().length > 0 }"
></textarea>
</fieldset>
<!-- Error -->
<div v-if="error" class="alert alert-error">
<span>{{ error }}</span>
@@ -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<PayloadFormat, string> = {
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<HTMLInputElement>();
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<PayloadFormat>(isEditing ? "custom" : "slack");
const template = ref(isEditing ? (destination?.template ?? "") : PAYLOAD_TEMPLATES[payloadFormat.value]);
const isTesting = ref(false);
const isSaving = ref(false);
const error = ref<string | null>(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 });
+2
View File
@@ -23,6 +23,7 @@ query GetDispatchers {
name
type
url
template
}
}
@@ -90,6 +91,7 @@ mutation UpdateDispatcher($id: Int!, $input: DispatcherInput!) {
name
type
url
template
}
}
+6 -4
View File
@@ -38,12 +38,14 @@ export type Dispatcher = {
__typename?: 'Dispatcher';
id: Scalars['Int']['output'];
name: Scalars['String']['output'];
template?: Maybe<Scalars['String']['output']>;
type: Scalars['String']['output'];
url?: Maybe<Scalars['String']['output']>;
};
export type DispatcherInput = {
name: Scalars['String']['input'];
template?: InputMaybe<Scalars['String']['input']>;
type: Scalars['String']['input'];
url?: InputMaybe<Scalars['String']['input']>;
};
@@ -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<GetNotificationRulesQuery, GetNotificationRulesQueryVariables>;
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<GetDispatchersQuery, GetDispatchersQueryVariables>;
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<GetDispatchersQuery, GetDispatchersQueryVariables>;
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<CreateNotificationRuleMutation, CreateNotificationRuleMutationVariables>;
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<UpdateNotificationRuleMutation, UpdateNotificationRuleMutationVariables>;
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<ReplaceNotificationRuleMutation, ReplaceNotificationRuleMutationVariables>;
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<DeleteNotificationRuleMutation, DeleteNotificationRuleMutationVariables>;
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<CreateDispatcherMutation, CreateDispatcherMutationVariables>;
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<UpdateDispatcherMutation, UpdateDispatcherMutationVariables>;
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<UpdateDispatcherMutation, UpdateDispatcherMutationVariables>;
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<DeleteDispatcherMutation, DeleteDispatcherMutationVariables>;
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<PreviewExpressionMutation, PreviewExpressionMutationVariables>;
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<GetReleasesQuery, GetReleasesQueryVariables>;
+60 -5
View File
@@ -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))
}
+9 -4
View File
@@ -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,
}
}
+9 -7
View File
@@ -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 {
+2
View File
@@ -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 {
+28 -10
View File
@@ -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
}
+47 -10
View File
@@ -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)
}
+4 -10
View File
@@ -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")
}
}
+18 -13
View File
@@ -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
}
+5 -4
View File
@@ -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
+7
View File
@@ -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