mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 10:14:44 +00:00
feat: adds better cloud error (#4438)
This commit is contained in:
Vendored
+3
@@ -32,6 +32,7 @@ declare module 'vue' {
|
||||
'Cil:circle': typeof import('~icons/cil/circle')['default']
|
||||
'Cil:columns': typeof import('~icons/cil/columns')['default']
|
||||
'Cil:xCircle': typeof import('~icons/cil/x-circle')['default']
|
||||
CloudDestinationForm: typeof import('./components/Notification/CloudDestinationForm.vue')['default']
|
||||
ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default']
|
||||
ContainerActionsToolbar: typeof import('./components/ContainerViewer/ContainerActionsToolbar.vue')['default']
|
||||
ContainerDropdown: typeof import('./components/ContainerDropdown.vue')['default']
|
||||
@@ -85,6 +86,7 @@ declare module 'vue' {
|
||||
'MaterialSymbolsLight:collapseAll': typeof import('~icons/material-symbols-light/collapse-all')['default']
|
||||
'Mdi:account': typeof import('~icons/mdi/account')['default']
|
||||
'Mdi:alert': typeof import('~icons/mdi/alert')['default']
|
||||
'Mdi:alertCircle': typeof import('~icons/mdi/alert-circle')['default']
|
||||
'Mdi:alertOutline': typeof import('~icons/mdi/alert-outline')['default']
|
||||
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
|
||||
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
|
||||
@@ -165,6 +167,7 @@ declare module 'vue' {
|
||||
ToastModal: typeof import('./components/common/ToastModal.vue')['default']
|
||||
Toggle: typeof import('./components/common/Toggle.vue')['default']
|
||||
ViewerWithSource: typeof import('./components/LogViewer/ViewerWithSource.vue')['default']
|
||||
WebhookDestinationForm: typeof import('./components/Notification/WebhookDestinationForm.vue')['default']
|
||||
ZigZag: typeof import('./components/LogViewer/ZigZag.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Cloud linked (when editing with prefix) -->
|
||||
<fieldset v-if="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
|
||||
disabled
|
||||
class="input join-item w-full font-mono"
|
||||
:class="cloudStatusError ? 'input-error' : 'input-success'"
|
||||
/>
|
||||
<span class="join-item btn pointer-events-none" :class="cloudStatusError ? 'btn-error' : 'btn-success'">
|
||||
<mdi:alert-circle v-if="cloudStatusError" class="text-lg" />
|
||||
<mdi:check v-else class="text-lg" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Cloud Status -->
|
||||
<div v-if="isLoadingCloudStatus" class="mt-3 flex items-center gap-2">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span class="text-base-content/60 text-sm">{{ $t("notifications.destination-form.cloud-checking") }}</span>
|
||||
</div>
|
||||
<div v-else-if="cloudStatusError" class="mt-3">
|
||||
<div class="alert alert-error">
|
||||
<mdi:alert-circle class="text-lg" />
|
||||
<span>{{ $t("notifications.destination-form.cloud-relink") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="cloudStatus" class="mt-3 space-y-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-base-content/60">{{ $t("notifications.destination-form.cloud-plan") }}</span>
|
||||
<span class="badge badge-primary badge-sm capitalize">{{ cloudStatus.plan.name }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between text-sm">
|
||||
<span class="text-base-content/60">{{ $t("notifications.destination-form.cloud-usage") }}</span>
|
||||
<span
|
||||
>{{ cloudStatus.usage.events_used.toLocaleString() }} /
|
||||
{{ cloudStatus.usage.events_limit.toLocaleString() }}</span
|
||||
>
|
||||
</div>
|
||||
<progress
|
||||
class="progress w-full"
|
||||
:class="usagePercent > 90 ? 'progress-error' : usagePercent > 70 ? 'progress-warning' : 'progress-primary'"
|
||||
:value="cloudStatus.usage.events_used"
|
||||
:max="cloudStatus.usage.events_limit"
|
||||
></progress>
|
||||
</div>
|
||||
</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 (when creating or not linked) -->
|
||||
<div v-else 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>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 pt-4">
|
||||
<div class="flex-1"></div>
|
||||
<button class="btn btn-primary" @click="close?.()">
|
||||
{{ $t("notifications.destination-form.close") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Dispatcher } from "@/types/notifications";
|
||||
|
||||
const { destination, close } = defineProps<{
|
||||
destination?: Dispatcher;
|
||||
close?: () => void;
|
||||
}>();
|
||||
|
||||
const callbackUrl = `${window.location.origin}${withBase("/")}`;
|
||||
const cloudLinkUrl = `${__CLOUD_URL__}/link?appUrl=${encodeURIComponent(callbackUrl)}`;
|
||||
const cloudSettingsUrl = `${__CLOUD_URL__}/settings`;
|
||||
|
||||
// Cloud status
|
||||
interface CloudStatus {
|
||||
user: { email: string; name: string };
|
||||
plan: { name: string; events_per_month: number; retention_days: number };
|
||||
usage: { events_used: number; events_limit: number; period: string };
|
||||
}
|
||||
|
||||
const cloudStatus = ref<CloudStatus | null>(null);
|
||||
const cloudStatusError = ref(false);
|
||||
const isLoadingCloudStatus = ref(false);
|
||||
|
||||
const usagePercent = computed(() => {
|
||||
if (!cloudStatus.value) return 0;
|
||||
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
|
||||
});
|
||||
|
||||
async function fetchCloudStatus() {
|
||||
isLoadingCloudStatus.value = true;
|
||||
cloudStatusError.value = false;
|
||||
try {
|
||||
const res = await fetch(withBase("/api/cloud/status"));
|
||||
if (!res.ok) {
|
||||
cloudStatusError.value = true;
|
||||
return;
|
||||
}
|
||||
cloudStatus.value = await res.json();
|
||||
} catch {
|
||||
cloudStatusError.value = true;
|
||||
} finally {
|
||||
isLoadingCloudStatus.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (destination?.prefix) {
|
||||
fetchCloudStatus();
|
||||
}
|
||||
</script>
|
||||
@@ -67,144 +67,22 @@
|
||||
</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 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
|
||||
disabled
|
||||
class="input join-item input-success w-full font-mono"
|
||||
/>
|
||||
<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) -->
|
||||
<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>
|
||||
<input
|
||||
v-model="webhookUrl"
|
||||
type="url"
|
||||
class="input focus:input-primary w-full text-base"
|
||||
:class="{ 'input-primary': isValidUrl, 'input-error': webhookUrl.trim() && !isValidUrl }"
|
||||
:placeholder="$t('notifications.destination-form.webhook-url-placeholder')"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<!-- Payload Format (only for webhook type) -->
|
||||
<fieldset v-if="type === 'webhook' && !isEditing" 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>
|
||||
<div
|
||||
ref="templateEditorRef"
|
||||
class="border-base-content/20 focus-within:border-primary min-h-48 w-full overflow-auto rounded-lg border"
|
||||
></div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="alert alert-error">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Test Result -->
|
||||
<div v-if="testResult" class="alert" :class="testResult.success ? 'alert-success' : 'alert-error'">
|
||||
<span v-if="testResult.success">
|
||||
{{ $t("notifications.destination-form.test-success") }}
|
||||
<span v-if="testResult.statusCode" class="opacity-70">({{ testResult.statusCode }})</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ testResult.error }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 pt-4">
|
||||
<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>
|
||||
<div class="flex-1"></div>
|
||||
<button class="btn" :class="{ 'btn-primary': type === 'cloud' }" @click="close?.()">
|
||||
{{
|
||||
type === "cloud" ? $t("notifications.destination-form.close") : $t("notifications.destination-form.cancel")
|
||||
}}
|
||||
</button>
|
||||
<button v-if="type === 'webhook'" class="btn btn-primary" :disabled="!canSave" @click="saveDestination">
|
||||
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
|
||||
{{ isEditing ? $t("notifications.destination-form.save") : $t("notifications.destination-form.add") }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Type-specific form -->
|
||||
<WebhookDestinationForm
|
||||
v-if="type === 'webhook'"
|
||||
:destination="destination"
|
||||
:close="close"
|
||||
:on-created="onCreated"
|
||||
:is-editing="isEditing"
|
||||
/>
|
||||
<CloudDestinationForm v-else :destination="destination" :close="close" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Dispatcher, TestWebhookResult } from "@/types/notifications";
|
||||
import { createTemplateEditor } from "@/composable/templateEditor";
|
||||
import { PAYLOAD_TEMPLATES, type PayloadFormat } from "./payloadTemplates";
|
||||
import type { Dispatcher } from "@/types/notifications";
|
||||
import WebhookDestinationForm from "./WebhookDestinationForm.vue";
|
||||
import CloudDestinationForm from "./CloudDestinationForm.vue";
|
||||
|
||||
const {
|
||||
close,
|
||||
@@ -220,145 +98,11 @@ const {
|
||||
showLinkSuccess?: boolean;
|
||||
}>();
|
||||
|
||||
const isEditing = !!destination;
|
||||
const type = ref<"webhook" | "cloud">((destination?.type as "webhook" | "cloud") ?? "webhook");
|
||||
|
||||
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 isEditing = !!destination;
|
||||
|
||||
const nameInput = ref<HTMLInputElement>();
|
||||
const templateEditorRef = ref<HTMLElement>();
|
||||
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);
|
||||
const testResult = ref<TestWebhookResult | null>(null);
|
||||
|
||||
const callbackUrl = `${window.location.origin}${withBase("/")}`;
|
||||
const cloudLinkUrl = `${__CLOUD_URL__}/link?appUrl=${encodeURIComponent(callbackUrl)}`;
|
||||
const cloudSettingsUrl = `${__CLOUD_URL__}/settings`;
|
||||
|
||||
let templateEditorView: Awaited<ReturnType<typeof createTemplateEditor>> | undefined;
|
||||
|
||||
function selectPayloadFormat(format: PayloadFormat) {
|
||||
payloadFormat.value = format;
|
||||
template.value = PAYLOAD_TEMPLATES[format];
|
||||
setEditorContent(template.value);
|
||||
}
|
||||
|
||||
function setEditorContent(value: string) {
|
||||
if (!templateEditorView) return;
|
||||
templateEditorView.dispatch({
|
||||
changes: { from: 0, to: templateEditorView.state.doc.length, insert: value },
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!templateEditorRef.value) return;
|
||||
templateEditorView = await createTemplateEditor({
|
||||
parent: templateEditorRef.value,
|
||||
initialValue: template.value,
|
||||
onChange: (v) => (template.value = v),
|
||||
});
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
templateEditorView?.destroy();
|
||||
});
|
||||
|
||||
const canTest = computed(() => {
|
||||
if (type.value === "webhook") {
|
||||
return webhookUrl.value.trim().length > 0;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const isValidUrl = computed(() => {
|
||||
try {
|
||||
new URL(webhookUrl.value.trim());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (isSaving.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;
|
||||
});
|
||||
|
||||
async function testDestination() {
|
||||
if (!canTest.value) return;
|
||||
|
||||
isTesting.value = true;
|
||||
testResult.value = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(withBase("/api/notifications/test-webhook"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: webhookUrl.value.trim(),
|
||||
template: template.value.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const data: TestWebhookResult = await res.json();
|
||||
testResult.value = data;
|
||||
} catch (e) {
|
||||
testResult.value = { success: false, error: e instanceof Error ? e.message : "Test failed" };
|
||||
} finally {
|
||||
isTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDestination() {
|
||||
if (!canSave.value) return;
|
||||
|
||||
isSaving.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const input = {
|
||||
name: name.value.trim(),
|
||||
type: type.value,
|
||||
url: webhookUrl.value.trim(),
|
||||
template: template.value.trim() || undefined,
|
||||
};
|
||||
|
||||
const url = isEditing
|
||||
? withBase(`/api/notifications/dispatchers/${destination!.id}`)
|
||||
: withBase("/api/notifications/dispatchers");
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: isEditing ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Failed to save destination");
|
||||
}
|
||||
|
||||
onCreated?.();
|
||||
close?.();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Failed to save destination";
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- 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>
|
||||
|
||||
<!-- Webhook URL -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.webhook-url") }}</legend>
|
||||
<input
|
||||
v-model="webhookUrl"
|
||||
type="url"
|
||||
class="input focus:input-primary w-full text-base"
|
||||
:class="{ 'input-primary': isValidUrl, 'input-error': webhookUrl.trim() && !isValidUrl }"
|
||||
:placeholder="$t('notifications.destination-form.webhook-url-placeholder')"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<!-- Payload Format (create mode only) -->
|
||||
<fieldset v-if="!isEditing" 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 -->
|
||||
<fieldset 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>
|
||||
<div
|
||||
ref="templateEditorRef"
|
||||
class="border-base-content/20 focus-within:border-primary min-h-48 w-full overflow-auto rounded-lg border"
|
||||
></div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="alert alert-error">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Test Result -->
|
||||
<div v-if="testResult" class="alert" :class="testResult.success ? 'alert-success' : 'alert-error'">
|
||||
<span v-if="testResult.success">
|
||||
{{ $t("notifications.destination-form.test-success") }}
|
||||
<span v-if="testResult.statusCode" class="opacity-70">({{ testResult.statusCode }})</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ testResult.error }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 pt-4">
|
||||
<button 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>
|
||||
<div class="flex-1"></div>
|
||||
<button class="btn" @click="close?.()">
|
||||
{{ $t("notifications.destination-form.cancel") }}
|
||||
</button>
|
||||
<button class="btn btn-primary" :disabled="!canSave" @click="saveDestination">
|
||||
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
|
||||
{{ isEditing ? $t("notifications.destination-form.save") : $t("notifications.destination-form.add") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Dispatcher, TestWebhookResult } from "@/types/notifications";
|
||||
import { createTemplateEditor } from "@/composable/templateEditor";
|
||||
import { PAYLOAD_TEMPLATES, type PayloadFormat } from "./payloadTemplates";
|
||||
|
||||
const { close, onCreated, destination, isEditing } = defineProps<{
|
||||
close?: () => void;
|
||||
onCreated?: () => void;
|
||||
destination?: Dispatcher;
|
||||
isEditing: boolean;
|
||||
}>();
|
||||
|
||||
const nameInput = ref<HTMLInputElement>();
|
||||
const templateEditorRef = ref<HTMLElement>();
|
||||
const name = ref(destination?.name ?? "");
|
||||
useFocus(nameInput, { initialValue: true });
|
||||
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);
|
||||
const testResult = ref<TestWebhookResult | null>(null);
|
||||
|
||||
let templateEditorView: Awaited<ReturnType<typeof createTemplateEditor>> | undefined;
|
||||
|
||||
function selectPayloadFormat(format: PayloadFormat) {
|
||||
payloadFormat.value = format;
|
||||
template.value = PAYLOAD_TEMPLATES[format];
|
||||
setEditorContent(template.value);
|
||||
}
|
||||
|
||||
function setEditorContent(value: string) {
|
||||
if (!templateEditorView) return;
|
||||
templateEditorView.dispatch({
|
||||
changes: { from: 0, to: templateEditorView.state.doc.length, insert: value },
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!templateEditorRef.value) return;
|
||||
templateEditorView = await createTemplateEditor({
|
||||
parent: templateEditorRef.value,
|
||||
initialValue: template.value,
|
||||
onChange: (v) => (template.value = v),
|
||||
});
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
templateEditorView?.destroy();
|
||||
});
|
||||
|
||||
const canTest = computed(() => webhookUrl.value.trim().length > 0);
|
||||
|
||||
const isValidUrl = computed(() => {
|
||||
try {
|
||||
new URL(webhookUrl.value.trim());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (isSaving.value) return false;
|
||||
if (!name.value.trim()) return false;
|
||||
if (!isValidUrl.value) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
async function testDestination() {
|
||||
if (!canTest.value) return;
|
||||
|
||||
isTesting.value = true;
|
||||
testResult.value = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(withBase("/api/notifications/test-webhook"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: webhookUrl.value.trim(),
|
||||
template: template.value.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const data: TestWebhookResult = await res.json();
|
||||
testResult.value = data;
|
||||
} catch (e) {
|
||||
testResult.value = { success: false, error: e instanceof Error ? e.message : "Test failed" };
|
||||
} finally {
|
||||
isTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDestination() {
|
||||
if (!canSave.value) return;
|
||||
|
||||
isSaving.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const input = {
|
||||
name: name.value.trim(),
|
||||
type: "webhook",
|
||||
url: webhookUrl.value.trim(),
|
||||
template: template.value.trim() || undefined,
|
||||
};
|
||||
|
||||
const url = isEditing
|
||||
? withBase(`/api/notifications/dispatchers/${destination!.id}`)
|
||||
: withBase("/api/notifications/dispatchers");
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: isEditing ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Failed to save destination");
|
||||
}
|
||||
|
||||
onCreated?.();
|
||||
close?.();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Failed to save destination";
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -99,3 +99,54 @@ func (h *handler) cloudCallback(w http.ResponseWriter, r *http.Request) {
|
||||
redirectURL := fmt.Sprintf("%s/notifications#cloudLinkSuccess=%d", base, id)
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *handler) cloudStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// Find the cloud dispatcher to get the API key
|
||||
var apiKey string
|
||||
for _, d := range h.hostService.Dispatchers() {
|
||||
if d.Type == "cloud" && d.APIKey != "" {
|
||||
apiKey = d.APIKey
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if apiKey == "" {
|
||||
writeError(w, http.StatusNotFound, "no cloud dispatcher configured")
|
||||
return
|
||||
}
|
||||
|
||||
cloudURL := os.Getenv("DOLIGENCE_URL")
|
||||
if cloudURL == "" {
|
||||
cloudURL = "https://doligence.dozzle.dev"
|
||||
}
|
||||
|
||||
statusURL := fmt.Sprintf("%s/api/status", cloudURL)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, statusURL, nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create cloud status request")
|
||||
writeError(w, http.StatusInternalServerError, "failed to create request")
|
||||
return
|
||||
}
|
||||
req.Header.Set("X-API-Key", apiKey)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to fetch cloud status")
|
||||
writeError(w, http.StatusBadGateway, "failed to fetch cloud status")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
log.Warn().Int("status", resp.StatusCode).Str("body", string(body)).Msg("Cloud status check failed")
|
||||
writeError(w, resp.StatusCode, "cloud API key is invalid or expired")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
@@ -179,6 +179,9 @@ func createRouter(h *handler) *chi.Mux {
|
||||
|
||||
// Releases API
|
||||
r.Get("/releases", h.getReleases)
|
||||
|
||||
// Cloud API
|
||||
r.Get("/cloud/status", h.cloudStatus)
|
||||
})
|
||||
|
||||
// Public API routes
|
||||
|
||||
@@ -233,6 +233,10 @@ notifications:
|
||||
link-cloud-button: Link Dozzle Cloud
|
||||
cloud-settings-hint: For at konfigurere dine administrerede kanaler, gå til
|
||||
cloud-settings-link: Dozzle Cloud Indstillinger
|
||||
cloud-checking: Kontrollerer cloud-status...
|
||||
cloud-relink: Din API-nøgle er ugyldig eller udløbet. Slet venligst og link din konto igen.
|
||||
cloud-plan: Plan
|
||||
cloud-usage: Begivenheder i denne periode
|
||||
cloud-link-success:
|
||||
title: Dozzle Cloud Linket
|
||||
message: Din konto er blevet succesfuldt linket til Dozzle Cloud.
|
||||
|
||||
@@ -233,6 +233,10 @@ notifications:
|
||||
link-cloud-button: Dozzle Cloud verknüpfen
|
||||
cloud-settings-hint: Um Ihre verwalteten Kanäle zu konfigurieren, gehen Sie zu
|
||||
cloud-settings-link: Dozzle Cloud Einstellungen
|
||||
cloud-checking: Cloud-Status wird überprüft...
|
||||
cloud-relink: Ihr API-Schlüssel ist ungültig oder abgelaufen. Bitte löschen und verknüpfen Sie Ihr Konto erneut.
|
||||
cloud-plan: Plan
|
||||
cloud-usage: Ereignisse in diesem Zeitraum
|
||||
cloud-link-success:
|
||||
title: Dozzle Cloud verknüpft
|
||||
message: Ihr Konto wurde erfolgreich mit Dozzle Cloud verknüpft.
|
||||
|
||||
@@ -242,6 +242,10 @@ notifications:
|
||||
link-cloud-button: Link Dozzle Cloud
|
||||
cloud-settings-hint: To configure your managed channels, go to
|
||||
cloud-settings-link: Dozzle Cloud Settings
|
||||
cloud-checking: Checking cloud status...
|
||||
cloud-relink: Your API key is invalid or expired. Please delete and link your account again.
|
||||
cloud-plan: Plan
|
||||
cloud-usage: Events this period
|
||||
cloud-link-success:
|
||||
title: Dozzle Cloud Linked
|
||||
message: Your account has been successfully linked to Dozzle Cloud.
|
||||
|
||||
@@ -233,6 +233,10 @@ notifications:
|
||||
link-cloud-button: Vincular Dozzle Cloud
|
||||
cloud-settings-hint: Para configurar sus canales administrados, vaya a
|
||||
cloud-settings-link: Configuración de Dozzle Cloud
|
||||
cloud-checking: Verificando estado de la nube...
|
||||
cloud-relink: Su clave API es inválida o ha expirado. Por favor, elimine y vincule su cuenta de nuevo.
|
||||
cloud-plan: Plan
|
||||
cloud-usage: Eventos en este período
|
||||
cloud-link-success:
|
||||
title: Dozzle Cloud Vinculado
|
||||
message: Su cuenta se ha vinculado correctamente a Dozzle Cloud.
|
||||
|
||||
@@ -233,6 +233,10 @@ notifications:
|
||||
link-cloud-button: Lier Dozzle Cloud
|
||||
cloud-settings-hint: Pour configurer vos canaux gérés, allez à
|
||||
cloud-settings-link: Paramètres Dozzle Cloud
|
||||
cloud-checking: Vérification du statut cloud...
|
||||
cloud-relink: Votre clé API est invalide ou expirée. Veuillez supprimer et relier votre compte.
|
||||
cloud-plan: Plan
|
||||
cloud-usage: Événements pour cette période
|
||||
cloud-link-success:
|
||||
title: Dozzle Cloud Lié
|
||||
message: Votre compte a été lié avec succès à Dozzle Cloud.
|
||||
|
||||
@@ -245,6 +245,10 @@ notifications:
|
||||
link-cloud-button: Tautkan Dozzle Cloud
|
||||
cloud-settings-hint: Untuk mengonfigurasi saluran terkelola Anda, buka
|
||||
cloud-settings-link: Pengaturan Dozzle Cloud
|
||||
cloud-checking: Memeriksa status cloud...
|
||||
cloud-relink: Kunci API Anda tidak valid atau kedaluwarsa. Silakan hapus dan hubungkan akun Anda lagi.
|
||||
cloud-plan: Paket
|
||||
cloud-usage: Event periode ini
|
||||
cloud-link-success:
|
||||
title: Dozzle Cloud Tertaut
|
||||
message: Akun Anda telah berhasil ditautkan ke Dozzle Cloud.
|
||||
|
||||
@@ -233,6 +233,10 @@ notifications:
|
||||
link-cloud-button: Collega Dozzle Cloud
|
||||
cloud-settings-hint: Per configurare i tuoi canali gestiti, vai a
|
||||
cloud-settings-link: Impostazioni Dozzle Cloud
|
||||
cloud-checking: Verifica dello stato cloud...
|
||||
cloud-relink: La tua chiave API non è valida o è scaduta. Elimina e collega nuovamente il tuo account.
|
||||
cloud-plan: Piano
|
||||
cloud-usage: Eventi in questo periodo
|
||||
cloud-link-success:
|
||||
title: Dozzle Cloud Collegato
|
||||
message: Il tuo account è stato collegato con successo a Dozzle Cloud.
|
||||
|
||||
@@ -236,6 +236,10 @@ notifications:
|
||||
link-cloud-button: Dozzle Cloud 연결
|
||||
cloud-settings-hint: 관리 채널을 설정하려면 다음으로 이동하세요
|
||||
cloud-settings-link: Dozzle Cloud 설정
|
||||
cloud-checking: 클라우드 상태 확인 중...
|
||||
cloud-relink: API 키가 유효하지 않거나 만료되었습니다. 삭제 후 계정을 다시 연결해 주세요.
|
||||
cloud-plan: 플랜
|
||||
cloud-usage: 이번 기간 이벤트
|
||||
cloud-link-success:
|
||||
title: Dozzle Cloud 연결됨
|
||||
message: 계정이 Dozzle Cloud에 성공적으로 연결되었습니다.
|
||||
|
||||
@@ -234,6 +234,10 @@ notifications:
|
||||
link-cloud-button: Dozzle Cloud koppelen
|
||||
cloud-settings-hint: Om je beheerde kanalen te configureren, ga naar
|
||||
cloud-settings-link: Dozzle Cloud-instellingen
|
||||
cloud-checking: Cloudstatus controleren...
|
||||
cloud-relink: Uw API-sleutel is ongeldig of verlopen. Verwijder en koppel uw account opnieuw.
|
||||
cloud-plan: Plan
|
||||
cloud-usage: Gebeurtenissen deze periode
|
||||
cloud-link-success:
|
||||
title: Dozzle Cloud gekoppeld
|
||||
message: Je account is succesvol gekoppeld aan Dozzle Cloud.
|
||||
|
||||
@@ -240,6 +240,10 @@ notifications:
|
||||
link-cloud-button: Połącz Dozzle Cloud
|
||||
cloud-settings-hint: Aby skonfigurować zarządzane kanały, przejdź do
|
||||
cloud-settings-link: Ustawienia Dozzle Cloud
|
||||
cloud-checking: Sprawdzanie statusu chmury...
|
||||
cloud-relink: Twój klucz API jest nieprawidłowy lub wygasł. Usuń i połącz swoje konto ponownie.
|
||||
cloud-plan: Plan
|
||||
cloud-usage: Zdarzenia w tym okresie
|
||||
cloud-link-success:
|
||||
title: Dozzle Cloud Połączony
|
||||
message: Twoje konto zostało pomyślnie połączone z Dozzle Cloud.
|
||||
|
||||
@@ -242,6 +242,10 @@ notifications:
|
||||
link-cloud-button: Ligar Dozzle Cloud
|
||||
cloud-settings-hint: Para configurar os seus canais geridos, vá a
|
||||
cloud-settings-link: Definições do Dozzle Cloud
|
||||
cloud-checking: A verificar o estado da cloud...
|
||||
cloud-relink: A sua chave API é inválida ou expirou. Por favor, elimine e ligue a sua conta novamente.
|
||||
cloud-plan: Plano
|
||||
cloud-usage: Eventos neste período
|
||||
cloud-link-success:
|
||||
title: Dozzle Cloud Ligado
|
||||
message: A sua conta foi ligada com sucesso ao Dozzle Cloud.
|
||||
|
||||
@@ -232,6 +232,10 @@ notifications:
|
||||
link-cloud-button: Vincular Dozzle Cloud
|
||||
cloud-settings-hint: Para configurar seus canais gerenciados, vá para
|
||||
cloud-settings-link: Configurações do Dozzle Cloud
|
||||
cloud-checking: Verificando status da nuvem...
|
||||
cloud-relink: Sua chave API é inválida ou expirou. Por favor, exclua e vincule sua conta novamente.
|
||||
cloud-plan: Plano
|
||||
cloud-usage: Eventos neste período
|
||||
cloud-link-success:
|
||||
title: Dozzle Cloud Vinculado
|
||||
message: Sua conta foi vinculada com sucesso ao Dozzle Cloud.
|
||||
|
||||
@@ -233,6 +233,10 @@ notifications:
|
||||
link-cloud-button: Связать Dozzle Cloud
|
||||
cloud-settings-hint: Чтобы настроить управляемые каналы, перейдите в
|
||||
cloud-settings-link: Настройки Dozzle Cloud
|
||||
cloud-checking: Проверка статуса облака...
|
||||
cloud-relink: Ваш API-ключ недействителен или истёк. Пожалуйста, удалите и привяжите аккаунт заново.
|
||||
cloud-plan: План
|
||||
cloud-usage: События за этот период
|
||||
cloud-link-success:
|
||||
title: Dozzle Cloud Связан
|
||||
message: Ваш аккаунт успешно связан с Dozzle Cloud.
|
||||
|
||||
@@ -238,6 +238,10 @@ notifications:
|
||||
link-cloud-button: Poveži Dozzle Cloud
|
||||
cloud-settings-hint: Za nastavitev upravljanih kanalov pojdite na
|
||||
cloud-settings-link: Nastavitve Dozzle Cloud
|
||||
cloud-checking: Preverjanje stanja oblaka...
|
||||
cloud-relink: Vaš API ključ je neveljaven ali potekel. Prosimo, izbrišite in ponovno povežite svoj račun.
|
||||
cloud-plan: Načrt
|
||||
cloud-usage: Dogodki v tem obdobju
|
||||
cloud-link-success:
|
||||
title: Dozzle Cloud Povezan
|
||||
message: Vaš račun je bil uspešno povezan z Dozzle Cloud.
|
||||
|
||||
@@ -233,6 +233,10 @@ notifications:
|
||||
link-cloud-button: Dozzle Cloud'u Bağla
|
||||
cloud-settings-hint: Yönetilen kanallarınızı yapılandırmak için şuraya gidin
|
||||
cloud-settings-link: Dozzle Cloud Ayarları
|
||||
cloud-checking: Bulut durumu kontrol ediliyor...
|
||||
cloud-relink: API anahtarınız geçersiz veya süresi dolmuş. Lütfen silin ve hesabınızı tekrar bağlayın.
|
||||
cloud-plan: Plan
|
||||
cloud-usage: Bu dönemdeki olaylar
|
||||
cloud-link-success:
|
||||
title: Dozzle Cloud Bağlandı
|
||||
message: Hesabınız Dozzle Cloud'a başarıyla bağlandı.
|
||||
|
||||
@@ -236,6 +236,10 @@ notifications:
|
||||
link-cloud-button: 連結 Dozzle Cloud
|
||||
cloud-settings-hint: 若要設定您的管理頻道,請前往
|
||||
cloud-settings-link: Dozzle Cloud 設定
|
||||
cloud-checking: 正在檢查雲端狀態...
|
||||
cloud-relink: 您的 API 金鑰無效或已過期。請刪除並重新關聯您的帳戶。
|
||||
cloud-plan: 方案
|
||||
cloud-usage: 本期事件數
|
||||
cloud-link-success:
|
||||
title: Dozzle Cloud 已連結
|
||||
message: 您的帳戶已成功連結至 Dozzle Cloud。
|
||||
|
||||
@@ -233,6 +233,10 @@ notifications:
|
||||
link-cloud-button: 关联 Dozzle Cloud
|
||||
cloud-settings-hint: 要配置您的托管频道,请前往
|
||||
cloud-settings-link: Dozzle Cloud 设置
|
||||
cloud-checking: 正在检查云状态...
|
||||
cloud-relink: 您的 API 密钥无效或已过期。请删除并重新关联您的账户。
|
||||
cloud-plan: 计划
|
||||
cloud-usage: 本期事件数
|
||||
cloud-link-success:
|
||||
title: Dozzle Cloud 已关联
|
||||
message: 您的账户已成功关联到 Dozzle Cloud。
|
||||
|
||||
Reference in New Issue
Block a user