feat: improve Dozzle Cloud discoverability (#4609)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Amir Raminfar
2026-04-10 13:46:40 -07:00
committed by GitHub
parent af4412f667
commit 6d8f3005b0
54 changed files with 1659 additions and 384 deletions
+2
View File
@@ -205,6 +205,7 @@ declare global {
const useClipboard: typeof import('@vueuse/core').useClipboard
const useClipboardItems: typeof import('@vueuse/core').useClipboardItems
const useCloned: typeof import('@vueuse/core').useCloned
const useCloudConfig: typeof import('./composable/cloudConfig').useCloudConfig
const useColorMode: typeof import('@vueuse/core').useColorMode
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
const useContainerActions: typeof import('./composable/containerActions').useContainerActions
@@ -634,6 +635,7 @@ declare module 'vue' {
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
readonly useCloudConfig: UnwrapRef<typeof import('./composable/cloudConfig')['useCloudConfig']>
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
+3 -1
View File
@@ -36,6 +36,8 @@ declare module 'vue' {
'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']
CloudPopover: typeof import('./components/CloudPopover.vue')['default']
CloudSettingsCard: typeof import('./components/CloudSettingsCard.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']
@@ -102,7 +104,6 @@ declare module 'vue' {
'Mdi:chartBar': typeof import('~icons/mdi/chart-bar')['default']
'Mdi:chartLine': typeof import('~icons/mdi/chart-line')['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']
@@ -121,6 +122,7 @@ declare module 'vue' {
'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:linkVariantOff': typeof import('~icons/mdi/link-variant-off')['default']
'Mdi:magnify': typeof import('~icons/mdi/magnify')['default']
'Mdi:pencilOutline': typeof import('~icons/mdi/pencil-outline')['default']
'Mdi:plus': typeof import('~icons/mdi/plus')['default']
+141
View File
@@ -0,0 +1,141 @@
<template>
<Dropdown class="dropdown-end" @click="onOpen">
<template #trigger>
<div class="relative">
<mdi:cloud
class="size-6"
:class="
!cloudConfig ? 'text-base-content/40' : cloudConfig.linked && !cloudStatusError ? 'text-info' : 'text-error'
"
/>
<span
v-if="cloudConfig?.linked"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full"
:class="cloudStatusError ? 'bg-error' : 'bg-success'"
></span>
</div>
</template>
<template #content>
<div class="w-80 space-y-3 p-1">
<!-- Not linked -->
<template v-if="!cloudConfig">
<div class="flex flex-col items-center gap-2 p-2 text-center">
<mdi:cloud class="text-base-content/40 text-4xl" />
<h3 class="text-base font-bold">{{ $t("cloud.title") }}</h3>
<p class="text-base-content/60 text-sm">{{ $t("cloud.description") }}</p>
<div class="mt-2 flex w-full gap-2">
<a :href="`${cloudUrl}`" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
{{ $t("cloud.learn-more") }}
</a>
<a :href="cloudLinkUrl" class="btn btn-primary btn-sm flex-1">
<mdi:link-variant class="text-base" />
{{ $t("cloud.link-instance") }}
</a>
</div>
</div>
</template>
<!-- Linked -->
<template v-else-if="cloudConfig.linked">
<!-- Error state -->
<div v-if="cloudStatusError" class="space-y-3">
<div class="alert alert-error">
<mdi:alert-circle class="text-lg" />
<span class="text-sm">{{ $t("cloud.error") }}</span>
</div>
<a :href="cloudLinkUrl" class="btn btn-primary btn-sm w-full">
<mdi:link-variant class="text-base" />
{{ $t("cloud.relink-instance") }}
</a>
</div>
<!-- Loading -->
<div v-else-if="isLoadingCloudStatus" class="flex items-center justify-center gap-2 py-4">
<span class="loading loading-spinner loading-xs"></span>
</div>
<!-- Healthy -->
<div v-else-if="cloudStatus" class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="font-bold">{{ $t("cloud.title") }}</h3>
<div class="flex items-center gap-1">
<span class="badge badge-success badge-sm">{{ $t("cloud.connected") }}</span>
<span class="badge badge-primary badge-sm capitalize">{{ cloudStatus.plan.name }}</span>
</div>
</div>
<div>
<div class="mb-1 flex items-center justify-between text-sm">
<span class="text-base-content/60">{{ $t("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 class="flex gap-2">
<a :href="cloudUrl" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
{{ $t("cloud.dashboard") }}
</a>
<a :href="`${cloudUrl}/settings`" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
{{ $t("cloud.settings") }}
</a>
</div>
</div>
</template>
</div>
</template>
</Dropdown>
</template>
<script lang="ts" setup>
const cloudUrl = __CLOUD_URL__;
const callbackUrl = `${window.location.origin}${withBase("/")}`;
const cloudLinkUrl = `${cloudUrl}/link?appUrl=${encodeURIComponent(callbackUrl)}&from=cloud`;
const { cloudConfig, cloudStatus, cloudStatusError, isLoadingCloudStatus, fetchCloudConfig, fetchCloudStatus } =
useCloudConfig();
const { showToast } = useToast();
const { t } = useI18n();
const usagePercent = computed(() => {
if (!cloudStatus.value) return 0;
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
});
function onOpen() {
if (cloudConfig.value?.linked && !cloudStatus.value && !isLoadingCloudStatus.value) {
fetchCloudStatus();
}
}
onMounted(async () => {
await fetchCloudConfig();
if (cloudConfig.value?.linked) {
fetchCloudStatus();
}
// Handle successful OAuth return
if (window.location.hash === "#cloudLinked") {
showToast(
{
type: "info",
title: t("notifications.cloud-link-success.title"),
message: t("notifications.cloud-link-success.message"),
},
{ expire: 6000 },
);
history.replaceState(null, "", window.location.pathname + window.location.search);
}
});
</script>
+151
View File
@@ -0,0 +1,151 @@
<template>
<div>
<!-- Not linked -->
<template v-if="!cloudConfig">
<div class="flex items-start gap-4">
<mdi:cloud class="text-base-content/40 mt-0.5 text-4xl" />
<div class="flex flex-col gap-1">
<p class="text-base-content/70 text-sm">{{ $t("cloud.description") }}</p>
<div class="mt-3 flex gap-2">
<a :href="`${cloudUrl}`" target="_blank" rel="noreferrer noopener" class="btn btn-sm">
{{ $t("cloud.learn-more") }}
</a>
<a :href="cloudLinkUrl" class="btn btn-primary btn-sm">
<mdi:link-variant class="text-base" />
{{ $t("cloud.link-instance") }}
</a>
</div>
</div>
</div>
</template>
<!-- Linked -->
<template v-else-if="cloudConfig.linked">
<!-- Error state -->
<div v-if="cloudStatusError" class="space-y-3">
<div class="alert alert-error">
<mdi:alert-circle class="text-lg" />
<span class="text-sm">{{ $t("cloud.error") }}</span>
</div>
<div class="flex gap-2">
<a :href="cloudLinkUrl" class="btn btn-primary btn-sm">
<mdi:link-variant class="text-base" />
{{ $t("cloud.relink-instance") }}
</a>
<button class="btn btn-outline btn-sm btn-error" @click="confirmUnlink">
<mdi:link-variant-off class="text-base" />
{{ $t("cloud.unlink") }}
</button>
</div>
</div>
<!-- Loading -->
<div v-else-if="isLoadingCloudStatus" class="flex items-center gap-2 py-2">
<span class="loading loading-spinner loading-sm"></span>
</div>
<!-- Healthy -->
<div v-else-if="cloudStatus" class="space-y-4">
<div class="flex items-center gap-2">
<span class="badge badge-success">{{ $t("cloud.connected") }}</span>
<span class="badge badge-primary capitalize">{{ cloudStatus.plan.name }}</span>
<span class="text-base-content/50 text-sm">{{ cloudStatus.user.email }}</span>
</div>
<div>
<div class="mb-1 flex items-center justify-between text-sm">
<span class="text-base-content/60">{{ $t("cloud.usage") }}</span>
<span>
{{ cloudStatus.usage.events_used.toLocaleString() }} /
{{ cloudStatus.usage.events_limit.toLocaleString() }}
</span>
</div>
<progress
class="progress w-full max-w-xs"
:class="usagePercent > 90 ? 'progress-error' : usagePercent > 70 ? 'progress-warning' : 'progress-primary'"
:value="cloudStatus.usage.events_used"
:max="cloudStatus.usage.events_limit"
></progress>
</div>
<div class="flex gap-2">
<a :href="cloudUrl" target="_blank" rel="noreferrer noopener" class="btn btn-sm">
{{ $t("cloud.dashboard") }}
</a>
<button class="btn btn-sm btn-error" @click="confirmUnlink">
{{ $t("cloud.unlink") }}
</button>
</div>
</div>
</template>
<!-- Unlink confirmation modal -->
<dialog ref="unlinkModal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">{{ $t("cloud.unlink") }}</h3>
<p class="py-4 text-sm">{{ $t("cloud.unlink-confirm") }}</p>
<div class="modal-action">
<form method="dialog">
<button class="btn btn-sm">{{ $t("button.cancel") }}</button>
</form>
<button class="btn btn-error btn-sm" :disabled="isUnlinking" @click="doUnlink">
<span v-if="isUnlinking" class="loading loading-spinner loading-xs"></span>
{{ $t("cloud.unlink") }}
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button></button>
</form>
</dialog>
</div>
</template>
<script lang="ts" setup>
const cloudUrl = __CLOUD_URL__;
const callbackUrl = `${window.location.origin}${withBase("/")}`;
const cloudLinkUrl = `${cloudUrl}/link?appUrl=${encodeURIComponent(callbackUrl)}&from=cloud`;
const {
cloudConfig,
cloudStatus,
cloudStatusError,
isLoadingCloudStatus,
fetchCloudConfig,
fetchCloudStatus,
clearCloudState,
} = useCloudConfig();
const isUnlinking = ref(false);
const unlinkModal = ref<HTMLDialogElement | null>(null);
const usagePercent = computed(() => {
if (!cloudStatus.value) return 0;
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
});
function confirmUnlink() {
unlinkModal.value?.showModal();
}
async function doUnlink() {
isUnlinking.value = true;
try {
const res = await fetch(withBase("/api/cloud/config"), { method: "DELETE" });
if (!res.ok) {
cloudStatusError.value = true;
return;
}
clearCloudState();
unlinkModal.value?.close();
} finally {
isUnlinking.value = false;
}
}
onMounted(async () => {
await fetchCloudConfig();
if (cloudConfig.value?.linked) {
fetchCloudStatus();
}
});
</script>
+2
View File
@@ -12,6 +12,8 @@
<mdi:bell class="size-6" />
</router-link>
<CloudPopover />
<router-link
:to="{ name: '/settings' }"
:aria-label="$t('title.settings')"
@@ -91,42 +91,16 @@ const { destination, close } = defineProps<{
}>();
const callbackUrl = `${window.location.origin}${withBase("/")}`;
const cloudLinkUrl = `${__CLOUD_URL__}/link?appUrl=${encodeURIComponent(callbackUrl)}`;
const cloudLinkUrl = `${__CLOUD_URL__}/link?appUrl=${encodeURIComponent(callbackUrl)}&from=notifications`;
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 { cloudStatus, cloudStatusError, isLoadingCloudStatus, fetchCloudStatus } = useCloudConfig();
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();
}
@@ -27,7 +27,7 @@
<li>
<a @click="editDestination">{{ $t("notifications.destination.edit") }}</a>
</li>
<li>
<li v-if="destination.type !== 'cloud'">
<a class="text-error" @click="deleteDestination">{{ $t("notifications.destination.delete") }}</a>
</li>
</ul>
@@ -2,30 +2,33 @@
<div class="space-y-4 p-4">
<div class="mb-6">
<h2 class="text-2xl font-bold">
{{
isEditing
? $t("notifications.destination-form.edit-title")
: $t("notifications.destination-form.create-title")
}}
<template v-if="type === 'cloud'">
{{ $t("notifications.destination-form.cloud-title") }}
</template>
<template v-else>
{{
isEditing
? $t("notifications.destination-form.edit-title")
: $t("notifications.destination-form.create-title")
}}
</template>
</h2>
<p class="text-base-content/60">{{ $t("notifications.destination-form.description") }}</p>
<p class="text-base-content/60">
<template v-if="type === 'cloud'">
{{ $t("notifications.destination-form.cloud-description") }}
</template>
<template v-else>
{{ $t("notifications.destination-form.description") }}
</template>
</p>
</div>
<!-- Link Success Alert -->
<div v-if="showLinkSuccess" class="alert alert-success">
<mdi:check-circle class="text-lg" />
<div>
<div class="font-semibold">{{ $t("notifications.cloud-link-success.title") }}</div>
<div class="text-sm">{{ $t("notifications.cloud-link-success.message") }}</div>
</div>
</div>
<!-- Type Selection -->
<fieldset class="fieldset">
<!-- Type Selection (only when creating) -->
<fieldset v-if="!isEditing" class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.type") }}</legend>
<div class="space-y-3">
<label
class="card card-border 20 cursor-pointer transition-colors"
class="card card-border cursor-pointer transition-colors"
:class="type === 'webhook' ? 'border-primary bg-primary/10' : ''"
>
<div class="card-body flex-row items-center gap-3 p-4">
@@ -39,26 +42,21 @@
</div>
</label>
<label
class="card card-border border-base-content/20 transition-colors"
class="card card-border cursor-pointer transition-colors"
:class="[
type === 'cloud' ? 'border-primary bg-primary/10' : '',
hasExistingCloudDestination && type !== 'cloud' ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
isCloudLinked ? 'cursor-not-allowed opacity-50' : '',
]"
>
<div class="card-body flex-row items-center gap-3 p-4">
<input
type="radio"
v-model="type"
value="cloud"
class="radio radio-primary"
:disabled="hasExistingCloudDestination && type !== 'cloud'"
/>
<input type="radio" v-model="type" value="cloud" class="radio radio-primary" :disabled="isCloudLinked" />
<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">
<div v-if="isCloudLinked" class="text-success mt-1 text-xs">
<mdi:check class="inline" />
{{ $t("notifications.destination-form.cloud-exists") }}
</div>
</div>
@@ -84,25 +82,17 @@ import type { Dispatcher } from "@/types/notifications";
import WebhookDestinationForm from "./WebhookDestinationForm.vue";
import CloudDestinationForm from "./CloudDestinationForm.vue";
const {
close,
onCreated,
destination,
existingDispatchers = [],
showLinkSuccess = false,
} = defineProps<{
const { close, onCreated, destination } = defineProps<{
close?: () => void;
onCreated?: () => void;
destination?: Dispatcher;
existingDispatchers?: Dispatcher[];
showLinkSuccess?: boolean;
}>();
const isEditing = !!destination;
const type = ref<"webhook" | "cloud">((destination?.type as "webhook" | "cloud") ?? "webhook");
const hasExistingCloudDestination = computed(() => {
const others = isEditing ? existingDispatchers.filter((d) => d.id !== destination!.id) : existingDispatchers;
return others.some((d) => d.type === "cloud");
});
const { cloudConfig, fetchCloudConfig } = useCloudConfig();
const isCloudLinked = computed(() => !!cloudConfig.value?.linked);
onMounted(() => fetchCloudConfig());
</script>
+3 -3
View File
@@ -25,11 +25,11 @@ export function useAlertForm(options: AlertFormOptions) {
const isEditing = computed(() => !!options.alert);
const alertName = ref(options.alert?.name ?? options.prefill?.name ?? "");
const containerExpression = ref(options.alert?.containerExpression ?? options.prefill?.containerExpression ?? "");
const dispatcherId = ref(options.alert?.dispatcher?.id ?? 0);
const dispatcherId = ref(options.alert?.dispatcher?.id ?? -1);
const isSaving = ref(false);
const saveError = ref<string | null>(null);
// Destinations
// Destinations (cloud dispatcher with id=0 is included by the backend when configured)
const destinations = ref<Dispatcher[]>([]);
onMounted(async () => {
const res = await fetch(withBase("/api/notifications/dispatchers"));
@@ -54,7 +54,7 @@ export function useAlertForm(options: AlertFormOptions) {
() =>
alertName.value.trim() &&
containerExpression.value.trim() &&
dispatcherId.value > 0 &&
dispatcherId.value >= 0 &&
!containerResult.value?.error &&
!isSaving.value,
);
+56
View File
@@ -0,0 +1,56 @@
import type { CloudConfig, CloudStatus } from "@/types/notifications";
// Shared state across all component instances
const cloudConfig = ref<CloudConfig | null>(null);
const cloudStatus = ref<CloudStatus | null>(null);
const cloudStatusError = ref(false);
const isLoadingCloudStatus = ref(false);
async function fetchCloudConfig() {
try {
const res = await fetch(withBase("/api/cloud/config"));
if (!res.ok) {
cloudConfig.value = null;
return;
}
cloudConfig.value = await res.json();
} catch {
cloudConfig.value = null;
}
}
async function fetchCloudStatus() {
if (!cloudConfig.value?.linked) return;
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;
}
}
function clearCloudState() {
cloudConfig.value = null;
cloudStatus.value = null;
cloudStatusError.value = false;
}
export function useCloudConfig() {
return {
cloudConfig,
cloudStatus,
cloudStatusError,
isLoadingCloudStatus,
fetchCloudConfig,
fetchCloudStatus,
clearCloudState,
};
}
+28 -29
View File
@@ -12,16 +12,9 @@
<h3 class="text-base-content/60 mb-4 font-semibold tracking-wide uppercase">
{{ $t("notifications.destinations") }}
</h3>
<div class="flex flex-wrap gap-4">
<DestinationCard
v-for="dest in dispatchers"
:key="dest.id"
:destination="dest"
:on-updated="fetchAll"
:existing-dispatchers="dispatchers"
class="w-full md:w-72"
/>
<!-- Add Destination Card -->
<template v-if="dispatchers.length === 0">
<p class="text-base-content/60 mb-4 text-sm">{{ $t("notifications.empty-state.description") }}</p>
<button
class="card card-border border-base-content/30 hover:border-base-content/50 w-full cursor-pointer border-dashed transition-colors md:w-72"
@click="openAddDestination"
@@ -31,7 +24,30 @@
<span class="text-base-content/60 text-sm">{{ $t("notifications.add-destination") }}</span>
</div>
</button>
</div>
</template>
<template v-else>
<div class="flex flex-wrap gap-4">
<DestinationCard
v-for="dest in dispatchers"
:key="dest.id"
:destination="dest"
:on-updated="fetchAll"
:existing-dispatchers="dispatchers"
class="w-full md:w-72"
/>
<!-- Add Destination Card -->
<button
class="card card-border border-base-content/30 hover:border-base-content/50 w-full cursor-pointer border-dashed transition-colors md:w-72"
@click="openAddDestination"
>
<div class="card-body items-center justify-center gap-1 p-4">
<mdi:plus class="text-2xl" />
<span class="text-base-content/60 text-sm">{{ $t("notifications.add-destination") }}</span>
</div>
</button>
</div>
</template>
</div>
<!-- Alerts Section -->
@@ -97,26 +113,10 @@ async function fetchAll() {
await Promise.all([fetchAlerts(), fetchDispatchers()]);
}
// Handle cloudLinkSuccess hash param
onMounted(async () => {
await fetchAll();
const hash = window.location.hash;
if (hash.startsWith("#cloudLinkSuccess=")) {
const id = Number(hash.replace("#cloudLinkSuccess=", ""));
if (!isNaN(id)) {
const destination = dispatchers.value.find((d) => d.id === id);
if (destination) {
showDrawer(
DestinationForm,
{
destination,
existingDispatchers: dispatchers.value,
showLinkSuccess: true,
},
"md",
);
}
}
if (hash === "#cloudLinked") {
router.replace({ hash: "" });
}
});
@@ -142,7 +142,6 @@ function openAddDestination() {
DestinationForm,
{
onCreated: fetchDispatchers,
existingDispatchers: dispatchers.value,
},
"md",
);
+7
View File
@@ -37,6 +37,13 @@
</div>
</section>
<section>
<div class="has-underline">
<h2>{{ $t("cloud.title") }}</h2>
</div>
<CloudSettingsCard />
</section>
<section class="@container flex flex-col">
<div class="has-underline">
<h2>{{ $t("settings.display") }}</h2>
+12
View File
@@ -66,3 +66,15 @@ export interface TestWebhookResult {
statusCode?: number;
error?: string;
}
export interface CloudConfig {
prefix: string;
expiresAt?: string;
linked: boolean;
}
export 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 };
}
+14 -6
View File
@@ -538,7 +538,6 @@ func (c *Client) Close() error {
}
func (c *Client) UpdateNotificationConfig(ctx context.Context, subscriptions []types.SubscriptionConfig, dispatchers []types.DispatcherConfig) error {
// Convert to proto
pbSubs := make([]*pb.NotificationSubscription, len(subscriptions))
for i, sub := range subscriptions {
pbSubs[i] = &pb.NotificationSubscription{
@@ -564,11 +563,6 @@ func (c *Client) UpdateNotificationConfig(ctx context.Context, subscriptions []t
Url: d.URL,
Template: d.Template,
Headers: d.Headers,
ApiKey: d.APIKey,
Prefix: d.Prefix,
}
if d.ExpiresAt != nil {
pbDispatchers[i].ExpiresAt = timestamppb.New(*d.ExpiresAt)
}
}
@@ -576,7 +570,21 @@ func (c *Client) UpdateNotificationConfig(ctx context.Context, subscriptions []t
Subscriptions: pbSubs,
Dispatchers: pbDispatchers,
})
return err
}
func (c *Client) UpdateCloudConfig(ctx context.Context, cloudConfig *types.CloudConfig) error {
req := &pb.UpdateCloudConfigRequest{}
if cloudConfig != nil {
req.CloudConfig = &pb.NotificationCloudConfig{
ApiKey: cloudConfig.APIKey,
Prefix: cloudConfig.Prefix,
}
if cloudConfig.ExpiresAt != nil {
req.CloudConfig.ExpiresAt = timestamppb.New(*cloudConfig.ExpiresAt)
}
}
_, err := c.client.UpdateCloudConfig(ctx, req)
return err
}
+4
View File
@@ -12,6 +12,7 @@ import (
"time"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/internal/notification/dispatcher"
"github.com/amir20/dozzle/internal/utils"
"github.com/amir20/dozzle/types"
"github.com/go-faker/faker/v4"
@@ -35,6 +36,9 @@ func (m *mockNotificationHandler) HandleNotificationConfig(subscriptions []types
return nil
}
func (m *mockNotificationHandler) SetCloudDispatcher(d dispatcher.Dispatcher) {}
func (m *mockNotificationHandler) ClearCloudDispatcher() {}
func (m *mockNotificationHandler) GetNotificationStats() []types.SubscriptionStats {
return nil
}
+170 -81
View File
@@ -1478,6 +1478,86 @@ func (*UpdateNotificationConfigResponse) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{28}
}
type UpdateCloudConfigRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
CloudConfig *NotificationCloudConfig `protobuf:"bytes,1,opt,name=cloudConfig,proto3" json:"cloudConfig,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateCloudConfigRequest) Reset() {
*x = UpdateCloudConfigRequest{}
mi := &file_rpc_proto_msgTypes[29]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpdateCloudConfigRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateCloudConfigRequest) ProtoMessage() {}
func (x *UpdateCloudConfigRequest) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[29]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use UpdateCloudConfigRequest.ProtoReflect.Descriptor instead.
func (*UpdateCloudConfigRequest) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{29}
}
func (x *UpdateCloudConfigRequest) GetCloudConfig() *NotificationCloudConfig {
if x != nil {
return x.CloudConfig
}
return nil
}
type UpdateCloudConfigResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateCloudConfigResponse) Reset() {
*x = UpdateCloudConfigResponse{}
mi := &file_rpc_proto_msgTypes[30]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpdateCloudConfigResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateCloudConfigResponse) ProtoMessage() {}
func (x *UpdateCloudConfigResponse) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[30]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use UpdateCloudConfigResponse.ProtoReflect.Descriptor instead.
func (*UpdateCloudConfigResponse) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{30}
}
type GetNotificationStatsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -1486,7 +1566,7 @@ type GetNotificationStatsRequest struct {
func (x *GetNotificationStatsRequest) Reset() {
*x = GetNotificationStatsRequest{}
mi := &file_rpc_proto_msgTypes[29]
mi := &file_rpc_proto_msgTypes[31]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1498,7 +1578,7 @@ func (x *GetNotificationStatsRequest) String() string {
func (*GetNotificationStatsRequest) ProtoMessage() {}
func (x *GetNotificationStatsRequest) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[29]
mi := &file_rpc_proto_msgTypes[31]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1511,7 +1591,7 @@ func (x *GetNotificationStatsRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetNotificationStatsRequest.ProtoReflect.Descriptor instead.
func (*GetNotificationStatsRequest) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{29}
return file_rpc_proto_rawDescGZIP(), []int{31}
}
type GetNotificationStatsResponse struct {
@@ -1523,7 +1603,7 @@ type GetNotificationStatsResponse struct {
func (x *GetNotificationStatsResponse) Reset() {
*x = GetNotificationStatsResponse{}
mi := &file_rpc_proto_msgTypes[30]
mi := &file_rpc_proto_msgTypes[32]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1535,7 +1615,7 @@ func (x *GetNotificationStatsResponse) String() string {
func (*GetNotificationStatsResponse) ProtoMessage() {}
func (x *GetNotificationStatsResponse) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[30]
mi := &file_rpc_proto_msgTypes[32]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1548,7 +1628,7 @@ func (x *GetNotificationStatsResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetNotificationStatsResponse.ProtoReflect.Descriptor instead.
func (*GetNotificationStatsResponse) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{30}
return file_rpc_proto_rawDescGZIP(), []int{32}
}
func (x *GetNotificationStatsResponse) GetStats() []*NotificationSubscriptionStats {
@@ -1645,11 +1725,13 @@ const file_rpc_proto_rawDesc = "" +
"\x1fUpdateNotificationConfigRequest\x12H\n" +
"\rsubscriptions\x18\x01 \x03(\v2\".protobuf.NotificationSubscriptionR\rsubscriptions\x12B\n" +
"\vdispatchers\x18\x02 \x03(\v2 .protobuf.NotificationDispatcherR\vdispatchers\"\"\n" +
" UpdateNotificationConfigResponse\"\x1d\n" +
" UpdateNotificationConfigResponse\"_\n" +
"\x18UpdateCloudConfigRequest\x12C\n" +
"\vcloudConfig\x18\x01 \x01(\v2!.protobuf.NotificationCloudConfigR\vcloudConfig\"\x1b\n" +
"\x19UpdateCloudConfigResponse\"\x1d\n" +
"\x1bGetNotificationStatsRequest\"]\n" +
"\x1cGetNotificationStatsResponse\x12=\n" +
"\x05stats\x18\x01 \x03(\v2'.protobuf.NotificationSubscriptionStatsR\x05stats2\xdb\n" +
"\n" +
"\x05stats\x18\x01 \x03(\v2'.protobuf.NotificationSubscriptionStatsR\x05stats2\xbb\v\n" +
"\fAgentService\x12U\n" +
"\x0eListContainers\x12\x1f.protobuf.ListContainersRequest\x1a .protobuf.ListContainersResponse\"\x00\x12R\n" +
"\rFindContainer\x12\x1e.protobuf.FindContainerRequest\x1a\x1f.protobuf.FindContainerResponse\"\x00\x12K\n" +
@@ -1665,7 +1747,8 @@ const file_rpc_proto_rawDesc = "" +
"\x0fUpdateContainer\x12 .protobuf.UpdateContainerRequest\x1a!.protobuf.UpdateContainerProgress\"\x000\x01\x12V\n" +
"\rContainerExec\x12\x1e.protobuf.ContainerExecRequest\x1a\x1f.protobuf.ContainerExecResponse\"\x00(\x010\x01\x12\\\n" +
"\x0fContainerAttach\x12 .protobuf.ContainerAttachRequest\x1a!.protobuf.ContainerAttachResponse\"\x00(\x010\x01\x12s\n" +
"\x18UpdateNotificationConfig\x12).protobuf.UpdateNotificationConfigRequest\x1a*.protobuf.UpdateNotificationConfigResponse\"\x00\x12g\n" +
"\x18UpdateNotificationConfig\x12).protobuf.UpdateNotificationConfigRequest\x1a*.protobuf.UpdateNotificationConfigResponse\"\x00\x12^\n" +
"\x11UpdateCloudConfig\x12\".protobuf.UpdateCloudConfigRequest\x1a#.protobuf.UpdateCloudConfigResponse\"\x00\x12g\n" +
"\x14GetNotificationStats\x12%.protobuf.GetNotificationStatsRequest\x1a&.protobuf.GetNotificationStatsResponse\"\x00B\x13Z\x11internal/agent/pbb\x06proto3"
var (
@@ -1680,7 +1763,7 @@ func file_rpc_proto_rawDescGZIP() []byte {
return file_rpc_proto_rawDescData
}
var file_rpc_proto_msgTypes = make([]protoimpl.MessageInfo, 33)
var file_rpc_proto_msgTypes = make([]protoimpl.MessageInfo, 35)
var file_rpc_proto_goTypes = []any{
(*ListContainersRequest)(nil), // 0: protobuf.ListContainersRequest
(*RepeatedString)(nil), // 1: protobuf.RepeatedString
@@ -1711,79 +1794,85 @@ var file_rpc_proto_goTypes = []any{
(*ContainerAttachResponse)(nil), // 26: protobuf.ContainerAttachResponse
(*UpdateNotificationConfigRequest)(nil), // 27: protobuf.UpdateNotificationConfigRequest
(*UpdateNotificationConfigResponse)(nil), // 28: protobuf.UpdateNotificationConfigResponse
(*GetNotificationStatsRequest)(nil), // 29: protobuf.GetNotificationStatsRequest
(*GetNotificationStatsResponse)(nil), // 30: protobuf.GetNotificationStatsResponse
nil, // 31: protobuf.ListContainersRequest.FilterEntry
nil, // 32: protobuf.FindContainerRequest.FilterEntry
(*Container)(nil), // 33: protobuf.Container
(*timestamppb.Timestamp)(nil), // 34: google.protobuf.Timestamp
(*LogEvent)(nil), // 35: protobuf.LogEvent
(*ContainerEvent)(nil), // 36: protobuf.ContainerEvent
(*ContainerStat)(nil), // 37: protobuf.ContainerStat
(*Host)(nil), // 38: protobuf.Host
(ContainerAction)(0), // 39: protobuf.ContainerAction
(*NotificationSubscription)(nil), // 40: protobuf.NotificationSubscription
(*NotificationDispatcher)(nil), // 41: protobuf.NotificationDispatcher
(*NotificationSubscriptionStats)(nil), // 42: protobuf.NotificationSubscriptionStats
(*UpdateCloudConfigRequest)(nil), // 29: protobuf.UpdateCloudConfigRequest
(*UpdateCloudConfigResponse)(nil), // 30: protobuf.UpdateCloudConfigResponse
(*GetNotificationStatsRequest)(nil), // 31: protobuf.GetNotificationStatsRequest
(*GetNotificationStatsResponse)(nil), // 32: protobuf.GetNotificationStatsResponse
nil, // 33: protobuf.ListContainersRequest.FilterEntry
nil, // 34: protobuf.FindContainerRequest.FilterEntry
(*Container)(nil), // 35: protobuf.Container
(*timestamppb.Timestamp)(nil), // 36: google.protobuf.Timestamp
(*LogEvent)(nil), // 37: protobuf.LogEvent
(*ContainerEvent)(nil), // 38: protobuf.ContainerEvent
(*ContainerStat)(nil), // 39: protobuf.ContainerStat
(*Host)(nil), // 40: protobuf.Host
(ContainerAction)(0), // 41: protobuf.ContainerAction
(*NotificationSubscription)(nil), // 42: protobuf.NotificationSubscription
(*NotificationDispatcher)(nil), // 43: protobuf.NotificationDispatcher
(*NotificationCloudConfig)(nil), // 44: protobuf.NotificationCloudConfig
(*NotificationSubscriptionStats)(nil), // 45: protobuf.NotificationSubscriptionStats
}
var file_rpc_proto_depIdxs = []int32{
31, // 0: protobuf.ListContainersRequest.filter:type_name -> protobuf.ListContainersRequest.FilterEntry
33, // 1: protobuf.ListContainersResponse.containers:type_name -> protobuf.Container
32, // 2: protobuf.FindContainerRequest.filter:type_name -> protobuf.FindContainerRequest.FilterEntry
33, // 3: protobuf.FindContainerResponse.container:type_name -> protobuf.Container
34, // 4: protobuf.StreamLogsRequest.since:type_name -> google.protobuf.Timestamp
35, // 5: protobuf.StreamLogsResponse.event:type_name -> protobuf.LogEvent
34, // 6: protobuf.LogsBetweenDatesRequest.since:type_name -> google.protobuf.Timestamp
34, // 7: protobuf.LogsBetweenDatesRequest.until:type_name -> google.protobuf.Timestamp
34, // 8: protobuf.StreamRawBytesRequest.since:type_name -> google.protobuf.Timestamp
34, // 9: protobuf.StreamRawBytesRequest.until:type_name -> google.protobuf.Timestamp
36, // 10: protobuf.StreamEventsResponse.event:type_name -> protobuf.ContainerEvent
37, // 11: protobuf.StreamStatsResponse.stat:type_name -> protobuf.ContainerStat
38, // 12: protobuf.HostInfoResponse.host:type_name -> protobuf.Host
33, // 13: protobuf.StreamContainerStartedResponse.container:type_name -> protobuf.Container
39, // 14: protobuf.ContainerActionRequest.action:type_name -> protobuf.ContainerAction
33, // 0: protobuf.ListContainersRequest.filter:type_name -> protobuf.ListContainersRequest.FilterEntry
35, // 1: protobuf.ListContainersResponse.containers:type_name -> protobuf.Container
34, // 2: protobuf.FindContainerRequest.filter:type_name -> protobuf.FindContainerRequest.FilterEntry
35, // 3: protobuf.FindContainerResponse.container:type_name -> protobuf.Container
36, // 4: protobuf.StreamLogsRequest.since:type_name -> google.protobuf.Timestamp
37, // 5: protobuf.StreamLogsResponse.event:type_name -> protobuf.LogEvent
36, // 6: protobuf.LogsBetweenDatesRequest.since:type_name -> google.protobuf.Timestamp
36, // 7: protobuf.LogsBetweenDatesRequest.until:type_name -> google.protobuf.Timestamp
36, // 8: protobuf.StreamRawBytesRequest.since:type_name -> google.protobuf.Timestamp
36, // 9: protobuf.StreamRawBytesRequest.until:type_name -> google.protobuf.Timestamp
38, // 10: protobuf.StreamEventsResponse.event:type_name -> protobuf.ContainerEvent
39, // 11: protobuf.StreamStatsResponse.stat:type_name -> protobuf.ContainerStat
40, // 12: protobuf.HostInfoResponse.host:type_name -> protobuf.Host
35, // 13: protobuf.StreamContainerStartedResponse.container:type_name -> protobuf.Container
41, // 14: protobuf.ContainerActionRequest.action:type_name -> protobuf.ContainerAction
23, // 15: protobuf.ContainerExecRequest.resize:type_name -> protobuf.ResizePayload
23, // 16: protobuf.ContainerAttachRequest.resize:type_name -> protobuf.ResizePayload
40, // 17: protobuf.UpdateNotificationConfigRequest.subscriptions:type_name -> protobuf.NotificationSubscription
41, // 18: protobuf.UpdateNotificationConfigRequest.dispatchers:type_name -> protobuf.NotificationDispatcher
42, // 19: protobuf.GetNotificationStatsResponse.stats:type_name -> protobuf.NotificationSubscriptionStats
1, // 20: protobuf.ListContainersRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
1, // 21: protobuf.FindContainerRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
0, // 22: protobuf.AgentService.ListContainers:input_type -> protobuf.ListContainersRequest
3, // 23: protobuf.AgentService.FindContainer:input_type -> protobuf.FindContainerRequest
5, // 24: protobuf.AgentService.StreamLogs:input_type -> protobuf.StreamLogsRequest
7, // 25: protobuf.AgentService.LogsBetweenDates:input_type -> protobuf.LogsBetweenDatesRequest
8, // 26: protobuf.AgentService.StreamRawBytes:input_type -> protobuf.StreamRawBytesRequest
10, // 27: protobuf.AgentService.StreamEvents:input_type -> protobuf.StreamEventsRequest
12, // 28: protobuf.AgentService.StreamStats:input_type -> protobuf.StreamStatsRequest
16, // 29: protobuf.AgentService.StreamContainerStarted:input_type -> protobuf.StreamContainerStartedRequest
14, // 30: protobuf.AgentService.HostInfo:input_type -> protobuf.HostInfoRequest
18, // 31: protobuf.AgentService.ContainerAction:input_type -> protobuf.ContainerActionRequest
20, // 32: protobuf.AgentService.UpdateContainer:input_type -> protobuf.UpdateContainerRequest
22, // 33: protobuf.AgentService.ContainerExec:input_type -> protobuf.ContainerExecRequest
25, // 34: protobuf.AgentService.ContainerAttach:input_type -> protobuf.ContainerAttachRequest
27, // 35: protobuf.AgentService.UpdateNotificationConfig:input_type -> protobuf.UpdateNotificationConfigRequest
29, // 36: protobuf.AgentService.GetNotificationStats:input_type -> protobuf.GetNotificationStatsRequest
2, // 37: protobuf.AgentService.ListContainers:output_type -> protobuf.ListContainersResponse
4, // 38: protobuf.AgentService.FindContainer:output_type -> protobuf.FindContainerResponse
6, // 39: protobuf.AgentService.StreamLogs:output_type -> protobuf.StreamLogsResponse
6, // 40: protobuf.AgentService.LogsBetweenDates:output_type -> protobuf.StreamLogsResponse
9, // 41: protobuf.AgentService.StreamRawBytes:output_type -> protobuf.StreamRawBytesResponse
11, // 42: protobuf.AgentService.StreamEvents:output_type -> protobuf.StreamEventsResponse
13, // 43: protobuf.AgentService.StreamStats:output_type -> protobuf.StreamStatsResponse
17, // 44: protobuf.AgentService.StreamContainerStarted:output_type -> protobuf.StreamContainerStartedResponse
15, // 45: protobuf.AgentService.HostInfo:output_type -> protobuf.HostInfoResponse
19, // 46: protobuf.AgentService.ContainerAction:output_type -> protobuf.ContainerActionResponse
21, // 47: protobuf.AgentService.UpdateContainer:output_type -> protobuf.UpdateContainerProgress
24, // 48: protobuf.AgentService.ContainerExec:output_type -> protobuf.ContainerExecResponse
26, // 49: protobuf.AgentService.ContainerAttach:output_type -> protobuf.ContainerAttachResponse
28, // 50: protobuf.AgentService.UpdateNotificationConfig:output_type -> protobuf.UpdateNotificationConfigResponse
30, // 51: protobuf.AgentService.GetNotificationStats:output_type -> protobuf.GetNotificationStatsResponse
37, // [37:52] is the sub-list for method output_type
22, // [22:37] is the sub-list for method input_type
22, // [22:22] is the sub-list for extension type_name
22, // [22:22] is the sub-list for extension extendee
0, // [0:22] is the sub-list for field type_name
42, // 17: protobuf.UpdateNotificationConfigRequest.subscriptions:type_name -> protobuf.NotificationSubscription
43, // 18: protobuf.UpdateNotificationConfigRequest.dispatchers:type_name -> protobuf.NotificationDispatcher
44, // 19: protobuf.UpdateCloudConfigRequest.cloudConfig:type_name -> protobuf.NotificationCloudConfig
45, // 20: protobuf.GetNotificationStatsResponse.stats:type_name -> protobuf.NotificationSubscriptionStats
1, // 21: protobuf.ListContainersRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
1, // 22: protobuf.FindContainerRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
0, // 23: protobuf.AgentService.ListContainers:input_type -> protobuf.ListContainersRequest
3, // 24: protobuf.AgentService.FindContainer:input_type -> protobuf.FindContainerRequest
5, // 25: protobuf.AgentService.StreamLogs:input_type -> protobuf.StreamLogsRequest
7, // 26: protobuf.AgentService.LogsBetweenDates:input_type -> protobuf.LogsBetweenDatesRequest
8, // 27: protobuf.AgentService.StreamRawBytes:input_type -> protobuf.StreamRawBytesRequest
10, // 28: protobuf.AgentService.StreamEvents:input_type -> protobuf.StreamEventsRequest
12, // 29: protobuf.AgentService.StreamStats:input_type -> protobuf.StreamStatsRequest
16, // 30: protobuf.AgentService.StreamContainerStarted:input_type -> protobuf.StreamContainerStartedRequest
14, // 31: protobuf.AgentService.HostInfo:input_type -> protobuf.HostInfoRequest
18, // 32: protobuf.AgentService.ContainerAction:input_type -> protobuf.ContainerActionRequest
20, // 33: protobuf.AgentService.UpdateContainer:input_type -> protobuf.UpdateContainerRequest
22, // 34: protobuf.AgentService.ContainerExec:input_type -> protobuf.ContainerExecRequest
25, // 35: protobuf.AgentService.ContainerAttach:input_type -> protobuf.ContainerAttachRequest
27, // 36: protobuf.AgentService.UpdateNotificationConfig:input_type -> protobuf.UpdateNotificationConfigRequest
29, // 37: protobuf.AgentService.UpdateCloudConfig:input_type -> protobuf.UpdateCloudConfigRequest
31, // 38: protobuf.AgentService.GetNotificationStats:input_type -> protobuf.GetNotificationStatsRequest
2, // 39: protobuf.AgentService.ListContainers:output_type -> protobuf.ListContainersResponse
4, // 40: protobuf.AgentService.FindContainer:output_type -> protobuf.FindContainerResponse
6, // 41: protobuf.AgentService.StreamLogs:output_type -> protobuf.StreamLogsResponse
6, // 42: protobuf.AgentService.LogsBetweenDates:output_type -> protobuf.StreamLogsResponse
9, // 43: protobuf.AgentService.StreamRawBytes:output_type -> protobuf.StreamRawBytesResponse
11, // 44: protobuf.AgentService.StreamEvents:output_type -> protobuf.StreamEventsResponse
13, // 45: protobuf.AgentService.StreamStats:output_type -> protobuf.StreamStatsResponse
17, // 46: protobuf.AgentService.StreamContainerStarted:output_type -> protobuf.StreamContainerStartedResponse
15, // 47: protobuf.AgentService.HostInfo:output_type -> protobuf.HostInfoResponse
19, // 48: protobuf.AgentService.ContainerAction:output_type -> protobuf.ContainerActionResponse
21, // 49: protobuf.AgentService.UpdateContainer:output_type -> protobuf.UpdateContainerProgress
24, // 50: protobuf.AgentService.ContainerExec:output_type -> protobuf.ContainerExecResponse
26, // 51: protobuf.AgentService.ContainerAttach:output_type -> protobuf.ContainerAttachResponse
28, // 52: protobuf.AgentService.UpdateNotificationConfig:output_type -> protobuf.UpdateNotificationConfigResponse
30, // 53: protobuf.AgentService.UpdateCloudConfig:output_type -> protobuf.UpdateCloudConfigResponse
32, // 54: protobuf.AgentService.GetNotificationStats:output_type -> protobuf.GetNotificationStatsResponse
39, // [39:55] is the sub-list for method output_type
23, // [23:39] is the sub-list for method input_type
23, // [23:23] is the sub-list for extension type_name
23, // [23:23] is the sub-list for extension extendee
0, // [0:23] is the sub-list for field type_name
}
func init() { file_rpc_proto_init() }
@@ -1806,7 +1895,7 @@ func file_rpc_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_rpc_proto_rawDesc), len(file_rpc_proto_rawDesc)),
NumEnums: 0,
NumMessages: 33,
NumMessages: 35,
NumExtensions: 0,
NumServices: 1,
},
+38
View File
@@ -33,6 +33,7 @@ const (
AgentService_ContainerExec_FullMethodName = "/protobuf.AgentService/ContainerExec"
AgentService_ContainerAttach_FullMethodName = "/protobuf.AgentService/ContainerAttach"
AgentService_UpdateNotificationConfig_FullMethodName = "/protobuf.AgentService/UpdateNotificationConfig"
AgentService_UpdateCloudConfig_FullMethodName = "/protobuf.AgentService/UpdateCloudConfig"
AgentService_GetNotificationStats_FullMethodName = "/protobuf.AgentService/GetNotificationStats"
)
@@ -54,6 +55,7 @@ type AgentServiceClient interface {
ContainerExec(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ContainerExecRequest, ContainerExecResponse], error)
ContainerAttach(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ContainerAttachRequest, ContainerAttachResponse], error)
UpdateNotificationConfig(ctx context.Context, in *UpdateNotificationConfigRequest, opts ...grpc.CallOption) (*UpdateNotificationConfigResponse, error)
UpdateCloudConfig(ctx context.Context, in *UpdateCloudConfigRequest, opts ...grpc.CallOption) (*UpdateCloudConfigResponse, error)
GetNotificationStats(ctx context.Context, in *GetNotificationStatsRequest, opts ...grpc.CallOption) (*GetNotificationStatsResponse, error)
}
@@ -274,6 +276,16 @@ func (c *agentServiceClient) UpdateNotificationConfig(ctx context.Context, in *U
return out, nil
}
func (c *agentServiceClient) UpdateCloudConfig(ctx context.Context, in *UpdateCloudConfigRequest, opts ...grpc.CallOption) (*UpdateCloudConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateCloudConfigResponse)
err := c.cc.Invoke(ctx, AgentService_UpdateCloudConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *agentServiceClient) GetNotificationStats(ctx context.Context, in *GetNotificationStatsRequest, opts ...grpc.CallOption) (*GetNotificationStatsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetNotificationStatsResponse)
@@ -302,6 +314,7 @@ type AgentServiceServer interface {
ContainerExec(grpc.BidiStreamingServer[ContainerExecRequest, ContainerExecResponse]) error
ContainerAttach(grpc.BidiStreamingServer[ContainerAttachRequest, ContainerAttachResponse]) error
UpdateNotificationConfig(context.Context, *UpdateNotificationConfigRequest) (*UpdateNotificationConfigResponse, error)
UpdateCloudConfig(context.Context, *UpdateCloudConfigRequest) (*UpdateCloudConfigResponse, error)
GetNotificationStats(context.Context, *GetNotificationStatsRequest) (*GetNotificationStatsResponse, error)
mustEmbedUnimplementedAgentServiceServer()
}
@@ -355,6 +368,9 @@ func (UnimplementedAgentServiceServer) ContainerAttach(grpc.BidiStreamingServer[
func (UnimplementedAgentServiceServer) UpdateNotificationConfig(context.Context, *UpdateNotificationConfigRequest) (*UpdateNotificationConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateNotificationConfig not implemented")
}
func (UnimplementedAgentServiceServer) UpdateCloudConfig(context.Context, *UpdateCloudConfigRequest) (*UpdateCloudConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateCloudConfig not implemented")
}
func (UnimplementedAgentServiceServer) GetNotificationStats(context.Context, *GetNotificationStatsRequest) (*GetNotificationStatsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetNotificationStats not implemented")
}
@@ -560,6 +576,24 @@ func _AgentService_UpdateNotificationConfig_Handler(srv interface{}, ctx context
return interceptor(ctx, in, info, handler)
}
func _AgentService_UpdateCloudConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateCloudConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServiceServer).UpdateCloudConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AgentService_UpdateCloudConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServiceServer).UpdateCloudConfig(ctx, req.(*UpdateCloudConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AgentService_GetNotificationStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetNotificationStatsRequest)
if err := dec(in); err != nil {
@@ -605,6 +639,10 @@ var AgentService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UpdateNotificationConfig",
Handler: _AgentService_UpdateNotificationConfig_Handler,
},
{
MethodName: "UpdateCloudConfig",
Handler: _AgentService_UpdateCloudConfig_Handler,
},
{
MethodName: "GetNotificationStats",
Handler: _AgentService_GetNotificationStats_Handler,
+75 -36
View File
@@ -992,9 +992,6 @@ type NotificationDispatcher struct {
Url string `protobuf:"bytes,4,opt,name=url,proto3" json:"url,omitempty"`
Template string `protobuf:"bytes,5,opt,name=template,proto3" json:"template,omitempty"`
Headers map[string]string `protobuf:"bytes,6,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
ApiKey string `protobuf:"bytes,7,opt,name=apiKey,proto3" json:"apiKey,omitempty"`
Prefix string `protobuf:"bytes,8,opt,name=prefix,proto3" json:"prefix,omitempty"`
ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=expiresAt,proto3" json:"expiresAt,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1071,21 +1068,60 @@ func (x *NotificationDispatcher) GetHeaders() map[string]string {
return nil
}
func (x *NotificationDispatcher) GetApiKey() string {
type NotificationCloudConfig struct {
state protoimpl.MessageState `protogen:"open.v1"`
ApiKey string `protobuf:"bytes,1,opt,name=apiKey,proto3" json:"apiKey,omitempty"`
Prefix string `protobuf:"bytes,2,opt,name=prefix,proto3" json:"prefix,omitempty"`
ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=expiresAt,proto3" json:"expiresAt,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NotificationCloudConfig) Reset() {
*x = NotificationCloudConfig{}
mi := &file_types_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NotificationCloudConfig) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NotificationCloudConfig) ProtoMessage() {}
func (x *NotificationCloudConfig) ProtoReflect() protoreflect.Message {
mi := &file_types_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NotificationCloudConfig.ProtoReflect.Descriptor instead.
func (*NotificationCloudConfig) Descriptor() ([]byte, []int) {
return file_types_proto_rawDescGZIP(), []int{11}
}
func (x *NotificationCloudConfig) GetApiKey() string {
if x != nil {
return x.ApiKey
}
return ""
}
func (x *NotificationDispatcher) GetPrefix() string {
func (x *NotificationCloudConfig) GetPrefix() string {
if x != nil {
return x.Prefix
}
return ""
}
func (x *NotificationDispatcher) GetExpiresAt() *timestamppb.Timestamp {
func (x *NotificationCloudConfig) GetExpiresAt() *timestamppb.Timestamp {
if x != nil {
return x.ExpiresAt
}
@@ -1104,7 +1140,7 @@ type NotificationSubscriptionStats struct {
func (x *NotificationSubscriptionStats) Reset() {
*x = NotificationSubscriptionStats{}
mi := &file_types_proto_msgTypes[11]
mi := &file_types_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1116,7 +1152,7 @@ func (x *NotificationSubscriptionStats) String() string {
func (*NotificationSubscriptionStats) ProtoMessage() {}
func (x *NotificationSubscriptionStats) ProtoReflect() protoreflect.Message {
mi := &file_types_proto_msgTypes[11]
mi := &file_types_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1129,7 +1165,7 @@ func (x *NotificationSubscriptionStats) ProtoReflect() protoreflect.Message {
// Deprecated: Use NotificationSubscriptionStats.ProtoReflect.Descriptor instead.
func (*NotificationSubscriptionStats) Descriptor() ([]byte, []int) {
return file_types_proto_rawDescGZIP(), []int{11}
return file_types_proto_rawDescGZIP(), []int{12}
}
func (x *NotificationSubscriptionStats) GetSubscriptionId() int32 {
@@ -1259,20 +1295,22 @@ const file_types_proto_rawDesc = "" +
"\bcooldown\x18\b \x01(\x05R\bcooldown\x12\"\n" +
"\fsampleWindow\x18\t \x01(\x05R\fsampleWindow\x12(\n" +
"\x0feventExpression\x18\n" +
" \x01(\tR\x0feventExpression\"\xed\x02\n" +
" \x01(\tR\x0feventExpression\"\x95\x02\n" +
"\x16NotificationDispatcher\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x05R\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" +
"\x04type\x18\x03 \x01(\tR\x04type\x12\x10\n" +
"\x03url\x18\x04 \x01(\tR\x03url\x12\x1a\n" +
"\btemplate\x18\x05 \x01(\tR\btemplate\x12G\n" +
"\aheaders\x18\x06 \x03(\v2-.protobuf.NotificationDispatcher.HeadersEntryR\aheaders\x12\x16\n" +
"\x06apiKey\x18\a \x01(\tR\x06apiKey\x12\x16\n" +
"\x06prefix\x18\b \x01(\tR\x06prefix\x128\n" +
"\texpiresAt\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x1a:\n" +
"\aheaders\x18\x06 \x03(\v2-.protobuf.NotificationDispatcher.HeadersEntryR\aheaders\x1a:\n" +
"\fHeadersEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xe7\x01\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\a\x10\bJ\x04\b\b\x10\tJ\x04\b\t\x10\n" +
"\"\x83\x01\n" +
"\x17NotificationCloudConfig\x12\x16\n" +
"\x06apiKey\x18\x01 \x01(\tR\x06apiKey\x12\x16\n" +
"\x06prefix\x18\x02 \x01(\tR\x06prefix\x128\n" +
"\texpiresAt\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"\xe7\x01\n" +
"\x1dNotificationSubscriptionStats\x12&\n" +
"\x0esubscriptionId\x18\x01 \x01(\x05R\x0esubscriptionId\x12\"\n" +
"\ftriggerCount\x18\x02 \x01(\x03R\ftriggerCount\x12D\n" +
@@ -1296,7 +1334,7 @@ func file_types_proto_rawDescGZIP() []byte {
}
var file_types_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_types_proto_msgTypes = make([]protoimpl.MessageInfo, 16)
var file_types_proto_msgTypes = make([]protoimpl.MessageInfo, 17)
var file_types_proto_goTypes = []any{
(ContainerAction)(0), // 0: protobuf.ContainerAction
(*Container)(nil), // 1: protobuf.Container
@@ -1310,29 +1348,30 @@ var file_types_proto_goTypes = []any{
(*Host)(nil), // 9: protobuf.Host
(*NotificationSubscription)(nil), // 10: protobuf.NotificationSubscription
(*NotificationDispatcher)(nil), // 11: protobuf.NotificationDispatcher
(*NotificationSubscriptionStats)(nil), // 12: protobuf.NotificationSubscriptionStats
nil, // 13: protobuf.Container.LabelsEntry
nil, // 14: protobuf.ContainerEvent.ActorAttributesEntry
nil, // 15: protobuf.Host.LabelsEntry
nil, // 16: protobuf.NotificationDispatcher.HeadersEntry
(*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp
(*anypb.Any)(nil), // 18: google.protobuf.Any
(*NotificationCloudConfig)(nil), // 12: protobuf.NotificationCloudConfig
(*NotificationSubscriptionStats)(nil), // 13: protobuf.NotificationSubscriptionStats
nil, // 14: protobuf.Container.LabelsEntry
nil, // 15: protobuf.ContainerEvent.ActorAttributesEntry
nil, // 16: protobuf.Host.LabelsEntry
nil, // 17: protobuf.NotificationDispatcher.HeadersEntry
(*timestamppb.Timestamp)(nil), // 18: google.protobuf.Timestamp
(*anypb.Any)(nil), // 19: google.protobuf.Any
}
var file_types_proto_depIdxs = []int32{
17, // 0: protobuf.Container.created:type_name -> google.protobuf.Timestamp
17, // 1: protobuf.Container.started:type_name -> google.protobuf.Timestamp
13, // 2: protobuf.Container.labels:type_name -> protobuf.Container.LabelsEntry
18, // 0: protobuf.Container.created:type_name -> google.protobuf.Timestamp
18, // 1: protobuf.Container.started:type_name -> google.protobuf.Timestamp
14, // 2: protobuf.Container.labels:type_name -> protobuf.Container.LabelsEntry
2, // 3: protobuf.Container.stats:type_name -> protobuf.ContainerStat
17, // 4: protobuf.Container.finished:type_name -> google.protobuf.Timestamp
18, // 5: protobuf.LogEvent.message:type_name -> google.protobuf.Any
17, // 6: protobuf.LogEvent.timestamp:type_name -> google.protobuf.Timestamp
18, // 4: protobuf.Container.finished:type_name -> google.protobuf.Timestamp
19, // 5: protobuf.LogEvent.message:type_name -> google.protobuf.Any
18, // 6: protobuf.LogEvent.timestamp:type_name -> google.protobuf.Timestamp
3, // 7: protobuf.GroupMessage.fragments:type_name -> protobuf.LogFragment
17, // 8: protobuf.ContainerEvent.timestamp:type_name -> google.protobuf.Timestamp
14, // 9: protobuf.ContainerEvent.actorAttributes:type_name -> protobuf.ContainerEvent.ActorAttributesEntry
15, // 10: protobuf.Host.labels:type_name -> protobuf.Host.LabelsEntry
16, // 11: protobuf.NotificationDispatcher.headers:type_name -> protobuf.NotificationDispatcher.HeadersEntry
17, // 12: protobuf.NotificationDispatcher.expiresAt:type_name -> google.protobuf.Timestamp
17, // 13: protobuf.NotificationSubscriptionStats.lastTriggeredAt:type_name -> google.protobuf.Timestamp
18, // 8: protobuf.ContainerEvent.timestamp:type_name -> google.protobuf.Timestamp
15, // 9: protobuf.ContainerEvent.actorAttributes:type_name -> protobuf.ContainerEvent.ActorAttributesEntry
16, // 10: protobuf.Host.labels:type_name -> protobuf.Host.LabelsEntry
17, // 11: protobuf.NotificationDispatcher.headers:type_name -> protobuf.NotificationDispatcher.HeadersEntry
18, // 12: protobuf.NotificationCloudConfig.expiresAt:type_name -> google.protobuf.Timestamp
18, // 13: protobuf.NotificationSubscriptionStats.lastTriggeredAt:type_name -> google.protobuf.Timestamp
14, // [14:14] is the sub-list for method output_type
14, // [14:14] is the sub-list for method input_type
14, // [14:14] is the sub-list for extension type_name
@@ -1351,7 +1390,7 @@ func file_types_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_types_proto_rawDesc), len(file_types_proto_rawDesc)),
NumEnums: 1,
NumMessages: 16,
NumMessages: 17,
NumExtensions: 0,
NumServices: 0,
},
+24 -6
View File
@@ -14,6 +14,7 @@ import (
"github.com/amir20/dozzle/internal/agent/pb"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/internal/notification/dispatcher"
"github.com/amir20/dozzle/types"
"github.com/rs/zerolog/log"
orderedmap "github.com/wk8/go-ordered-map/v2"
@@ -30,6 +31,8 @@ import (
// NotificationConfigHandler handles notification config updates received from the main server
type NotificationConfigHandler interface {
HandleNotificationConfig(subscriptions []types.SubscriptionConfig, dispatchers []types.DispatcherConfig) error
SetCloudDispatcher(d dispatcher.Dispatcher)
ClearCloudDispatcher()
GetNotificationStats() []types.SubscriptionStats
}
@@ -462,12 +465,6 @@ func (s *server) UpdateNotificationConfig(ctx context.Context, req *pb.UpdateNot
URL: d.Url,
Template: d.Template,
Headers: d.Headers,
APIKey: d.ApiKey,
Prefix: d.Prefix,
}
if d.ExpiresAt != nil {
t := d.ExpiresAt.AsTime()
dispatchers[i].ExpiresAt = &t
}
}
@@ -481,6 +478,27 @@ func (s *server) UpdateNotificationConfig(ctx context.Context, req *pb.UpdateNot
return &pb.UpdateNotificationConfigResponse{}, nil
}
func (s *server) UpdateCloudConfig(ctx context.Context, req *pb.UpdateCloudConfigRequest) (*pb.UpdateCloudConfigResponse, error) {
if cc := req.CloudConfig; cc != nil && cc.ApiKey != "" {
var expiresAt *time.Time
if cc.ExpiresAt != nil {
t := cc.ExpiresAt.AsTime()
expiresAt = &t
}
d, err := dispatcher.NewCloudDispatcher("Dozzle Cloud", cc.ApiKey, cc.Prefix, expiresAt)
if err != nil {
log.Error().Err(err).Msg("Failed to create cloud dispatcher from broadcast config")
return nil, status.Error(codes.Internal, err.Error())
}
s.notificationConfigHandler.SetCloudDispatcher(d)
log.Info().Msg("Updated cloud config from main server")
} else {
s.notificationConfigHandler.ClearCloudDispatcher()
log.Info().Msg("Cleared cloud config from main server")
}
return &pb.UpdateCloudConfigResponse{}, nil
}
func (s *server) GetNotificationStats(ctx context.Context, req *pb.GetNotificationStatsRequest) (*pb.GetNotificationStatsResponse, error) {
stats := s.notificationConfigHandler.GetNotificationStats()
+124
View File
@@ -0,0 +1,124 @@
// Package migration contains one-time data migrations that run at startup.
// Delete this package once all users have migrated.
package migration
import (
"os"
"time"
"github.com/rs/zerolog/log"
"go.yaml.in/yaml/v3"
)
type notificationConfig struct {
Subscriptions []subscription `yaml:"subscriptions"`
Dispatchers []dispatcher `yaml:"dispatchers"`
}
type subscription struct {
ID int `yaml:"id"`
Name string `yaml:"name"`
Enabled bool `yaml:"enabled"`
DispatcherID int `yaml:"dispatcherId"`
LogExpression string `yaml:"logExpression"`
ContainerExpression string `yaml:"containerExpression"`
MetricExpression string `yaml:"metricExpression,omitempty"`
EventExpression string `yaml:"eventExpression,omitempty"`
Cooldown int `yaml:"cooldown,omitempty"`
SampleWindow int `yaml:"sampleWindow,omitempty"`
}
type dispatcher struct {
ID int `yaml:"id"`
Name string `yaml:"name"`
Type string `yaml:"type"`
URL string `yaml:"url,omitempty"`
Template string `yaml:"template,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
APIKey string `yaml:"apiKey,omitempty"`
Prefix string `yaml:"prefix,omitempty"`
ExpiresAt *time.Time `yaml:"expiresAt,omitempty"`
}
type cloudConfig struct {
APIKey string `yaml:"apiKey"`
Prefix string `yaml:"prefix"`
ExpiresAt *time.Time `yaml:"expiresAt,omitempty"`
}
// MigrateCloudConfig splits the old notifications.yml (which embedded cloud credentials
// in the dispatchers list) into two files: a clean notifications.yml and a new cloud.yml.
// Subscriptions are remapped from the old cloud dispatcher ID to 0.
// No-op if cloud.yml already exists or there is no cloud dispatcher to migrate.
func MigrateCloudConfig(notificationsPath, cloudPath string) {
if fileExists(cloudPath) || !fileExists(notificationsPath) {
return
}
data, err := os.ReadFile(notificationsPath)
if err != nil {
return
}
var config notificationConfig
if err := yaml.Unmarshal(data, &config); err != nil {
return
}
// Find cloud dispatcher
var cloud *cloudConfig
var cloudID int
remaining := make([]dispatcher, 0, len(config.Dispatchers))
for _, d := range config.Dispatchers {
if d.Type == "cloud" && d.APIKey != "" {
cloud = &cloudConfig{APIKey: d.APIKey, Prefix: d.Prefix, ExpiresAt: d.ExpiresAt}
cloudID = d.ID
} else {
remaining = append(remaining, d)
}
}
if cloud == nil {
return
}
log.Info().Int("oldDispatcherId", cloudID).Msg("Migrating cloud config from notifications.yml to cloud.yml")
// Remap subscriptions from old cloud dispatcher ID to 0
for i := range config.Subscriptions {
if config.Subscriptions[i].DispatcherID == cloudID {
config.Subscriptions[i].DispatcherID = 0
}
}
config.Dispatchers = remaining
// Write cloud.yml
if err := writeYAML(cloudPath, cloud); err != nil {
log.Error().Err(err).Msg("Could not write cloud.yml")
return
}
// Rewrite notifications.yml
if err := writeYAML(notificationsPath, config); err != nil {
log.Error().Err(err).Msg("Could not rewrite notifications.yml")
return
}
log.Info().Msg("Migration complete: created cloud.yml and updated notifications.yml")
}
func writeYAML(path string, v any) error {
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
encoder := yaml.NewEncoder(file)
defer encoder.Close()
return encoder.Encode(v)
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
+33
View File
@@ -0,0 +1,33 @@
package notification
import (
"io"
"time"
"go.yaml.in/yaml/v3"
)
// CloudConfig holds the cloud dispatcher credentials and metadata.
type CloudConfig struct {
APIKey string `yaml:"apiKey"`
Prefix string `yaml:"prefix"`
ExpiresAt *time.Time `yaml:"expiresAt,omitempty"`
}
// WriteCloudConfig encodes the given CloudConfig to the writer in YAML format.
func WriteCloudConfig(w io.Writer, config CloudConfig) error {
encoder := yaml.NewEncoder(w)
defer encoder.Close()
return encoder.Encode(config)
}
// LoadCloudConfig decodes a CloudConfig from the reader.
func LoadCloudConfig(r io.Reader) (CloudConfig, error) {
var config CloudConfig
decoder := yaml.NewDecoder(r)
if err := decoder.Decode(&config); err != nil {
return CloudConfig{}, err
}
return config, nil
}
+31 -25
View File
@@ -13,11 +13,20 @@ import (
"go.yaml.in/yaml/v3"
)
// WriteConfig writes the current configuration to a writer in YAML format
// WriteConfig writes the current configuration to a writer in YAML format.
// Cloud dispatchers are excluded because they are persisted separately in cloud.yml.
func (m *Manager) WriteConfig(w io.Writer) error {
allDispatchers := m.Dispatchers()
dispatchers := make([]DispatcherConfig, 0, len(allDispatchers))
for _, d := range allDispatchers {
if d.Type != "cloud" {
dispatchers = append(dispatchers, d)
}
}
config := Config{
Subscriptions: m.Subscriptions(),
Dispatchers: m.Dispatchers(),
Dispatchers: dispatchers,
}
encoder := yaml.NewEncoder(w)
@@ -55,15 +64,12 @@ 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,
Headers: d.Headers,
APIKey: d.APIKey,
Prefix: d.Prefix,
ExpiresAt: d.ExpiresAt,
ID: d.ID,
Name: d.Name,
Type: d.Type,
URL: d.URL,
Template: d.Template,
Headers: d.Headers,
}
}
@@ -162,21 +168,22 @@ func (m *Manager) HandleNotificationConfig(subscriptions []types.SubscriptionCon
}
}
// Load dispatchers
// Load dispatchers (cloud dispatchers are skipped; they are managed via cloud.yml)
for _, dc := range dispatchers {
if dc.Type == "cloud" {
continue
}
d, err := createDispatcher(DispatcherConfig{
ID: dc.ID,
Name: dc.Name,
Type: dc.Type,
URL: dc.URL,
Template: dc.Template,
Headers: dc.Headers,
APIKey: dc.APIKey,
Prefix: dc.Prefix,
ExpiresAt: dc.ExpiresAt,
ID: dc.ID,
Name: dc.Name,
Type: dc.Type,
URL: dc.URL,
Template: dc.Template,
Headers: dc.Headers,
})
if err != nil {
return fmt.Errorf("failed to create dispatcher %s: %w", dc.Name, err)
log.Warn().Err(err).Str("name", dc.Name).Str("type", dc.Type).Msg("Skipping unknown dispatcher type")
continue
}
m.dispatchers.Store(dc.ID, d)
log.Debug().Int("id", dc.ID).Msg("Loaded dispatcher from state sync")
@@ -188,13 +195,12 @@ func (m *Manager) HandleNotificationConfig(subscriptions []types.SubscriptionCon
return nil
}
// createDispatcher creates a dispatcher from a DispatcherConfig
// createDispatcher creates a dispatcher from a DispatcherConfig.
// Cloud dispatchers are not created here; they are managed via cloud.yml and SetCloudDispatcher.
func createDispatcher(config DispatcherConfig) (dispatcher.Dispatcher, error) {
switch config.Type {
case "webhook":
return dispatcher.NewWebhookDispatcher(config.Name, config.URL, config.Template, config.Headers)
case "cloud":
return dispatcher.NewCloudDispatcher(config.Name, config.APIKey, config.Prefix, config.ExpiresAt)
default:
return nil, fmt.Errorf("unknown dispatcher type: %s", config.Type)
}
+1 -1
View File
@@ -134,7 +134,7 @@ func (l *ContainerLogListener) startListening(c container.Container, client cont
go func() {
defer l.cleanupStream(c.ID, streamCtx)
log.Debug().Str("containerID", c.ID).Str("name", c.Name).Msg("Started listening to container")
if err := client.StreamLogs(streamCtx, c, since, container.STDALL, l.logChannel); err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) {
if err := client.StreamLogs(streamCtx, c, since, container.STDALL, l.logChannel); err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) && streamCtx.Err() == nil {
log.Error().Err(err).Str("containerID", c.ID).Msg("Error streaming logs")
}
}()
+41 -10
View File
@@ -21,6 +21,7 @@ import (
type Manager struct {
subscriptions *xsync.Map[int, *Subscription]
dispatchers *xsync.Map[int, dispatcher.Dispatcher]
cloudDispatcher atomic.Pointer[dispatcher.Dispatcher]
subscriptionCounter atomic.Int32
dispatcherCounter atomic.Int32
listener *ContainerLogListener
@@ -322,6 +323,31 @@ func (m *Manager) RemoveDispatcher(id int) {
}
}
// SetCloudDispatcher sets the dedicated cloud dispatcher used for subscriptions with DispatcherID == 0.
func (m *Manager) SetCloudDispatcher(d dispatcher.Dispatcher) {
m.cloudDispatcher.Store(&d)
log.Debug().Msg("Set cloud dispatcher")
}
// ClearCloudDispatcher removes the cloud dispatcher.
func (m *Manager) ClearCloudDispatcher() {
m.cloudDispatcher.Store(nil)
log.Debug().Msg("Cleared cloud dispatcher")
}
// getDispatcher resolves a dispatcher by subscription's DispatcherID.
// DispatcherID == 0 means the cloud dispatcher; otherwise lookup in the dispatchers map.
func (m *Manager) getDispatcher(id int) (dispatcher.Dispatcher, bool) {
if id == 0 {
if p := m.cloudDispatcher.Load(); p != nil {
return *p, true
}
return nil, false
}
return m.dispatchers.Load(id)
}
// Subscriptions returns all subscriptions sorted by ID
func (m *Manager) Subscriptions() []*Subscription {
result := make([]*Subscription, 0)
@@ -363,9 +389,23 @@ func (m *Manager) GetNotificationStats() []types.SubscriptionStats {
return stats
}
// Dispatchers returns all dispatchers as DispatcherConfig sorted by ID
// Dispatchers returns all dispatchers as DispatcherConfig sorted by ID.
// Includes the cloud dispatcher (ID 0) when configured.
func (m *Manager) Dispatchers() []DispatcherConfig {
result := make([]DispatcherConfig, 0)
// Include cloud dispatcher if configured
if p := m.cloudDispatcher.Load(); p != nil {
if cd, ok := (*p).(*dispatcher.CloudDispatcher); ok {
result = append(result, DispatcherConfig{
ID: 0,
Name: cd.Name,
Type: "cloud",
Prefix: cd.Prefix,
})
}
}
m.dispatchers.Range(func(id int, d dispatcher.Dispatcher) bool {
switch v := d.(type) {
case *dispatcher.WebhookDispatcher:
@@ -377,15 +417,6 @@ func (m *Manager) Dispatchers() []DispatcherConfig {
Template: v.TemplateText,
Headers: v.Headers,
})
case *dispatcher.CloudDispatcher:
result = append(result, DispatcherConfig{
ID: id,
Name: v.Name,
Type: "cloud",
APIKey: v.APIKey,
Prefix: v.Prefix,
ExpiresAt: v.ExpiresAt,
})
}
return true
})
+3 -3
View File
@@ -90,7 +90,7 @@ func (m *Manager) processLogEvent(logEvent *container.LogEvent) {
}
// Send to the subscription's dispatcher
if d, ok := m.dispatchers.Load(sub.DispatcherID); ok {
if d, ok := m.getDispatcher(sub.DispatcherID); ok {
go m.sendNotification(d, notification, sub.DispatcherID)
}
return true
@@ -177,7 +177,7 @@ func (m *Manager) processStatEvent(event *ContainerStatEvent) {
Timestamp: time.Now(),
}
if d, ok := m.dispatchers.Load(sub.DispatcherID); ok {
if d, ok := m.getDispatcher(sub.DispatcherID); ok {
go m.sendNotification(d, notification, sub.DispatcherID)
}
return true
@@ -264,7 +264,7 @@ func (m *Manager) processDockerEvent(event *ContainerEventEntry) {
Timestamp: time.Now(),
}
if d, ok := m.dispatchers.Load(sub.DispatcherID); ok {
if d, ok := m.getDispatcher(sub.DispatcherID); ok {
go m.sendNotification(d, notification, sub.DispatcherID)
}
return true
+7 -9
View File
@@ -173,15 +173,13 @@ func (s *Subscription) CompileExpressions() error {
// 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
Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` // Custom HTTP headers
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"`
ID int `json:"id" yaml:"id"`
Name string `json:"name" yaml:"name"`
Type string `json:"type" yaml:"type"` // "webhook" or "cloud"
URL string `json:"url,omitempty" yaml:"url,omitempty"`
Template string `json:"template,omitempty" yaml:"template,omitempty"` // Go template for custom payload format
Headers map[string]string `json:"headers,omitempty" yaml:"headers,omitempty"` // Custom HTTP headers
Prefix string `json:"prefix,omitempty" yaml:"-"` // Cloud dispatcher API key prefix (not persisted)
}
// Config represents the persisted notification configuration
+56
View File
@@ -13,6 +13,7 @@ import (
"github.com/amir20/dozzle/internal/agent"
"github.com/amir20/dozzle/internal/docker"
"github.com/amir20/dozzle/internal/notification"
"github.com/amir20/dozzle/internal/notification/dispatcher"
container_support "github.com/amir20/dozzle/internal/support/container"
docker_support "github.com/amir20/dozzle/internal/support/docker"
"github.com/amir20/dozzle/types"
@@ -58,6 +59,44 @@ func (h *persistingNotificationHandler) HandleNotificationConfig(subscriptions [
return nil
}
func (h *persistingNotificationHandler) SetCloudDispatcher(d dispatcher.Dispatcher) {
h.manager.SetCloudDispatcher(d)
// Persist cloud config to disk so it survives agent restarts
cd, ok := d.(*dispatcher.CloudDispatcher)
if !ok {
log.Warn().Str("type", fmt.Sprintf("%T", d)).Msg("Cloud dispatcher type assertion failed, cannot persist")
return
}
cc := notification.CloudConfig{
APIKey: cd.APIKey,
Prefix: cd.Prefix,
ExpiresAt: cd.ExpiresAt,
}
if err := os.MkdirAll("./data", 0755); err != nil {
log.Error().Err(err).Msg("Could not create data directory for cloud config")
return
}
file, err := os.Create("./data/cloud.yml")
if err != nil {
log.Error().Err(err).Msg("Could not create cloud.yml on agent")
return
}
defer file.Close()
if err := notification.WriteCloudConfig(file, cc); err != nil {
log.Error().Err(err).Msg("Could not write cloud.yml on agent")
} else {
log.Debug().Msg("Persisted cloud.yml on agent")
}
}
func (h *persistingNotificationHandler) ClearCloudDispatcher() {
h.manager.ClearCloudDispatcher()
if err := os.Remove("./data/cloud.yml"); err != nil && !os.IsNotExist(err) {
log.Error().Err(err).Msg("Could not remove cloud.yml on agent")
}
}
func (a *AgentCmd) Run(args Args, embeddedCerts embed.FS) error {
if args.Mode != "server" {
return fmt.Errorf("agent command is only available in server mode")
@@ -113,6 +152,23 @@ func (a *AgentCmd) Run(args Args, embeddedCerts embed.FS) error {
file.Close()
}
// Load cloud config if available
if file, err := os.Open("./data/cloud.yml"); err == nil {
cc, err := notification.LoadCloudConfig(file)
file.Close()
if err != nil {
log.Warn().Err(err).Msg("Failed to load cloud config on agent")
} else {
d, err := dispatcher.NewCloudDispatcher("Dozzle Cloud", cc.APIKey, cc.Prefix, cc.ExpiresAt)
if err != nil {
log.Error().Err(err).Msg("Failed to create cloud dispatcher on agent")
} else {
notificationManager.SetCloudDispatcher(d)
log.Info().Msg("Loaded cloud config from disk")
}
}
}
// Create handler that wraps manager and persists config to disk
notificationHandler := &persistingNotificationHandler{
manager: notificationManager,
@@ -92,6 +92,10 @@ func (a *agentService) UpdateNotificationConfig(ctx context.Context, subscriptio
return a.client.UpdateNotificationConfig(ctx, subscriptions, dispatchers)
}
func (a *agentService) UpdateCloudConfig(ctx context.Context, cloudConfig *types.CloudConfig) error {
return a.client.UpdateCloudConfig(ctx, cloudConfig)
}
func (a *agentService) GetNotificationStats(ctx context.Context) ([]types.SubscriptionStats, error) {
return a.client.GetNotificationStats(ctx)
}
+159 -18
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/internal/migration"
"github.com/amir20/dozzle/internal/notification"
"github.com/amir20/dozzle/internal/notification/dispatcher"
container_support "github.com/amir20/dozzle/internal/support/container"
@@ -39,6 +40,8 @@ type MultiHostService struct {
manager ClientManager
timeout time.Duration
notificationManager *notification.Manager
cloudConfig *notification.CloudConfig
cloudMu sync.RWMutex
}
func NewMultiHostService(manager ClientManager, timeout time.Duration) *MultiHostService {
@@ -184,6 +187,7 @@ func (m *MultiHostService) TotalClients() int {
}
const notificationConfigPath = "./data/notifications.yml"
const cloudConfigPath = "./data/cloud.yml"
// StartNotificationManager initializes and starts the notification manager
func (m *MultiHostService) StartNotificationManager(ctx context.Context) error {
@@ -193,12 +197,15 @@ func (m *MultiHostService) StartNotificationManager(ctx context.Context) error {
eventListener := notification.NewContainerEventListener(ctx, clients)
m.notificationManager = notification.NewManager(listener, statsListener, eventListener)
// Migrate old config format before loading (splits cloud into cloud.yml)
migration.MigrateCloudConfig(notificationConfigPath, cloudConfigPath)
// Start first so matcher is available for LoadConfig
if err := m.notificationManager.Start(); err != nil {
return err
}
// Load config if exists
// Load notification config
if file, err := os.Open(notificationConfigPath); err == nil {
defer file.Close()
if err := m.notificationManager.LoadConfig(file); err != nil {
@@ -208,6 +215,41 @@ func (m *MultiHostService) StartNotificationManager(ctx context.Context) error {
}
}
// Load cloud config
if file, err := os.Open(cloudConfigPath); err == nil {
defer file.Close()
cc, err := notification.LoadCloudConfig(file)
if err != nil {
log.Warn().Err(err).Msg("Could not load cloud config")
} else {
m.cloudConfig = &cc
m.setCloudDispatcherFromConfig(&cc)
log.Debug().Str("path", cloudConfigPath).Msg("Loaded cloud config")
}
}
// Broadcast loaded config to any already-connected agents
m.broadcastNotificationConfig()
m.broadcastCloudConfig()
// Re-broadcast when new agents connect so they receive the current config
hostCh := make(chan container.Host, 1)
m.manager.Subscribe(ctx, hostCh)
go func() {
for {
select {
case <-ctx.Done():
return
case host := <-hostCh:
if host.Available {
log.Debug().Str("host", host.Name).Msg("New host available, broadcasting config")
m.broadcastNotificationConfig()
m.broadcastCloudConfig()
}
}
}
}()
return nil
}
@@ -232,9 +274,79 @@ func (m *MultiHostService) saveNotificationConfig() {
m.broadcastNotificationConfig()
}
// saveCloudConfig writes the current cloud config to cloudConfigPath and
// broadcasts the notification config to all agents so they receive the API key.
func (m *MultiHostService) saveCloudConfig() {
m.cloudMu.RLock()
cc := m.cloudConfig
m.cloudMu.RUnlock()
if cc == nil {
return
}
if err := os.MkdirAll("./data", 0755); err != nil {
log.Error().Err(err).Msg("Could not create data directory")
return
}
file, err := os.Create(cloudConfigPath)
if err != nil {
log.Error().Err(err).Msg("Could not create cloud config file")
return
}
defer file.Close()
if err := notification.WriteCloudConfig(file, *cc); err != nil {
log.Error().Err(err).Msg("Could not write cloud config")
}
m.broadcastCloudConfig()
}
// CloudConfig returns the current cloud config, or nil if not set.
func (m *MultiHostService) CloudConfig() *notification.CloudConfig {
m.cloudMu.RLock()
defer m.cloudMu.RUnlock()
return m.cloudConfig
}
// SetCloudConfig sets the cloud config, creates the cloud dispatcher, and persists to disk.
func (m *MultiHostService) SetCloudConfig(cc *notification.CloudConfig) {
m.cloudMu.Lock()
m.cloudConfig = cc
m.cloudMu.Unlock()
m.setCloudDispatcherFromConfig(cc)
m.saveCloudConfig()
}
// RemoveCloudConfig clears the cloud config, removes the cloud dispatcher, deletes the file,
// and broadcasts the change to all agents so they stop sending to cloud.
func (m *MultiHostService) RemoveCloudConfig() {
m.cloudMu.Lock()
m.cloudConfig = nil
m.cloudMu.Unlock()
m.notificationManager.ClearCloudDispatcher()
if err := os.Remove(cloudConfigPath); err != nil && !os.IsNotExist(err) {
log.Error().Err(err).Msg("Could not remove cloud config file")
}
m.broadcastCloudConfig()
}
// setCloudDispatcherFromConfig creates a CloudDispatcher from the given config and sets it on the manager.
func (m *MultiHostService) setCloudDispatcherFromConfig(cc *notification.CloudConfig) {
d, err := dispatcher.NewCloudDispatcher("Dozzle Cloud", cc.APIKey, cc.Prefix, cc.ExpiresAt)
if err != nil {
log.Error().Err(err).Msg("Could not create cloud dispatcher from config")
return
}
m.notificationManager.SetCloudDispatcher(d)
}
// NotificationConfigUpdater is an interface for clients that support notification config updates
type NotificationConfigUpdater interface {
UpdateNotificationConfig(ctx context.Context, subscriptions []types.SubscriptionConfig, dispatchers []types.DispatcherConfig) error
UpdateCloudConfig(ctx context.Context, cloudConfig *types.CloudConfig) error
}
// broadcastNotificationConfig sends current notification config to all agent clients
@@ -242,7 +354,6 @@ func (m *MultiHostService) broadcastNotificationConfig() {
notifSubs := m.notificationManager.Subscriptions()
notifDispatchers := m.notificationManager.Dispatchers()
// Convert notification.Subscription to types.SubscriptionConfig
subscriptions := make([]types.SubscriptionConfig, len(notifSubs))
for i, sub := range notifSubs {
subscriptions[i] = types.SubscriptionConfig{
@@ -259,33 +370,30 @@ func (m *MultiHostService) broadcastNotificationConfig() {
}
}
// Convert notification.DispatcherConfig to types.DispatcherConfig
dispatchers := make([]types.DispatcherConfig, len(notifDispatchers))
for i, d := range notifDispatchers {
dispatchers[i] = types.DispatcherConfig{
ID: d.ID,
Name: d.Name,
Type: d.Type,
URL: d.URL,
Template: d.Template,
Headers: d.Headers,
APIKey: d.APIKey,
Prefix: d.Prefix,
ExpiresAt: d.ExpiresAt,
// Cloud dispatchers are excluded; cloud config is broadcast separately.
dispatchers := make([]types.DispatcherConfig, 0, len(notifDispatchers))
for _, d := range notifDispatchers {
if d.Type == "cloud" {
continue
}
dispatchers = append(dispatchers, types.DispatcherConfig{
ID: d.ID,
Name: d.Name,
Type: d.Type,
URL: d.URL,
Template: d.Template,
Headers: d.Headers,
})
}
var wg sync.WaitGroup
for _, client := range m.manager.List() {
// Check if client supports notification config updates (agents do, local docker clients don't)
if updater, ok := client.(NotificationConfigUpdater); ok {
wg.Go(func() {
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
defer cancel()
if err := updater.UpdateNotificationConfig(ctx, subscriptions, dispatchers); err != nil {
log.Error().Err(err).Msg("Failed to broadcast notification config to agent")
} else {
log.Debug().Int("subscriptions", len(subscriptions)).Int("dispatchers", len(dispatchers)).Msg("Broadcasted notification config to agent")
}
})
}
@@ -293,6 +401,39 @@ func (m *MultiHostService) broadcastNotificationConfig() {
wg.Wait()
}
// broadcastCloudConfig sends current cloud config to all agent clients
func (m *MultiHostService) broadcastCloudConfig() {
m.cloudMu.RLock()
ncc := m.cloudConfig
m.cloudMu.RUnlock()
var cc *types.CloudConfig
if ncc != nil {
cc = &types.CloudConfig{
APIKey: ncc.APIKey,
Prefix: ncc.Prefix,
ExpiresAt: ncc.ExpiresAt,
}
}
var count int
var wg sync.WaitGroup
for _, client := range m.manager.List() {
if updater, ok := client.(NotificationConfigUpdater); ok {
count++
wg.Go(func() {
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
defer cancel()
if err := updater.UpdateCloudConfig(ctx, cc); err != nil {
log.Error().Err(err).Msg("Failed to broadcast cloud config to agent")
}
})
}
}
wg.Wait()
log.Debug().Int("agents", count).Bool("hasCloud", cc != nil).Msg("Broadcasted cloud config")
}
// NotificationHandler returns the notification manager as an agent.NotificationConfigHandler.
// This is used in swarm mode to pass the handler to the local agent server.
func (m *MultiHostService) NotificationHandler() *notification.Manager {
@@ -178,3 +178,15 @@ func (m *K8sClusterService) Dispatchers() []notification.DispatcherConfig {
func (m *K8sClusterService) FetchAgentNotificationStats() map[int]types.SubscriptionStats {
return nil
}
func (m *K8sClusterService) CloudConfig() *notification.CloudConfig {
return nil
}
func (m *K8sClusterService) SetCloudConfig(cc *notification.CloudConfig) {
// Not supported in k8s mode
}
func (m *K8sClusterService) RemoveCloudConfig() {
// Not supported in k8s mode
}
+51 -21
View File
@@ -8,6 +8,7 @@ import (
"os"
"time"
"github.com/amir20/dozzle/internal/notification"
"github.com/amir20/dozzle/internal/notification/dispatcher"
"github.com/rs/zerolog/log"
)
@@ -20,6 +21,7 @@ type exchangeTokenResponse struct {
func (h *handler) cloudCallback(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
from := r.URL.Query().Get("from")
if token == "" {
http.Error(w, "missing token parameter", http.StatusBadRequest)
return
@@ -82,16 +84,13 @@ func (h *handler) cloudCallback(w http.ResponseWriter, r *http.Request) {
}
}
name := "Dozzle Cloud"
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)
return
// Save cloud config (also creates the cloud dispatcher and broadcasts to agents)
cc := &notification.CloudConfig{
APIKey: tokenResp.Key,
Prefix: tokenResp.Prefix,
ExpiresAt: expiresAt,
}
id := h.hostService.AddDispatcher(cloudDispatcher)
h.hostService.SetCloudConfig(cc)
if h.config.OnCloudSetup != nil {
h.config.OnCloudSetup()
@@ -101,24 +100,23 @@ func (h *handler) cloudCallback(w http.ResponseWriter, r *http.Request) {
if base == "/" {
base = ""
}
redirectURL := fmt.Sprintf("%s/notifications#cloudLinkSuccess=%d", base, id)
var redirectURL string
if from == "notifications" {
redirectURL = fmt.Sprintf("%s/notifications#cloudLinked", base)
} else {
redirectURL = fmt.Sprintf("%s/#cloudLinked", base)
}
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")
cc := h.hostService.CloudConfig()
if cc == nil || cc.APIKey == "" {
writeError(w, http.StatusNotFound, "no cloud configuration")
return
}
apiKey := cc.APIKey
cloudURL := os.Getenv("DOLIGENCE_URL")
if cloudURL == "" {
@@ -160,3 +158,35 @@ func (h *handler) cloudStatus(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
io.Copy(w, resp.Body)
}
type cloudConfigResponse struct {
Prefix string `json:"prefix"`
ExpiresAt *string `json:"expiresAt,omitempty"`
Linked bool `json:"linked"`
}
func (h *handler) cloudConfig(w http.ResponseWriter, r *http.Request) {
cc := h.hostService.CloudConfig()
if cc == nil {
writeError(w, http.StatusNotFound, "no cloud configuration")
return
}
resp := cloudConfigResponse{
Prefix: cc.Prefix,
Linked: true,
}
if cc.ExpiresAt != nil {
s := cc.ExpiresAt.Format(time.RFC3339)
resp.ExpiresAt = &s
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}
func (h *handler) deleteCloudConfig(w http.ResponseWriter, r *http.Request) {
h.hostService.RemoveCloudConfig()
w.WriteHeader(http.StatusNoContent)
}
+14 -16
View File
@@ -38,14 +38,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"`
Headers map[string]string `json:"headers,omitempty"`
Prefix *string `json:"prefix,omitempty"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
URL *string `json:"url,omitempty"`
Template *string `json:"template,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Prefix *string `json:"prefix,omitempty"`
}
type NotificationRuleInput struct {
@@ -186,14 +185,13 @@ func dispatcherConfigToResponse(d *notification.DispatcherConfig) *DispatcherRes
prefix = &d.Prefix
}
return &DispatcherResponse{
ID: d.ID,
Name: d.Name,
Type: d.Type,
URL: url,
Template: template,
Headers: headers,
Prefix: prefix,
ExpiresAt: d.ExpiresAt,
ID: d.ID,
Name: d.Name,
Type: d.Type,
URL: url,
Template: template,
Headers: headers,
Prefix: prefix,
}
}
+5
View File
@@ -88,6 +88,9 @@ type HostService interface {
RemoveDispatcher(id int)
Dispatchers() []notification.DispatcherConfig
FetchAgentNotificationStats() map[int]types.SubscriptionStats
CloudConfig() *notification.CloudConfig
SetCloudConfig(cc *notification.CloudConfig)
RemoveCloudConfig()
}
type handler struct {
@@ -186,6 +189,8 @@ func createRouter(h *handler) *chi.Mux {
// Cloud API
r.Get("/cloud/status", h.cloudStatus)
r.Get("/cloud/config", h.cloudConfig)
r.Delete("/cloud/config", h.deleteCloudConfig)
})
// Public API routes
+19 -2
View File
@@ -270,7 +270,7 @@ notifications:
close: Luk
save: Gem
add: Tilføj Destination
cloud-exists: Kun én Dozzle Cloud destination kan konfigureres
cloud-exists: Dozzle Cloud er allerede tilknyttet
link-cloud: Link Konto
link-cloud-button: Link Dozzle Cloud
cloud-settings-hint: For at konfigurere dine administrerede kanaler, gå til
@@ -279,6 +279,23 @@ notifications:
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
empty-state:
title: Kom i gang med notifikationer
description: Vælg hvordan du vil modtage alarmer, når dine containere kræver opmærksomhed.
cloud-link-success:
title: Dozzle Cloud Linket
message: Din konto er blevet succesfuldt linket til Dozzle Cloud.
message: Din instans er blevet tilsluttet. Du kan fjerne tilknytningen i indstillinger eller tjekke dit forbrug når som helst.
cloud:
title: Dozzle Cloud
description: Styr dine containere eksternt og brug AI til at undersøge problemer på tværs af din klynge.
learn-more: Læs mere
link-instance: Tilknyt instans
relink-instance: Gentilknyt instans
connected: Tilsluttet
plan: Plan
usage: Begivenheder i denne periode
dashboard: Dashboard
settings: Indstillinger
error: Forbindelsesfejl. Gentilknyt venligst din instans.
unlink: Fjern tilknytning
unlink-confirm: Er du sikker på, at du vil fjerne tilknytningen til Dozzle Cloud? Dette fjerner alle cloud-notifikationsdestinationer.
+19 -2
View File
@@ -270,7 +270,7 @@ notifications:
close: Schließen
save: Speichern
add: Ziel hinzufügen
cloud-exists: Es kann nur ein Dozzle Cloud Ziel konfiguriert werden
cloud-exists: Dozzle Cloud ist bereits verknüpft
link-cloud: Konto verknüpfen
link-cloud-button: Dozzle Cloud verknüpfen
cloud-settings-hint: Um Ihre verwalteten Kanäle zu konfigurieren, gehen Sie zu
@@ -279,6 +279,23 @@ notifications:
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
empty-state:
title: Erste Schritte mit Benachrichtigungen
description: Wählen Sie aus, wie Sie Alarme erhalten möchten, wenn Ihre Container Aufmerksamkeit benötigen.
cloud-link-success:
title: Dozzle Cloud verknüpft
message: Ihr Konto wurde erfolgreich mit Dozzle Cloud verknüpft.
message: Ihre Instanz wurde erfolgreich verbunden. Sie können die Verknüpfung in den Einstellungen aufheben oder Ihre Nutzung jederzeit einsehen.
cloud:
title: Dozzle Cloud
description: Steuern Sie Ihre Container aus der Ferne und nutzen Sie KI, um Probleme in Ihrem Cluster zu untersuchen.
learn-more: Mehr erfahren
link-instance: Instanz verknüpfen
relink-instance: Instanz erneut verknüpfen
connected: Verbunden
plan: Plan
usage: Ereignisse in diesem Zeitraum
dashboard: Dashboard
settings: Einstellungen
error: Verbindungsfehler. Bitte verknüpfen Sie Ihre Instanz erneut.
unlink: Verknüpfung aufheben
unlink-confirm: Sind Sie sicher, dass Sie die Verknüpfung mit Dozzle Cloud aufheben möchten? Dies entfernt alle Cloud-Benachrichtigungsziele.
+21 -2
View File
@@ -279,7 +279,7 @@ notifications:
close: Close
save: Save
add: Add Destination
cloud-exists: Only one Dozzle Cloud destination can be configured
cloud-exists: Dozzle Cloud is already linked
link-cloud: Link Account
link-cloud-button: Link Dozzle Cloud
cloud-settings-hint: To configure your managed channels, go to
@@ -288,6 +288,25 @@ notifications:
cloud-relink: Your API key is invalid or expired. Please delete and link your account again.
cloud-plan: Plan
cloud-usage: Events this period
empty-state:
title: Get started with notifications
description: Choose how you want to receive alerts when your containers need attention.
cloud-subtitle: AI summaries, remote control & more
webhook-subtitle: Send alerts to any endpoint
cloud-link-success:
title: Dozzle Cloud Linked
message: Your account has been successfully linked to Dozzle Cloud.
message: Your instance has been connected successfully. You can unlink it in settings or check your usage anytime.
cloud:
title: Dozzle Cloud
description: Control your containers remotely and use AI to investigate issues across your cluster.
learn-more: Learn more
link-instance: Link instance
relink-instance: Re-link instance
connected: Connected
plan: Plan
usage: Events this period
dashboard: Dashboard
settings: Settings
error: Connection error. Please re-link your instance.
unlink: Unlink
unlink-confirm: Are you sure you want to unlink from Dozzle Cloud? This will remove all cloud notification destinations.
+19 -2
View File
@@ -270,7 +270,7 @@ notifications:
close: Cerrar
save: Guardar
add: Añadir Destino
cloud-exists: Solo se puede configurar un destino de Dozzle Cloud
cloud-exists: Dozzle Cloud ya está vinculado
link-cloud: Vincular Cuenta
link-cloud-button: Vincular Dozzle Cloud
cloud-settings-hint: Para configurar sus canales administrados, vaya a
@@ -279,6 +279,23 @@ notifications:
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
empty-state:
title: Comenzar con las notificaciones
description: Elija cómo desea recibir alertas cuando sus contenedores necesiten atención.
cloud-link-success:
title: Dozzle Cloud Vinculado
message: Su cuenta se ha vinculado correctamente a Dozzle Cloud.
message: Su instancia se ha conectado correctamente. Puede desvincularla en la configuración o consultar su uso en cualquier momento.
cloud:
title: Dozzle Cloud
description: Controle sus contenedores de forma remota y use IA para investigar problemas en su clúster.
learn-more: Más información
link-instance: Vincular instancia
relink-instance: Revincular instancia
connected: Conectado
plan: Plan
usage: Eventos en este período
dashboard: Panel
settings: Configuración
error: Error de conexión. Por favor, revincule su instancia.
unlink: Desvincular
unlink-confirm: ¿Está seguro de que desea desvincular de Dozzle Cloud? Esto eliminará todos los destinos de notificación en la nube.
+19 -2
View File
@@ -270,7 +270,7 @@ notifications:
close: Fermer
save: Enregistrer
add: Ajouter la Destination
cloud-exists: Une seule destination Dozzle Cloud peut être configurée
cloud-exists: Dozzle Cloud est déjà lié
link-cloud: Lier le Compte
link-cloud-button: Lier Dozzle Cloud
cloud-settings-hint: Pour configurer vos canaux gérés, allez à
@@ -279,6 +279,23 @@ notifications:
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
empty-state:
title: Commencer avec les notifications
description: Choisissez comment vous souhaitez recevoir des alertes lorsque vos conteneurs nécessitent une attention.
cloud-link-success:
title: Dozzle Cloud Lié
message: Votre compte a été lié avec succès à Dozzle Cloud.
message: Votre instance a été connectée avec succès. Vous pouvez la délier dans les paramètres ou consulter votre utilisation à tout moment.
cloud:
title: Dozzle Cloud
description: Contrôlez vos conteneurs à distance et utilisez l'IA pour examiner les problèmes dans votre cluster.
learn-more: En savoir plus
link-instance: Lier l'instance
relink-instance: Relier l'instance
connected: Connecté
plan: Plan
usage: Événements pour cette période
dashboard: Tableau de bord
settings: Paramètres
error: Erreur de connexion. Veuillez relier votre instance.
unlink: Délier
unlink-confirm: Êtes-vous sûr de vouloir délier de Dozzle Cloud ? Cela supprimera toutes les destinations de notification cloud.
+19 -2
View File
@@ -282,7 +282,7 @@ notifications:
close: Tutup
save: Simpan
add: Tambah Tujuan
cloud-exists: Hanya satu tujuan Dozzle Cloud yang dapat dikonfigurasi
cloud-exists: Dozzle Cloud sudah tertaut
link-cloud: Tautkan Akun
link-cloud-button: Tautkan Dozzle Cloud
cloud-settings-hint: Untuk mengonfigurasi saluran terkelola Anda, buka
@@ -291,6 +291,23 @@ notifications:
cloud-relink: Kunci API Anda tidak valid atau kedaluwarsa. Silakan hapus dan hubungkan akun Anda lagi.
cloud-plan: Paket
cloud-usage: Event periode ini
empty-state:
title: Mulai dengan notifikasi
description: Pilih cara Anda ingin menerima peringatan saat kontainer Anda membutuhkan perhatian.
cloud-link-success:
title: Dozzle Cloud Tertaut
message: Akun Anda telah berhasil ditautkan ke Dozzle Cloud.
message: Instans Anda telah berhasil terhubung. Anda dapat melepaskan tautan di pengaturan atau memeriksa penggunaan Anda kapan saja.
cloud:
title: Dozzle Cloud
description: Kendalikan kontainer Anda dari jarak jauh dan gunakan AI untuk menyelidiki masalah di seluruh kluster Anda.
learn-more: Pelajari lebih lanjut
link-instance: Tautkan instans
relink-instance: Tautkan ulang instans
connected: Terhubung
plan: Paket
usage: Event periode ini
dashboard: Dasbor
settings: Pengaturan
error: Kesalahan koneksi. Silakan tautkan ulang instans Anda.
unlink: Lepaskan tautan
unlink-confirm: Apakah Anda yakin ingin melepaskan tautan dari Dozzle Cloud? Ini akan menghapus semua tujuan notifikasi cloud.
+19 -2
View File
@@ -270,7 +270,7 @@ notifications:
close: Chiudi
save: Salva
add: Aggiungi Destinazione
cloud-exists: È possibile configurare solo una destinazione Dozzle Cloud
cloud-exists: Dozzle Cloud è già collegato
link-cloud: Collega Account
link-cloud-button: Collega Dozzle Cloud
cloud-settings-hint: Per configurare i tuoi canali gestiti, vai a
@@ -279,6 +279,23 @@ notifications:
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
empty-state:
title: Inizia con le notifiche
description: Scegli come vuoi ricevere avvisi quando i tuoi container richiedono attenzione.
cloud-link-success:
title: Dozzle Cloud Collegato
message: Il tuo account è stato collegato con successo a Dozzle Cloud.
message: La tua istanza è stata connessa con successo. Puoi scollegarla nelle impostazioni o controllare il tuo utilizzo in qualsiasi momento.
cloud:
title: Dozzle Cloud
description: Controlla i tuoi container da remoto e usa l'AI per investigare problemi nel tuo cluster.
learn-more: Scopri di più
link-instance: Collega istanza
relink-instance: Ricollega istanza
connected: Connesso
plan: Piano
usage: Eventi in questo periodo
dashboard: Dashboard
settings: Impostazioni
error: Errore di connessione. Ricollega la tua istanza.
unlink: Scollega
unlink-confirm: Sei sicuro di voler scollegare da Dozzle Cloud? Questo rimuoverà tutte le destinazioni di notifica cloud.
+19 -2
View File
@@ -273,7 +273,7 @@ notifications:
close: 닫기
save: 저장
add: 목적지 추가
cloud-exists: Dozzle Cloud 목적지는 하나만 설정할 수 있습니다
cloud-exists: Dozzle Cloud가 이미 연결되어 있습니다
link-cloud: 계정 연결
link-cloud-button: Dozzle Cloud 연결
cloud-settings-hint: 관리 채널을 설정하려면 다음으로 이동하세요
@@ -282,6 +282,23 @@ notifications:
cloud-relink: API 키가 유효하지 않거나 만료되었습니다. 삭제 후 계정을 다시 연결해 주세요.
cloud-plan: 플랜
cloud-usage: 이번 기간 이벤트
empty-state:
title: 알림 시작하기
description: 컨테이너에 문제가 생겼을 때 알림을 받을 방법을 선택하세요.
cloud-link-success:
title: Dozzle Cloud 연결됨
message: 계정이 Dozzle Cloud에 성공적으로 연결되었습니다.
message: 인스턴스가 성공적으로 연결되었습니다. 설정에서 연결을 해제하거나 사용량을 언제든지 확인할 수 있습니다.
cloud:
title: Dozzle Cloud
description: 원격으로 컨테이너를 제어하고 AI를 사용하여 클러스터 전반의 문제를 조사하세요.
learn-more: 자세히 알아보기
link-instance: 인스턴스 연결
relink-instance: 인스턴스 재연결
connected: 연결됨
plan: 플랜
usage: 이번 기간 이벤트
dashboard: 대시보드
settings: 설정
error: 연결 오류. 인스턴스를 다시 연결해 주세요.
unlink: 연결 해제
unlink-confirm: Dozzle Cloud 연결을 해제하시겠습니까? 모든 클라우드 알림 목적지가 삭제됩니다.
+19 -2
View File
@@ -271,7 +271,7 @@ notifications:
close: Sluiten
save: Opslaan
add: Bestemming toevoegen
cloud-exists: Er kan slechts één Dozzle Cloud-bestemming worden geconfigureerd
cloud-exists: Dozzle Cloud is al gekoppeld
link-cloud: Account koppelen
link-cloud-button: Dozzle Cloud koppelen
cloud-settings-hint: Om je beheerde kanalen te configureren, ga naar
@@ -280,6 +280,23 @@ notifications:
cloud-relink: Uw API-sleutel is ongeldig of verlopen. Verwijder en koppel uw account opnieuw.
cloud-plan: Plan
cloud-usage: Gebeurtenissen deze periode
empty-state:
title: Aan de slag met meldingen
description: Kies hoe je waarschuwingen wilt ontvangen wanneer je containers aandacht nodig hebben.
cloud-link-success:
title: Dozzle Cloud gekoppeld
message: Je account is succesvol gekoppeld aan Dozzle Cloud.
message: Je instantie is succesvol verbonden. Je kunt de koppeling ongedaan maken in de instellingen of je gebruik op elk moment bekijken.
cloud:
title: Dozzle Cloud
description: Beheer je containers op afstand en gebruik AI om problemen in je cluster te onderzoeken.
learn-more: Meer informatie
link-instance: Instantie koppelen
relink-instance: Instantie opnieuw koppelen
connected: Verbonden
plan: Plan
usage: Gebeurtenissen deze periode
dashboard: Dashboard
settings: Instellingen
error: Verbindingsfout. Koppel je instantie opnieuw.
unlink: Ontkoppelen
unlink-confirm: Weet je zeker dat je de koppeling met Dozzle Cloud wilt opheffen? Dit verwijdert alle cloudmeldingsbestemmingen.
+19 -2
View File
@@ -277,7 +277,7 @@ notifications:
close: Zamknij
save: Zapisz
add: Dodaj Miejsce Docelowe
cloud-exists: Można skonfigurować tylko jedno miejsce docelowe Dozzle Cloud
cloud-exists: Dozzle Cloud jest już połączony
link-cloud: Połącz Konto
link-cloud-button: Połącz Dozzle Cloud
cloud-settings-hint: Aby skonfigurować zarządzane kanały, przejdź do
@@ -286,6 +286,23 @@ notifications:
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
empty-state:
title: Zacznij korzystać z powiadomień
description: Wybierz, jak chcesz otrzymywać alerty, gdy Twoje kontenery wymagają uwagi.
cloud-link-success:
title: Dozzle Cloud Połączony
message: Twoje konto zostało pomyślnie połączone z Dozzle Cloud.
message: Twoja instancja została pomyślnie połączona. Możesz rozłączyć ją w ustawieniach lub sprawdzić swoje użycie w dowolnym momencie.
cloud:
title: Dozzle Cloud
description: Zarządzaj kontenerami zdalnie i wykorzystuj AI do badania problemów w całym klastrze.
learn-more: Dowiedz się więcej
link-instance: Połącz instancję
relink-instance: Połącz ponownie instancję
connected: Połączony
plan: Plan
usage: Zdarzenia w tym okresie
dashboard: Panel
settings: Ustawienia
error: Błąd połączenia. Połącz ponownie swoją instancję.
unlink: Rozłącz
unlink-confirm: Czy na pewno chcesz rozłączyć się z Dozzle Cloud? Spowoduje to usunięcie wszystkich miejsc docelowych powiadomień w chmurze.
+19 -2
View File
@@ -279,7 +279,7 @@ notifications:
close: Fechar
save: Guardar
add: Adicionar Destino
cloud-exists: Apenas um destino Dozzle Cloud pode ser configurado
cloud-exists: Dozzle Cloud já está ligado
link-cloud: Ligar Conta
link-cloud-button: Ligar Dozzle Cloud
cloud-settings-hint: Para configurar os seus canais geridos, vá a
@@ -288,6 +288,23 @@ notifications:
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
empty-state:
title: Comece com as notificações
description: Escolha como pretende receber alertas quando os seus contentores necessitam de atenção.
cloud-link-success:
title: Dozzle Cloud Ligado
message: A sua conta foi ligada com sucesso ao Dozzle Cloud.
message: A sua instância foi ligada com sucesso. Pode desligar nas definições ou verificar a sua utilização a qualquer momento.
cloud:
title: Dozzle Cloud
description: Controle os seus contentores remotamente e use IA para investigar problemas no seu cluster.
learn-more: Saber mais
link-instance: Ligar instância
relink-instance: Religar instância
connected: Ligado
plan: Plano
usage: Eventos neste período
dashboard: Painel
settings: Definições
error: Erro de ligação. Por favor, religue a sua instância.
unlink: Desligar
unlink-confirm: Tem a certeza de que pretende desligar do Dozzle Cloud? Isto irá remover todos os destinos de notificação na nuvem.
+19 -2
View File
@@ -269,7 +269,7 @@ notifications:
close: Fechar
save: Salvar
add: Adicionar Destino
cloud-exists: Apenas um destino Dozzle Cloud pode ser configurado
cloud-exists: Dozzle Cloud já está vinculado
link-cloud: Vincular Conta
link-cloud-button: Vincular Dozzle Cloud
cloud-settings-hint: Para configurar seus canais gerenciados, vá para
@@ -278,6 +278,23 @@ notifications:
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
empty-state:
title: Comece com as notificações
description: Escolha como você deseja receber alertas quando seus containers precisarem de atenção.
cloud-link-success:
title: Dozzle Cloud Vinculado
message: Sua conta foi vinculada com sucesso ao Dozzle Cloud.
message: Sua instância foi conectada com sucesso. Você pode desvincular nas configurações ou verificar seu uso a qualquer momento.
cloud:
title: Dozzle Cloud
description: Controle seus containers remotamente e use IA para investigar problemas em todo o seu cluster.
learn-more: Saiba mais
link-instance: Vincular instância
relink-instance: Revincular instância
connected: Conectado
plan: Plano
usage: Eventos neste período
dashboard: Painel
settings: Configurações
error: Erro de conexão. Por favor, revincule sua instância.
unlink: Desvincular
unlink-confirm: Tem certeza de que deseja desvincular do Dozzle Cloud? Isso removerá todos os destinos de notificação na nuvem.
+19 -2
View File
@@ -270,7 +270,7 @@ notifications:
close: Закрыть
save: Сохранить
add: Добавить Назначение
cloud-exists: Можно настроить только одно назначение Dozzle Cloud
cloud-exists: Dozzle Cloud уже привязан
link-cloud: Связать Аккаунт
link-cloud-button: Связать Dozzle Cloud
cloud-settings-hint: Чтобы настроить управляемые каналы, перейдите в
@@ -279,6 +279,23 @@ notifications:
cloud-relink: Ваш API-ключ недействителен или истёк. Пожалуйста, удалите и привяжите аккаунт заново.
cloud-plan: План
cloud-usage: События за этот период
empty-state:
title: Начните с уведомлений
description: Выберите, как вы хотите получать оповещения, когда ваши контейнеры требуют внимания.
cloud-link-success:
title: Dozzle Cloud Связан
message: Ваш аккаунт успешно связан с Dozzle Cloud.
message: Ваш экземпляр успешно подключён. Вы можете отвязать его в настройках или проверить использование в любое время.
cloud:
title: Dozzle Cloud
description: Управляйте контейнерами удалённо и используйте ИИ для расследования проблем в вашем кластере.
learn-more: Узнать больше
link-instance: Привязать экземпляр
relink-instance: Привязать повторно
connected: Подключён
plan: План
usage: События за этот период
dashboard: Панель управления
settings: Настройки
error: Ошибка подключения. Пожалуйста, привяжите экземпляр повторно.
unlink: Отвязать
unlink-confirm: Вы уверены, что хотите отвязать от Dozzle Cloud? Это удалит все облачные назначения уведомлений.
+19 -2
View File
@@ -275,7 +275,7 @@ notifications:
close: Zapri
save: Shrani
add: Dodaj Cilj
cloud-exists: Nastavljen je lahko samo en cilj Dozzle Cloud
cloud-exists: Dozzle Cloud je že povezan
link-cloud: Poveži Račun
link-cloud-button: Poveži Dozzle Cloud
cloud-settings-hint: Za nastavitev upravljanih kanalov pojdite na
@@ -284,6 +284,23 @@ notifications:
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
empty-state:
title: Začnite z obvestili
description: Izberite, kako želite prejemati opozorila, ko vaši zabojniki potrebujejo pozornost.
cloud-link-success:
title: Dozzle Cloud Povezan
message: Vaš račun je bil uspešno povezan z Dozzle Cloud.
message: Vaša instanca je bila uspešno povezana. Povezavo lahko prekinete v nastavitvah ali kadar koli preverite svojo uporabo.
cloud:
title: Dozzle Cloud
description: Upravljajte svoje zabojnike na daljavo in uporabite UI za preiskovanje težav v vašem grozdu.
learn-more: Več o tem
link-instance: Poveži instanco
relink-instance: Ponovno poveži instanco
connected: Povezano
plan: Načrt
usage: Dogodki v tem obdobju
dashboard: Nadzorna plošča
settings: Nastavitve
error: Napaka povezave. Prosimo, ponovno povežite svojo instanco.
unlink: Prekini povezavo
unlink-confirm: Ali ste prepričani, da želite prekiniti povezavo z Dozzle Cloud? To bo odstranilo vse cilje obvestil v oblaku.
+19 -2
View File
@@ -270,7 +270,7 @@ notifications:
close: Kapat
save: Kaydet
add: Hedef Ekle
cloud-exists: Yalnızca bir Dozzle Cloud hedefi yapılandırılabilir
cloud-exists: Dozzle Cloud zaten bağlı
link-cloud: Hesabı Bağla
link-cloud-button: Dozzle Cloud'u Bağla
cloud-settings-hint: Yönetilen kanallarınızı yapılandırmak için şuraya gidin
@@ -279,6 +279,23 @@ notifications:
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
empty-state:
title: Bildirimlerle başlayın
description: Konteynerleriniz dikkat gerektirdiğinde nasıl uyarı almak istediğinizi seçin.
cloud-link-success:
title: Dozzle Cloud Bağlandı
message: Hesabınız Dozzle Cloud'a başarıyla bağlandı.
message: Örneğiniz başarıyla bağlandı. Ayarlardan bağlantıyı kaldırabilir veya kullanımınızı istediğiniz zaman kontrol edebilirsiniz.
cloud:
title: Dozzle Cloud
description: Konteynerlerinizi uzaktan kontrol edin ve kümenizde sorunları araştırmak için yapay zeka kullanın.
learn-more: Daha fazla bilgi
link-instance: Örneği bağla
relink-instance: Örneği yeniden bağla
connected: Bağlı
plan: Plan
usage: Bu dönemdeki olaylar
dashboard: Pano
settings: Ayarlar
error: Bağlantı hatası. Lütfen örneğinizi yeniden bağlayın.
unlink: Bağlantıyı kaldır
unlink-confirm: Dozzle Cloud bağlantısını kaldırmak istediğinizden emin misiniz? Bu, tüm bulut bildirim hedeflerini kaldıracaktır.
+19 -2
View File
@@ -273,7 +273,7 @@ notifications:
close: 關閉
save: 儲存
add: 新增目標
cloud-exists: 只能設定一個 Dozzle Cloud 目標
cloud-exists: Dozzle Cloud 已連結
link-cloud: 連結帳戶
link-cloud-button: 連結 Dozzle Cloud
cloud-settings-hint: 若要設定您的管理頻道,請前往
@@ -282,6 +282,23 @@ notifications:
cloud-relink: 您的 API 金鑰無效或已過期。請刪除並重新關聯您的帳戶。
cloud-plan: 方案
cloud-usage: 本期事件數
empty-state:
title: 開始使用通知
description: 選擇當您的容器需要關注時,您希望如何接收警報。
cloud-link-success:
title: Dozzle Cloud 已連結
message: 您的帳戶已成功連結至 Dozzle Cloud
message: 您的實例已成功連線。您可以在設定中取消連結,或隨時查看使用量
cloud:
title: Dozzle Cloud
description: 遠端控制您的容器,並使用 AI 調查叢集中的問題。
learn-more: 瞭解更多
link-instance: 連結實例
relink-instance: 重新連結實例
connected: 已連線
plan: 方案
usage: 本期事件數
dashboard: 儀表板
settings: 設定
error: 連線錯誤。請重新連結您的實例。
unlink: 取消連結
unlink-confirm: 確定要取消與 Dozzle Cloud 的連結嗎?這將移除所有雲端通知目標。
+19 -2
View File
@@ -270,7 +270,7 @@ notifications:
close: 关闭
save: 保存
add: 添加目标
cloud-exists: 只能配置一个 Dozzle Cloud 目标
cloud-exists: Dozzle Cloud 已关联
link-cloud: 关联账户
link-cloud-button: 关联 Dozzle Cloud
cloud-settings-hint: 要配置您的托管频道,请前往
@@ -279,6 +279,23 @@ notifications:
cloud-relink: 您的 API 密钥无效或已过期。请删除并重新关联您的账户。
cloud-plan: 计划
cloud-usage: 本期事件数
empty-state:
title: 开始使用通知
description: 选择当您的容器需要关注时,您希望如何接收警报。
cloud-link-success:
title: Dozzle Cloud 已关联
message: 您的账户已成功关联到 Dozzle Cloud
message: 您的实例已成功连接。您可以在设置中取消关联,或随时查看使用情况
cloud:
title: Dozzle Cloud
description: 远程控制您的容器,并使用 AI 调查集群中的问题。
learn-more: 了解更多
link-instance: 关联实例
relink-instance: 重新关联实例
connected: 已连接
plan: 计划
usage: 本期事件数
dashboard: 仪表盘
settings: 设置
error: 连接错误。请重新关联您的实例。
unlink: 取消关联
unlink-confirm: 确定要取消与 Dozzle Cloud 的关联吗?这将移除所有云通知目标。
+2 -4
View File
@@ -126,10 +126,8 @@ func main() {
// Create cloud tool client — does nothing until Notify() is called
apiKeyFunc := func() string {
for _, d := range hostService.Dispatchers() {
if d.Type == "cloud" && d.APIKey != "" {
return d.APIKey
}
if cc := hostService.CloudConfig(); cc != nil {
return cc.APIKey
}
return ""
}
+7
View File
@@ -22,6 +22,7 @@ service AgentService {
rpc ContainerExec(stream ContainerExecRequest) returns (stream ContainerExecResponse) {}
rpc ContainerAttach(stream ContainerAttachRequest) returns (stream ContainerAttachResponse) {}
rpc UpdateNotificationConfig(UpdateNotificationConfigRequest) returns (UpdateNotificationConfigResponse) {}
rpc UpdateCloudConfig(UpdateCloudConfigRequest) returns (UpdateCloudConfigResponse) {}
rpc GetNotificationStats(GetNotificationStatsRequest) returns (GetNotificationStatsResponse) {}
}
@@ -150,6 +151,12 @@ message UpdateNotificationConfigRequest {
message UpdateNotificationConfigResponse {}
message UpdateCloudConfigRequest {
NotificationCloudConfig cloudConfig = 1;
}
message UpdateCloudConfigResponse {}
message GetNotificationStatsRequest {}
message GetNotificationStatsResponse {
+8 -3
View File
@@ -119,9 +119,14 @@ message NotificationDispatcher {
string url = 4;
string template = 5;
map<string, string> headers = 6;
string apiKey = 7;
string prefix = 8;
google.protobuf.Timestamp expiresAt = 9;
// Fields 7-9 removed (cloud fields moved to NotificationCloudConfig)
reserved 7, 8, 9;
}
message NotificationCloudConfig {
string apiKey = 1;
string prefix = 2;
google.protobuf.Timestamp expiresAt = 3;
}
message NotificationSubscriptionStats {
+12 -8
View File
@@ -83,15 +83,19 @@ type SubscriptionStats struct {
TriggeredContainerIDs []string `json:"triggeredContainerIds"`
}
// DispatcherConfig represents a notification dispatcher configuration
type DispatcherConfig struct {
ID int
Name string
Type string
URL string
Template string
Headers map[string]string
// CloudConfig holds the cloud API key and metadata for broadcasting to agents.
type CloudConfig struct {
APIKey string
Prefix string
ExpiresAt *time.Time
}
// DispatcherConfig represents a dispatcher configuration
type DispatcherConfig struct {
ID int
Name string
Type string
URL string
Template string
Headers map[string]string
}