diff --git a/assets/components.d.ts b/assets/components.d.ts index 31ab1da6..c9705310 100644 --- a/assets/components.d.ts +++ b/assets/components.d.ts @@ -92,6 +92,7 @@ declare module 'vue' { 'Mdi:bell': typeof import('~icons/mdi/bell')['default'] 'Mdi:bellOutline': typeof import('~icons/mdi/bell-outline')['default'] 'Mdi:check': typeof import('~icons/mdi/check')['default'] + 'Mdi:checkCircle': typeof import('~icons/mdi/check-circle')['default'] 'Mdi:chevronDoubleDown': typeof import('~icons/mdi/chevron-double-down')['default'] 'Mdi:chevronDown': typeof import('~icons/mdi/chevron-down')['default'] 'Mdi:chevronLeft': typeof import('~icons/mdi/chevron-left')['default'] @@ -99,6 +100,7 @@ declare module 'vue' { 'Mdi:clockOutline': typeof import('~icons/mdi/clock-outline')['default'] 'Mdi:close': typeof import('~icons/mdi/close')['default'] 'Mdi:cloud': typeof import('~icons/mdi/cloud')['default'] + 'Mdi:cloudOutline': typeof import('~icons/mdi/cloud-outline')['default'] 'Mdi:cog': typeof import('~icons/mdi/cog')['default'] 'Mdi:docker': typeof import('~icons/mdi/docker')['default'] 'Mdi:gauge': typeof import('~icons/mdi/gauge')['default'] @@ -108,6 +110,7 @@ declare module 'vue' { 'Mdi:key': typeof import('~icons/mdi/key')['default'] 'Mdi:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default'] 'Mdi:lightningBolt': typeof import('~icons/mdi/lightning-bolt')['default'] + 'Mdi:linkVariant': typeof import('~icons/mdi/link-variant')['default'] 'Mdi:magnify': typeof import('~icons/mdi/magnify')['default'] 'Mdi:packageVariantClosed': typeof import('~icons/mdi/package-variant-closed')['default'] 'Mdi:pencilOutline': typeof import('~icons/mdi/pencil-outline')['default'] diff --git a/assets/components/Notification/DestinationCard.vue b/assets/components/Notification/DestinationCard.vue index 709d8aad..bd441d10 100644 --- a/assets/components/Notification/DestinationCard.vue +++ b/assets/components/Notification/DestinationCard.vue @@ -53,7 +53,7 @@ const { destination } = defineProps<{ const showDrawer = useDrawer(); const deleteMutation = useMutation(DeleteDispatcherDocument); -const dispatchersQuery = useQuery({ query: GetDispatchersDocument, pause: true }); +const dispatchersQuery = useQuery({ query: GetDispatchersDocument }); const alertsQuery = useQuery({ query: GetNotificationRulesDocument, pause: true }); function editDestination() { @@ -62,6 +62,7 @@ function editDestination() { { destination, onCreated: () => dispatchersQuery.executeQuery({ requestPolicy: "network-only" }), + existingDispatchers: dispatchersQuery.data.value?.dispatchers ?? [], }, "md", ); diff --git a/assets/components/Notification/DestinationForm.vue b/assets/components/Notification/DestinationForm.vue index 322f12ef..dbbed689 100644 --- a/assets/components/Notification/DestinationForm.vue +++ b/assets/components/Notification/DestinationForm.vue @@ -11,20 +11,6 @@

{{ $t("notifications.destination-form.description") }}

- -
- {{ $t("notifications.destination-form.name") }} - -
-
{{ $t("notifications.destination-form.type") }} @@ -44,22 +30,69 @@
+ +
+ {{ $t("notifications.destination-form.name") }} + +
+ + +
+
+ + {{ $t("notifications.destination-form.cloud-linked") }} +
+
+ + +
+
+ +

{{ $t("notifications.destination-form.link-cloud") }}

+

{{ $t("notifications.destination-form.cloud-description") }}

+ + + {{ $t("notifications.destination-form.link-cloud-button") }} + +
+
+
{{ $t("notifications.destination-form.webhook-url") }} @@ -122,7 +155,12 @@
- @@ -195,12 +233,24 @@ const PAYLOAD_TEMPLATES: Record = { }`, }; -const { close, onCreated, destination } = defineProps<{ +const { + close, + onCreated, + destination, + existingDispatchers = [], +} = defineProps<{ close?: () => void; onCreated?: () => void; destination?: Dispatcher; + existingDispatchers?: Dispatcher[]; }>(); +const hasExistingCloudDestination = computed(() => { + // When editing, exclude the current destination from the check + const others = isEditing ? existingDispatchers.filter((d) => d.id !== destination!.id) : existingDispatchers; + return others.some((d) => d.type === "cloud"); +}); + const createMutation = useMutation(CreateDispatcherDocument); const updateMutation = useMutation(UpdateDispatcherDocument); const testMutation = useMutation(TestWebhookDocument); @@ -219,6 +269,11 @@ const isSaving = ref(false); const error = ref(null); const testResult = ref(null); +const cloudLinkUrl = computed(() => { + const callbackUrl = `${window.location.origin}${withBase("/")}`; + return `${__CLOUD_URL__}/link?appUrl=${encodeURIComponent(callbackUrl)}`; +}); + function selectPayloadFormat(format: PayloadFormat) { payloadFormat.value = format; template.value = PAYLOAD_TEMPLATES[format]; @@ -242,8 +297,11 @@ const isValidUrl = computed(() => { const canSave = computed(() => { if (isSaving.value) return false; - if (!name.value.trim()) return false; - if (type.value === "webhook" && !isValidUrl.value) return false; + if (type.value === "cloud") return false; + if (type.value === "webhook") { + if (!name.value.trim()) return false; + if (!isValidUrl.value) return false; + } return true; }); @@ -283,8 +341,8 @@ async function saveDestination() { const input = { 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, + url: webhookUrl.value.trim(), + template: template.value.trim() || undefined, }; const result = isEditing diff --git a/assets/graphql/notifications.graphql b/assets/graphql/notifications.graphql index d14abb2e..af840f13 100644 --- a/assets/graphql/notifications.graphql +++ b/assets/graphql/notifications.graphql @@ -24,6 +24,7 @@ query GetDispatchers { type url template + apiKey } } diff --git a/assets/pages/notifications.vue b/assets/pages/notifications.vue index 1a765419..24d18e59 100644 --- a/assets/pages/notifications.vue +++ b/assets/pages/notifications.vue @@ -70,11 +70,35 @@ import DestinationForm from "@/components/Notification/DestinationForm.vue"; import DestinationCard from "@/components/Notification/DestinationCard.vue"; const showDrawer = useDrawer(); +const route = useRoute(); // GraphQL queries const alertsQuery = useQuery({ query: GetNotificationRulesDocument }); const dispatchersQuery = useQuery({ query: GetDispatchersDocument }); +// Handle newCloudLink query param +watch( + () => [route.query.newCloudLink, dispatchersQuery.data.value], + ([newCloudLink, data]) => { + if (newCloudLink && data) { + const id = Number(newCloudLink); + const destination = dispatchers.value.find((d) => d.id === id); + if (destination) { + showDrawer( + DestinationForm, + { + destination, + onCreated: () => dispatchersQuery.executeQuery({ requestPolicy: "network-only" }), + existingDispatchers: dispatchers.value, + }, + "md", + ); + } + } + }, + { immediate: true }, +); + // Computed data from queries const alerts = computed(() => alertsQuery.data.value?.notificationRules ?? []); const dispatchers = computed(() => dispatchersQuery.data.value?.dispatchers ?? []); @@ -98,7 +122,10 @@ function openCreateAlert() { function openAddDestination() { showDrawer( DestinationForm, - { onCreated: () => dispatchersQuery.executeQuery({ requestPolicy: "network-only" }) }, + { + onCreated: () => dispatchersQuery.executeQuery({ requestPolicy: "network-only" }), + existingDispatchers: dispatchers.value, + }, "md", ); } diff --git a/assets/shims-vue.d.ts b/assets/shims-vue.d.ts index 55e0ca6b..58c17271 100644 --- a/assets/shims-vue.d.ts +++ b/assets/shims-vue.d.ts @@ -4,3 +4,5 @@ declare module "*.vue" { const component: DefineComponent<{}, {}, any>; export default component; } + +declare const __CLOUD_URL__: string; diff --git a/assets/types/graphql.ts b/assets/types/graphql.ts index 0fd1549a..02a301f2 100644 --- a/assets/types/graphql.ts +++ b/assets/types/graphql.ts @@ -36,6 +36,7 @@ export type Container = { export type Dispatcher = { __typename?: 'Dispatcher'; + apiKey?: Maybe; id: Scalars['Int']['output']; name: Scalars['String']['output']; template?: Maybe; @@ -44,6 +45,7 @@ export type Dispatcher = { }; export type DispatcherInput = { + apiKey?: InputMaybe; name: Scalars['String']['input']; template?: InputMaybe; type: Scalars['String']['input']; @@ -218,7 +220,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, template?: string | null }> }; +export type GetDispatchersQuery = { __typename?: 'Query', dispatchers: Array<{ __typename?: 'Dispatcher', id: number, name: string, type: string, url?: string | null, template?: string | null, apiKey?: string | null }> }; export type CreateNotificationRuleMutationVariables = Exact<{ input: NotificationRuleInput; @@ -293,7 +295,7 @@ 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"}},{"kind":"Field","name":{"kind":"Name","value":"template"}}]}}]}}]} 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"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"}}]}}]}}]} 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; diff --git a/go.sum b/go.sum index 986f1ff4..bf2c5192 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,7 @@ github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43 github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= -github.com/air-verse/air v1.64.3 h1:bCLjOVQUvHd7e8FjQjYNDQsSSeXa6/S2s7uYhAVogdk= -github.com/air-verse/air v1.64.3/go.mod h1:SOwv08NTvKextanAY819AK0FK6XzQO5/tC6s1R+rk8o= +github.com/air-verse/air v1.64.4 h1:P0alz5Jia5NucZew1HYZy69lzPWZHFjLTCKo0VhxME8= github.com/air-verse/air v1.64.4/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= diff --git a/graph/generated.go b/graph/generated.go index e1a3e202..4928f160 100644 --- a/graph/generated.go +++ b/graph/generated.go @@ -68,6 +68,7 @@ type ComplexityRoot struct { } Dispatcher struct { + APIKey func(childComplexity int) int ID func(childComplexity int) int Name func(childComplexity int) int Template func(childComplexity int) int @@ -264,6 +265,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Container.State(childComplexity), true + case "Dispatcher.apiKey": + if e.complexity.Dispatcher.APIKey == nil { + break + } + + return e.complexity.Dispatcher.APIKey(childComplexity), true case "Dispatcher.id": if e.complexity.Dispatcher.ID == nil { break @@ -1437,6 +1444,35 @@ func (ec *executionContext) fieldContext_Dispatcher_template(_ context.Context, return fc, nil } +func (ec *executionContext) _Dispatcher_apiKey(ctx context.Context, field graphql.CollectedField, obj *model.Dispatcher) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Dispatcher_apiKey, + func(ctx context.Context) (any, error) { + return obj.APIKey, nil + }, + nil, + ec.marshalOString2áš–string, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Dispatcher_apiKey(_ 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, @@ -1899,6 +1935,8 @@ func (ec *executionContext) fieldContext_Mutation_createDispatcher(ctx context.C return ec.fieldContext_Dispatcher_url(ctx, field) case "template": return ec.fieldContext_Dispatcher_template(ctx, field) + case "apiKey": + return ec.fieldContext_Dispatcher_apiKey(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Dispatcher", field.Name) }, @@ -1952,6 +1990,8 @@ func (ec *executionContext) fieldContext_Mutation_updateDispatcher(ctx context.C return ec.fieldContext_Dispatcher_url(ctx, field) case "template": return ec.fieldContext_Dispatcher_template(ctx, field) + case "apiKey": + return ec.fieldContext_Dispatcher_apiKey(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Dispatcher", field.Name) }, @@ -2234,6 +2274,8 @@ func (ec *executionContext) fieldContext_NotificationRule_dispatcher(_ context.C return ec.fieldContext_Dispatcher_url(ctx, field) case "template": return ec.fieldContext_Dispatcher_template(ctx, field) + case "apiKey": + return ec.fieldContext_Dispatcher_apiKey(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Dispatcher", field.Name) }, @@ -2715,6 +2757,8 @@ func (ec *executionContext) fieldContext_Query_dispatchers(_ context.Context, fi return ec.fieldContext_Dispatcher_url(ctx, field) case "template": return ec.fieldContext_Dispatcher_template(ctx, field) + case "apiKey": + return ec.fieldContext_Dispatcher_apiKey(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Dispatcher", field.Name) }, @@ -2757,6 +2801,8 @@ func (ec *executionContext) fieldContext_Query_dispatcher(ctx context.Context, f return ec.fieldContext_Dispatcher_url(ctx, field) case "template": return ec.fieldContext_Dispatcher_template(ctx, field) + case "apiKey": + return ec.fieldContext_Dispatcher_apiKey(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Dispatcher", field.Name) }, @@ -4764,7 +4810,7 @@ func (ec *executionContext) unmarshalInputDispatcherInput(ctx context.Context, o asMap[k] = v } - fieldsInOrder := [...]string{"name", "type", "url", "template"} + fieldsInOrder := [...]string{"name", "type", "url", "template", "apiKey"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -4799,6 +4845,13 @@ func (ec *executionContext) unmarshalInputDispatcherInput(ctx context.Context, o return it, err } it.Template = data + case "apiKey": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("apiKey")) + data, err := ec.unmarshalOString2áš–string(ctx, v) + if err != nil { + return it, err + } + it.APIKey = data } } @@ -5129,6 +5182,8 @@ func (ec *executionContext) _Dispatcher(ctx context.Context, sel ast.SelectionSe out.Values[i] = ec._Dispatcher_url(ctx, field, obj) case "template": out.Values[i] = ec._Dispatcher_template(ctx, field, obj) + case "apiKey": + out.Values[i] = ec._Dispatcher_apiKey(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/graph/helpers.go b/graph/helpers.go index 9b2cae85..70cc24d8 100644 --- a/graph/helpers.go +++ b/graph/helpers.go @@ -44,12 +44,17 @@ func dispatcherConfigToDispatcher(d *notification.DispatcherConfig) *model.Dispa if d.Template != "" { template = &d.Template } + var apiKey *string + if d.APIKey != "" { + apiKey = &d.APIKey + } return &model.Dispatcher{ ID: int32(d.ID), Name: d.Name, Type: d.Type, URL: url, Template: template, + APIKey: apiKey, } } diff --git a/graph/model/models_gen.go b/graph/model/models_gen.go index 9a4c2816..80353965 100644 --- a/graph/model/models_gen.go +++ b/graph/model/models_gen.go @@ -14,6 +14,7 @@ type Dispatcher struct { Type string `json:"type"` URL *string `json:"url,omitempty"` Template *string `json:"template,omitempty"` + APIKey *string `json:"apiKey,omitempty"` } type DispatcherInput struct { @@ -21,6 +22,7 @@ type DispatcherInput struct { Type string `json:"type"` URL *string `json:"url,omitempty"` Template *string `json:"template,omitempty"` + APIKey *string `json:"apiKey,omitempty"` } type Mutation struct { diff --git a/graph/schema.graphqls b/graph/schema.graphqls index c26e6cc8..44c65f73 100644 --- a/graph/schema.graphqls +++ b/graph/schema.graphqls @@ -47,6 +47,7 @@ type Dispatcher { type: String! url: String template: String + apiKey: String } type PreviewResult { @@ -92,6 +93,7 @@ input DispatcherInput { type: String! url: String template: String + apiKey: String } input PreviewInput { diff --git a/graph/schema.resolvers.go b/graph/schema.resolvers.go index 1b18c13e..7901b2c9 100644 --- a/graph/schema.resolvers.go +++ b/graph/schema.resolvers.go @@ -131,6 +131,16 @@ func (r *mutationResolver) CreateDispatcher(ctx context.Context, input model.Dis return nil, &Error{Message: err.Error()} } d = webhook + case "cloud": + apiKey := "" + if input.APIKey != nil { + apiKey = *input.APIKey + } + cloud, err := dispatcher.NewCloudDispatcher(input.Name, apiKey) + if err != nil { + return nil, &Error{Message: err.Error()} + } + d = cloud default: return nil, &Error{Message: "unknown dispatcher type"} } @@ -164,6 +174,16 @@ func (r *mutationResolver) UpdateDispatcher(ctx context.Context, id int32, input return nil, &Error{Message: err.Error()} } d = webhook + case "cloud": + apiKey := "" + if input.APIKey != nil { + apiKey = *input.APIKey + } + cloud, err := dispatcher.NewCloudDispatcher(input.Name, apiKey) + if err != nil { + return nil, &Error{Message: err.Error()} + } + d = cloud default: return nil, &Error{Message: "unknown dispatcher type"} } diff --git a/internal/notification/dispatcher/cloud.go b/internal/notification/dispatcher/cloud.go new file mode 100644 index 00000000..dc695e97 --- /dev/null +++ b/internal/notification/dispatcher/cloud.go @@ -0,0 +1,82 @@ +package dispatcher + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/amir20/dozzle/types" + "github.com/rs/zerolog/log" +) + +// CloudDispatcher sends notifications to Dozzle Cloud +type CloudDispatcher struct { + Name string + URL string + APIKey string + client *http.Client +} + +// NewCloudDispatcher creates a new cloud dispatcher +func NewCloudDispatcher(name string, apiKey string) (*CloudDispatcher, error) { + url := os.Getenv("DOLIGENCE_URL") + if url == "" { + url = "https://doligence.dozzle.dev" + } + url = url + "/api/events" + + if apiKey == "" { + return nil, fmt.Errorf("API key is required for cloud dispatcher") + } + + return &CloudDispatcher{ + Name: name, + URL: url, + APIKey: apiKey, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + }, nil +} + +// Send sends a notification to Dozzle Cloud +func (c *CloudDispatcher) Send(ctx context.Context, notification types.Notification) error { + payload, err := json.Marshal(notification) + if err != nil { + return fmt.Errorf("failed to marshal notification: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", c.APIKey) + + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("failed to send to cloud: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + limitedReader := io.LimitReader(resp.Body, 1024*1024) + responseBody, _ := io.ReadAll(limitedReader) + log.Debug(). + Str("cloud", c.Name). + Str("url", c.URL). + Int("status_code", resp.StatusCode). + Str("payload", string(payload)). + Str("response_body", string(responseBody)). + Msg("cloud returned non-success status code") + return fmt.Errorf("cloud returned status code %d: %s", resp.StatusCode, string(responseBody)) + } + + return nil +} diff --git a/internal/notification/manager.go b/internal/notification/manager.go index c9261b48..61ad8c73 100644 --- a/internal/notification/manager.go +++ b/internal/notification/manager.go @@ -290,6 +290,13 @@ func (m *Manager) Dispatchers() []DispatcherConfig { URL: v.URL, Template: v.TemplateText, }) + case *dispatcher.CloudDispatcher: + result = append(result, DispatcherConfig{ + ID: id, + Name: v.Name, + Type: "cloud", + APIKey: v.APIKey, + }) } return true }) @@ -410,53 +417,32 @@ func (m *Manager) LoadConfig(r io.Reader) error { return fmt.Errorf("failed to decode config: %w", err) } - // Find max IDs to initialize counters - var maxSubID, maxDispatcherID int - for _, sub := range config.Subscriptions { - if sub.ID > maxSubID { - maxSubID = sub.ID - } - } - for _, d := range config.Dispatchers { - if d.ID > maxDispatcherID { - maxDispatcherID = d.ID - } - } - m.subscriptionCounter.Store(int32(maxSubID)) - m.dispatcherCounter.Store(int32(maxDispatcherID)) - - // Load subscriptions - for _, sub := range config.Subscriptions { - if err := m.loadSubscription(sub); err != nil { - return fmt.Errorf("failed to add subscription %s: %w", sub.Name, err) + // Convert to types for HandleNotificationConfig + subscriptions := make([]types.SubscriptionConfig, len(config.Subscriptions)) + for i, sub := range config.Subscriptions { + subscriptions[i] = types.SubscriptionConfig{ + ID: sub.ID, + Name: sub.Name, + Enabled: sub.Enabled, + DispatcherID: sub.DispatcherID, + LogExpression: sub.LogExpression, + ContainerExpression: sub.ContainerExpression, } } - // Load dispatchers - for _, dispatcherConfig := range config.Dispatchers { - var d dispatcher.Dispatcher - switch dispatcherConfig.Type { - case "webhook": - 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.Debug().Int("id", dispatcherConfig.ID).Msg("Loaded dispatcher") - } - - // Update listener to start streams for loaded subscriptions - if m.listener != nil { - if err := m.listener.UpdateStreams(); err != nil { - return fmt.Errorf("failed to update listener streams: %w", err) + dispatchers := make([]types.DispatcherConfig, len(config.Dispatchers)) + for i, d := range config.Dispatchers { + dispatchers[i] = types.DispatcherConfig{ + ID: d.ID, + Name: d.Name, + Type: d.Type, + URL: d.URL, + Template: d.Template, + APIKey: d.APIKey, } } - return nil + return m.HandleNotificationConfig(subscriptions, dispatchers) } // HandleNotificationConfig implements agent.NotificationConfigHandler interface @@ -505,20 +491,20 @@ func (m *Manager) HandleNotificationConfig(subscriptions []types.SubscriptionCon } // Load dispatchers - for _, dispatcherConfig := range dispatchers { - var d dispatcher.Dispatcher - switch dispatcherConfig.Type { - case "webhook": - 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) + for _, dc := range dispatchers { + d, err := createDispatcher(DispatcherConfig{ + ID: dc.ID, + Name: dc.Name, + Type: dc.Type, + URL: dc.URL, + Template: dc.Template, + APIKey: dc.APIKey, + }) + if err != nil { + return fmt.Errorf("failed to create dispatcher %s: %w", dc.Name, err) } - m.dispatchers.Store(dispatcherConfig.ID, d) - log.Debug().Int("id", dispatcherConfig.ID).Msg("Loaded dispatcher from state sync") + m.dispatchers.Store(dc.ID, d) + log.Debug().Int("id", dc.ID).Msg("Loaded dispatcher from state sync") } // Update listener to start/stop streams based on new subscriptions @@ -532,6 +518,18 @@ func (m *Manager) HandleNotificationConfig(subscriptions []types.SubscriptionCon return nil } +// createDispatcher creates a dispatcher from a DispatcherConfig +func createDispatcher(config DispatcherConfig) (dispatcher.Dispatcher, error) { + switch config.Type { + case "webhook": + return dispatcher.NewWebhookDispatcher(config.Name, config.URL, config.Template) + case "cloud": + return dispatcher.NewCloudDispatcher(config.Name, config.APIKey) + default: + return nil, fmt.Errorf("unknown dispatcher type: %s", config.Type) + } +} + // loadSubscription loads a subscription with its existing ID (used when loading from config) func (m *Manager) loadSubscription(sub *Subscription) error { // Compile container expression if provided diff --git a/internal/notification/types.go b/internal/notification/types.go index 0020afa5..220ec254 100644 --- a/internal/notification/types.go +++ b/internal/notification/types.go @@ -114,9 +114,10 @@ func (s *Subscription) AddTriggeredContainer(id string) { type DispatcherConfig struct { ID int `json:"id" yaml:"id"` Name string `json:"name" yaml:"name"` - Type string `json:"type" yaml:"type"` // "webhook", etc. + Type string `json:"type" yaml:"type"` // "webhook", "cloud" URL string `json:"url,omitempty" yaml:"url,omitempty"` Template string `json:"template,omitempty" yaml:"template,omitempty"` // Go template for custom payload format + APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"` // API key for cloud dispatcher } // Config represents the persisted notification configuration diff --git a/internal/web/cloud.go b/internal/web/cloud.go new file mode 100644 index 00000000..7c6ba1e2 --- /dev/null +++ b/internal/web/cloud.go @@ -0,0 +1,91 @@ +package web + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/amir20/dozzle/internal/notification/dispatcher" + "github.com/rs/zerolog/log" +) + +type exchangeTokenResponse struct { + Key string `json:"key"` + Prefix string `json:"prefix"` + ExpiresAt *string `json:"expiresAt,omitempty"` +} + +func (h *handler) cloudCallback(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" { + http.Error(w, "missing token parameter", http.StatusBadRequest) + return + } + + cloudURL := os.Getenv("DOLIGENCE_URL") + if cloudURL == "" { + cloudURL = "https://doligence.dozzle.dev" + } + + exchangeURL := fmt.Sprintf("%s/api/exchange-token", cloudURL) + + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, exchangeURL, nil) + if err != nil { + log.Error().Err(err).Msg("Failed to create request") + http.Error(w, "failed to create request", http.StatusInternalServerError) + return + } + q := req.URL.Query() + q.Set("token", token) + req.URL.RawQuery = q.Encode() + + resp, err := client.Do(req) + if err != nil { + log.Error().Err(err).Msg("Failed to exchange token") + http.Error(w, "failed to exchange token", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + log.Error().Int("status", resp.StatusCode).Str("body", string(body)).Msg("Token exchange failed") + http.Error(w, "token exchange failed", http.StatusInternalServerError) + return + } + + var tokenResp exchangeTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + log.Error().Err(err).Msg("Failed to decode token response") + http.Error(w, "failed to decode token response", http.StatusInternalServerError) + return + } + + if tokenResp.Key == "" { + log.Error().Msg("Empty key received") + http.Error(w, "empty key received", http.StatusInternalServerError) + return + } + + name := "Dozzle Cloud" + + cloudDispatcher, err := dispatcher.NewCloudDispatcher(name, tokenResp.Key) + if err != nil { + log.Error().Err(err).Msg("Failed to create cloud dispatcher") + http.Error(w, "failed to create cloud dispatcher", http.StatusInternalServerError) + return + } + + id := h.hostService.AddDispatcher(cloudDispatcher) + + base := h.config.Base + if base == "/" { + base = "" + } + redirectURL := fmt.Sprintf("%s/notifications?newCloudLink=%d", base, id) + http.Redirect(w, r, redirectURL, http.StatusFound) +} diff --git a/internal/web/routes.go b/internal/web/routes.go index dfef9ec8..0e640c97 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -170,6 +170,9 @@ func createRouter(h *handler) *chi.Mux { r.Post("/token", h.createToken) r.Delete("/token", h.deleteToken) } + + // Cloud callback (public, handles OAuth-style code exchange) + r.Get("/cloud/callback", h.cloudCallback) }) r.Get("/healthcheck", h.healthcheck) diff --git a/locales/en.yml b/locales/en.yml index 7a54081f..d417c592 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -224,6 +224,8 @@ notifications: cloud-description: Push, email, and dashboard webhook-url: Webhook URL webhook-url-placeholder: https://hooks.foo.com/services/... + api-key: API Key + api-key-placeholder: Enter your Dozzle Cloud API key payload-format: Payload Format format-slack: Slack format-discord: Discord @@ -236,3 +238,7 @@ notifications: cancel: Cancel save: Save add: Add Destination + cloud-exists: Only one Dozzle Cloud destination can be configured + link-cloud: Link Account + link-cloud-button: Link Dozzle Cloud + cloud-linked: Your Dozzle Cloud account is linked and ready to receive notifications. diff --git a/types/notification.go b/types/notification.go index f5ea1ce6..5002fa72 100644 --- a/types/notification.go +++ b/types/notification.go @@ -49,4 +49,5 @@ type DispatcherConfig struct { Type string URL string Template string + APIKey string } diff --git a/vite.config.ts b/vite.config.ts index 7dcf0ef9..c20db400 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,9 @@ import svgLoader from "vite-svg-loader"; import tailwindcss from "@tailwindcss/vite"; export default defineConfig(() => ({ + define: { + __CLOUD_URL__: JSON.stringify(process.env.CLOUD_URL || "https://cloud.dozzle.dev"), + }, resolve: { alias: { "@/": `${path.resolve(__dirname, "assets")}/`,