mirror of
https://github.com/amir20/dozzle.git
synced 2026-06-23 04:10:12 +00:00
feat: add metric-based alerts for container CPU/memory thresholds (#4454)
Co-authored-by: Dhaval Patel <dhavu262@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# Bug Hunter Agent Memory
|
||||
|
||||
## Codebase Patterns
|
||||
|
||||
### Notification System Architecture
|
||||
|
||||
- `notification.Manager` owns subscriptions (xsync.Map) and dispatchers (xsync.Map)
|
||||
- Two processing goroutines: `processLogEvents` and `processStatEvents` started in `NewManager`
|
||||
- Stats listener has start/stop lifecycle; log listener is always-on once started
|
||||
- `MultiHostService` wraps Manager and handles config persistence + agent broadcast
|
||||
|
||||
### Known Bug-Prone Areas
|
||||
|
||||
- **broadcastNotificationConfig**: Has historically missed fields when converting between internal and types packages (APIKey, Prefix, ExpiresAt were missed)
|
||||
- **TriggeredContainerIDs lazy init**: Race condition risk - initialized lazily in AddTriggeredContainer without sync
|
||||
- **Channel close handling**: enrich() in stats_listener doesn't check for closed channel, can hot-spin
|
||||
- **LogAlertFields canSave**: Allows empty logExpression (no error = valid), creates dead subscriptions
|
||||
|
||||
### Type Mapping Gotchas
|
||||
|
||||
- `container.ContainerStat` -> `types.NotificationStat`: field names differ (CPUPercent vs cpu expr tag)
|
||||
- `notification.DispatcherConfig` -> `types.DispatcherConfig`: must copy ALL fields including APIKey, Prefix, ExpiresAt
|
||||
- Frontend `NotificationRule.cooldown` is optional, backend defaults to 300 via `GetCooldownSeconds()`
|
||||
|
||||
### Concurrency Model
|
||||
|
||||
- xsync.Map used throughout for concurrent access (subscriptions, dispatchers, activeStreams, containers)
|
||||
- Subscription fields use atomic types: TriggerCount (atomic.Int64), LastTriggeredAt (atomic.Pointer)
|
||||
- MetricCooldowns uses xsync.Map for per-container cooldown tracking
|
||||
- sendSem (semaphore.Weighted=5) limits concurrent notification sends
|
||||
@@ -0,0 +1,30 @@
|
||||
# Go Performance Reviewer Memory
|
||||
|
||||
## Hot Paths Identified
|
||||
|
||||
- **Stats processing pipeline**: `ContainerStatsListener.enrich()` -> channel -> `Manager.processStatEvents()` -> `processStatEvent()`. Runs continuously for every container stat tick (~1/sec per container).
|
||||
- **Log processing pipeline**: `ContainerLogListener` -> `logChannel` (buffered 1000) -> `Manager.processLogEvents()` -> `processLogEvent()`. Every log line from matched containers flows here.
|
||||
- Both pipelines do `expr.Run()` per subscription per event -- compiled programs are cached but still run per-event.
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- `ContainerStore.SubscribeStats` and `SubscribeEvents` both call `statsCollector.Start/Stop` -- multiple subscribers share the same collector. Stop-on-cancel can interfere across subscribers.
|
||||
- `xsync.Map` (from puzpuzpuz/xsync/v4) used extensively for concurrent maps. No built-in TTL/eviction.
|
||||
- `ContainerStatEvent` carries full `Container` + `Host` structs by value through channels. `Container` is ~200+ bytes (strings, map, RingBuffer pointer, times).
|
||||
- TTL caches in both listeners (`cachedContainerInfo`) lack eviction -- grow unboundedly with container churn.
|
||||
|
||||
## Existing Patterns
|
||||
|
||||
- Semaphore-based concurrency limiting: `sendSem` (weighted 5) for notification dispatch, `maxFetchParallelism` (30) for container fetching.
|
||||
- Lazy stats collection: stats collector starts on first subscriber, stops when last unsubscribes (but the multi-subscriber Stop race exists).
|
||||
- `sync.Pool` not currently used in notification/stats paths.
|
||||
- Cooldown tracking via `xsync.Map[string, time.Time]` per subscription -- also no eviction.
|
||||
|
||||
## Key File Paths
|
||||
|
||||
- `internal/container/container_store.go` -- Container store with stats collector lifecycle
|
||||
- `internal/notification/processing.go` -- Log + stat event processing (hot path)
|
||||
- `internal/notification/stats_listener.go` -- Stats subscription and enrichment
|
||||
- `internal/notification/log_listener.go` -- Log stream management
|
||||
- `internal/notification/manager.go` -- Subscription CRUD and listener orchestration
|
||||
- `internal/notification/types.go` -- Subscription matching (expr evaluation)
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
name: bug-hunter
|
||||
description: "Use this agent when you need to review recently written or modified code for bugs, logic errors, edge cases, and unexpected behavior. This includes both Go backend code and Vue/TypeScript frontend code. This agent should be used after writing new features, fixing bugs, or refactoring code to catch issues before they reach production.\\n\\nExamples:\\n\\n- User writes a new HTTP handler in Go:\\n user: \"I just added a new endpoint for container health checks\"\\n assistant: \"Let me review the new code for potential bugs and edge cases.\"\\n <uses Task tool to launch bug-hunter agent to review the recently changed files>\\n\\n- User implements a new Vue composable:\\n user: \"I created a new composable for managing WebSocket reconnection\"\\n assistant: \"I'll launch the bug hunter to review your new composable for edge cases and potential issues.\"\\n <uses Task tool to launch bug-hunter agent to analyze the composable>\\n\\n- User modifies log parsing logic:\\n user: \"I updated the event_generator.go to handle a new log format\"\\n assistant: \"Let me have the bug hunter review your changes to ensure all edge cases in log parsing are covered.\"\\n <uses Task tool to launch bug-hunter agent to review the modified parsing code>\\n\\n- After a chunk of code is written during a feature implementation:\\n assistant: \"I've finished implementing the notification dispatcher. Let me run the bug hunter to check for issues.\"\\n <uses Task tool to launch bug-hunter agent proactively to review the new code>"
|
||||
model: opus
|
||||
color: blue
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are an elite bug-hunting code reviewer with deep expertise in both Go backend systems and Vue 3/TypeScript frontend applications. You have extensive experience finding subtle bugs, race conditions, nil pointer dereferences, unhandled edge cases, type safety issues, and logic errors that slip past typical review. You think adversarially — always asking "what could go wrong here?"
|
||||
|
||||
## Your Mission
|
||||
|
||||
Review recently written or modified code to find bugs, logic errors, and uncovered edge cases. You focus on code that could fail at runtime, produce incorrect results, or behave unexpectedly under specific conditions.
|
||||
|
||||
## Review Process
|
||||
|
||||
1. **Identify Changed/New Code**: Use git status, git diff, or examine the files the user points you to. Focus on recently written code, not the entire codebase.
|
||||
|
||||
2. **Analyze Each File Systematically**: For every file with changes, examine:
|
||||
- Control flow paths (all branches of if/else, switch, select)
|
||||
- Error handling (are errors checked? propagated correctly? logged?)
|
||||
- Nil/undefined checks (pointer dereference in Go, optional chaining in TS)
|
||||
- Concurrency safety (goroutine leaks, race conditions, channel operations)
|
||||
- Resource cleanup (deferred closes, stream cleanup, event listener removal)
|
||||
- Boundary conditions (empty arrays, zero values, max values, negative numbers)
|
||||
- Type safety (type assertions in Go, type narrowing in TS)
|
||||
- API contract adherence (correct HTTP status codes, proper SSE format, GraphQL schema alignment)
|
||||
|
||||
3. **Cross-Layer Analysis**: Check that frontend and backend changes are consistent:
|
||||
- API request/response shapes match between Go handlers and TypeScript types
|
||||
- SSE event names and payload structures align
|
||||
- GraphQL schema, resolvers, and frontend queries are in sync
|
||||
- WebSocket message formats match on both sides
|
||||
|
||||
## Go-Specific Bug Patterns to Check
|
||||
|
||||
- **Nil pointer dereference**: Especially after type assertions, map lookups, and interface conversions
|
||||
- **Goroutine leaks**: Goroutines blocked on channels that are never closed or written to
|
||||
- **Race conditions**: Shared state accessed from multiple goroutines without synchronization
|
||||
- **Deferred closure in loops**: `defer` inside loops won't execute until function returns
|
||||
- **Error shadowing**: Using `:=` that shadows an outer `err` variable
|
||||
- **Slice/map initialization**: Operating on nil slices/maps (nil map write panics)
|
||||
- **Context cancellation**: Not respecting context.Done() in long-running operations
|
||||
- **HTTP response body leaks**: Not closing response bodies after HTTP calls
|
||||
- **Integer overflow**: Especially in stats calculations with uint64
|
||||
- **String/byte slice sharing**: Modifying a slice that shares underlying array
|
||||
- **Channel operations on nil channels**: Blocking forever on nil channel send/receive
|
||||
- **Mutex copy**: Passing sync.Mutex by value instead of pointer
|
||||
|
||||
## Vue/TypeScript-Specific Bug Patterns to Check
|
||||
|
||||
- **Reactive reference unwrapping**: Using `.value` correctly with `ref()` vs `reactive()`
|
||||
- **Computed dependency tracking**: Missing reactive dependencies in computed properties
|
||||
- **Watch cleanup**: Not cleaning up watchers, event listeners, or timers in `onUnmounted`
|
||||
- **SSE/EventSource cleanup**: Connections not properly closed on component unmount
|
||||
- **Array reactivity**: Using index assignment instead of reactive methods
|
||||
- **Optional chaining gaps**: Accessing nested properties without null checks
|
||||
- **Promise error handling**: Unhandled promise rejections, missing `.catch()` or try/catch
|
||||
- **Type narrowing issues**: Assuming a type without proper guards
|
||||
- **Stale closure references**: Callbacks capturing outdated reactive values
|
||||
- **Memory leaks**: Growing arrays without bounds (check maxLogs enforcement, statsHistory rolling window)
|
||||
- **Race conditions in async operations**: Component unmounted before async operation completes
|
||||
- **Incorrect v-if/v-show usage**: Rendering components that depend on data not yet loaded
|
||||
- **Event buffer overflow**: Not handling backpressure in SSE streams
|
||||
|
||||
## Project-Specific Concerns
|
||||
|
||||
- **EMA calculations**: Alpha=0.2 for stats smoothing — verify division by zero, NaN handling
|
||||
- **Rolling window (300 items)**: Ensure proper eviction and no off-by-one errors
|
||||
- **Log entry type discrimination**: `LogEntry.create()` must handle all `logEvent.t` values
|
||||
- **Multi-host operations**: Container/host lookups via `FindContainer()`/`FindHost()` may return nil
|
||||
- **Docker API version negotiation**: Calls may fail on older Docker versions
|
||||
- **Protobuf serialization**: `FromProto()` methods must handle nil/empty fields
|
||||
- **Authentication modes**: Code must work correctly in all three auth modes (none, simple, forward-proxy)
|
||||
- **SSE buffering**: 250ms debounce with 1000ms max — verify timer cleanup
|
||||
- **CPU normalization**: Division by `cpuLimit` or `nCPU` — check for zero values
|
||||
|
||||
## Output Format
|
||||
|
||||
Follow ultra-brief mode as specified in the project guidelines:
|
||||
|
||||
- Critical issues only (bugs, security, blockers)
|
||||
- Brief bullet points, no lengthy explanations
|
||||
- Skip verbose sections (no "Strengths", "Summary", etc.)
|
||||
- Include file:line references when relevant
|
||||
- Maximum ~10-15 lines per response
|
||||
|
||||
For each bug found, report:
|
||||
|
||||
- **File:line** — Brief description of the bug
|
||||
- **Severity**: 🔴 Critical (will crash/corrupt) | 🟡 Warning (could fail under specific conditions) | 🟠 Edge case (uncovered scenario)
|
||||
- One-line fix suggestion if obvious
|
||||
|
||||
If no bugs are found, say so concisely. Do not fabricate issues.
|
||||
|
||||
## Approach
|
||||
|
||||
1. First, determine what code was recently changed (check git diff or ask the user)
|
||||
2. Read the changed files carefully
|
||||
3. For each file, apply the relevant bug pattern checklist above
|
||||
4. Cross-reference frontend and backend changes for consistency
|
||||
5. Report findings in the ultra-brief format
|
||||
|
||||
Do NOT review code style, naming conventions, or suggest refactors unless they mask a bug. Focus exclusively on correctness.
|
||||
|
||||
**Update your agent memory** as you discover recurring bug patterns, common error-prone code paths, areas of the codebase with historical issues, and edge cases specific to this project's architecture. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
|
||||
|
||||
Examples of what to record:
|
||||
|
||||
- Recurring nil-check omissions in specific packages
|
||||
- Components that frequently have cleanup issues
|
||||
- API endpoints with known edge case gaps
|
||||
- Stats calculation patterns that are error-prone
|
||||
- Log parsing paths that have caused issues before
|
||||
|
||||
# Persistent Agent Memory
|
||||
|
||||
You have a persistent Persistent Agent Memory directory at `/Users/araminfar/Workspace/dozzle/.claude/agent-memory/bug-hunter/`. Its contents persist across conversations.
|
||||
|
||||
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
|
||||
|
||||
Guidelines:
|
||||
|
||||
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
|
||||
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
|
||||
- Update or remove memories that turn out to be wrong or outdated
|
||||
- Organize memory semantically by topic, not chronologically
|
||||
- Use the Write and Edit tools to update your memory files
|
||||
|
||||
What to save:
|
||||
|
||||
- Stable patterns and conventions confirmed across multiple interactions
|
||||
- Key architectural decisions, important file paths, and project structure
|
||||
- User preferences for workflow, tools, and communication style
|
||||
- Solutions to recurring problems and debugging insights
|
||||
|
||||
What NOT to save:
|
||||
|
||||
- Session-specific context (current task details, in-progress work, temporary state)
|
||||
- Information that might be incomplete — verify against project docs before writing
|
||||
- Anything that duplicates or contradicts existing CLAUDE.md instructions
|
||||
- Speculative or unverified conclusions from reading a single file
|
||||
|
||||
Explicit user requests:
|
||||
|
||||
- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions
|
||||
- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files
|
||||
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
|
||||
|
||||
## MEMORY.md
|
||||
|
||||
Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time.
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
name: go-perf-reviewer
|
||||
description: "Use this agent when Go code has been written or modified and needs review for performance issues, inefficient patterns, or unnecessary resource loading. This agent focuses exclusively on Go backend code and should be triggered after changes to `.go` files.\\n\\nExamples:\\n\\n- user: \"I just added a new endpoint that fetches container stats\"\\n assistant: \"Let me use the go-perf-reviewer agent to check the new endpoint for performance issues.\"\\n <commentary>Since Go code was written for a new endpoint, use the Task tool to launch the go-perf-reviewer agent to review the changes for inefficient patterns.</commentary>\\n\\n- user: \"Can you review my changes to the Docker client wrapper?\"\\n assistant: \"I'll launch the go-perf-reviewer agent to analyze your Docker client changes for performance concerns.\"\\n <commentary>The user is asking for a review of Go code changes. Use the Task tool to launch the go-perf-reviewer agent to identify any performance anti-patterns.</commentary>\\n\\n- user: \"I refactored the log streaming pipeline\"\\n assistant: \"Let me run the go-perf-reviewer agent against your refactored streaming code to catch any performance regressions.\"\\n <commentary>Streaming code is performance-critical. Use the Task tool to launch the go-perf-reviewer agent to ensure the refactor doesn't introduce inefficiencies.</commentary>"
|
||||
model: opus
|
||||
color: green
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are an expert Go performance engineer with deep knowledge of runtime internals, memory allocation patterns, garbage collector behavior, and idiomatic high-performance Go. You specialize in reviewing Go code for performance anti-patterns in long-running server applications that interact with Docker, gRPC, and streaming APIs.
|
||||
|
||||
**Core Philosophy**: Dozzle must be lean and lazy by default. It should never load, allocate, compute, or fetch anything until it is actually needed. Every byte of memory and every CPU cycle matters in a lightweight monitoring tool.
|
||||
|
||||
## Your Review Process
|
||||
|
||||
1. **Examine only the changed Go files**. Use available tools to read the recent changes or diffs.
|
||||
2. **Identify specific performance issues** — do not comment on style, naming, or correctness unless it directly causes a performance problem.
|
||||
3. **Provide actionable feedback** with file:line references and concrete fix suggestions.
|
||||
|
||||
## Patterns to Flag
|
||||
|
||||
### Memory & Allocation
|
||||
|
||||
- Unnecessary allocations in hot paths (loops, stream handlers, per-request code)
|
||||
- Slice/map pre-allocation missing when size is known or estimable
|
||||
- `append` in tight loops without pre-sized slices
|
||||
- String concatenation with `+` instead of `strings.Builder` in loops
|
||||
- Returning large structs by value instead of pointer when appropriate
|
||||
- Unnecessary copies of large data (e.g., ranging over slice of structs by value)
|
||||
- `[]byte` ↔ `string` conversions that could be avoided
|
||||
- Creating closures in loops that capture loop variables unnecessarily
|
||||
- Allocating buffers per-call that could be pooled via `sync.Pool`
|
||||
|
||||
### Lazy Loading & Eager Initialization
|
||||
|
||||
- **Top priority**: Loading data, making API calls, or initializing resources before they are actually needed
|
||||
- Fetching all containers/stats when only a subset is requested
|
||||
- Initializing clients, connections, or caches at startup that may never be used
|
||||
- Reading entire files/streams into memory when streaming/pagination would suffice
|
||||
- Computing derived data eagerly when it could be computed on demand
|
||||
- Pre-populating maps/caches with all possible entries instead of lazy-filling
|
||||
|
||||
### Concurrency & Goroutines
|
||||
|
||||
- Goroutine leaks: goroutines without proper cancellation via `context.Context`
|
||||
- Missing `defer cancel()` after `context.WithCancel/WithTimeout`
|
||||
- Unbounded goroutine spawning without semaphore/worker pool
|
||||
- Channel misuse: unbuffered channels causing unnecessary blocking, or oversized buffered channels wasting memory
|
||||
- Holding locks longer than necessary; lock contention in hot paths
|
||||
- Using `sync.Mutex` where `sync.RWMutex` would reduce contention
|
||||
|
||||
### I/O & Streaming
|
||||
|
||||
- Not using `bufio.Reader`/`bufio.Writer` for I/O operations
|
||||
- Reading entire HTTP response bodies into memory (`io.ReadAll`) when streaming is possible
|
||||
- Not closing response bodies, readers, or connections (resource leaks)
|
||||
- Blocking I/O without timeouts or context cancellation
|
||||
- Serializing/deserializing JSON repeatedly when it could be done once
|
||||
- Using `encoding/json` in ultra-hot paths where a faster serializer is warranted
|
||||
|
||||
### Docker/gRPC Specific
|
||||
|
||||
- Making redundant Docker API calls (e.g., inspecting a container multiple times)
|
||||
- Not using Docker API filters to narrow results server-side
|
||||
- Fetching all container logs when `tail` or `since` parameters should limit scope
|
||||
- gRPC streams not properly drained or closed
|
||||
- Creating new Docker/gRPC clients per request instead of reusing
|
||||
|
||||
### General Go Anti-Patterns
|
||||
|
||||
- `reflect` usage in hot paths
|
||||
- `fmt.Sprintf` for simple string operations where direct concatenation suffices
|
||||
- `interface{}` / `any` boxing causing heap escapes
|
||||
- Unnecessary use of `defer` in tight loops (small but real overhead)
|
||||
- `time.After` in select loops (creates new timer each iteration; use `time.NewTimer` + `Reset`)
|
||||
- Regex compilation inside functions instead of package-level `var` with `regexp.MustCompile`
|
||||
- Sorting large slices repeatedly instead of maintaining sorted order
|
||||
|
||||
## Output Format
|
||||
|
||||
Use ultra-brief mode as specified by the project:
|
||||
|
||||
- Critical performance issues only
|
||||
- Brief bullet points with file:line references
|
||||
- Concrete suggestion for each issue
|
||||
- Maximum ~10-15 lines per response
|
||||
- No praise sections, no summaries, no fluff
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
- `internal/docker/client.go:142` — `io.ReadAll(resp.Body)` reads entire log stream into memory. Stream with `bufio.Scanner` instead.
|
||||
- `internal/web/logs.go:87` — New `json.Encoder` created per log line in hot loop. Reuse encoder or use `sync.Pool`.
|
||||
- `internal/support/docker/manager.go:53` — All hosts initialized eagerly at startup. Defer client creation until first access.
|
||||
```
|
||||
|
||||
If no performance issues are found, state that clearly in one line.
|
||||
|
||||
**Update your agent memory** as you discover performance patterns, hot paths, allocation-heavy code paths, and architectural decisions in this codebase. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
|
||||
|
||||
Examples of what to record:
|
||||
|
||||
- Hot paths identified (log streaming, stats collection, event processing)
|
||||
- Existing `sync.Pool` usage or buffer reuse patterns
|
||||
- Known allocation-heavy areas
|
||||
- Docker API call patterns and caching strategies
|
||||
- gRPC streaming patterns used in agent mode
|
||||
- Areas where lazy loading is already implemented vs. missing
|
||||
|
||||
# Persistent Agent Memory
|
||||
|
||||
You have a persistent Persistent Agent Memory directory at `/Users/araminfar/Workspace/dozzle/.claude/agent-memory/go-perf-reviewer/`. Its contents persist across conversations.
|
||||
|
||||
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
|
||||
|
||||
Guidelines:
|
||||
|
||||
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
|
||||
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
|
||||
- Update or remove memories that turn out to be wrong or outdated
|
||||
- Organize memory semantically by topic, not chronologically
|
||||
- Use the Write and Edit tools to update your memory files
|
||||
|
||||
What to save:
|
||||
|
||||
- Stable patterns and conventions confirmed across multiple interactions
|
||||
- Key architectural decisions, important file paths, and project structure
|
||||
- User preferences for workflow, tools, and communication style
|
||||
- Solutions to recurring problems and debugging insights
|
||||
|
||||
What NOT to save:
|
||||
|
||||
- Session-specific context (current task details, in-progress work, temporary state)
|
||||
- Information that might be incomplete — verify against project docs before writing
|
||||
- Anything that duplicates or contradicts existing CLAUDE.md instructions
|
||||
- Speculative or unverified conclusions from reading a single file
|
||||
|
||||
Explicit user requests:
|
||||
|
||||
- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions
|
||||
- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files
|
||||
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
|
||||
|
||||
## MEMORY.md
|
||||
|
||||
Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__acp__Edit",
|
||||
"mcp__acp__Bash",
|
||||
"mcp__acp__Write",
|
||||
"Bash(gh pr view:*)",
|
||||
"Bash(gh api:*)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"Bash(make test:*)",
|
||||
"Bash(go test:*)",
|
||||
"Bash(go run -e -c 'package main; import \\(\"\"fmt\"\"; \"\"golang.org/x/crypto/bcrypt\"\"\\); func main\\(\\) { h, _ := bcrypt.GenerateFromPassword\\([]byte\\(\"\"password\"\"\\), bcrypt.DefaultCost\\); fmt.Println\\(string\\(h\\)\\) }')",
|
||||
"Bash(python3:*)",
|
||||
"Bash(pnpm test:*)",
|
||||
"Bash(node --version:*)",
|
||||
"Bash(pnpm list:*)",
|
||||
"Bash(git rebase:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(pnpm build:*)",
|
||||
"Bash(go build:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -15,4 +15,3 @@ coverage.out
|
||||
*.pem
|
||||
*.csr
|
||||
tmp
|
||||
.claude
|
||||
|
||||
Vendored
+9
@@ -34,6 +34,7 @@ declare global {
|
||||
const createGlobalState: typeof import('@vueuse/core').createGlobalState
|
||||
const createInjectionState: typeof import('@vueuse/core').createInjectionState
|
||||
const createLogHints: typeof import('./composable/exprEditor').createLogHints
|
||||
const createMetricHints: typeof import('./composable/exprEditor').createMetricHints
|
||||
const createPinia: typeof import('pinia').createPinia
|
||||
const createReactiveFn: typeof import('@vueuse/core').createReactiveFn
|
||||
const createRef: typeof import('@vueuse/core').createRef
|
||||
@@ -56,6 +57,7 @@ declare global {
|
||||
const flattenJSON: typeof import('./utils/index').flattenJSON
|
||||
const flattenJSONToMap: typeof import('./utils/index').flattenJSONToMap
|
||||
const formatBytes: typeof import('./utils/index').formatBytes
|
||||
const formatDuration: typeof import('./utils/index').formatDuration
|
||||
const getActivePinia: typeof import('pinia').getActivePinia
|
||||
const getCurrentInstance: typeof import('vue').getCurrentInstance
|
||||
const getCurrentScope: typeof import('vue').getCurrentScope
|
||||
@@ -173,6 +175,7 @@ declare global {
|
||||
const unrefElement: typeof import('@vueuse/core').unrefElement
|
||||
const until: typeof import('@vueuse/core').until
|
||||
const useActiveElement: typeof import('@vueuse/core').useActiveElement
|
||||
const useAlertForm: typeof import('./composable/alertForm').useAlertForm
|
||||
const useAnimate: typeof import('@vueuse/core').useAnimate
|
||||
const useAnnouncements: typeof import('./stores/announcements').useAnnouncements
|
||||
const useArrayDifference: typeof import('@vueuse/core').useArrayDifference
|
||||
@@ -395,6 +398,9 @@ 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'
|
||||
import('./composable/alertForm')
|
||||
// @ts-ignore
|
||||
export type { DrawerWidth } from './composable/drawer'
|
||||
import('./composable/drawer')
|
||||
// @ts-ignore
|
||||
@@ -453,6 +459,7 @@ declare module 'vue' {
|
||||
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
|
||||
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
|
||||
readonly createLogHints: UnwrapRef<typeof import('./composable/exprEditor')['createLogHints']>
|
||||
readonly createMetricHints: UnwrapRef<typeof import('./composable/exprEditor')['createMetricHints']>
|
||||
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
|
||||
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
|
||||
readonly createRef: UnwrapRef<typeof import('@vueuse/core')['createRef']>
|
||||
@@ -475,6 +482,7 @@ declare module 'vue' {
|
||||
readonly flattenJSON: UnwrapRef<typeof import('./utils/index')['flattenJSON']>
|
||||
readonly flattenJSONToMap: UnwrapRef<typeof import('./utils/index')['flattenJSONToMap']>
|
||||
readonly formatBytes: UnwrapRef<typeof import('./utils/index')['formatBytes']>
|
||||
readonly formatDuration: UnwrapRef<typeof import('./utils/index')['formatDuration']>
|
||||
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
|
||||
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
@@ -592,6 +600,7 @@ declare module 'vue' {
|
||||
readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']>
|
||||
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
|
||||
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
|
||||
readonly useAlertForm: UnwrapRef<typeof import('./composable/alertForm')['useAlertForm']>
|
||||
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
|
||||
readonly useAnnouncements: UnwrapRef<typeof import('./stores/announcements')['useAnnouncements']>
|
||||
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
|
||||
|
||||
Vendored
+4
@@ -68,6 +68,7 @@ declare module 'vue' {
|
||||
Links: typeof import('./components/Links.vue')['default']
|
||||
LoadMoreLogItem: typeof import('./components/LogViewer/LoadMoreLogItem.vue')['default']
|
||||
LogActions: typeof import('./components/LogViewer/LogActions.vue')['default']
|
||||
LogAlertFields: typeof import('./components/Notification/LogAlertFields.vue')['default']
|
||||
LogAnalytics: typeof import('./components/LogViewer/LogAnalytics.vue')['default']
|
||||
LogDate: typeof import('./components/LogViewer/LogDate.vue')['default']
|
||||
LogDetails: typeof import('./components/LogViewer/LogDetails.vue')['default']
|
||||
@@ -92,6 +93,7 @@ declare module 'vue' {
|
||||
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
|
||||
'Mdi:beer': typeof import('~icons/mdi/beer')['default']
|
||||
'Mdi:bell': typeof import('~icons/mdi/bell')['default']
|
||||
'Mdi:chartLine': typeof import('~icons/mdi/chart-line')['default']
|
||||
'Mdi:check': typeof import('~icons/mdi/check')['default']
|
||||
'Mdi:checkCircle': typeof import('~icons/mdi/check-circle')['default']
|
||||
'Mdi:chevronDoubleDown': typeof import('~icons/mdi/chevron-double-down')['default']
|
||||
@@ -116,8 +118,10 @@ declare module 'vue' {
|
||||
'Mdi:pencilOutline': typeof import('~icons/mdi/pencil-outline')['default']
|
||||
'Mdi:plus': typeof import('~icons/mdi/plus')['default']
|
||||
'Mdi:satelliteVariant': typeof import('~icons/mdi/satellite-variant')['default']
|
||||
'Mdi:textBoxOutline': typeof import('~icons/mdi/text-box-outline')['default']
|
||||
'Mdi:trashCanOutline': typeof import('~icons/mdi/trash-can-outline')['default']
|
||||
'Mdi:webhook': typeof import('~icons/mdi/webhook')['default']
|
||||
MetricAlertFields: typeof import('./components/Notification/MetricAlertFields.vue')['default']
|
||||
MetricCard: typeof import('./components/MetricCard.vue')['default']
|
||||
MobileMenu: typeof import('./components/common/MobileMenu.vue')['default']
|
||||
MultiContainerActionToolbar: typeof import('./components/LogViewer/MultiContainerActionToolbar.vue')['default']
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<mdi:chart-line v-if="alert.metricExpression" class="text-info" />
|
||||
<mdi:text-box-outline v-else class="text-info" />
|
||||
<span>{{ alert.name }}</span> <span class="text-sm font-light">→</span>
|
||||
<span class="flex gap-1 text-xs font-light" :class="{ 'text-warning': !alert.dispatcher }">
|
||||
<template v-if="alert.dispatcher">
|
||||
@@ -27,8 +29,16 @@
|
||||
<div class="text-base-content/80 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<span>{{ $t("notifications.alert.containers") }}</span>
|
||||
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.containerExpression }}</code>
|
||||
<span>{{ $t("notifications.alert.log-filter") }}</span>
|
||||
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.logExpression }}</code>
|
||||
<template v-if="alert.metricExpression">
|
||||
<span>{{ $t("notifications.alert.metric-filter") }}</span>
|
||||
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.metricExpression }}</code>
|
||||
<span>{{ $t("notifications.alert.cooldown") }}</span>
|
||||
<span>{{ formatDuration(alert.cooldown || 300, locale || undefined) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ $t("notifications.alert.log-filter") }}</span>
|
||||
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.logExpression }}</code>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
@@ -21,6 +21,29 @@
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<!-- Alert Type Toggle -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("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("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("alert-form.metric-alert") }}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Container Filter -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.container-filter") }}</legend>
|
||||
@@ -52,27 +75,27 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Log Filter -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.log-filter") }}</legend>
|
||||
<div
|
||||
class="input focus-within:input-primary w-full focus-within:z-50"
|
||||
:class="logExpression.trim() && !logError ? 'input-primary' : { 'input-error!': logError }"
|
||||
>
|
||||
<div ref="logEditorRef" 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="!isLoading" class="text-warning">
|
||||
<mdi:alert class="inline" />
|
||||
{{ $t("notifications.alert-form.no-logs-match") }}
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
<!-- 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
|
||||
ref="fieldsRef"
|
||||
:alert="alert"
|
||||
:prefill="prefill"
|
||||
:container-expression="containerExpression"
|
||||
:is-loading="isLoading"
|
||||
:validate-preview="validatePreview"
|
||||
/>
|
||||
</KeepAlive>
|
||||
|
||||
<!-- Destination -->
|
||||
<fieldset class="fieldset">
|
||||
@@ -113,16 +136,6 @@
|
||||
</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
|
||||
:messages="logMessages"
|
||||
:last-selected-item="undefined"
|
||||
class="border-base-content/50 h-64 overflow-hidden rounded-lg border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="saveError" class="alert alert-error">
|
||||
<span>{{ saveError }}</span>
|
||||
@@ -131,7 +144,7 @@
|
||||
<!-- 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="saveAlert">
|
||||
<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>
|
||||
@@ -140,209 +153,62 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type LogEvent, type LogEntry, type LogMessage, asLogEntry } from "@/models/LogEntry";
|
||||
import { Container } from "@/models/Container";
|
||||
import type { ContainerJson } from "@/types/Container";
|
||||
import { createExprEditor, createContainerHints, createLogHints } from "@/composable/exprEditor";
|
||||
import { useAlertForm } from "@/composable/alertForm";
|
||||
import LogAlertFields from "./LogAlertFields.vue";
|
||||
import MetricAlertFields from "./MetricAlertFields.vue";
|
||||
import type { NotificationRule } from "@/types/notifications";
|
||||
|
||||
import type { Dispatcher, NotificationRule, PreviewResult } from "@/types/notifications";
|
||||
|
||||
const { close, onCreated, alert, prefill } = defineProps<{
|
||||
const props = defineProps<{
|
||||
close?: () => void;
|
||||
onCreated?: () => void;
|
||||
alert?: NotificationRule;
|
||||
prefill?: { name?: string; containerExpression?: string; logExpression?: string };
|
||||
prefill?: { name?: string; containerExpression?: string; logExpression?: string; metricExpression?: string };
|
||||
}>();
|
||||
|
||||
// Fetch dispatchers
|
||||
const destinations = ref<Dispatcher[]>([]);
|
||||
onMounted(async () => {
|
||||
const res = await fetch(withBase("/api/notifications/dispatchers"));
|
||||
destinations.value = await res.json();
|
||||
});
|
||||
|
||||
// Container store for autocomplete hints
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore);
|
||||
const containerNames = computed(() => [
|
||||
...new Set(containers.value.filter((c) => c.state === "running").map((c) => c.name)),
|
||||
]);
|
||||
const imageNames = computed(() => [...new Set(containers.value.map((c) => c.image))]);
|
||||
const hostNames = computed(() => [...new Set(containers.value.map((c) => c.host))]);
|
||||
const {
|
||||
isEditing,
|
||||
alertName,
|
||||
containerExpression,
|
||||
dispatcherId,
|
||||
destinations,
|
||||
selectedDestination,
|
||||
containerResult,
|
||||
isLoading,
|
||||
isSaving,
|
||||
saveError,
|
||||
baseCanSave,
|
||||
initContainerEditor,
|
||||
saveAlert,
|
||||
validatePreview,
|
||||
} = useAlertForm(props);
|
||||
|
||||
// Template refs
|
||||
const alertNameInput = ref<HTMLInputElement>();
|
||||
const containerEditorRef = ref<HTMLElement>();
|
||||
const logEditorRef = ref<HTMLElement>();
|
||||
const destinationDropdown = ref<HTMLDetailsElement>();
|
||||
|
||||
// Form state
|
||||
const isEditing = computed(() => !!alert);
|
||||
const alertName = ref(alert?.name ?? prefill?.name ?? "");
|
||||
const containerExpression = ref(alert?.containerExpression ?? prefill?.containerExpression ?? "");
|
||||
const logExpression = ref(alert?.logExpression ?? prefill?.logExpression ?? "");
|
||||
const dispatcherId = ref(alert?.dispatcher?.id ?? 0);
|
||||
const selectedDestination = computed(() => destinations.value.find((d) => d.id === dispatcherId.value));
|
||||
const fieldsRef = ref<InstanceType<typeof LogAlertFields> | InstanceType<typeof MetricAlertFields>>();
|
||||
useFocus(alertNameInput, { initialValue: true });
|
||||
|
||||
// Validation state
|
||||
interface ContainerResult {
|
||||
error?: string;
|
||||
containers?: Container[];
|
||||
}
|
||||
const containerResult = ref<ContainerResult | null>(null);
|
||||
const logError = ref<string | null>(null);
|
||||
const logTotalCount = ref(0);
|
||||
const logMessages = shallowRef<LogEntry<LogMessage>[]>([]);
|
||||
const messageKeys = ref<string[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const saveError = ref<string | null>(null);
|
||||
// Alert type
|
||||
const alertType = ref<"log" | "metric">(props.alert?.metricExpression ? "metric" : "log");
|
||||
|
||||
const canSave = computed(
|
||||
() =>
|
||||
alertName.value.trim() &&
|
||||
containerExpression.value.trim() &&
|
||||
dispatcherId.value > 0 &&
|
||||
!containerResult.value?.error &&
|
||||
!logError.value &&
|
||||
!isSaving.value,
|
||||
);
|
||||
const canSave = computed(() => baseCanSave.value && (fieldsRef.value?.canSave ?? false));
|
||||
|
||||
async function saveAlert() {
|
||||
if (!canSave.value) return;
|
||||
|
||||
isSaving.value = true;
|
||||
saveError.value = null;
|
||||
|
||||
try {
|
||||
const input = {
|
||||
name: alertName.value.trim(),
|
||||
containerExpression: containerExpression.value,
|
||||
logExpression: logExpression.value,
|
||||
dispatcherId: dispatcherId.value!,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const url = isEditing.value
|
||||
? withBase(`/api/notifications/rules/${alert!.id}`)
|
||||
: withBase("/api/notifications/rules");
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: isEditing.value ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Failed to save alert");
|
||||
}
|
||||
|
||||
onCreated?.();
|
||||
close?.();
|
||||
} catch (e) {
|
||||
saveError.value = e instanceof Error ? e.message : "Failed to save alert";
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
async function save() {
|
||||
if (!canSave.value || !fieldsRef.value) return;
|
||||
await saveAlert(fieldsRef.value.typeFields);
|
||||
}
|
||||
|
||||
async function validateExpressions() {
|
||||
if (!containerExpression.value && !logExpression.value) {
|
||||
containerResult.value = null;
|
||||
logError.value = null;
|
||||
logTotalCount.value = 0;
|
||||
logMessages.value = [];
|
||||
messageKeys.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(withBase("/api/notifications/preview"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
containerExpression: containerExpression.value,
|
||||
logExpression: logExpression.value || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json();
|
||||
throw new Error(errData.error || "Preview failed");
|
||||
}
|
||||
|
||||
const data: PreviewResult = await res.json();
|
||||
|
||||
// Update container result
|
||||
containerResult.value = containerExpression.value
|
||||
? {
|
||||
error: data.containerError ?? undefined,
|
||||
containers: data.matchedContainers?.map((c) => Container.fromJSON(c as ContainerJson)),
|
||||
}
|
||||
: null;
|
||||
|
||||
// Update message keys for autocomplete
|
||||
messageKeys.value = data.messageKeys ?? [];
|
||||
|
||||
// Update log result
|
||||
if (logExpression.value && !data.containerError) {
|
||||
logError.value = data.logError ?? null;
|
||||
logTotalCount.value = data.totalLogs;
|
||||
logMessages.value = data.matchedLogs?.map((event) => asLogEntry(event as LogEvent)) ?? [];
|
||||
} else {
|
||||
logError.value = null;
|
||||
logTotalCount.value = 0;
|
||||
logMessages.value = [];
|
||||
}
|
||||
} catch (e) {
|
||||
containerResult.value = { error: e instanceof Error ? e.message : "Unknown error" };
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedValidate = useDebounceFn(validateExpressions, 500);
|
||||
|
||||
watch(
|
||||
[containerExpression, logExpression],
|
||||
() => {
|
||||
isLoading.value = true;
|
||||
debouncedValidate();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
let containerEditorView: Awaited<ReturnType<typeof createExprEditor>> | undefined;
|
||||
let logEditorView: Awaited<ReturnType<typeof createExprEditor>> | undefined;
|
||||
// Container editor
|
||||
let containerEditorView: Awaited<ReturnType<typeof initContainerEditor>> | undefined;
|
||||
|
||||
onMounted(async () => {
|
||||
if (containerEditorRef.value) {
|
||||
containerEditorView = await createExprEditor({
|
||||
parent: containerEditorRef.value,
|
||||
placeholder: 'name contains "api"',
|
||||
initialValue: alert?.containerExpression ?? prefill?.containerExpression ?? "",
|
||||
getHints: () => createContainerHints(containerNames.value, imageNames.value, hostNames.value),
|
||||
onChange: (v) => (containerExpression.value = v),
|
||||
});
|
||||
}
|
||||
|
||||
if (logEditorRef.value) {
|
||||
logEditorView = await createExprEditor({
|
||||
parent: logEditorRef.value,
|
||||
placeholder: 'level == "error" && message contains "timeout"',
|
||||
initialValue: alert?.logExpression ?? prefill?.logExpression ?? "",
|
||||
getHints: () => createLogHints(messageKeys.value),
|
||||
onChange: (v) => (logExpression.value = v),
|
||||
});
|
||||
containerEditorView = await initContainerEditor(containerEditorRef.value);
|
||||
}
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
containerEditorView?.destroy();
|
||||
logEditorView?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.log-filter") }}</legend>
|
||||
<div
|
||||
class="input focus-within:input-primary w-full focus-within:z-50"
|
||||
:class="logExpression.trim() && !logError ? 'input-primary' : { 'input-error!': logError }"
|
||||
>
|
||||
<div ref="logEditorRef" 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") }}
|
||||
</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
|
||||
:messages="logMessages"
|
||||
:last-selected-item="undefined"
|
||||
class="border-base-content/50 h-64 overflow-hidden rounded-lg border"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type LogEvent, type LogEntry, type LogMessage, asLogEntry } from "@/models/LogEntry";
|
||||
import { createExprEditor, createLogHints } from "@/composable/exprEditor";
|
||||
import type { NotificationRule, PreviewResult } from "@/types/notifications";
|
||||
|
||||
const props = defineProps<{
|
||||
alert?: NotificationRule;
|
||||
prefill?: { logExpression?: string };
|
||||
containerExpression: string;
|
||||
isLoading: boolean;
|
||||
validatePreview: (extra: Record<string, unknown>) => Promise<{ data: PreviewResult | null }>;
|
||||
}>();
|
||||
|
||||
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 canSave = computed(() => !logError.value);
|
||||
const typeFields = computed(() => ({ logExpression: logExpression.value, metricExpression: "", cooldown: 0 }));
|
||||
|
||||
defineExpose({ canSave, typeFields });
|
||||
|
||||
// Validation
|
||||
async function validate() {
|
||||
if (!props.containerExpression && !logExpression.value) {
|
||||
logError.value = null;
|
||||
logTotalCount.value = 0;
|
||||
logMessages.value = [];
|
||||
messageKeys.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await props.validatePreview({
|
||||
logExpression: logExpression.value || undefined,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
messageKeys.value = data.messageKeys ?? [];
|
||||
if (logExpression.value && !data.containerError) {
|
||||
logError.value = data.logError ?? null;
|
||||
logTotalCount.value = data.totalLogs;
|
||||
logMessages.value = data.matchedLogs?.map((event) => asLogEntry(event as LogEvent)) ?? [];
|
||||
} else {
|
||||
logError.value = null;
|
||||
logTotalCount.value = 0;
|
||||
logMessages.value = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedValidate = useDebounceFn(validate, 500);
|
||||
watch(
|
||||
[() => props.containerExpression, logExpression],
|
||||
() => {
|
||||
debouncedValidate();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Editor
|
||||
const logEditorRef = ref<HTMLElement>();
|
||||
let logEditorView: Awaited<ReturnType<typeof createExprEditor>> | undefined;
|
||||
|
||||
onMounted(async () => {
|
||||
if (logEditorRef.value) {
|
||||
logEditorView = await createExprEditor({
|
||||
parent: logEditorRef.value,
|
||||
placeholder: 'level == "error" && message contains "timeout"',
|
||||
initialValue: props.alert?.logExpression ?? props.prefill?.logExpression ?? "",
|
||||
getHints: () => createLogHints(messageKeys.value),
|
||||
onChange: (v) => (logExpression.value = v),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
logEditorView?.destroy();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.metric-filter") }}</legend>
|
||||
<div
|
||||
class="input focus-within:input-primary w-full focus-within:z-50"
|
||||
:class="metricExpression.trim() && !metricError ? 'input-primary' : { 'input-error!': metricError }"
|
||||
>
|
||||
<div ref="metricEditorRef" 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") }}
|
||||
</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.cooldown-label") }}</legend>
|
||||
<input v-model.number="cooldown" type="range" min="10" max="3600" step="10" class="range range-primary" />
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
{{ $t("notifications.alert-form.cooldown-hint", { duration: formatDuration(cooldown, locale || undefined) }) }}
|
||||
</p>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { createExprEditor, createMetricHints } from "@/composable/exprEditor";
|
||||
import type { NotificationRule, PreviewResult } from "@/types/notifications";
|
||||
|
||||
const props = defineProps<{
|
||||
alert?: NotificationRule;
|
||||
prefill?: { metricExpression?: string };
|
||||
containerExpression: string;
|
||||
isLoading: boolean;
|
||||
validatePreview: (extra: Record<string, unknown>) => Promise<{ data: PreviewResult | null }>;
|
||||
}>();
|
||||
|
||||
const metricExpression = ref(props.alert?.metricExpression ?? props.prefill?.metricExpression ?? "");
|
||||
const metricError = ref<string | null>(null);
|
||||
const cooldown = ref(props.alert?.cooldown ?? 300);
|
||||
|
||||
const canSave = computed(() => !!metricExpression.value.trim() && !metricError.value);
|
||||
const typeFields = computed(() => ({
|
||||
metricExpression: metricExpression.value,
|
||||
logExpression: "",
|
||||
cooldown: cooldown.value,
|
||||
}));
|
||||
|
||||
defineExpose({ canSave, typeFields });
|
||||
|
||||
// Validation
|
||||
async function validate() {
|
||||
if (!props.containerExpression && !metricExpression.value) {
|
||||
metricError.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await props.validatePreview({
|
||||
metricExpression: metricExpression.value || undefined,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
metricError.value = data.metricError ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedValidate = useDebounceFn(validate, 500);
|
||||
watch(
|
||||
[() => props.containerExpression, metricExpression],
|
||||
() => {
|
||||
debouncedValidate();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Editor
|
||||
const metricEditorRef = ref<HTMLElement>();
|
||||
let metricEditorView: Awaited<ReturnType<typeof createExprEditor>> | undefined;
|
||||
|
||||
onMounted(async () => {
|
||||
if (metricEditorRef.value) {
|
||||
metricEditorView = await createExprEditor({
|
||||
parent: metricEditorRef.value,
|
||||
placeholder: "cpu > 80 || memory > 90",
|
||||
initialValue: props.alert?.metricExpression ?? props.prefill?.metricExpression ?? "",
|
||||
getHints: () => createMetricHints(),
|
||||
onChange: (v) => (metricExpression.value = v),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
metricEditorView?.destroy();
|
||||
});
|
||||
</script>
|
||||
@@ -9,7 +9,7 @@ export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "*{{ .Container.Name }}*\n{{ .Log.Message }}",
|
||||
text: "*{{ .Container.Name }}*\n{{ .Detail }}",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -32,7 +32,7 @@ export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
|
||||
embeds: [
|
||||
{
|
||||
title: "{{ .Container.Name }}",
|
||||
description: "{{ .Log.Message }}",
|
||||
description: "{{ .Detail }}",
|
||||
fields: [
|
||||
{ name: "Host", value: "{{ .Container.HostName }}", inline: true },
|
||||
{ name: "Image", value: "{{ .Container.Image }}", inline: true },
|
||||
@@ -47,7 +47,7 @@ export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
|
||||
{
|
||||
topic: "dozzle-{{ .Container.HostName }}",
|
||||
title: "{{ .Container.Name }}",
|
||||
message: "{{ .Log.Message }}",
|
||||
message: "{{ .Detail }}",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -55,8 +55,7 @@ export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
|
||||
custom: JSON.stringify(
|
||||
{
|
||||
container: "{{ .Container.Name }}",
|
||||
level: "{{ .Log.Level }}",
|
||||
message: "{{ .Log.Message }}",
|
||||
message: "{{ .Detail }}",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Container } from "@/models/Container";
|
||||
import type { ContainerJson } from "@/types/Container";
|
||||
import type { Dispatcher, NotificationRule, PreviewResult } from "@/types/notifications";
|
||||
import { createExprEditor, createContainerHints } from "@/composable/exprEditor";
|
||||
|
||||
export interface AlertFormOptions {
|
||||
close?: () => void;
|
||||
onCreated?: () => void;
|
||||
alert?: NotificationRule;
|
||||
prefill?: { name?: string; containerExpression?: string; logExpression?: string; metricExpression?: string };
|
||||
}
|
||||
|
||||
export interface ContainerResult {
|
||||
error?: string;
|
||||
containers?: Container[];
|
||||
}
|
||||
|
||||
export function useAlertForm(options: AlertFormOptions) {
|
||||
const isEditing = computed(() => !!options.alert);
|
||||
const alertName = ref(options.alert?.name ?? options.prefill?.name ?? "");
|
||||
const containerExpression = ref(options.alert?.containerExpression ?? options.prefill?.containerExpression ?? "");
|
||||
const dispatcherId = ref(options.alert?.dispatcher?.id ?? 0);
|
||||
const isSaving = ref(false);
|
||||
const saveError = ref<string | null>(null);
|
||||
|
||||
// Destinations
|
||||
const destinations = ref<Dispatcher[]>([]);
|
||||
onMounted(async () => {
|
||||
const res = await fetch(withBase("/api/notifications/dispatchers"));
|
||||
destinations.value = await res.json();
|
||||
});
|
||||
const selectedDestination = computed(() => destinations.value.find((d) => d.id === dispatcherId.value));
|
||||
|
||||
// Container store for autocomplete
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore);
|
||||
const containerNames = computed(() => [
|
||||
...new Set(containers.value.filter((c) => c.state === "running").map((c) => c.name)),
|
||||
]);
|
||||
const imageNames = computed(() => [...new Set(containers.value.map((c) => c.image))]);
|
||||
const hostNames = computed(() => [...new Set(containers.value.map((c) => c.host))]);
|
||||
|
||||
// Container validation
|
||||
const containerResult = ref<ContainerResult | null>(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const baseCanSave = computed(
|
||||
() =>
|
||||
alertName.value.trim() &&
|
||||
containerExpression.value.trim() &&
|
||||
dispatcherId.value > 0 &&
|
||||
!containerResult.value?.error &&
|
||||
!isSaving.value,
|
||||
);
|
||||
|
||||
async function initContainerEditor(el: HTMLElement) {
|
||||
return await createExprEditor({
|
||||
parent: el,
|
||||
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;
|
||||
try {
|
||||
const input = {
|
||||
name: alertName.value.trim(),
|
||||
containerExpression: containerExpression.value,
|
||||
dispatcherId: dispatcherId.value,
|
||||
enabled: true,
|
||||
...typeSpecificFields,
|
||||
};
|
||||
const url = isEditing.value
|
||||
? withBase(`/api/notifications/rules/${options.alert!.id}`)
|
||||
: withBase("/api/notifications/rules");
|
||||
const res = await fetch(url, {
|
||||
method: isEditing.value ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Failed to save alert");
|
||||
}
|
||||
options.onCreated?.();
|
||||
options.close?.();
|
||||
} catch (e) {
|
||||
saveError.value = e instanceof Error ? e.message : "Failed to save alert";
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function validatePreview(extraFields: Record<string, unknown> = {}) {
|
||||
if (!containerExpression.value && !Object.values(extraFields).some(Boolean)) {
|
||||
containerResult.value = null;
|
||||
return { data: null };
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const res = await fetch(withBase("/api/notifications/preview"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
containerExpression: containerExpression.value,
|
||||
...extraFields,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json();
|
||||
throw new Error(errData.error || "Preview failed");
|
||||
}
|
||||
const data: PreviewResult = await res.json();
|
||||
containerResult.value = containerExpression.value
|
||||
? {
|
||||
error: data.containerError ?? undefined,
|
||||
containers: data.matchedContainers?.map((c) => Container.fromJSON(c as ContainerJson)),
|
||||
}
|
||||
: null;
|
||||
return { data };
|
||||
} catch (e) {
|
||||
containerResult.value = { error: e instanceof Error ? e.message : "Unknown error" };
|
||||
return { data: null };
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isEditing,
|
||||
alertName,
|
||||
containerExpression,
|
||||
dispatcherId,
|
||||
destinations,
|
||||
selectedDestination,
|
||||
containerResult,
|
||||
isLoading,
|
||||
isSaving,
|
||||
saveError,
|
||||
baseCanSave,
|
||||
initContainerEditor,
|
||||
saveAlert,
|
||||
validatePreview,
|
||||
};
|
||||
}
|
||||
@@ -75,6 +75,22 @@ export function createLogHints(messageKeys?: string[]): Completion[] {
|
||||
];
|
||||
}
|
||||
|
||||
export function createMetricHints(): Completion[] {
|
||||
return [
|
||||
{ label: "cpu", detail: "CPU usage percent", type: "property" },
|
||||
{ label: "memory", detail: "memory usage percent", type: "property" },
|
||||
{ label: "memoryUsage", detail: "memory usage bytes", type: "property" },
|
||||
...exprOperators,
|
||||
{ label: ">", detail: "greater than", type: "operator" },
|
||||
{ label: "<", detail: "less than", type: "operator" },
|
||||
{ label: ">=", detail: "greater or equal", type: "operator" },
|
||||
{ label: "<=", detail: "less or equal", type: "operator" },
|
||||
{ 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 },
|
||||
];
|
||||
}
|
||||
|
||||
function createAutocomplete(getHints: () => Completion[]) {
|
||||
return (context: any) => {
|
||||
const word = context.matchBefore(/[\w"=!&|]+/);
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
import type { NotificationRule, Dispatcher } from "@/types/notifications";
|
||||
import AlertForm from "@/components/Notification/AlertForm.vue";
|
||||
import DestinationForm from "@/components/Notification/DestinationForm.vue";
|
||||
import DestinationCard from "@/components/Notification/DestinationCard.vue";
|
||||
|
||||
const showDrawer = useDrawer();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -4,6 +4,8 @@ export interface NotificationRule {
|
||||
enabled: boolean;
|
||||
containerExpression: string;
|
||||
logExpression: string;
|
||||
metricExpression?: string;
|
||||
cooldown?: number;
|
||||
triggerCount: number;
|
||||
triggeredContainers: number;
|
||||
lastTriggeredAt: string | null;
|
||||
@@ -26,11 +28,14 @@ export interface NotificationRuleInput {
|
||||
dispatcherId: number;
|
||||
logExpression: string;
|
||||
containerExpression: string;
|
||||
metricExpression?: string;
|
||||
cooldown?: number;
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
containerError?: string;
|
||||
logError?: string;
|
||||
metricError?: string;
|
||||
matchedContainers: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export function formatDuration(seconds: number, locale: string | undefined): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
const duration = { hours, minutes, seconds: secs };
|
||||
|
||||
if (typeof Intl !== "undefined" && "DurationFormat" in Intl) {
|
||||
// @ts-expect-error DurationFormat is not yet in all TS lib types
|
||||
return new Intl.DurationFormat(locale, { style: "narrow" }).format(duration);
|
||||
}
|
||||
|
||||
if (hours > 0) return `${hours}h ${minutes ? `${minutes}m` : ""}`.trim();
|
||||
if (minutes > 0) return `${minutes}m ${secs ? `${secs}s` : ""}`.trim();
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
const units: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||
["year", 31536000],
|
||||
["month", 2592000],
|
||||
["week", 604800],
|
||||
["day", 86400],
|
||||
["hour", 3600],
|
||||
["minute", 60],
|
||||
["second", 1],
|
||||
];
|
||||
|
||||
export function toRelativeTime(date: Date, locale: string | undefined): string {
|
||||
const diffInSeconds = (date.getTime() - new Date().getTime()) / 1000;
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
|
||||
for (const [unit, seconds] of units) {
|
||||
const value = Math.round(diffInSeconds / seconds);
|
||||
if (Math.abs(value) >= 1) {
|
||||
return rtf.format(value, unit);
|
||||
}
|
||||
}
|
||||
|
||||
return rtf.format(0, "second");
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export function formatBytes(
|
||||
bytes: number,
|
||||
{ decimals = 2, short = false }: { decimals?: number; short?: boolean } = { decimals: 2, short: false },
|
||||
) {
|
||||
if (bytes === 0) return short ? "0B" : "0 Bytes";
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
|
||||
if (short) {
|
||||
return value + sizes[i].charAt(0);
|
||||
} else {
|
||||
return value + " " + sizes[i];
|
||||
}
|
||||
}
|
||||
|
||||
export function stripVersion(label: string) {
|
||||
const [name, _] = label.split(":");
|
||||
return name;
|
||||
}
|
||||
|
||||
export function hashCode(str: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
+4
-135
@@ -1,135 +1,4 @@
|
||||
export function formatBytes(
|
||||
bytes: number,
|
||||
{ decimals = 2, short = false }: { decimals?: number; short?: boolean } = { decimals: 2, short: false },
|
||||
) {
|
||||
if (bytes === 0) return short ? "0B" : "0 Bytes";
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
|
||||
if (short) {
|
||||
return value + sizes[i].charAt(0);
|
||||
} else {
|
||||
return value + " " + sizes[i];
|
||||
}
|
||||
}
|
||||
|
||||
export function getDeep(obj: Record<string, any>, path: string[]) {
|
||||
return path.reduce((acc, key) => acc?.[key], obj);
|
||||
}
|
||||
|
||||
export function isObject(value: any): value is Record<string, any> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function flattenJSON(obj: Record<string, any>, path: string[] = []) {
|
||||
const map = flattenJSONToMap(obj);
|
||||
const result = {} as Record<string, any>;
|
||||
for (const [key, value] of map) {
|
||||
result[key.join(".")] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function flattenJSONToMap(obj: Record<string, any>, path: string[] = []): Map<string[], any> {
|
||||
const result = new Map<string[], any>();
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = obj[key];
|
||||
const newPath = path.concat(key);
|
||||
if (isObject(value)) {
|
||||
for (const [k, v] of flattenJSONToMap(value, newPath)) {
|
||||
result.set(k, v);
|
||||
}
|
||||
} else {
|
||||
result.set(newPath, value);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function arrayEquals(a: string[], b: string[]): boolean {
|
||||
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((val, index) => val === b[index]);
|
||||
}
|
||||
|
||||
export function stripVersion(label: string) {
|
||||
const [name, _] = label.split(":");
|
||||
return name;
|
||||
}
|
||||
|
||||
export function useExponentialMovingAverage<T extends Record<string, number>>(source: Ref<T>, alpha: number = 0.2) {
|
||||
const ema = ref<T>(source.value) as Ref<T>;
|
||||
|
||||
watch(source, (value) => {
|
||||
const newValue = {} as Record<string, number>;
|
||||
for (const key in value) {
|
||||
newValue[key] = alpha * value[key] + (1 - alpha) * ema.value[key];
|
||||
}
|
||||
ema.value = newValue as T;
|
||||
});
|
||||
|
||||
return { movingAverage: ema, reset: (value: T) => (ema.value = value) };
|
||||
}
|
||||
|
||||
interface UseSimpleRefHistoryOptions<T> {
|
||||
capacity: number;
|
||||
deep?: boolean;
|
||||
initial?: T[];
|
||||
}
|
||||
|
||||
export function useSimpleRefHistory<T>(source: Ref<T>, options: UseSimpleRefHistoryOptions<T>) {
|
||||
const { capacity, deep = true, initial = [] as T[] } = options;
|
||||
const history = ref<T[]>(initial) as Ref<T[]>;
|
||||
|
||||
watch(
|
||||
source,
|
||||
(value) => {
|
||||
history.value.push(value);
|
||||
if (history.value.length > capacity) {
|
||||
history.value.shift();
|
||||
}
|
||||
},
|
||||
{ deep },
|
||||
);
|
||||
|
||||
const reset = ({ initial = [] }: Pick<UseSimpleRefHistoryOptions<T>, "initial">) => {
|
||||
history.value = initial;
|
||||
};
|
||||
|
||||
return { history, reset };
|
||||
}
|
||||
|
||||
export function hashCode(str: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
const units: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||
["year", 31536000],
|
||||
["month", 2592000],
|
||||
["week", 604800],
|
||||
["day", 86400],
|
||||
["hour", 3600],
|
||||
["minute", 60],
|
||||
["second", 1],
|
||||
];
|
||||
|
||||
export function toRelativeTime(date: Date, locale: string | undefined): string {
|
||||
const diffInSeconds = (date.getTime() - new Date().getTime()) / 1000;
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
|
||||
for (const [unit, seconds] of units) {
|
||||
const value = Math.round(diffInSeconds / seconds);
|
||||
if (Math.abs(value) >= 1) {
|
||||
return rtf.format(value, unit);
|
||||
}
|
||||
}
|
||||
|
||||
return rtf.format(0, "second");
|
||||
}
|
||||
export { formatDuration, toRelativeTime } from "./date";
|
||||
export { getDeep, isObject, flattenJSON, flattenJSONToMap, arrayEquals } from "./object";
|
||||
export { useExponentialMovingAverage, useSimpleRefHistory } from "./reactive";
|
||||
export { formatBytes, stripVersion, hashCode } from "./format";
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export function getDeep(obj: Record<string, any>, path: string[]) {
|
||||
return path.reduce((acc, key) => acc?.[key], obj);
|
||||
}
|
||||
|
||||
export function isObject(value: any): value is Record<string, any> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function flattenJSON(obj: Record<string, any>, path: string[] = []) {
|
||||
const map = flattenJSONToMap(obj);
|
||||
const result = {} as Record<string, any>;
|
||||
for (const [key, value] of map) {
|
||||
result[key.join(".")] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function flattenJSONToMap(obj: Record<string, any>, path: string[] = []): Map<string[], any> {
|
||||
const result = new Map<string[], any>();
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = obj[key];
|
||||
const newPath = path.concat(key);
|
||||
if (isObject(value)) {
|
||||
for (const [k, v] of flattenJSONToMap(value, newPath)) {
|
||||
result.set(k, v);
|
||||
}
|
||||
} else {
|
||||
result.set(newPath, value);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function arrayEquals(a: string[], b: string[]): boolean {
|
||||
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((val, index) => val === b[index]);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export function useExponentialMovingAverage<T extends Record<string, number>>(source: Ref<T>, alpha: number = 0.2) {
|
||||
const ema = ref<T>(source.value) as Ref<T>;
|
||||
|
||||
watch(source, (value) => {
|
||||
const newValue = {} as Record<string, number>;
|
||||
for (const key in value) {
|
||||
newValue[key] = alpha * value[key] + (1 - alpha) * ema.value[key];
|
||||
}
|
||||
ema.value = newValue as T;
|
||||
});
|
||||
|
||||
return { movingAverage: ema, reset: (value: T) => (ema.value = value) };
|
||||
}
|
||||
|
||||
interface UseSimpleRefHistoryOptions<T> {
|
||||
capacity: number;
|
||||
deep?: boolean;
|
||||
initial?: T[];
|
||||
}
|
||||
|
||||
export function useSimpleRefHistory<T>(source: Ref<T>, options: UseSimpleRefHistoryOptions<T>) {
|
||||
const { capacity, deep = true, initial = [] as T[] } = options;
|
||||
const history = ref<T[]>(initial) as Ref<T[]>;
|
||||
|
||||
watch(
|
||||
source,
|
||||
(value) => {
|
||||
history.value.push(value);
|
||||
if (history.value.length > capacity) {
|
||||
history.value.shift();
|
||||
}
|
||||
},
|
||||
{ deep },
|
||||
);
|
||||
|
||||
const reset = ({ initial = [] }: Pick<UseSimpleRefHistoryOptions<T>, "initial">) => {
|
||||
history.value = initial;
|
||||
};
|
||||
|
||||
return { history, reset };
|
||||
}
|
||||
@@ -56,17 +56,21 @@ You can also write your own payload template using Go's `text/template` syntax.
|
||||
|
||||
<div v-pre>
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------- | --------------------------- |
|
||||
| `{{.Container.Name}}` | Container name |
|
||||
| `{{.Container.Image}}` | Container image |
|
||||
| `{{.Container.HostName}}` | Docker host name |
|
||||
| `{{.Container.State}}` | Container state |
|
||||
| `{{.Log.Message}}` | Log message content |
|
||||
| `{{.Log.Level}}` | Log level |
|
||||
| `{{.Log.Timestamp}}` | Log timestamp |
|
||||
| `{{.Log.Stream}}` | Stream type (stdout/stderr) |
|
||||
| `{{.Subscription.Name}}` | Alert rule name |
|
||||
| Variable | Description |
|
||||
| ------------------------- | -------------------------------------- |
|
||||
| `{{.Detail}}` | Summary (log message or metric values) |
|
||||
| `{{.Container.Name}}` | Container name |
|
||||
| `{{.Container.Image}}` | Container image |
|
||||
| `{{.Container.HostName}}` | Docker host name |
|
||||
| `{{.Container.State}}` | Container state |
|
||||
| `{{.Log.Message}}` | Log message content |
|
||||
| `{{.Log.Level}}` | Log level |
|
||||
| `{{.Log.Timestamp}}` | Log timestamp |
|
||||
| `{{.Log.Stream}}` | Stream type (stdout/stderr) |
|
||||
| `{{.Stat.CPUPercent}}` | CPU usage percentage |
|
||||
| `{{.Stat.MemoryPercent}}` | Memory usage percentage |
|
||||
| `{{.Stat.MemoryUsage}}` | Memory usage in bytes |
|
||||
| `{{.Subscription.Name}}` | Alert rule name |
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -242,7 +242,20 @@ func (s *ContainerStore) SubscribeEvents(ctx context.Context, events chan<- Cont
|
||||
}
|
||||
|
||||
func (s *ContainerStore) SubscribeStats(ctx context.Context, stats chan<- ContainerStat) {
|
||||
go func() {
|
||||
if s.statsCollector.Start(s.ctx) {
|
||||
s.containers.Range(func(_ string, c *Container) bool {
|
||||
c.Stats.Clear()
|
||||
return true
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
s.statsCollector.Subscribe(ctx, stats)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
s.statsCollector.Stop()
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *ContainerStore) SubscribeNewContainers(ctx context.Context, containers chan<- Container) {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/amir20/dozzle/internal/notification/dispatcher"
|
||||
"github.com/amir20/dozzle/types"
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
// WriteConfig writes the current configuration to a writer in YAML format
|
||||
func (m *Manager) WriteConfig(w io.Writer) error {
|
||||
config := Config{
|
||||
Subscriptions: m.Subscriptions(),
|
||||
Dispatchers: m.Dispatchers(),
|
||||
}
|
||||
|
||||
encoder := yaml.NewEncoder(w)
|
||||
defer encoder.Close()
|
||||
|
||||
return encoder.Encode(config)
|
||||
}
|
||||
|
||||
// LoadConfig reads configuration from a reader in YAML format and loads it
|
||||
func (m *Manager) LoadConfig(r io.Reader) error {
|
||||
var config Config
|
||||
|
||||
decoder := yaml.NewDecoder(r)
|
||||
if err := decoder.Decode(&config); err != nil {
|
||||
return fmt.Errorf("failed to decode config: %w", err)
|
||||
}
|
||||
|
||||
// Convert to types for HandleNotificationConfig
|
||||
subscriptions := make([]types.SubscriptionConfig, len(config.Subscriptions))
|
||||
for i, sub := range config.Subscriptions {
|
||||
subscriptions[i] = types.SubscriptionConfig{
|
||||
ID: sub.ID,
|
||||
Name: sub.Name,
|
||||
Enabled: sub.Enabled,
|
||||
DispatcherID: sub.DispatcherID,
|
||||
LogExpression: sub.LogExpression,
|
||||
ContainerExpression: sub.ContainerExpression,
|
||||
MetricExpression: sub.MetricExpression,
|
||||
Cooldown: sub.Cooldown,
|
||||
}
|
||||
}
|
||||
|
||||
dispatchers := make([]types.DispatcherConfig, len(config.Dispatchers))
|
||||
for i, d := range config.Dispatchers {
|
||||
dispatchers[i] = types.DispatcherConfig{
|
||||
ID: d.ID,
|
||||
Name: d.Name,
|
||||
Type: d.Type,
|
||||
URL: d.URL,
|
||||
Template: d.Template,
|
||||
APIKey: d.APIKey,
|
||||
Prefix: d.Prefix,
|
||||
ExpiresAt: d.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
return m.HandleNotificationConfig(subscriptions, dispatchers)
|
||||
}
|
||||
|
||||
// HandleNotificationConfig implements agent.NotificationConfigHandler interface
|
||||
// It atomically replaces all subscriptions and dispatchers with new state from the main server
|
||||
func (m *Manager) HandleNotificationConfig(subscriptions []types.SubscriptionConfig, dispatchers []types.DispatcherConfig) error {
|
||||
// Clear existing state
|
||||
m.subscriptions.Clear()
|
||||
m.dispatchers.Clear()
|
||||
|
||||
// Find max IDs to initialize counters
|
||||
var maxSubID, maxDispatcherID int
|
||||
for _, sub := range subscriptions {
|
||||
if sub.ID > maxSubID {
|
||||
maxSubID = sub.ID
|
||||
}
|
||||
}
|
||||
for _, d := range dispatchers {
|
||||
if d.ID > maxDispatcherID {
|
||||
maxDispatcherID = d.ID
|
||||
}
|
||||
}
|
||||
m.subscriptionCounter.Store(int32(maxSubID))
|
||||
m.dispatcherCounter.Store(int32(maxDispatcherID))
|
||||
|
||||
// Load subscriptions (convert from types.SubscriptionConfig to Subscription)
|
||||
for _, sub := range subscriptions {
|
||||
s := &Subscription{
|
||||
ID: sub.ID,
|
||||
Name: sub.Name,
|
||||
Enabled: sub.Enabled,
|
||||
DispatcherID: sub.DispatcherID,
|
||||
LogExpression: sub.LogExpression,
|
||||
ContainerExpression: sub.ContainerExpression,
|
||||
MetricExpression: sub.MetricExpression,
|
||||
Cooldown: sub.Cooldown,
|
||||
}
|
||||
if err := m.loadSubscription(s); err != nil {
|
||||
return fmt.Errorf("failed to load subscription %s: %w", sub.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Load dispatchers
|
||||
for _, dc := range dispatchers {
|
||||
d, err := createDispatcher(DispatcherConfig{
|
||||
ID: dc.ID,
|
||||
Name: dc.Name,
|
||||
Type: dc.Type,
|
||||
URL: dc.URL,
|
||||
Template: dc.Template,
|
||||
APIKey: dc.APIKey,
|
||||
Prefix: dc.Prefix,
|
||||
ExpiresAt: dc.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create dispatcher %s: %w", dc.Name, err)
|
||||
}
|
||||
m.dispatchers.Store(dc.ID, d)
|
||||
log.Debug().Int("id", dc.ID).Msg("Loaded dispatcher from state sync")
|
||||
}
|
||||
|
||||
m.updateListeners()
|
||||
|
||||
log.Debug().Int("subscriptions", len(subscriptions)).Int("dispatchers", len(dispatchers)).Msg("Replaced notification state")
|
||||
return nil
|
||||
}
|
||||
|
||||
// createDispatcher creates a dispatcher from a DispatcherConfig
|
||||
func createDispatcher(config DispatcherConfig) (dispatcher.Dispatcher, error) {
|
||||
switch config.Type {
|
||||
case "webhook":
|
||||
return dispatcher.NewWebhookDispatcher(config.Name, config.URL, config.Template)
|
||||
case "cloud":
|
||||
return dispatcher.NewCloudDispatcher(config.Name, config.APIKey, config.Prefix, config.ExpiresAt)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown dispatcher type: %s", config.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// loadSubscription loads a subscription with its existing ID (used when loading from config)
|
||||
func (m *Manager) loadSubscription(sub *Subscription) error {
|
||||
if err := sub.CompileExpressions(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if sub.MetricCooldowns == nil {
|
||||
sub.MetricCooldowns = xsync.NewMap[string, time.Time]()
|
||||
}
|
||||
|
||||
m.subscriptions.Store(sub.ID, sub)
|
||||
log.Debug().Str("name", sub.Name).Int("id", sub.ID).Msg("Loaded subscription")
|
||||
return nil
|
||||
}
|
||||
@@ -26,6 +26,7 @@ type ContainerLogListener struct {
|
||||
matcher ContainerMatcher
|
||||
logChannel chan *container.LogEvent
|
||||
ctx context.Context
|
||||
cache *TTLCache[string, containerInfo]
|
||||
}
|
||||
|
||||
// NewContainerLogListener creates a new listener for multiple clients
|
||||
@@ -36,6 +37,7 @@ func NewContainerLogListener(ctx context.Context, clients []container_support.Cl
|
||||
activeStreams: xsync.NewMap[string, context.CancelFunc](),
|
||||
logChannel: make(chan *container.LogEvent, 1000),
|
||||
ctx: ctx,
|
||||
cache: NewTTLCache[string, containerInfo](ctx, 30*time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +72,10 @@ func (l *ContainerLogListener) Start(matcher ContainerMatcher) error {
|
||||
select {
|
||||
case <-l.ctx.Done():
|
||||
return
|
||||
case c := <-containerChan:
|
||||
case c, ok := <-containerChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if l.matcher.ShouldListenToContainer(c) {
|
||||
l.startListeningByID(c)
|
||||
}
|
||||
@@ -82,7 +87,7 @@ func (l *ContainerLogListener) Start(matcher ContainerMatcher) error {
|
||||
}
|
||||
|
||||
// UpdateStreams updates which containers to listen to based on current matcher rules
|
||||
func (l *ContainerLogListener) UpdateStreams() error {
|
||||
func (l *ContainerLogListener) UpdateStreams() {
|
||||
// Get all current containers from all clients
|
||||
for _, client := range l.clients {
|
||||
containers, err := client.ListContainers(l.ctx, nil)
|
||||
@@ -103,8 +108,6 @@ func (l *ContainerLogListener) UpdateStreams() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// startListening starts listening to a container's logs with a known client
|
||||
@@ -164,8 +167,12 @@ func (l *ContainerLogListener) FindContainer(ctx context.Context, id string, lab
|
||||
return client.FindContainer(ctx, id, labels)
|
||||
}
|
||||
|
||||
// FindContainerWithHost finds a container and its host by container ID
|
||||
// FindContainerWithHost finds a container and its host by container ID, using a TTL cache.
|
||||
func (l *ContainerLogListener) FindContainerWithHost(ctx context.Context, id string, labels container.ContainerLabels) (container.Container, container.Host, error) {
|
||||
if cached, ok := l.cache.Load(id); ok {
|
||||
return cached.container, cached.host, nil
|
||||
}
|
||||
|
||||
client, exists := l.containerClients.Load(id)
|
||||
if !exists {
|
||||
return container.Container{}, container.Host{}, fmt.Errorf("container %s not found in any client", id)
|
||||
@@ -181,6 +188,11 @@ func (l *ContainerLogListener) FindContainerWithHost(ctx context.Context, id str
|
||||
return container.Container{}, container.Host{}, fmt.Errorf("failed to get host for container %s: %w", id, err)
|
||||
}
|
||||
|
||||
l.cache.Store(id, containerInfo{
|
||||
container: c,
|
||||
host: host,
|
||||
})
|
||||
|
||||
return c, host, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,7 @@ package notification
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -15,7 +13,6 @@ import (
|
||||
"github.com/expr-lang/expr"
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
"go.yaml.in/yaml/v3"
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
|
||||
@@ -26,18 +23,20 @@ type Manager struct {
|
||||
subscriptionCounter atomic.Int32
|
||||
dispatcherCounter atomic.Int32
|
||||
listener *ContainerLogListener
|
||||
statsListener *ContainerStatsListener
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
sendSem *semaphore.Weighted
|
||||
}
|
||||
|
||||
// NewManager creates a new notification manager
|
||||
func NewManager(listener *ContainerLogListener) *Manager {
|
||||
func NewManager(listener *ContainerLogListener, statsListener *ContainerStatsListener) *Manager {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m := &Manager{
|
||||
subscriptions: xsync.NewMap[int, *Subscription](),
|
||||
dispatchers: xsync.NewMap[int, dispatcher.Dispatcher](),
|
||||
listener: listener,
|
||||
statsListener: statsListener,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
sendSem: semaphore.NewWeighted(5),
|
||||
@@ -46,25 +45,26 @@ func NewManager(listener *ContainerLogListener) *Manager {
|
||||
// Start processing log events from the listener
|
||||
go m.processLogEvents()
|
||||
|
||||
// Start processing stat events from the stats listener
|
||||
go m.processStatEvents()
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// Start initializes the manager and starts the log listener
|
||||
func (m *Manager) Start() error {
|
||||
if m.listener != nil {
|
||||
return m.listener.Start(m)
|
||||
}
|
||||
return nil
|
||||
return m.listener.Start(m)
|
||||
}
|
||||
|
||||
// ShouldListenToContainer implements ContainerMatcher interface
|
||||
// Only matches log-based subscriptions (metric-only subscriptions don't need log streaming)
|
||||
func (m *Manager) ShouldListenToContainer(c container.Container) bool {
|
||||
// Pass empty host for matching - host fields aren't used in container expressions
|
||||
notificationContainer := FromContainerModel(c, container.Host{})
|
||||
|
||||
shouldListen := false
|
||||
m.subscriptions.Range(func(_ int, sub *Subscription) bool {
|
||||
if sub.Enabled && sub.MatchesContainer(notificationContainer) {
|
||||
if sub.Enabled && sub.LogExpression != "" && sub.MatchesContainer(notificationContainer) {
|
||||
shouldListen = true
|
||||
return false
|
||||
}
|
||||
@@ -78,34 +78,16 @@ func (m *Manager) AddSubscription(sub *Subscription) error {
|
||||
// Auto-increment ID using atomic counter
|
||||
sub.ID = int(m.subscriptionCounter.Add(1))
|
||||
sub.Enabled = true
|
||||
sub.MetricCooldowns = xsync.NewMap[string, time.Time]()
|
||||
|
||||
// Compile container expression if provided
|
||||
if sub.ContainerExpression != "" {
|
||||
program, err := expr.Compile(sub.ContainerExpression, expr.Env(types.NotificationContainer{}))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile container expression: %w", err)
|
||||
}
|
||||
sub.ContainerProgram = program
|
||||
}
|
||||
|
||||
// Compile log expression if provided
|
||||
if sub.LogExpression != "" {
|
||||
program, err := expr.Compile(sub.LogExpression, expr.Env(types.NotificationLog{}))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile log expression: %w", err)
|
||||
}
|
||||
sub.LogProgram = program
|
||||
if err := sub.CompileExpressions(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.subscriptions.Store(sub.ID, sub)
|
||||
log.Debug().Str("name", sub.Name).Int("id", sub.ID).Msg("Added subscription")
|
||||
|
||||
// Update listener to start/stop streams based on new subscription
|
||||
if m.listener != nil {
|
||||
if err := m.listener.UpdateStreams(); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to update listener streams")
|
||||
}
|
||||
}
|
||||
m.updateListeners()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -115,33 +97,16 @@ func (m *Manager) RemoveSubscription(id int) {
|
||||
if sub, ok := m.subscriptions.LoadAndDelete(id); ok {
|
||||
log.Debug().Int("id", id).Str("name", sub.Name).Msg("Removed subscription")
|
||||
|
||||
// Update listener to stop streams that are no longer needed
|
||||
if m.listener != nil {
|
||||
if err := m.listener.UpdateStreams(); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to update listener streams")
|
||||
}
|
||||
}
|
||||
m.updateListeners()
|
||||
}
|
||||
}
|
||||
|
||||
// ReplaceSubscription replaces a subscription with new data
|
||||
func (m *Manager) ReplaceSubscription(sub *Subscription) error {
|
||||
// Compile container expression if provided
|
||||
if sub.ContainerExpression != "" {
|
||||
program, err := expr.Compile(sub.ContainerExpression, expr.Env(types.NotificationContainer{}))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile container expression: %w", err)
|
||||
}
|
||||
sub.ContainerProgram = program
|
||||
}
|
||||
sub.MetricCooldowns = xsync.NewMap[string, time.Time]()
|
||||
|
||||
// Compile log expression if provided
|
||||
if sub.LogExpression != "" {
|
||||
program, err := expr.Compile(sub.LogExpression, expr.Env(types.NotificationLog{}))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile log expression: %w", err)
|
||||
}
|
||||
sub.LogProgram = program
|
||||
if err := sub.CompileExpressions(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Preserve enabled state from existing subscription if it exists
|
||||
@@ -154,12 +119,7 @@ func (m *Manager) ReplaceSubscription(sub *Subscription) error {
|
||||
m.subscriptions.Store(sub.ID, sub)
|
||||
log.Debug().Str("name", sub.Name).Int("id", sub.ID).Msg("Replaced subscription")
|
||||
|
||||
// Update listener to start/stop streams based on new subscription
|
||||
if m.listener != nil {
|
||||
if err := m.listener.UpdateStreams(); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to update listener streams")
|
||||
}
|
||||
}
|
||||
m.updateListeners()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -183,6 +143,10 @@ func (m *Manager) UpdateSubscription(id int, updates map[string]any) error {
|
||||
ContainerProgram: sub.ContainerProgram,
|
||||
LogExpression: sub.LogExpression,
|
||||
LogProgram: sub.LogProgram,
|
||||
MetricExpression: sub.MetricExpression,
|
||||
MetricProgram: sub.MetricProgram,
|
||||
Cooldown: sub.Cooldown,
|
||||
MetricCooldowns: sub.MetricCooldowns,
|
||||
}
|
||||
|
||||
// Apply updates to the clone
|
||||
@@ -220,8 +184,30 @@ func (m *Manager) UpdateSubscription(id int, updates map[string]any) error {
|
||||
}
|
||||
updated.LogExpression = exprStr
|
||||
updated.LogProgram = program
|
||||
} else {
|
||||
updated.LogExpression = ""
|
||||
updated.LogProgram = nil
|
||||
}
|
||||
}
|
||||
case "metricExpression":
|
||||
if exprStr, ok := value.(string); ok {
|
||||
if exprStr != "" {
|
||||
program, err := expr.Compile(exprStr, expr.Env(types.NotificationStat{}))
|
||||
if err != nil {
|
||||
updateErr = fmt.Errorf("failed to compile metric expression: %w", err)
|
||||
return nil, xsync.CancelOp
|
||||
}
|
||||
updated.MetricExpression = exprStr
|
||||
updated.MetricProgram = program
|
||||
} else {
|
||||
updated.MetricExpression = ""
|
||||
updated.MetricProgram = nil
|
||||
}
|
||||
}
|
||||
case "cooldown":
|
||||
if cd, ok := value.(int); ok {
|
||||
updated.Cooldown = cd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,16 +224,31 @@ func (m *Manager) UpdateSubscription(id int, updates map[string]any) error {
|
||||
|
||||
log.Debug().Int("id", id).Interface("updates", updates).Msg("Updated subscription")
|
||||
|
||||
// Update listener streams in case expressions changed
|
||||
if m.listener != nil {
|
||||
if err := m.listener.UpdateStreams(); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to update listener streams")
|
||||
}
|
||||
}
|
||||
m.updateListeners()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateListeners updates log and stats listeners based on current subscriptions
|
||||
func (m *Manager) updateListeners() {
|
||||
m.listener.UpdateStreams()
|
||||
|
||||
hasMetric := false
|
||||
m.subscriptions.Range(func(_ int, sub *Subscription) bool {
|
||||
if sub.Enabled && sub.IsMetricAlert() {
|
||||
hasMetric = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if hasMetric {
|
||||
m.statsListener.Start()
|
||||
} else {
|
||||
m.statsListener.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// AddDispatcher adds a dispatcher and returns its auto-generated ID
|
||||
func (m *Manager) AddDispatcher(d dispatcher.Dispatcher) int {
|
||||
id := int(m.dispatcherCounter.Add(1))
|
||||
@@ -312,269 +313,3 @@ func (m *Manager) Dispatchers() []DispatcherConfig {
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// processLogEvents processes log events from the listener channel
|
||||
func (m *Manager) processLogEvents() {
|
||||
for {
|
||||
select {
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
case logEvent := <-m.listener.LogChannel():
|
||||
if logEvent == nil {
|
||||
return
|
||||
}
|
||||
m.processLogEvent(logEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processLogEvent processes a single log event and sends notifications for matching subscriptions
|
||||
func (m *Manager) processLogEvent(logEvent *container.LogEvent) {
|
||||
// Get container and host from log event's ContainerID
|
||||
ctx, cancel := context.WithTimeout(m.ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c, host, err := m.listener.FindContainerWithHost(ctx, logEvent.ContainerID, nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("containerID", logEvent.ContainerID).Msg("Failed to find container")
|
||||
return
|
||||
}
|
||||
|
||||
// Skip logs from Dozzle itself to avoid feedback loops
|
||||
if strings.Contains(c.Image, "dozzle") {
|
||||
return
|
||||
}
|
||||
|
||||
notificationContainer := FromContainerModel(c, host)
|
||||
notificationLog := FromLogEvent(*logEvent)
|
||||
|
||||
m.subscriptions.Range(func(_ int, sub *Subscription) bool {
|
||||
// Skip disabled subscriptions
|
||||
if !sub.Enabled {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check container filter
|
||||
if !sub.MatchesContainer(notificationContainer) {
|
||||
return true
|
||||
}
|
||||
|
||||
sub.AddTriggeredContainer(notificationContainer.ID)
|
||||
|
||||
// Check log filter
|
||||
if !sub.MatchesLog(notificationLog) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Update stats
|
||||
sub.TriggerCount.Add(1)
|
||||
now := time.Now()
|
||||
sub.LastTriggeredAt.Store(&now)
|
||||
|
||||
log.Debug().Str("containerID", notificationContainer.ID).Interface("log", notificationLog.Message).Msg("Matched subscription")
|
||||
|
||||
// Create notification
|
||||
notification := types.Notification{
|
||||
ID: fmt.Sprintf("%s-%d", c.ID, time.Now().UnixNano()),
|
||||
Container: notificationContainer,
|
||||
Log: notificationLog,
|
||||
Subscription: types.SubscriptionConfig{
|
||||
ID: sub.ID,
|
||||
Name: sub.Name,
|
||||
Enabled: sub.Enabled,
|
||||
DispatcherID: sub.DispatcherID,
|
||||
LogExpression: sub.LogExpression,
|
||||
ContainerExpression: sub.ContainerExpression,
|
||||
},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
// Send to the subscription's dispatcher
|
||||
if d, ok := m.dispatchers.Load(sub.DispatcherID); ok {
|
||||
go m.sendNotification(d, notification, sub.DispatcherID)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// sendNotification sends a notification using the dispatcher
|
||||
func (m *Manager) sendNotification(d dispatcher.Dispatcher, notification types.Notification, id int) {
|
||||
acquireCtx, acquireCancel := context.WithTimeout(m.ctx, time.Minute)
|
||||
defer acquireCancel()
|
||||
if err := m.sendSem.Acquire(acquireCtx, 1); err != nil {
|
||||
log.Warn().Err(err).Int("subscription", id).Msg("Notification dropped: too many pending")
|
||||
return
|
||||
}
|
||||
defer m.sendSem.Release(1)
|
||||
|
||||
ctx, cancel := context.WithTimeout(m.ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := d.Send(ctx, notification); err != nil {
|
||||
log.Error().Err(err).Int("subscription", id).Msg("Failed to send notification")
|
||||
}
|
||||
}
|
||||
|
||||
// WriteConfig writes the current configuration to a writer in YAML format
|
||||
func (m *Manager) WriteConfig(w io.Writer) error {
|
||||
config := Config{
|
||||
Subscriptions: m.Subscriptions(),
|
||||
Dispatchers: m.Dispatchers(),
|
||||
}
|
||||
|
||||
encoder := yaml.NewEncoder(w)
|
||||
defer encoder.Close()
|
||||
|
||||
return encoder.Encode(config)
|
||||
}
|
||||
|
||||
// LoadConfig reads configuration from a reader in YAML format and loads it
|
||||
func (m *Manager) LoadConfig(r io.Reader) error {
|
||||
var config Config
|
||||
|
||||
decoder := yaml.NewDecoder(r)
|
||||
if err := decoder.Decode(&config); err != nil {
|
||||
return fmt.Errorf("failed to decode config: %w", err)
|
||||
}
|
||||
|
||||
// Convert to types for HandleNotificationConfig
|
||||
subscriptions := make([]types.SubscriptionConfig, len(config.Subscriptions))
|
||||
for i, sub := range config.Subscriptions {
|
||||
subscriptions[i] = types.SubscriptionConfig{
|
||||
ID: sub.ID,
|
||||
Name: sub.Name,
|
||||
Enabled: sub.Enabled,
|
||||
DispatcherID: sub.DispatcherID,
|
||||
LogExpression: sub.LogExpression,
|
||||
ContainerExpression: sub.ContainerExpression,
|
||||
}
|
||||
}
|
||||
|
||||
dispatchers := make([]types.DispatcherConfig, len(config.Dispatchers))
|
||||
for i, d := range config.Dispatchers {
|
||||
dispatchers[i] = types.DispatcherConfig{
|
||||
ID: d.ID,
|
||||
Name: d.Name,
|
||||
Type: d.Type,
|
||||
URL: d.URL,
|
||||
Template: d.Template,
|
||||
APIKey: d.APIKey,
|
||||
Prefix: d.Prefix,
|
||||
ExpiresAt: d.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
return m.HandleNotificationConfig(subscriptions, dispatchers)
|
||||
}
|
||||
|
||||
// HandleNotificationConfig implements agent.NotificationConfigHandler interface
|
||||
// It atomically replaces all subscriptions and dispatchers with new state from the main server
|
||||
func (m *Manager) HandleNotificationConfig(subscriptions []types.SubscriptionConfig, dispatchers []types.DispatcherConfig) error {
|
||||
// Clear existing state (with nil checks for defensive programming)
|
||||
if m.subscriptions != nil {
|
||||
m.subscriptions.Clear()
|
||||
} else {
|
||||
m.subscriptions = xsync.NewMap[int, *Subscription]()
|
||||
}
|
||||
if m.dispatchers != nil {
|
||||
m.dispatchers.Clear()
|
||||
} else {
|
||||
m.dispatchers = xsync.NewMap[int, dispatcher.Dispatcher]()
|
||||
}
|
||||
|
||||
// Find max IDs to initialize counters
|
||||
var maxSubID, maxDispatcherID int
|
||||
for _, sub := range subscriptions {
|
||||
if sub.ID > maxSubID {
|
||||
maxSubID = sub.ID
|
||||
}
|
||||
}
|
||||
for _, d := range dispatchers {
|
||||
if d.ID > maxDispatcherID {
|
||||
maxDispatcherID = d.ID
|
||||
}
|
||||
}
|
||||
m.subscriptionCounter.Store(int32(maxSubID))
|
||||
m.dispatcherCounter.Store(int32(maxDispatcherID))
|
||||
|
||||
// Load subscriptions (convert from types.SubscriptionConfig to Subscription)
|
||||
for _, sub := range subscriptions {
|
||||
s := &Subscription{
|
||||
ID: sub.ID,
|
||||
Name: sub.Name,
|
||||
Enabled: sub.Enabled,
|
||||
DispatcherID: sub.DispatcherID,
|
||||
LogExpression: sub.LogExpression,
|
||||
ContainerExpression: sub.ContainerExpression,
|
||||
}
|
||||
if err := m.loadSubscription(s); err != nil {
|
||||
return fmt.Errorf("failed to load subscription %s: %w", sub.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Load dispatchers
|
||||
for _, dc := range dispatchers {
|
||||
d, err := createDispatcher(DispatcherConfig{
|
||||
ID: dc.ID,
|
||||
Name: dc.Name,
|
||||
Type: dc.Type,
|
||||
URL: dc.URL,
|
||||
Template: dc.Template,
|
||||
APIKey: dc.APIKey,
|
||||
Prefix: dc.Prefix,
|
||||
ExpiresAt: dc.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create dispatcher %s: %w", dc.Name, err)
|
||||
}
|
||||
m.dispatchers.Store(dc.ID, d)
|
||||
log.Debug().Int("id", dc.ID).Msg("Loaded dispatcher from state sync")
|
||||
}
|
||||
|
||||
// Update listener to start/stop streams based on new subscriptions
|
||||
if m.listener != nil {
|
||||
if err := m.listener.UpdateStreams(); err != nil {
|
||||
return fmt.Errorf("failed to update listener streams: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug().Int("subscriptions", len(subscriptions)).Int("dispatchers", len(dispatchers)).Msg("Replaced notification state")
|
||||
return nil
|
||||
}
|
||||
|
||||
// createDispatcher creates a dispatcher from a DispatcherConfig
|
||||
func createDispatcher(config DispatcherConfig) (dispatcher.Dispatcher, error) {
|
||||
switch config.Type {
|
||||
case "webhook":
|
||||
return dispatcher.NewWebhookDispatcher(config.Name, config.URL, config.Template)
|
||||
case "cloud":
|
||||
return dispatcher.NewCloudDispatcher(config.Name, config.APIKey, config.Prefix, config.ExpiresAt)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown dispatcher type: %s", config.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// loadSubscription loads a subscription with its existing ID (used when loading from config)
|
||||
func (m *Manager) loadSubscription(sub *Subscription) error {
|
||||
// Compile container expression if provided
|
||||
if sub.ContainerExpression != "" {
|
||||
program, err := expr.Compile(sub.ContainerExpression, expr.Env(types.NotificationContainer{}))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile container expression: %w", err)
|
||||
}
|
||||
sub.ContainerProgram = program
|
||||
}
|
||||
|
||||
// Compile log expression if provided
|
||||
if sub.LogExpression != "" {
|
||||
program, err := expr.Compile(sub.LogExpression, expr.Env(types.NotificationLog{}))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile log expression: %w", err)
|
||||
}
|
||||
sub.LogProgram = program
|
||||
}
|
||||
|
||||
m.subscriptions.Store(sub.ID, sub)
|
||||
log.Debug().Str("name", sub.Name).Int("id", sub.ID).Msg("Loaded subscription")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/amir20/dozzle/internal/container"
|
||||
"github.com/amir20/dozzle/internal/notification/dispatcher"
|
||||
"github.com/amir20/dozzle/types"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// processLogEvents processes log events from the listener channel
|
||||
func (m *Manager) processLogEvents() {
|
||||
for {
|
||||
select {
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
case logEvent := <-m.listener.LogChannel():
|
||||
if logEvent == nil {
|
||||
return
|
||||
}
|
||||
m.processLogEvent(logEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processLogEvent processes a single log event and sends notifications for matching subscriptions
|
||||
func (m *Manager) processLogEvent(logEvent *container.LogEvent) {
|
||||
// Get container and host from log event's ContainerID
|
||||
ctx, cancel := context.WithTimeout(m.ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c, host, err := m.listener.FindContainerWithHost(ctx, logEvent.ContainerID, nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("containerID", logEvent.ContainerID).Msg("Failed to find container")
|
||||
return
|
||||
}
|
||||
|
||||
// Skip logs from Dozzle's own containers to avoid feedback loops
|
||||
if isDozzleContainer(c) {
|
||||
return
|
||||
}
|
||||
|
||||
notificationContainer := FromContainerModel(c, host)
|
||||
notificationLog := FromLogEvent(*logEvent)
|
||||
|
||||
m.subscriptions.Range(func(_ int, sub *Subscription) bool {
|
||||
// Skip disabled or non-log subscriptions
|
||||
if !sub.Enabled || !sub.IsLogAlert() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check container filter
|
||||
if !sub.MatchesContainer(notificationContainer) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check log filter
|
||||
if !sub.MatchesLog(notificationLog) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Update stats
|
||||
sub.AddTriggeredContainer(notificationContainer.ID)
|
||||
sub.TriggerCount.Add(1)
|
||||
now := time.Now()
|
||||
sub.LastTriggeredAt.Store(&now)
|
||||
|
||||
log.Debug().Str("containerID", notificationContainer.ID).Interface("log", notificationLog.Message).Msg("Matched subscription")
|
||||
|
||||
// Create notification
|
||||
notification := types.Notification{
|
||||
ID: fmt.Sprintf("%s-%d", c.ID, time.Now().UnixNano()),
|
||||
Detail: fmt.Sprintf("%v", notificationLog.Message),
|
||||
Container: notificationContainer,
|
||||
Log: ¬ificationLog,
|
||||
Stat: &types.NotificationStat{},
|
||||
Subscription: types.SubscriptionConfig{
|
||||
ID: sub.ID,
|
||||
Name: sub.Name,
|
||||
Enabled: sub.Enabled,
|
||||
DispatcherID: sub.DispatcherID,
|
||||
LogExpression: sub.LogExpression,
|
||||
ContainerExpression: sub.ContainerExpression,
|
||||
},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
// Send to the subscription's dispatcher
|
||||
if d, ok := m.dispatchers.Load(sub.DispatcherID); ok {
|
||||
go m.sendNotification(d, notification, sub.DispatcherID)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// processStatEvents processes stat events from the stats listener channel
|
||||
func (m *Manager) processStatEvents() {
|
||||
for {
|
||||
select {
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
case event, ok := <-m.statsListener.Channel():
|
||||
if !ok || event == nil {
|
||||
return
|
||||
}
|
||||
m.processStatEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processStatEvent processes a single stat event and sends notifications for matching metric subscriptions
|
||||
func (m *Manager) processStatEvent(event *ContainerStatEvent) {
|
||||
notificationStat := types.NotificationStat{
|
||||
CPUPercent: event.Stat.CPUPercent,
|
||||
MemoryPercent: event.Stat.MemoryPercent,
|
||||
MemoryUsage: event.Stat.MemoryUsage,
|
||||
}
|
||||
|
||||
notificationContainer := FromContainerModel(event.Container, event.Host)
|
||||
|
||||
m.subscriptions.Range(func(_ int, sub *Subscription) bool {
|
||||
// Skip disabled or non-metric subscriptions
|
||||
if !sub.Enabled || !sub.IsMetricAlert() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check container filter first
|
||||
if !sub.MatchesContainer(notificationContainer) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check metric expression
|
||||
if !sub.MatchesMetric(notificationStat) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check per-container cooldown
|
||||
if sub.IsMetricCooldownActive(event.Stat.ID) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Set cooldown and update stats
|
||||
sub.SetMetricCooldown(event.Stat.ID)
|
||||
sub.AddTriggeredContainer(event.Stat.ID)
|
||||
sub.TriggerCount.Add(1)
|
||||
now := time.Now()
|
||||
sub.LastTriggeredAt.Store(&now)
|
||||
|
||||
log.Debug().
|
||||
Str("containerID", event.Stat.ID).
|
||||
Float64("cpu", event.Stat.CPUPercent).
|
||||
Float64("memory", event.Stat.MemoryPercent).
|
||||
Str("subscription", sub.Name).
|
||||
Msg("Metric alert triggered")
|
||||
|
||||
notification := types.Notification{
|
||||
ID: fmt.Sprintf("%s-metric-%d", event.Stat.ID, time.Now().UnixNano()),
|
||||
Detail: fmt.Sprintf("CPU: %.1f%%, Memory: %.1f%%", notificationStat.CPUPercent, notificationStat.MemoryPercent),
|
||||
Container: notificationContainer,
|
||||
Log: &types.NotificationLog{},
|
||||
Stat: ¬ificationStat,
|
||||
Subscription: types.SubscriptionConfig{
|
||||
ID: sub.ID,
|
||||
Name: sub.Name,
|
||||
Enabled: sub.Enabled,
|
||||
DispatcherID: sub.DispatcherID,
|
||||
MetricExpression: sub.MetricExpression,
|
||||
ContainerExpression: sub.ContainerExpression,
|
||||
Cooldown: sub.Cooldown,
|
||||
},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
if d, ok := m.dispatchers.Load(sub.DispatcherID); ok {
|
||||
go m.sendNotification(d, notification, sub.DispatcherID)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// sendNotification sends a notification using the dispatcher
|
||||
func (m *Manager) sendNotification(d dispatcher.Dispatcher, notification types.Notification, id int) {
|
||||
acquireCtx, acquireCancel := context.WithTimeout(m.ctx, time.Minute)
|
||||
defer acquireCancel()
|
||||
if err := m.sendSem.Acquire(acquireCtx, 1); err != nil {
|
||||
log.Warn().Err(err).Int("subscription", id).Msg("Notification dropped: too many pending")
|
||||
return
|
||||
}
|
||||
defer m.sendSem.Release(1)
|
||||
|
||||
ctx, cancel := context.WithTimeout(m.ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := d.Send(ctx, notification); err != nil {
|
||||
log.Error().Err(err).Int("subscription", id).Msg("Failed to send notification")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/amir20/dozzle/internal/container"
|
||||
container_support "github.com/amir20/dozzle/internal/support/container"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// ContainerStatEvent pairs a stat with its resolved container and host metadata.
|
||||
type ContainerStatEvent struct {
|
||||
Stat container.ContainerStat
|
||||
Container container.Container
|
||||
Host container.Host
|
||||
}
|
||||
|
||||
type containerInfo struct {
|
||||
container container.Container
|
||||
host container.Host
|
||||
}
|
||||
|
||||
// ContainerStatsListener subscribes to container stats from all clients,
|
||||
// enriches each stat with container and host metadata, and forwards them to a channel.
|
||||
type ContainerStatsListener struct {
|
||||
clients []container_support.ClientService
|
||||
channel chan *ContainerStatEvent
|
||||
parentCtx context.Context
|
||||
cache *TTLCache[string, containerInfo]
|
||||
mu sync.Mutex
|
||||
cancelFunc context.CancelFunc
|
||||
}
|
||||
|
||||
// NewContainerStatsListener creates a new listener that can subscribe to stats from the given clients.
|
||||
// Call Start() to begin receiving stats.
|
||||
func NewContainerStatsListener(ctx context.Context, clients []container_support.ClientService) *ContainerStatsListener {
|
||||
return &ContainerStatsListener{
|
||||
clients: clients,
|
||||
channel: make(chan *ContainerStatEvent, 1000),
|
||||
parentCtx: ctx,
|
||||
cache: NewTTLCache[string, containerInfo](ctx, 30*time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// Start subscribes to stats from all clients and begins enriching events.
|
||||
// No-op if already running.
|
||||
func (l *ContainerStatsListener) Start() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.cancelFunc != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(l.parentCtx)
|
||||
l.cancelFunc = cancel
|
||||
|
||||
rawStats := make(chan container.ContainerStat, 1000)
|
||||
for _, client := range l.clients {
|
||||
client.SubscribeStats(ctx, rawStats)
|
||||
}
|
||||
|
||||
go l.enrich(ctx, rawStats)
|
||||
|
||||
log.Debug().Msg("Started container stats listener for metric alerts")
|
||||
}
|
||||
|
||||
// Stop unsubscribes from all clients' stats by cancelling the subscription context.
|
||||
// No-op if not running.
|
||||
func (l *ContainerStatsListener) Stop() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.cancelFunc == nil {
|
||||
return
|
||||
}
|
||||
l.cancelFunc()
|
||||
l.cancelFunc = nil
|
||||
log.Debug().Msg("Stopped container stats listener for metric alerts")
|
||||
}
|
||||
|
||||
// IsRunning returns whether the listener is currently subscribed to stats.
|
||||
func (l *ContainerStatsListener) IsRunning() bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.cancelFunc != nil
|
||||
}
|
||||
|
||||
// enrich reads raw stats, resolves container+host, and sends enriched events.
|
||||
func (l *ContainerStatsListener) enrich(ctx context.Context, rawStats <-chan container.ContainerStat) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case stat, ok := <-rawStats:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c, host, err := l.resolveContainer(stat.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip stats from Dozzle's own containers
|
||||
if isDozzleContainer(c) {
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case l.channel <- &ContainerStatEvent{Stat: stat, Container: c, Host: host}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
log.Warn().Str("containerID", stat.ID).Msg("Metric stats channel full, dropping stat event")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveContainer looks up container+host, using a TTL cache to avoid repeated API calls.
|
||||
func (l *ContainerStatsListener) resolveContainer(containerID string) (container.Container, container.Host, error) {
|
||||
if cached, ok := l.cache.Load(containerID); ok {
|
||||
return cached.container, cached.host, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(l.parentCtx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c, host, err := l.findContainerWithHost(ctx, containerID)
|
||||
if err != nil {
|
||||
return c, host, err
|
||||
}
|
||||
|
||||
l.cache.Store(containerID, containerInfo{
|
||||
container: c,
|
||||
host: host,
|
||||
})
|
||||
|
||||
return c, host, nil
|
||||
}
|
||||
|
||||
// Channel returns the channel for enriched stat events.
|
||||
func (l *ContainerStatsListener) Channel() <-chan *ContainerStatEvent {
|
||||
return l.channel
|
||||
}
|
||||
|
||||
func (l *ContainerStatsListener) findContainerWithHost(ctx context.Context, containerID string) (container.Container, container.Host, error) {
|
||||
for _, client := range l.clients {
|
||||
c, err := client.FindContainer(ctx, containerID, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
host, err := client.Host(ctx)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return c, host, nil
|
||||
}
|
||||
return container.Container{}, container.Host{}, container.ErrContainerNotFound
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
)
|
||||
|
||||
type ttlEntry[V any] struct {
|
||||
value V
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// TTLCache is a concurrent-safe cache with per-entry TTL and periodic eviction.
|
||||
type TTLCache[K comparable, V any] struct {
|
||||
entries *xsync.Map[K, ttlEntry[V]]
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewTTLCache creates a cache with the given TTL. It starts a background goroutine
|
||||
// that sweeps expired entries every sweepInterval. The goroutine stops when ctx is cancelled.
|
||||
func NewTTLCache[K comparable, V any](ctx context.Context, ttl time.Duration) *TTLCache[K, V] {
|
||||
c := &TTLCache[K, V]{
|
||||
entries: xsync.NewMap[K, ttlEntry[V]](),
|
||||
ttl: ttl,
|
||||
}
|
||||
|
||||
sweepInterval := max(ttl*2, time.Minute)
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(sweepInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.evict()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// Load returns the cached value if present and not expired.
|
||||
func (c *TTLCache[K, V]) Load(key K) (V, bool) {
|
||||
entry, ok := c.entries.Load(key)
|
||||
if !ok || time.Now().After(entry.expiresAt) {
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
return entry.value, true
|
||||
}
|
||||
|
||||
// Store adds or updates a value in the cache with the configured TTL.
|
||||
func (c *TTLCache[K, V]) Store(key K, value V) {
|
||||
c.entries.Store(key, ttlEntry[V]{
|
||||
value: value,
|
||||
expiresAt: time.Now().Add(c.ttl),
|
||||
})
|
||||
}
|
||||
|
||||
// evict removes all expired entries.
|
||||
func (c *TTLCache[K, V]) evict() {
|
||||
now := time.Now()
|
||||
c.entries.Range(func(key K, entry ttlEntry[V]) bool {
|
||||
if now.After(entry.expiresAt) {
|
||||
c.entries.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -14,6 +15,11 @@ import (
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
)
|
||||
|
||||
// isDozzleContainer returns true if the container is a Dozzle instance (to avoid feedback loops)
|
||||
func isDozzleContainer(c container.Container) bool {
|
||||
return c.Image == "amir20/dozzle" || strings.HasPrefix(c.Image, "amir20/dozzle:")
|
||||
}
|
||||
|
||||
// FromContainerModel converts internal container.Container to types.NotificationContainer
|
||||
func FromContainerModel(c container.Container, host container.Host) types.NotificationContainer {
|
||||
return types.NotificationContainer{
|
||||
@@ -84,15 +90,21 @@ type Subscription struct {
|
||||
DispatcherID int `json:"dispatcherId" yaml:"dispatcherId"`
|
||||
LogExpression string `json:"logExpression" yaml:"logExpression"`
|
||||
ContainerExpression string `json:"containerExpression" yaml:"containerExpression"`
|
||||
MetricExpression string `json:"metricExpression,omitempty" yaml:"metricExpression,omitempty"`
|
||||
Cooldown int `json:"cooldown,omitempty" yaml:"cooldown,omitempty"` // seconds between metric notifications, default 300
|
||||
|
||||
// Compiled log filter expression
|
||||
// Compiled filter expressions
|
||||
LogProgram *vm.Program `json:"-" yaml:"-"` // Compiled log filter expression
|
||||
ContainerProgram *vm.Program `json:"-" yaml:"-"` // Compiled container filter expression
|
||||
MetricProgram *vm.Program `json:"-" yaml:"-"` // Compiled metric filter expression
|
||||
|
||||
// Runtime stats (not persisted)
|
||||
TriggerCount atomic.Int64 `json:"-" yaml:"-"`
|
||||
LastTriggeredAt atomic.Pointer[time.Time] `json:"-" yaml:"-"`
|
||||
TriggeredContainerIDs *xsync.Map[string, struct{}] `json:"-" yaml:"-"` // unique container IDs that triggered
|
||||
|
||||
// Per-container cooldown tracking for metric alerts (containerID -> last triggered time)
|
||||
MetricCooldowns *xsync.Map[string, time.Time] `json:"-" yaml:"-"`
|
||||
}
|
||||
|
||||
// TriggeredContainersCount returns the number of unique containers that triggered this subscription
|
||||
@@ -111,6 +123,36 @@ func (s *Subscription) AddTriggeredContainer(id string) {
|
||||
s.TriggeredContainerIDs.Store(id, struct{}{})
|
||||
}
|
||||
|
||||
// CompileExpressions compiles all expression strings into executable programs.
|
||||
// Returns an error describing which expression failed to compile.
|
||||
func (s *Subscription) CompileExpressions() error {
|
||||
if s.ContainerExpression != "" {
|
||||
program, err := expr.Compile(s.ContainerExpression, expr.Env(types.NotificationContainer{}))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile container expression: %w", err)
|
||||
}
|
||||
s.ContainerProgram = program
|
||||
}
|
||||
|
||||
if s.LogExpression != "" {
|
||||
program, err := expr.Compile(s.LogExpression, expr.Env(types.NotificationLog{}))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile log expression: %w", err)
|
||||
}
|
||||
s.LogProgram = program
|
||||
}
|
||||
|
||||
if s.MetricExpression != "" {
|
||||
program, err := expr.Compile(s.MetricExpression, expr.Env(types.NotificationStat{}))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile metric expression: %w", err)
|
||||
}
|
||||
s.MetricProgram = program
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DispatcherConfig represents a dispatcher configuration
|
||||
type DispatcherConfig struct {
|
||||
ID int `json:"id" yaml:"id"`
|
||||
@@ -162,3 +204,58 @@ func (s *Subscription) MatchesLog(l types.NotificationLog) bool {
|
||||
match, ok := result.(bool)
|
||||
return ok && match
|
||||
}
|
||||
|
||||
// IsLogAlert returns true if this subscription is a log-based alert
|
||||
func (s *Subscription) IsLogAlert() bool {
|
||||
return s.LogExpression != "" && s.LogProgram != nil
|
||||
}
|
||||
|
||||
// IsMetricAlert returns true if this subscription is a metric-based alert
|
||||
func (s *Subscription) IsMetricAlert() bool {
|
||||
return s.MetricExpression != "" && s.MetricProgram != nil
|
||||
}
|
||||
|
||||
// MatchesMetric checks if a stat matches this subscription's metric filter
|
||||
func (s *Subscription) MatchesMetric(stat types.NotificationStat) bool {
|
||||
if s.MetricProgram == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
result, err := expr.Run(s.MetricProgram, stat)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("expression", s.MetricExpression).Msg("metric expression evaluation error")
|
||||
return false
|
||||
}
|
||||
|
||||
match, ok := result.(bool)
|
||||
return ok && match
|
||||
}
|
||||
|
||||
// GetCooldownSeconds returns the cooldown in seconds, clamped to [10, 3600], defaulting to 300 (5 min)
|
||||
func (s *Subscription) GetCooldownSeconds() int {
|
||||
if s.Cooldown <= 0 {
|
||||
return 300
|
||||
}
|
||||
if s.Cooldown < 10 {
|
||||
return 10
|
||||
}
|
||||
if s.Cooldown > 3600 {
|
||||
return 3600
|
||||
}
|
||||
return s.Cooldown
|
||||
}
|
||||
|
||||
// IsMetricCooldownActive checks if the cooldown is still active for a given container
|
||||
func (s *Subscription) IsMetricCooldownActive(containerID string) bool {
|
||||
lastTriggered, ok := s.MetricCooldowns.Load(containerID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
cooldown := time.Duration(s.GetCooldownSeconds()) * time.Second
|
||||
return time.Now().Before(lastTriggered.Add(cooldown))
|
||||
}
|
||||
|
||||
// SetMetricCooldown records the current time as the last triggered time for a container
|
||||
func (s *Subscription) SetMetricCooldown(containerID string) {
|
||||
s.MetricCooldowns.Store(containerID, time.Now())
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ func (a *AgentCmd) Run(args Args, embeddedCerts embed.FS) error {
|
||||
// Create notification manager using the shared client service
|
||||
const notificationConfigPath = "./data/notifications.yml"
|
||||
clients := []container_support.ClientService{clientService}
|
||||
notificationManager := notification.NewManager(notification.NewContainerLogListener(ctx, clients))
|
||||
notificationManager := notification.NewManager(notification.NewContainerLogListener(ctx, clients), notification.NewContainerStatsListener(ctx, clients))
|
||||
|
||||
// Load existing notification config if available
|
||||
if file, err := os.Open(notificationConfigPath); err == nil {
|
||||
|
||||
@@ -189,7 +189,8 @@ const notificationConfigPath = "./data/notifications.yml"
|
||||
func (m *MultiHostService) StartNotificationManager(ctx context.Context) error {
|
||||
clients := m.manager.LocalClientServices()
|
||||
listener := notification.NewContainerLogListener(ctx, clients)
|
||||
m.notificationManager = notification.NewManager(listener)
|
||||
statsListener := notification.NewContainerStatsListener(ctx, clients)
|
||||
m.notificationManager = notification.NewManager(listener, statsListener)
|
||||
|
||||
// Start first so matcher is available for LoadConfig
|
||||
if err := m.notificationManager.Start(); err != nil {
|
||||
@@ -250,6 +251,8 @@ func (m *MultiHostService) broadcastNotificationConfig() {
|
||||
DispatcherID: sub.DispatcherID,
|
||||
LogExpression: sub.LogExpression,
|
||||
ContainerExpression: sub.ContainerExpression,
|
||||
MetricExpression: sub.MetricExpression,
|
||||
Cooldown: sub.Cooldown,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,11 +260,14 @@ func (m *MultiHostService) broadcastNotificationConfig() {
|
||||
dispatchers := make([]types.DispatcherConfig, len(notifDispatchers))
|
||||
for i, d := range notifDispatchers {
|
||||
dispatchers[i] = types.DispatcherConfig{
|
||||
ID: d.ID,
|
||||
Name: d.Name,
|
||||
Type: d.Type,
|
||||
URL: d.URL,
|
||||
Template: d.Template,
|
||||
ID: d.ID,
|
||||
Name: d.Name,
|
||||
Type: d.Type,
|
||||
URL: d.URL,
|
||||
Template: d.Template,
|
||||
APIKey: d.APIKey,
|
||||
Prefix: d.Prefix,
|
||||
ExpiresAt: d.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ type NotificationRuleResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ContainerExpression string `json:"containerExpression"`
|
||||
LogExpression string `json:"logExpression"`
|
||||
MetricExpression string `json:"metricExpression,omitempty"`
|
||||
Cooldown int `json:"cooldown,omitempty"`
|
||||
TriggerCount int64 `json:"triggerCount"`
|
||||
TriggeredContainers int `json:"triggeredContainers"`
|
||||
LastTriggeredAt *time.Time `json:"lastTriggeredAt"`
|
||||
@@ -49,6 +51,8 @@ type NotificationRuleInput struct {
|
||||
DispatcherID int `json:"dispatcherId"`
|
||||
LogExpression string `json:"logExpression"`
|
||||
ContainerExpression string `json:"containerExpression"`
|
||||
MetricExpression string `json:"metricExpression,omitempty"`
|
||||
Cooldown int `json:"cooldown,omitempty"`
|
||||
}
|
||||
|
||||
type NotificationRuleUpdateInput struct {
|
||||
@@ -57,6 +61,8 @@ type NotificationRuleUpdateInput struct {
|
||||
DispatcherID *int `json:"dispatcherId,omitempty"`
|
||||
LogExpression *string `json:"logExpression,omitempty"`
|
||||
ContainerExpression *string `json:"containerExpression,omitempty"`
|
||||
MetricExpression *string `json:"metricExpression,omitempty"`
|
||||
Cooldown *int `json:"cooldown,omitempty"`
|
||||
}
|
||||
|
||||
type DispatcherInput struct {
|
||||
@@ -69,11 +75,13 @@ type DispatcherInput struct {
|
||||
type PreviewInput struct {
|
||||
ContainerExpression string `json:"containerExpression"`
|
||||
LogExpression *string `json:"logExpression,omitempty"`
|
||||
MetricExpression *string `json:"metricExpression,omitempty"`
|
||||
}
|
||||
|
||||
type PreviewResult struct {
|
||||
ContainerError *string `json:"containerError,omitempty"`
|
||||
LogError *string `json:"logError,omitempty"`
|
||||
MetricError *string `json:"metricError,omitempty"`
|
||||
MatchedContainers []container.Container `json:"matchedContainers"`
|
||||
MatchedLogs []container.LogEvent `json:"matchedLogs"`
|
||||
TotalLogs int `json:"totalLogs"`
|
||||
@@ -113,6 +121,8 @@ func subscriptionToResponse(sub *notification.Subscription, dispatchers []notifi
|
||||
Dispatcher: disp,
|
||||
LogExpression: sub.LogExpression,
|
||||
ContainerExpression: sub.ContainerExpression,
|
||||
MetricExpression: sub.MetricExpression,
|
||||
Cooldown: sub.Cooldown,
|
||||
TriggerCount: sub.TriggerCount.Load(),
|
||||
LastTriggeredAt: lastTriggeredAt,
|
||||
TriggeredContainers: sub.TriggeredContainersCount(),
|
||||
@@ -198,6 +208,8 @@ func (h *handler) createNotificationRule(w http.ResponseWriter, r *http.Request)
|
||||
DispatcherID: input.DispatcherID,
|
||||
LogExpression: input.LogExpression,
|
||||
ContainerExpression: input.ContainerExpression,
|
||||
MetricExpression: input.MetricExpression,
|
||||
Cooldown: input.Cooldown,
|
||||
}
|
||||
|
||||
if err := h.hostService.AddSubscription(sub); err != nil {
|
||||
@@ -228,6 +240,8 @@ func (h *handler) replaceNotificationRule(w http.ResponseWriter, r *http.Request
|
||||
DispatcherID: input.DispatcherID,
|
||||
LogExpression: input.LogExpression,
|
||||
ContainerExpression: input.ContainerExpression,
|
||||
MetricExpression: input.MetricExpression,
|
||||
Cooldown: input.Cooldown,
|
||||
}
|
||||
|
||||
if err := h.hostService.ReplaceSubscription(sub); err != nil {
|
||||
@@ -267,6 +281,12 @@ func (h *handler) updateNotificationRule(w http.ResponseWriter, r *http.Request)
|
||||
if input.ContainerExpression != nil {
|
||||
updates["containerExpression"] = *input.ContainerExpression
|
||||
}
|
||||
if input.MetricExpression != nil {
|
||||
updates["metricExpression"] = *input.MetricExpression
|
||||
}
|
||||
if input.Cooldown != nil {
|
||||
updates["cooldown"] = *input.Cooldown
|
||||
}
|
||||
|
||||
if err := h.hostService.UpdateSubscription(id, updates); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
@@ -438,6 +458,9 @@ func (h *handler) previewExpression(w http.ResponseWriter, r *http.Request) {
|
||||
if input.LogExpression != nil {
|
||||
sub.LogExpression = *input.LogExpression
|
||||
}
|
||||
if input.MetricExpression != nil {
|
||||
sub.MetricExpression = *input.MetricExpression
|
||||
}
|
||||
|
||||
// Compile container expression
|
||||
if sub.ContainerExpression != "" {
|
||||
@@ -461,6 +484,15 @@ 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{}))
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
result.MetricError = &errStr
|
||||
}
|
||||
}
|
||||
|
||||
// Find matching running containers
|
||||
if sub.ContainerProgram != nil {
|
||||
containers, _ := h.hostService.ListAllContainers(container.ContainerLabels{})
|
||||
@@ -567,6 +599,7 @@ func (h *handler) testWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
mockNotification := types.Notification{
|
||||
ID: "test-notification",
|
||||
Detail: "This is a test log message from Dozzle",
|
||||
Timestamp: time.Now(),
|
||||
Container: types.NotificationContainer{
|
||||
ID: "abc123",
|
||||
@@ -578,7 +611,7 @@ func (h *handler) testWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
HostName: "localhost",
|
||||
Labels: map[string]string{"env": "test"},
|
||||
},
|
||||
Log: types.NotificationLog{
|
||||
Log: &types.NotificationLog{
|
||||
ID: 1,
|
||||
Message: "This is a test log message from Dozzle",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
@@ -586,6 +619,7 @@ func (h *handler) testWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
Stream: "stdout",
|
||||
Type: "simple",
|
||||
},
|
||||
Stat: &types.NotificationStat{},
|
||||
}
|
||||
|
||||
result := webhook.SendTest(r.Context(), mockNotification)
|
||||
|
||||
@@ -177,6 +177,8 @@ notifications:
|
||||
paused: Pauset
|
||||
containers: Containere
|
||||
log-filter: Log filter
|
||||
metric-filter: Metrik
|
||||
cooldown: Afkøling
|
||||
destination: Destination
|
||||
dispatcher-deleted: Dispatcher slettet
|
||||
containers-count: "{count} containere"
|
||||
@@ -206,6 +208,14 @@ notifications:
|
||||
cancel: Annuller
|
||||
save: Gem
|
||||
create: Opret Alarm
|
||||
metric-filter: Metrikudtryk
|
||||
expression-valid: Udtryk er gyldigt
|
||||
metric-fields-hint: "Tilgængelige felter: {fields}"
|
||||
cooldown-label: Afkøling
|
||||
cooldown-hint: "{duration} mellem alarmer pr. container"
|
||||
alert-type: Alarmtype
|
||||
log-alert: Logalarm
|
||||
metric-alert: Metrikalarm
|
||||
destination-form:
|
||||
create-title: Tilføj Destination
|
||||
edit-title: Rediger Destination
|
||||
|
||||
@@ -177,6 +177,8 @@ notifications:
|
||||
paused: Pausiert
|
||||
containers: Container
|
||||
log-filter: Log-Filter
|
||||
metric-filter: Metrik
|
||||
cooldown: Abklingzeit
|
||||
destination: Ziel
|
||||
dispatcher-deleted: Dispatcher gelöscht
|
||||
containers-count: "{count} Container"
|
||||
@@ -206,6 +208,14 @@ notifications:
|
||||
cancel: Abbrechen
|
||||
save: Speichern
|
||||
create: Alarm erstellen
|
||||
metric-filter: Metrik-Ausdruck
|
||||
expression-valid: Ausdruck ist gültig
|
||||
metric-fields-hint: "Verfügbare Felder: {fields}"
|
||||
cooldown-label: Abklingzeit
|
||||
cooldown-hint: "{duration} zwischen Alarmen pro Container"
|
||||
alert-type: Alarmtyp
|
||||
log-alert: Log-Alarm
|
||||
metric-alert: Metrik-Alarm
|
||||
destination-form:
|
||||
create-title: Ziel hinzufügen
|
||||
edit-title: Ziel bearbeiten
|
||||
|
||||
@@ -186,6 +186,8 @@ notifications:
|
||||
paused: Paused
|
||||
containers: Containers
|
||||
log-filter: Log filter
|
||||
metric-filter: Metric
|
||||
cooldown: Cooldown
|
||||
destination: Destination
|
||||
dispatcher-deleted: Dispatcher deleted
|
||||
containers-count: "{count} containers"
|
||||
@@ -215,6 +217,14 @@ notifications:
|
||||
cancel: Cancel
|
||||
save: Save
|
||||
create: Create Alert
|
||||
metric-filter: Metric Expression
|
||||
expression-valid: Expression is valid
|
||||
metric-fields-hint: "Available fields: {fields}"
|
||||
cooldown-label: Cooldown
|
||||
cooldown-hint: "{duration} between alerts per container"
|
||||
alert-type: Alert Type
|
||||
log-alert: Log Alert
|
||||
metric-alert: Metric Alert
|
||||
destination-form:
|
||||
create-title: Add Destination
|
||||
edit-title: Edit Destination
|
||||
|
||||
@@ -177,6 +177,8 @@ notifications:
|
||||
paused: Pausado
|
||||
containers: Contenedores
|
||||
log-filter: Filtro de registro
|
||||
metric-filter: Métrica
|
||||
cooldown: Tiempo de espera
|
||||
destination: Destino
|
||||
dispatcher-deleted: Dispatcher eliminado
|
||||
containers-count: "{count} contenedores"
|
||||
@@ -206,6 +208,14 @@ notifications:
|
||||
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}"
|
||||
cooldown-label: Tiempo de espera
|
||||
cooldown-hint: "{duration} entre alertas por contenedor"
|
||||
alert-type: Tipo de alerta
|
||||
log-alert: Alerta de registro
|
||||
metric-alert: Alerta de métrica
|
||||
destination-form:
|
||||
create-title: Añadir Destino
|
||||
edit-title: Editar Destino
|
||||
|
||||
@@ -177,6 +177,8 @@ notifications:
|
||||
paused: En pause
|
||||
containers: Conteneurs
|
||||
log-filter: Filtre de journal
|
||||
metric-filter: Métrique
|
||||
cooldown: Temps d'attente
|
||||
destination: Destination
|
||||
dispatcher-deleted: Dispatcher supprimé
|
||||
containers-count: "{count} conteneurs"
|
||||
@@ -206,6 +208,14 @@ notifications:
|
||||
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}"
|
||||
cooldown-label: Temps d'attente
|
||||
cooldown-hint: "{duration} entre les alertes par conteneur"
|
||||
alert-type: Type d'alerte
|
||||
log-alert: Alerte de journal
|
||||
metric-alert: Alerte de métrique
|
||||
destination-form:
|
||||
create-title: Ajouter une Destination
|
||||
edit-title: Modifier la Destination
|
||||
|
||||
@@ -189,6 +189,8 @@ notifications:
|
||||
paused: Dijeda
|
||||
containers: Kontainer
|
||||
log-filter: Filter log
|
||||
metric-filter: Metrik
|
||||
cooldown: Jeda
|
||||
destination: Tujuan
|
||||
dispatcher-deleted: Dispatcher dihapus
|
||||
containers-count: "{count} kontainer"
|
||||
@@ -218,6 +220,14 @@ notifications:
|
||||
cancel: Batal
|
||||
save: Simpan
|
||||
create: Buat Peringatan
|
||||
metric-filter: Ekspresi Metrik
|
||||
expression-valid: Ekspresi valid
|
||||
metric-fields-hint: "Field tersedia: {fields}"
|
||||
cooldown-label: Jeda
|
||||
cooldown-hint: "{duration} antara peringatan per kontainer"
|
||||
alert-type: Jenis Peringatan
|
||||
log-alert: Peringatan Log
|
||||
metric-alert: Peringatan Metrik
|
||||
destination-form:
|
||||
create-title: Tambah Tujuan
|
||||
edit-title: Edit Tujuan
|
||||
|
||||
@@ -177,6 +177,8 @@ notifications:
|
||||
paused: In pausa
|
||||
containers: Container
|
||||
log-filter: Filtro log
|
||||
metric-filter: Metrica
|
||||
cooldown: Tempo di attesa
|
||||
destination: Destinazione
|
||||
dispatcher-deleted: Dispatcher eliminato
|
||||
containers-count: "{count} container"
|
||||
@@ -206,6 +208,14 @@ notifications:
|
||||
cancel: Annulla
|
||||
save: Salva
|
||||
create: Crea Avviso
|
||||
metric-filter: Espressione Metrica
|
||||
expression-valid: L'espressione è valida
|
||||
metric-fields-hint: "Campi disponibili: {fields}"
|
||||
cooldown-label: Tempo di attesa
|
||||
cooldown-hint: "{duration} tra gli avvisi per container"
|
||||
alert-type: Tipo di avviso
|
||||
log-alert: Avviso log
|
||||
metric-alert: Avviso metrica
|
||||
destination-form:
|
||||
create-title: Aggiungi Destinazione
|
||||
edit-title: Modifica Destinazione
|
||||
|
||||
@@ -180,6 +180,8 @@ notifications:
|
||||
paused: 일시정지됨
|
||||
containers: 컨테이너
|
||||
log-filter: 로그 필터
|
||||
metric-filter: 메트릭
|
||||
cooldown: 대기 시간
|
||||
destination: 목적지
|
||||
dispatcher-deleted: 디스패처 삭제됨
|
||||
containers-count: "컨테이너 {count}개"
|
||||
@@ -209,6 +211,14 @@ notifications:
|
||||
cancel: 취소
|
||||
save: 저장
|
||||
create: 알림 만들기
|
||||
metric-filter: 메트릭 표현식
|
||||
expression-valid: 표현식이 유효합니다
|
||||
metric-fields-hint: "사용 가능한 필드: {fields}"
|
||||
cooldown-label: 대기 시간
|
||||
cooldown-hint: "컨테이너당 알림 간격 {duration}"
|
||||
alert-type: 알림 유형
|
||||
log-alert: 로그 알림
|
||||
metric-alert: 메트릭 알림
|
||||
destination-form:
|
||||
create-title: 목적지 추가
|
||||
edit-title: 목적지 편집
|
||||
|
||||
@@ -178,6 +178,8 @@ notifications:
|
||||
paused: Gepauzeerd
|
||||
containers: Containers
|
||||
log-filter: Logfilter
|
||||
metric-filter: Metriek
|
||||
cooldown: Wachttijd
|
||||
destination: Bestemming
|
||||
dispatcher-deleted: Dispatcher verwijderd
|
||||
containers-count: "{count} containers"
|
||||
@@ -207,6 +209,14 @@ notifications:
|
||||
cancel: Annuleren
|
||||
save: Opslaan
|
||||
create: Waarschuwing aanmaken
|
||||
metric-filter: Metriekexpressie
|
||||
expression-valid: Expressie is geldig
|
||||
metric-fields-hint: "Beschikbare velden: {fields}"
|
||||
cooldown-label: Wachttijd
|
||||
cooldown-hint: "{duration} tussen waarschuwingen per container"
|
||||
alert-type: Waarschuwingstype
|
||||
log-alert: Logwaarschuwing
|
||||
metric-alert: Metrikwaarschuwing
|
||||
destination-form:
|
||||
create-title: Bestemming toevoegen
|
||||
edit-title: Bestemming bewerken
|
||||
|
||||
@@ -184,6 +184,8 @@ notifications:
|
||||
paused: Wstrzymany
|
||||
containers: Kontenery
|
||||
log-filter: Filtr logów
|
||||
metric-filter: Metryka
|
||||
cooldown: Czas oczekiwania
|
||||
destination: Miejsce docelowe
|
||||
dispatcher-deleted: Dispatcher usunięty
|
||||
containers-count: "{count} kontenerów"
|
||||
@@ -213,6 +215,14 @@ notifications:
|
||||
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}"
|
||||
cooldown-label: Czas oczekiwania
|
||||
cooldown-hint: "{duration} między alertami na kontener"
|
||||
alert-type: Typ alertu
|
||||
log-alert: Alert logów
|
||||
metric-alert: Alert metryki
|
||||
destination-form:
|
||||
create-title: Dodaj Miejsce Docelowe
|
||||
edit-title: Edytuj Miejsce Docelowe
|
||||
|
||||
@@ -186,6 +186,8 @@ notifications:
|
||||
paused: Pausado
|
||||
containers: Contentores
|
||||
log-filter: Filtro de registo
|
||||
metric-filter: Métrica
|
||||
cooldown: Tempo de espera
|
||||
destination: Destino
|
||||
dispatcher-deleted: Dispatcher eliminado
|
||||
containers-count: "{count} contentores"
|
||||
@@ -215,6 +217,14 @@ notifications:
|
||||
cancel: Cancelar
|
||||
save: Guardar
|
||||
create: Criar Alerta
|
||||
metric-filter: Expressão de Métrica
|
||||
expression-valid: A expressão é válida
|
||||
metric-fields-hint: "Campos disponíveis: {fields}"
|
||||
cooldown-label: Tempo de espera
|
||||
cooldown-hint: "{duration} entre alertas por contentor"
|
||||
alert-type: Tipo de alerta
|
||||
log-alert: Alerta de log
|
||||
metric-alert: Alerta de métrica
|
||||
destination-form:
|
||||
create-title: Adicionar Destino
|
||||
edit-title: Editar Destino
|
||||
|
||||
@@ -176,6 +176,8 @@ notifications:
|
||||
paused: Pausado
|
||||
containers: Containers
|
||||
log-filter: Filtro de log
|
||||
metric-filter: Métrica
|
||||
cooldown: Tempo de espera
|
||||
destination: Destino
|
||||
dispatcher-deleted: Dispatcher excluído
|
||||
containers-count: "{count} containers"
|
||||
@@ -205,6 +207,14 @@ notifications:
|
||||
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}"
|
||||
cooldown-label: Tempo de espera
|
||||
cooldown-hint: "{duration} entre alertas por container"
|
||||
alert-type: Tipo de alerta
|
||||
log-alert: Alerta de log
|
||||
metric-alert: Alerta de métrica
|
||||
destination-form:
|
||||
create-title: Adicionar Destino
|
||||
edit-title: Editar Destino
|
||||
|
||||
@@ -177,6 +177,8 @@ notifications:
|
||||
paused: Приостановлено
|
||||
containers: Контейнеры
|
||||
log-filter: Фильтр логов
|
||||
metric-filter: Метрика
|
||||
cooldown: Период ожидания
|
||||
destination: Назначение
|
||||
dispatcher-deleted: Диспетчер удалён
|
||||
containers-count: "{count} контейнеров"
|
||||
@@ -206,6 +208,14 @@ notifications:
|
||||
cancel: Отмена
|
||||
save: Сохранить
|
||||
create: Создать Оповещение
|
||||
metric-filter: Выражение метрики
|
||||
expression-valid: Выражение корректно
|
||||
metric-fields-hint: "Доступные поля: {fields}"
|
||||
cooldown-label: Период ожидания
|
||||
cooldown-hint: "{duration} между оповещениями на контейнер"
|
||||
alert-type: Тип оповещения
|
||||
log-alert: Оповещение журнала
|
||||
metric-alert: Оповещение метрики
|
||||
destination-form:
|
||||
create-title: Добавить Назначение
|
||||
edit-title: Редактировать Назначение
|
||||
|
||||
@@ -182,6 +182,8 @@ notifications:
|
||||
paused: Ustavljeno
|
||||
containers: Zabojniki
|
||||
log-filter: Filter dnevnika
|
||||
metric-filter: Metrika
|
||||
cooldown: Čas mirovanja
|
||||
destination: Cilj
|
||||
dispatcher-deleted: Pošiljatelj izbrisan
|
||||
containers-count: "{count} zabojnikov"
|
||||
@@ -211,6 +213,14 @@ notifications:
|
||||
cancel: Prekliči
|
||||
save: Shrani
|
||||
create: Ustvari Opozorilo
|
||||
metric-filter: Izraz metrike
|
||||
expression-valid: Izraz je veljaven
|
||||
metric-fields-hint: "Razpoložljiva polja: {fields}"
|
||||
cooldown-label: Čas mirovanja
|
||||
cooldown-hint: "{duration} med opozorili na zabojnik"
|
||||
alert-type: Vrsta opozorila
|
||||
log-alert: Opozorilo dnevnika
|
||||
metric-alert: Opozorilo metrike
|
||||
destination-form:
|
||||
create-title: Dodaj Cilj
|
||||
edit-title: Uredi Cilj
|
||||
|
||||
@@ -177,6 +177,8 @@ notifications:
|
||||
paused: Duraklatıldı
|
||||
containers: Konteynerler
|
||||
log-filter: Günlük filtresi
|
||||
metric-filter: Metrik
|
||||
cooldown: Bekleme süresi
|
||||
destination: Hedef
|
||||
dispatcher-deleted: Dağıtıcı silindi
|
||||
containers-count: "{count} konteyner"
|
||||
@@ -206,6 +208,14 @@ notifications:
|
||||
cancel: İptal
|
||||
save: Kaydet
|
||||
create: Uyarı Oluştur
|
||||
metric-filter: Metrik İfadesi
|
||||
expression-valid: İfade geçerli
|
||||
metric-fields-hint: "Kullanılabilir alanlar: {fields}"
|
||||
cooldown-label: Bekleme süresi
|
||||
cooldown-hint: "Konteyner başına uyarılar arası {duration}"
|
||||
alert-type: Uyarı Türü
|
||||
log-alert: Günlük Uyarısı
|
||||
metric-alert: Metrik Uyarısı
|
||||
destination-form:
|
||||
create-title: Hedef Ekle
|
||||
edit-title: Hedefi Düzenle
|
||||
|
||||
@@ -180,6 +180,8 @@ notifications:
|
||||
paused: 已暫停
|
||||
containers: 容器
|
||||
log-filter: 日誌篩選器
|
||||
metric-filter: 指標
|
||||
cooldown: 冷卻時間
|
||||
destination: 目標
|
||||
dispatcher-deleted: 發送器已刪除
|
||||
containers-count: "{count} 個容器"
|
||||
@@ -209,6 +211,14 @@ notifications:
|
||||
cancel: 取消
|
||||
save: 儲存
|
||||
create: 建立警報
|
||||
metric-filter: 指標表達式
|
||||
expression-valid: 表達式有效
|
||||
metric-fields-hint: "可用欄位:{fields}"
|
||||
cooldown-label: 冷卻時間
|
||||
cooldown-hint: "每個容器警報間隔 {duration}"
|
||||
alert-type: 警報類型
|
||||
log-alert: 日誌警報
|
||||
metric-alert: 指標警報
|
||||
destination-form:
|
||||
create-title: 新增目標
|
||||
edit-title: 編輯目標
|
||||
|
||||
@@ -177,6 +177,8 @@ notifications:
|
||||
paused: 已暂停
|
||||
containers: 容器
|
||||
log-filter: 日志筛选器
|
||||
metric-filter: 指标
|
||||
cooldown: 冷却时间
|
||||
destination: 目标
|
||||
dispatcher-deleted: 发送器已删除
|
||||
containers-count: "{count} 个容器"
|
||||
@@ -206,6 +208,14 @@ notifications:
|
||||
cancel: 取消
|
||||
save: 保存
|
||||
create: 创建警报
|
||||
metric-filter: 指标表达式
|
||||
expression-valid: 表达式有效
|
||||
metric-fields-hint: "可用字段:{fields}"
|
||||
cooldown-label: 冷却时间
|
||||
cooldown-hint: "每个容器警报间隔 {duration}"
|
||||
alert-type: 警报类型
|
||||
log-alert: 日志警报
|
||||
metric-alert: 指标警报
|
||||
destination-form:
|
||||
create-title: 添加目标
|
||||
edit-title: 编辑目标
|
||||
|
||||
+13
-2
@@ -5,8 +5,10 @@ import "time"
|
||||
// Notification represents a notification event that can be filtered and sent
|
||||
type Notification struct {
|
||||
ID string `json:"id"`
|
||||
Detail string `json:"detail"`
|
||||
Container NotificationContainer `json:"container"`
|
||||
Log NotificationLog `json:"log"`
|
||||
Log *NotificationLog `json:"log"`
|
||||
Stat *NotificationStat `json:"stat"`
|
||||
Subscription SubscriptionConfig `json:"subscription"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
@@ -33,14 +35,23 @@ type NotificationLog struct {
|
||||
Type string `json:"type" expr:"type"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// SubscriptionConfig represents a notification subscription configuration
|
||||
type SubscriptionConfig struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"-"`
|
||||
DispatcherID int `json:"-"`
|
||||
LogExpression string `json:"logExpression"`
|
||||
LogExpression string `json:"logExpression,omitempty"`
|
||||
ContainerExpression string `json:"containerExpression"`
|
||||
MetricExpression string `json:"metricExpression,omitempty"`
|
||||
Cooldown int `json:"cooldown,omitempty"`
|
||||
}
|
||||
|
||||
// DispatcherConfig represents a notification dispatcher configuration
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ export default defineConfig(() => ({
|
||||
"@vueuse/core",
|
||||
],
|
||||
dts: "assets/auto-imports.d.ts",
|
||||
dirs: ["assets/composable", "assets/stores", "assets/utils"],
|
||||
dirs: ["assets/composable", "assets/stores", "assets/utils/index.ts"],
|
||||
vueTemplate: true,
|
||||
}),
|
||||
VueI18nPlugin({
|
||||
|
||||
Reference in New Issue
Block a user