diff --git a/assets/components/WelcomeModal.spec.ts b/assets/components/WelcomeModal.spec.ts index 58480612..e83975bb 100644 --- a/assets/components/WelcomeModal.spec.ts +++ b/assets/components/WelcomeModal.spec.ts @@ -35,6 +35,8 @@ const i18n = createI18n({ "oom-desc": "Fires when Docker reports an out-of-memory kill.", restart: "Container restarted", "restart-desc": "Off by default — noisy on its own; Cloud also uses this for loop detection.", + disk: "Disk space running low on any volume", + "disk-desc": "Fires when any mounted volume is over 85% full.", }, }, }, @@ -98,23 +100,38 @@ describe(" Create First Alert", () => { await flushPromises(); const ruleCalls = fetchMock.mock.calls.filter((c) => String(c[0]).includes("/api/notifications/rules")); - expect(ruleCalls).toHaveLength(3); // exited + unhealthy + oom on by default; restart off + expect(ruleCalls).toHaveLength(4); // exited + unhealthy + oom + disk on by default; restart off - const expressions = ruleCalls.map((c) => JSON.parse((c[1] as RequestInit).body as string).eventExpression); - expect(expressions).toContain('name == "die" && attributes["exitCode"] != "0"'); - expect(expressions).toContain('name == "health_status" && attributes["healthStatus"] == "unhealthy"'); - expect(expressions).toContain('name == "oom"'); - expect(expressions).not.toContain('name == "restart"'); + const bodies = ruleCalls.map((c) => JSON.parse((c[1] as RequestInit).body as string)); + const eventExpressions = bodies.map((b) => b.eventExpression).filter(Boolean); + expect(eventExpressions).toContain('name == "die" && attributes["exitCode"] != "0"'); + expect(eventExpressions).toContain('name == "health_status" && attributes["healthStatus"] == "unhealthy"'); + expect(eventExpressions).toContain('name == "oom"'); + expect(eventExpressions).not.toContain('name == "restart"'); - // every POST uses cloud dispatcher id - for (const c of ruleCalls) { - const body = JSON.parse((c[1] as RequestInit).body as string); - expect(body).toMatchObject({ + const metricExpressions = bodies.map((b) => b.metricExpression).filter(Boolean); + expect(metricExpressions).toContain("any(mounts, .usedPercent >= 85)"); + + // disk rule should carry its own cooldown/sampleWindow; event rules should remain at 0 + const diskBody = bodies.find((b) => b.metricExpression === "any(mounts, .usedPercent >= 85)"); + expect(diskBody).toMatchObject({ + enabled: true, + dispatcherId: 7, + cooldown: 3600, + sampleWindow: 60, + containerExpression: "true", + eventExpression: "", + }); + + // event-based POSTs use cloud dispatcher id with no cooldown + for (const b of bodies.filter((x) => x.eventExpression)) { + expect(b).toMatchObject({ enabled: true, dispatcherId: 7, cooldown: 0, sampleWindow: 0, containerExpression: "true", + metricExpression: "", }); } diff --git a/assets/components/WelcomeModal.vue b/assets/components/WelcomeModal.vue index 1e90829e..7d7eed51 100644 --- a/assets/components/WelcomeModal.vue +++ b/assets/components/WelcomeModal.vue @@ -110,10 +110,12 @@ const chipOptions = [ { value: "something_else", label: t("cloud.welcome.chip-other") }, ]; -type SignalKey = "exited" | "unhealthy" | "oom" | "restart"; +type SignalKey = "exited" | "unhealthy" | "oom" | "restart" | "disk"; +type SignalKind = "event" | "metric"; interface SignalDef { key: SignalKey; + kind: SignalKind; label: string; description: string; // ruleName is intentionally English/stable so the rule stays recognizable @@ -126,6 +128,7 @@ interface SignalDef { const signals = computed(() => [ { key: "exited", + kind: "event", label: t("cloud.welcome.signals.exited"), description: t("cloud.welcome.signals.exited-desc"), ruleName: "Container exited with an error", @@ -134,6 +137,7 @@ const signals = computed(() => [ }, { key: "unhealthy", + kind: "event", label: t("cloud.welcome.signals.unhealthy"), description: t("cloud.welcome.signals.unhealthy-desc"), ruleName: "Container became unhealthy", @@ -142,6 +146,7 @@ const signals = computed(() => [ }, { key: "oom", + kind: "event", label: t("cloud.welcome.signals.oom"), description: t("cloud.welcome.signals.oom-desc"), ruleName: "Container killed (OOM)", @@ -150,12 +155,22 @@ const signals = computed(() => [ }, { key: "restart", + kind: "event", label: t("cloud.welcome.signals.restart"), description: t("cloud.welcome.signals.restart-desc"), ruleName: "Container restarted", expression: 'name == "restart"', defaultOn: false, }, + { + key: "disk", + kind: "metric", + label: t("cloud.welcome.signals.disk"), + description: t("cloud.welcome.signals.disk-desc"), + ruleName: "Volume running out of space", + expression: "any(mounts, .usedPercent >= 85)", + defaultOn: true, + }, ]); const selectedSignals = ref([]); @@ -241,10 +256,12 @@ async function createDefaultAlerts() { dispatcherId: cloud.id, logExpression: "", containerExpression: "true", - eventExpression: signal.expression, - metricExpression: "", - cooldown: 0, - sampleWindow: 0, + eventExpression: signal.kind === "event" ? signal.expression : "", + metricExpression: signal.kind === "metric" ? signal.expression : "", + // Metric alerts: don't re-fire more than once an hour per container, + // and require the threshold to hold for the default sample window. + cooldown: signal.kind === "metric" ? 3600 : 0, + sampleWindow: signal.kind === "metric" ? 60 : 0, }), }).then((res) => { if (!res.ok) throw new Error("rule POST failed"); diff --git a/assets/composable/exprEditor.ts b/assets/composable/exprEditor.ts index 84f99e5e..58ccafa6 100644 --- a/assets/composable/exprEditor.ts +++ b/assets/composable/exprEditor.ts @@ -80,6 +80,11 @@ export function createMetricHints(): Completion[] { { label: "cpu", detail: "CPU usage percent", type: "property" }, { label: "memory", detail: "memory usage percent", type: "property" }, { label: "memoryUsage", detail: "memory usage bytes", type: "property" }, + { label: "mounts", detail: "list of container mounts with free-space info", type: "property" }, + { label: ".usedPercent", detail: "mount field: % of mount used", type: "property" }, + { label: ".availableBytes", detail: "mount field: free bytes on mount", type: "property" }, + { label: ".destination", detail: "mount field: in-container mount path", type: "property" }, + { label: "any(mounts, ...)", detail: "true if any mount matches the predicate", type: "keyword" }, ...exprOperators, { label: ">", detail: "greater than", type: "operator" }, { label: "<", detail: "less than", type: "operator" }, @@ -88,6 +93,12 @@ export function createMetricHints(): Completion[] { { label: "cpu > 80", detail: "CPU over 80%", type: "text", boost: 10 }, { label: "memory > 90", detail: "memory over 90%", type: "text", boost: 10 }, { label: "cpu > 80 || memory > 90", detail: "CPU or memory high", type: "text", boost: 10 }, + { + label: "any(mounts, .usedPercent >= 85)", + detail: "alert when any mount is over 85% full", + type: "text", + boost: 10, + }, ]; } diff --git a/internal/agent/client_test.go b/internal/agent/client_test.go index bdfbe2ca..ff1f790c 100644 --- a/internal/agent/client_test.go +++ b/internal/agent/client_test.go @@ -112,7 +112,7 @@ func (m *MockedClientService) UpdateContainer(ctx context.Context, c container.C var wantedContainer = container.Container{} func init() { - faker.FakeData(&wantedContainer, options.WithFieldsToIgnore("Stats", "MountStats")) + faker.FakeData(&wantedContainer, options.WithFieldsToIgnore("Stats", "MountStats", "Ports")) wantedContainer.FinishedAt = wantedContainer.FinishedAt.UTC() wantedContainer.Created = wantedContainer.Created.UTC() wantedContainer.StartedAt = wantedContainer.StartedAt.UTC() diff --git a/internal/notification/processing.go b/internal/notification/processing.go index 788723a8..d849a683 100644 --- a/internal/notification/processing.go +++ b/internal/notification/processing.go @@ -118,6 +118,7 @@ func (m *Manager) processStatEvent(event *ContainerStatEvent) { CPUPercent: event.Stat.CPUPercent, MemoryPercent: event.Stat.MemoryPercent, MemoryUsage: event.Stat.MemoryUsage, + Mounts: FromContainerMounts(event.Container), } notificationContainer := FromContainerModel(event.Container, event.Host) diff --git a/internal/notification/stats_listener.go b/internal/notification/stats_listener.go index 581eb7c9..a3a8c94c 100644 --- a/internal/notification/stats_listener.go +++ b/internal/notification/stats_listener.go @@ -40,7 +40,11 @@ func NewContainerStatsListener(ctx context.Context, clients []container_support. clients: clients, channel: make(chan *ContainerStatEvent, 1000), parentCtx: ctx, - cache: NewTTLCache[string, containerInfo](ctx, 30*time.Second), + // 5s TTL: the cache exists to avoid re-resolving the container+host on every + // per-second stat tick, but mount free-space (Container.MountStats) is refreshed + // out-of-band by the volume monitor and we want metric expressions that read + // `mounts[*].usedPercent` to see fresh values within a few seconds. + cache: NewTTLCache[string, containerInfo](ctx, 5*time.Second), } } diff --git a/internal/notification/types.go b/internal/notification/types.go index ad8c384f..b0edb134 100644 --- a/internal/notification/types.go +++ b/internal/notification/types.go @@ -21,6 +21,36 @@ func isDozzleContainer(c container.Container) bool { return strings.Contains(c.Image, "amir20/dozzle") } +// FromContainerMounts converts a container's MountStats map into the slice form +// exposed to metric expressions. Mounts whose free-space could not be measured +// (Available == false — e.g. Windows volumes or permission errors) are skipped +// so that `any(mounts, .usedPercent >= 85)` never fires on unmeasurable mounts. +func FromContainerMounts(c container.Container) []types.NotificationMount { + if len(c.MountStats) == 0 { + return nil + } + mounts := make([]types.NotificationMount, 0, len(c.MountStats)) + for _, ms := range c.MountStats { + if !ms.Available || ms.Total == 0 { + continue + } + used := ms.Used + // Some fs implementations report Used as Total-Free; recompute to be safe. + if used == 0 && ms.Free <= ms.Total { + used = ms.Total - ms.Free + } + mounts = append(mounts, types.NotificationMount{ + Destination: ms.Destination, + TotalBytes: ms.Total, + FreeBytes: ms.Free, + UsedBytes: used, + UsedPercent: float64(used) / float64(ms.Total) * 100.0, + AvailableBytes: ms.Free, + }) + } + return mounts +} + // FromContainerModel converts internal container.Container to types.NotificationContainer func FromContainerModel(c container.Container, host container.Host) types.NotificationContainer { return types.NotificationContainer{ @@ -96,7 +126,7 @@ type Subscription struct { ContainerExpression string `json:"containerExpression" yaml:"containerExpression"` MetricExpression string `json:"metricExpression,omitempty" yaml:"metricExpression,omitempty"` EventExpression string `json:"eventExpression,omitempty" yaml:"eventExpression,omitempty"` - Cooldown int `json:"cooldown,omitempty" yaml:"cooldown,omitempty"` // seconds between metric notifications, default 300 + Cooldown int `json:"cooldown,omitempty" yaml:"cooldown,omitempty"` // seconds between metric notifications, default 300 SampleWindow int `json:"sampleWindow,omitempty" yaml:"sampleWindow,omitempty"` // seconds of samples to evaluate, default 15 // Compiled filter expressions diff --git a/internal/notification/types_test.go b/internal/notification/types_test.go index 47923a74..22b84543 100644 --- a/internal/notification/types_test.go +++ b/internal/notification/types_test.go @@ -433,3 +433,109 @@ func TestFromLogEvent_OrderedMapConversion(t *testing.T) { }) } } + +func TestSubscription_MatchesMetric_Mounts(t *testing.T) { + tests := []struct { + name string + expression string + stat types.NotificationStat + want bool + }{ + { + name: "any mount over 85 percent matches", + expression: `any(mounts, .usedPercent >= 85)`, + stat: types.NotificationStat{ + Mounts: []types.NotificationMount{ + {Destination: "/data", TotalBytes: 100, UsedBytes: 50, FreeBytes: 50, UsedPercent: 50}, + {Destination: "/logs", TotalBytes: 100, UsedBytes: 90, FreeBytes: 10, UsedPercent: 90}, + }, + }, + want: true, + }, + { + name: "no mount over 85 percent does not match", + expression: `any(mounts, .usedPercent >= 85)`, + stat: types.NotificationStat{ + Mounts: []types.NotificationMount{ + {Destination: "/data", TotalBytes: 100, UsedBytes: 50, FreeBytes: 50, UsedPercent: 50}, + {Destination: "/logs", TotalBytes: 100, UsedBytes: 80, FreeBytes: 20, UsedPercent: 80}, + }, + }, + want: false, + }, + { + name: "empty mounts does not match", + expression: `any(mounts, .usedPercent >= 85)`, + stat: types.NotificationStat{}, + want: false, + }, + { + name: "available bytes filter", + expression: `any(mounts, .availableBytes < 1024)`, + stat: types.NotificationStat{ + Mounts: []types.NotificationMount{ + {Destination: "/data", FreeBytes: 500, AvailableBytes: 500}, + }, + }, + want: true, + }, + { + name: "combined cpu and mount expression", + expression: `cpu > 80 || any(mounts, .usedPercent >= 85)`, + stat: types.NotificationStat{ + CPUPercent: 10, + Mounts: []types.NotificationMount{ + {Destination: "/data", UsedPercent: 95}, + }, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + program, err := expr.Compile(tt.expression, expr.Env(types.NotificationStat{})) + require.NoError(t, err, "failed to compile expression") + + sub := &Subscription{ + MetricExpression: tt.expression, + MetricProgram: program, + } + + got := sub.MatchesMetric(tt.stat) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFromContainerMounts(t *testing.T) { + t.Run("skips unavailable mounts", func(t *testing.T) { + c := container.Container{ + MountStats: map[string]container.MountStat{ + "/data": {Destination: "/data", Total: 100, Used: 80, Free: 20, Available: true}, + "/win": {Destination: "/win", Total: 0, Available: false}, + }, + } + got := FromContainerMounts(c) + require.Len(t, got, 1) + assert.Equal(t, "/data", got[0].Destination) + assert.InDelta(t, 80.0, got[0].UsedPercent, 0.01) + assert.Equal(t, uint64(20), got[0].AvailableBytes) + }) + + t.Run("derives used from total minus free", func(t *testing.T) { + c := container.Container{ + MountStats: map[string]container.MountStat{ + "/data": {Destination: "/data", Total: 100, Used: 0, Free: 25, Available: true}, + }, + } + got := FromContainerMounts(c) + require.Len(t, got, 1) + assert.Equal(t, uint64(75), got[0].UsedBytes) + assert.InDelta(t, 75.0, got[0].UsedPercent, 0.01) + }) + + t.Run("nil for empty input", func(t *testing.T) { + assert.Nil(t, FromContainerMounts(container.Container{})) + }) +} diff --git a/locales/da.yml b/locales/da.yml index ce629df0..0c76acd4 100644 --- a/locales/da.yml +++ b/locales/da.yml @@ -344,6 +344,8 @@ cloud: oom-desc: "Udløses, når Docker rapporterer et out-of-memory-kill." restart: "Container genstartede" restart-desc: "Slået fra som standard — støjende alene; Cloud bruger det også til loop-detektion." + disk: "Diskplads på et volumen er ved at slippe op" + disk-desc: "Udløses, når et monteret volumen er over 85% fyldt." create-alerts: "Slå valgte signaler til" later: "Det gør jeg senere" cloud-search: diff --git a/locales/de.yml b/locales/de.yml index 33fd0253..650f3493 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -344,6 +344,8 @@ cloud: oom-desc: "Wird ausgelöst, wenn Docker einen Out-of-Memory-Kill meldet." restart: "Container neu gestartet" restart-desc: "Standardmäßig aus — für sich allein laut; Cloud nutzt es auch zur Loop-Erkennung." + disk: "Speicherplatz auf einem Volume wird knapp" + disk-desc: "Wird ausgelöst, wenn ein eingebundenes Volume zu über 85% belegt ist." create-alerts: "Ausgewählte Signale aktivieren" later: "Das mache ich später" cloud-search: diff --git a/locales/en.yml b/locales/en.yml index 6e0ebdc3..183af863 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -385,6 +385,8 @@ cloud: oom-desc: "Fires when Docker reports an out-of-memory kill." restart: Container restarted restart-desc: "Off by default — noisy on its own; Cloud also uses this for loop detection." + disk: Disk space running low on any volume + disk-desc: "Fires when any mounted volume is over 85% full." create-alert: Create Your First Alert create-alerts: Turn on selected signals later: "I'll do this later" diff --git a/locales/es.yml b/locales/es.yml index 97b607f8..dbcca46d 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -372,5 +372,7 @@ cloud: oom-desc: "Se dispara cuando Docker reporta un kill por falta de memoria." restart: "Contenedor reiniciado" restart-desc: "Desactivado por defecto — ruidoso por sí solo; Cloud también lo usa para detectar bucles." + disk: "Espacio en disco bajo en algún volumen" + disk-desc: "Se dispara cuando algún volumen montado supera el 85% de uso." create-alerts: "Activar señales seleccionadas" later: "Lo haré después" diff --git a/locales/fr.yml b/locales/fr.yml index 30b710cb..74aea7ab 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -344,6 +344,8 @@ cloud: oom-desc: "Se déclenche quand Docker signale un kill par manque de mémoire." restart: "Conteneur redémarré" restart-desc: "Désactivé par défaut — bruyant seul ; Cloud l'utilise aussi pour détecter les boucles." + disk: "Espace disque faible sur un volume" + disk-desc: "Se déclenche quand un volume monté est rempli à plus de 85%." create-alerts: "Activer les signaux sélectionnés" later: "Je ferai ça plus tard" cloud-search: diff --git a/locales/id.yml b/locales/id.yml index 6c6a6fbf..70283c19 100644 --- a/locales/id.yml +++ b/locales/id.yml @@ -356,6 +356,8 @@ cloud: oom-desc: "Dipicu ketika Docker melaporkan kill karena kehabisan memori." restart: "Kontainer dimulai ulang" restart-desc: "Mati secara default — berisik sendirian; Cloud juga memakainya untuk deteksi loop." + disk: "Ruang disk hampir habis di salah satu volume" + disk-desc: "Dipicu ketika ada volume yang terpasang lebih dari 85% terisi." create-alerts: "Aktifkan sinyal terpilih" later: "Nanti saja" cloud-search: diff --git a/locales/it.yml b/locales/it.yml index 6c18f223..71eb15a0 100644 --- a/locales/it.yml +++ b/locales/it.yml @@ -344,6 +344,8 @@ cloud: oom-desc: "Si attiva quando Docker segnala un kill per esaurimento memoria." restart: "Container riavviato" restart-desc: "Disattivo di default — rumoroso da solo; Cloud lo usa anche per rilevare loop." + disk: "Spazio su disco in esaurimento su un volume" + disk-desc: "Si attiva quando un volume montato supera l'85% di utilizzo." create-alerts: "Attiva i segnali selezionati" later: "Lo farò dopo" cloud-search: diff --git a/locales/ko.yml b/locales/ko.yml index 6fb3d0ce..88d7fd8b 100644 --- a/locales/ko.yml +++ b/locales/ko.yml @@ -347,6 +347,8 @@ cloud: oom-desc: "Docker가 메모리 부족 종료를 보고할 때 발동합니다." restart: "컨테이너 재시작됨" restart-desc: "기본 꺼짐 — 단독으로는 시끄러움; Cloud는 루프 감지에도 사용합니다." + disk: "볼륨의 디스크 공간 부족" + disk-desc: "마운트된 볼륨의 사용량이 85%를 초과하면 발동합니다." create-alerts: "선택한 신호 켜기" later: "나중에 할게요" cloud-search: diff --git a/locales/nl.yml b/locales/nl.yml index 2af16369..abd05955 100644 --- a/locales/nl.yml +++ b/locales/nl.yml @@ -345,6 +345,8 @@ cloud: oom-desc: "Wordt geactiveerd wanneer Docker een out-of-memory kill meldt." restart: "Container herstart" restart-desc: "Standaard uit — luidruchtig op zichzelf; Cloud gebruikt het ook voor loopdetectie." + disk: "Schijfruimte raakt op op een volume" + disk-desc: "Wordt geactiveerd wanneer een aangekoppeld volume voor meer dan 85% vol is." create-alerts: "Geselecteerde signalen aanzetten" later: "Dat doe ik later" cloud-search: diff --git a/locales/pl.yml b/locales/pl.yml index 8f6b6fb4..f6edea76 100644 --- a/locales/pl.yml +++ b/locales/pl.yml @@ -351,6 +351,8 @@ cloud: oom-desc: "Wyzwala się, gdy Docker zgłasza zabicie z powodu braku pamięci." restart: "Kontener zrestartowany" restart-desc: "Domyślnie wyłączone — głośne samo w sobie; Cloud używa tego też do wykrywania pętli." + disk: "Mało miejsca na dysku na jednym z wolumenów" + disk-desc: "Wyzwala się, gdy jakikolwiek zamontowany wolumen jest zapełniony w ponad 85%." create-alerts: "Włącz wybrane sygnały" later: "Zrobię to później" cloud-search: diff --git a/locales/pt.yml b/locales/pt.yml index 802435d6..013cc012 100644 --- a/locales/pt.yml +++ b/locales/pt.yml @@ -343,6 +343,8 @@ cloud: oom-desc: "É disparado quando o Docker reporta um kill por falta de memória." restart: "Contentor reiniciado" restart-desc: "Desativado por omissão — ruidoso por si só; o Cloud também o usa para deteção de loops." + disk: "Espaço em disco a esgotar-se num volume" + disk-desc: "É disparado quando algum volume montado está com mais de 85% de utilização." create-alerts: "Ativar sinais selecionados" later: "Farei isso depois" cloud-search: diff --git a/locales/ru.yml b/locales/ru.yml index 9cd56c41..fade500c 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -344,6 +344,8 @@ cloud: oom-desc: "Срабатывает, когда Docker сообщает о завершении из-за нехватки памяти." restart: "Контейнер перезапущен" restart-desc: "Выключено по умолчанию — шумно само по себе; Cloud также использует это для обнаружения циклов." + disk: "Заканчивается место на одном из томов" + disk-desc: "Срабатывает, когда любой смонтированный том заполнен более чем на 85%." create-alerts: "Включить выбранные сигналы" later: "Сделаю это позже" cloud-search: diff --git a/locales/sl.yml b/locales/sl.yml index 275f5bca..f1b5717a 100644 --- a/locales/sl.yml +++ b/locales/sl.yml @@ -349,6 +349,8 @@ cloud: oom-desc: "Sproži se, ko Docker poroča o prekinitvi zaradi pomanjkanja pomnilnika." restart: "Vsebnik znova zagnan" restart-desc: "Privzeto izklopljeno — sam po sebi hrupen; Cloud ga uporablja tudi za zaznavanje zank." + disk: "Na enem od nosilcev zmanjkuje prostora na disku" + disk-desc: "Sproži se, ko je kateri koli priklopljen nosilec napolnjen več kot 85%." create-alerts: "Vklopi izbrane signale" later: "To bom naredil pozneje" cloud-search: diff --git a/locales/tr.yml b/locales/tr.yml index 78771be0..a2d77e4b 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -344,6 +344,8 @@ cloud: oom-desc: "Docker bellek yetersizliği nedeniyle bir sonlandırma bildirdiğinde tetiklenir." restart: "Container yeniden başlatıldı" restart-desc: "Varsayılan olarak kapalı — tek başına gürültülü; Cloud bunu döngü tespiti için de kullanır." + disk: "Bir bağlamada disk alanı azalıyor" + disk-desc: "Bağlı bir birim %85 doluluğu aştığında tetiklenir." create-alerts: "Seçili sinyalleri aç" later: "Bunu daha sonra yapacağım" cloud-search: diff --git a/locales/zh-tw.yml b/locales/zh-tw.yml index d1a2bf01..50e8899a 100644 --- a/locales/zh-tw.yml +++ b/locales/zh-tw.yml @@ -347,6 +347,8 @@ cloud: oom-desc: "當 Docker 回報記憶體不足而終止時觸發。" restart: "容器已重新啟動" restart-desc: "預設關閉 — 單獨使用會很吵;Cloud 也用它來偵測循環。" + disk: "某個磁碟區的磁碟空間即將用盡" + disk-desc: "當任一掛載的磁碟區使用率超過 85% 時觸發。" create-alerts: "啟用所選訊號" later: "稍後再說" cloud-search: diff --git a/locales/zh.yml b/locales/zh.yml index 346ce545..398ff49f 100644 --- a/locales/zh.yml +++ b/locales/zh.yml @@ -344,6 +344,8 @@ cloud: oom-desc: "当 Docker 报告内存不足终止时触发。" restart: "容器已重启" restart-desc: "默认关闭 — 单独使用会很吵;Cloud 也用它来检测循环。" + disk: "某个卷的磁盘空间即将耗尽" + disk-desc: "当任一已挂载的卷使用率超过 85% 时触发。" create-alerts: "启用所选信号" later: "稍后再说" cloud-search: diff --git a/types/notification.go b/types/notification.go index 18ca9a2d..6da354ae 100644 --- a/types/notification.go +++ b/types/notification.go @@ -48,9 +48,24 @@ type NotificationLog struct { // NotificationStat represents container resource metrics for metric-based alerts type NotificationStat struct { - CPUPercent float64 `json:"cpu" expr:"cpu"` - MemoryPercent float64 `json:"memory" expr:"memory"` - MemoryUsage float64 `json:"memoryUsage" expr:"memoryUsage"` + CPUPercent float64 `json:"cpu" expr:"cpu"` + MemoryPercent float64 `json:"memory" expr:"memory"` + MemoryUsage float64 `json:"memoryUsage" expr:"memoryUsage"` + Mounts []NotificationMount `json:"mounts,omitempty" expr:"mounts"` +} + +// NotificationMount represents a single container mount's free-space stats, +// exposed to metric expressions via the `mounts` field (e.g. `any(mounts, .usedPercent >= 85)`). +// Only mounts where free-space reporting succeeded (Available == true on the source MountStat) +// are included — mounts that can't be measured (Windows volumes, permission errors) are skipped +// so they never trigger or suppress an alert spuriously. +type NotificationMount struct { + Destination string `json:"destination" expr:"destination"` + TotalBytes uint64 `json:"totalBytes" expr:"totalBytes"` + FreeBytes uint64 `json:"freeBytes" expr:"freeBytes"` + UsedBytes uint64 `json:"usedBytes" expr:"usedBytes"` + UsedPercent float64 `json:"usedPercent" expr:"usedPercent"` + AvailableBytes uint64 `json:"availableBytes" expr:"availableBytes"` // alias of FreeBytes for expression ergonomics } // NotificationEvent represents a Docker container lifecycle event for event-based alerts