feat: adds better cloud error (#4438)

This commit is contained in:
Amir Raminfar
2026-02-14 10:37:10 -08:00
committed by GitHub
parent 6840e5b55c
commit fed03cb4db
23 changed files with 499 additions and 271 deletions
+3
View File
@@ -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>
+51
View File
@@ -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)
}
+3
View File
@@ -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
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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에 성공적으로 연결되었습니다.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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ı.
+4
View File
@@ -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。
+4
View File
@@ -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。