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:
co-authored by
Claude Opus 5
parent
c5fbdf404b
commit
8e8f9db446
@@ -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.
|
||||
|
||||
Vendored
+11
-3
@@ -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']>
|
||||
|
||||
Vendored
+6
-1
@@ -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']
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 := (¬ification.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¬ification.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, ¬ification.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, ¬ification.Subscription{ContainerExpression: "true"})))
|
||||
}
|
||||
|
||||
func Test_previewMetricSamples_reports_window_ratio(t *testing.T) {
|
||||
sub := compiledSub(t, ¬ification.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, ¬ification.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, ¬ification.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, ¬ification.Subscription{ContainerExpression: "true"})
|
||||
assert.Nil(t, previewMetricSamples(sub, []container.Container{containerWithStats("a", "x", 5)}, 15))
|
||||
}
|
||||
+92
-16
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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: 取消
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+7
-23
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user