chore: makes the experience better for cloud linking (#4393)

This commit is contained in:
Amir Raminfar
2026-01-29 07:05:16 -08:00
committed by GitHub
parent dc41e018ca
commit 69b4d8b651
12 changed files with 132 additions and 126 deletions
@@ -1,5 +1,5 @@
<template>
<div class="card bg-base-100">
<div class="card bg-base-100 hover:border-primary cursor-pointer border border-transparent" @click="editDestination">
<div class="card-body gap-2 p-4">
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg">
@@ -16,7 +16,7 @@
}}
</p>
</div>
<div class="dropdown dropdown-end">
<div class="dropdown dropdown-end" @click.stop>
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
<ion:ellipsis-vertical />
</label>
@@ -72,12 +72,21 @@
/>
</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>
<!-- Cloud linked success (when editing cloud with prefix) -->
<fieldset v-if="type === 'cloud' && destination?.prefix" class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.api-key") }}</legend>
<div class="join w-full">
<input type="text" :value="destination.prefix + '...'" readonly class="input join-item input-success w-full" />
<span class="join-item btn btn-success pointer-events-none">
<mdi:check class="text-lg" />
</span>
</div>
<p class="text-base-content/60 mt-2 text-sm">
{{ $t("notifications.destination-form.cloud-settings-hint") }}
<a :href="cloudSettingsUrl" target="_blank" class="link link-primary">
{{ $t("notifications.destination-form.cloud-settings-link") }}
</a>
</p>
</fieldset>
<!-- Link Dozzle Cloud (only for cloud type, when creating or not linked) -->
@@ -258,10 +267,9 @@ 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)}`;
});
const callbackUrl = `${window.location.origin}${withBase("/")}`;
const cloudLinkUrl = `${__CLOUD_URL__}/link?appUrl=${encodeURIComponent(callbackUrl)}`;
const cloudSettingsUrl = `${__CLOUD_URL__}/settings`;
function selectPayloadFormat(format: PayloadFormat) {
payloadFormat.value = format;
+1 -1
View File
@@ -3,7 +3,7 @@ type Toast = {
createdAt: Date;
title?: string;
message: string;
type: "success" | "error" | "warning" | "info";
type: "error" | "warning" | "info";
action?: {
label: string;
handler: () => void;
+24 -25
View File
@@ -17,7 +17,7 @@
v-for="dest in dispatchers"
:key="dest.id"
:destination="dest"
:on-updated="fetchDispatchers"
:on-updated="fetchAll"
:existing-dispatchers="dispatchers"
class="w-full md:w-72"
/>
@@ -76,7 +76,9 @@ import DestinationForm from "@/components/Notification/DestinationForm.vue";
import DestinationCard from "@/components/Notification/DestinationCard.vue";
const showDrawer = useDrawer();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { showToast } = useToast();
// State
const alerts = ref<NotificationRule[]>([]);
@@ -92,31 +94,28 @@ async function fetchDispatchers() {
dispatchers.value = await res.json();
}
fetchAlerts();
fetchDispatchers();
async function fetchAll() {
await fetchAlerts();
await fetchDispatchers();
}
// Handle newCloudLink query param
watch(
() => [route.query.newCloudLink, dispatchers.value] as const,
([newCloudLink, data]) => {
if (newCloudLink && data?.length) {
const id = Number(newCloudLink);
const destination = dispatchers.value.find((d) => d.id === id);
if (destination) {
showDrawer(
DestinationForm,
{
destination,
onCreated: fetchDispatchers,
existingDispatchers: dispatchers.value,
},
"md",
);
}
fetchAll();
// Handle cloudLinkSuccess hash param
onMounted(() => {
const hash = window.location.hash;
if (hash.startsWith("#cloudLinkSuccess=")) {
const id = Number(hash.replace("#cloudLinkSuccess=", ""));
if (!isNaN(id)) {
showToast({
title: t("notifications.cloud-link-success.title"),
message: t("notifications.cloud-link-success.message"),
type: "info",
});
}
},
{ immediate: true },
);
router.replace({ hash: "" });
}
});
// Local state
const filter = ref<"all" | "enabled" | "paused">("all");
+2 -9
View File
@@ -16,7 +16,8 @@ export interface Dispatcher {
type: string;
url?: string;
template?: string;
apiKey?: string;
prefix?: string;
expiresAt?: string;
}
export interface NotificationRuleInput {
@@ -27,14 +28,6 @@ export interface NotificationRuleInput {
containerExpression: string;
}
export interface DispatcherInput {
name: string;
type: string;
url?: string;
template?: string;
apiKey?: string;
}
export interface PreviewResult {
containerError?: string;
logError?: string;
+12 -8
View File
@@ -16,14 +16,16 @@ import (
// CloudDispatcher sends notifications to Dozzle Cloud
type CloudDispatcher struct {
Name string
URL string
APIKey string
client *http.Client
Name string
URL string
APIKey string
Prefix string
ExpiresAt *time.Time
client *http.Client
}
// NewCloudDispatcher creates a new cloud dispatcher
func NewCloudDispatcher(name string, apiKey string) (*CloudDispatcher, error) {
func NewCloudDispatcher(name string, apiKey string, prefix string, expiresAt *time.Time) (*CloudDispatcher, error) {
url := os.Getenv("DOLIGENCE_URL")
if url == "" {
url = "https://doligence.dozzle.dev"
@@ -35,9 +37,11 @@ func NewCloudDispatcher(name string, apiKey string) (*CloudDispatcher, error) {
}
return &CloudDispatcher{
Name: name,
URL: url,
APIKey: apiKey,
Name: name,
URL: url,
APIKey: apiKey,
Prefix: prefix,
ExpiresAt: expiresAt,
client: &http.Client{
Timeout: 10 * time.Second,
},
+23 -17
View File
@@ -292,10 +292,12 @@ func (m *Manager) Dispatchers() []DispatcherConfig {
})
case *dispatcher.CloudDispatcher:
result = append(result, DispatcherConfig{
ID: id,
Name: v.Name,
Type: "cloud",
APIKey: v.APIKey,
ID: id,
Name: v.Name,
Type: "cloud",
APIKey: v.APIKey,
Prefix: v.Prefix,
ExpiresAt: v.ExpiresAt,
})
}
return true
@@ -433,12 +435,14 @@ func (m *Manager) LoadConfig(r io.Reader) error {
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,
ID: d.ID,
Name: d.Name,
Type: d.Type,
URL: d.URL,
Template: d.Template,
APIKey: d.APIKey,
Prefix: d.Prefix,
ExpiresAt: d.ExpiresAt,
}
}
@@ -493,12 +497,14 @@ func (m *Manager) HandleNotificationConfig(subscriptions []types.SubscriptionCon
// Load dispatchers
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,
ID: dc.ID,
Name: dc.Name,
Type: dc.Type,
URL: dc.URL,
Template: dc.Template,
APIKey: dc.APIKey,
Prefix: dc.Prefix,
ExpiresAt: dc.ExpiresAt,
})
if err != nil {
return fmt.Errorf("failed to create dispatcher %s: %w", dc.Name, err)
@@ -524,7 +530,7 @@ func createDispatcher(config DispatcherConfig) (dispatcher.Dispatcher, error) {
case "webhook":
return dispatcher.NewWebhookDispatcher(config.Name, config.URL, config.Template)
case "cloud":
return dispatcher.NewCloudDispatcher(config.Name, config.APIKey)
return dispatcher.NewCloudDispatcher(config.Name, config.APIKey, config.Prefix, config.ExpiresAt)
default:
return nil, fmt.Errorf("unknown dispatcher type: %s", config.Type)
}
+8 -6
View File
@@ -112,12 +112,14 @@ 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", "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
ID int `json:"id" yaml:"id"`
Name string `json:"name" yaml:"name"`
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
Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"` // API key prefix for cloud dispatcher
ExpiresAt *time.Time `json:"expiresAt,omitempty" yaml:"expiresAt,omitempty"`
}
// Config represents the persisted notification configuration
+12 -2
View File
@@ -71,9 +71,19 @@ func (h *handler) cloudCallback(w http.ResponseWriter, r *http.Request) {
return
}
var expiresAt *time.Time
if tokenResp.ExpiresAt != nil {
parsed, err := time.Parse(time.RFC3339, *tokenResp.ExpiresAt)
if err != nil {
log.Warn().Err(err).Str("expiresAt", *tokenResp.ExpiresAt).Msg("Failed to parse expiresAt, ignoring")
} else {
expiresAt = &parsed
}
}
name := "Dozzle Cloud"
cloudDispatcher, err := dispatcher.NewCloudDispatcher(name, tokenResp.Key)
cloudDispatcher, err := dispatcher.NewCloudDispatcher(name, tokenResp.Key, tokenResp.Prefix, expiresAt)
if err != nil {
log.Error().Err(err).Msg("Failed to create cloud dispatcher")
http.Error(w, "failed to create cloud dispatcher", http.StatusInternalServerError)
@@ -86,6 +96,6 @@ func (h *handler) cloudCallback(w http.ResponseWriter, r *http.Request) {
if base == "/" {
base = ""
}
redirectURL := fmt.Sprintf("%s/notifications?newCloudLink=%d", base, id)
redirectURL := fmt.Sprintf("%s/notifications#cloudLinkSuccess=%d", base, id)
http.Redirect(w, r, redirectURL, http.StatusFound)
}
+17 -40
View File
@@ -32,12 +32,13 @@ type NotificationRuleResponse struct {
}
type DispatcherResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
URL *string `json:"url,omitempty"`
Template *string `json:"template,omitempty"`
APIKey *string `json:"apiKey,omitempty"`
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
URL *string `json:"url,omitempty"`
Template *string `json:"template,omitempty"`
Prefix *string `json:"prefix,omitempty"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
type NotificationRuleInput struct {
@@ -61,7 +62,6 @@ type DispatcherInput struct {
Type string `json:"type"`
URL *string `json:"url,omitempty"`
Template *string `json:"template,omitempty"`
APIKey *string `json:"apiKey,omitempty"`
}
type PreviewInput struct {
@@ -125,17 +125,18 @@ func dispatcherConfigToResponse(d *notification.DispatcherConfig) *DispatcherRes
if d.Template != "" {
template = &d.Template
}
var apiKey *string
if d.APIKey != "" {
apiKey = &d.APIKey
var prefix *string
if d.Prefix != "" {
prefix = &d.Prefix
}
return &DispatcherResponse{
ID: d.ID,
Name: d.Name,
Type: d.Type,
URL: url,
Template: template,
APIKey: apiKey,
ID: d.ID,
Name: d.Name,
Type: d.Type,
URL: url,
Template: template,
Prefix: prefix,
ExpiresAt: d.ExpiresAt,
}
}
@@ -342,17 +343,6 @@ func (h *handler) createDispatcher(w http.ResponseWriter, r *http.Request) {
return
}
d = webhook
case "cloud":
apiKey := ""
if input.APIKey != nil {
apiKey = *input.APIKey
}
cloud, err := dispatcher.NewCloudDispatcher(input.Name, apiKey)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
d = cloud
default:
writeError(w, http.StatusBadRequest, "unknown dispatcher type")
return
@@ -366,7 +356,6 @@ func (h *handler) createDispatcher(w http.ResponseWriter, r *http.Request) {
Type: input.Type,
URL: input.URL,
Template: input.Template,
APIKey: input.APIKey,
})
}
@@ -400,17 +389,6 @@ func (h *handler) updateDispatcher(w http.ResponseWriter, r *http.Request) {
return
}
d = webhook
case "cloud":
apiKey := ""
if input.APIKey != nil {
apiKey = *input.APIKey
}
cloud, err := dispatcher.NewCloudDispatcher(input.Name, apiKey)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
d = cloud
default:
writeError(w, http.StatusBadRequest, "unknown dispatcher type")
return
@@ -424,7 +402,6 @@ func (h *handler) updateDispatcher(w http.ResponseWriter, r *http.Request) {
Type: input.Type,
URL: input.URL,
Template: input.Template,
APIKey: input.APIKey,
})
}
+6 -1
View File
@@ -221,7 +221,7 @@ notifications:
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, custom endpoint
cloud-title: Dozzle Cloud
cloud-description: Push, email, and dashboard
cloud-description: Push notifications, email alerts, and AI-powered summaries
webhook-url: Webhook URL
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: API Key
@@ -242,3 +242,8 @@ notifications:
link-cloud: Link Account
link-cloud-button: Link Dozzle Cloud
cloud-linked: Your Dozzle Cloud account is linked and ready to receive notifications.
cloud-settings-hint: To configure your managed channels, go to
cloud-settings-link: Dozzle Cloud Settings
cloud-link-success:
title: Dozzle Cloud Linked
message: Your account has been successfully linked to Dozzle Cloud.
+8 -6
View File
@@ -44,10 +44,12 @@ type SubscriptionConfig struct {
// DispatcherConfig represents a notification dispatcher configuration
type DispatcherConfig struct {
ID int
Name string
Type string
URL string
Template string
APIKey string
ID int
Name string
Type string
URL string
Template string
APIKey string
Prefix string
ExpiresAt *time.Time
}