feat(notifications): rebuild the alert and destination workflows (#5020)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Amir Raminfar
2026-09-06 01:04:14 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent c5fbdf404b
commit 8e8f9db446
46 changed files with 3545 additions and 859 deletions
+6
View File
@@ -14,6 +14,12 @@ Format:
- Include file:line references when relevant
- Maximum ~10-15 lines per response
## Translations
`locales/en.yml` is the source of truth, but it is never the only file to change. Whenever you add, rename, or remove a key in `en.yml`, apply the same change to **every** other file in `locales/` in the same commit, with the string actually translated (not the English text copied over). Keys are also deleted everywhere when they lose their last consumer.
There is no Crowdin or Weblate sync in this repo, so a key that only exists in `en.yml` silently falls back to English for every other locale and nothing ever flags it.
## Testing Unreleased PRs
When replying to a GitHub issue or discussion where the fix lives in an open PR, ask the reporter to test the pre-built image: `amir20/dozzle:pr-XXX` (XXX = PR number). CI builds a tagged image per PR, so reporters can verify without waiting for the next release.
+11 -3
View File
@@ -14,6 +14,7 @@ declare global {
const K8sNamespace: typeof import('./stores/k8s').K8sNamespace
const K8sOwner: typeof import('./stores/k8s').K8sOwner
const MIN_MENU_WIDTH: typeof import('./stores/settings').MIN_MENU_WIDTH
const TEMPLATE_VARIABLES: typeof import('./composable/templateEditor').TEMPLATE_VARIABLES
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const activePopup: typeof import('./composable/popup').activePopup
const allLevels: typeof import('./composable/logContext').allLevels
@@ -62,6 +63,7 @@ declare global {
const dismissedImageUpdates: typeof import('./composable/storage').dismissedImageUpdates
const dismissedLinkHint: typeof import('./composable/storage').dismissedLinkHint
const drawerContext: typeof import('./composable/drawer').drawerContext
const drawerGuardContext: typeof import('./composable/drawer').drawerGuardContext
const eagerComputed: typeof import('@vueuse/core').eagerComputed
const effectScope: typeof import('vue').effectScope
const escapeHtml: typeof import('./utils/index').escapeHtml
@@ -139,6 +141,7 @@ declare global {
const ownerMembershipLabel: typeof import('./stores/k8s').ownerMembershipLabel
const parseMessage: typeof import('./composable/loadBetween').parseMessage
const pausableWatch: typeof import('@vueuse/core').pausableWatch
const payloadMode: typeof import('./composable/templateEditor').payloadMode
const persistentVisibleKeysForContainer: typeof import('./composable/storage').persistentVisibleKeysForContainer
const pinnedContainers: typeof import('./composable/storage').pinnedContainers
const provide: typeof import('vue').provide
@@ -265,6 +268,7 @@ declare global {
const useDownloadUrl: typeof import('./composable/downloadUrl').useDownloadUrl
const useDraggable: typeof import('@vueuse/core').useDraggable
const useDrawer: typeof import('./composable/drawer').useDrawer
const useDrawerCloseGuard: typeof import('./composable/drawer').useDrawerCloseGuard
const useDropZone: typeof import('@vueuse/core').useDropZone
const useDuckDB: typeof import('./composable/duckdb').useDuckDB
const useElementBounding: typeof import('@vueuse/core').useElementBounding
@@ -441,7 +445,7 @@ declare global {
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
// @ts-ignore
export type { AlertFormOptions, ContainerResult } from './composable/alertForm'
export type { AlertType, AlertPrefill, AlertFormOptions, ContainerResult, SaveBlocker } from './composable/alertForm'
import('./composable/alertForm')
// @ts-ignore
export type { CloudAlert, CloudEvent } from './composable/cloudAlerts'
@@ -453,7 +457,7 @@ declare global {
export type { CommandSection, Command } from './composable/commands'
import('./composable/commands')
// @ts-ignore
export type { DrawerWidth } from './composable/drawer'
export type { DrawerWidth, DrawerCloseGuard } from './composable/drawer'
import('./composable/drawer')
// @ts-ignore
export type { SearchStatus, LogStreamSource } from './composable/eventStreams'
@@ -465,7 +469,7 @@ declare global {
export type { ImageUpdateStatus, ImageUpdateResult } from './composable/imageUpdate'
import('./composable/imageUpdate')
// @ts-ignore
export type { TemplateEditorOptions } from './composable/templateEditor'
export type { TemplateEditorOptions, TemplateVariable, PayloadMode } from './composable/templateEditor'
import('./composable/templateEditor')
// @ts-ignore
export type { Config, Profile } from './stores/config'
@@ -490,6 +494,7 @@ declare module 'vue' {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly K8sNamespace: UnwrapRef<typeof import('./stores/k8s')['K8sNamespace']>
readonly K8sOwner: UnwrapRef<typeof import('./stores/k8s')['K8sOwner']>
readonly TEMPLATE_VARIABLES: UnwrapRef<typeof import('./composable/templateEditor')['TEMPLATE_VARIABLES']>
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
readonly activePopup: UnwrapRef<typeof import('./composable/popup')['activePopup']>
readonly allLevels: UnwrapRef<typeof import('./composable/logContext')['allLevels']>
@@ -538,6 +543,7 @@ declare module 'vue' {
readonly dismissedImageUpdates: UnwrapRef<typeof import('./composable/storage')['dismissedImageUpdates']>
readonly dismissedLinkHint: UnwrapRef<typeof import('./composable/storage')['dismissedLinkHint']>
readonly drawerContext: UnwrapRef<typeof import('./composable/drawer')['drawerContext']>
readonly drawerGuardContext: UnwrapRef<typeof import('./composable/drawer')['drawerGuardContext']>
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly escapeHtml: UnwrapRef<typeof import('./utils/index')['escapeHtml']>
@@ -613,6 +619,7 @@ declare module 'vue' {
readonly ownerMembershipLabel: UnwrapRef<typeof import('./stores/k8s')['ownerMembershipLabel']>
readonly parseMessage: UnwrapRef<typeof import('./composable/loadBetween')['parseMessage']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly payloadMode: UnwrapRef<typeof import('./composable/templateEditor')['payloadMode']>
readonly persistentVisibleKeysForContainer: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeysForContainer']>
readonly pinnedContainers: UnwrapRef<typeof import('./composable/storage')['pinnedContainers']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
@@ -736,6 +743,7 @@ declare module 'vue' {
readonly useDownloadUrl: UnwrapRef<typeof import('./composable/downloadUrl')['useDownloadUrl']>
readonly useDraggable: UnwrapRef<typeof import('@vueuse/core')['useDraggable']>
readonly useDrawer: UnwrapRef<typeof import('./composable/drawer')['useDrawer']>
readonly useDrawerCloseGuard: UnwrapRef<typeof import('./composable/drawer')['useDrawerCloseGuard']>
readonly useDropZone: UnwrapRef<typeof import('@vueuse/core')['useDropZone']>
readonly useDuckDB: UnwrapRef<typeof import('./composable/duckdb')['useDuckDB']>
readonly useElementBounding: UnwrapRef<typeof import('@vueuse/core')['useElementBounding']>
+6 -1
View File
@@ -55,15 +55,17 @@ declare module 'vue' {
ContainerStatCell: typeof import('./components/ContainerStatCell.vue')['default']
ContainerTable: typeof import('./components/ContainerTable.vue')['default']
ContainerTitle: typeof import('./components/ContainerViewer/ContainerTitle.vue')['default']
CooldownField: typeof import('./components/Notification/CooldownField.vue')['default']
DateTime: typeof import('./components/common/DateTime.vue')['default']
DestinationCard: typeof import('./components/Notification/DestinationCard.vue')['default']
DestinationForm: typeof import('./components/Notification/DestinationForm.vue')['default']
DiscoveryHint: typeof import('./components/common/DiscoveryHint.vue')['default']
Dropdown: typeof import('./components/common/Dropdown.vue')['default']
DropdownMenu: typeof import('./components/common/DropdownMenu.vue')['default']
DurationField: typeof import('./components/Notification/DurationField.vue')['default']
EventAlertFields: typeof import('./components/Notification/EventAlertFields.vue')['default']
EventSource: typeof import('./components/LogViewer/EventSource.vue')['default']
ExpressionField: typeof import('./components/Notification/ExpressionField.vue')['default']
FormStepHeading: typeof import('./components/Notification/FormStepHeading.vue')['default']
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.vue')['default']
GroupedLogItem: typeof import('./components/LogViewer/GroupedLogItem.vue')['default']
@@ -148,6 +150,7 @@ declare module 'vue' {
'Mdi:hamburgerMenu': typeof import('~icons/mdi/hamburger-menu')['default']
'Mdi:heart': typeof import('~icons/mdi/heart')['default']
'Mdi:hexagonMultiple': typeof import('~icons/mdi/hexagon-multiple')['default']
'Mdi:informationOutline': typeof import('~icons/mdi/information-outline')['default']
'Mdi:key': typeof import('~icons/mdi/key')['default']
'Mdi:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default']
'Mdi:lightbulbOnOutline': typeof import('~icons/mdi/lightbulb-on-outline')['default']
@@ -156,6 +159,7 @@ declare module 'vue' {
'Mdi:linkVariantOff': typeof import('~icons/mdi/link-variant-off')['default']
'Mdi:linkVariantPlus': typeof import('~icons/mdi/link-variant-plus')['default']
'Mdi:magnify': typeof import('~icons/mdi/magnify')['default']
'Mdi:minus': typeof import('~icons/mdi/minus')['default']
'Mdi:openInNew': typeof import('~icons/mdi/open-in-new')['default']
'Mdi:pencilOutline': typeof import('~icons/mdi/pencil-outline')['default']
'Mdi:plus': typeof import('~icons/mdi/plus')['default']
@@ -221,6 +225,7 @@ declare module 'vue' {
'SvgSpinners:ringResize': typeof import('~icons/svg-spinners/ring-resize')['default']
SwarmMenu: typeof import('./components/SwarmMenu.vue')['default']
Tag: typeof import('./components/common/Tag.vue')['default']
TemplateVariables: typeof import('./components/Notification/TemplateVariables.vue')['default']
Terminal: typeof import('./components/Terminal.vue')['default']
TimedButton: typeof import('./components/common/TimedButton.vue')['default']
ToastModal: typeof import('./components/common/ToastModal.vue')['default']
+326 -182
View File
@@ -1,233 +1,377 @@
<template>
<div class="space-y-4 p-4">
<div class="mb-6">
<h2 class="text-2xl font-bold">
{{ isEditing ? $t("notifications.alert-form.edit-title") : $t("notifications.alert-form.create-title") }}
</h2>
<p class="text-base-content/60">{{ $t("notifications.alert-form.description") }}</p>
</div>
<!-- Alert Name -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.alert-name") }}</legend>
<input
ref="alertNameInput"
v-model="alertName"
type="text"
class="input focus:input-primary w-full text-base"
:class="alertName.trim() ? 'input-primary' : ''"
required
:placeholder="$t('notifications.alert-form.alert-name-placeholder')"
/>
</fieldset>
<!-- Alert Type Toggle -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.alert-type") }}</legend>
<div class="flex gap-2">
<button
class="btn btn-sm"
:class="alertType === 'log' ? 'btn-primary' : 'btn-outline'"
@click="alertType = 'log'"
>
<mdi:text-box-outline class="mr-1" />
{{ $t("notifications.alert-form.log-alert") }}
</button>
<button
class="btn btn-sm"
:class="alertType === 'metric' ? 'btn-primary' : 'btn-outline'"
@click="alertType = 'metric'"
>
<mdi:chart-line class="mr-1" />
{{ $t("notifications.alert-form.metric-alert") }}
</button>
<button
class="btn btn-sm"
:class="alertType === 'event' ? 'btn-primary' : 'btn-outline'"
@click="alertType = 'event'"
>
<mdi:bell-ring-outline class="mr-1" />
{{ $t("notifications.alert-form.event-alert") }}
</button>
<div class="flex min-h-full flex-col">
<div class="space-y-6 p-4 pb-8">
<div>
<h2 class="text-2xl font-bold">
{{ isEditing ? $t("notifications.alert-form.edit-title") : $t("notifications.alert-form.create-title") }}
</h2>
<p class="text-base-content/60">{{ $t(`notifications.alert-form.description-${alertType}`) }}</p>
</div>
</fieldset>
<!-- Container Filter -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.container-filter") }}</legend>
<div
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
:class="
containerExpression.trim() && !containerResult?.error
? 'input-primary'
: { 'input-error!': containerResult?.error }
"
>
<div ref="containerEditorRef" class="w-full"></div>
</div>
<div v-if="containerResult" class="fieldset-label">
<span v-if="containerResult.error" class="text-error">{{ containerResult.error }}</span>
<span v-else-if="containerResult.containers?.length" class="text-success">
<mdi:check class="inline" />
{{
$t("notifications.alert-form.containers-match", {
count: containerResult.containers.length,
names: containerResult.containers.map((c) => c.name).join(", "),
})
}}
</span>
<span v-else class="text-warning">
<mdi:alert class="inline" />
{{ $t("notifications.alert-form.no-containers-match") }}
</span>
</div>
</fieldset>
<!-- 1. Alert type -->
<section>
<FormStepHeading :step="1" :title="$t('notifications.alert-form.alert-type')" />
<div class="grid gap-2 sm:grid-cols-3">
<button
v-for="option in alertTypes"
:key="option.type"
type="button"
class="card cursor-pointer border text-left transition-colors"
:class="
alertType === option.type
? 'border-primary bg-primary/10 ring-primary/40 ring-1'
: 'border-base-content/15 hover:border-base-content/35 hover:bg-base-content/5'
"
:aria-pressed="alertType === option.type"
@click="alertType = option.type"
>
<div class="card-body gap-1 p-3">
<div class="flex items-center gap-2 font-semibold">
<component
:is="option.icon"
:class="alertType === option.type ? 'text-primary' : 'text-base-content/50'"
/>
{{ $t(`notifications.alert-form.${option.type}-alert`) }}
</div>
<div class="text-base-content/60 text-xs">
{{ $t(`notifications.alert-form.${option.type}-alert-description`) }}
</div>
</div>
</button>
</div>
<div v-if="typeChanged" class="alert alert-warning mt-2 py-2 text-sm">
<mdi:alert-outline />
<span>{{ $t("notifications.alert-form.type-switch-warning", { type: originalTypeLabel }) }}</span>
</div>
</section>
<!-- Type-specific fields -->
<KeepAlive>
<LogAlertFields
v-if="alertType === 'log'"
ref="fieldsRef"
:alert="alert"
:prefill="prefill"
:container-expression="containerExpression"
:is-loading="isLoading"
:validate-preview="validatePreview"
/>
<MetricAlertFields
v-else-if="alertType === 'metric'"
ref="fieldsRef"
:alert="alert"
:prefill="prefill"
:container-expression="containerExpression"
:is-loading="isLoading"
:validate-preview="validatePreview"
/>
<EventAlertFields
v-else
ref="fieldsRef"
:alert="alert"
:prefill="prefill"
:container-expression="containerExpression"
:is-loading="isLoading"
:validate-preview="validatePreview"
/>
</KeepAlive>
<!-- Destination -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.destination") }}</legend>
<details class="dropdown w-full" ref="destinationDropdown">
<summary class="btn btn-outline w-full justify-between" :class="{ 'btn-primary': selectedDestination }">
<span class="flex items-center gap-2">
<template v-if="selectedDestination">
<mdi:webhook v-if="selectedDestination.type === 'webhook'" />
<mdi:cloud v-else />
{{ selectedDestination.name }}
</template>
<span v-else class="text-base-content/60">{{ $t("notifications.alert-form.select-destination") }}</span>
<!-- 2. Containers -->
<section>
<FormStepHeading :step="2" :title="$t('notifications.alert-form.container-filter')" required />
<ExpressionField
v-model="containerExpression"
:hint="$t('notifications.alert-form.container-filter-hint')"
placeholder='name contains "api"'
:error="containerResult?.error"
:examples="containerExamples"
:get-hints="containerHints"
/>
<div class="mt-1 text-sm">
<span v-if="isLoading && !containerResult" class="text-base-content/60">
<span class="loading loading-spinner loading-xs align-middle"></span>
{{ $t("notifications.alert-form.checking") }}
</span>
<carbon:caret-down />
</summary>
<ul class="dropdown-content menu bg-base-200 rounded-box z-50 mt-1 w-full border p-2 shadow-sm">
<li v-for="dest in destinations" :key="dest.id">
<a
@click="
dispatcherId = dest.id;
destinationDropdown?.removeAttribute('open');
"
:class="{ active: dispatcherId === dest.id }"
>
<mdi:webhook v-if="dest.type === 'webhook'" />
<mdi:cloud v-else />
{{ dest.name }}
</a>
</li>
</ul>
</details>
<div v-if="!destinations.length" class="fieldset-label">
<span class="text-warning">
<mdi:alert class="inline" />
{{ $t("notifications.alert-form.no-destinations") }}
</span>
</div>
</fieldset>
<template v-else-if="containerResult && !containerResult.error">
<span v-if="containerResult.containers?.length" class="text-success">
<mdi:check class="inline" />
{{
$t("notifications.alert-form.containers-match", {
count: containerResult.containers.length,
names: matchedNames,
})
}}
</span>
<span v-else class="text-warning">
<mdi:alert class="inline" />
{{ $t("notifications.alert-form.no-containers-match") }}
</span>
</template>
</div>
</section>
<!-- Error -->
<div v-if="saveError" class="alert alert-error">
<span>{{ saveError }}</span>
<!-- 3. Condition -->
<section>
<FormStepHeading :step="3" :title="$t(`notifications.alert-form.${alertType}-filter`)" required />
<KeepAlive>
<LogAlertFields
v-if="alertType === 'log'"
ref="fieldsRef"
:alert="alert"
:prefill="prefill"
:container-expression="containerExpression"
:has-containers="hasContainers"
:is-loading="isLoading"
:validate-preview="validatePreview"
/>
<MetricAlertFields
v-else-if="alertType === 'metric'"
ref="fieldsRef"
:alert="alert"
:prefill="prefill"
:container-expression="containerExpression"
:has-containers="hasContainers"
:is-loading="isLoading"
:validate-preview="validatePreview"
/>
<EventAlertFields
v-else
ref="fieldsRef"
:alert="alert"
:prefill="prefill"
:container-expression="containerExpression"
:has-containers="hasContainers"
:is-loading="isLoading"
:validate-preview="validatePreview"
/>
</KeepAlive>
</section>
<!-- 4. Destination -->
<section>
<FormStepHeading :step="4" :title="$t('notifications.alert-form.destination')" required />
<div v-if="destinations.length" class="grid gap-2 sm:grid-cols-2">
<button
v-for="dest in destinations"
:key="dest.id"
type="button"
class="card cursor-pointer border text-left transition-colors"
:class="
dispatcherId === dest.id
? 'border-primary bg-primary/10 ring-primary/40 ring-1'
: 'border-base-content/15 hover:border-base-content/35 hover:bg-base-content/5'
"
:aria-pressed="dispatcherId === dest.id"
@click="dispatcherId = dest.id"
>
<div class="card-body flex-row items-center gap-3 p-3">
<mdi:webhook
v-if="dest.type === 'webhook'"
class="shrink-0 text-lg"
:class="dispatcherId === dest.id ? 'text-primary' : 'text-base-content/50'"
/>
<mdi:cloud
v-else
class="shrink-0 text-lg"
:class="dispatcherId === dest.id ? 'text-primary' : 'text-base-content/50'"
/>
<div class="min-w-0">
<div class="truncate font-semibold">{{ dest.name }}</div>
<div class="text-base-content/60 text-xs">
{{
dest.type === "webhook"
? $t("notifications.destination.http-webhook")
: $t("notifications.destination.dozzle-cloud")
}}
</div>
</div>
<mdi:check v-if="dispatcherId === dest.id" class="text-primary ml-auto shrink-0" />
</div>
</button>
</div>
<div v-else class="text-base-content/60 text-sm">{{ $t("notifications.alert-form.no-destinations") }}</div>
<button type="button" class="btn btn-ghost btn-sm mt-2 gap-1" @click="addDestination">
<mdi:plus />
{{ $t("notifications.add-destination") }}
</button>
</section>
<!-- 5. Name -->
<section>
<FormStepHeading :step="5" :title="$t('notifications.alert-form.alert-name')" required />
<input
v-model="alertName"
type="text"
class="input focus:input-primary w-full text-base"
:class="alertName.trim() ? 'input-primary' : ''"
required
:placeholder="suggestedName || $t('notifications.alert-form.alert-name-placeholder')"
@input="nameTouched = true"
/>
<p class="text-base-content/60 mt-1 text-xs">{{ $t("notifications.alert-form.alert-name-hint") }}</p>
</section>
</div>
<!-- Actions -->
<div class="flex justify-end gap-2 pt-4">
<button class="btn" @click="close?.()">{{ $t("notifications.alert-form.cancel") }}</button>
<button class="btn btn-primary" :disabled="!canSave" @click="save">
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
{{ isEditing ? $t("notifications.alert-form.save") : $t("notifications.alert-form.create") }}
</button>
<!-- Actions stay reachable in a form this tall -->
<div class="bg-base-100 border-base-content/10 sticky bottom-0 z-10 mt-auto border-t p-4">
<div v-if="saveError" class="alert alert-error mb-3 py-2 text-sm">
<span>{{ saveError }}</span>
</div>
<div v-if="confirmingDiscard" class="flex flex-wrap items-center justify-end gap-2">
<span class="mr-auto text-sm">{{ $t("notifications.alert-form.discard-title") }}</span>
<button class="btn btn-sm" @click="confirmingDiscard = false">
{{ $t("notifications.alert-form.discard-keep") }}
</button>
<button class="btn btn-sm btn-error" @click="discard">
{{ $t("notifications.alert-form.discard-confirm") }}
</button>
</div>
<div v-else class="flex flex-wrap items-center justify-end gap-2">
<span v-if="blockers.length" class="text-base-content/70 mr-auto text-sm">
{{ $t("notifications.alert-form.still-needed", { fields: blockerLabels }) }}
</span>
<button class="btn" @click="close?.()">{{ $t("notifications.alert-form.cancel") }}</button>
<button class="btn btn-primary" :disabled="!canSave" @click="save">
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
{{ isEditing ? $t("notifications.alert-form.save") : $t("notifications.alert-form.create") }}
</button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { useAlertForm } from "@/composable/alertForm";
import { useAlertForm, type AlertType, type AlertPrefill, type SaveBlocker } from "@/composable/alertForm";
import LogAlertFields from "./LogAlertFields.vue";
import MetricAlertFields from "./MetricAlertFields.vue";
import EventAlertFields from "./EventAlertFields.vue";
import type { NotificationRule } from "@/types/notifications";
import DestinationForm from "./DestinationForm.vue";
import FormStepHeading from "./FormStepHeading.vue";
import ExpressionField from "./ExpressionField.vue";
import type { Dispatcher, NotificationRule } from "@/types/notifications";
import TextBoxIcon from "~icons/mdi/text-box-outline";
import ChartLineIcon from "~icons/mdi/chart-line";
import BellRingIcon from "~icons/mdi/bell-ring-outline";
const props = defineProps<{
close?: () => void;
onCreated?: () => void;
alert?: NotificationRule;
prefill?: {
name?: string;
containerExpression?: string;
logExpression?: string;
metricExpression?: string;
eventExpression?: string;
dispatcherId?: number;
};
prefill?: AlertPrefill;
}>();
const { t } = useI18n();
const showDrawer = useDrawer();
// Captured in setup so addDestination can hand the drawer back to this same component.
const self = getCurrentInstance()!.type;
const {
isEditing,
alertName,
containerExpression,
dispatcherId,
destinations,
selectedDestination,
containerResult,
containerHints,
isLoading,
isSaving,
saveError,
baseCanSave,
setupContainerEditor,
saveAlert,
validatePreview,
} = useAlertForm(props);
// Template refs
const alertNameInput = ref<HTMLInputElement>();
const containerEditorRef = ref<HTMLElement>();
const destinationDropdown = ref<HTMLDetailsElement>();
const alertTypes = [
{ type: "log" as const, icon: TextBoxIcon },
{ type: "metric" as const, icon: ChartLineIcon },
{ type: "event" as const, icon: BellRingIcon },
];
const containerExamples = ['name contains "api"', 'image startsWith "postgres"', 'labels["env"] == "prod"'];
function typeOf(alert?: NotificationRule): AlertType {
if (alert?.metricExpression) return "metric";
if (alert?.eventExpression) return "event";
return "log";
}
const originalType = typeOf(props.alert);
const alertType = ref<AlertType>(props.alert ? originalType : (props.prefill?.alertType ?? "log"));
// Saving writes only the active type's expression, so switching type on an existing alert
// throws the other one away. Say so rather than letting it happen silently.
const typeChanged = computed(() => isEditing.value && alertType.value !== originalType);
const originalTypeLabel = computed(() => t(`notifications.alert-form.${originalType}-alert`));
const fieldsRef = ref<
InstanceType<typeof LogAlertFields> | InstanceType<typeof MetricAlertFields> | InstanceType<typeof EventAlertFields>
>();
useFocus(alertNameInput, { initialValue: true });
// Alert type
const alertType = ref<"log" | "metric" | "event">(
props.alert?.metricExpression ? "metric" : props.alert?.eventExpression ? "event" : "log",
);
const matchedContainers = computed(() => containerResult.value?.containers ?? []);
const hasContainers = computed(() => matchedContainers.value.length > 0);
const matchedNames = computed(() => {
const names = matchedContainers.value.map((c) => c.name);
return names.length > 3 ? `${names.slice(0, 3).join(", ")}…` : names.join(", ");
});
// Name comes last, so offer one derived from what the user just built.
const nameTouched = ref(!!(props.alert?.name || props.prefill?.name));
const suggestedName = computed(() => {
const type = t(`notifications.alert-form.${alertType.value}-alert`);
if (matchedNames.value) return `${type} · ${matchedNames.value}`;
if (containerExpression.value.trim()) return `${type} · ${containerExpression.value.trim()}`;
return "";
});
watch(suggestedName, (name) => {
if (!nameTouched.value) alertName.value = name;
});
const blockers = computed<SaveBlocker[]>(() => {
const missing: SaveBlocker[] = [];
if (!containerExpression.value.trim() || containerResult.value?.error) missing.push("container-expression");
if (!fieldsRef.value?.canSave) missing.push("condition");
if (dispatcherId.value < 0) missing.push("destination");
if (!alertName.value.trim()) missing.push("name");
return missing;
});
const blockerLabels = computed(() => blockers.value.map((b) => t(`notifications.alert-form.missing.${b}`)).join(", "));
const canSave = computed(() => baseCanSave.value && (fieldsRef.value?.canSave ?? false));
async function save() {
if (!canSave.value || !fieldsRef.value) return;
registerGuard(null);
await saveAlert(fieldsRef.value.typeFields);
}
// Container editor
setupContainerEditor(containerEditorRef);
// Unsaved-changes guard. Esc and the backdrop are one keystroke away from throwing away a
// half-built expression, so confirm in the footer instead of closing.
const registerGuard = useDrawerCloseGuard();
const confirmingDiscard = ref(false);
const initial = ref<string>();
const snapshot = computed(() =>
JSON.stringify({
alertType: alertType.value,
alertName: alertName.value,
containerExpression: containerExpression.value,
dispatcherId: dispatcherId.value,
typeFields: fieldsRef.value?.typeFields,
}),
);
onMounted(async () => {
await nextTick();
initial.value = snapshot.value;
});
const isDirty = computed(() => initial.value !== undefined && initial.value !== snapshot.value);
registerGuard(() => {
if (!isDirty.value || isSaving.value) return true;
confirmingDiscard.value = true;
return false;
});
function discard() {
registerGuard(null);
props.close?.();
}
// The drawer holds one component at a time, so adding a destination means handing it over
// and coming back with everything the user has typed so far.
function addDestination() {
const carried: AlertPrefill = {
name: nameTouched.value ? alertName.value : undefined,
alertType: alertType.value,
containerExpression: containerExpression.value,
...(fieldsRef.value?.typeFields as Partial<AlertPrefill>),
};
registerGuard(null);
showDrawer(
DestinationForm,
{
onCreated: (created?: Dispatcher) => {
showDrawer(
self,
{
alert: props.alert,
onCreated: props.onCreated,
prefill: { ...carried, dispatcherId: created?.id ?? dispatcherId.value },
},
"lg",
);
},
},
"md",
);
}
</script>
@@ -96,7 +96,7 @@
<!-- Actions -->
<div class="flex items-center gap-2 pt-4">
<div class="flex-1"></div>
<button class="btn btn-primary" @click="close?.()">
<button class="btn" @click="close?.()">
{{ $t("notifications.destination-form.close") }}
</button>
</div>
@@ -1,16 +0,0 @@
<template>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.cooldown-label") }}</legend>
<input v-model.number="model" type="range" min="0" max="3600" step="10" class="range range-primary" />
<p class="text-base-content/50 mt-1 text-xs">
<template v-if="model === 0">{{ $t("notifications.alert-form.no-cooldown") }}</template>
<template v-else>{{
$t("notifications.alert-form.cooldown-hint", { duration: formatDuration(model, locale || undefined) })
}}</template>
</p>
</fieldset>
</template>
<script lang="ts" setup>
const model = defineModel<number>({ required: true });
</script>
@@ -1,25 +1,39 @@
<template>
<div class="card bg-base-100 hover:border-primary cursor-pointer border border-transparent" @click="editDestination">
<div class="card bg-base-100 border border-transparent" :class="{ 'hover:border-primary': !confirmingDelete }">
<div class="card-body gap-2 p-4">
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg">
<mdi:webhook v-if="destination.type === 'webhook'" class="text-lg" />
<mdi:cloud v-else class="text-primary-content text-lg" />
</div>
<div class="flex-1">
<h4 class="font-semibold">{{ destination.name }}</h4>
<p class="text-base-content/60 text-sm">
{{
destination.type === "webhook"
? $t("notifications.destination.http-webhook")
: $t("notifications.destination.dozzle-cloud")
}}
</p>
</div>
<button
type="button"
class="flex flex-1 cursor-pointer items-start gap-3 text-left"
:aria-label="$t('notifications.destination.edit')"
@click="editDestination"
>
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
<mdi:webhook v-if="destination.type === 'webhook'" class="text-lg" />
<mdi:cloud v-else class="text-primary text-lg" />
</div>
<div class="min-w-0 flex-1">
<h4 class="truncate font-semibold">{{ destination.name }}</h4>
<p class="text-base-content/60 text-sm">
{{
destination.type === "webhook"
? $t("notifications.destination.http-webhook")
: $t("notifications.destination.dozzle-cloud")
}}
</p>
<p class="text-base-content/50 mt-1 text-xs">
{{
usedByCount
? $t("notifications.destination.used-by", { count: usedByCount })
: $t("notifications.destination.unused")
}}
</p>
</div>
</button>
<div class="dropdown dropdown-end" @click.stop>
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-square">
<ion:ellipsis-vertical />
</label>
</div>
<ul
tabindex="0"
class="menu dropdown-content rounded-box bg-base-100 border-base-content/20 z-50 w-40 border p-1 shadow-sm"
@@ -31,11 +45,30 @@
<a @click="duplicateDestination">{{ $t("notifications.destination.duplicate") }}</a>
</li>
<li v-if="destination.type !== 'cloud'">
<a class="text-error" @click="deleteDestination">{{ $t("notifications.destination.delete") }}</a>
<a class="text-error" @click="confirmingDelete = true">{{ $t("notifications.destination.delete") }}</a>
</li>
</ul>
</div>
</div>
<!-- Deleting orphans every alert pointing here, so name the cost before doing it. -->
<div v-if="confirmingDelete" class="alert alert-warning flex-wrap py-2 text-sm">
<mdi:alert-outline />
<span class="flex-1">
{{
usedByCount
? $t("notifications.destination.delete-warning-used", { count: usedByCount })
: $t("notifications.destination.delete-warning")
}}
</span>
<button class="btn btn-xs" :disabled="isDeleting" @click="confirmingDelete = false">
{{ $t("notifications.destination.delete-cancel") }}
</button>
<button class="btn btn-xs btn-error" :disabled="isDeleting" @click="deleteDestination">
<span v-if="isDeleting" class="loading loading-spinner loading-xs"></span>
{{ $t("notifications.destination.delete-confirm") }}
</button>
</div>
</div>
</div>
</template>
@@ -44,14 +77,21 @@
import type { Dispatcher } from "@/types/notifications";
import DestinationForm from "./DestinationForm.vue";
const { destination, onUpdated, existingDispatchers } = defineProps<{
const { destination, onUpdated, existingDispatchers, usedByCount } = defineProps<{
destination: Dispatcher;
onUpdated?: () => void;
existingDispatchers: Dispatcher[];
/** How many alerts send here. Drives the delete warning. */
usedByCount: number;
}>();
const { t } = useI18n();
const { showToast } = useToast();
const showDrawer = useDrawer();
const confirmingDelete = ref(false);
const isDeleting = ref(false);
function editDestination() {
showDrawer(
DestinationForm,
@@ -64,23 +104,51 @@ function editDestination() {
);
}
/** Appends a counter so duplicating twice doesn't produce two identically named copies. */
function copyName() {
const taken = new Set(existingDispatchers.map((d) => d.name));
const base = t("notifications.destination.copy-of", { name: destination.name });
if (!taken.has(base)) return base;
for (let i = 2; ; i++) {
const candidate = `${base} ${i}`;
if (!taken.has(candidate)) return candidate;
}
}
async function duplicateDestination() {
await fetch(withBase("/api/notifications/dispatchers"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: `Copy of ${destination.name}`,
type: destination.type,
url: destination.url,
template: destination.template,
headers: destination.headers,
}),
});
onUpdated?.();
try {
const res = await fetch(withBase("/api/notifications/dispatchers"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: copyName(),
type: destination.type,
url: destination.url,
template: destination.template,
headers: destination.headers,
}),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText);
onUpdated?.();
} catch (e) {
showToast({ type: "error", message: e instanceof Error ? e.message : t("notifications.destination.copy-failed") });
}
}
async function deleteDestination() {
await fetch(withBase(`/api/notifications/dispatchers/${destination.id}`), { method: "DELETE" });
onUpdated?.();
isDeleting.value = true;
try {
const res = await fetch(withBase(`/api/notifications/dispatchers/${destination.id}`), { method: "DELETE" });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText);
confirmingDelete.value = false;
onUpdated?.();
} catch (e) {
showToast({
type: "error",
message: e instanceof Error ? e.message : t("notifications.destination.delete-failed"),
});
} finally {
isDeleting.value = false;
}
}
</script>
@@ -1,5 +1,5 @@
<template>
<div class="space-y-4 p-4">
<div class="flex min-h-full flex-col space-y-4 p-4">
<div class="mb-6">
<h2 class="text-2xl font-bold">
<template v-if="type === 'cloud'">
@@ -69,6 +69,7 @@
<WebhookDestinationForm
v-if="type === 'webhook'"
:destination="destination"
:existing-dispatchers="existingDispatchers"
:close="close"
:on-created="onCreated"
:is-editing="isEditing"
@@ -82,10 +83,12 @@ import type { Dispatcher } from "@/types/notifications";
import WebhookDestinationForm from "./WebhookDestinationForm.vue";
import CloudDestinationForm from "./CloudDestinationForm.vue";
const { close, onCreated, destination } = defineProps<{
const { close, onCreated, destination, existingDispatchers } = defineProps<{
close?: () => void;
onCreated?: () => void;
onCreated?: (created?: Dispatcher) => void;
destination?: Dispatcher;
/** Used to warn when a name is already taken; the picker in the alert form goes by name. */
existingDispatchers?: Dispatcher[];
}>();
const isEditing = !!destination;
@@ -0,0 +1,62 @@
<template>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ label }}</legend>
<div class="flex items-center gap-3">
<input v-model.number="slider" type="range" :min="min" :max="max" :step="step" class="range range-primary" />
<label class="input input-sm w-28 shrink-0">
<input
v-model.number="typed"
type="number"
:min="min"
:max="max"
:step="step"
class="text-right"
:aria-label="label"
@blur="commitTyped"
@keydown.enter.prevent="commitTyped"
/>
<span class="label">{{ $t("notifications.alert-form.seconds-unit") }}</span>
</label>
</div>
<p class="text-base-content/60 mt-1 text-xs">
<template v-if="model === 0 && zeroHint">{{ zeroHint }}</template>
<template v-else>{{ hint(formatDuration(model, locale || undefined)) }}</template>
</p>
</fieldset>
</template>
<script lang="ts" setup>
const { min, max, step } = defineProps<{
label: string;
/** Renders the description for the current value, e.g. "5m between alerts". */
hint: (duration: string) => string;
/** Shown instead of `hint` when the value is 0, for fields where 0 means "off". */
zeroHint?: string;
min: number;
max: number;
step: number;
}>();
const model = defineModel<number>({ required: true });
// The slider snaps to `step`; the number input lets you land on an exact value the
// slider can't reach. Both write through to the same model.
const slider = computed({
get: () => model.value,
set: (v) => (model.value = clamp(v)),
});
const typed = ref(model.value);
watch(model, (v) => (typed.value = v));
function clamp(value: number) {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, Math.round(value)));
}
function commitTyped() {
const next = clamp(typed.value);
typed.value = next;
model.value = next;
}
</script>
@@ -1,46 +1,96 @@
<template>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.event-filter") }}</legend>
<div
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
:class="eventExpression.trim() && !eventError ? 'input-primary' : { 'input-error!': eventError }"
>
<div ref="editorRef" class="w-full"></div>
</div>
<div v-if="eventError || eventExpression" class="fieldset-label">
<span v-if="eventError" class="text-error">{{ eventError }}</span>
<span v-else class="text-success">
<mdi:check class="inline" />
{{ $t("notifications.alert-form.expression-valid") }}
<ExpressionField
v-model="eventExpression"
:hint="$t('notifications.alert-form.event-filter-hint')"
placeholder='name == "die"'
:error="eventError"
:examples="examples"
:get-hints="getHints"
/>
<DurationField
v-model="cooldown"
class="mt-4"
:label="$t('notifications.alert-form.cooldown-label')"
:hint="(duration: string) => $t('notifications.alert-form.cooldown-hint', { duration })"
:zero-hint="$t('notifications.alert-form.no-cooldown')"
:min="0"
:max="3600"
:step="10"
/>
<!-- Preview: Docker events aren't retained, so show which representative events match -->
<div class="mt-4">
<div class="mb-2 flex items-center justify-between gap-2">
<span class="text-base-content/70 text-sm font-semibold">{{ $t("notifications.alert-form.preview") }}</span>
<span v-if="isLoading" class="loading loading-spinner loading-xs"></span>
<span v-else-if="samples.length" class="text-sm" :class="matchCount ? 'text-success' : 'text-warning'">
<mdi:check v-if="matchCount" class="inline" />
<mdi:alert v-else class="inline" />
{{
matchCount
? $t("notifications.alert-form.event-matches", { count: matchCount })
: $t("notifications.alert-form.event-matches-none")
}}
</span>
</div>
<p class="text-base-content/50 mt-1 text-xs">
{{
$t("notifications.alert-form.event-fields-hint", {
fields: "name (start, stop, die, restart, health_status), attributes (healthStatus, exitCode, signal, etc.)",
})
}}
</p>
</fieldset>
<CooldownField v-model="cooldown" />
<div v-if="samples.length" class="border-base-content/20 divide-base-content/10 divide-y rounded-lg border">
<div
v-for="(sample, index) in samples"
:key="index"
class="flex items-center gap-2 px-3 py-1.5 text-sm"
:class="sample.matches ? 'bg-success/10' : 'opacity-50'"
>
<mdi:check v-if="sample.matches" class="text-success shrink-0" />
<mdi:minus v-else class="text-base-content/40 shrink-0" />
<code class="font-mono">{{ sample.name }}</code>
<span v-for="(value, key) in sample.attributes" :key="key" class="badge badge-sm badge-ghost font-mono">
{{ key }}={{ value }}
</span>
</div>
</div>
<div
v-else
class="border-base-content/15 bg-base-content/[0.03] text-base-content/60 flex h-24 items-center justify-center rounded-lg border border-dashed px-4 text-center text-sm"
>
{{ emptyStateMessage }}
</div>
<p v-if="samples.length" class="text-base-content/50 mt-1 text-xs">
{{ $t("notifications.alert-form.event-preview-note") }}
</p>
</div>
</template>
<script lang="ts" setup>
import { createEventHints } from "@/composable/exprEditor";
import type { NotificationRule, PreviewResult } from "@/types/notifications";
import ExpressionField from "./ExpressionField.vue";
import DurationField from "./DurationField.vue";
import type { EventSample, NotificationRule, PreviewResult } from "@/types/notifications";
const props = defineProps<{
alert?: NotificationRule;
prefill?: { eventExpression?: string };
prefill?: { eventExpression?: string; cooldown?: number };
containerExpression: string;
hasContainers: boolean;
isLoading: boolean;
validatePreview: (extra: Record<string, unknown>) => Promise<{ data: PreviewResult | null }>;
}>();
const { t } = useI18n();
const eventExpression = ref(props.alert?.eventExpression ?? props.prefill?.eventExpression ?? "");
const eventError = ref<string | null>(null);
const cooldown = ref(props.alert?.cooldown ?? 10);
const cooldown = ref(props.alert?.cooldown ?? props.prefill?.cooldown ?? 10);
const samples = shallowRef<EventSample[]>([]);
const examples = [
'name == "die"',
'name in ["stop", "die"]',
'name == "health_status" && attributes["healthStatus"] == "unhealthy"',
];
const getHints = () => createEventHints();
const canSave = computed(() => !!eventExpression.value.trim() && !eventError.value);
const typeFields = computed(() => ({
@@ -53,10 +103,19 @@ const typeFields = computed(() => ({
defineExpose({ canSave, typeFields });
const matchCount = computed(() => samples.value.filter((s) => s.matches).length);
const emptyStateMessage = computed(() => {
if (!eventExpression.value.trim()) return t("notifications.alert-form.event-preview-hint");
if (eventError.value) return t("notifications.alert-form.preview-fix-expression");
return t("notifications.alert-form.event-preview-hint");
});
// Validation
async function validate() {
if (!props.containerExpression && !eventExpression.value) {
eventError.value = null;
samples.value = [];
return;
}
@@ -65,7 +124,8 @@ async function validate() {
});
if (data) {
eventError.value = data.eventError ?? null;
eventError.value = data.containerError ? null : (data.eventError ?? null);
samples.value = data.eventError || data.containerError ? [] : (data.eventSamples ?? []);
}
}
@@ -77,13 +137,4 @@ watch(
},
{ immediate: true },
);
// Editor
const editorRef = ref<HTMLElement>();
useExprEditorField(editorRef, {
placeholder: 'name == "die"',
initialValue: props.alert?.eventExpression ?? props.prefill?.eventExpression ?? "",
getHints: () => createEventHints(),
onChange: (v) => (eventExpression.value = v),
});
</script>
@@ -0,0 +1,53 @@
<template>
<div>
<p v-if="hint" class="text-base-content/60 mb-1.5 text-sm">{{ hint }}</p>
<div
class="input focus-within:input-primary h-auto w-full py-1 focus-within:z-50"
:class="modelValue.trim() && !error ? 'input-primary' : { 'input-error!': error }"
>
<div ref="editorRef" class="w-full"></div>
</div>
<p v-if="error" class="text-error mt-1 font-mono text-xs">{{ error }}</p>
<!-- Examples double as documentation: clicking one fills the editor. -->
<div v-if="examples.length" class="mt-2 flex flex-wrap items-center gap-1.5">
<span class="text-base-content/50 text-xs">{{ $t("notifications.alert-form.examples") }}</span>
<button
v-for="example in examples"
:key="example"
type="button"
class="border-base-content/15 bg-base-content/5 text-base-content/70 hover:border-primary hover:text-primary cursor-pointer rounded-md border px-2 py-0.5 font-mono text-xs transition-colors"
:title="$t('notifications.alert-form.use-example')"
@click="setValue(example)"
>
{{ example }}
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Completion } from "@codemirror/autocomplete";
const props = defineProps<{
hint?: string;
placeholder: string;
error?: string | null;
examples?: string[];
getHints: () => Completion[];
}>();
const examples = computed(() => props.examples ?? []);
const modelValue = defineModel<string>({ required: true });
const editorRef = ref<HTMLElement>();
const { setValue } = useExprEditorField(editorRef, {
placeholder: props.placeholder,
// Read once: the editor owns its content after mount, and re-seeding it on every model
// change would fight the user's cursor.
initialValue: modelValue.value,
getHints: () => props.getHints(),
onChange: (v) => (modelValue.value = v),
});
</script>
@@ -0,0 +1,19 @@
<template>
<div class="mb-2 flex items-center gap-2">
<span
class="bg-base-content/10 text-base-content/60 flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-semibold"
aria-hidden="true"
>
{{ step }}
</span>
<h3 class="text-base-content/70 text-sm font-semibold tracking-wide uppercase">
{{ title }}
<!-- Muted on purpose: a column of red asterisks reads as a column of errors. -->
<span v-if="required" class="text-base-content/35" :title="$t('notifications.alert-form.required')">*</span>
</h3>
</div>
</template>
<script lang="ts" setup>
defineProps<{ step: number; title: string; required?: boolean }>();
</script>
@@ -1,60 +1,96 @@
<template>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.log-filter") }}</legend>
<div
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
:class="logExpression.trim() && !logError ? 'input-primary' : { 'input-error!': logError }"
>
<div ref="editorRef" class="w-full"></div>
</div>
<div v-if="logError || logExpression" class="fieldset-label">
<span v-if="logError" class="text-error">{{ logError }}</span>
<span v-else-if="logMessages.length" class="text-success">
<mdi:check class="inline" />
{{ $t("notifications.alert-form.logs-match", { count: logTotalCount }) }}
</span>
<span v-else-if="!props.isLoading" class="text-warning">
<mdi:alert class="inline" />
{{ $t("notifications.alert-form.no-logs-match") }}
<ExpressionField
v-model="logExpression"
:hint="$t('notifications.alert-form.log-filter-hint')"
placeholder='level == "error" && message contains "timeout"'
:error="logError"
:examples="examples"
:get-hints="getHints"
/>
<!-- Preview -->
<div class="mt-4">
<div class="mb-2 flex items-center justify-between gap-2">
<span class="text-base-content/70 text-sm font-semibold">{{ $t("notifications.alert-form.preview") }}</span>
<span v-if="isLoading" class="loading loading-spinner loading-xs"></span>
<span v-else-if="!logError && logExpression.trim() && hasContainers" class="text-sm">
<span v-if="logMessages.length" class="text-success">
<mdi:check class="inline" />
{{ $t("notifications.alert-form.logs-match", { count: logTotalCount, window: windowLabel }) }}
</span>
<span v-else class="text-warning">
<mdi:alert class="inline" />
{{ $t("notifications.alert-form.no-logs-match", { window: windowLabel }) }}
</span>
</span>
</div>
</fieldset>
<!-- Log Preview -->
<div v-if="logMessages.length" class="mt-4">
<div class="mb-2 text-lg">{{ $t("notifications.alert-form.preview") }}</div>
<LogList
v-if="logMessages.length"
:messages="logMessages"
:last-selected-item="undefined"
class="border-base-content/50 h-64 overflow-hidden rounded-lg border"
class="border-base-content/20 bg-base-200/40 h-64 overflow-hidden rounded-lg border"
/>
<div
v-else
class="border-base-content/15 bg-base-content/[0.03] text-base-content/60 flex h-24 items-center justify-center rounded-lg border border-dashed px-4 text-center text-sm"
>
{{ emptyStateMessage }}
</div>
<p v-if="truncated" class="text-base-content/50 mt-1 text-xs">
{{ $t("notifications.alert-form.log-scan-note", { count: scannedContainers }) }}
</p>
</div>
</template>
<script lang="ts" setup>
import { type LogEvent, type LogEntry, type LogMessage, asLogEntry } from "@/models/LogEntry";
import { createLogHints } from "@/composable/exprEditor";
import ExpressionField from "./ExpressionField.vue";
import type { NotificationRule, PreviewResult } from "@/types/notifications";
const props = defineProps<{
alert?: NotificationRule;
prefill?: { logExpression?: string };
containerExpression: string;
hasContainers: boolean;
isLoading: boolean;
validatePreview: (extra: Record<string, unknown>) => Promise<{ data: PreviewResult | null }>;
}>();
const { t } = useI18n();
const logExpression = ref(props.alert?.logExpression ?? props.prefill?.logExpression ?? "");
const logError = ref<string | null>(null);
const logTotalCount = ref(0);
const logMessages = shallowRef<LogEntry<LogMessage>[]>([]);
const messageKeys = ref<string[]>([]);
const logWindowSeconds = ref(7200);
const scannedContainers = ref(0);
const matchedContainerCount = ref(0);
const canSave = computed(() => !logError.value);
const examples = ['level == "error"', 'message contains "timeout"', 'stream == "stderr"'];
const getHints = () => createLogHints(messageKeys.value);
// An empty expression is not "match everything" — the backend treats a rule without a log
// expression as inert — so it is a required field, not an optional filter.
const canSave = computed(() => !!logExpression.value.trim() && !logError.value);
const typeFields = computed(() => ({ logExpression: logExpression.value, metricExpression: "", cooldown: 0 }));
defineExpose({ canSave, typeFields });
const windowLabel = computed(() => formatDuration(logWindowSeconds.value, locale.value || undefined));
const truncated = computed(() => scannedContainers.value > 0 && matchedContainerCount.value > scannedContainers.value);
const emptyStateMessage = computed(() => {
if (!logExpression.value.trim()) return t("notifications.alert-form.log-preview-hint");
if (!props.containerExpression.trim() || !props.hasContainers)
return t("notifications.alert-form.preview-needs-containers");
if (logError.value) return t("notifications.alert-form.preview-fix-expression");
return t("notifications.alert-form.no-logs-match", { window: windowLabel.value });
});
// Validation
async function validate() {
if (!props.containerExpression && !logExpression.value) {
@@ -71,6 +107,9 @@ async function validate() {
if (data) {
messageKeys.value = data.messageKeys ?? [];
logWindowSeconds.value = data.logWindowSeconds || logWindowSeconds.value;
scannedContainers.value = data.scannedContainers ?? 0;
matchedContainerCount.value = data.matchedContainers?.length ?? 0;
if (logExpression.value && !data.containerError) {
logError.value = data.logError ?? null;
logTotalCount.value = data.totalLogs;
@@ -91,13 +130,4 @@ watch(
},
{ immediate: true },
);
// Editor
const editorRef = ref<HTMLElement>();
useExprEditorField(editorRef, {
placeholder: 'level == "error" && message contains "timeout"',
initialValue: props.alert?.logExpression ?? props.prefill?.logExpression ?? "",
getHints: () => createLogHints(messageKeys.value),
onChange: (v) => (logExpression.value = v),
});
</script>
@@ -1,59 +1,126 @@
<template>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.metric-filter") }}</legend>
<div
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
:class="metricExpression.trim() && !metricError ? 'input-primary' : { 'input-error!': metricError }"
>
<div ref="editorRef" class="w-full"></div>
</div>
<div v-if="metricError || metricExpression" class="fieldset-label">
<span v-if="metricError" class="text-error">{{ metricError }}</span>
<span v-else class="text-success">
<mdi:check class="inline" />
{{ $t("notifications.alert-form.expression-valid") }}
<ExpressionField
v-model="metricExpression"
:hint="$t('notifications.alert-form.metric-filter-hint')"
placeholder="cpu > 80 || memory > 90"
:error="metricError"
:examples="examples"
:get-hints="getHints"
/>
<DurationField
v-model="sampleWindow"
class="mt-4"
:label="$t('notifications.alert-form.sample-window-label')"
:hint="(duration: string) => $t('notifications.alert-form.sample-window-hint', { duration })"
:min="15"
:max="300"
:step="15"
/>
<DurationField
v-model="cooldown"
:label="$t('notifications.alert-form.cooldown-label')"
:hint="(duration: string) => $t('notifications.alert-form.cooldown-hint', { duration })"
:zero-hint="$t('notifications.alert-form.no-cooldown')"
:min="0"
:max="3600"
:step="10"
/>
<!-- Preview: the expression replayed over the stats Dozzle already has buffered -->
<div class="mt-4">
<div class="mb-2 flex items-center justify-between gap-2">
<span class="text-base-content/70 text-sm font-semibold">{{ $t("notifications.alert-form.preview") }}</span>
<span v-if="isLoading" class="loading loading-spinner loading-xs"></span>
<span v-else-if="samples.length" class="text-sm" :class="triggerCount ? 'text-warning' : 'text-success'">
<mdi:alert v-if="triggerCount" class="inline" />
<mdi:check v-else class="inline" />
{{
triggerCount
? $t("notifications.alert-form.metric-would-fire", { count: triggerCount })
: $t("notifications.alert-form.metric-would-not-fire")
}}
</span>
</div>
<p class="text-base-content/50 mt-1 text-xs">
{{
$t("notifications.alert-form.metric-fields-hint", {
fields: "cpu (CPU %), memory (memory %), memoryUsage (bytes)",
})
}}
</p>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.sample-window-label") }}</legend>
<input v-model.number="sampleWindow" type="range" min="15" max="300" step="15" class="range range-primary" />
<p class="text-base-content/50 mt-1 text-xs">
{{
$t("notifications.alert-form.sample-window-hint", {
duration: formatDuration(sampleWindow, locale || undefined),
})
}}
</p>
</fieldset>
<div v-if="samples.length" class="border-base-content/20 overflow-x-auto rounded-lg border">
<table class="table-sm table">
<thead>
<tr>
<th>{{ $t("notifications.alert-form.metric-container") }}</th>
<th class="text-right">{{ $t("notifications.alert-form.metric-cpu") }}</th>
<th class="text-right">{{ $t("notifications.alert-form.metric-memory") }}</th>
<th class="text-right">{{ $t("notifications.alert-form.metric-window-match") }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="sample in samples" :key="sample.containerId">
<td class="max-w-40 truncate font-medium">{{ sample.name }}</td>
<td class="text-right font-mono" :class="{ 'text-warning': sample.matches }">
{{ sample.totalSamples ? `${sample.cpu.toFixed(1)}%` : "—" }}
</td>
<td class="text-right font-mono" :class="{ 'text-warning': sample.matches }">
{{ sample.totalSamples ? `${sample.memory.toFixed(1)}%` : "—" }}
</td>
<td class="text-base-content/70 text-right font-mono">
<template v-if="sample.totalSamples">{{ sample.matchedSamples }}/{{ sample.totalSamples }}</template>
<template v-else>—</template>
</td>
<td class="text-right">
<span v-if="sample.wouldTrigger" class="badge badge-sm badge-warning">
{{ $t("notifications.alert-form.metric-firing") }}
</span>
<span v-else-if="!sample.totalSamples" class="text-base-content/50 text-xs">
{{ $t("notifications.alert-form.metric-no-samples") }}
</span>
<span v-else-if="sample.matches" class="badge badge-sm badge-ghost">
{{ $t("notifications.alert-form.metric-matching-now") }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-else
class="border-base-content/15 bg-base-content/[0.03] text-base-content/60 flex h-24 items-center justify-center rounded-lg border border-dashed px-4 text-center text-sm"
>
{{ emptyStateMessage }}
</div>
<CooldownField v-model="cooldown" />
<p v-if="samples.length" class="text-base-content/50 mt-1 text-xs">
{{ $t("notifications.alert-form.metric-preview-note", { duration: windowLabel }) }}
</p>
</div>
</template>
<script lang="ts" setup>
import { createMetricHints } from "@/composable/exprEditor";
import type { NotificationRule, PreviewResult } from "@/types/notifications";
import ExpressionField from "./ExpressionField.vue";
import DurationField from "./DurationField.vue";
import type { MetricSample, NotificationRule, PreviewResult } from "@/types/notifications";
const props = defineProps<{
alert?: NotificationRule;
prefill?: { metricExpression?: string };
prefill?: { metricExpression?: string; cooldown?: number; sampleWindow?: number };
containerExpression: string;
hasContainers: boolean;
isLoading: boolean;
validatePreview: (extra: Record<string, unknown>) => Promise<{ data: PreviewResult | null }>;
}>();
const { t } = useI18n();
const metricExpression = ref(props.alert?.metricExpression ?? props.prefill?.metricExpression ?? "");
const metricError = ref<string | null>(null);
const sampleWindow = ref(props.alert?.sampleWindow ?? 15);
const cooldown = ref(props.alert?.cooldown ?? 300);
const sampleWindow = ref(props.alert?.sampleWindow ?? props.prefill?.sampleWindow ?? 15);
const cooldown = ref(props.alert?.cooldown ?? props.prefill?.cooldown ?? 300);
const samples = shallowRef<MetricSample[]>([]);
const examples = ["cpu > 80", "memory > 90", "any(mounts, .usedPercent >= 85)"];
const getHints = () => createMetricHints();
const canSave = computed(() => !!metricExpression.value.trim() && !metricError.value);
const typeFields = computed(() => ({
@@ -65,37 +132,42 @@ const typeFields = computed(() => ({
defineExpose({ canSave, typeFields });
const triggerCount = computed(() => samples.value.filter((s) => s.wouldTrigger).length);
const windowLabel = computed(() => formatDuration(sampleWindow.value, locale.value || undefined));
const emptyStateMessage = computed(() => {
if (!metricExpression.value.trim()) return t("notifications.alert-form.metric-preview-hint");
if (!props.containerExpression.trim() || !props.hasContainers)
return t("notifications.alert-form.preview-needs-containers");
if (metricError.value) return t("notifications.alert-form.preview-fix-expression");
return t("notifications.alert-form.metric-no-samples-hint");
});
// Validation
async function validate() {
if (!props.containerExpression && !metricExpression.value) {
metricError.value = null;
samples.value = [];
return;
}
const { data } = await props.validatePreview({
metricExpression: metricExpression.value || undefined,
sampleWindow: sampleWindow.value,
});
if (data) {
metricError.value = data.metricError ?? null;
metricError.value = data.containerError ? null : (data.metricError ?? null);
samples.value = data.metricError || data.containerError ? [] : (data.metricSamples ?? []);
}
}
const debouncedValidate = useDebounceFn(validate, 500);
watch(
[() => props.containerExpression, metricExpression],
[() => props.containerExpression, metricExpression, sampleWindow],
() => {
debouncedValidate();
},
{ immediate: true },
);
// Editor
const editorRef = ref<HTMLElement>();
useExprEditorField(editorRef, {
placeholder: "cpu > 80 || memory > 90",
initialValue: props.alert?.metricExpression ?? props.prefill?.metricExpression ?? "",
getHints: () => createMetricHints(),
onChange: (v) => (metricExpression.value = v),
});
</script>
@@ -0,0 +1,44 @@
<template>
<div class="mt-2">
<button type="button" class="btn btn-ghost btn-xs gap-1" @click="expanded = !expanded">
<mdi:chevron-right class="transition-transform" :class="{ 'rotate-90': expanded }" />
{{ $t("notifications.destination-form.variables") }}
</button>
<div v-if="expanded" class="border-base-content/15 mt-2 space-y-3 rounded-lg border p-3">
<div v-for="group in groups" :key="group.scope">
<div class="text-base-content/60 mb-1 text-xs">
{{ $t(`notifications.destination-form.variables-${group.scope}`) }}
</div>
<div class="flex flex-wrap gap-1.5">
<button
v-for="variable in group.variables"
:key="variable.path"
type="button"
class="border-base-content/15 bg-base-content/5 text-base-content/70 hover:border-primary hover:text-primary cursor-pointer rounded-md border px-2 py-0.5 font-mono text-xs transition-colors"
:title="variable.detail"
@click="emit('insert', `{{ ${variable.path} }}`)"
>
{{ variable.path }}
</button>
</div>
</div>
<p class="text-base-content/50 text-xs">{{ $t("notifications.destination-form.variables-scope-hint") }}</p>
</div>
</div>
</template>
<script lang="ts" setup>
import { TEMPLATE_VARIABLES, type TemplateVariable } from "@/composable/templateEditor";
const emit = defineEmits<{ insert: [snippet: string] }>();
const expanded = ref(false);
const groups = computed(() => {
const scopes: TemplateVariable["scope"][] = ["all", "log", "metric", "event"];
return scopes
.map((scope) => ({ scope, variables: TEMPLATE_VARIABLES.filter((v) => v.scope === scope) }))
.filter((g) => g.variables.length);
});
</script>
@@ -1,166 +1,243 @@
<template>
<div class="space-y-4">
<!-- Name -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.name") }}</legend>
<input
ref="nameInput"
v-model="name"
type="text"
class="input focus:input-primary w-full text-base"
required
:class="{ 'input-primary': name.trim().length > 0 }"
:placeholder="$t('notifications.destination-form.name-placeholder')"
/>
</fieldset>
<div class="flex min-h-full flex-1 flex-col">
<div class="space-y-6 pb-8">
<!-- 1. Name -->
<section>
<FormStepHeading :step="1" :title="$t('notifications.destination-form.name')" required />
<input
ref="nameInput"
v-model="name"
type="text"
class="input focus:input-primary w-full text-base"
required
:class="{ 'input-primary': name.trim().length > 0, 'input-warning': isDuplicateName }"
:placeholder="$t('notifications.destination-form.name-placeholder')"
/>
<p v-if="isDuplicateName" class="text-warning mt-1 text-xs">
<mdi:alert class="inline" />
{{ $t("notifications.destination-form.name-taken") }}
</p>
<p v-else class="text-base-content/60 mt-1 text-xs">{{ $t("notifications.destination-form.name-hint") }}</p>
</section>
<!-- Webhook URL -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.webhook-url") }}</legend>
<input
v-model="webhookUrl"
type="url"
class="input focus:input-primary w-full text-base"
:class="{ 'input-primary': isValidUrl, 'input-error': webhookUrl.trim() && !isValidUrl }"
:placeholder="$t('notifications.destination-form.webhook-url-placeholder')"
/>
</fieldset>
<!-- 2. Webhook URL -->
<section>
<FormStepHeading :step="2" :title="$t('notifications.destination-form.webhook-url')" required />
<input
v-model="webhookUrl"
type="url"
class="input focus:input-primary w-full text-base"
:class="{ 'input-primary': isValidUrl, 'input-error': webhookUrl.trim() && !isValidUrl }"
:placeholder="$t('notifications.destination-form.webhook-url-placeholder')"
/>
<p v-if="webhookUrl.trim() && !isValidUrl" class="text-error mt-1 text-xs">
{{ $t("notifications.destination-form.webhook-url-invalid") }}
</p>
</section>
<!-- Payload Format (create mode only) -->
<fieldset v-if="!isEditing" class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.payload-format") }}</legend>
<div class="flex flex-wrap gap-2">
<button
v-for="format in ['slack', 'discord', 'ntfy', 'custom'] as const"
:key="format"
type="button"
class="btn btn-sm"
:class="payloadFormat === format ? 'btn-primary' : 'btn-ghost'"
@click="selectPayloadFormat(format)"
>
{{ $t(`notifications.destination-form.format-${format}`) }}
</button>
</div>
</fieldset>
<!-- Template -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">
{{ $t("notifications.destination-form.template") }}
<span class="text-base-content/60 ml-2 text-sm font-normal">{{
$t("notifications.destination-form.template-hint")
}}</span>
</legend>
<div
ref="templateEditorRef"
class="border-base-content/20 focus-within:border-primary min-h-48 w-full overflow-auto rounded-lg border"
></div>
</fieldset>
<!-- Custom Headers -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">
{{ $t("notifications.destination-form.headers") }}
<span class="text-base-content/60 ml-2 text-sm font-normal">{{
$t("notifications.destination-form.headers-hint")
}}</span>
</legend>
<div class="space-y-2">
<div v-for="(header, index) in headers" :key="header.key" class="flex items-center gap-2">
<input
v-model="header.name"
type="text"
class="input focus:input-primary flex-1 text-base"
:placeholder="$t('notifications.destination-form.header-name')"
/>
<input
v-model="header.value"
type="text"
class="input focus:input-primary flex-1 text-base"
:placeholder="$t('notifications.destination-form.header-value')"
/>
<button type="button" class="btn btn-ghost btn-sm btn-square" @click="headers.splice(index, 1)">
<carbon:close />
<!-- 3. Payload -->
<section>
<FormStepHeading :step="3" :title="$t('notifications.destination-form.payload-format')" />
<div class="mb-2 flex flex-wrap gap-2">
<button
v-for="format in FORMATS"
:key="format"
type="button"
class="btn btn-sm"
:class="payloadFormat === format ? 'btn-primary' : 'btn-ghost'"
@click="selectPayloadFormat(format)"
>
{{ $t(`notifications.destination-form.format-${format}`) }}
</button>
</div>
<button
type="button"
class="btn btn-ghost btn-sm"
@click="headers.push({ name: '', value: '', key: headerKeyCounter++ })"
>
<carbon:add />
{{ $t("notifications.destination-form.add-header") }}
</button>
</div>
</fieldset>
<!-- Error -->
<div v-if="error" class="alert alert-error">
<span>{{ error }}</span>
</div>
<div v-if="confirmingFormat" class="alert alert-warning mb-2 flex-wrap py-2 text-sm">
<mdi:alert-outline />
<span class="flex-1">{{ $t("notifications.destination-form.format-replace-warning") }}</span>
<button class="btn btn-xs" @click="confirmingFormat = null">
{{ $t("notifications.destination-form.cancel") }}
</button>
<button class="btn btn-xs btn-warning" @click="applyPayloadFormat(confirmingFormat)">
{{ $t("notifications.destination-form.format-replace-confirm") }}
</button>
</div>
<!-- Test Result -->
<div v-if="testResult" class="alert" :class="testResult.success ? 'alert-success' : 'alert-error'">
<span v-if="testResult.success">
{{ $t("notifications.destination-form.test-success") }}
<span v-if="testResult.statusCode" class="opacity-70">({{ testResult.statusCode }})</span>
</span>
<span v-else>
{{ testResult.error }}
</span>
<div
ref="templateEditorRef"
class="border-base-content/20 focus-within:border-primary min-h-48 w-full overflow-auto rounded-lg border"
></div>
<!-- The backend renders valid JSON field-by-field and anything else as raw text, so say
which one this template lands in instead of leaving it to a failed send. -->
<p class="mt-1 text-xs" :class="payloadNoteClass">
<mdi:alert v-if="mode === 'unbalanced'" class="inline" />
<mdi:check v-else-if="mode === 'json'" class="inline" />
<mdi:information-outline v-else class="inline" />
{{ $t(`notifications.destination-form.payload-mode-${mode}`) }}
</p>
<TemplateVariables @insert="insertSnippet" />
</section>
<!-- 4. Custom Headers -->
<section>
<FormStepHeading :step="4" :title="$t('notifications.destination-form.headers')" />
<p class="text-base-content/60 mb-2 text-sm">{{ $t("notifications.destination-form.headers-hint") }}</p>
<div class="space-y-2">
<div v-for="(header, index) in headers" :key="header.key" class="flex items-center gap-2">
<input
v-model="header.name"
type="text"
class="input focus:input-primary flex-1 text-base"
:placeholder="$t('notifications.destination-form.header-name')"
/>
<input
v-model="header.value"
type="text"
class="input focus:input-primary flex-1 text-base"
:placeholder="$t('notifications.destination-form.header-value')"
/>
<button
type="button"
class="btn btn-ghost btn-sm btn-square"
:aria-label="$t('notifications.destination-form.remove-header')"
@click="headers.splice(index, 1)"
>
<carbon:close />
</button>
</div>
<button
type="button"
class="btn btn-ghost btn-sm"
@click="headers.push({ name: '', value: '', key: headerKeyCounter++ })"
>
<carbon:add />
{{ $t("notifications.destination-form.add-header") }}
</button>
</div>
</section>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 pt-4">
<button class="btn" @click="testDestination" :disabled="!canTest || !isValidUrl || isTesting">
<span v-if="isTesting" class="loading loading-spinner loading-sm"></span>
{{ $t("notifications.destination-form.test") }}
</button>
<div class="flex-1"></div>
<button class="btn" @click="close?.()">
{{ $t("notifications.destination-form.cancel") }}
</button>
<button class="btn btn-primary" :disabled="!canSave" @click="saveDestination">
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
{{ isEditing ? $t("notifications.destination-form.save") : $t("notifications.destination-form.add") }}
</button>
<!-- Opaque and full-bleed: the parent's padding would otherwise leave the scrolling content
visible down both sides of the bar. -->
<div class="bg-base-100 border-base-content/10 sticky bottom-0 z-10 -mx-4 mt-auto border-t px-4 py-4">
<div v-if="error" class="alert alert-error mb-3 py-2 text-sm">
<span>{{ error }}</span>
</div>
<!-- Cleared whenever the request changes, so a green tick always describes what is on screen -->
<div
v-if="testResult"
class="alert mb-3 py-2 text-sm"
:class="testResult.success ? 'alert-success' : 'alert-error'"
>
<span v-if="testResult.success">
{{ $t("notifications.destination-form.test-success") }}
<span v-if="testResult.statusCode" class="opacity-70">({{ testResult.statusCode }})</span>
</span>
<span v-else>{{ testResult.error }}</span>
</div>
<div v-if="confirmingDiscard" class="flex flex-wrap items-center justify-end gap-2">
<span class="mr-auto text-sm">{{ $t("notifications.destination-form.discard-title") }}</span>
<button class="btn btn-sm" @click="confirmingDiscard = false">
{{ $t("notifications.destination-form.discard-keep") }}
</button>
<button class="btn btn-sm btn-error" @click="discard">
{{ $t("notifications.destination-form.discard-confirm") }}
</button>
</div>
<div v-else class="flex flex-wrap items-center gap-2">
<button class="btn" @click="testDestination" :disabled="!isValidUrl || isTesting">
<span v-if="isTesting" class="loading loading-spinner loading-sm"></span>
{{ $t("notifications.destination-form.test") }}
</button>
<span v-if="blockers.length" class="text-base-content/60 text-sm">
{{ $t("notifications.destination-form.still-needed", { fields: blockerLabels }) }}
</span>
<div class="flex-1"></div>
<button class="btn" @click="close?.()">
{{ $t("notifications.destination-form.cancel") }}
</button>
<button class="btn btn-primary" :disabled="!canSave" @click="saveDestination">
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
{{ isEditing ? $t("notifications.destination-form.save") : $t("notifications.destination-form.add") }}
</button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Dispatcher, TestWebhookResult } from "@/types/notifications";
import { createTemplateEditor } from "@/composable/templateEditor";
import { createTemplateEditor, payloadMode } from "@/composable/templateEditor";
import { PAYLOAD_TEMPLATES, type PayloadFormat } from "./payloadTemplates";
import FormStepHeading from "./FormStepHeading.vue";
import TemplateVariables from "./TemplateVariables.vue";
const { close, onCreated, destination, isEditing } = defineProps<{
const props = defineProps<{
close?: () => void;
onCreated?: () => void;
onCreated?: (created?: Dispatcher) => void;
destination?: Dispatcher;
existingDispatchers?: Dispatcher[];
isEditing: boolean;
}>();
const { t } = useI18n();
const FORMATS = ["slack", "discord", "ntfy", "custom"] as const;
const nameInput = ref<HTMLInputElement>();
const templateEditorRef = ref<HTMLElement>();
const name = ref(destination?.name ?? "");
const name = ref(props.destination?.name ?? "");
useFocus(nameInput, { initialValue: true });
const webhookUrl = ref(destination?.url ?? "");
const payloadFormat = ref<PayloadFormat>(isEditing ? "custom" : "slack");
const template = ref(isEditing ? (destination?.template ?? "") : PAYLOAD_TEMPLATES[payloadFormat.value]);
const webhookUrl = ref(props.destination?.url ?? "");
const payloadFormat = ref<PayloadFormat>(props.isEditing ? "custom" : "slack");
const template = ref(props.isEditing ? (props.destination?.template ?? "") : PAYLOAD_TEMPLATES[payloadFormat.value]);
let headerKeyCounter = 0;
const headers = ref<{ name: string; value: string; key: number }[]>(
destination?.headers
? Object.entries(destination.headers).map(([name, value]) => ({ name, value, key: headerKeyCounter++ }))
props.destination?.headers
? Object.entries(props.destination.headers).map(([name, value]) => ({ name, value, key: headerKeyCounter++ }))
: [],
);
const isTesting = ref(false);
const isSaving = ref(false);
const error = ref<string | null>(null);
const testResult = ref<TestWebhookResult | null>(null);
const confirmingFormat = ref<PayloadFormat | null>(null);
let templateEditorView: Awaited<ReturnType<typeof createTemplateEditor>> | undefined;
const mode = computed(() => payloadMode(template.value));
const payloadNoteClass = computed(() =>
mode.value === "unbalanced" ? "text-error" : mode.value === "json" ? "text-success" : "text-base-content/60",
);
// Two destinations with the same name are indistinguishable in the alert form's picker.
const isDuplicateName = computed(() => {
const trimmed = name.value.trim().toLowerCase();
if (!trimmed) return false;
return (props.existingDispatchers ?? []).some(
(d) => d.id !== props.destination?.id && d.name.trim().toLowerCase() === trimmed,
);
});
function selectPayloadFormat(format: PayloadFormat) {
if (format === payloadFormat.value) return;
// Picking a preset overwrites whatever is in the editor, which is fine for an untouched
// preset and destructive for a template the user has actually written.
const isPreset = Object.values(PAYLOAD_TEMPLATES).includes(template.value.trim());
if (template.value.trim() && !isPreset) {
confirmingFormat.value = format;
return;
}
applyPayloadFormat(format);
}
function applyPayloadFormat(format: PayloadFormat) {
confirmingFormat.value = null;
payloadFormat.value = format;
template.value = PAYLOAD_TEMPLATES[format];
setEditorContent(template.value);
@@ -173,11 +250,18 @@ function setEditorContent(value: string) {
});
}
function insertSnippet(snippet: string) {
if (!templateEditorView) return;
templateEditorView.dispatch(templateEditorView.state.replaceSelection(snippet));
templateEditorView.focus();
}
onMounted(async () => {
if (!templateEditorRef.value) return;
templateEditorView = await createTemplateEditor({
parent: templateEditorRef.value,
initialValue: template.value,
placeholder: t("notifications.destination-form.template-placeholder"),
onChange: (v) => (template.value = v),
});
});
@@ -192,26 +276,36 @@ function headersToRecord(): Record<string, string> | undefined {
return Object.fromEntries(filtered.map((h) => [h.name.trim(), h.value.trim()]));
}
const canTest = computed(() => webhookUrl.value.trim().length > 0);
const isValidUrl = computed(() => {
const trimmed = webhookUrl.value.trim();
if (!trimmed) return false;
try {
new URL(webhookUrl.value.trim());
return true;
const url = new URL(trimmed);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
});
const canSave = computed(() => {
if (isSaving.value) return false;
if (!name.value.trim()) return false;
if (!isValidUrl.value) return false;
return true;
const blockers = computed(() => {
const missing: string[] = [];
if (!name.value.trim()) missing.push("name");
if (!isValidUrl.value) missing.push("url");
if (mode.value === "unbalanced") missing.push("template");
return missing;
});
const blockerLabels = computed(() =>
blockers.value.map((b) => t(`notifications.destination-form.missing.${b}`)).join(", "),
);
const canSave = computed(() => !isSaving.value && blockers.value.length === 0);
// A result that predates the current URL, template or headers is worse than none: it reads as
// a green light for a request that was never actually sent.
watch([webhookUrl, template, headers], () => (testResult.value = null), { deep: true });
async function testDestination() {
if (!canTest.value) return;
if (!isValidUrl.value) return;
isTesting.value = true;
testResult.value = null;
@@ -251,12 +345,12 @@ async function saveDestination() {
headers: headersToRecord(),
};
const url = isEditing
? withBase(`/api/notifications/dispatchers/${destination!.id}`)
const url = props.isEditing
? withBase(`/api/notifications/dispatchers/${props.destination!.id}`)
: withBase("/api/notifications/dispatchers");
const res = await fetch(url, {
method: isEditing ? "PUT" : "POST",
method: props.isEditing ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
@@ -266,12 +360,43 @@ async function saveDestination() {
throw new Error(data.error || "Failed to save destination");
}
onCreated?.();
close?.();
registerGuard(null);
// Hand the saved destination back so callers can select it right away.
props.onCreated?.(await res.json().catch(() => undefined));
props.close?.();
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to save destination";
} finally {
isSaving.value = false;
}
}
// Unsaved-changes guard, matching the alert form: a stray Esc should not throw away a template.
const registerGuard = useDrawerCloseGuard();
const confirmingDiscard = ref(false);
const initial = ref<string>();
const snapshot = computed(() =>
JSON.stringify({
name: name.value,
webhookUrl: webhookUrl.value,
template: template.value,
headers: headers.value.map((h) => [h.name, h.value]),
}),
);
onMounted(async () => {
await nextTick();
initial.value = snapshot.value;
});
const isDirty = computed(() => initial.value !== undefined && initial.value !== snapshot.value);
registerGuard(() => {
if (!isDirty.value || isSaving.value) return true;
confirmingDiscard.value = true;
return false;
});
function discard() {
registerGuard(null);
props.close?.();
}
</script>
+24 -16
View File
@@ -2,7 +2,7 @@
<dialog ref="panel" class="modal-right modal items-start outline-hidden backdrop:bg-none">
<div class="modal-box" :width="maximized ? 'full' : width">
<div class="pt-safe relative">
<div class="absolute right-0 flex items-center gap-3">
<div class="absolute right-0 z-10 flex items-center gap-3">
<button
v-if="!isMobile"
class="icon-btn hover:text-base-content/60 outline-hidden"
@@ -14,38 +14,42 @@
<mdi:arrow-collapse v-if="maximized" />
<mdi:arrow-expand v-else />
</button>
<form method="dialog">
<button v-if="isMobile" class="icon-btn">
<mdi:close />
</button>
<button v-else class="icon-btn swap hover:swap-active outline-hidden">
<mdi:keyboard-esc class="swap-off" />
<mdi:close class="swap-on" />
</button>
</form>
<button v-if="isMobile" class="icon-btn" type="button" @click="requestClose">
<mdi:close />
</button>
<button v-else class="icon-btn swap hover:swap-active outline-hidden" type="button" @click="requestClose">
<mdi:keyboard-esc class="swap-off" />
<mdi:close class="swap-on" />
</button>
</div>
<slot v-if="open" :close="close"></slot>
<slot v-if="open" :close="requestClose"></slot>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
<button class="modal-backdrop cursor-pointer" type="button" @click="requestClose">close</button>
</dialog>
</template>
<script setup lang="ts">
import { type DrawerWidth } from "@/composable/drawer";
import { type DrawerWidth, type DrawerCloseGuard } from "@/composable/drawer";
const panel = useTemplateRef<HTMLDialogElement>("panel");
const open = ref(false);
const maximized = ref(false);
const { width } = defineProps<{
const { width, closeGuard } = defineProps<{
width: DrawerWidth;
/** Set by the drawer's occupant to veto a close, e.g. to confirm discarding a form. */
closeGuard?: DrawerCloseGuard | null;
}>();
function close() {
panel.value?.close();
}
/** Every close path goes through the guard: Esc, the backdrop, the close button, the slot. */
function requestClose() {
if (closeGuard && !closeGuard()) return;
close();
}
defineExpose({
open: () => {
open.value = true;
@@ -55,6 +59,10 @@ defineExpose({
close,
});
// Esc closes a native dialog without going through any of our handlers.
useEventListener(panel, "cancel", (event: Event) => {
if (closeGuard && !closeGuard()) event.preventDefault();
});
useEventListener(panel, "close", () => (open.value = false));
</script>
<style scoped>
+31 -20
View File
@@ -3,18 +3,25 @@ import type { ContainerJson } from "@/types/Container";
import type { Dispatcher, NotificationRule, PreviewResult } from "@/types/notifications";
import { createContainerHints } from "@/composable/exprEditor";
export type AlertType = "log" | "metric" | "event";
export interface AlertPrefill {
name?: string;
alertType?: AlertType;
containerExpression?: string;
logExpression?: string;
metricExpression?: string;
eventExpression?: string;
cooldown?: number;
sampleWindow?: number;
dispatcherId?: number;
}
export interface AlertFormOptions {
close?: () => void;
onCreated?: () => void;
alert?: NotificationRule;
prefill?: {
name?: string;
containerExpression?: string;
logExpression?: string;
metricExpression?: string;
eventExpression?: string;
dispatcherId?: number;
};
prefill?: AlertPrefill;
}
export interface ContainerResult {
@@ -22,6 +29,9 @@ export interface ContainerResult {
containers?: Container[];
}
/** What is still missing before the alert can be saved, in the order the form asks for it. */
export type SaveBlocker = "container-expression" | "condition" | "destination" | "name";
export function useAlertForm(options: AlertFormOptions) {
const isEditing = computed(() => !!options.alert);
const alertName = ref(options.alert?.name ?? options.prefill?.name ?? "");
@@ -32,9 +42,16 @@ export function useAlertForm(options: AlertFormOptions) {
// Destinations (cloud dispatcher with id=0 is included by the backend when configured)
const destinations = ref<Dispatcher[]>([]);
onMounted(async () => {
async function fetchDestinations() {
const res = await fetch(withBase("/api/notifications/dispatchers"));
destinations.value = await res.json();
}
onMounted(async () => {
await fetchDestinations();
// With exactly one destination there is nothing to choose, so don't make the user choose it.
if (dispatcherId.value < 0 && destinations.value.length === 1) {
dispatcherId.value = destinations.value[0].id;
}
});
const selectedDestination = computed(() => destinations.value.find((d) => d.id === dispatcherId.value));
@@ -51,6 +68,8 @@ export function useAlertForm(options: AlertFormOptions) {
const containerResult = ref<ContainerResult | null>(null);
const isLoading = ref(false);
const containerHints = () => createContainerHints(containerNames.value, imageNames.value, hostNames.value);
const baseCanSave = computed(
() =>
alertName.value.trim() &&
@@ -60,15 +79,6 @@ export function useAlertForm(options: AlertFormOptions) {
!isSaving.value,
);
function setupContainerEditor(editorRef: Ref<HTMLElement | undefined>) {
useExprEditorField(editorRef, {
placeholder: 'name contains "api"',
initialValue: options.alert?.containerExpression ?? options.prefill?.containerExpression ?? "",
getHints: () => createContainerHints(containerNames.value, imageNames.value, hostNames.value),
onChange: (v) => (containerExpression.value = v),
});
}
async function saveAlert(typeSpecificFields: Record<string, unknown>) {
isSaving.value = true;
saveError.value = null;
@@ -77,7 +87,7 @@ export function useAlertForm(options: AlertFormOptions) {
name: alertName.value.trim(),
containerExpression: containerExpression.value,
dispatcherId: dispatcherId.value,
enabled: true,
enabled: options.alert?.enabled ?? true,
...typeSpecificFields,
};
const url = isEditing.value
@@ -143,13 +153,14 @@ export function useAlertForm(options: AlertFormOptions) {
containerExpression,
dispatcherId,
destinations,
fetchDestinations,
selectedDestination,
containerResult,
containerHints,
isLoading,
isSaving,
saveError,
baseCanSave,
setupContainerEditor,
saveAlert,
validatePreview,
};
+41 -1
View File
@@ -3,15 +3,28 @@ import { Component } from "vue";
export type DrawerWidth = "md" | "xl" | "lg";
/** Returns true when the drawer may close. A guard that returns false owns telling the user why. */
export type DrawerCloseGuard = () => boolean;
export const drawerContext = Symbol("drawer") as InjectionKey<
(c: Component, p: Record<string, any>, s?: DrawerWidth) => void
>;
interface DrawerGuardApi {
set: (guard: DrawerCloseGuard | null) => void;
current: () => DrawerCloseGuard | null;
}
export const drawerGuardContext = Symbol("drawer-guard") as InjectionKey<DrawerGuardApi>;
export const createDrawer = (drawer: Ref<InstanceType<typeof SideDrawer>>) => {
const component = shallowRef<Component | null>(null);
const properties = shallowRef<Record<string, any>>({});
const width = ref<DrawerWidth>("md");
const closeGuard = shallowRef<DrawerCloseGuard | null>(null);
const showDrawer = (c: Component, p: Record<string, any>, w: DrawerWidth = "md") => {
// A new occupant never inherits the previous one's guard.
closeGuard.value = null;
component.value = c;
properties.value = p;
width.value = w;
@@ -19,11 +32,38 @@ export const createDrawer = (drawer: Ref<InstanceType<typeof SideDrawer>>) => {
};
provide(drawerContext, showDrawer);
provide(drawerGuardContext, {
set: (guard: DrawerCloseGuard | null) => (closeGuard.value = guard),
current: () => closeGuard.value,
});
return { component, properties, showDrawer, width };
return { component, properties, showDrawer, width, closeGuard };
};
export const useDrawer = () =>
inject(drawerContext, () => {
console.error("No drawer context provided");
});
/**
* Lets drawer content veto a close (Esc, backdrop click, the close button) — for example to
* confirm discarding an unsaved form. The guard is dropped when the component goes away.
*/
export const useDrawerCloseGuard = () => {
const api = inject(drawerGuardContext, { set: () => {}, current: () => null });
let mine: DrawerCloseGuard | null = null;
const register = (guard: DrawerCloseGuard | null) => {
mine = guard;
api.set(guard);
};
// Unmounting after the drawer's next occupant has already registered its own guard must
// not clear that guard, so only retract one that is still ours.
onScopeDispose(() => {
if (mine && api.current() === mine) api.set(null);
mine = null;
});
return register;
};
+182 -25
View File
@@ -1,4 +1,5 @@
import type { Completion, CompletionContext, CompletionSource } from "@codemirror/autocomplete";
import type { StringStream } from "@codemirror/language";
export interface ExprEditorOptions {
parent: HTMLElement;
@@ -58,9 +59,11 @@ export function createLogHints(messageKeys?: string[]): Completion[] {
{ label: "type", detail: "log type", type: "property" },
{ label: "timestamp", detail: "unix timestamp", type: "property" },
{ label: "id", detail: "log entry ID", type: "property" },
...(messageKeys ?? []).map(
(key): Completion => ({ label: `message.${key}`, detail: "message field", type: "property" }),
),
...(messageKeys ?? []).map((key): Completion => ({
label: `message.${key}`,
detail: "message field",
type: "property",
})),
...exprOperators,
{ label: '"error"', detail: "level value", type: "string" },
{ label: '"warn"', detail: "level value", type: "string" },
@@ -108,11 +111,14 @@ export function createEventHints(): Completion[] {
{ label: "attributes", detail: "event attributes map", type: "property" },
{ label: 'attributes["healthStatus"]', detail: "healthy or unhealthy (health_status events)", type: "property" },
{ label: 'attributes["exitCode"]', detail: "exit code (die events)", type: "property" },
{ label: 'attributes["signal"]', detail: "signal name (kill events)", type: "property" },
...exprOperators,
{ label: '"start"', detail: "container started", type: "string" },
{ label: '"stop"', detail: "container stopped", type: "string" },
{ label: '"die"', detail: "container died", type: "string" },
{ label: '"restart"', detail: "container restarted", type: "string" },
{ label: '"kill"', detail: "container killed by signal", type: "string" },
{ label: '"oom"', detail: "container out of memory", type: "string" },
{ label: '"health_status"', detail: "health check changed", type: "string" },
{ label: 'name == "die"', detail: "match container death", type: "text", boost: 10 },
{ label: 'name == "health_status"', detail: "match health changes", type: "text", boost: 10 },
@@ -128,7 +134,7 @@ export function createEventHints(): Completion[] {
function createAutocomplete(getHints: () => Completion[]): CompletionSource {
return (context: CompletionContext) => {
const word = context.matchBefore(/[\w"=!&|]+/);
const word = context.matchBefore(/[\w"=!&|.]+/);
if (!word && !context.explicit) return null;
const currentWord = word ? word.text.toLowerCase() : "";
@@ -139,75 +145,226 @@ function createAutocomplete(getHints: () => Completion[]): CompletionSource {
};
}
// Keywords and operator words of the expr language (https://expr-lang.org). Symbol operators
// are tokenized separately below.
const exprKeywords = new Set([
"contains",
"startsWith",
"endsWith",
"matches",
"in",
"not",
"and",
"or",
"any",
"all",
"none",
"one",
"filter",
"map",
"count",
"len",
"nil",
]);
const exprBooleans = new Set(["true", "false"]);
/**
* Minimal tokenizer for expr expressions. Without a language attached, CodeMirror never
* assigns highlight tags, so the editor renders as flat unstyled text — this is what makes
* `exprHighlightStyle` below actually do something.
*/
function tokenizeExpr(stream: StringStream): string | null {
if (stream.eatSpace()) return null;
const char = stream.peek();
if (char === undefined) {
stream.next();
return null;
}
// Strings — both quote styles, with escapes.
if (char === '"' || char === "'") {
const quote = stream.next();
let escaped = false;
let ch: string | void;
while ((ch = stream.next()) !== undefined) {
if (ch === quote && !escaped) break;
escaped = !escaped && ch === "\\";
}
return "string";
}
if (stream.match(/^\d+(\.\d+)?/)) return "number";
// Identifiers, keywords and booleans. A leading dot covers the `.usedPercent` form used
// inside `any(mounts, ...)` predicates.
if (stream.match(/^\.?[A-Za-z_][\w]*/)) {
const word = stream.current().replace(/^\./, "");
if (exprBooleans.has(word)) return "bool";
if (exprKeywords.has(word)) return "keyword";
return "propertyName";
}
if (stream.match(/^(==|!=|>=|<=|&&|\|\||[<>!+\-*/%?:])/)) return "operator";
stream.next();
return null;
}
export async function createExprEditor(options: ExprEditorOptions) {
const [
{ EditorView, keymap, placeholder },
{ EditorState },
{ autocompletion, completionKeymap },
{ HighlightStyle, syntaxHighlighting },
{ EditorView, keymap, placeholder, drawSelection },
{ EditorState, Prec },
{ autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap },
{ HighlightStyle, syntaxHighlighting, StreamLanguage },
{ history, historyKeymap, defaultKeymap },
{ tags },
] = await Promise.all([
import("@codemirror/view"),
import("@codemirror/state"),
import("@codemirror/autocomplete"),
import("@codemirror/language"),
import("@codemirror/commands"),
import("@lezer/highlight"),
]);
const exprLanguage = StreamLanguage.define({
name: "expr",
token: tokenizeExpr,
languageData: {
closeBrackets: { brackets: ["(", "[", '"', "'"] },
},
});
const editorTheme = EditorView.theme({
"&": {
backgroundColor: "var(--color-base-100)",
backgroundColor: "transparent",
color: "var(--color-base-content)",
fontSize: "0.875rem",
width: "100%",
},
"&.cm-editor.cm-focused": {
// The wrapping DaisyUI `.input` already draws the focus ring.
outline: "none",
},
".cm-scroller": {
fontFamily: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)",
lineHeight: "1.6",
},
".cm-content": {
caretColor: "var(--color-primary)",
padding: "0.375rem 0",
},
".cm-line": {
padding: "0",
},
".cm-cursor": {
borderLeftColor: "var(--color-primary)",
borderLeftWidth: "2px",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
backgroundColor: "var(--color-base-300)",
".cm-placeholder": {
color: "color-mix(in oklch, var(--color-base-content) 40%, transparent)",
fontStyle: "normal",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": {
backgroundColor: "color-mix(in oklch, var(--color-primary) 25%, transparent)",
},
".cm-activeLine": {
backgroundColor: "color-mix(in oklch, var(--color-base-200) 50%, transparent)",
// A single-line expression field looks broken with a highlighted "active line".
backgroundColor: "transparent",
},
".cm-gutters": {
".cm-tooltip": {
backgroundColor: "var(--color-base-200)",
color: "color-mix(in oklch, var(--color-base-content) 50%, transparent)",
border: "none",
border: "1px solid color-mix(in oklch, var(--color-base-content) 15%, transparent)",
borderRadius: "var(--radius-box, 0.5rem)",
boxShadow: "0 8px 24px rgb(0 0 0 / 0.18)",
overflow: "hidden",
},
".cm-activeLineGutter": {
backgroundColor: "var(--color-base-300)",
".cm-tooltip.cm-tooltip-autocomplete > ul": {
fontFamily: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)",
fontSize: "0.8125rem",
maxHeight: "16rem",
},
".cm-tooltip.cm-tooltip-autocomplete > ul > li": {
padding: "0.25rem 0.625rem",
color: "var(--color-base-content)",
display: "flex",
alignItems: "baseline",
gap: "0.5rem",
},
".cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]": {
backgroundColor: "var(--color-primary)",
color: "var(--color-primary-content)",
},
".cm-completionLabel": {
flex: "1 1 auto",
},
".cm-completionMatchedText": {
textDecoration: "none",
fontWeight: "600",
color: "var(--color-primary)",
},
"li[aria-selected] .cm-completionMatchedText": {
color: "var(--color-primary-content)",
},
".cm-completionDetail": {
fontStyle: "normal",
fontSize: "0.75rem",
opacity: "0.6",
flex: "0 0 auto",
},
});
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: "var(--color-primary)" },
{ tag: tags.operator, color: "var(--color-secondary)" },
{ tag: tags.keyword, color: "var(--color-secondary)", fontWeight: "600" },
{ tag: tags.operator, color: "color-mix(in oklch, var(--color-base-content) 70%, transparent)" },
{ tag: tags.string, color: "var(--color-success)" },
{ tag: tags.number, color: "var(--color-warning)" },
{ tag: tags.bool, color: "var(--color-warning)" },
{ tag: tags.propertyName, color: "var(--color-info)" },
{ tag: tags.variableName, color: "var(--color-base-content)" },
{
tag: tags.comment,
color: "color-mix(in oklch, var(--color-base-content) 50%, transparent)",
fontStyle: "italic",
},
]);
const state = EditorState.create({
doc: options.initialValue,
extensions: [
EditorView.lineWrapping,
exprLanguage,
history(),
drawSelection(),
closeBrackets(),
placeholder(options.placeholder),
autocompletion({
override: [createAutocomplete(options.getHints)],
activateOnTyping: true,
icons: false,
}),
keymap.of(completionKeymap),
// Expressions are single-line: swallow Enter unless it is picking a completion,
// which completionKeymap handles first. Higher precedence than the default keymap's
// insertNewlineAndIndent.
Prec.high(
keymap.of([
{
key: "Enter",
run: () => true,
},
]),
),
keymap.of([...closeBracketsKeymap, ...completionKeymap, ...historyKeymap, ...defaultKeymap]),
editorTheme,
syntaxHighlighting(highlightStyle),
// Pasting a wrapped expression out of the docs would otherwise leave a multi-line
// document that can never compile. Flatten it instead of rejecting the paste.
EditorView.domEventHandlers({
paste(event, view) {
const text = event.clipboardData?.getData("text/plain");
if (!text || !/[\r\n]/.test(text)) return false;
event.preventDefault();
view.dispatch(view.state.replaceSelection(text.replace(/\s*[\r\n]+\s*/g, " ").trim()));
return true;
},
}),
EditorView.updateListener.of((update) => {
if (update.docChanged && options.onChange) {
options.onChange(update.view.state.doc.toString());
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, test } from "vitest";
import { payloadMode } from "./templateEditor";
import { PAYLOAD_TEMPLATES } from "@/components/Notification/payloadTemplates";
// payloadMode has to agree with executeJSONTemplate in internal/notification/dispatcher/webhook.go,
// which unmarshals the raw template text (actions still in place) and only falls back to running
// the whole body through text/template when that fails.
describe("payloadMode", () => {
test("every shipped preset is valid JSON with its actions in place", () => {
for (const [format, template] of Object.entries(PAYLOAD_TEMPLATES)) {
expect(payloadMode(template), format).toBe("json");
}
});
test("actions inside string values keep the template JSON", () => {
expect(payloadMode('{"text": "{{ .Detail }}"}')).toBe("json");
});
test("an action used as a bare value falls back to raw text", () => {
// Valid Go template, not valid JSON — the backend renders the whole body instead.
expect(payloadMode('{"count": {{ .Count }}}')).toBe("raw");
});
test("plain text is raw", () => {
expect(payloadMode("{{ .Container.Name }} went down")).toBe("raw");
});
test("an unclosed action is reported regardless of JSON validity", () => {
expect(payloadMode('{"text": "{{ .Detail "}')).toBe("unbalanced");
expect(payloadMode("{{ .Detail")).toBe("unbalanced");
});
test("blank templates report empty", () => {
expect(payloadMode("")).toBe("empty");
expect(payloadMode(" \n ")).toBe("empty");
});
});
+194 -14
View File
@@ -1,18 +1,129 @@
import type { Completion } from "@codemirror/autocomplete";
import type { DecorationSet, EditorView as EditorViewType, ViewUpdate } from "@codemirror/view";
export interface TemplateEditorOptions {
parent: HTMLElement;
initialValue: string;
placeholder?: string;
onChange?: (value: string) => void;
}
/**
* Fields a webhook template can read, as Go template paths.
*
* `scope` says which alert types populate the value: `Log`, `Stat` and `Event` are pointers on
* the notification and are nil for the other two kinds, so `{{ .Log.Level }}` on a metric alert
* fails at send time rather than rendering empty.
*/
export interface TemplateVariable {
path: string;
detail: string;
scope: "all" | "log" | "metric" | "event";
}
export const TEMPLATE_VARIABLES: TemplateVariable[] = [
{ path: ".Detail", detail: "human-readable summary of what fired", scope: "all" },
{ path: ".Type", detail: "log, metric or event", scope: "all" },
{ path: ".Timestamp", detail: "when the alert fired", scope: "all" },
{ path: ".Container.Name", detail: "container name", scope: "all" },
{ path: ".Container.ID", detail: "container id", scope: "all" },
{ path: ".Container.Image", detail: "container image", scope: "all" },
{ path: ".Container.State", detail: "running, exited, etc.", scope: "all" },
{ path: ".Container.Health", detail: "healthy, unhealthy, none", scope: "all" },
{ path: ".Container.HostName", detail: "docker host name", scope: "all" },
{ path: ".Container.HostID", detail: "docker host id", scope: "all" },
{ path: ".Subscription.Name", detail: "name of the alert that fired", scope: "all" },
{ path: ".Log.Message", detail: "matched log line", scope: "log" },
{ path: ".Log.Level", detail: "log level", scope: "log" },
{ path: ".Log.Stream", detail: "stdout or stderr", scope: "log" },
{ path: ".Stat.CPUPercent", detail: "CPU usage percent", scope: "metric" },
{ path: ".Stat.MemoryPercent", detail: "memory usage percent", scope: "metric" },
{ path: ".Stat.MemoryUsage", detail: "memory usage in bytes", scope: "metric" },
{ path: ".Event.Name", detail: "start, stop, die, health_status…", scope: "event" },
];
export type PayloadMode = "json" | "raw" | "unbalanced" | "empty";
/**
* Classifies how the backend will treat this template.
*
* `executeJSONTemplate` unmarshals the raw template text first, so a template that is valid JSON
* with its `{{ }}` actions still in place gets each string value rendered and re-marshalled
* (values are JSON-escaped for you). Anything else falls back to running the whole text through
* text/template, where escaping is your problem. Both are supported, so this reports which one
* you are in rather than calling either wrong.
*/
export function payloadMode(template: string): PayloadMode {
const text = template.trim();
if (!text) return "empty";
// Cheap balance check first: an unclosed action is a genuine error in either mode.
let depth = 0;
for (let i = 0; i < text.length - 1; i++) {
if (text[i] === "{" && text[i + 1] === "{") {
depth++;
i++;
} else if (text[i] === "}" && text[i + 1] === "}" && depth > 0) {
depth--;
i++;
}
}
if (depth !== 0) return "unbalanced";
try {
JSON.parse(text);
return "json";
} catch {
return "raw";
}
}
function templateCompletions(): Completion[] {
return TEMPLATE_VARIABLES.map((v) => ({
label: `{{ ${v.path} }}`,
detail: v.detail,
type: "property",
}));
}
export async function createTemplateEditor(options: TemplateEditorOptions) {
const [{ EditorView }, { EditorState }, { json }, { HighlightStyle, syntaxHighlighting }, { tags }] =
await Promise.all([
import("@codemirror/view"),
import("@codemirror/state"),
import("@codemirror/lang-json"),
import("@codemirror/language"),
import("@lezer/highlight"),
]);
const [
{ EditorView, keymap, placeholder, Decoration, MatchDecorator, ViewPlugin },
{ EditorState },
{ json },
{ HighlightStyle, syntaxHighlighting },
{ autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap },
{ history, historyKeymap, defaultKeymap, indentWithTab },
{ tags },
] = await Promise.all([
import("@codemirror/view"),
import("@codemirror/state"),
import("@codemirror/lang-json"),
import("@codemirror/language"),
import("@codemirror/autocomplete"),
import("@codemirror/commands"),
import("@lezer/highlight"),
]);
// The JSON grammar sees `{{ .Container.Name }}` as ordinary string content, so the part that
// actually does something renders like the text around it. Decorate the actions on top.
const actionMatcher = new MatchDecorator({
regexp: /\{\{[^}]*\}\}/g,
decoration: Decoration.mark({ class: "cm-template-action" }),
});
const templateActions = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorViewType) {
this.decorations = actionMatcher.createDeco(view);
}
update(update: ViewUpdate) {
this.decorations = actionMatcher.updateDeco(update, this.decorations);
}
},
{ decorations: (v) => v.decorations },
);
const editorTheme = EditorView.theme({
"&": {
@@ -20,26 +131,79 @@ export async function createTemplateEditor(options: TemplateEditorOptions) {
color: "var(--color-base-content)",
fontSize: "0.875rem",
},
"&.cm-editor.cm-focused": {
// The wrapping container already draws the focus ring.
outline: "none",
},
".cm-content": {
caretColor: "var(--color-primary)",
fontFamily: "ui-monospace, monospace",
fontFamily: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)",
padding: "0.5rem 0",
},
".cm-cursor": {
borderLeftColor: "var(--color-primary)",
borderLeftWidth: "2px",
},
".cm-placeholder": {
color: "color-mix(in oklch, var(--color-base-content) 40%, transparent)",
fontStyle: "normal",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
backgroundColor: "var(--color-base-300)",
backgroundColor: "color-mix(in oklch, var(--color-primary) 25%, transparent)",
},
".cm-activeLine": {
backgroundColor: "color-mix(in oklch, var(--color-base-200) 50%, transparent)",
},
".cm-gutters": {
backgroundColor: "var(--color-base-200)",
color: "color-mix(in oklch, var(--color-base-content) 50%, transparent)",
backgroundColor: "transparent",
color: "color-mix(in oklch, var(--color-base-content) 40%, transparent)",
border: "none",
},
".cm-activeLineGutter": {
backgroundColor: "var(--color-base-300)",
backgroundColor: "transparent",
color: "var(--color-base-content)",
},
".cm-template-action": {
color: "var(--color-secondary)",
backgroundColor: "color-mix(in oklch, var(--color-secondary) 12%, transparent)",
borderRadius: "3px",
fontWeight: "600",
},
".cm-tooltip": {
backgroundColor: "var(--color-base-200)",
border: "1px solid color-mix(in oklch, var(--color-base-content) 15%, transparent)",
borderRadius: "var(--radius-box, 0.5rem)",
boxShadow: "0 8px 24px rgb(0 0 0 / 0.18)",
overflow: "hidden",
},
".cm-tooltip.cm-tooltip-autocomplete > ul": {
fontFamily: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)",
fontSize: "0.8125rem",
maxHeight: "16rem",
},
".cm-tooltip.cm-tooltip-autocomplete > ul > li": {
padding: "0.25rem 0.625rem",
color: "var(--color-base-content)",
display: "flex",
alignItems: "baseline",
gap: "0.5rem",
},
".cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]": {
backgroundColor: "var(--color-primary)",
color: "var(--color-primary-content)",
},
".cm-completionLabel": { flex: "1 1 auto" },
".cm-completionMatchedText": {
textDecoration: "none",
fontWeight: "600",
color: "var(--color-primary)",
},
"li[aria-selected] .cm-completionMatchedText": { color: "var(--color-primary-content)" },
".cm-completionDetail": {
fontStyle: "normal",
fontSize: "0.75rem",
opacity: "0.6",
flex: "0 0 auto",
},
});
@@ -49,7 +213,7 @@ export async function createTemplateEditor(options: TemplateEditorOptions) {
{ tag: tags.number, color: "var(--color-warning)" },
{ tag: tags.bool, color: "var(--color-warning)" },
{ tag: tags.null, color: "var(--color-secondary)" },
{ tag: tags.punctuation, color: "var(--color-base-content)" },
{ tag: tags.punctuation, color: "color-mix(in oklch, var(--color-base-content) 70%, transparent)" },
]);
const state = EditorState.create({
@@ -57,6 +221,22 @@ export async function createTemplateEditor(options: TemplateEditorOptions) {
extensions: [
EditorView.lineWrapping,
json(),
templateActions,
history(),
closeBrackets(),
...(options.placeholder ? [placeholder(options.placeholder)] : []),
autocompletion({
override: [
(context) => {
// Offer the notification fields as soon as an action is opened, and on demand.
const word = context.matchBefore(/[{.\w]+/);
if (!word && !context.explicit) return null;
return { from: word ? word.from : context.pos, options: templateCompletions() };
},
],
icons: false,
}),
keymap.of([...closeBracketsKeymap, ...completionKeymap, ...historyKeymap, ...defaultKeymap, indentWithTab]),
editorTheme,
syntaxHighlighting(highlightStyle),
EditorView.updateListener.of((update) => {
+24
View File
@@ -7,6 +7,9 @@ export function useExprEditorField(
options: Omit<ExprEditorOptions, "parent">,
) {
let editorView: Awaited<ReturnType<typeof createExprEditor>> | undefined;
// The editor loads CodeMirror lazily, so a setValue that lands before it is ready
// (an example chip clicked on a fast connection) is applied once it mounts.
let pending: string | undefined;
onMounted(async () => {
if (editorRef.value) {
@@ -14,10 +17,31 @@ export function useExprEditorField(
parent: editorRef.value,
...options,
});
if (pending !== undefined) {
const value = pending;
pending = undefined;
setValue(value);
}
}
});
onScopeDispose(() => {
editorView?.destroy();
});
/** Replaces the editor's content, e.g. when an example is picked. */
function setValue(value: string) {
if (!editorView) {
pending = value;
options.onChange?.(value);
return;
}
editorView.dispatch({
changes: { from: 0, to: editorView.state.doc.length, insert: value },
selection: { anchor: value.length },
});
editorView.focus();
}
return { setValue };
}
+7 -2
View File
@@ -42,7 +42,7 @@
<button>close</button>
</form>
</dialog>
<SideDrawer ref="drawer" :width="drawerWidth" v-slot="{ close }">
<SideDrawer ref="drawer" :width="drawerWidth" :close-guard="drawerCloseGuard" v-slot="{ close }">
<Suspense :timeout="0">
<component :is="drawerComponent" v-bind="drawerProperties" :close="close" />
<template #fallback> <span class="loading loading-spinner loading-sm"></span></template>
@@ -60,7 +60,12 @@ const pinnedLogsStore = usePinnedLogsStore();
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
const drawer = useTemplateRef<InstanceType<typeof SideDrawer>>("drawer") as Ref<InstanceType<typeof SideDrawer>>;
const { component: drawerComponent, properties: drawerProperties, width: drawerWidth } = createDrawer(drawer);
const {
component: drawerComponent,
properties: drawerProperties,
width: drawerWidth,
closeGuard: drawerCloseGuard,
} = createDrawer(drawer);
import { useFuzzySearch } from "@/composable/fuzzySearch";
+18 -2
View File
@@ -34,6 +34,7 @@
:destination="dest"
:on-updated="fetchAll"
:existing-dispatchers="dispatchers"
:used-by-count="alertsPerDispatcher.get(dest.id) ?? 0"
class="w-full md:w-72"
/>
<!-- Add Destination Card -->
@@ -56,8 +57,8 @@
<h3 class="text-base-content/60 font-semibold tracking-wide uppercase">{{ $t("notifications.alerts") }}</h3>
</div>
<!-- Filter Tabs -->
<div class="tabs tabs-box mb-6">
<!-- Filter Tabs. Nothing to filter until there is more than one alert. -->
<div v-if="alerts.length > 1" class="tabs tabs-box mb-6">
<button class="tab" :class="{ 'tab-active': filter === 'all' }" @click="filter = 'all'">
{{ $t("notifications.filter.all", { count: alerts.length }) }}
</button>
@@ -71,6 +72,10 @@
<!-- Alerts List -->
<div class="space-y-4">
<p v-if="!alerts.length" class="text-base-content/60 text-sm">{{ $t("notifications.no-alerts") }}</p>
<p v-else-if="!filteredAlerts.length" class="text-base-content/60 text-sm">
{{ $t("notifications.no-alerts-in-filter") }}
</p>
<AlertCard
v-for="alert in filteredAlerts"
:key="alert.id"
@@ -174,6 +179,16 @@ const filter = ref<"all" | "enabled" | "paused">("all");
const enabledCount = computed(() => alerts.value.filter((a) => a.enabled).length);
const pausedCount = computed(() => alerts.value.filter((a) => !a.enabled).length);
// Deleting a destination orphans the alerts pointing at it, so each card shows its usage.
const alertsPerDispatcher = computed(() => {
const counts = new Map<number, number>();
for (const alert of alerts.value) {
if (!alert.dispatcher) continue;
counts.set(alert.dispatcher.id, (counts.get(alert.dispatcher.id) ?? 0) + 1);
}
return counts;
});
const filteredAlerts = computed(() => {
if (filter.value === "enabled") return alerts.value.filter((a) => a.enabled);
if (filter.value === "paused") return alerts.value.filter((a) => !a.enabled);
@@ -192,6 +207,7 @@ function openCreateAlertPrefilled() {
onCreated: fetchAlerts,
prefill: {
name: t("notifications.prefill-name"),
alertType: "log" as const,
logExpression: t("notifications.prefill-expression"),
...(cloudDispatcher ? { dispatcherId: cloudDispatcher.id } : {}),
},
+29
View File
@@ -59,6 +59,35 @@ export interface PreviewResult {
}[];
totalLogs: number;
messageKeys?: string[];
/** How many matched containers were actually read for logs (the scan is capped). */
scannedContainers: number;
/** How far back the log preview looked, in seconds. */
logWindowSeconds: number;
metricSamples?: MetricSample[];
eventSamples?: EventSample[];
}
/** A dry run of the metric expression against one container's buffered stats. */
export interface MetricSample {
containerId: string;
name: string;
host: string;
cpu: number;
memory: number;
memoryUsage: number;
/** Whether the most recent sample matches. */
matches: boolean;
matchedSamples: number;
totalSamples: number;
/** Whether a full sample window is matched well enough to fire. */
wouldTrigger: boolean;
}
/** A representative container event, tagged with whether the event expression matches it. */
export interface EventSample {
name: string;
attributes?: Record<string, string>;
matches: boolean;
}
export interface TestWebhookResult {
+179 -6
View File
@@ -85,6 +85,7 @@ type PreviewInput struct {
LogExpression *string `json:"logExpression,omitempty"`
MetricExpression *string `json:"metricExpression,omitempty"`
EventExpression *string `json:"eventExpression,omitempty"`
SampleWindow int `json:"sampleWindow,omitempty"`
}
type PreviewResult struct {
@@ -96,6 +97,37 @@ type PreviewResult struct {
MatchedLogs []container.LogEvent `json:"matchedLogs"`
TotalLogs int `json:"totalLogs"`
MessageKeys []string `json:"messageKeys,omitempty"`
// ScannedContainers is how many of the matched containers were actually read for logs.
// The scan is capped so a broad container filter can't stall the form.
ScannedContainers int `json:"scannedContainers"`
LogWindowSeconds int `json:"logWindowSeconds"`
MetricSamples []MetricSample `json:"metricSamples,omitempty"`
EventSamples []EventSample `json:"eventSamples,omitempty"`
}
// MetricSample is a per-container dry run of the metric expression against the stats Dozzle
// already has buffered. It lets the alert form show what the rule would do right now instead
// of only whether the expression compiles.
type MetricSample struct {
ContainerID string `json:"containerId"`
Name string `json:"name"`
Host string `json:"host"`
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
MemoryUsage float64 `json:"memoryUsage"`
Matches bool `json:"matches"`
MatchedSamples int `json:"matchedSamples"`
TotalSamples int `json:"totalSamples"`
WouldTrigger bool `json:"wouldTrigger"`
}
// EventSample is one representative container event from previewEventCatalog, tagged with
// whether the event expression matches it. Docker events aren't retained anywhere, so this is
// a dry run against realistic samples rather than against history.
type EventSample struct {
Name string `json:"name"`
Attributes map[string]string `json:"attributes,omitempty"`
Matches bool `json:"matches"`
}
type TestWebhookInput struct {
@@ -521,6 +553,131 @@ func (h *handler) requireNotificationsRole(next http.Handler) http.Handler {
})
}
const (
// previewLogWindow is how far back the log preview reads. Kept in the response so the
// form can describe the window it is showing without hardcoding a duplicate value.
previewLogWindow = 2 * time.Hour
// previewMaxLogs is how many matching log lines are returned as examples.
previewMaxLogs = 10
// previewMaxLogContainers caps how many matched containers are read for logs. A filter
// like `state == "running"` can match hundreds of containers and reading all of them
// would block the drawer for the full request timeout.
previewMaxLogContainers = 10
// previewMaxMetricSamples caps how many per-container metric rows are returned.
previewMaxMetricSamples = 20
)
// previewEventCatalog is a set of representative container events used to dry-run event
// expressions. Docker events are not retained anywhere, so instead of showing nothing until
// an event happens, the form shows which of these an expression would match. Names are the
// ones the event listener allows through, with health events already normalized.
var previewEventCatalog = []types.NotificationEvent{
{Name: "start"},
{Name: "stop"},
{Name: "die", Attributes: map[string]string{"exitCode": "0"}},
{Name: "die", Attributes: map[string]string{"exitCode": "1"}},
{Name: "restart"},
{Name: "kill", Attributes: map[string]string{"signal": "SIGKILL"}},
{Name: "oom"},
{Name: "health_status", Attributes: map[string]string{"healthStatus": "healthy"}},
{Name: "health_status", Attributes: map[string]string{"healthStatus": "unhealthy"}},
}
// previewEventSamples evaluates the event expression against previewEventCatalog.
func previewEventSamples(sub *notification.Subscription) []EventSample {
if sub.EventProgram == nil {
return nil
}
samples := make([]EventSample, 0, len(previewEventCatalog))
for _, event := range previewEventCatalog {
event.Timestamp = time.Now()
if event.Attributes == nil {
// Expressions that index attributes must not blow up on the events that carry none.
event.Attributes = map[string]string{}
}
samples = append(samples, EventSample{
Name: event.Name,
Attributes: event.Attributes,
Matches: sub.MatchesEvent(event),
})
}
return samples
}
// previewMetricSamples replays the metric expression over the stats already buffered for each
// matched container. WouldTrigger mirrors Subscription.RecordMetricSample: a full sample window
// where at least 80% of samples match.
func previewMetricSamples(sub *notification.Subscription, containers []container.Container, sampleWindow int) []MetricSample {
if sub.MetricProgram == nil || len(containers) == 0 {
return nil
}
window := (&notification.Subscription{SampleWindow: sampleWindow}).GetSampleWindowSeconds()
samples := make([]MetricSample, 0, len(containers))
for _, c := range containers {
sample := MetricSample{ContainerID: c.ID, Name: c.Name, Host: c.Host}
if c.Stats != nil {
stats := c.Stats.Data()
if len(stats) > window {
stats = stats[len(stats)-window:]
}
mounts := notification.FromContainerMounts(c)
for _, stat := range stats {
sample.Matches = sub.MatchesMetric(types.NotificationStat{
CPUPercent: stat.CPUPercent,
MemoryPercent: stat.MemoryPercent,
MemoryUsage: stat.MemoryUsage,
Mounts: mounts,
})
if sample.Matches {
sample.MatchedSamples++
}
}
sample.TotalSamples = len(stats)
if len(stats) > 0 {
latest := stats[len(stats)-1]
sample.CPU = latest.CPUPercent
sample.Memory = latest.MemoryPercent
sample.MemoryUsage = latest.MemoryUsage
}
if window <= 1 {
sample.WouldTrigger = sample.Matches
} else {
sample.WouldTrigger = sample.TotalSamples >= window &&
float64(sample.MatchedSamples)/float64(sample.TotalSamples) >= 0.8
}
}
samples = append(samples, sample)
}
// Surface the containers closest to firing first, so a long list stays useful when truncated.
sort.SliceStable(samples, func(i, j int) bool {
a, b := samples[i], samples[j]
if a.WouldTrigger != b.WouldTrigger {
return a.WouldTrigger
}
if a.Matches != b.Matches {
return a.Matches
}
if a.MatchedSamples != b.MatchedSamples {
return a.MatchedSamples > b.MatchedSamples
}
return a.Name < b.Name
})
if len(samples) > previewMaxMetricSamples {
samples = samples[:previewMaxMetricSamples]
}
return samples
}
// Preview and test handlers
func (h *handler) previewExpression(w http.ResponseWriter, r *http.Request) {
var input PreviewInput
@@ -571,18 +728,22 @@ func (h *handler) previewExpression(w http.ResponseWriter, r *http.Request) {
// Compile metric expression
if sub.MetricExpression != "" {
_, err := expr.Compile(sub.MetricExpression, expr.Env(types.NotificationStat{}))
program, err := expr.Compile(sub.MetricExpression, expr.Env(types.NotificationStat{}))
if err != nil {
errStr := err.Error()
result.MetricError = &errStr
} else {
sub.MetricProgram = program
}
}
if sub.EventExpression != "" {
_, err := expr.Compile(sub.EventExpression, expr.Env(types.NotificationEvent{}))
program, err := expr.Compile(sub.EventExpression, expr.Env(types.NotificationEvent{}))
if err != nil {
errStr := err.Error()
result.EventError = &errStr
} else {
sub.EventProgram = program
}
}
@@ -601,22 +762,34 @@ func (h *handler) previewExpression(w http.ResponseWriter, r *http.Request) {
}
}
// Dry-run the metric expression against buffered stats, and the event expression against
// a catalog of representative events. Neither needs a live subscription, so both give the
// form something concrete to show the moment the expression compiles.
result.MetricSamples = previewMetricSamples(sub, result.MatchedContainers, input.SampleWindow)
result.EventSamples = previewEventSamples(sub)
// Fetch real logs from matched containers
result.LogWindowSeconds = int(previewLogWindow / time.Second)
if len(result.MatchedContainers) > 0 {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
const maxLogs = 10
totalMatched := 0
keySet := make(map[string]struct{})
for _, c := range result.MatchedContainers {
scanned := result.MatchedContainers
if len(scanned) > previewMaxLogContainers {
scanned = scanned[:previewMaxLogContainers]
}
result.ScannedContainers = len(scanned)
for _, c := range scanned {
containerService, err := h.hostService.FindContainer(c.Host, c.ID, h.resolveLabels(r))
if err != nil {
continue
}
from := time.Now().Add(-2 * time.Hour)
from := time.Now().Add(-previewLogWindow)
to := time.Now()
logChan, err := containerService.LogsBetweenDates(ctx, from, to, container.STDALL)
@@ -645,7 +818,7 @@ func (h *handler) previewExpression(w http.ResponseWriter, r *http.Request) {
notificationLog := notification.FromLogEvent(*logEvent)
if sub.MatchesLog(notificationLog) {
totalMatched++
if len(result.MatchedLogs) < maxLogs {
if len(result.MatchedLogs) < previewMaxLogs {
result.MatchedLogs = append(result.MatchedLogs, *logEvent)
}
}
+131
View File
@@ -0,0 +1,131 @@
package web
import (
"testing"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/internal/notification"
"github.com/amir20/dozzle/internal/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func compiledSub(t *testing.T, sub *notification.Subscription) *notification.Subscription {
t.Helper()
require.NoError(t, sub.CompileExpressions())
return sub
}
func containerWithStats(id, name string, cpu ...float64) container.Container {
stats := utils.NewRingBuffer[container.ContainerStat](300)
for _, c := range cpu {
stats.Push(container.ContainerStat{CPUPercent: c, MemoryPercent: c / 2})
}
return container.Container{ID: id, Name: name, State: "running", Host: "localhost", Stats: stats}
}
func Test_previewEventSamples_marks_matching_events(t *testing.T) {
sub := compiledSub(t, &notification.Subscription{
ContainerExpression: "true",
EventExpression: `name == "health_status" && attributes["healthStatus"] == "unhealthy"`,
})
samples := previewEventSamples(sub)
require.Len(t, samples, len(previewEventCatalog))
matched := make([]string, 0)
for _, s := range samples {
if s.Matches {
matched = append(matched, s.Name+":"+s.Attributes["healthStatus"])
}
}
assert.Equal(t, []string{"health_status:unhealthy"}, matched)
}
func Test_previewEventSamples_handles_events_without_attributes(t *testing.T) {
// `attributes["exitCode"]` must not error on start/restart, which carry no attributes.
sub := compiledSub(t, &notification.Subscription{
ContainerExpression: "true",
EventExpression: `attributes["exitCode"] == "1"`,
})
samples := previewEventSamples(sub)
matched := 0
for _, s := range samples {
if s.Matches {
matched++
}
}
assert.Equal(t, 1, matched, "only die with exitCode 1 should match")
}
func Test_previewEventSamples_nil_without_expression(t *testing.T) {
assert.Nil(t, previewEventSamples(compiledSub(t, &notification.Subscription{ContainerExpression: "true"})))
}
func Test_previewMetricSamples_reports_window_ratio(t *testing.T) {
sub := compiledSub(t, &notification.Subscription{
ContainerExpression: "true",
MetricExpression: "cpu > 80",
})
// 5 samples, 4 over the threshold: 80% of a full window, which is exactly what fires.
hot := containerWithStats("a", "hot", 90, 90, 90, 10, 95)
// 5 samples, 1 over: matches right now but nowhere near the window threshold.
spiky := containerWithStats("b", "spiky", 10, 10, 10, 10, 95)
cold := containerWithStats("c", "cold", 1, 2, 3, 4, 5)
samples := previewMetricSamples(sub, []container.Container{cold, spiky, hot}, 5)
require.Len(t, samples, 3)
// Containers closest to firing sort first.
assert.Equal(t, "hot", samples[0].Name)
assert.True(t, samples[0].WouldTrigger)
assert.Equal(t, 4, samples[0].MatchedSamples)
assert.Equal(t, 5, samples[0].TotalSamples)
assert.InDelta(t, 95, samples[0].CPU, 0.001, "cpu should be the latest sample")
assert.Equal(t, "spiky", samples[1].Name)
assert.True(t, samples[1].Matches, "latest sample is over the threshold")
assert.False(t, samples[1].WouldTrigger, "one spike is not a full window")
assert.Equal(t, "cold", samples[2].Name)
assert.False(t, samples[2].Matches)
}
func Test_previewMetricSamples_needs_a_full_window(t *testing.T) {
sub := compiledSub(t, &notification.Subscription{
ContainerExpression: "true",
MetricExpression: "cpu > 80",
})
// Every sample matches, but there are fewer of them than the window asks for.
samples := previewMetricSamples(sub, []container.Container{containerWithStats("a", "hot", 90, 90)}, 15)
require.Len(t, samples, 1)
assert.True(t, samples[0].Matches)
assert.False(t, samples[0].WouldTrigger)
assert.Equal(t, 2, samples[0].TotalSamples)
}
func Test_previewMetricSamples_handles_containers_without_stats(t *testing.T) {
sub := compiledSub(t, &notification.Subscription{
ContainerExpression: "true",
MetricExpression: "cpu > 80",
})
samples := previewMetricSamples(sub, []container.Container{
{ID: "a", Name: "no-stats", State: "running"},
containerWithStats("b", "empty"),
}, 15)
require.Len(t, samples, 2)
for _, s := range samples {
assert.Zero(t, s.TotalSamples)
assert.False(t, s.WouldTrigger)
}
}
func Test_previewMetricSamples_nil_without_expression(t *testing.T) {
sub := compiledSub(t, &notification.Subscription{ContainerExpression: "true"})
assert.Nil(t, previewMetricSamples(sub, []container.Container{containerWithStats("a", "x", 5)}, 15))
}
+92 -16
View File
@@ -292,6 +292,7 @@ notifications:
enabled: Aktiveret ({count})
paused: Pauset ({count})
no-alerts: Ingen alarmer konfigureret endnu. Opret en for at komme i gang.
no-alerts-in-filter: Ingen alarmer matcher dette filter.
default-alert-created: Vi har oprettet en standardalarm for dig. Du får besked, når en container afsluttes med en fejl. Du kan redigere eller sætte den på pause når som helst.
default-alert-failed: Kunne ikke oprette din alarm automatisk. Her er formularen i stedet.
alert:
@@ -313,30 +314,72 @@ notifications:
edit: Rediger
duplicate: Dupliker
delete: Slet
used-by: "Bruges af {count} alarmer"
unused: Bruges ikke af nogen alarm
copy-of: "Kopi af {name}"
copy-failed: Kunne ikke duplikere denne destination
delete-failed: Kunne ikke slette denne destination
delete-warning: Slet denne destination?
delete-warning-used: "{count} alarmer sender hertil og holder op med at give besked."
delete-cancel: Behold
delete-confirm: Slet
alert-form:
create-title: Opret Alarm
edit-title: Rediger Alarm
description: Abonner på log hændelser der matcher dine kriterier
description-log: Få besked når matchende linjer dukker op i dine containerlogs
description-metric: Få besked når en containers CPU, hukommelse eller diskforbrug overskrider en grænse
description-event: Få besked når containere starter, stopper, dør eller skifter helbred
required: Påkrævet
still-needed: "Mangler stadig: {fields}"
missing:
container-expression: containere
condition: betingelse
destination: destination
name: navn
alert-name: Alarm Navn
alert-name-placeholder: f.eks. Test API Fejl
container-filter: Container Filter
containers-match: "{count} containere matcher: {names}"
no-containers-match: Ingen containere matcher dette filter
log-filter: Log Filter
logs-match: "{count} logs matcher"
no-logs-match: Ingen logs matcher dette filter
alert-name-hint: Vises på alarmkortet og i hver notifikation den sender.
container-filter: Containere
container-filter-hint: Hvilke containere denne alarm overvåger. Kun kørende containere matches.
containers-match: "Matcher {count} kørende containere: {names}"
no-containers-match: Ingen kørende containere matcher dette filter
checking: Kontrollerer…
examples: "Eksempler:"
use-example: Brug dette eksempel
seconds-unit: sek
log-filter: Log-betingelse
log-filter-hint: Hvilke loglinjer der udløser alarmen.
logs-match: "{count} matchende linjer i de sidste {window}"
no-logs-match: Ingen matchende linjer i de sidste {window}
log-preview-hint: Skriv en betingelse for at se hvilke nylige loglinjer der ville have udløst denne alarm.
log-scan-note: Forhåndsvisningen gennemgik de første {count} matchende containere.
preview-needs-containers: Vælg containere først for at se en forhåndsvisning med rigtige data.
preview-fix-expression: Ret udtrykket for at se en forhåndsvisning.
destination: Destination
select-destination: Vælg en destination
no-destinations: Ingen destinationer konfigureret. Tilføj en først.
no-destinations: Ingen destinationer konfigureret endnu.
preview: Forhåndsvisning
cancel: Annuller
save: Gem
create: Opret Alarm
metric-filter: Metrikudtryk
expression-valid: Udtryk er gyldigt
metric-fields-hint: "Tilgængelige felter: {fields}"
discard-title: Kassér denne alarm?
discard-keep: Fortsæt redigering
discard-confirm: Kassér
metric-filter: Metrik-betingelse
metric-filter-hint: "Sammenlign cpu (%), memory (%), memoryUsage (bytes) eller ledig plads på mounts."
metric-preview-hint: Skriv en betingelse for at se hvordan dine containere ligger lige nu.
metric-preview-note: Baseret på de sidste {duration} statistik som Dozzle har indsamlet.
metric-container: Container
metric-cpu: CPU
metric-memory: Hukommelse
metric-window-match: Vindue
metric-firing: Ville udløse
metric-matching-now: Over nu
metric-no-samples: Ingen data
metric-no-samples-hint: Der er endnu ikke indsamlet statistik for de matchende containere.
metric-would-fire: "Ville udløse for {count} containere lige nu"
metric-would-not-fire: Ingen container er over grænsen lige nu
sample-window-label: Prøvevindue
sample-window-hint: "Alarm udløses, når betingelsen er opfyldt i {duration}"
sample-window-hint: "Udløses når betingelsen holder i {duration}"
cooldown-label: Afkøling
cooldown-hint: "{duration} mellem alarmer pr. container"
no-cooldown: Ingen afkøling
@@ -344,14 +387,32 @@ notifications:
log-alert: Logalarm
metric-alert: Metrikalarm
event-alert: Hændelsesalarm
event-filter: Hændelsesudtryk
event-fields-hint: "Tilgængelige felter: {fields}"
log-alert-description: Match linjer i containerlogs
metric-alert-description: Overvåg CPU, hukommelse og disk
event-alert-description: Reagér på start, stop og helbredsændringer
type-switch-warning: Ved at gemme erstattes denne alarms {type}-betingelse, som går tabt.
event-filter: Hændelses-betingelse
event-filter-hint: Hvilke containerhændelser der udløser alarmen.
event-matches: "Matcher {count} af disse hændelser"
event-matches-none: Matcher ingen af disse hændelser
event-preview-hint: Skriv en betingelse for at se hvilke containerhændelser den ville fange.
event-preview-note: Eksempelhændelser, ikke historik. Docker-hændelser vurderes når de sker.
destination-form:
create-title: Tilføj Destination
edit-title: Rediger Destination
description: Hvor skal notifikationer sendes til?
name: Navn
name-placeholder: f.eks. Produktion Slack
name-hint: Sådan vises denne destination, når du vælger en til en alarm.
name-taken: En anden destination bruger allerede dette navn.
still-needed: "Mangler stadig: {fields}"
missing:
name: navn
url: webhook-URL
template: skabelon
discard-title: Kassér denne destination?
discard-keep: Fortsæt redigering
discard-confirm: Kassér
type: Type
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, brugerdefineret endpoint
@@ -359,19 +420,34 @@ notifications:
cloud-description: Push notifikationer, email alarmer og AI-drevne resuméer
webhook-url: Webhook URL
webhook-url-placeholder: https://hooks.foo.com/services/...
webhook-url-invalid: Indtast en fuld http:// eller https:// URL.
api-key: API Nøgle
payload-format: Payload Format
payload-format: Payload
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Brugerdefineret
format-replace-warning: Dette erstatter den skabelon, du har skrevet.
format-replace-confirm: Erstat
template: Skabelon
template-hint: Go skabelon syntaks
template-placeholder: JSON-body med Go-skabelonfelter
payload-mode-json: Gyldig JSON. Hver værdi bliver renderet og escapet for dig.
payload-mode-raw: Ikke JSON, så hele bodyen sendes som renderet tekst.
payload-mode-unbalanced: Ulukket skabelonhandling.
payload-mode-empty: En tom skabelon sender hele notifikationen som JSON.
variables: Tilgængelige variabler
variables-all: Alle alarmer
variables-log: Kun log-alarmer
variables-metric: Kun metrik-alarmer
variables-event: Kun hændelsesalarmer
variables-scope-hint: Felter uden for “Alle alarmer” findes kun for deres egen alarmtype; bruges de andre steder, fejler afsendelsen.
headers: Brugerdefinerede Headers
headers-hint: Valgfrie HTTP-headers (f.eks. Authorization)
header-name: Header-navn
header-value: Header-værdi
add-header: Tilføj Header
remove-header: Fjern header
test: Test
test-success: Test succesfuld
cancel: Annuller
+92 -16
View File
@@ -292,6 +292,7 @@ notifications:
enabled: Aktiviert ({count})
paused: Pausiert ({count})
no-alerts: Noch keine Alarme konfiguriert. Erstellen Sie einen, um loszulegen.
no-alerts-in-filter: Keine Alarme entsprechen diesem Filter.
default-alert-created: Wir haben einen Standardalarm für dich erstellt. Du wirst benachrichtigt, wenn ein Container mit einem Fehler beendet wird. Du kannst ihn jederzeit bearbeiten oder pausieren.
default-alert-failed: Dein Alarm konnte nicht automatisch erstellt werden. Hier ist stattdessen das Formular.
alert:
@@ -313,30 +314,72 @@ notifications:
edit: Bearbeiten
duplicate: Duplizieren
delete: Löschen
used-by: "Von {count} Alarmen verwendet"
unused: Von keinem Alarm verwendet
copy-of: "{name} Kopie"
copy-failed: Dieses Ziel konnte nicht dupliziert werden
delete-failed: Dieses Ziel konnte nicht gelöscht werden
delete-warning: Dieses Ziel löschen?
delete-warning-used: "{count} Alarme senden hierhin und benachrichtigen dann nicht mehr."
delete-cancel: Behalten
delete-confirm: Löschen
alert-form:
create-title: Alarm erstellen
edit-title: Alarm bearbeiten
description: Abonnieren Sie Log-Ereignisse, die Ihren Kriterien entsprechen
description-log: Benachrichtigung, wenn passende Zeilen in Ihren Container-Logs auftauchen
description-metric: Benachrichtigung, wenn CPU-, Speicher- oder Festplattennutzung einen Schwellenwert überschreitet
description-event: Benachrichtigung, wenn Container starten, stoppen, abstürzen oder ihren Zustand ändern
required: Erforderlich
still-needed: "Noch erforderlich: {fields}"
missing:
container-expression: Container
condition: Bedingung
destination: Ziel
name: Name
alert-name: Alarmname
alert-name-placeholder: z.B. Test API Fehler
container-filter: Container-Filter
containers-match: "{count} Container stimmen überein: {names}"
no-containers-match: Keine Container entsprechen diesem Filter
log-filter: Log-Filter
logs-match: "{count} Logs stimmen überein"
no-logs-match: Keine Logs entsprechen diesem Filter
alert-name-hint: Wird auf der Alarmkarte und in jeder gesendeten Benachrichtigung angezeigt.
container-filter: Container
container-filter-hint: Welche Container dieser Alarm überwacht. Nur laufende Container werden berücksichtigt.
containers-match: "Trifft auf {count} laufende Container zu: {names}"
no-containers-match: Keine laufenden Container entsprechen diesem Filter
checking: Wird geprüft…
examples: "Beispiele:"
use-example: Dieses Beispiel übernehmen
seconds-unit: Sek.
log-filter: Log-Bedingung
log-filter-hint: Welche Log-Zeilen den Alarm auslösen.
logs-match: "{count} passende Zeilen in den letzten {window}"
no-logs-match: Keine passenden Zeilen in den letzten {window}
log-preview-hint: Schreiben Sie eine Bedingung, um zu sehen, welche letzten Log-Zeilen diesen Alarm ausgelöst hätten.
log-scan-note: Die Vorschau hat die ersten {count} passenden Container durchsucht.
preview-needs-containers: Wählen Sie zuerst Container aus, um eine Vorschau mit echten Daten zu sehen.
preview-fix-expression: Korrigieren Sie den Ausdruck, um eine Vorschau zu sehen.
destination: Ziel
select-destination: Wählen Sie ein Ziel
no-destinations: Keine Ziele konfiguriert. Fügen Sie zuerst eines hinzu.
no-destinations: Noch keine Ziele konfiguriert.
preview: Vorschau
cancel: Abbrechen
save: Speichern
create: Alarm erstellen
metric-filter: Metrik-Ausdruck
expression-valid: Ausdruck ist gültig
metric-fields-hint: "Verfügbare Felder: {fields}"
discard-title: Diesen Alarm verwerfen?
discard-keep: Weiter bearbeiten
discard-confirm: Verwerfen
metric-filter: Metrik-Bedingung
metric-filter-hint: "Vergleichen Sie cpu (%), memory (%), memoryUsage (Bytes) oder den freien Speicher von mounts."
metric-preview-hint: Schreiben Sie eine Bedingung, um zu sehen, wie Ihre Container gerade dastehen.
metric-preview-note: Basierend auf den letzten {duration} der von Dozzle erfassten Statistiken.
metric-container: Container
metric-cpu: CPU
metric-memory: Speicher
metric-window-match: Fenster
metric-firing: Würde auslösen
metric-matching-now: Jetzt darüber
metric-no-samples: Keine Daten
metric-no-samples-hint: Für die passenden Container wurden noch keine Statistiken erfasst.
metric-would-fire: "Würde derzeit für {count} Container auslösen"
metric-would-not-fire: Derzeit liegt kein Container über dem Schwellenwert
sample-window-label: Zeitfenster
sample-window-hint: "Alarm wird ausgelöst, wenn Bedingung für {duration} erfüllt ist"
sample-window-hint: "Löst aus, sobald die Bedingung {duration} lang erfüllt ist"
cooldown-label: Abklingzeit
cooldown-hint: "{duration} zwischen Alarmen pro Container"
no-cooldown: Keine Abklingzeit
@@ -344,14 +387,32 @@ notifications:
log-alert: Log-Alarm
metric-alert: Metrik-Alarm
event-alert: Ereignisalarm
event-filter: Ereignisausdruck
event-fields-hint: "Verfügbare Felder: {fields}"
log-alert-description: Passende Zeilen in Container-Logs
metric-alert-description: CPU-, Speicher- und Festplattennutzung überwachen
event-alert-description: Auf Starts, Stopps und Zustandsänderungen reagieren
type-switch-warning: Beim Speichern wird die {type}-Bedingung dieses Alarms ersetzt und geht verloren.
event-filter: Ereignis-Bedingung
event-filter-hint: Welche Container-Ereignisse den Alarm auslösen.
event-matches: "Trifft auf {count} dieser Ereignisse zu"
event-matches-none: Trifft auf keines dieser Ereignisse zu
event-preview-hint: Schreiben Sie eine Bedingung, um zu sehen, welche Container-Ereignisse sie erfassen würde.
event-preview-note: Beispielereignisse, kein Verlauf. Docker-Ereignisse werden abgeglichen, sobald sie auftreten.
destination-form:
create-title: Ziel hinzufügen
edit-title: Ziel bearbeiten
description: Wohin sollen Benachrichtigungen gesendet werden?
name: Name
name-placeholder: z.B. Produktion Slack
name-hint: So wird dieses Ziel bei der Auswahl für einen Alarm angezeigt.
name-taken: Ein anderes Ziel verwendet diesen Namen bereits.
still-needed: "Noch erforderlich: {fields}"
missing:
name: Name
url: Webhook-URL
template: Vorlage
discard-title: Dieses Ziel verwerfen?
discard-keep: Weiter bearbeiten
discard-confirm: Verwerfen
type: Typ
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, benutzerdefinierter Endpunkt
@@ -359,19 +420,34 @@ notifications:
cloud-description: Push-Benachrichtigungen, E-Mail-Alarme und KI-gestützte Zusammenfassungen
webhook-url: Webhook URL
webhook-url-placeholder: https://hooks.foo.com/services/...
webhook-url-invalid: Geben Sie eine vollständige http:// oder https:// URL ein.
api-key: API-Schlüssel
payload-format: Payload-Format
payload-format: Nutzlast
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Benutzerdefiniert
format-replace-warning: Dies ersetzt die von Ihnen geschriebene Vorlage.
format-replace-confirm: Ersetzen
template: Vorlage
template-hint: Go-Template-Syntax
template-placeholder: JSON-Body mit Go-Template-Platzhaltern
payload-mode-json: Gültiges JSON. Jeder Wert wird für Sie gerendert und escaped.
payload-mode-raw: Kein JSON, daher wird der gesamte Body als gerenderter Text gesendet.
payload-mode-unbalanced: Nicht geschlossene Template-Anweisung.
payload-mode-empty: Eine leere Vorlage sendet die vollständige Benachrichtigung als JSON.
variables: Verfügbare Variablen
variables-all: Jeder Alarm
variables-log: Nur Log-Alarme
variables-metric: Nur Metrik-Alarme
variables-event: Nur Ereignisalarme
variables-scope-hint: Felder außerhalb von „Jeder Alarm“ sind nur für ihren eigenen Alarmtyp gesetzt; anderswo verwendet schlagen sie beim Senden fehl.
headers: Benutzerdefinierte Header
headers-hint: Optionale HTTP-Header (z.B. Authorization)
header-name: Header-Name
header-value: Header-Wert
add-header: Header hinzufügen
remove-header: Header entfernen
test: Testen
test-success: Test erfolgreich
cancel: Abbrechen
+92 -16
View File
@@ -348,6 +348,7 @@ notifications:
enabled: Enabled ({count})
paused: Paused ({count})
no-alerts: No alerts configured yet. Create one to get started.
no-alerts-in-filter: No alerts match this filter.
alert:
paused: Paused
containers: Containers
@@ -367,30 +368,72 @@ notifications:
edit: Edit
duplicate: Duplicate
delete: Delete
used-by: "Used by {count} alerts"
unused: Not used by any alert
copy-of: "{name} copy"
copy-failed: Could not duplicate this destination
delete-failed: Could not delete this destination
delete-warning: Delete this destination?
delete-warning-used: "{count} alerts send here and will stop notifying."
delete-cancel: Keep
delete-confirm: Delete
alert-form:
create-title: Create Alert
edit-title: Edit Alert
description: Subscribe to log events matching your criteria
description-log: Get notified when matching lines show up in your container logs
description-metric: Get notified when container CPU, memory or disk usage crosses a threshold
description-event: Get notified when containers start, stop, die or change health
required: Required
still-needed: "Still needed: {fields}"
missing:
container-expression: containers
condition: condition
destination: destination
name: name
alert-name: Alert Name
alert-name-placeholder: e.g., Test API Errors
container-filter: Container Filter
containers-match: "{count} containers match: {names}"
no-containers-match: No containers match this filter
log-filter: Log Filter
logs-match: "{count} logs match"
no-logs-match: No logs match this filter
alert-name-hint: Shown on the alert card and in every notification it sends.
container-filter: Containers
container-filter-hint: Which containers this alert watches. Only running containers are matched.
containers-match: "Matches {count} running containers: {names}"
no-containers-match: No running containers match this filter
checking: Checking…
examples: "Examples:"
use-example: Use this example
seconds-unit: sec
log-filter: Log Condition
log-filter-hint: Which log lines trigger the alert.
logs-match: "{count} matching lines in the last {window}"
no-logs-match: No matching lines in the last {window}
log-preview-hint: Write a condition to see which recent log lines would have triggered this alert.
log-scan-note: Preview scanned the first {count} matching containers.
preview-needs-containers: Pick containers first to preview against real data.
preview-fix-expression: Fix the expression to see a preview.
destination: Destination
select-destination: Select a destination
no-destinations: No destinations configured. Add one first.
no-destinations: No destinations configured yet.
preview: Preview
cancel: Cancel
save: Save
create: Create Alert
metric-filter: Metric Expression
expression-valid: Expression is valid
metric-fields-hint: "Available fields: {fields}"
discard-title: Discard this alert?
discard-keep: Keep editing
discard-confirm: Discard
metric-filter: Metric Condition
metric-filter-hint: "Compare cpu (%), memory (%), memoryUsage (bytes) or mounts free space."
metric-preview-hint: Write a condition to see how your containers measure up right now.
metric-preview-note: Based on the last {duration} of stats Dozzle has collected.
metric-container: Container
metric-cpu: CPU
metric-memory: Memory
metric-window-match: Window
metric-firing: Would fire
metric-matching-now: Over now
metric-no-samples: No stats
metric-no-samples-hint: No stats collected yet for the matching containers.
metric-would-fire: "Would fire for {count} containers right now"
metric-would-not-fire: No container is over the threshold right now
sample-window-label: Sample Window
sample-window-hint: "Alert triggers when condition is met for {duration}"
sample-window-hint: "Fires once the condition holds for {duration}"
cooldown-label: Cooldown
cooldown-hint: "{duration} between alerts per container"
no-cooldown: No cooldown
@@ -398,14 +441,32 @@ notifications:
log-alert: Log Alert
metric-alert: Metric Alert
event-alert: Event Alert
event-filter: Event Expression
event-fields-hint: "Available fields: {fields}"
log-alert-description: Match lines in container logs
metric-alert-description: Watch CPU, memory and disk usage
event-alert-description: React to starts, stops and health changes
type-switch-warning: Saving replaces this alert's {type} condition, which will be lost.
event-filter: Event Condition
event-filter-hint: Which container events trigger the alert.
event-matches: "Matches {count} of these events"
event-matches-none: Matches none of these events
event-preview-hint: Write a condition to see which container events it would catch.
event-preview-note: Sample events, not history. Docker events are matched as they happen.
destination-form:
create-title: Add Destination
edit-title: Edit Destination
description: Where should notifications be sent?
name: Name
name-placeholder: e.g., Production Slack
name-hint: How this destination is listed when picking one for an alert.
name-taken: Another destination already uses this name.
still-needed: "Still needed: {fields}"
missing:
name: name
url: webhook URL
template: template
discard-title: Discard this destination?
discard-keep: Keep editing
discard-confirm: Discard
type: Type
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, custom endpoint
@@ -413,19 +474,34 @@ notifications:
cloud-description: Push notifications, email alerts, and AI-powered summaries
webhook-url: Webhook URL
webhook-url-placeholder: https://hooks.foo.com/services/...
webhook-url-invalid: Enter a full http:// or https:// URL.
api-key: API Key
payload-format: Payload Format
payload-format: Payload
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Custom
format-replace-warning: This replaces the template you have written.
format-replace-confirm: Replace
template: Template
template-hint: Go template syntax
template-placeholder: JSON body with Go template placeholders
payload-mode-json: Valid JSON. Each value is rendered and escaped for you.
payload-mode-raw: Not JSON, so the whole body is sent as rendered text.
payload-mode-unbalanced: Unclosed template action.
payload-mode-empty: Empty template sends the full notification as JSON.
variables: Available variables
variables-all: Every alert
variables-log: Log alerts only
variables-metric: Metric alerts only
variables-event: Event alerts only
variables-scope-hint: Fields outside "Every alert" are only set for their own alert type; using one elsewhere fails at send time.
headers: Custom Headers
headers-hint: Optional HTTP headers (e.g., Authorization)
header-name: Header name
header-value: Header value
add-header: Add Header
remove-header: Remove header
test: Test
test-success: Test successful
cancel: Cancel
+92 -16
View File
@@ -336,6 +336,7 @@ notifications:
enabled: Habilitados ({count})
paused: Pausados ({count})
no-alerts: Aún no hay alertas configuradas. Cree una para comenzar.
no-alerts-in-filter: Ninguna alerta coincide con este filtro.
default-alert-created: Hemos creado una alerta predeterminada para ti. Recibirás una notificación cuando un contenedor termine con un error. Puedes editarla o pausarla cuando quieras.
default-alert-failed: No se pudo crear tu alerta automáticamente. Aquí tienes el formulario.
alert:
@@ -357,30 +358,72 @@ notifications:
edit: Editar
duplicate: Duplicar
delete: Eliminar
used-by: "Usado por {count} alertas"
unused: No lo usa ninguna alerta
copy-of: "Copia de {name}"
copy-failed: No se pudo duplicar este destino
delete-failed: No se pudo eliminar este destino
delete-warning: ¿Eliminar este destino?
delete-warning-used: "{count} alertas envían aquí y dejarán de notificar."
delete-cancel: Conservar
delete-confirm: Eliminar
alert-form:
create-title: Crear Alerta
edit-title: Editar Alerta
description: Suscríbase a eventos de registro que coincidan con sus criterios
description-log: Recibe una notificación cuando aparezcan líneas coincidentes en los logs de tus contenedores
description-metric: Recibe una notificación cuando la CPU, memoria o disco de un contenedor supere un umbral
description-event: Recibe una notificación cuando los contenedores arranquen, se detengan, mueran o cambien de estado
required: Obligatorio
still-needed: "Aún falta: {fields}"
missing:
container-expression: contenedores
condition: condición
destination: destino
name: nombre
alert-name: Nombre de la Alerta
alert-name-placeholder: ej., Errores de API de Prueba
container-filter: Filtro de Contenedor
containers-match: "{count} contenedores coinciden: {names}"
no-containers-match: Ningún contenedor coincide con este filtro
log-filter: Filtro de Registro
logs-match: "{count} registros coinciden"
no-logs-match: Ningún registro coincide con este filtro
alert-name-hint: Se muestra en la tarjeta de la alerta y en cada notificación que envía.
container-filter: Contenedores
container-filter-hint: Qué contenedores vigila esta alerta. Solo se consideran los contenedores en ejecución.
containers-match: "Coincide con {count} contenedores en ejecución: {names}"
no-containers-match: Ningún contenedor en ejecución coincide con este filtro
checking: Comprobando…
examples: "Ejemplos:"
use-example: Usar este ejemplo
seconds-unit: seg
log-filter: Condición de log
log-filter-hint: Qué líneas de log disparan la alerta.
logs-match: "{count} líneas coincidentes en las últimas {window}"
no-logs-match: Ninguna línea coincidente en las últimas {window}
log-preview-hint: Escribe una condición para ver qué líneas de log recientes habrían disparado esta alerta.
log-scan-note: La vista previa revisó los primeros {count} contenedores coincidentes.
preview-needs-containers: Elige contenedores primero para ver una vista previa con datos reales.
preview-fix-expression: Corrige la expresión para ver una vista previa.
destination: Destino
select-destination: Seleccione un destino
no-destinations: No hay destinos configurados. Añada uno primero.
no-destinations: Todavía no hay destinos configurados.
preview: Vista previa
cancel: Cancelar
save: Guardar
create: Crear Alerta
metric-filter: Expresión de Métrica
expression-valid: La expresión es válida
metric-fields-hint: "Campos disponibles: {fields}"
discard-title: ¿Descartar esta alerta?
discard-keep: Seguir editando
discard-confirm: Descartar
metric-filter: Condición de métrica
metric-filter-hint: "Compara cpu (%), memory (%), memoryUsage (bytes) o el espacio libre de mounts."
metric-preview-hint: Escribe una condición para ver cómo están tus contenedores ahora mismo.
metric-preview-note: Basado en las últimas {duration} de estadísticas recogidas por Dozzle.
metric-container: Contenedor
metric-cpu: CPU
metric-memory: Memoria
metric-window-match: Ventana
metric-firing: Se dispararía
metric-matching-now: Por encima
metric-no-samples: Sin datos
metric-no-samples-hint: Todavía no se han recogido estadísticas de los contenedores coincidentes.
metric-would-fire: "Se dispararía para {count} contenedores ahora mismo"
metric-would-not-fire: Ningún contenedor supera el umbral ahora mismo
sample-window-label: Ventana de muestreo
sample-window-hint: "La alerta se activa cuando la condición se cumple durante {duration}"
sample-window-hint: "Se dispara cuando la condición se mantiene durante {duration}"
cooldown-label: Tiempo de espera
cooldown-hint: "{duration} entre alertas por contenedor"
no-cooldown: Sin tiempo de espera
@@ -388,14 +431,32 @@ notifications:
log-alert: Alerta de registro
metric-alert: Alerta de métrica
event-alert: Alerta de evento
event-filter: Expresión de evento
event-fields-hint: "Campos disponibles: {fields}"
log-alert-description: Filtrar líneas en los logs de contenedores
metric-alert-description: Vigilar CPU, memoria y disco
event-alert-description: Reaccionar a arranques, paradas y cambios de estado
type-switch-warning: Al guardar se reemplazará la condición {type} de esta alerta y se perderá.
event-filter: Condición de evento
event-filter-hint: Qué eventos de contenedor disparan la alerta.
event-matches: "Coincide con {count} de estos eventos"
event-matches-none: No coincide con ninguno de estos eventos
event-preview-hint: Escribe una condición para ver qué eventos de contenedor detectaría.
event-preview-note: Eventos de ejemplo, no un histórico. Los eventos de Docker se evalúan en el momento.
destination-form:
create-title: Añadir Destino
edit-title: Editar Destino
description: ¿Dónde deben enviarse las notificaciones?
name: Nombre
name-placeholder: ej., Slack de Producción
name-hint: Así se lista este destino al elegir uno para una alerta.
name-taken: Otro destino ya usa este nombre.
still-needed: "Aún falta: {fields}"
missing:
name: nombre
url: URL del webhook
template: plantilla
discard-title: ¿Descartar este destino?
discard-keep: Seguir editando
discard-confirm: Descartar
type: Tipo
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, endpoint personalizado
@@ -403,19 +464,34 @@ notifications:
cloud-description: Notificaciones push, alertas por correo electrónico y resúmenes con IA
webhook-url: URL del Webhook
webhook-url-placeholder: https://hooks.foo.com/services/...
webhook-url-invalid: Introduce una URL http:// o https:// completa.
api-key: Clave API
payload-format: Formato de Payload
payload-format: Carga útil
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Personalizado
format-replace-warning: Esto reemplaza la plantilla que has escrito.
format-replace-confirm: Reemplazar
template: Plantilla
template-hint: Sintaxis de plantilla Go
template-placeholder: Cuerpo JSON con marcadores de plantilla Go
payload-mode-json: JSON válido. Cada valor se renderiza y escapa por ti.
payload-mode-raw: No es JSON, así que el cuerpo entero se envía como texto renderizado.
payload-mode-unbalanced: Acción de plantilla sin cerrar.
payload-mode-empty: Una plantilla vacía envía la notificación completa como JSON.
variables: Variables disponibles
variables-all: Todas las alertas
variables-log: Solo alertas de log
variables-metric: Solo alertas de métrica
variables-event: Solo alertas de evento
variables-scope-hint: Los campos fuera de "Todas las alertas" solo existen para su propio tipo; usarlos en otro falla al enviar.
headers: Encabezados Personalizados
headers-hint: Encabezados HTTP opcionales (ej. Authorization)
header-name: Nombre del encabezado
header-value: Valor del encabezado
add-header: Añadir Encabezado
remove-header: Quitar cabecera
test: Probar
test-success: Prueba exitosa
cancel: Cancelar
+92 -16
View File
@@ -292,6 +292,7 @@ notifications:
enabled: Activés ({count})
paused: En pause ({count})
no-alerts: Aucune alerte configurée. Créez-en une pour commencer.
no-alerts-in-filter: Aucune alerte ne correspond à ce filtre.
default-alert-created: Nous avons créé une alerte par défaut pour vous. Vous serez notifié lorsqu'un conteneur se termine avec une erreur. Vous pouvez la modifier ou la mettre en pause à tout moment.
default-alert-failed: Impossible de créer votre alerte automatiquement. Voici le formulaire à la place.
alert:
@@ -313,30 +314,72 @@ notifications:
edit: Modifier
duplicate: Dupliquer
delete: Supprimer
used-by: "Utilisée par {count} alertes"
unused: Utilisée par aucune alerte
copy-of: "Copie de {name}"
copy-failed: Impossible de dupliquer cette destination
delete-failed: Impossible de supprimer cette destination
delete-warning: Supprimer cette destination ?
delete-warning-used: "{count} alertes envoient ici et cesseront de notifier."
delete-cancel: Conserver
delete-confirm: Supprimer
alert-form:
create-title: Créer une Alerte
edit-title: Modifier l'Alerte
description: Abonnez-vous aux événements de journal correspondant à vos critères
description-log: Soyez notifié quand des lignes correspondantes apparaissent dans vos logs de conteneurs
description-metric: Soyez notifié quand le CPU, la mémoire ou le disque d'un conteneur dépasse un seuil
description-event: Soyez notifié quand des conteneurs démarrent, s'arrêtent, meurent ou changent d'état de santé
required: Obligatoire
still-needed: "Encore nécessaire : {fields}"
missing:
container-expression: conteneurs
condition: condition
destination: destination
name: nom
alert-name: Nom de l'Alerte
alert-name-placeholder: ex., Erreurs API de Test
container-filter: Filtre de Conteneur
containers-match: "{count} conteneurs correspondent: {names}"
no-containers-match: Aucun conteneur ne correspond à ce filtre
log-filter: Filtre de Journal
logs-match: "{count} journaux correspondent"
no-logs-match: Aucun journal ne correspond à ce filtre
alert-name-hint: Affiché sur la carte de l'alerte et dans chaque notification envoyée.
container-filter: Conteneurs
container-filter-hint: Quels conteneurs cette alerte surveille. Seuls les conteneurs en cours d'exécution sont pris en compte.
containers-match: "Correspond à {count} conteneurs en cours d'exécution : {names}"
no-containers-match: Aucun conteneur en cours d'exécution ne correspond à ce filtre
checking: Vérification…
examples: "Exemples :"
use-example: Utiliser cet exemple
seconds-unit: s
log-filter: Condition de log
log-filter-hint: Quelles lignes de log déclenchent l'alerte.
logs-match: "{count} lignes correspondantes sur les {window} dernières"
no-logs-match: Aucune ligne correspondante sur les {window} dernières
log-preview-hint: Écrivez une condition pour voir quelles lignes de log récentes auraient déclenché cette alerte.
log-scan-note: L'aperçu a parcouru les {count} premiers conteneurs correspondants.
preview-needs-containers: Choisissez d'abord des conteneurs pour un aperçu sur des données réelles.
preview-fix-expression: Corrigez l'expression pour voir un aperçu.
destination: Destination
select-destination: Sélectionnez une destination
no-destinations: Aucune destination configurée. Ajoutez-en une d'abord.
no-destinations: Aucune destination configurée pour le moment.
preview: Aperçu
cancel: Annuler
save: Enregistrer
create: Créer l'Alerte
metric-filter: Expression de Métrique
expression-valid: L'expression est valide
metric-fields-hint: "Champs disponibles : {fields}"
discard-title: Abandonner cette alerte ?
discard-keep: Continuer l'édition
discard-confirm: Abandonner
metric-filter: Condition de métrique
metric-filter-hint: "Comparez cpu (%), memory (%), memoryUsage (octets) ou l'espace libre de mounts."
metric-preview-hint: Écrivez une condition pour voir où en sont vos conteneurs en ce moment.
metric-preview-note: Basé sur les {duration} dernières de statistiques collectées par Dozzle.
metric-container: Conteneur
metric-cpu: CPU
metric-memory: Mémoire
metric-window-match: Fenêtre
metric-firing: Se déclencherait
metric-matching-now: Au-dessus
metric-no-samples: Aucune stat
metric-no-samples-hint: Aucune statistique collectée pour l'instant sur les conteneurs correspondants.
metric-would-fire: "Se déclencherait pour {count} conteneurs en ce moment"
metric-would-not-fire: Aucun conteneur ne dépasse le seuil en ce moment
sample-window-label: Fenêtre d'échantillonnage
sample-window-hint: "L'alerte se déclenche lorsque la condition est remplie pendant {duration}"
sample-window-hint: "Se déclenche dès que la condition tient pendant {duration}"
cooldown-label: Temps d'attente
cooldown-hint: "{duration} entre les alertes par conteneur"
no-cooldown: Aucun temps d'attente
@@ -344,14 +387,32 @@ notifications:
log-alert: Alerte de journal
metric-alert: Alerte de métrique
event-alert: Alerte d'événement
event-filter: Expression d'événement
event-fields-hint: "Champs disponibles : {fields}"
log-alert-description: Filtrer les lignes des logs de conteneurs
metric-alert-description: Surveiller CPU, mémoire et disque
event-alert-description: Réagir aux démarrages, arrêts et changements de santé
type-switch-warning: L'enregistrement remplacera la condition {type} de cette alerte, qui sera perdue.
event-filter: Condition d'événement
event-filter-hint: Quels événements de conteneur déclenchent l'alerte.
event-matches: "Correspond à {count} de ces événements"
event-matches-none: Ne correspond à aucun de ces événements
event-preview-hint: Écrivez une condition pour voir quels événements de conteneur elle capterait.
event-preview-note: Événements d'exemple, pas un historique. Les événements Docker sont évalués en temps réel.
destination-form:
create-title: Ajouter une Destination
edit-title: Modifier la Destination
description: Où les notifications doivent-elles être envoyées?
name: Nom
name-placeholder: ex., Slack Production
name-hint: Nom affiché lors du choix d'une destination pour une alerte.
name-taken: Une autre destination porte déjà ce nom.
still-needed: "Encore nécessaire : {fields}"
missing:
name: nom
url: URL du webhook
template: modèle
discard-title: Abandonner cette destination ?
discard-keep: Continuer l'édition
discard-confirm: Abandonner
type: Type
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, endpoint personnalisé
@@ -359,19 +420,34 @@ notifications:
cloud-description: Notifications push, alertes par e-mail et résumés alimentés par l'IA
webhook-url: URL du Webhook
webhook-url-placeholder: https://hooks.foo.com/services/...
webhook-url-invalid: Saisissez une URL http:// ou https:// complète.
api-key: Clé API
payload-format: Format du Payload
payload-format: Charge utile
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Personnalisé
format-replace-warning: Cela remplacera le modèle que vous avez écrit.
format-replace-confirm: Remplacer
template: Modèle
template-hint: Syntaxe de modèle Go
template-placeholder: Corps JSON avec des variables de modèle Go
payload-mode-json: JSON valide. Chaque valeur est rendue et échappée pour vous.
payload-mode-raw: Pas du JSON, le corps entier est donc envoyé en texte rendu.
payload-mode-unbalanced: Action de modèle non fermée.
payload-mode-empty: Un modèle vide envoie la notification complète en JSON.
variables: Variables disponibles
variables-all: Toutes les alertes
variables-log: Alertes de log uniquement
variables-metric: Alertes de métrique uniquement
variables-event: Alertes d'événement uniquement
variables-scope-hint: Les champs hors « Toutes les alertes » ne sont renseignés que pour leur propre type ; les utiliser ailleurs échoue à l'envoi.
headers: En-têtes Personnalisés
headers-hint: En-têtes HTTP optionnels (ex. Authorization)
header-name: Nom de l'en-tête
header-value: Valeur de l'en-tête
add-header: Ajouter un En-tête
remove-header: Supprimer l'en-tête
test: Tester
test-success: Test réussi
cancel: Annuler
+92 -16
View File
@@ -304,6 +304,7 @@ notifications:
enabled: Diaktifkan ({count})
paused: Dijeda ({count})
no-alerts: Belum ada peringatan yang dikonfigurasi. Buat satu untuk memulai.
no-alerts-in-filter: Tidak ada peringatan yang cocok dengan filter ini.
default-alert-created: Kami membuat peringatan bawaan untuk Anda. Anda akan diberi tahu saat sebuah kontainer berhenti dengan kesalahan. Ubah atau jeda kapan saja.
default-alert-failed: Peringatan tidak bisa dibuat otomatis. Silakan gunakan formulir ini.
alert:
@@ -325,30 +326,72 @@ notifications:
edit: Edit
duplicate: Duplikat
delete: Hapus
used-by: "Dipakai oleh {count} peringatan"
unused: Tidak dipakai peringatan mana pun
copy-of: "Salinan {name}"
copy-failed: Tidak dapat menduplikasi tujuan ini
delete-failed: Tidak dapat menghapus tujuan ini
delete-warning: Hapus tujuan ini?
delete-warning-used: "{count} peringatan mengirim ke sini dan akan berhenti memberi tahu."
delete-cancel: Simpan
delete-confirm: Hapus
alert-form:
create-title: Buat Peringatan
edit-title: Edit Peringatan
description: Berlangganan ke event log yang sesuai dengan kriteria Anda
description-log: Dapatkan notifikasi saat baris yang cocok muncul di log kontainer Anda
description-metric: Dapatkan notifikasi saat CPU, memori, atau disk kontainer melewati ambang batas
description-event: Dapatkan notifikasi saat kontainer mulai, berhenti, mati, atau berubah kondisi
required: Wajib
still-needed: "Masih dibutuhkan: {fields}"
missing:
container-expression: kontainer
condition: kondisi
destination: tujuan
name: nama
alert-name: Nama Peringatan
alert-name-placeholder: mis., Kesalahan API Test
container-filter: Filter Kontainer
containers-match: "{count} kontainer cocok: {names}"
no-containers-match: Tidak ada kontainer yang cocok dengan filter ini
log-filter: Filter Log
logs-match: "{count} log cocok"
no-logs-match: Tidak ada log yang cocok dengan filter ini
alert-name-hint: Ditampilkan pada kartu peringatan dan di setiap notifikasi yang dikirim.
container-filter: Kontainer
container-filter-hint: Kontainer mana yang diawasi peringatan ini. Hanya kontainer yang berjalan yang dicocokkan.
containers-match: "Cocok dengan {count} kontainer yang berjalan: {names}"
no-containers-match: Tidak ada kontainer berjalan yang cocok dengan filter ini
checking: Memeriksa…
examples: "Contoh:"
use-example: Gunakan contoh ini
seconds-unit: dtk
log-filter: Kondisi log
log-filter-hint: Baris log mana yang memicu peringatan.
logs-match: "{count} baris cocok dalam {window} terakhir"
no-logs-match: Tidak ada baris yang cocok dalam {window} terakhir
log-preview-hint: Tulis kondisi untuk melihat baris log terbaru mana yang akan memicu peringatan ini.
log-scan-note: Pratinjau memindai {count} kontainer cocok pertama.
preview-needs-containers: Pilih kontainer dulu untuk melihat pratinjau dengan data nyata.
preview-fix-expression: Perbaiki ekspresi untuk melihat pratinjau.
destination: Tujuan
select-destination: Pilih tujuan
no-destinations: Tidak ada tujuan yang dikonfigurasi. Tambahkan satu terlebih dahulu.
no-destinations: Belum ada tujuan yang dikonfigurasi.
preview: Pratinjau
cancel: Batal
save: Simpan
create: Buat Peringatan
metric-filter: Ekspresi Metrik
expression-valid: Ekspresi valid
metric-fields-hint: "Field tersedia: {fields}"
discard-title: Buang peringatan ini?
discard-keep: Lanjut mengedit
discard-confirm: Buang
metric-filter: Kondisi metrik
metric-filter-hint: "Bandingkan cpu (%), memory (%), memoryUsage (byte), atau ruang kosong mounts."
metric-preview-hint: Tulis kondisi untuk melihat kondisi kontainer Anda saat ini.
metric-preview-note: Berdasarkan {duration} terakhir statistik yang dikumpulkan Dozzle.
metric-container: Kontainer
metric-cpu: CPU
metric-memory: Memori
metric-window-match: Jendela
metric-firing: Akan terpicu
metric-matching-now: Di atas kini
metric-no-samples: Tanpa data
metric-no-samples-hint: Belum ada statistik yang dikumpulkan untuk kontainer yang cocok.
metric-would-fire: "Saat ini akan terpicu untuk {count} kontainer"
metric-would-not-fire: Tidak ada kontainer yang melewati ambang batas saat ini
sample-window-label: Jendela Sampel
sample-window-hint: "Peringatan dipicu ketika kondisi terpenuhi selama {duration}"
sample-window-hint: "Terpicu setelah kondisi bertahan selama {duration}"
cooldown-label: Jeda
cooldown-hint: "{duration} antara peringatan per kontainer"
no-cooldown: Tanpa jeda
@@ -356,14 +399,32 @@ notifications:
log-alert: Peringatan Log
metric-alert: Peringatan Metrik
event-alert: Peringatan Peristiwa
event-filter: Ekspresi Peristiwa
event-fields-hint: "Kolom yang tersedia: {fields}"
log-alert-description: Cocokkan baris di log kontainer
metric-alert-description: Awasi penggunaan CPU, memori, dan disk
event-alert-description: Bereaksi pada start, stop, dan perubahan kondisi
type-switch-warning: Menyimpan akan mengganti kondisi {type} peringatan ini, dan kondisi itu hilang.
event-filter: Kondisi peristiwa
event-filter-hint: Peristiwa kontainer mana yang memicu peringatan.
event-matches: "Cocok dengan {count} dari peristiwa ini"
event-matches-none: Tidak cocok dengan satu pun peristiwa ini
event-preview-hint: Tulis kondisi untuk melihat peristiwa kontainer mana yang akan tertangkap.
event-preview-note: Contoh peristiwa, bukan riwayat. Peristiwa Docker dicocokkan saat terjadi.
destination-form:
create-title: Tambah Tujuan
edit-title: Edit Tujuan
description: Ke mana notifikasi harus dikirim?
name: Nama
name-placeholder: mis., Slack Produksi
name-hint: Begini tujuan ini tampil saat memilih tujuan untuk sebuah peringatan.
name-taken: Tujuan lain sudah memakai nama ini.
still-needed: "Masih dibutuhkan: {fields}"
missing:
name: nama
url: URL webhook
template: templat
discard-title: Buang tujuan ini?
discard-keep: Lanjut mengedit
discard-confirm: Buang
type: Tipe
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, endpoint kustom
@@ -371,19 +432,34 @@ notifications:
cloud-description: Notifikasi push, peringatan email, dan ringkasan bertenaga AI
webhook-url: URL Webhook
webhook-url-placeholder: https://hooks.foo.com/services/...
webhook-url-invalid: Masukkan URL http:// atau https:// yang lengkap.
api-key: Kunci API
payload-format: Format Payload
payload-format: Payload
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Kustom
format-replace-warning: Ini menggantikan templat yang sudah Anda tulis.
format-replace-confirm: Ganti
template: Template
template-hint: Sintaks template Go
template-placeholder: Body JSON dengan placeholder templat Go
payload-mode-json: JSON valid. Setiap nilai dirender dan di-escape untuk Anda.
payload-mode-raw: Bukan JSON, jadi seluruh body dikirim sebagai teks hasil render.
payload-mode-unbalanced: Aksi templat belum ditutup.
payload-mode-empty: Templat kosong mengirim seluruh notifikasi sebagai JSON.
variables: Variabel tersedia
variables-all: Semua peringatan
variables-log: Hanya peringatan log
variables-metric: Hanya peringatan metrik
variables-event: Hanya peringatan peristiwa
variables-scope-hint: Field di luar "Semua peringatan" hanya terisi untuk jenisnya sendiri; dipakai di jenis lain akan gagal saat dikirim.
headers: Header Kustom
headers-hint: Header HTTP opsional (mis. Authorization)
header-name: Nama header
header-value: Nilai header
add-header: Tambah Header
remove-header: Hapus header
test: Test
test-success: Test berhasil
cancel: Batal
+92 -16
View File
@@ -292,6 +292,7 @@ notifications:
enabled: Abilitati ({count})
paused: In pausa ({count})
no-alerts: Nessun avviso configurato. Creane uno per iniziare.
no-alerts-in-filter: Nessun avviso corrisponde a questo filtro.
default-alert-created: Abbiamo creato un avviso predefinito per te. Riceverai una notifica quando un container termina con un errore. Puoi modificarlo o metterlo in pausa quando vuoi.
default-alert-failed: Non è stato possibile creare l'avviso automaticamente. Ecco il modulo.
alert:
@@ -313,30 +314,72 @@ notifications:
edit: Modifica
duplicate: Duplica
delete: Elimina
used-by: "Usata da {count} avvisi"
unused: Non usata da nessun avviso
copy-of: "Copia di {name}"
copy-failed: Impossibile duplicare questa destinazione
delete-failed: Impossibile eliminare questa destinazione
delete-warning: Eliminare questa destinazione?
delete-warning-used: "{count} avvisi inviano qui e smetteranno di notificare."
delete-cancel: Mantieni
delete-confirm: Elimina
alert-form:
create-title: Crea Avviso
edit-title: Modifica Avviso
description: Iscriviti agli eventi di log che corrispondono ai tuoi criteri
description-log: Ricevi una notifica quando compaiono righe corrispondenti nei log dei tuoi container
description-metric: Ricevi una notifica quando CPU, memoria o disco di un container supera una soglia
description-event: Ricevi una notifica quando i container si avviano, si fermano, terminano o cambiano stato di salute
required: Obbligatorio
still-needed: "Manca ancora: {fields}"
missing:
container-expression: container
condition: condizione
destination: destinazione
name: nome
alert-name: Nome Avviso
alert-name-placeholder: es., Errori API Test
container-filter: Filtro Container
containers-match: "{count} container corrispondono: {names}"
no-containers-match: Nessun container corrisponde a questo filtro
log-filter: Filtro Log
logs-match: "{count} log corrispondono"
no-logs-match: Nessun log corrisponde a questo filtro
alert-name-hint: Mostrato sulla scheda dell'avviso e in ogni notifica che invia.
container-filter: Container
container-filter-hint: Quali container osserva questo avviso. Vengono considerati solo i container in esecuzione.
containers-match: "Corrisponde a {count} container in esecuzione: {names}"
no-containers-match: Nessun container in esecuzione corrisponde a questo filtro
checking: Verifica in corso…
examples: "Esempi:"
use-example: Usa questo esempio
seconds-unit: sec
log-filter: Condizione sui log
log-filter-hint: Quali righe di log attivano l'avviso.
logs-match: "{count} righe corrispondenti nelle ultime {window}"
no-logs-match: Nessuna riga corrispondente nelle ultime {window}
log-preview-hint: Scrivi una condizione per vedere quali righe di log recenti avrebbero attivato questo avviso.
log-scan-note: L'anteprima ha esaminato i primi {count} container corrispondenti.
preview-needs-containers: Scegli prima i container per vedere un'anteprima con dati reali.
preview-fix-expression: Correggi l'espressione per vedere un'anteprima.
destination: Destinazione
select-destination: Seleziona una destinazione
no-destinations: Nessuna destinazione configurata. Aggiungine una prima.
no-destinations: Nessuna destinazione configurata finora.
preview: Anteprima
cancel: Annulla
save: Salva
create: Crea Avviso
metric-filter: Espressione Metrica
expression-valid: L'espressione è valida
metric-fields-hint: "Campi disponibili: {fields}"
discard-title: Scartare questo avviso?
discard-keep: Continua a modificare
discard-confirm: Scarta
metric-filter: Condizione sulle metriche
metric-filter-hint: "Confronta cpu (%), memory (%), memoryUsage (byte) o lo spazio libero di mounts."
metric-preview-hint: Scrivi una condizione per vedere come stanno i tuoi container in questo momento.
metric-preview-note: Basato sulle ultime {duration} di statistiche raccolte da Dozzle.
metric-container: Container
metric-cpu: CPU
metric-memory: Memoria
metric-window-match: Finestra
metric-firing: Scatterebbe
metric-matching-now: Sopra ora
metric-no-samples: Nessun dato
metric-no-samples-hint: Non sono ancora state raccolte statistiche per i container corrispondenti.
metric-would-fire: "Scatterebbe per {count} container in questo momento"
metric-would-not-fire: Nessun container supera la soglia in questo momento
sample-window-label: Finestra di campionamento
sample-window-hint: "L'avviso si attiva quando la condizione è soddisfatta per {duration}"
sample-window-hint: "Scatta quando la condizione si mantiene per {duration}"
cooldown-label: Tempo di attesa
cooldown-hint: "{duration} tra gli avvisi per container"
no-cooldown: Nessun tempo di attesa
@@ -344,14 +387,32 @@ notifications:
log-alert: Avviso log
metric-alert: Avviso metrica
event-alert: Avviso evento
event-filter: Espressione evento
event-fields-hint: "Campi disponibili: {fields}"
log-alert-description: Filtra le righe nei log dei container
metric-alert-description: Sorveglia CPU, memoria e disco
event-alert-description: Reagisci ad avvii, arresti e cambi di salute
type-switch-warning: Salvando sostituirai la condizione {type} di questo avviso, che andrà persa.
event-filter: Condizione sugli eventi
event-filter-hint: Quali eventi dei container attivano l'avviso.
event-matches: "Corrisponde a {count} di questi eventi"
event-matches-none: Non corrisponde a nessuno di questi eventi
event-preview-hint: Scrivi una condizione per vedere quali eventi dei container intercetterebbe.
event-preview-note: Eventi di esempio, non uno storico. Gli eventi Docker vengono valutati quando accadono.
destination-form:
create-title: Aggiungi Destinazione
edit-title: Modifica Destinazione
description: Dove devono essere inviate le notifiche?
name: Nome
name-placeholder: es., Slack Produzione
name-hint: Come questa destinazione compare quando ne scegli una per un avviso.
name-taken: Un'altra destinazione usa già questo nome.
still-needed: "Manca ancora: {fields}"
missing:
name: nome
url: URL del webhook
template: modello
discard-title: Scartare questa destinazione?
discard-keep: Continua a modificare
discard-confirm: Scarta
type: Tipo
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, endpoint personalizzato
@@ -359,19 +420,34 @@ notifications:
cloud-description: Notifiche push, avvisi email e riepiloghi basati su AI
webhook-url: URL Webhook
webhook-url-placeholder: https://hooks.foo.com/services/...
webhook-url-invalid: Inserisci un URL http:// o https:// completo.
api-key: Chiave API
payload-format: Formato Payload
payload-format: Payload
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Personalizzato
format-replace-warning: Questo sostituisce il modello che hai scritto.
format-replace-confirm: Sostituisci
template: Template
template-hint: Sintassi template Go
template-placeholder: Corpo JSON con segnaposto di template Go
payload-mode-json: JSON valido. Ogni valore viene reso ed escapato per te.
payload-mode-raw: Non è JSON, quindi l'intero corpo viene inviato come testo reso.
payload-mode-unbalanced: Azione di template non chiusa.
payload-mode-empty: Un modello vuoto invia la notifica completa come JSON.
variables: Variabili disponibili
variables-all: Tutti gli avvisi
variables-log: Solo avvisi sui log
variables-metric: Solo avvisi sulle metriche
variables-event: Solo avvisi sugli eventi
variables-scope-hint: I campi fuori da "Tutti gli avvisi" esistono solo per il proprio tipo; usarli altrove fallisce all'invio.
headers: Header Personalizzati
headers-hint: Header HTTP opzionali (es. Authorization)
header-name: Nome header
header-value: Valore header
add-header: Aggiungi Header
remove-header: Rimuovi intestazione
test: Test
test-success: Test riuscito
cancel: Annulla
+92 -16
View File
@@ -296,6 +296,7 @@ notifications:
enabled: 활성화됨 ({count})
paused: 일시정지됨 ({count})
no-alerts: 아직 설정된 알림이 없습니다. 시작하려면 하나를 만드세요.
no-alerts-in-filter: 이 필터와 일치하는 알림이 없습니다.
default-alert-created: 기본 알림을 만들어 두었습니다. 컨테이너가 오류로 종료되면 알려드립니다. 언제든지 수정하거나 일시중지할 수 있습니다.
default-alert-failed: 알림을 자동으로 만들지 못했습니다. 대신 아래 양식을 사용하세요.
alert:
@@ -317,30 +318,72 @@ notifications:
edit: 편집
duplicate: 복제
delete: 삭제
used-by: "알림 {count}개가 사용 중"
unused: 사용하는 알림 없음
copy-of: "{name} 복사본"
copy-failed: 이 대상을 복제하지 못했습니다
delete-failed: 이 대상을 삭제하지 못했습니다
delete-warning: 이 대상을 삭제할까요?
delete-warning-used: "알림 {count}개가 여기로 보내고 있으며 알림이 중단됩니다."
delete-cancel: 유지
delete-confirm: 삭제
alert-form:
create-title: 알림 만들기
edit-title: 알림 편집
description: 기준에 맞는 로그 이벤트를 구독합니다
description-log: 컨테이너 로그에 일치하는 줄이 나타나면 알림을 받습니다
description-metric: 컨테이너의 CPU, 메모리 또는 디스크 사용량이 임계값을 넘으면 알림을 받습니다
description-event: 컨테이너가 시작, 중지, 종료되거나 상태가 바뀌면 알림을 받습니다
required: 필수
still-needed: "아직 필요: {fields}"
missing:
container-expression: 컨테이너
condition: 조건
destination: 대상
name: 이름
alert-name: 알림 이름
alert-name-placeholder: "예: 테스트 API 오류"
container-filter: 컨테이너 필터
containers-match: "컨테이너 {count}개 일치: {names}"
no-containers-match: 이 필터와 일치하는 컨테이너가 없습니다
log-filter: 로그 필터
logs-match: "로그 {count}개 일치"
no-logs-match: 이 필터와 일치하는 로그가 없습니다
alert-name-hint: 알림 카드와 전송되는 모든 알림에 표시됩니다.
container-filter: 컨테이너
container-filter-hint: 이 알림이 감시할 컨테이너입니다. 실행 중인 컨테이너만 일치합니다.
containers-match: "실행 중인 컨테이너 {count}개와 일치: {names}"
no-containers-match: 이 필터와 일치하는 실행 중인 컨테이너가 없습니다
checking: 확인 중…
examples: "예시:"
use-example: 이 예시 사용
seconds-unit: 초
log-filter: 로그 조건
log-filter-hint: 어떤 로그 줄이 알림을 발생시킬지 정합니다.
logs-match: "최근 {window} 동안 일치하는 줄 {count}개"
no-logs-match: 최근 {window} 동안 일치하는 줄이 없습니다
log-preview-hint: 조건을 작성하면 최근 로그 중 어떤 줄이 이 알림을 발생시켰을지 볼 수 있습니다.
log-scan-note: 미리보기는 일치하는 컨테이너 중 처음 {count}개를 검사했습니다.
preview-needs-containers: 실제 데이터로 미리 보려면 먼저 컨테이너를 선택하세요.
preview-fix-expression: 미리보기를 보려면 표현식을 수정하세요.
destination: 목적지
select-destination: 목적지 선택
no-destinations: 설정된 목적지가 없습니다. 먼저 하나를 추가하세요.
no-destinations: 아직 구성된 대상이 없습니다.
preview: 미리보기
cancel: 취소
save: 저장
create: 알림 만들기
metric-filter: 메트릭 표현식
expression-valid: 표현식이 유효합니다
metric-fields-hint: "사용 가능한 필드: {fields}"
discard-title: 이 알림을 취소할까요?
discard-keep: 계속 편집
discard-confirm: 취소
metric-filter: 메트릭 조건
metric-filter-hint: "cpu (%), memory (%), memoryUsage (바이트) 또는 mounts의 여유 공간을 비교합니다."
metric-preview-hint: 조건을 작성하면 지금 컨테이너 상태가 어떤지 볼 수 있습니다.
metric-preview-note: Dozzle이 수집한 최근 {duration}의 통계를 기준으로 합니다.
metric-container: 컨테이너
metric-cpu: CPU
metric-memory: 메모리
metric-window-match: 구간
metric-firing: 발생함
metric-matching-now: 현재 초과
metric-no-samples: 통계 없음
metric-no-samples-hint: 일치하는 컨테이너에 대해 아직 수집된 통계가 없습니다.
metric-would-fire: "지금 컨테이너 {count}개에서 발생합니다"
metric-would-not-fire: 지금 임계값을 넘은 컨테이너가 없습니다
sample-window-label: 샘플 창
sample-window-hint: "조건이 {duration} 동안 충족되면 알림이 트리거됩니다"
sample-window-hint: "조건이 {duration} 동안 유지되면 발생합니다"
cooldown-label: 대기 시간
cooldown-hint: "컨테이너당 알림 간격 {duration}"
no-cooldown: 대기 시간 없음
@@ -348,14 +391,32 @@ notifications:
log-alert: 로그 알림
metric-alert: 메트릭 알림
event-alert: 이벤트 알림
event-filter: 이벤트 표현식
event-fields-hint: "사용 가능한 필드: {fields}"
log-alert-description: 컨테이너 로그의 줄을 매칭
metric-alert-description: CPU, 메모리, 디스크 사용량 감시
event-alert-description: 시작, 중지, 상태 변화에 반응
type-switch-warning: 저장하면 이 알림의 {type} 조건이 대체되어 사라집니다.
event-filter: 이벤트 조건
event-filter-hint: 어떤 컨테이너 이벤트가 알림을 발생시킬지 정합니다.
event-matches: "이 이벤트 중 {count}개와 일치"
event-matches-none: 이 이벤트 중 어느 것과도 일치하지 않습니다
event-preview-hint: 조건을 작성하면 어떤 컨테이너 이벤트를 잡아낼지 볼 수 있습니다.
event-preview-note: 이력이 아니라 예시 이벤트입니다. Docker 이벤트는 발생하는 즉시 평가됩니다.
destination-form:
create-title: 목적지 추가
edit-title: 목적지 편집
description: 알림을 어디로 보낼까요?
name: 이름
name-placeholder: "예: 프로덕션 Slack"
name-hint: 알림에서 대상을 고를 때 이 이름으로 표시됩니다.
name-taken: 다른 대상이 이미 이 이름을 사용 중입니다.
still-needed: "아직 필요: {fields}"
missing:
name: 이름
url: 웹훅 URL
template: 템플릿
discard-title: 이 대상을 취소할까요?
discard-keep: 계속 편집
discard-confirm: 취소
type: 유형
webhook-title: HTTP 웹훅
webhook-description: Slack, Discord, 사용자 정의 엔드포인트
@@ -363,19 +424,34 @@ notifications:
cloud-description: 푸시 알림, 이메일 알림 및 AI 기반 요약
webhook-url: 웹훅 URL
webhook-url-placeholder: https://hooks.foo.com/services/...
webhook-url-invalid: http:// 또는 https:// 로 시작하는 전체 URL을 입력하세요.
api-key: API 키
payload-format: 페이로드 형식
payload-format: 페이로드
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: 사용자 정의
format-replace-warning: 작성한 템플릿을 덮어씁니다.
format-replace-confirm: 바꾸기
template: 템플릿
template-hint: Go 템플릿 구문
template-placeholder: Go 템플릿 자리표시자가 들어간 JSON 본문
payload-mode-json: 올바른 JSON입니다. 각 값이 렌더링되고 이스케이프됩니다.
payload-mode-raw: JSON이 아니므로 본문 전체가 렌더링된 텍스트로 전송됩니다.
payload-mode-unbalanced: 닫히지 않은 템플릿 구문입니다.
payload-mode-empty: 빈 템플릿은 알림 전체를 JSON으로 보냅니다.
variables: 사용 가능한 변수
variables-all: 모든 알림
variables-log: 로그 알림 전용
variables-metric: 메트릭 알림 전용
variables-event: 이벤트 알림 전용
variables-scope-hint: ‘모든 알림’ 밖의 필드는 해당 알림 유형에서만 채워지며, 다른 유형에서 쓰면 전송 시 실패합니다.
headers: 사용자 정의 헤더
headers-hint: "선택적 HTTP 헤더 (예: Authorization)"
header-name: 헤더 이름
header-value: 헤더 값
add-header: 헤더 추가
remove-header: 헤더 제거
test: 테스트
test-success: 테스트 성공
cancel: 취소
+92 -16
View File
@@ -293,6 +293,7 @@ notifications:
enabled: Ingeschakeld ({count})
paused: Gepauzeerd ({count})
no-alerts: Nog geen waarschuwingen geconfigureerd. Maak er een om te beginnen.
no-alerts-in-filter: Geen meldingen komen overeen met dit filter.
default-alert-created: We hebben een standaardwaarschuwing voor je aangemaakt. Je krijgt bericht wanneer een container met een fout stopt. Je kunt deze altijd aanpassen of pauzeren.
default-alert-failed: Je waarschuwing kon niet automatisch worden aangemaakt. Gebruik in plaats daarvan dit formulier.
alert:
@@ -314,30 +315,72 @@ notifications:
edit: Bewerken
duplicate: Dupliceren
delete: Verwijderen
used-by: "Gebruikt door {count} meldingen"
unused: Door geen enkele melding gebruikt
copy-of: "Kopie van {name}"
copy-failed: Deze bestemming kon niet worden gedupliceerd
delete-failed: Deze bestemming kon niet worden verwijderd
delete-warning: Deze bestemming verwijderen?
delete-warning-used: "{count} meldingen sturen hierheen en stoppen met notificeren."
delete-cancel: Behouden
delete-confirm: Verwijderen
alert-form:
create-title: Waarschuwing aanmaken
edit-title: Waarschuwing bewerken
description: Abonneer op loggebeurtenissen die voldoen aan je criteria
description-log: Krijg een melding wanneer overeenkomende regels in je containerlogs verschijnen
description-metric: Krijg een melding wanneer CPU, geheugen of schijfgebruik van een container een drempel overschrijdt
description-event: Krijg een melding wanneer containers starten, stoppen, crashen of van gezondheid veranderen
required: Verplicht
still-needed: "Nog nodig: {fields}"
missing:
container-expression: containers
condition: voorwaarde
destination: bestemming
name: naam
alert-name: Waarschuwingsnaam
alert-name-placeholder: bijv. Test API-fouten
container-filter: Containerfilter
containers-match: "{count} containers komen overeen: {names}"
no-containers-match: Geen containers komen overeen met dit filter
log-filter: Logfilter
logs-match: "{count} logs komen overeen"
no-logs-match: Geen logs komen overeen met dit filter
alert-name-hint: Wordt getoond op de meldingskaart en in elke notificatie die hij verstuurt.
container-filter: Containers
container-filter-hint: Welke containers deze melding bewaakt. Alleen draaiende containers tellen mee.
containers-match: "Komt overeen met {count} draaiende containers: {names}"
no-containers-match: Geen draaiende containers komen overeen met dit filter
checking: Bezig met controleren…
examples: "Voorbeelden:"
use-example: Dit voorbeeld gebruiken
seconds-unit: sec
log-filter: Logvoorwaarde
log-filter-hint: Welke logregels de melding activeren.
logs-match: "{count} overeenkomende regels in de laatste {window}"
no-logs-match: Geen overeenkomende regels in de laatste {window}
log-preview-hint: Schrijf een voorwaarde om te zien welke recente logregels deze melding hadden geactiveerd.
log-scan-note: Het voorbeeld doorzocht de eerste {count} overeenkomende containers.
preview-needs-containers: Kies eerst containers om een voorbeeld met echte data te zien.
preview-fix-expression: Corrigeer de expressie om een voorbeeld te zien.
destination: Bestemming
select-destination: Selecteer een bestemming
no-destinations: Geen bestemmingen geconfigureerd. Voeg er eerst een toe.
no-destinations: Nog geen bestemmingen geconfigureerd.
preview: Voorbeeld
cancel: Annuleren
save: Opslaan
create: Waarschuwing aanmaken
metric-filter: Metriekexpressie
expression-valid: Expressie is geldig
metric-fields-hint: "Beschikbare velden: {fields}"
discard-title: Deze melding verwerpen?
discard-keep: Verder bewerken
discard-confirm: Verwerpen
metric-filter: Metriekvoorwaarde
metric-filter-hint: "Vergelijk cpu (%), memory (%), memoryUsage (bytes) of de vrije ruimte van mounts."
metric-preview-hint: Schrijf een voorwaarde om te zien hoe je containers er nu voor staan.
metric-preview-note: Gebaseerd op de laatste {duration} aan statistieken die Dozzle heeft verzameld.
metric-container: Container
metric-cpu: CPU
metric-memory: Geheugen
metric-window-match: Venster
metric-firing: Zou afgaan
metric-matching-now: Nu erboven
metric-no-samples: Geen data
metric-no-samples-hint: Nog geen statistieken verzameld voor de overeenkomende containers.
metric-would-fire: "Zou nu afgaan voor {count} containers"
metric-would-not-fire: Geen enkele container zit nu boven de drempel
sample-window-label: Steekproefvenster
sample-window-hint: "Waarschuwing wordt geactiveerd wanneer de voorwaarde gedurende {duration} is voldaan"
sample-window-hint: "Gaat af zodra de voorwaarde {duration} standhoudt"
cooldown-label: Wachttijd
cooldown-hint: "{duration} tussen waarschuwingen per container"
no-cooldown: Geen wachttijd
@@ -345,14 +388,32 @@ notifications:
log-alert: Logwaarschuwing
metric-alert: Metrikwaarschuwing
event-alert: Gebeurteniswaarschuwing
event-filter: Gebeurtenisexpressie
event-fields-hint: "Beschikbare velden: {fields}"
log-alert-description: Regels in containerlogs matchen
metric-alert-description: CPU-, geheugen- en schijfgebruik bewaken
event-alert-description: Reageren op starts, stops en gezondheidswijzigingen
type-switch-warning: Opslaan vervangt de {type}-voorwaarde van deze melding, die verloren gaat.
event-filter: Gebeurtenisvoorwaarde
event-filter-hint: Welke containergebeurtenissen de melding activeren.
event-matches: "Komt overeen met {count} van deze gebeurtenissen"
event-matches-none: Komt met geen van deze gebeurtenissen overeen
event-preview-hint: Schrijf een voorwaarde om te zien welke containergebeurtenissen hij zou opvangen.
event-preview-note: Voorbeeldgebeurtenissen, geen geschiedenis. Docker-gebeurtenissen worden live beoordeeld.
destination-form:
create-title: Bestemming toevoegen
edit-title: Bestemming bewerken
description: Waar moeten meldingen naartoe worden gestuurd?
name: Naam
name-placeholder: bijv. Productie Slack
name-hint: Zo staat deze bestemming in de lijst bij het kiezen voor een melding.
name-taken: Een andere bestemming gebruikt deze naam al.
still-needed: "Nog nodig: {fields}"
missing:
name: naam
url: webhook-URL
template: sjabloon
discard-title: Deze bestemming verwerpen?
discard-keep: Verder bewerken
discard-confirm: Verwerpen
type: Type
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, aangepast eindpunt
@@ -360,19 +421,34 @@ notifications:
cloud-description: Pushmeldingen, e-mailwaarschuwingen en AI-samenvattingen
webhook-url: Webhook URL
webhook-url-placeholder: https://hooks.foo.com/services/...
webhook-url-invalid: Voer een volledige http:// of https:// URL in.
api-key: API-sleutel
payload-format: Payload-formaat
payload-format: Payload
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Aangepast
format-replace-warning: Dit vervangt het sjabloon dat je hebt geschreven.
format-replace-confirm: Vervangen
template: Sjabloon
template-hint: Go-sjabloonsyntaxis
template-placeholder: JSON-body met Go-sjabloonvelden
payload-mode-json: Geldige JSON. Elke waarde wordt voor je gerenderd en geëscaped.
payload-mode-raw: Geen JSON, dus de hele body wordt als gerenderde tekst verstuurd.
payload-mode-unbalanced: Niet-afgesloten sjabloonactie.
payload-mode-empty: Een leeg sjabloon stuurt de volledige melding als JSON.
variables: Beschikbare variabelen
variables-all: Elke melding
variables-log: Alleen logmeldingen
variables-metric: Alleen metriekmeldingen
variables-event: Alleen gebeurtenismeldingen
variables-scope-hint: Velden buiten "Elke melding" bestaan alleen voor hun eigen type; elders gebruikt mislukt het bij verzenden.
headers: Aangepaste Headers
headers-hint: Optionele HTTP-headers (bijv. Authorization)
header-name: Headernaam
header-value: Headerwaarde
add-header: Header toevoegen
remove-header: Header verwijderen
test: Testen
test-success: Test geslaagd
cancel: Annuleren
+92 -16
View File
@@ -295,6 +295,7 @@ notifications:
enabled: Włączone ({count})
paused: Wstrzymane ({count})
no-alerts: Brak skonfigurowanych alertów. Utwórz jeden, aby rozpocząć.
no-alerts-in-filter: Żaden alert nie pasuje do tego filtra.
default-alert-created: Utworzyliśmy dla Ciebie domyślny alert. Otrzymasz powiadomienie, gdy kontener zakończy się błędem. Możesz go edytować lub wstrzymać w dowolnej chwili.
default-alert-failed: Nie udało się automatycznie utworzyć alertu. Skorzystaj z formularza poniżej.
alert:
@@ -316,30 +317,72 @@ notifications:
edit: Edytuj
duplicate: Duplikuj
delete: Usuń
used-by: "Używany przez {count} alertów"
unused: Nieużywany przez żaden alert
copy-of: "Kopia {name}"
copy-failed: Nie udało się zduplikować tego celu
delete-failed: Nie udało się usunąć tego celu
delete-warning: Usunąć ten cel?
delete-warning-used: "{count} alertów wysyła tutaj i przestanie powiadamiać."
delete-cancel: Zachowaj
delete-confirm: Usuń
alert-form:
create-title: Utwórz Alert
edit-title: Edytuj Alert
description: Subskrybuj zdarzenia logów pasujące do twoich kryteriów
description-log: Otrzymuj powiadomienia, gdy w logach kontenerów pojawią się pasujące linie
description-metric: Otrzymuj powiadomienia, gdy CPU, pamięć lub dysk kontenera przekroczy próg
description-event: Otrzymuj powiadomienia, gdy kontenery startują, zatrzymują się, kończą pracę lub zmieniają stan
required: Wymagane
still-needed: "Wciąż brakuje: {fields}"
missing:
container-expression: kontenery
condition: warunek
destination: cel
name: nazwa
alert-name: Nazwa Alertu
alert-name-placeholder: np. Błędy API Testowego
container-filter: Filtr Kontenerów
containers-match: "{count} kontenerów pasuje: {names}"
no-containers-match: Żaden kontener nie pasuje do tego filtra
log-filter: Filtr Logów
logs-match: "{count} logów pasuje"
no-logs-match: Żaden log nie pasuje do tego filtra
alert-name-hint: Widoczna na karcie alertu i w każdym wysyłanym powiadomieniu.
container-filter: Kontenery
container-filter-hint: Które kontenery obserwuje ten alert. Dopasowywane są tylko działające kontenery.
containers-match: "Pasuje do {count} działających kontenerów: {names}"
no-containers-match: Żaden działający kontener nie pasuje do tego filtra
checking: Sprawdzanie…
examples: "Przykłady:"
use-example: Użyj tego przykładu
seconds-unit: sek
log-filter: Warunek logów
log-filter-hint: Które linie logów wyzwalają alert.
logs-match: "{count} pasujących linii w ciągu ostatnich {window}"
no-logs-match: Brak pasujących linii w ciągu ostatnich {window}
log-preview-hint: Napisz warunek, aby zobaczyć, które ostatnie linie logów wyzwoliłyby ten alert.
log-scan-note: Podgląd przeszukał pierwsze {count} pasujących kontenerów.
preview-needs-containers: Najpierw wybierz kontenery, aby zobaczyć podgląd na prawdziwych danych.
preview-fix-expression: Popraw wyrażenie, aby zobaczyć podgląd.
destination: Miejsce docelowe
select-destination: Wybierz miejsce docelowe
no-destinations: Brak skonfigurowanych miejsc docelowych. Najpierw dodaj jedno.
no-destinations: Nie skonfigurowano jeszcze żadnych celów.
preview: Podgląd
cancel: Anuluj
save: Zapisz
create: Utwórz Alert
metric-filter: Wyrażenie metryki
expression-valid: Wyrażenie jest poprawne
metric-fields-hint: "Dostępne pola: {fields}"
discard-title: Odrzucić ten alert?
discard-keep: Kontynuuj edycję
discard-confirm: Odrzuć
metric-filter: Warunek metryki
metric-filter-hint: "Porównaj cpu (%), memory (%), memoryUsage (bajty) lub wolne miejsce w mounts."
metric-preview-hint: Napisz warunek, aby zobaczyć, jak wypadają Twoje kontenery w tej chwili.
metric-preview-note: Na podstawie ostatnich {duration} statystyk zebranych przez Dozzle.
metric-container: Kontener
metric-cpu: CPU
metric-memory: Pamięć
metric-window-match: Okno
metric-firing: Zadziałałby
metric-matching-now: Powyżej teraz
metric-no-samples: Brak danych
metric-no-samples-hint: Nie zebrano jeszcze statystyk dla pasujących kontenerów.
metric-would-fire: "Zadziałałby teraz dla {count} kontenerów"
metric-would-not-fire: Żaden kontener nie przekracza teraz progu
sample-window-label: Okno próbkowania
sample-window-hint: "Alert jest wyzwalany, gdy warunek jest spełniony przez {duration}"
sample-window-hint: "Wyzwala się, gdy warunek utrzyma się przez {duration}"
cooldown-label: Czas oczekiwania
cooldown-hint: "{duration} między alertami na kontener"
no-cooldown: Bez czasu oczekiwania
@@ -347,14 +390,32 @@ notifications:
log-alert: Alert logów
metric-alert: Alert metryki
event-alert: Alert zdarzenia
event-filter: Wyrażenie zdarzenia
event-fields-hint: "Dostępne pola: {fields}"
log-alert-description: Dopasuj linie w logach kontenerów
metric-alert-description: Obserwuj CPU, pamięć i dysk
event-alert-description: Reaguj na starty, zatrzymania i zmiany stanu
type-switch-warning: Zapisanie zastąpi warunek {type} tego alertu, który zostanie utracony.
event-filter: Warunek zdarzenia
event-filter-hint: Które zdarzenia kontenerów wyzwalają alert.
event-matches: "Pasuje do {count} z tych zdarzeń"
event-matches-none: Nie pasuje do żadnego z tych zdarzeń
event-preview-hint: Napisz warunek, aby zobaczyć, które zdarzenia kontenerów by wychwycił.
event-preview-note: Przykładowe zdarzenia, nie historia. Zdarzenia Dockera są oceniane na bieżąco.
destination-form:
create-title: Dodaj Miejsce Docelowe
edit-title: Edytuj Miejsce Docelowe
description: Gdzie mają być wysyłane powiadomienia?
name: Nazwa
name-placeholder: np. Produkcyjny Slack
name-hint: Tak ten cel jest opisany przy wyborze celu dla alertu.
name-taken: Inny cel już używa tej nazwy.
still-needed: "Wciąż brakuje: {fields}"
missing:
name: nazwa
url: adres webhooka
template: szablon
discard-title: Odrzucić ten cel?
discard-keep: Kontynuuj edycję
discard-confirm: Odrzuć
type: Typ
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, własny endpoint
@@ -362,19 +423,34 @@ notifications:
cloud-description: Powiadomienia push, alerty e-mail i podsumowania AI
webhook-url: URL Webhooka
webhook-url-placeholder: https://hooks.foo.com/services/...
webhook-url-invalid: Podaj pełny adres http:// lub https://.
api-key: Klucz API
payload-format: Format Payload
payload-format: Ładunek
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Własny
format-replace-warning: To zastąpi napisany przez Ciebie szablon.
format-replace-confirm: Zastąp
template: Szablon
template-hint: Składnia szablonu Go
template-placeholder: Treść JSON z symbolami szablonu Go
payload-mode-json: Poprawny JSON. Każda wartość jest renderowana i escapowana za Ciebie.
payload-mode-raw: To nie JSON, więc cała treść jest wysyłana jako wyrenderowany tekst.
payload-mode-unbalanced: Niedomknięta instrukcja szablonu.
payload-mode-empty: Pusty szablon wysyła całe powiadomienie jako JSON.
variables: Dostępne zmienne
variables-all: Każdy alert
variables-log: Tylko alerty logów
variables-metric: Tylko alerty metryk
variables-event: Tylko alerty zdarzeń
variables-scope-hint: Pola spoza „Każdy alert” istnieją tylko dla swojego typu; użyte gdzie indziej zawiodą przy wysyłce.
headers: Niestandardowe Nagłówki
headers-hint: Opcjonalne nagłówki HTTP (np. Authorization)
header-name: Nazwa nagłówka
header-value: Wartość nagłówka
add-header: Dodaj Nagłówek
remove-header: Usuń nagłówek
test: Test
test-success: Test zakończony pomyślnie
cancel: Anuluj
+92 -16
View File
@@ -292,6 +292,7 @@ notifications:
enabled: Habilitados ({count})
paused: Pausados ({count})
no-alerts: Nenhum alerta configurado ainda. Crie um para começar.
no-alerts-in-filter: Nenhum alerta corresponde a este filtro.
default-alert-created: Criamos um alerta padrão para você. Você será notificado quando um container encerrar com erro. Edite ou pause quando quiser.
default-alert-failed: Não foi possível criar seu alerta automaticamente. Use o formulário abaixo.
alert:
@@ -313,30 +314,72 @@ notifications:
edit: Editar
duplicate: Duplicar
delete: Excluir
used-by: "Usado por {count} alertas"
unused: Não usado por nenhum alerta
copy-of: "Cópia de {name}"
copy-failed: Não foi possível duplicar este destino
delete-failed: Não foi possível excluir este destino
delete-warning: Excluir este destino?
delete-warning-used: "{count} alertas enviam para cá e deixarão de notificar."
delete-cancel: Manter
delete-confirm: Excluir
alert-form:
create-title: Criar Alerta
edit-title: Editar Alerta
description: Inscreva-se em eventos de log que correspondam aos seus critérios
description-log: Seja notificado quando linhas correspondentes aparecerem nos logs dos seus contêineres
description-metric: Seja notificado quando CPU, memória ou disco de um contêiner ultrapassar um limite
description-event: Seja notificado quando contêineres iniciarem, pararem, morrerem ou mudarem de saúde
required: Obrigatório
still-needed: "Ainda falta: {fields}"
missing:
container-expression: contêineres
condition: condição
destination: destino
name: nome
alert-name: Nome do Alerta
alert-name-placeholder: ex., Erros de API de Teste
container-filter: Filtro de Container
containers-match: "{count} containers correspondem: {names}"
no-containers-match: Nenhum container corresponde a este filtro
log-filter: Filtro de Log
logs-match: "{count} logs correspondem"
no-logs-match: Nenhum log corresponde a este filtro
alert-name-hint: Aparece no cartão do alerta e em todas as notificações que ele envia.
container-filter: Contêineres
container-filter-hint: Quais contêineres este alerta observa. Apenas contêineres em execução são considerados.
containers-match: "Corresponde a {count} contêineres em execução: {names}"
no-containers-match: Nenhum contêiner em execução corresponde a este filtro
checking: Verificando…
examples: "Exemplos:"
use-example: Usar este exemplo
seconds-unit: seg
log-filter: Condição de log
log-filter-hint: Quais linhas de log disparam o alerta.
logs-match: "{count} linhas correspondentes nas últimas {window}"
no-logs-match: Nenhuma linha correspondente nas últimas {window}
log-preview-hint: Escreva uma condição para ver quais linhas de log recentes teriam disparado este alerta.
log-scan-note: A pré-visualização percorreu os primeiros {count} contêineres correspondentes.
preview-needs-containers: Escolha contêineres primeiro para pré-visualizar com dados reais.
preview-fix-expression: Corrija a expressão para ver uma pré-visualização.
destination: Destino
select-destination: Selecione um destino
no-destinations: Nenhum destino configurado. Adicione um primeiro.
no-destinations: Nenhum destino configurado ainda.
preview: Prévia
cancel: Cancelar
save: Salvar
create: Criar Alerta
metric-filter: Expressão de Métrica
expression-valid: A expressão é válida
metric-fields-hint: "Campos disponíveis: {fields}"
discard-title: Descartar este alerta?
discard-keep: Continuar editando
discard-confirm: Descartar
metric-filter: Condição de métrica
metric-filter-hint: "Compare cpu (%), memory (%), memoryUsage (bytes) ou o espaço livre de mounts."
metric-preview-hint: Escreva uma condição para ver como seus contêineres estão agora.
metric-preview-note: Baseado nas últimas {duration} de estatísticas coletadas pelo Dozzle.
metric-container: Contêiner
metric-cpu: CPU
metric-memory: Memória
metric-window-match: Janela
metric-firing: Dispararia
metric-matching-now: Acima agora
metric-no-samples: Sem dados
metric-no-samples-hint: Nenhuma estatística coletada ainda para os contêineres correspondentes.
metric-would-fire: "Dispararia para {count} contêineres agora"
metric-would-not-fire: Nenhum contêiner está acima do limite agora
sample-window-label: Janela de amostragem
sample-window-hint: "O alerta é acionado quando a condição é atendida por {duration}"
sample-window-hint: "Dispara quando a condição se mantém por {duration}"
cooldown-label: Tempo de espera
cooldown-hint: "{duration} entre alertas por container"
no-cooldown: Sem tempo de espera
@@ -344,14 +387,32 @@ notifications:
log-alert: Alerta de log
metric-alert: Alerta de métrica
event-alert: Alerta de evento
event-filter: Expressão de evento
event-fields-hint: "Campos disponíveis: {fields}"
log-alert-description: Filtrar linhas nos logs dos contêineres
metric-alert-description: Observar CPU, memória e disco
event-alert-description: Reagir a inícios, paradas e mudanças de saúde
type-switch-warning: Salvar substitui a condição {type} deste alerta, que será perdida.
event-filter: Condição de evento
event-filter-hint: Quais eventos de contêiner disparam o alerta.
event-matches: "Corresponde a {count} destes eventos"
event-matches-none: Não corresponde a nenhum destes eventos
event-preview-hint: Escreva uma condição para ver quais eventos de contêiner ela capturaria.
event-preview-note: Eventos de exemplo, não histórico. Eventos do Docker são avaliados no momento em que ocorrem.
destination-form:
create-title: Adicionar Destino
edit-title: Editar Destino
description: Para onde as notificações devem ser enviadas?
name: Nome
name-placeholder: ex., Slack de Produção
name-hint: Como este destino aparece ao escolher um para um alerta.
name-taken: Outro destino já usa este nome.
still-needed: "Ainda falta: {fields}"
missing:
name: nome
url: URL do webhook
template: modelo
discard-title: Descartar este destino?
discard-keep: Continuar editando
discard-confirm: Descartar
type: Tipo
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, endpoint personalizado
@@ -359,19 +420,34 @@ notifications:
cloud-description: Notificações push, alertas por email e resumos com IA
webhook-url: URL do Webhook
webhook-url-placeholder: https://hooks.foo.com/services/...
webhook-url-invalid: Informe uma URL http:// ou https:// completa.
api-key: Chave API
payload-format: Formato do Payload
payload-format: Carga
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Personalizado
format-replace-warning: Isto substitui o modelo que você escreveu.
format-replace-confirm: Substituir
template: Template
template-hint: Sintaxe de template Go
template-placeholder: Corpo JSON com marcadores de modelo Go
payload-mode-json: JSON válido. Cada valor é renderizado e escapado para você.
payload-mode-raw: Não é JSON, então o corpo inteiro é enviado como texto renderizado.
payload-mode-unbalanced: Ação de modelo não fechada.
payload-mode-empty: Um modelo vazio envia a notificação completa como JSON.
variables: Variáveis disponíveis
variables-all: Todos os alertas
variables-log: Apenas alertas de log
variables-metric: Apenas alertas de métrica
variables-event: Apenas alertas de evento
variables-scope-hint: Campos fora de "Todos os alertas" só existem para o próprio tipo; usar um em outro falha no envio.
headers: Cabeçalhos Personalizados
headers-hint: Cabeçalhos HTTP opcionais (ex. Authorization)
header-name: Nome do cabeçalho
header-value: Valor do cabeçalho
add-header: Adicionar Cabeçalho
remove-header: Remover cabeçalho
test: Testar
test-success: Teste bem-sucedido
cancel: Cancelar
+92 -16
View File
@@ -292,6 +292,7 @@ notifications:
enabled: Включены ({count})
paused: Приостановлены ({count})
no-alerts: Оповещения ещё не настроены. Создайте одно для начала.
no-alerts-in-filter: Нет оповещений, соответствующих этому фильтру.
default-alert-created: Мы создали для вас оповещение по умолчанию. Вы получите уведомление, когда контейнер завершится с ошибкой. Его можно изменить или приостановить в любой момент.
default-alert-failed: Не удалось создать оповещение автоматически. Воспользуйтесь формой ниже.
alert:
@@ -313,30 +314,72 @@ notifications:
edit: Редактировать
duplicate: Дублировать
delete: Удалить
used-by: "Используется в {count} оповещениях"
unused: Не используется ни в одном оповещении
copy-of: "Копия {name}"
copy-failed: Не удалось продублировать это назначение
delete-failed: Не удалось удалить это назначение
delete-warning: Удалить это назначение?
delete-warning-used: "{count} оповещений отправляют сюда и перестанут уведомлять."
delete-cancel: Оставить
delete-confirm: Удалить
alert-form:
create-title: Создать Оповещение
edit-title: Редактировать Оповещение
description: Подпишитесь на события логов, соответствующие вашим критериям
description-log: Получайте уведомления, когда в логах контейнеров появляются подходящие строки
description-metric: Получайте уведомления, когда CPU, память или диск контейнера превышают порог
description-event: Получайте уведомления, когда контейнеры запускаются, останавливаются, падают или меняют состояние
required: Обязательно
still-needed: "Ещё нужно: {fields}"
missing:
container-expression: контейнеры
condition: условие
destination: назначение
name: имя
alert-name: Имя Оповещения
alert-name-placeholder: напр., Ошибки Тестового API
container-filter: Фильтр Контейнеров
containers-match: "{count} контейнеров соответствует: {names}"
no-containers-match: Нет контейнеров, соответствующих этому фильтру
log-filter: Фильтр Логов
logs-match: "{count} логов соответствует"
no-logs-match: Нет логов, соответствующих этому фильтру
alert-name-hint: Показывается на карточке оповещения и в каждом отправленном уведомлении.
container-filter: Контейнеры
container-filter-hint: За какими контейнерами следит это оповещение. Учитываются только запущенные контейнеры.
containers-match: "Подходит {count} запущенных контейнеров: {names}"
no-containers-match: Ни один запущенный контейнер не подходит под этот фильтр
checking: Проверка…
examples: "Примеры:"
use-example: Использовать этот пример
seconds-unit: сек
log-filter: Условие по логам
log-filter-hint: Какие строки логов вызывают оповещение.
logs-match: "{count} подходящих строк за последние {window}"
no-logs-match: Нет подходящих строк за последние {window}
log-preview-hint: Напишите условие, чтобы увидеть, какие недавние строки логов вызвали бы это оповещение.
log-scan-note: Предпросмотр просмотрел первые {count} подходящих контейнеров.
preview-needs-containers: Сначала выберите контейнеры, чтобы увидеть предпросмотр на реальных данных.
preview-fix-expression: Исправьте выражение, чтобы увидеть предпросмотр.
destination: Назначение
select-destination: Выберите назначение
no-destinations: Назначения не настроены. Сначала добавьте одно.
no-destinations: Назначения ещё не настроены.
preview: Предпросмотр
cancel: Отмена
save: Сохранить
create: Создать Оповещение
metric-filter: Выражение метрики
expression-valid: Выражение корректно
metric-fields-hint: "Доступные поля: {fields}"
discard-title: Отменить это оповещение?
discard-keep: Продолжить редактирование
discard-confirm: Отменить
metric-filter: Условие по метрикам
metric-filter-hint: "Сравнивайте cpu (%), memory (%), memoryUsage (байты) или свободное место в mounts."
metric-preview-hint: Напишите условие, чтобы увидеть текущее состояние ваших контейнеров.
metric-preview-note: На основе последних {duration} статистики, собранной Dozzle.
metric-container: Контейнер
metric-cpu: CPU
metric-memory: Память
metric-window-match: Окно
metric-firing: Сработает
metric-matching-now: Сейчас выше
metric-no-samples: Нет данных
metric-no-samples-hint: Для подходящих контейнеров статистика ещё не собрана.
metric-would-fire: "Сейчас сработало бы для {count} контейнеров"
metric-would-not-fire: Сейчас ни один контейнер не превышает порог
sample-window-label: Окно выборки
sample-window-hint: "Оповещение срабатывает, когда условие выполняется в течение {duration}"
sample-window-hint: "Срабатывает, когда условие держится {duration}"
cooldown-label: Период ожидания
cooldown-hint: "{duration} между оповещениями на контейнер"
no-cooldown: Без периода ожидания
@@ -344,14 +387,32 @@ notifications:
log-alert: Оповещение журнала
metric-alert: Оповещение метрики
event-alert: Оповещение о событии
event-filter: Выражение события
event-fields-hint: "Доступные поля: {fields}"
log-alert-description: Совпадения строк в логах контейнеров
metric-alert-description: Следить за CPU, памятью и диском
event-alert-description: Реагировать на запуски, остановки и смену состояния
type-switch-warning: Сохранение заменит условие «{type}» этого оповещения, и оно будет потеряно.
event-filter: Условие по событиям
event-filter-hint: Какие события контейнеров вызывают оповещение.
event-matches: "Подходит {count} из этих событий"
event-matches-none: Не подходит ни одно из этих событий
event-preview-hint: Напишите условие, чтобы увидеть, какие события контейнеров оно поймает.
event-preview-note: Примеры событий, а не история. События Docker проверяются по мере их появления.
destination-form:
create-title: Добавить Назначение
edit-title: Редактировать Назначение
description: Куда отправлять уведомления?
name: Имя
name-placeholder: напр., Продакшн Slack
name-hint: Так это назначение отображается при выборе для оповещения.
name-taken: Другое назначение уже занимает это имя.
still-needed: "Ещё нужно: {fields}"
missing:
name: имя
url: URL вебхука
template: шаблон
discard-title: Отменить это назначение?
discard-keep: Продолжить редактирование
discard-confirm: Отменить
type: Тип
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, пользовательская конечная точка
@@ -359,19 +420,34 @@ notifications:
cloud-description: Push-уведомления, email-оповещения и сводки на основе ИИ
webhook-url: URL Webhook
webhook-url-placeholder: https://hooks.foo.com/services/...
webhook-url-invalid: Введите полный адрес http:// или https://.
api-key: API Ключ
payload-format: Формат Payload
payload-format: Тело запроса
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Пользовательский
format-replace-warning: Это заменит написанный вами шаблон.
format-replace-confirm: Заменить
template: Шаблон
template-hint: Синтаксис шаблона Go
template-placeholder: Тело JSON с подстановками шаблона Go
payload-mode-json: Корректный JSON. Каждое значение отрисовывается и экранируется за вас.
payload-mode-raw: Это не JSON, поэтому всё тело отправляется как отрисованный текст.
payload-mode-unbalanced: Незакрытая конструкция шаблона.
payload-mode-empty: Пустой шаблон отправляет уведомление целиком в JSON.
variables: Доступные переменные
variables-all: Все оповещения
variables-log: Только оповещения по логам
variables-metric: Только оповещения по метрикам
variables-event: Только оповещения по событиям
variables-scope-hint: Поля вне раздела «Все оповещения» заполнены только для своего типа; в другом типе отправка завершится ошибкой.
headers: Пользовательские Заголовки
headers-hint: Необязательные HTTP-заголовки (напр. Authorization)
header-name: Имя заголовка
header-value: Значение заголовка
add-header: Добавить Заголовок
remove-header: Удалить заголовок
test: Тест
test-success: Тест успешен
cancel: Отмена
+92 -16
View File
@@ -298,6 +298,7 @@ notifications:
enabled: Omogočeni ({count})
paused: Ustavljeni ({count})
no-alerts: Opozorila še niso nastavljena. Ustvarite enega za začetek.
no-alerts-in-filter: Nobeno opozorilo ne ustreza temu filtru.
default-alert-created: Za vas smo ustvarili privzeto opozorilo. Obvestili vas bomo, ko se zabojnik konča z napako. Kadar koli ga lahko uredite ali začasno ustavite.
default-alert-failed: Opozorila ni bilo mogoče ustvariti samodejno. Uporabite spodnji obrazec.
alert:
@@ -319,30 +320,72 @@ notifications:
edit: Uredi
duplicate: Podvoji
delete: Izbriši
used-by: "Uporablja ga {count} opozoril"
unused: Ne uporablja ga nobeno opozorilo
copy-of: "Kopija {name}"
copy-failed: Tega cilja ni bilo mogoče podvojiti
delete-failed: Tega cilja ni bilo mogoče izbrisati
delete-warning: Izbrišem ta cilj?
delete-warning-used: "{count} opozoril pošilja sem in bo prenehalo obveščati."
delete-cancel: Obdrži
delete-confirm: Izbriši
alert-form:
create-title: Ustvari Opozorilo
edit-title: Uredi Opozorilo
description: Naročite se na dogodke dnevnika, ki ustrezajo vašim kriterijem
description-log: Prejmite obvestilo, ko se v dnevnikih vsebnikov pojavijo ustrezne vrstice
description-metric: Prejmite obvestilo, ko poraba CPE, pomnilnika ali diska vsebnika preseže prag
description-event: Prejmite obvestilo, ko se vsebniki zaženejo, ustavijo, končajo ali spremenijo stanje
required: Obvezno
still-needed: "Še manjka: {fields}"
missing:
container-expression: vsebniki
condition: pogoj
destination: cilj
name: ime
alert-name: Ime Opozorila
alert-name-placeholder: npr., Napake Testnega API-ja
container-filter: Filter Zabojnikov
containers-match: "{count} zabojnikov ustreza: {names}"
no-containers-match: Noben zabojnik ne ustreza temu filtru
log-filter: Filter Dnevnika
logs-match: "{count} dnevnikov ustreza"
no-logs-match: Noben dnevnik ne ustreza temu filtru
alert-name-hint: Prikazano na kartici opozorila in v vsakem poslanem obvestilu.
container-filter: Vsebniki
container-filter-hint: Katere vsebnike to opozorilo nadzoruje. Upoštevani so samo delujoči vsebniki.
containers-match: "Ustreza {count} delujočim vsebnikom: {names}"
no-containers-match: Noben delujoč vsebnik ne ustreza temu filtru
checking: Preverjanje…
examples: "Primeri:"
use-example: Uporabi ta primer
seconds-unit: s
log-filter: Pogoj dnevnika
log-filter-hint: Katere vrstice dnevnika sprožijo opozorilo.
logs-match: "{count} ustreznih vrstic v zadnjih {window}"
no-logs-match: Nobena vrstica ne ustreza v zadnjih {window}
log-preview-hint: Napišite pogoj, da vidite, katere nedavne vrstice dnevnika bi sprožile to opozorilo.
log-scan-note: Predogled je pregledal prvih {count} ustreznih vsebnikov.
preview-needs-containers: Najprej izberite vsebnike za predogled z resničnimi podatki.
preview-fix-expression: Popravite izraz, da vidite predogled.
destination: Cilj
select-destination: Izberite cilj
no-destinations: Cilji niso nastavljeni. Najprej dodajte enega.
no-destinations: Ciljev še ni nastavljenih.
preview: Predogled
cancel: Prekliči
save: Shrani
create: Ustvari Opozorilo
metric-filter: Izraz metrike
expression-valid: Izraz je veljaven
metric-fields-hint: "Razpoložljiva polja: {fields}"
discard-title: Zavržem to opozorilo?
discard-keep: Nadaljuj urejanje
discard-confirm: Zavrzi
metric-filter: Pogoj metrike
metric-filter-hint: "Primerjajte cpu (%), memory (%), memoryUsage (bajti) ali prosti prostor v mounts."
metric-preview-hint: Napišite pogoj, da vidite, kako so vaši vsebniki trenutno.
metric-preview-note: Na podlagi zadnjih {duration} statistike, ki jo je zbral Dozzle.
metric-container: Vsebnik
metric-cpu: CPE
metric-memory: Pomnilnik
metric-window-match: Okno
metric-firing: Bi se sprožilo
metric-matching-now: Zdaj nad
metric-no-samples: Ni podatkov
metric-no-samples-hint: Za ustrezne vsebnike statistika še ni bila zbrana.
metric-would-fire: "Trenutno bi se sprožilo za {count} vsebnikov"
metric-would-not-fire: Noben vsebnik trenutno ne presega praga
sample-window-label: Vzorčno okno
sample-window-hint: "Opozorilo se sproži, ko je pogoj izpolnjen za {duration}"
sample-window-hint: "Sproži se, ko pogoj velja {duration}"
cooldown-label: Čas mirovanja
cooldown-hint: "{duration} med opozorili na zabojnik"
no-cooldown: Brez časa mirovanja
@@ -350,14 +393,32 @@ notifications:
log-alert: Opozorilo dnevnika
metric-alert: Opozorilo metrike
event-alert: Opozorilo o dogodku
event-filter: Izraz dogodka
event-fields-hint: "Polja na voljo: {fields}"
log-alert-description: Ujemanje vrstic v dnevnikih vsebnikov
metric-alert-description: Nadzor porabe CPE, pomnilnika in diska
event-alert-description: Odziv na zagone, zaustavitve in spremembe stanja
type-switch-warning: Shranjevanje bo zamenjalo pogoj {type} tega opozorila, ki bo izgubljen.
event-filter: Pogoj dogodka
event-filter-hint: Kateri dogodki vsebnikov sprožijo opozorilo.
event-matches: "Ustreza {count} od teh dogodkov"
event-matches-none: Ne ustreza nobenemu od teh dogodkov
event-preview-hint: Napišite pogoj, da vidite, katere dogodke vsebnikov bi ujel.
event-preview-note: Vzorčni dogodki, ne zgodovina. Dogodki Dockerja se ovrednotijo, ko se zgodijo.
destination-form:
create-title: Dodaj Cilj
edit-title: Uredi Cilj
description: Kam naj se pošiljajo obvestila?
name: Ime
name-placeholder: npr., Produkcijski Slack
name-hint: Tako je ta cilj naveden pri izbiri cilja za opozorilo.
name-taken: To ime že uporablja drug cilj.
still-needed: "Še manjka: {fields}"
missing:
name: ime
url: URL webhooka
template: predloga
discard-title: Zavržem ta cilj?
discard-keep: Nadaljuj urejanje
discard-confirm: Zavrzi
type: Tip
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, končna točka po meri
@@ -365,19 +426,34 @@ notifications:
cloud-description: Push obvestila, e-poštna opozorila in povzetki z umetno inteligenco
webhook-url: URL Webhook
webhook-url-placeholder: https://hooks.foo.com/services/...
webhook-url-invalid: Vnesite celoten naslov http:// ali https://.
api-key: API Ključ
payload-format: Format Payload
payload-format: Vsebina
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Po meri
format-replace-warning: To bo zamenjalo predlogo, ki ste jo napisali.
format-replace-confirm: Zamenjaj
template: Predloga
template-hint: Sintaksa predloge Go
template-placeholder: Telo JSON z oznakami predloge Go
payload-mode-json: Veljaven JSON. Vsaka vrednost je izrisana in ubežno kodirana namesto vas.
payload-mode-raw: Ni JSON, zato je celotno telo poslano kot izrisano besedilo.
payload-mode-unbalanced: Nezaprt ukaz predloge.
payload-mode-empty: Prazna predloga pošlje celotno obvestilo kot JSON.
variables: Razpoložljive spremenljivke
variables-all: Vsa opozorila
variables-log: Samo opozorila dnevnika
variables-metric: Samo opozorila metrik
variables-event: Samo opozorila dogodkov
variables-scope-hint: Polja zunaj »Vsa opozorila« so nastavljena samo za svojo vrsto opozorila; drugje uporabljena odpovejo pri pošiljanju.
headers: Prilagojene Glave
headers-hint: Neobvezne HTTP glave (npr. Authorization)
header-name: Ime glave
header-value: Vrednost glave
add-header: Dodaj Glavo
remove-header: Odstrani glavo
test: Test
test-success: Test uspešen
cancel: Prekliči
+92 -16
View File
@@ -296,6 +296,7 @@ notifications:
enabled: Etkin ({count})
paused: Duraklatılmış ({count})
no-alerts: Henüz uyarı yapılandırılmadı. Başlamak için bir tane oluşturun.
no-alerts-in-filter: Bu filtreyle eşleşen uyarı yok.
default-alert-created: Sizin için varsayılan bir uyarı oluşturduk. Bir konteyner hatayla sonlandığında bildirim alacaksınız. Dilediğiniz zaman düzenleyebilir veya duraklatabilirsiniz.
default-alert-failed: Uyarınız otomatik olarak oluşturulamadı. Bunun yerine aşağıdaki formu kullanın.
alert:
@@ -317,30 +318,72 @@ notifications:
edit: Düzenle
duplicate: Çoğalt
delete: Sil
used-by: "{count} uyarı tarafından kullanılıyor"
unused: Hiçbir uyarı kullanmıyor
copy-of: "{name} kopyası"
copy-failed: Bu hedef çoğaltılamadı
delete-failed: Bu hedef silinemedi
delete-warning: Bu hedef silinsin mi?
delete-warning-used: "{count} uyarı buraya gönderiyor ve bildirim durduracak."
delete-cancel: Vazgeç
delete-confirm: Sil
alert-form:
create-title: Uyarı Oluştur
edit-title: Uyarıyı Düzenle
description: Kriterlerinize uyan günlük olaylarına abone olun
description-log: Konteyner loglarınızda eşleşen satırlar göründüğünde bildirim alın
description-metric: Bir konteynerin CPU, bellek veya disk kullanımı eşiği aştığında bildirim alın
description-event: Konteynerler başladığında, durduğunda, sonlandığında veya sağlık durumu değiştiğinde bildirim alın
required: Zorunlu
still-needed: "Hâlâ gerekli: {fields}"
missing:
container-expression: konteynerler
condition: koşul
destination: hedef
name: ad
alert-name: Uyarı Adı
alert-name-placeholder: ör., Test API Hataları
container-filter: Konteyner Filtresi
containers-match: "{count} konteyner eşleşiyor: {names}"
no-containers-match: Bu filtreyle eşleşen konteyner yok
log-filter: Günlük Filtresi
logs-match: "{count} günlük eşleşiyor"
no-logs-match: Bu filtreyle eşleşen günlük yok
alert-name-hint: Uyarı kartında ve gönderdiği her bildirimde görünür.
container-filter: Konteynerler
container-filter-hint: Bu uyarının izlediği konteynerler. Yalnızca çalışan konteynerler eşleştirilir.
containers-match: "{count} çalışan konteynerle eşleşiyor: {names}"
no-containers-match: Bu filtreyle eşleşen çalışan konteyner yok
checking: Kontrol ediliyor…
examples: "Örnekler:"
use-example: Bu örneği kullan
seconds-unit: sn
log-filter: Log koşulu
log-filter-hint: Hangi log satırlarının uyarıyı tetiklediği.
logs-match: "Son {window} içinde {count} eşleşen satır"
no-logs-match: Son {window} içinde eşleşen satır yok
log-preview-hint: Son log satırlarından hangilerinin bu uyarıyı tetikleyeceğini görmek için bir koşul yazın.
log-scan-note: Önizleme, eşleşen ilk {count} konteyneri taradı.
preview-needs-containers: Gerçek veriyle önizleme için önce konteynerleri seçin.
preview-fix-expression: Önizleme görmek için ifadeyi düzeltin.
destination: Hedef
select-destination: Bir hedef seçin
no-destinations: Yapılandırılmış hedef yok. Önce bir tane ekleyin.
no-destinations: Henüz hedef yapılandırılmadı.
preview: Önizleme
cancel: İptal
save: Kaydet
create: Uyarı Oluştur
metric-filter: Metrik İfadesi
expression-valid: İfade geçerli
metric-fields-hint: "Kullanılabilir alanlar: {fields}"
discard-title: Bu uyarı atılsın mı?
discard-keep: Düzenlemeye devam et
discard-confirm: At
metric-filter: Metrik koşulu
metric-filter-hint: "cpu (%), memory (%), memoryUsage (bayt) veya mounts boş alanını karşılaştırın."
metric-preview-hint: Konteynerlerinizin şu anki durumunu görmek için bir koşul yazın.
metric-preview-note: Dozzle’ın topladığı son {duration} istatistiğe dayanır.
metric-container: Konteyner
metric-cpu: CPU
metric-memory: Bellek
metric-window-match: Pencere
metric-firing: Tetiklenirdi
metric-matching-now: Şu an üstünde
metric-no-samples: Veri yok
metric-no-samples-hint: Eşleşen konteynerler için henüz istatistik toplanmadı.
metric-would-fire: "Şu anda {count} konteyner için tetiklenirdi"
metric-would-not-fire: Şu anda eşiğin üstünde konteyner yok
sample-window-label: Örnek Penceresi
sample-window-hint: "Koşul {duration} boyunca karşılandığında uyarı tetiklenir"
sample-window-hint: "Koşul {duration} boyunca sürdüğünde tetiklenir"
cooldown-label: Bekleme süresi
cooldown-hint: "Konteyner başına uyarılar arası {duration}"
no-cooldown: Bekleme süresi yok
@@ -348,14 +391,32 @@ notifications:
log-alert: Günlük Uyarısı
metric-alert: Metrik Uyarısı
event-alert: Olay Uyarısı
event-filter: Olay İfadesi
event-fields-hint: "Kullanılabilir alanlar: {fields}"
log-alert-description: Konteyner loglarındaki satırları eşleştir
metric-alert-description: CPU, bellek ve disk kullanımını izle
event-alert-description: Başlatma, durdurma ve sağlık değişimlerine tepki ver
type-switch-warning: Kaydetmek bu uyarının {type} koşulunu değiştirir ve o koşul kaybolur.
event-filter: Olay koşulu
event-filter-hint: Hangi konteyner olaylarının uyarıyı tetiklediği.
event-matches: "Bu olaylardan {count} tanesiyle eşleşiyor"
event-matches-none: Bu olayların hiçbiriyle eşleşmiyor
event-preview-hint: Hangi konteyner olaylarını yakalayacağını görmek için bir koşul yazın.
event-preview-note: Geçmiş değil, örnek olaylar. Docker olayları gerçekleştikçe değerlendirilir.
destination-form:
create-title: Hedef Ekle
edit-title: Hedefi Düzenle
description: Bildirimler nereye gönderilmeli?
name: Ad
name-placeholder: ör., Prodüksiyon Slack
name-hint: Bir uyarı için hedef seçerken bu hedef böyle listelenir.
name-taken: Bu adı başka bir hedef zaten kullanıyor.
still-needed: "Hâlâ gerekli: {fields}"
missing:
name: ad
url: webhook URL'si
template: şablon
discard-title: Bu hedef atılsın mı?
discard-keep: Düzenlemeye devam et
discard-confirm: At
type: Tür
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, özel uç nokta
@@ -363,19 +424,34 @@ notifications:
cloud-description: Push bildirimleri, e-posta uyarıları ve yapay zeka destekli özetler
webhook-url: Webhook URL
webhook-url-placeholder: https://hooks.foo.com/services/...
webhook-url-invalid: Tam bir http:// veya https:// adresi girin.
api-key: API Anahtarı
payload-format: Payload Formatı
payload-format: Gövde
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Özel
format-replace-warning: Bu, yazdığınız şablonun yerine geçer.
format-replace-confirm: Değiştir
template: Şablon
template-hint: Go şablon söz dizimi
template-placeholder: Go şablon yer tutuculu JSON gövdesi
payload-mode-json: Geçerli JSON. Her değer sizin için işlenir ve kaçışlanır.
payload-mode-raw: JSON değil, bu yüzden gövdenin tamamı işlenmiş metin olarak gönderilir.
payload-mode-unbalanced: Kapatılmamış şablon ifadesi.
payload-mode-empty: Boş şablon, bildirimin tamamını JSON olarak gönderir.
variables: Kullanılabilir değişkenler
variables-all: Tüm uyarılar
variables-log: Yalnızca log uyarıları
variables-metric: Yalnızca metrik uyarıları
variables-event: Yalnızca olay uyarıları
variables-scope-hint: “Tüm uyarılar” dışındaki alanlar yalnızca kendi uyarı türünde doludur; başka bir türde kullanmak gönderimde hata verir.
headers: Özel Başlıklar
headers-hint: İsteğe bağlı HTTP başlıkları (ör. Authorization)
header-name: Başlık adı
header-value: Başlık değeri
add-header: Başlık Ekle
remove-header: Başlığı kaldır
test: Test
test-success: Test başarılı
cancel: İptal
+92 -16
View File
@@ -295,6 +295,7 @@ notifications:
enabled: 已啟用 ({count})
paused: 已暫停 ({count})
no-alerts: 尚未設定任何警報。建立一個以開始使用。
no-alerts-in-filter: 沒有符合此篩選條件的警示。
default-alert-created: 我們已為您建立預設警報。當容器以錯誤結束時,您會收到通知。您隨時可以編輯或暫停。
default-alert-failed: 無法自動建立警報,請改用下方的表單。
alert:
@@ -316,30 +317,72 @@ notifications:
edit: 編輯
duplicate: 複製
delete: 刪除
used-by: "被 {count} 則警示使用"
unused: 沒有警示使用
copy-of: "{name} 副本"
copy-failed: 無法複製此接收目標
delete-failed: 無法刪除此接收目標
delete-warning: 刪除此接收目標?
delete-warning-used: "有 {count} 則警示傳送到這裡,刪除後將不再通知。"
delete-cancel: 保留
delete-confirm: 刪除
alert-form:
create-title: 建立警報
edit-title: 編輯警報
description: 訂閱符合您條件的日誌事件
description-log: 當容器日誌中出現符合的行時收到通知
description-metric: 當容器的 CPU、記憶體或磁碟使用率超過閾值時收到通知
description-event: 當容器啟動、停止、結束或健康狀態改變時收到通知
required: 必填
still-needed: "還需要:{fields}"
missing:
container-expression: 容器
condition: 條件
destination: 接收目標
name: 名稱
alert-name: 警報名稱
alert-name-placeholder: 例如,測試 API 錯誤
container-filter: 容器篩選器
containers-match: "{count} 個容器符合: {names}"
no-containers-match: 沒有容器符合此篩選器
log-filter: 日誌篩選器
logs-match: "{count} 筆日誌符合"
no-logs-match: 沒有日誌符合此篩選器
alert-name-hint: 顯示在警示卡片以及它發出的每則通知中。
container-filter: 容器
container-filter-hint: 此警示監看哪些容器。僅比對執行中的容器。
containers-match: "符合 {count} 個執行中的容器:{names}"
no-containers-match: 沒有執行中的容器符合此篩選條件
checking: 檢查中…
examples: "範例:"
use-example: 使用此範例
seconds-unit: 秒
log-filter: 日誌條件
log-filter-hint: 哪些日誌行會觸發警示。
logs-match: "最近 {window} 內有 {count} 行符合"
no-logs-match: 最近 {window} 內沒有符合的行
log-preview-hint: 寫一個條件,看看最近哪些日誌行會觸發這則警示。
log-scan-note: 預覽掃描了前 {count} 個符合的容器。
preview-needs-containers: 請先選擇容器,才能用真實資料預覽。
preview-fix-expression: 修正運算式後即可看到預覽。
destination: 目標
select-destination: 選擇目標
no-destinations: 尚未設定目標。請先新增一個。
no-destinations: 尚未設定接收目標。
preview: 預覽
cancel: 取消
save: 儲存
create: 建立警報
metric-filter: 指標表達式
expression-valid: 表達式有效
metric-fields-hint: "可用欄位:{fields}"
discard-title: 放棄這則警示?
discard-keep: 繼續編輯
discard-confirm: 放棄
metric-filter: 指標條件
metric-filter-hint: "可比較 cpu(%)、memory(%)、memoryUsage(位元組)或 mounts 的剩餘空間。"
metric-preview-hint: 寫一個條件,看看你的容器目前的狀況。
metric-preview-note: 根據 Dozzle 收集的最近 {duration} 統計資料。
metric-container: 容器
metric-cpu: CPU
metric-memory: 記憶體
metric-window-match: 區間
metric-firing: 將觸發
metric-matching-now: 目前超標
metric-no-samples: 無資料
metric-no-samples-hint: 符合的容器尚未收集到統計資料。
metric-would-fire: "目前會為 {count} 個容器觸發"
metric-would-not-fire: 目前沒有容器超過閾值
sample-window-label: 取樣視窗
sample-window-hint: "當條件在 {duration} 內滿足時觸發警報"
sample-window-hint: "條件持續 {duration} 後觸發"
cooldown-label: 冷卻時間
cooldown-hint: "每個容器警報間隔 {duration}"
no-cooldown: 無冷卻時間
@@ -347,14 +390,32 @@ notifications:
log-alert: 日誌警報
metric-alert: 指標警報
event-alert: 事件警報
event-filter: 事件運算式
event-fields-hint: 可用欄位:{fields}
log-alert-description: 比對容器日誌中的行
metric-alert-description: 監看 CPU、記憶體與磁碟使用率
event-alert-description: 回應啟動、停止與健康狀態改變
type-switch-warning: 儲存會取代此警示的{type}條件,該條件將遺失。
event-filter: 事件條件
event-filter-hint: 哪些容器事件會觸發警示。
event-matches: "符合其中 {count} 個事件"
event-matches-none: 不符合其中任何事件
event-preview-hint: 寫一個條件,看看它會攔截哪些容器事件。
event-preview-note: 這是範例事件,不是歷史紀錄。Docker 事件會在發生時即時比對。
destination-form:
create-title: 新增目標
edit-title: 編輯目標
description: 通知應發送到哪裡?
name: 名稱
name-placeholder: 例如,正式環境 Slack
name-hint: 為警示挑選接收目標時顯示的名稱。
name-taken: 已有其他接收目標使用此名稱。
still-needed: "還需要:{fields}"
missing:
name: 名稱
url: Webhook 網址
template: 範本
discard-title: 放棄這個接收目標?
discard-keep: 繼續編輯
discard-confirm: 放棄
type: 類型
webhook-title: HTTP Webhook
webhook-description: Slack、Discord、自訂端點
@@ -362,19 +423,34 @@ notifications:
cloud-description: 推送通知、電子郵件警報和 AI 摘要
webhook-url: Webhook URL
webhook-url-placeholder: https://hooks.foo.com/services/...
webhook-url-invalid: 請輸入完整的 http:// 或 https:// 網址。
api-key: API 金鑰
payload-format: Payload 格式
payload-format: 請求內容
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: 自訂
format-replace-warning: 這會覆蓋你已經寫好的範本。
format-replace-confirm: 取代
template: 範本
template-hint: Go 範本語法
template-placeholder: 帶 Go 範本佔位符的 JSON 內容
payload-mode-json: 有效的 JSON。每個值都會為你算繪並跳脫。
payload-mode-raw: 不是 JSON,因此整個內容會以算繪後的文字送出。
payload-mode-unbalanced: 範本語句未關閉。
payload-mode-empty: 留空則將完整通知以 JSON 送出。
variables: 可用變數
variables-all: 所有警示
variables-log: 僅日誌警示
variables-metric: 僅指標警示
variables-event: 僅事件警示
variables-scope-hint: 「所有警示」以外的欄位只在各自的警示類型下有值,用在別處會在送出時失敗。
headers: 自訂標頭
headers-hint: 選填的 HTTP 標頭(例如 Authorization)
header-name: 標頭名稱
header-value: 標頭值
add-header: 新增標頭
remove-header: 移除標頭
test: 測試
test-success: 測試成功
cancel: 取消
+92 -16
View File
@@ -292,6 +292,7 @@ notifications:
enabled: 已启用 ({count})
paused: 已暂停 ({count})
no-alerts: 尚未配置任何警报。创建一个以开始使用。
no-alerts-in-filter: 没有符合此筛选条件的告警。
default-alert-created: 我们已为您创建默认警报。当容器以错误退出时,您会收到通知。您可以随时编辑或暂停。
default-alert-failed: 无法自动创建警报,请改用下方的表单。
alert:
@@ -313,30 +314,72 @@ notifications:
edit: 编辑
duplicate: 复制
delete: 删除
used-by: "被 {count} 条告警使用"
unused: 没有告警使用
copy-of: "{name} 副本"
copy-failed: 无法复制此接收目标
delete-failed: 无法删除此接收目标
delete-warning: 删除此接收目标?
delete-warning-used: "有 {count} 条告警发送到这里,删除后将不再通知。"
delete-cancel: 保留
delete-confirm: 删除
alert-form:
create-title: 创建警报
edit-title: 编辑警报
description: 订阅符合您条件的日志事件
description-log: 当容器日志中出现匹配的行时收到通知
description-metric: 当容器的 CPU、内存或磁盘使用率超过阈值时收到通知
description-event: 当容器启动、停止、退出或健康状态变化时收到通知
required: 必填
still-needed: "还需要:{fields}"
missing:
container-expression: 容器
condition: 条件
destination: 接收目标
name: 名称
alert-name: 警报名称
alert-name-placeholder: 例如,测试 API 错误
container-filter: 容器筛选器
containers-match: "{count} 个容器匹配: {names}"
no-containers-match: 没有容器匹配此筛选器
log-filter: 日志筛选器
logs-match: "{count} 条日志匹配"
no-logs-match: 没有日志匹配此筛选器
alert-name-hint: 显示在告警卡片以及它发出的每条通知中。
container-filter: 容器
container-filter-hint: 此告警监视哪些容器。仅匹配正在运行的容器。
containers-match: "匹配 {count} 个运行中的容器:{names}"
no-containers-match: 没有运行中的容器符合此筛选条件
checking: 检查中…
examples: "示例:"
use-example: 使用此示例
seconds-unit: 秒
log-filter: 日志条件
log-filter-hint: 哪些日志行会触发告警。
logs-match: "最近 {window} 内有 {count} 行匹配"
no-logs-match: 最近 {window} 内没有匹配的行
log-preview-hint: 写一个条件,看看最近哪些日志行会触发这条告警。
log-scan-note: 预览扫描了前 {count} 个匹配的容器。
preview-needs-containers: 先选择容器,才能用真实数据预览。
preview-fix-expression: 修正表达式后即可看到预览。
destination: 目标
select-destination: 选择目标
no-destinations: 尚未配置目标。请先添加一个。
no-destinations: 尚未配置接收目标。
preview: 预览
cancel: 取消
save: 保存
create: 创建警报
metric-filter: 指标表达式
expression-valid: 表达式有效
metric-fields-hint: "可用字段:{fields}"
discard-title: 放弃这条告警?
discard-keep: 继续编辑
discard-confirm: 放弃
metric-filter: 指标条件
metric-filter-hint: "可比较 cpu(%)、memory(%)、memoryUsage(字节)或 mounts 的剩余空间。"
metric-preview-hint: 写一个条件,看看你的容器当前的情况。
metric-preview-note: 基于 Dozzle 收集的最近 {duration} 的统计数据。
metric-container: 容器
metric-cpu: CPU
metric-memory: 内存
metric-window-match: 窗口
metric-firing: 将触发
metric-matching-now: 当前超标
metric-no-samples: 无数据
metric-no-samples-hint: 匹配的容器尚未收集到统计数据。
metric-would-fire: "当前会为 {count} 个容器触发"
metric-would-not-fire: 当前没有容器超过阈值
sample-window-label: 采样窗口
sample-window-hint: "当条件在 {duration} 内满足时触发警报"
sample-window-hint: "条件持续 {duration} 后触发"
cooldown-label: 冷却时间
cooldown-hint: "每个容器警报间隔 {duration}"
no-cooldown: 无冷却时间
@@ -344,14 +387,32 @@ notifications:
log-alert: 日志警报
metric-alert: 指标警报
event-alert: 事件警报
event-filter: 事件表达式
event-fields-hint: 可用字段:{fields}
log-alert-description: 匹配容器日志中的行
metric-alert-description: 监视 CPU、内存和磁盘使用率
event-alert-description: 响应启动、停止和健康状态变化
type-switch-warning: 保存会替换此告警的{type}条件,该条件将丢失。
event-filter: 事件条件
event-filter-hint: 哪些容器事件会触发告警。
event-matches: "匹配其中 {count} 个事件"
event-matches-none: 不匹配其中任何事件
event-preview-hint: 写一个条件,看看它会捕获哪些容器事件。
event-preview-note: 这是示例事件,不是历史记录。Docker 事件在发生时实时匹配。
destination-form:
create-title: 添加目标
edit-title: 编辑目标
description: 通知应发送到哪里?
name: 名称
name-placeholder: 例如,生产环境 Slack
name-hint: 为告警选择接收目标时显示的名称。
name-taken: 已有其他接收目标使用此名称。
still-needed: "还需要:{fields}"
missing:
name: 名称
url: Webhook 地址
template: 模板
discard-title: 放弃这个接收目标?
discard-keep: 继续编辑
discard-confirm: 放弃
type: 类型
webhook-title: HTTP Webhook
webhook-description: Slack、Discord、自定义端点
@@ -359,19 +420,34 @@ notifications:
cloud-description: 推送通知、邮件警报和 AI 摘要
webhook-url: Webhook URL
webhook-url-placeholder: https://hooks.foo.com/services/...
webhook-url-invalid: 请输入完整的 http:// 或 https:// 地址。
api-key: API 密钥
payload-format: Payload 格式
payload-format: 请求体
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: 自定义
format-replace-warning: 这会覆盖你已经写好的模板。
format-replace-confirm: 替换
template: 模板
template-hint: Go 模板语法
template-placeholder: 带 Go 模板占位符的 JSON 请求体
payload-mode-json: 有效的 JSON。每个值都会为你渲染并转义。
payload-mode-raw: 不是 JSON,因此整个请求体会作为渲染后的文本发送。
payload-mode-unbalanced: 模板语句未闭合。
payload-mode-empty: 留空则把完整通知作为 JSON 发送。
variables: 可用变量
variables-all: 所有告警
variables-log: 仅日志告警
variables-metric: 仅指标告警
variables-event: 仅事件告警
variables-scope-hint: “所有告警”以外的字段只在各自的告警类型下有值,用在别处会在发送时失败。
headers: 自定义请求头
headers-hint: 可选的 HTTP 请求头(如 Authorization)
header-name: 请求头名称
header-value: 请求头值
add-header: 添加请求头
remove-header: 移除请求头
test: 测试
test-success: 测试成功
cancel: 取消
+1
View File
@@ -30,6 +30,7 @@
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.11.0",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.4",
+7 -23
View File
@@ -6,9 +6,6 @@ importers:
.:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: 12.3.4
version: 12.3.4
pnpm:
specifier: 12.3.4
version: 12.3.4
@@ -59,11 +56,6 @@ packages:
cpu: [x64]
os: [win32]
'@pnpm/exe@12.3.4':
resolution: {integrity: sha512-Rq7JokWAyYF9IFfn8zofB/q2AgnZlmoMuH5fMHB2+Ta61I9aD7sNm+woZpx6iH+5vb38chdSoYRvOtSqlH72YQ==}
engines: {node: '>=18.*'}
hasBin: true
pnpm@12.3.4:
resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==}
engines: {node: '>=18.*'}
@@ -95,17 +87,6 @@ snapshots:
'@pnpm/exe.win32-x64@12.3.4':
optional: true
'@pnpm/exe@12.3.4':
optionalDependencies:
'@pnpm/exe.darwin-arm64': 12.3.4
'@pnpm/exe.darwin-x64': 12.3.4
'@pnpm/exe.linux-arm64': 12.3.4
'@pnpm/exe.linux-arm64-musl': 12.3.4
'@pnpm/exe.linux-x64': 12.3.4
'@pnpm/exe.linux-x64-musl': 12.3.4
'@pnpm/exe.win32-arm64': 12.3.4
'@pnpm/exe.win32-x64': 12.3.4
pnpm@12.3.4:
optionalDependencies:
'@pnpm/exe.darwin-arm64': 12.3.4
@@ -134,6 +115,9 @@ importers:
'@codemirror/autocomplete':
specifier: ^6.20.3
version: 6.20.3
'@codemirror/commands':
specifier: ^6.11.0
version: 6.11.0
'@codemirror/lang-javascript':
specifier: ^6.2.5
version: 6.2.5
@@ -498,8 +482,8 @@ packages:
'@codemirror/autocomplete@6.20.3':
resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
'@codemirror/commands@6.10.3':
resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==}
'@codemirror/commands@6.11.0':
resolution: {integrity: sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==}
'@codemirror/lang-javascript@6.2.5':
resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==}
@@ -4314,7 +4298,7 @@ snapshots:
'@codemirror/view': 6.43.11
'@lezer/common': 1.5.2
'@codemirror/commands@6.10.3':
'@codemirror/commands@6.11.0':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.3
@@ -5999,7 +5983,7 @@ snapshots:
codemirror@6.0.2:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/commands': 6.10.3
'@codemirror/commands': 6.11.0
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.6
'@codemirror/search': 6.7.0