feat: supports dozzle cloud (#4381)

This commit is contained in:
Amir Raminfar
2026-01-26 07:57:35 -08:00
committed by GitHub
parent 4db74360e6
commit 8b4f4d1b00
21 changed files with 447 additions and 85 deletions
+3
View File
@@ -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']
@@ -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",
);
@@ -11,20 +11,6 @@
<p class="text-base-content/60">{{ $t("notifications.destination-form.description") }}</p>
</div>
<!-- Name -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.name") }}</legend>
<input
ref="nameInput"
v-model="name"
type="text"
class="input focus:input-primary w-full text-base"
required
:class="{ 'input-primary': name.trim().length > 0 }"
:placeholder="$t('notifications.destination-form.name-placeholder')"
/>
</fieldset>
<!-- Type Selection -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.type") }}</legend>
@@ -44,22 +30,69 @@
</div>
</label>
<label
class="card card-border border-base-content/20 cursor-pointer transition-colors"
:class="type === 'cloud' ? 'border-primary bg-primary/10' : ''"
class="card card-border border-base-content/20 transition-colors"
:class="[
type === 'cloud' ? 'border-primary bg-primary/10' : '',
hasExistingCloudDestination && type !== 'cloud' ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
]"
>
<div class="card-body flex-row items-center gap-3 p-4">
<input type="radio" v-model="type" value="cloud" class="radio radio-primary" />
<input
type="radio"
v-model="type"
value="cloud"
class="radio radio-primary"
:disabled="hasExistingCloudDestination && type !== 'cloud'"
/>
<div>
<div class="font-semibold">{{ $t("notifications.destination-form.cloud-title") }}</div>
<div class="text-base-content/60 text-sm">
{{ $t("notifications.destination-form.cloud-description") }}
</div>
<div v-if="hasExistingCloudDestination && type !== 'cloud'" class="text-warning mt-1 text-xs">
{{ $t("notifications.destination-form.cloud-exists") }}
</div>
</div>
</div>
</label>
</div>
</fieldset>
<!-- Name (only for webhook type) -->
<fieldset v-if="type === 'webhook'" class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.name") }}</legend>
<input
ref="nameInput"
v-model="name"
type="text"
class="input focus:input-primary w-full text-base"
required
:class="{ 'input-primary': name.trim().length > 0 }"
:placeholder="$t('notifications.destination-form.name-placeholder')"
/>
</fieldset>
<!-- Cloud linked success (when editing cloud with apiKey) -->
<fieldset v-if="type === 'cloud' && destination?.apiKey" class="fieldset">
<div class="alert alert-success">
<mdi:check-circle class="text-xl" />
<span>{{ $t("notifications.destination-form.cloud-linked") }}</span>
</div>
</fieldset>
<!-- Link Dozzle Cloud (only for cloud type, when creating or not linked) -->
<div v-else-if="type === 'cloud'" class="card card-border border-primary/30 bg-primary/5">
<div class="card-body items-center text-center">
<mdi:cloud-outline class="text-primary text-4xl" />
<h3 class="card-title">{{ $t("notifications.destination-form.link-cloud") }}</h3>
<p class="text-base-content/60 text-sm">{{ $t("notifications.destination-form.cloud-description") }}</p>
<a :href="cloudLinkUrl" class="btn btn-primary btn-lg mt-2">
<mdi:link-variant class="text-lg" />
{{ $t("notifications.destination-form.link-cloud-button") }}
</a>
</div>
</div>
<!-- Webhook URL (only for webhook type) -->
<fieldset v-if="type === 'webhook'" class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.webhook-url") }}</legend>
@@ -122,7 +155,12 @@
<!-- Actions -->
<div class="flex items-center gap-2 pt-4">
<button class="btn" @click="testDestination" :disabled="!canTest || !isValidUrl || isTesting">
<button
v-if="type === 'webhook'"
class="btn"
@click="testDestination"
:disabled="!canTest || !isValidUrl || isTesting"
>
<span v-if="isTesting" class="loading loading-spinner loading-sm"></span>
{{ $t("notifications.destination-form.test") }}
</button>
@@ -195,12 +233,24 @@ const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
}`,
};
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<string | null>(null);
const testResult = ref<TestWebhookResult | null>(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
+1
View File
@@ -24,6 +24,7 @@ query GetDispatchers {
type
url
template
apiKey
}
}
+28 -1
View File
@@ -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",
);
}
+2
View File
@@ -4,3 +4,5 @@ declare module "*.vue" {
const component: DefineComponent<{}, {}, any>;
export default component;
}
declare const __CLOUD_URL__: string;
+4 -2
View File
@@ -36,6 +36,7 @@ export type Container = {
export type Dispatcher = {
__typename?: 'Dispatcher';
apiKey?: Maybe<Scalars['String']['output']>;
id: Scalars['Int']['output'];
name: Scalars['String']['output'];
template?: Maybe<Scalars['String']['output']>;
@@ -44,6 +45,7 @@ export type Dispatcher = {
};
export type DispatcherInput = {
apiKey?: InputMaybe<Scalars['String']['input']>;
name: Scalars['String']['input'];
template?: InputMaybe<Scalars['String']['input']>;
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<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"}},{"kind":"Field","name":{"kind":"Name","value":"template"}}]}}]}}]} 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"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"}}]}}]}}]} 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>;
+1 -2
View File
@@ -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=
+56 -1
View File
@@ -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))
}
+5
View File
@@ -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,
}
}
+2
View File
@@ -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 {
+2
View File
@@ -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 {
+20
View File
@@ -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"}
}
+82
View File
@@ -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
}
+52 -54
View File
@@ -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
+2 -1
View File
@@ -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
+91
View File
@@ -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)
}
+3
View File
@@ -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)
+6
View File
@@ -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.
+1
View File
@@ -49,4 +49,5 @@ type DispatcherConfig struct {
Type string
URL string
Template string
APIKey string
}
+3
View File
@@ -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")}/`,