mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 18:31:58 +00:00
Compare commits
185 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 922d6de14e | |||
| e271551a5d | |||
| f60fbc254a | |||
| fd943272f0 | |||
| 2380c87bb0 | |||
| 166037617f | |||
| aa428b6c2c | |||
| 9c9d3613d6 | |||
| 6bb3c4256a | |||
| 21302b4173 | |||
| dc38dde2d8 | |||
| 2975a9ceda | |||
| 6e0c028439 | |||
| e7c83b6a2f | |||
| 4f7695d494 | |||
| fc1e86fc14 | |||
| 6e3e82e806 | |||
| 8957fe3a6f | |||
| abdcbe835e | |||
| 0db989b193 | |||
| 177d721a49 | |||
| e1f4ed2919 | |||
| 824bfc9f0d | |||
| 111076fccf | |||
| 4d8213a66f | |||
| 106dfe18c7 | |||
| 72af56f42d | |||
| f00f3bdde5 | |||
| 72c9b66286 | |||
| e0109a7f4f | |||
| 790c8724ee | |||
| 3167f0a9a2 | |||
| 4251076211 | |||
| e1797ad5c0 | |||
| 68d7720ae6 | |||
| fce15c8ec9 | |||
| 330e952ca4 | |||
| 1cd1b4a0af | |||
| 6f1390f412 | |||
| 72ea5eed2c | |||
| 8121eb1aad | |||
| 45b7a6243e | |||
| 91ad1cb8d3 | |||
| 302ba4b5a0 | |||
| ebc64d8641 | |||
| dc8d87c7da | |||
| ada81017de | |||
| 4c6dfa7b72 | |||
| 32ea1bedfd | |||
| 1a29adc90f | |||
| 3bc5745147 | |||
| 7e92cfee96 | |||
| b68fb45123 | |||
| 3c88036c46 | |||
| 63ed910d81 | |||
| 93d9c88424 | |||
| 0991df5554 | |||
| 57c54ed6dd | |||
| e410804727 | |||
| c7e5615264 | |||
| 66126a1d79 | |||
| 7371b86779 | |||
| f2cb9ae672 | |||
| d01b161808 | |||
| c8b736a2d5 | |||
| d3e237a562 | |||
| bdfeb0a880 | |||
| 89fdbd55bb | |||
| 228030b257 | |||
| 27d2fdefd6 | |||
| 5d38eb7127 | |||
| e66dda89e1 | |||
| b0da9637be | |||
| a3fc0ea992 | |||
| 54469dc163 | |||
| 5b412456cd | |||
| 475cd916f2 | |||
| 9982e6e2a6 | |||
| 798981b334 | |||
| 76e94a7ad4 | |||
| 4351493914 | |||
| a074aa9511 | |||
| 78114eb2d2 | |||
| 5ff6d100d4 | |||
| 94cfa6da1c | |||
| ffa38bcb2f | |||
| 8ab06d9a24 | |||
| c3a4583ba7 | |||
| bf16c1042d | |||
| 1174c1d58e | |||
| f3c4e806ae | |||
| 374865589a | |||
| ec7d15c6bc | |||
| 9e128d8de8 | |||
| 94ea7b39e9 | |||
| bcc8adc81b | |||
| a79ffdaf50 | |||
| 51a92ba448 | |||
| 8057bffd33 | |||
| f30e8e748f | |||
| 7622db0c32 | |||
| d6109e39c3 | |||
| 6e38c193ef | |||
| a300d84b31 | |||
| ed950481ad | |||
| 7df0243533 | |||
| 00132ef1cf | |||
| fed03cb4db | |||
| 6840e5b55c | |||
| e9c9eca4ae | |||
| 161b8d91d7 | |||
| 585771b804 | |||
| 78362b35fd | |||
| fa8f6e4aeb | |||
| c605bbc1bb | |||
| f1d4e682db | |||
| fb65dc9b7d | |||
| 2324e511b2 | |||
| cc82f9f993 | |||
| e48aa3139c | |||
| aef834ba06 | |||
| 3678332ab2 | |||
| 30614269c7 | |||
| 6b2af1cf3a | |||
| 80220f2388 | |||
| 98ab88aa71 | |||
| c5af5a6f5e | |||
| b8f224c13d | |||
| 40f335265f | |||
| 9ac5fcc2c4 | |||
| 8d38034c86 | |||
| 8c231d7ba7 | |||
| 0a1d854168 | |||
| 425d9e4fdd | |||
| 21676d9917 | |||
| c923980ae6 | |||
| 87ea90966e | |||
| cd15f91447 | |||
| 8800bc8c37 | |||
| ecdcd5fb26 | |||
| ff5867eaca | |||
| e5422513f7 | |||
| cd164e62c5 | |||
| 03d6c6a41a | |||
| 7131a6bdf8 | |||
| 4c523d5185 | |||
| 69b4d8b651 | |||
| dc41e018ca | |||
| ca3cd93e9a | |||
| 9224a4d1f9 | |||
| c826564e3f | |||
| 8b4f4d1b00 | |||
| 463fea4634 | |||
| 4db74360e6 | |||
| 58caca2d68 | |||
| 98a4901256 | |||
| 4a6461ce2a | |||
| 620e59aa24 | |||
| c13aca6d2c | |||
| f3afbf843c | |||
| 10e4dfd17f | |||
| efcadfb98a | |||
| f831dc6ecd | |||
| 0ff0d486b1 | |||
| a7405ac2d6 | |||
| 3f4baea664 | |||
| 0d71261358 | |||
| 1411c70c9b | |||
| 6c2c58345c | |||
| af0407157b | |||
| 9f6d24523c | |||
| abeb86d285 | |||
| 4954a020f9 | |||
| 5b96267f1f | |||
| e2109257e4 | |||
| 9ac4762f1d | |||
| 321bf43f1c | |||
| bca061fa7b | |||
| 8599b602dc | |||
| d60a119f9e | |||
| 4b3e44fcdc | |||
| 51fce48137 | |||
| c1432c33a2 | |||
| 22d659f509 | |||
| 9c529af633 |
@@ -5,7 +5,7 @@ tmp_dir = "tmp"
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/dozzle"
|
||||
cmd = "GOEXPERIMENT=jsonv2 go build -race -o ./tmp/dozzle ."
|
||||
cmd = "go build -race -o ./tmp/dozzle ."
|
||||
delay = 1000
|
||||
exclude_dir = [
|
||||
"assets",
|
||||
|
||||
@@ -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": [
|
||||
"Bash(gh pr view:*)",
|
||||
"Bash(gh api:*)",
|
||||
"Bash(gh issue view:*)",
|
||||
"Bash(gh pr list:*)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"Bash(make test:*)",
|
||||
"Bash(go test:*)",
|
||||
"Bash(go build:*)",
|
||||
"Bash(pnpm test:*)",
|
||||
"Bash(pnpm build:*)",
|
||||
"Bash(pnpm typecheck:*)",
|
||||
"Bash(pnpm list:*)",
|
||||
"Bash(node --version:*)",
|
||||
"Bash(git rebase:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(xargs ls:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,3 @@ dist
|
||||
.git
|
||||
e2e
|
||||
docs
|
||||
internal/agent/pb/
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: "\u2601\uFE0F Dozzle Cloud Bug Report"
|
||||
labels: ["bug", "cloud"]
|
||||
description: |
|
||||
Use this template for issues related to Dozzle Cloud.
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Check for existing issues
|
||||
description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` on it.
|
||||
options:
|
||||
- label: Completed
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Dozzle version
|
||||
description: The version of Dozzle connected to Dozzle Cloud.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the bug / provide steps to reproduce it
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Screenshots or logs
|
||||
description: Drag screenshots or paste logs into the text input below.
|
||||
validations:
|
||||
required: false
|
||||
@@ -3,25 +3,25 @@ on:
|
||||
tags:
|
||||
- "v*"
|
||||
name: Test and Release
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
npm-test:
|
||||
name: JavaScript Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: 24.14.1
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- run: corepack enable
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
- name: Run Tests
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 1.25.5
|
||||
go-version: 1.26.1
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
@@ -51,29 +51,25 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: 24.14.1
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- run: corepack enable
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Writing certs to file
|
||||
run: |
|
||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||
- name: Build
|
||||
uses: docker/bake-action@v6
|
||||
uses: docker/bake-action@v7
|
||||
with:
|
||||
source: .
|
||||
load: true
|
||||
@@ -82,7 +78,7 @@ jobs:
|
||||
*.cache-to=type=gha,mode=max
|
||||
- name: Run Playwright tests
|
||||
run: docker compose up --exit-code-from playwright
|
||||
- uses: actions/upload-artifact@v6
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
@@ -92,16 +88,19 @@ jobs:
|
||||
needs: [go-test, npm-test, int-test]
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3.6.0
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3.6.0
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -110,7 +109,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
amir20/dozzle
|
||||
@@ -125,7 +124,7 @@ jobs:
|
||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6.18.0
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
@@ -139,12 +138,14 @@ jobs:
|
||||
needs: [buildx]
|
||||
name: Github Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -6,6 +6,11 @@ on:
|
||||
branches:
|
||||
- master
|
||||
name: Push container
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
buildx:
|
||||
name: Push branches and PRs
|
||||
@@ -13,14 +18,14 @@ jobs:
|
||||
if: ${{ !github.event.repository.fork && !github.event.pull_request.head.repo.fork && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'amir20/dozzle') }}
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3.6.0
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3.6.0
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -29,7 +34,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
amir20/dozzle
|
||||
@@ -39,11 +44,11 @@ jobs:
|
||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6.18.0
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
build-args: TAG=${{ steps.meta.outputs.version }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: 24.14.1
|
||||
cache: pnpm # or pnpm / yarn
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
+18
-14
@@ -6,23 +6,27 @@ on:
|
||||
branches:
|
||||
- master
|
||||
name: Test
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: 24.14.1
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: 24.14.1
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -34,13 +38,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: 24.14.1
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -54,7 +58,7 @@ jobs:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.5"
|
||||
go-version: "1.26.1"
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
@@ -77,12 +81,12 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.5"
|
||||
go-version: "1.26.1"
|
||||
check-latest: true
|
||||
- name: Generate dependencies
|
||||
run: make fake_assets shared_key.pem shared_cert.pem
|
||||
- name: Stactic checker
|
||||
uses: dominikh/staticcheck-action@v1.4.0
|
||||
uses: dominikh/staticcheck-action@v1.4.1
|
||||
with:
|
||||
install-go: false
|
||||
int-test:
|
||||
@@ -91,27 +95,27 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: 24.14.1
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.12.0
|
||||
node-version: 24.14.1
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Generate certs
|
||||
run: make shared_key.pem shared_cert.pem
|
||||
- name: Build
|
||||
uses: docker/bake-action@v6
|
||||
uses: docker/bake-action@v7
|
||||
with:
|
||||
source: .
|
||||
load: true
|
||||
@@ -120,7 +124,7 @@ jobs:
|
||||
*.cache-to=type=gha,mode=max
|
||||
- name: Run Playwright tests
|
||||
run: docker compose up --exit-code-from playwright
|
||||
- uses: actions/upload-artifact@v6
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
|
||||
+2
-1
@@ -15,4 +15,5 @@ coverage.out
|
||||
*.pem
|
||||
*.csr
|
||||
tmp
|
||||
.claude
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -4,3 +4,4 @@ typed-router.d.ts
|
||||
docs/.vitepress/cache
|
||||
docs/.vitepress/dist
|
||||
dist
|
||||
assets/types/graphql.ts
|
||||
|
||||
@@ -31,9 +31,6 @@ The application supports multiple deployment modes: standalone server, Docker Sw
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Install Go tools (protobuf, air hot-reloader)
|
||||
make tools
|
||||
|
||||
# Generate certificates and protobuf files
|
||||
make generate
|
||||
```
|
||||
@@ -130,8 +127,18 @@ The Go backend is organized into these key packages:
|
||||
- Role-based authorization (`roles.go`)
|
||||
|
||||
- **`internal/container/`** - Container domain models and interfaces
|
||||
- `event_generator.go`: Log parsing and grouping logic (multi-line, JSON detection)
|
||||
|
||||
- **`main.go`** - Application entry point with mode switching (server/swarm/k8s)
|
||||
- **`internal/notification/`** - Alert and notification system
|
||||
- `manager.go`: Notification rule evaluation and dispatching
|
||||
- `log_listener.go`: Log pattern matching for alerts
|
||||
- `dispatcher/`: Notification channel implementations (email, webhook, etc.)
|
||||
|
||||
- **`graph/`** - GraphQL API layer
|
||||
- `schema.graphqls`: GraphQL schema definitions
|
||||
- `*.resolvers.go`: GraphQL resolver implementations
|
||||
|
||||
- **`main.go`** - Application entry point with mode switching (server/swarm/k8s/agent)
|
||||
|
||||
### Frontend (Vue 3)
|
||||
|
||||
@@ -161,17 +168,23 @@ The frontend uses file-based routing with these conventions:
|
||||
- `ContainerTable.vue`: Container table with historical stat visualization
|
||||
|
||||
- **`assets/stores/`** - Pinia stores (auto-imported)
|
||||
- `config.ts`: App configuration and feature flags
|
||||
- `container.ts`: Container state management
|
||||
- `config.ts`: App configuration and feature flags (injected from backend HTML, frozen immutable)
|
||||
- `container.ts`: Container state management with EventSource streaming (`/api/events/stream`)
|
||||
- `hosts.ts`: Multi-host state
|
||||
- `settings.ts`: User preferences
|
||||
- `settings.ts`: User preferences (localStorage-backed via profileStorage)
|
||||
- `pinned.ts`: Pinned container logs for side-by-side viewing
|
||||
- `swarm.ts`, `k8s.ts`: Deployment mode-specific state
|
||||
- `announcements.ts`: Feature announcements
|
||||
|
||||
- **`assets/composable/`** - Vue composables (auto-imported)
|
||||
- `eventStreams.ts`: SSE connection management
|
||||
- `eventStreams.ts`: SSE connection management with buffer-based flushing (250ms debounce)
|
||||
- `historicalLogs.ts`: Historical log fetching
|
||||
- `logContext.ts`: Log filtering and search context
|
||||
- `storage.ts`: LocalStorage abstractions
|
||||
- `logContext.ts`: Log filtering and search context (provide/inject pattern)
|
||||
- `scrollContext.ts`: Scroll state management (paused, progress, currentDate)
|
||||
- `storage.ts`: LocalStorage abstractions with reactivity
|
||||
- `visible.ts`: Log filtering by visible keys for complex logs
|
||||
- `containerActions.ts`: Container control operations
|
||||
- `duckdb.ts`: DuckDB WASM for SQL queries on logs
|
||||
|
||||
- **`assets/modules/`** - Vue plugins
|
||||
- `router.ts`: Vue Router configuration
|
||||
@@ -185,6 +198,7 @@ The frontend uses file-based routing with these conventions:
|
||||
3. **Stats**: Real-time CPU/memory stats streamed via SSE alongside events
|
||||
4. **Actions**: POST to `/api/hosts/{host}/containers/{id}/actions/{action}` (start/stop/restart)
|
||||
5. **Terminal**: WebSocket connections for container attach/exec at `/api/hosts/{host}/containers/{id}/attach`
|
||||
6. **GraphQL**: POST to `/api/graphql` for queries and mutations (container metadata, historical logs, notifications)
|
||||
|
||||
### Build System
|
||||
|
||||
@@ -206,10 +220,15 @@ The frontend uses file-based routing with these conventions:
|
||||
- `ComplexLogEntry`: Structured JSON logs (`JSONObject`)
|
||||
- `GroupedLogEntry`: Multi-line grouped logs (`string[]`)
|
||||
- **Type consistency**: Use `LogMessage` type alias instead of `string | string[] | JSONObject` for log entry messages
|
||||
- **Log Entry Factory Pattern**: Use `LogEntry.create(logEvent)` to instantiate the correct entry type based on `logEvent.t` field
|
||||
- **EventSource Buffering**: Log streams use buffer-based flushing (250ms debounce, 1000ms max) to batch UI updates
|
||||
- **Charts/Visualizations**: Custom lightweight implementations (no D3.js)
|
||||
- `BarChart.vue`: Self-contained bar chart with responsive downsampling
|
||||
- Downsampling algorithm: Averages data into buckets based on available screen width
|
||||
- All stat history tracked in `Container.statsHistory` (max 300 items via rolling window)
|
||||
- `chartData` is always a rolling window of max 300 items — array length stays constant
|
||||
- Uses `ref` (not `computed`) for `downsampledBars` to enable in-place mutation of the last bar, avoiding full re-renders
|
||||
- Component instance is reused when switching containers; parent must call exposed `recalculate()` to force refresh
|
||||
|
||||
### Backend
|
||||
|
||||
@@ -217,6 +236,13 @@ The frontend uses file-based routing with these conventions:
|
||||
- Certificate generation is required (`make generate` creates shared_key.pem and shared_cert.pem)
|
||||
- Protocol buffer generation happens via `go generate` directive in `main.go`
|
||||
- Docker client uses API version negotiation for compatibility
|
||||
- **GraphQL API**: Uses gqlgen with schema in `graph/schema.graphqls`, generated code in `graph/generated.go`
|
||||
- Run `pnpm codegen` to regenerate GraphQL types
|
||||
- Resolvers follow-schema layout in `graph/*.resolvers.go`
|
||||
- **Service Layer Architecture**:
|
||||
- `ClientService` interface abstracts Docker/K8s/Agent backends
|
||||
- `MultiHostService` orchestrates multi-host operations
|
||||
- `ClientManager` implementations: `RetriableClientManager` (server mode), `SwarmClientManager` (swarm mode)
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -247,7 +273,168 @@ The frontend uses file-based routing with these conventions:
|
||||
|
||||
### Deployment Modes
|
||||
|
||||
- **Server mode**: Single or multi-host Docker monitoring
|
||||
- **Server mode** (default): Single or multi-host Docker monitoring
|
||||
- Uses `RetriableClientManager` with local + remote agent clients
|
||||
- **Swarm mode**: Automatic discovery of Swarm nodes via Docker API
|
||||
- Creates gRPC agent server on each node (port 7007)
|
||||
- Uses `SwarmClientManager` for node discovery
|
||||
- **K8s mode**: Pod log monitoring in Kubernetes cluster
|
||||
- Implements `container.Client` interface via Kubernetes API
|
||||
- **Agent mode**: Lightweight gRPC agent for remote log collection
|
||||
- Run with `dozzle agent` or `pnpm run agent:dev`
|
||||
- Listens on port 7007 with TLS certificate authentication
|
||||
|
||||
## Key Architectural Patterns
|
||||
|
||||
### Backend Abstraction Layers
|
||||
|
||||
The backend follows a clean layered architecture:
|
||||
|
||||
```
|
||||
HTTP Handlers (internal/web)
|
||||
↓
|
||||
HostService Interface (MultiHostService)
|
||||
↓
|
||||
ClientService Interface (per host)
|
||||
↓
|
||||
container.Client Interface
|
||||
↓
|
||||
Implementation (DockerClient, K8sClient, AgentClient)
|
||||
```
|
||||
|
||||
**When adding new container operations:**
|
||||
|
||||
1. Define method in `container.Client` interface (`internal/container/client.go`)
|
||||
2. Implement in `internal/docker/client.go` (and `internal/k8s/client.go` if applicable)
|
||||
3. Add wrapper method in `ClientService` interface (`internal/support/container/service.go`)
|
||||
4. Add HTTP handler in `internal/web/` with appropriate route
|
||||
|
||||
### Frontend Data Flow
|
||||
|
||||
**Real-time Log Viewing:**
|
||||
|
||||
1. User navigates to `/container/{id}` route
|
||||
2. Page component calls `useContainerStream(container)` composable
|
||||
3. Composable creates EventSource connection to `/api/hosts/{host}/containers/{id}/logs/stream`
|
||||
4. Backend streams `LogEvent` objects via SSE
|
||||
5. Frontend buffers events (250ms debounce, max 1000ms)
|
||||
6. Batched buffer flushes update reactive `messages` array
|
||||
7. `LogViewer.vue` renders using appropriate component (`SimpleLogItem`, `ComplexLogItem`, `GroupedLogItem`)
|
||||
8. When messages exceed `maxLogs` (400), oldest entries replaced or marked as `SkippedLogsEntry`
|
||||
|
||||
**Stats Streaming:**
|
||||
|
||||
1. `container.ts` store connects to `/api/events/stream` on app init
|
||||
2. Backend multiplexes container events and stats into single SSE stream
|
||||
3. `container-stat` events update `Container._stat` and append to `_statsHistory`
|
||||
4. EMA calculation provides smoothed `movingAverageStat` (alpha=0.2)
|
||||
5. `ContainerTable.vue` displays mini bar charts using `statsHistory` with downsampling
|
||||
|
||||
### Protocol Buffer Flow (Agent Mode)
|
||||
|
||||
1. Main server creates `agent.NewClient(endpoint, certs)` for each remote host
|
||||
2. AgentClient implements `container.Client` interface
|
||||
3. Method calls translate to gRPC requests defined in `protos/rpc.proto`
|
||||
4. Remote agent receives gRPC call, delegates to local `DockerClient`
|
||||
5. Streaming RPCs (logs, stats, events) use bidirectional channels
|
||||
6. Responses converted back to domain models via `FromProto()` methods
|
||||
|
||||
### Log Parsing Pipeline
|
||||
|
||||
1. Docker API returns multiplexed stream (8-byte headers + payload)
|
||||
2. `log_reader.go` parses headers, extracts stdout/stderr type
|
||||
3. `event_generator.go` receives raw log lines
|
||||
4. Detection logic identifies:
|
||||
- JSON structure → `ComplexLogEntry`
|
||||
- Multi-line patterns (stack traces) → `GroupedLogEntry`
|
||||
- Single lines → `SimpleLogEntry`
|
||||
5. Log level extraction via regex patterns
|
||||
6. `LogEvent` serialized to JSON and sent via SSE
|
||||
7. Frontend deserializes and renders with appropriate component
|
||||
|
||||
## Adding New Features
|
||||
|
||||
### Adding a New HTTP Route
|
||||
|
||||
1. Define route in `internal/web/routes.go` using chi router:
|
||||
```go
|
||||
r.Get("/api/custom-endpoint", h.customHandler)
|
||||
```
|
||||
2. Implement handler method in appropriate file (e.g., `actions.go`, `logs.go`)
|
||||
3. Use `hostService` to find container/host via `FindContainer()` or `FindHost()`
|
||||
4. Return JSON response or establish SSE/WebSocket stream
|
||||
|
||||
### Adding a New Log View Type
|
||||
|
||||
1. Create route file in `assets/pages/` (e.g., `custom/[id].vue`)
|
||||
2. Create composable in `assets/composable/eventStreams.ts` (e.g., `useCustomStream()`)
|
||||
3. Composable should:
|
||||
- Build API URL with appropriate filters
|
||||
- Create EventSource connection
|
||||
- Handle buffering and message batching
|
||||
- Return reactive `messages` array and control methods
|
||||
4. Use `LogViewer.vue` component to render messages
|
||||
5. Add backend API endpoint if needed (see above)
|
||||
|
||||
### Adding a New GraphQL Query/Mutation
|
||||
|
||||
1. Define in `graph/schema.graphqls`
|
||||
2. Run `pnpm codegen` to regenerate types
|
||||
3. Implement resolver in `graph/schema.resolvers.go`
|
||||
4. Use `hostService` from resolver context to access backend services
|
||||
5. Frontend calls via urql client (auto-imported via `@urql/vue`)
|
||||
|
||||
### Adding Container Stats/Metrics
|
||||
|
||||
1. Add field to `Stat` type in `internal/container/types.go`
|
||||
2. Update `stats_collector.go` to extract metric from Docker API response
|
||||
3. Add calculation logic in `docker/calculation.go` if needed
|
||||
4. Ensure protobuf definition includes field in `protos/rpc.proto`
|
||||
5. Frontend automatically receives updates via existing SSE stream
|
||||
6. Update `Container` model in `assets/models/Container.ts` if UI needs access
|
||||
|
||||
### Working with Notifications/Alerts
|
||||
|
||||
**Backend** (`internal/notification/`):
|
||||
|
||||
- `manager.go`: Rule evaluation engine, manages alert state
|
||||
- `log_listener.go`: Subscribes to container log streams, evaluates rules against incoming logs
|
||||
- `types.go`: Alert rule definitions (log pattern matching, thresholds)
|
||||
- `dispatcher/`: Notification channel implementations
|
||||
|
||||
**Frontend** (`assets/pages/notifications.vue`, `assets/components/Notification/`):
|
||||
|
||||
- `AlertForm.vue`, `DestinationForm.vue`: UI for creating rules
|
||||
- Rules stored via GraphQL mutations
|
||||
- Alert state displayed in notification cards
|
||||
|
||||
**Adding a new notification channel:**
|
||||
|
||||
1. Implement dispatcher interface in `internal/notification/dispatcher/`
|
||||
2. Register in `manager.go` dispatcher factory
|
||||
3. Add UI form in `assets/components/Notification/DestinationForm.vue`
|
||||
4. Add GraphQL schema fields if needed
|
||||
|
||||
## Common Development Patterns
|
||||
|
||||
### Testing
|
||||
|
||||
- Always run Go tests with race detector: `go test -race`
|
||||
- Frontend tests require `TZ=UTC` for timestamp consistency
|
||||
- Integration tests use Playwright with `make int` (runs docker-compose setup)
|
||||
- Use `testify/assert` for Go test assertions
|
||||
|
||||
### Hot Reload Development
|
||||
|
||||
- `make dev` runs both backend (air) and frontend (vite) with hot reload
|
||||
- `DEV=true` disables embedded asset serving
|
||||
- `LIVE_FS=true` serves assets from filesystem instead of embedded
|
||||
- Backend changes trigger air restart automatically
|
||||
- Frontend changes trigger vite HMR
|
||||
|
||||
### Debugging
|
||||
|
||||
- Backend logs: Set `--level debug` flag or `DOZZLE_LEVEL=debug` env var
|
||||
- Frontend: Vue DevTools browser extension
|
||||
- GraphQL: Use GraphQL Playground at `/api/graphql` (when enabled)
|
||||
- SSE streams: Browser DevTools Network tab shows EventSource connections
|
||||
|
||||
+8
-14
@@ -1,7 +1,7 @@
|
||||
# Build assets
|
||||
FROM --platform=$BUILDPLATFORM node:24.12.0-alpine AS node
|
||||
FROM --platform=$BUILDPLATFORM node:25.8.2-alpine AS node
|
||||
|
||||
RUN corepack enable
|
||||
RUN npm install -g --force corepack && corepack enable
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -14,7 +14,7 @@ COPY package.json ./
|
||||
RUN pnpm install --offline --ignore-scripts --no-optional
|
||||
|
||||
# Copy assets and translations to build
|
||||
COPY .* *.config.ts *.config.js *.config.cjs ./
|
||||
COPY vite.config.ts tsconfig.json .prettierrc.cjs .npmrc ./
|
||||
COPY assets ./assets
|
||||
COPY locales ./locales
|
||||
COPY public ./public
|
||||
@@ -22,19 +22,15 @@ COPY public ./public
|
||||
# Build assets
|
||||
RUN pnpm build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
|
||||
|
||||
# install gRPC dependencies
|
||||
RUN apk add --no-cache ca-certificates protoc protobuf-dev\
|
||||
&& mkdir /dozzle \
|
||||
&& go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \
|
||||
&& go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
|
||||
RUN apk add --no-cache ca-certificates && mkdir /dozzle
|
||||
|
||||
WORKDIR /dozzle
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.* ./
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
|
||||
# Copy all other files
|
||||
COPY internal ./internal
|
||||
@@ -50,11 +46,9 @@ COPY --from=node /build/dist ./dist
|
||||
ARG TAG=dev
|
||||
ARG TARGETOS TARGETARCH
|
||||
|
||||
# Generate protos
|
||||
RUN go generate
|
||||
|
||||
# Build binary
|
||||
RUN GOEXPERIMENT=jsonv2 GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
|
||||
GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle
|
||||
|
||||
RUN mkdir /data
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
PROTO_DIR := protos
|
||||
GEN_DIR := internal/agent/pb
|
||||
PROTO_FILES := $(wildcard $(PROTO_DIR)/*.proto)
|
||||
GEN_FILES := $(patsubst $(PROTO_DIR)/%.proto,$(GEN_DIR)/%.pb.go,$(PROTO_FILES))
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@@ -29,10 +26,12 @@ build: dist generate
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=local"
|
||||
|
||||
.PHONY: docker
|
||||
docker: shared_key.pem shared_cert.pem
|
||||
@docker build --build-arg TAG=local -t amir20/dozzle .
|
||||
docker: generate
|
||||
@docker build --build-arg TAG=local -t amir20/dozzle:local .
|
||||
|
||||
generate: shared_key.pem shared_cert.pem $(GEN_FILES)
|
||||
.PHONY: generate
|
||||
generate: shared_key.pem shared_cert.pem
|
||||
@go generate ./...
|
||||
|
||||
.PHONY: dev
|
||||
dev: generate fake_assets
|
||||
@@ -50,21 +49,26 @@ shared_cert.pem: shared_key.pem
|
||||
@openssl x509 -req -in shared_request.csr -signkey shared_key.pem -out shared_cert.pem -days 1825
|
||||
@rm shared_request.csr
|
||||
|
||||
$(GEN_DIR)/%.pb.go: $(PROTO_DIR)/%.proto
|
||||
@go generate
|
||||
|
||||
.PHONY: push
|
||||
push: docker
|
||||
@docker tag amir20/dozzle:latest amir20/dozzle:local-test
|
||||
@docker tag amir20/dozzle:local amir20/dozzle:local-test
|
||||
@docker push amir20/dozzle:local-test
|
||||
|
||||
tools:
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
|
||||
go install github.com/air-verse/air@latest
|
||||
|
||||
.PHONY: run
|
||||
run: docker
|
||||
docker run -it --rm -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock amir20/dozzle:latest
|
||||
docker run -it --rm -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock amir20/dozzle:local
|
||||
|
||||
.PHONY: preview
|
||||
preview: build
|
||||
pnpm preview
|
||||
|
||||
.PHONY: agent-reload
|
||||
agent-reload: docker
|
||||
@VM_NAME=$${VM_NAME:-dozzle-agent}; \
|
||||
echo "📦 Loading image into VM $$VM_NAME..."; \
|
||||
docker save amir20/dozzle:local | orb exec -m $$VM_NAME docker load; \
|
||||
echo "🔄 Recreating agent..."; \
|
||||
orb exec -m $$VM_NAME docker stop dozzle-agent || true; \
|
||||
orb exec -m $$VM_NAME docker rm dozzle-agent || true; \
|
||||
orb exec -m $$VM_NAME docker run -d --name dozzle-agent -p 7007:7007 -v /var/run/docker.sock:/var/run/docker.sock -v ~/dozzle-certs:/certs -v ~/dozzle-data:/data -e DOZZLE_LEVEL=debug amir20/dozzle:local agent --cert /certs/shared_cert.pem --key /certs/shared_key.pem; \
|
||||
echo "✅ Agent reloaded"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# Dozzle - [dozzle.dev](https://dozzle.dev/)
|
||||
|
||||
Dozzle is a small lightweight application with a web based interface to monitor Docker logs. It doesn’t store any log files. It is for live monitoring of your container logs only.
|
||||
Dozzle is a lightweight, web-based application for monitoring Docker logs in real time. It doesn't store any log files—it's designed purely for live log viewing.
|
||||
|
||||
https://github.com/user-attachments/assets/66a7b4b2-d6c9-4fca-ab04-aef6cd7c0c31
|
||||
|
||||
@@ -14,22 +14,22 @@ https://github.com/user-attachments/assets/66a7b4b2-d6c9-4fca-ab04-aef6cd7c0c31
|
||||

|
||||
|
||||
> [!NOTE]
|
||||
> If you like Dozzle, check out [`dtop`](https://github.com/amir20/dtop) which is a top like application for monitoring Docker containers. It integrates with Dozzle to allow for linking directly to container logs.
|
||||
> If you like Dozzle, check out [`dtop`](https://github.com/amir20/dtop), a top-like application for monitoring Docker containers. It integrates with Dozzle to link directly to container logs.
|
||||
|
||||
## Features
|
||||
|
||||
- Intelligent fuzzy search for container names 🤖
|
||||
- Search logs using regex 🔦
|
||||
- Search logs using [SQL queries](https://dozzle.dev/guide/sql-engine) 📊
|
||||
- Small memory footprint 🏎
|
||||
- Intelligent fuzzy search for container names
|
||||
- Search logs using regex
|
||||
- Search logs using [SQL queries](https://dozzle.dev/guide/sql-engine)
|
||||
- Small memory footprint
|
||||
- Split screen for viewing multiple logs
|
||||
- Live stats with memory and CPU usage
|
||||
- Multi-user [authentication](https://dozzle.dev/guide/authentication) with support for proxy forward authorization 🚨
|
||||
- [Swarm](https://dozzle.dev/guide/swarm-mode) mode support 🐳
|
||||
- [Agent](https://dozzle.dev/guide/agent) mode for monitoring multiple Docker hosts 🕵️♂️
|
||||
- Dark mode 🌙
|
||||
- Multi-user [authentication](https://dozzle.dev/guide/authentication) with support for forward proxy authorization
|
||||
- [Swarm mode](https://dozzle.dev/guide/swarm-mode) support
|
||||
- [Agent mode](https://dozzle.dev/guide/agent) for monitoring multiple Docker hosts
|
||||
- Dark mode
|
||||
|
||||
Dozzle has been tested with hundreds of containers. However, it doesn't support offline searching. Products like [Loggly](https://www.loggly.com), [Papertrail](https://papertrailapp.com) or [Kibana](https://www.elastic.co/products/kibana) are more suited for full search capabilities.
|
||||
Dozzle has been tested with hundreds of containers. However, it doesn't support offline searching. Products like [Loggly](https://www.loggly.com), [Papertrail](https://papertrailapp.com), or [Kibana](https://www.elastic.co/products/kibana) are better suited for full search capabilities.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -39,13 +39,13 @@ Dozzle is a small container (7 MB compressed). Pull the latest release with:
|
||||
|
||||
### Running Dozzle
|
||||
|
||||
The simplest way to use dozzle is to run the docker container. Also, mount the Docker Unix socket with `--volume` to `/var/run/docker.sock`:
|
||||
The simplest way to use Dozzle is to run the Docker container. Mount the Docker Unix socket with `--volume` to `/var/run/docker.sock`:
|
||||
|
||||
$ docker run --name dozzle -d --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle:latest
|
||||
|
||||
Dozzle will be available at [http://localhost:8080/](http://localhost:8080/).
|
||||
|
||||
Here is the Docker Compose file:
|
||||
Here is a Docker Compose example:
|
||||
|
||||
services:
|
||||
dozzle:
|
||||
@@ -56,11 +56,11 @@ Here is the Docker Compose file:
|
||||
ports:
|
||||
- 8080:8080
|
||||
|
||||
For advanced options like [authentication](https://dozzle.dev/guide/authentication), [remote hosts](https://dozzle.dev/guide/remote-hosts) or common [questions](https://dozzle.dev/guide/faq) see documentation at [dozzle.dev](https://dozzle.dev/guide/getting-started).
|
||||
For advanced options like [authentication](https://dozzle.dev/guide/authentication), [remote hosts](https://dozzle.dev/guide/remote-hosts), or common [questions](https://dozzle.dev/guide/faq), see the documentation at [dozzle.dev](https://dozzle.dev/guide/getting-started).
|
||||
|
||||
## Swarm Mode
|
||||
|
||||
Dozzle works with Docker Swarm mode. You can run Dozzle as a global service with:
|
||||
Dozzle works with Docker Swarm. You can run Dozzle as a global service:
|
||||
|
||||
$ docker service create --name dozzle --env DOZZLE_MODE=swarm --mode global --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock -p 8080:8080 amir20/dozzle:latest
|
||||
|
||||
@@ -68,7 +68,7 @@ See the [Swarm Mode](https://dozzle.dev/guide/swarm-mode) documentation for more
|
||||
|
||||
## Agent Mode
|
||||
|
||||
Dozzle can be used to monitor multiple Docker hosts. You can run Dozzle in agent mode with:
|
||||
Dozzle can monitor multiple Docker hosts. Run Dozzle in agent mode with:
|
||||
|
||||
$ docker run -v /var/run/docker.sock:/var/run/docker.sock -p 7007:7007 amir20/dozzle:latest agent
|
||||
|
||||
@@ -76,19 +76,19 @@ See the [Agent Mode](https://dozzle.dev/guide/agent) documentation for more deta
|
||||
|
||||
## Technical Details
|
||||
|
||||
Dozzle users automatic API negotiation which works with most Docker configurations. Dozzle also works with [Colima](https://github.com/abiosoft/colima) and [Podman](https://podman.io/).
|
||||
Dozzle uses automatic API negotiation, which works with most Docker configurations. Dozzle also works with [Colima](https://github.com/abiosoft/colima) and [Podman](https://podman.io/).
|
||||
|
||||
### Installation on podman
|
||||
### Installation on Podman
|
||||
|
||||
By default Podman doesn't have a background process but you can enable this for Dozzle to work.
|
||||
By default, Podman doesn't have a background process, but you can enable the remote socket for Dozzle to work.
|
||||
|
||||
Verify first if your podman installation has enabled remote socket:
|
||||
First, verify if your Podman installation has the remote socket enabled:
|
||||
|
||||
```
|
||||
podman info
|
||||
```
|
||||
|
||||
When you get under the key remote socket output like this, its already enabled:
|
||||
If you see output like this under the remote socket key, it's already enabled:
|
||||
|
||||
```
|
||||
remoteSocket:
|
||||
@@ -96,40 +96,40 @@ When you get under the key remote socket output like this, its already enabled:
|
||||
path: /run/user/1000/podman/podman.sock
|
||||
```
|
||||
|
||||
If it's not enabled please follow [this tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/socket_activation.md) to enable it.
|
||||
If it's not enabled, follow [this tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/socket_activation.md) to enable it.
|
||||
|
||||
Once you have the podman remote socket you can run Dozzle on podman.
|
||||
Once the Podman remote socket is enabled, you can run Dozzle:
|
||||
|
||||
```
|
||||
podman run --volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock -d -p 8080:8080 docker.io/amir20/dozzle:latest
|
||||
```
|
||||
|
||||
Additionally you have to create a fake engine-id to prevent `host not found` errors. Podman doesn't generate an engine-id like Docker by itself due to its daemonless architecture.
|
||||
Additionally, you need to create a fake engine-id to prevent `host not found` errors. Podman doesn't generate an engine-id like Docker does, due to its daemonless architecture.
|
||||
|
||||
Under `/var/lib/docker` create a file named `engine-id`. On a system with Podman you will have to create the folder path as well. Inside the file place the UUID, for instance using `uuidgen > engine-id`. After that the file should have an identifier that looks like this: `b9f1d7fc-b459-4b6e-9f7a-e3d1cd2e14a9`.
|
||||
Create a file named `engine-id` under `/var/lib/docker`. On a system with Podman, you'll need to create the folder path as well. Place a UUID inside the file, for example using `uuidgen > engine-id`. The file should contain an identifier like: `b9f1d7fc-b459-4b6e-9f7a-e3d1cd2e14a9`.
|
||||
|
||||
For more details check [Podman Infos](docs/guide/podman.md) or the [FAQ](docs/guide/faq.md#i-am-seeing-host-not-found-error-in-the-logs-how-do-i-fix-it)
|
||||
For more details, see [Podman Info](docs/guide/podman.md) or the [FAQ](docs/guide/faq.md#i-am-seeing-host-not-found-error-in-the-logs-how-do-i-fix-it).
|
||||
|
||||
## Security
|
||||
|
||||
Dozzle supports file based authentication and forward proxy like [Authelia](https://www.authelia.com/). These are documented at https://dozzle.dev/guide/authentication.
|
||||
Dozzle supports file-based authentication and forward proxy authentication with tools like [Authelia](https://www.authelia.com/). See the documentation at https://dozzle.dev/guide/authentication.
|
||||
|
||||
## Analytics collected
|
||||
## Analytics
|
||||
|
||||
Dozzle collects anonymous user configurations using Google Analytics. Why? Dozzle is an open source project with no funding. As a result, there is no time to do user studies of Dozzle. Analytics is collected to prioritize features and fixes based on how people use Dozzle. This data is completely public and can be viewed live using [ Data Studio dashboard](https://datastudio.google.com/s/naeIu0MiWsY).
|
||||
Dozzle collects anonymous user configurations using Google Analytics. Why? Dozzle is an open source project with no funding, so there's no time for formal user studies. Analytics help prioritize features and fixes based on how people use Dozzle. This data is completely public and can be viewed live on the [Data Studio dashboard](https://datastudio.google.com/s/naeIu0MiWsY).
|
||||
|
||||
If you do not want to be tracked at all, see the `--no-analytics` flag below.
|
||||
To disable analytics, use the `--no-analytics` flag.
|
||||
|
||||
## Environment variables and configuration
|
||||
## Environment Variables and Configuration
|
||||
|
||||
Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can use the CLI flags or environment variables. See documentation at [https://dozzle.dev/guide/supported-env-vars](https://dozzle.dev/guide/supported-env-vars) for more details.
|
||||
Dozzle follows the [12-factor](https://12factor.net/) model. Configuration can be done via CLI flags or environment variables. See the documentation at [dozzle.dev/guide/supported-env-vars](https://dozzle.dev/guide/supported-env-vars) for more details.
|
||||
|
||||
## Support
|
||||
|
||||
There are many ways you can support Dozzle:
|
||||
There are many ways to support Dozzle:
|
||||
|
||||
- Use it! Write about it! Star it! If you love Dozzle, drop me a line and tell me what you love.
|
||||
- Blog about Dozzle to spread the word. If you are good at writing send PRs to improve the documentation at [dozzle.dev](https://dozzle.dev/)
|
||||
- Blog about Dozzle to spread the word. If you're good at writing, send PRs to improve the documentation at [dozzle.dev](https://dozzle.dev/).
|
||||
- Sponsor my work at https://www.buymeacoffee.com/amirraminfar
|
||||
|
||||
<a href="https://www.buymeacoffee.com/amirraminfar" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
|
||||
@@ -142,8 +142,9 @@ There are many ways you can support Dozzle:
|
||||
|
||||
To build and test locally:
|
||||
|
||||
1. Install [NodeJs](https://nodejs.org/en/download/) and [pnpm](https://pnpm.io/installation).
|
||||
1. Install [Node.js](https://nodejs.org/en/download/) and [pnpm](https://pnpm.io/installation).
|
||||
2. Install [Go](https://go.dev/doc/install).
|
||||
3. Install tools with `make tools`.
|
||||
4. Install node modules `pnpm install`.
|
||||
5. Run `make dev` to start a development server with hot reload.
|
||||
3. Install [protoc](https://grpc.io/docs/protoc-installation/).
|
||||
4. Install Go tools with `go install tool`.
|
||||
5. Install Node modules with `pnpm install`.
|
||||
6. Run `make dev` to start a development server with hot reload.
|
||||
|
||||
Vendored
+35
-6
@@ -27,15 +27,21 @@ declare global {
|
||||
const controlledComputed: typeof import('@vueuse/core').controlledComputed
|
||||
const controlledRef: typeof import('@vueuse/core').controlledRef
|
||||
const createApp: typeof import('vue').createApp
|
||||
const createContainerHints: typeof import('./composable/exprEditor').createContainerHints
|
||||
const createDrawer: typeof import('./composable/drawer').createDrawer
|
||||
const createEventHints: typeof import('./composable/exprEditor').createEventHints
|
||||
const createEventHook: typeof import('@vueuse/core').createEventHook
|
||||
const createExprEditor: typeof import('./composable/exprEditor').createExprEditor
|
||||
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
|
||||
const createReusableTemplate: typeof import('@vueuse/core').createReusableTemplate
|
||||
const createSharedComposable: typeof import('@vueuse/core').createSharedComposable
|
||||
const createTemplateEditor: typeof import('./composable/templateEditor').createTemplateEditor
|
||||
const createTemplatePromise: typeof import('@vueuse/core').createTemplatePromise
|
||||
const createUnrefFn: typeof import('@vueuse/core').createUnrefFn
|
||||
const customRef: typeof import('vue').customRef
|
||||
@@ -52,6 +58,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
|
||||
@@ -78,7 +85,6 @@ declare global {
|
||||
const locale: typeof import('./stores/settings').locale
|
||||
const loggingContextKey: typeof import('./composable/logContext').loggingContextKey
|
||||
const makeDestructurable: typeof import('@vueuse/core').makeDestructurable
|
||||
const manualResetRef: typeof import('@vueuse/core')['manualResetRef']
|
||||
const mapActions: typeof import('pinia').mapActions
|
||||
const mapGetters: typeof import('pinia').mapGetters
|
||||
const mapState: typeof import('pinia').mapState
|
||||
@@ -131,7 +137,6 @@ declare global {
|
||||
const refWithControl: typeof import('@vueuse/core').refWithControl
|
||||
const resolveComponent: typeof import('vue').resolveComponent
|
||||
const resolveRef: typeof import('@vueuse/core').resolveRef
|
||||
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
|
||||
const scrollContextKey: typeof import('./composable/scrollContext').scrollContextKey
|
||||
const search: typeof import('./stores/settings').search
|
||||
const sessionHost: typeof import('./composable/storage').sessionHost
|
||||
@@ -171,6 +176,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
|
||||
@@ -206,6 +212,7 @@ declare global {
|
||||
const useCountdown: typeof import('@vueuse/core').useCountdown
|
||||
const useCounter: typeof import('@vueuse/core').useCounter
|
||||
const useCssModule: typeof import('vue').useCssModule
|
||||
const useCssSupports: typeof import('@vueuse/core').useCssSupports
|
||||
const useCssVar: typeof import('@vueuse/core').useCssVar
|
||||
const useCssVars: typeof import('vue').useCssVars
|
||||
const useCurrentElement: typeof import('@vueuse/core').useCurrentElement
|
||||
@@ -235,6 +242,7 @@ declare global {
|
||||
const useEventListener: typeof import('@vueuse/core').useEventListener
|
||||
const useEventSource: typeof import('@vueuse/core').useEventSource
|
||||
const useExponentialMovingAverage: typeof import('./utils/index').useExponentialMovingAverage
|
||||
const useExprEditorField: typeof import('./composable/useExprEditorField').useExprEditorField
|
||||
const useEyeDropper: typeof import('@vueuse/core').useEyeDropper
|
||||
const useFavicon: typeof import('@vueuse/core').useFavicon
|
||||
const useFetch: typeof import('@vueuse/core').useFetch
|
||||
@@ -262,6 +270,7 @@ declare global {
|
||||
const useK8sStore: typeof import('./stores/k8s').useK8sStore
|
||||
const useKeyModifier: typeof import('@vueuse/core').useKeyModifier
|
||||
const useLastChanged: typeof import('@vueuse/core').useLastChanged
|
||||
const useLink: typeof import('vue-router/auto').useLink
|
||||
const useLocalStorage: typeof import('@vueuse/core').useLocalStorage
|
||||
const useLoggingContext: typeof import('./composable/logContext').useLoggingContext
|
||||
const useMagicKeys: typeof import('@vueuse/core').useMagicKeys
|
||||
@@ -305,8 +314,8 @@ declare global {
|
||||
const useRafFn: typeof import('@vueuse/core').useRafFn
|
||||
const useRefHistory: typeof import('@vueuse/core').useRefHistory
|
||||
const useResizeObserver: typeof import('@vueuse/core').useResizeObserver
|
||||
const useRoute: typeof import('vue-router').useRoute
|
||||
const useRouter: typeof import('vue-router').useRouter
|
||||
const useRoute: typeof import('vue-router/auto').useRoute
|
||||
const useRouter: typeof import('vue-router/auto').useRouter
|
||||
const useSSRWidth: typeof import('@vueuse/core').useSSRWidth
|
||||
const useScreenOrientation: typeof import('@vueuse/core').useScreenOrientation
|
||||
const useScreenSafeArea: typeof import('@vueuse/core').useScreenSafeArea
|
||||
@@ -391,12 +400,21 @@ 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
|
||||
export type { LogStreamSource } from './composable/eventStreams'
|
||||
import('./composable/eventStreams')
|
||||
// @ts-ignore
|
||||
export type { ExprEditorOptions } from './composable/exprEditor'
|
||||
import('./composable/exprEditor')
|
||||
// @ts-ignore
|
||||
export type { TemplateEditorOptions } from './composable/templateEditor'
|
||||
import('./composable/templateEditor')
|
||||
// @ts-ignore
|
||||
export type { Config, Profile } from './stores/config'
|
||||
import('./stores/config')
|
||||
// @ts-ignore
|
||||
@@ -436,15 +454,21 @@ declare module 'vue' {
|
||||
readonly controlledComputed: UnwrapRef<typeof import('@vueuse/core')['controlledComputed']>
|
||||
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
|
||||
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
|
||||
readonly createContainerHints: UnwrapRef<typeof import('./composable/exprEditor')['createContainerHints']>
|
||||
readonly createDrawer: UnwrapRef<typeof import('./composable/drawer')['createDrawer']>
|
||||
readonly createEventHints: UnwrapRef<typeof import('./composable/exprEditor')['createEventHints']>
|
||||
readonly createEventHook: UnwrapRef<typeof import('@vueuse/core')['createEventHook']>
|
||||
readonly createExprEditor: UnwrapRef<typeof import('./composable/exprEditor')['createExprEditor']>
|
||||
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']>
|
||||
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
|
||||
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
|
||||
readonly createTemplateEditor: UnwrapRef<typeof import('./composable/templateEditor')['createTemplateEditor']>
|
||||
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
|
||||
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
|
||||
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
|
||||
@@ -461,6 +485,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']>
|
||||
@@ -578,6 +603,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']>
|
||||
@@ -613,6 +639,7 @@ declare module 'vue' {
|
||||
readonly useCountdown: UnwrapRef<typeof import('@vueuse/core')['useCountdown']>
|
||||
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
|
||||
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
||||
readonly useCssSupports: UnwrapRef<typeof import('@vueuse/core')['useCssSupports']>
|
||||
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
|
||||
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
|
||||
readonly useCurrentElement: UnwrapRef<typeof import('@vueuse/core')['useCurrentElement']>
|
||||
@@ -642,6 +669,7 @@ declare module 'vue' {
|
||||
readonly useEventListener: UnwrapRef<typeof import('@vueuse/core')['useEventListener']>
|
||||
readonly useEventSource: UnwrapRef<typeof import('@vueuse/core')['useEventSource']>
|
||||
readonly useExponentialMovingAverage: UnwrapRef<typeof import('./utils/index')['useExponentialMovingAverage']>
|
||||
readonly useExprEditorField: UnwrapRef<typeof import('./composable/useExprEditorField')['useExprEditorField']>
|
||||
readonly useEyeDropper: UnwrapRef<typeof import('@vueuse/core')['useEyeDropper']>
|
||||
readonly useFavicon: UnwrapRef<typeof import('@vueuse/core')['useFavicon']>
|
||||
readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>
|
||||
@@ -669,6 +697,7 @@ declare module 'vue' {
|
||||
readonly useK8sStore: UnwrapRef<typeof import('./stores/k8s')['useK8sStore']>
|
||||
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
|
||||
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
|
||||
readonly useLink: UnwrapRef<typeof import('vue-router/auto')['useLink']>
|
||||
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
|
||||
readonly useLoggingContext: UnwrapRef<typeof import('./composable/logContext')['useLoggingContext']>
|
||||
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
|
||||
@@ -712,8 +741,8 @@ declare module 'vue' {
|
||||
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
|
||||
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
|
||||
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
|
||||
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
|
||||
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
|
||||
readonly useRoute: UnwrapRef<typeof import('vue-router/auto')['useRoute']>
|
||||
readonly useRouter: UnwrapRef<typeof import('vue-router/auto')['useRouter']>
|
||||
readonly useSSRWidth: UnwrapRef<typeof import('@vueuse/core')['useSSRWidth']>
|
||||
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
|
||||
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
|
||||
|
||||
Vendored
+31
@@ -11,10 +11,14 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AlertCard: typeof import('./components/Notification/AlertCard.vue')['default']
|
||||
AlertForm: typeof import('./components/Notification/AlertForm.vue')['default']
|
||||
Announcements: typeof import('./components/Announcements.vue')['default']
|
||||
BarChart: typeof import('./components/BarChart.vue')['default']
|
||||
'Carbon:add': typeof import('~icons/carbon/add')['default']
|
||||
'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default']
|
||||
'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default']
|
||||
'Carbon:close': typeof import('~icons/carbon/close')['default']
|
||||
'Carbon:information': typeof import('~icons/carbon/information')['default']
|
||||
'Carbon:logoKubernetes': typeof import('~icons/carbon/logo-kubernetes')['default']
|
||||
'Carbon:macShift': typeof import('~icons/carbon/mac-shift')['default']
|
||||
@@ -30,6 +34,7 @@ declare module 'vue' {
|
||||
'Cil:circle': typeof import('~icons/cil/circle')['default']
|
||||
'Cil:columns': typeof import('~icons/cil/columns')['default']
|
||||
'Cil:xCircle': typeof import('~icons/cil/x-circle')['default']
|
||||
CloudDestinationForm: typeof import('./components/Notification/CloudDestinationForm.vue')['default']
|
||||
ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default']
|
||||
ContainerActionsToolbar: typeof import('./components/ContainerViewer/ContainerActionsToolbar.vue')['default']
|
||||
ContainerDropdown: typeof import('./components/ContainerDropdown.vue')['default']
|
||||
@@ -40,9 +45,13 @@ declare module 'vue' {
|
||||
ContainerStatCell: typeof import('./components/ContainerStatCell.vue')['default']
|
||||
ContainerTable: typeof import('./components/ContainerTable.vue')['default']
|
||||
ContainerTitle: typeof import('./components/ContainerViewer/ContainerTitle.vue')['default']
|
||||
CooldownField: typeof import('./components/Notification/CooldownField.vue')['default']
|
||||
DateTime: typeof import('./components/common/DateTime.vue')['default']
|
||||
DestinationCard: typeof import('./components/Notification/DestinationCard.vue')['default']
|
||||
DestinationForm: typeof import('./components/Notification/DestinationForm.vue')['default']
|
||||
Dropdown: typeof import('./components/common/Dropdown.vue')['default']
|
||||
DropdownMenu: typeof import('./components/common/DropdownMenu.vue')['default']
|
||||
EventAlertFields: typeof import('./components/Notification/EventAlertFields.vue')['default']
|
||||
EventSource: typeof import('./components/LogViewer/EventSource.vue')['default']
|
||||
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
|
||||
GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.vue')['default']
|
||||
@@ -63,6 +72,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']
|
||||
@@ -80,16 +90,27 @@ declare module 'vue' {
|
||||
'MaterialSymbols:terminal': typeof import('~icons/material-symbols/terminal')['default']
|
||||
'MaterialSymbolsLight:collapseAll': typeof import('~icons/material-symbols-light/collapse-all')['default']
|
||||
'Mdi:account': typeof import('~icons/mdi/account')['default']
|
||||
'Mdi:alert': typeof import('~icons/mdi/alert')['default']
|
||||
'Mdi:alertCircle': typeof import('~icons/mdi/alert-circle')['default']
|
||||
'Mdi:alertOutline': typeof import('~icons/mdi/alert-outline')['default']
|
||||
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
|
||||
'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:bellRingOutline': typeof import('~icons/mdi/bell-ring-outline')['default']
|
||||
'Mdi:chartBar': typeof import('~icons/mdi/chart-bar')['default']
|
||||
'Mdi:chartLine': typeof import('~icons/mdi/chart-line')['default']
|
||||
'Mdi:check': typeof import('~icons/mdi/check')['default']
|
||||
'Mdi:checkCircle': typeof import('~icons/mdi/check-circle')['default']
|
||||
'Mdi:chevronDoubleDown': typeof import('~icons/mdi/chevron-double-down')['default']
|
||||
'Mdi:chevronDown': typeof import('~icons/mdi/chevron-down')['default']
|
||||
'Mdi:chevronLeft': typeof import('~icons/mdi/chevron-left')['default']
|
||||
'Mdi:chevronRight': typeof import('~icons/mdi/chevron-right')['default']
|
||||
'Mdi:close': typeof import('~icons/mdi/close')['default']
|
||||
'Mdi:cloud': typeof import('~icons/mdi/cloud')['default']
|
||||
'Mdi:cloudOutline': typeof import('~icons/mdi/cloud-outline')['default']
|
||||
'Mdi:cog': typeof import('~icons/mdi/cog')['default']
|
||||
'Mdi:contentCopy': typeof import('~icons/mdi/content-copy')['default']
|
||||
'Mdi:docker': typeof import('~icons/mdi/docker')['default']
|
||||
'Mdi:gauge': typeof import('~icons/mdi/gauge')['default']
|
||||
'Mdi:github': typeof import('~icons/mdi/github')['default']
|
||||
@@ -98,8 +119,16 @@ declare module 'vue' {
|
||||
'Mdi:key': typeof import('~icons/mdi/key')['default']
|
||||
'Mdi:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default']
|
||||
'Mdi:lightningBolt': typeof import('~icons/mdi/lightning-bolt')['default']
|
||||
'Mdi:linkVariant': typeof import('~icons/mdi/link-variant')['default']
|
||||
'Mdi:magnify': typeof import('~icons/mdi/magnify')['default']
|
||||
'Mdi:pencilOutline': typeof import('~icons/mdi/pencil-outline')['default']
|
||||
'Mdi:plus': typeof import('~icons/mdi/plus')['default']
|
||||
'Mdi:poll': typeof import('~icons/mdi/poll')['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']
|
||||
@@ -143,6 +172,7 @@ declare module 'vue' {
|
||||
SQLTable: typeof import('./components/LogViewer/SQLTable.vue')['default']
|
||||
StackLog: typeof import('./components/StackViewer/StackLog.vue')['default']
|
||||
StatMonitor: typeof import('./components/LogViewer/StatMonitor.vue')['default']
|
||||
'SvgSpinners:ringResize': typeof import('~icons/svg-spinners/ring-resize')['default']
|
||||
SwarmMenu: typeof import('./components/SwarmMenu.vue')['default']
|
||||
Tag: typeof import('./components/common/Tag.vue')['default']
|
||||
Terminal: typeof import('./components/Terminal.vue')['default']
|
||||
@@ -150,6 +180,7 @@ declare module 'vue' {
|
||||
ToastModal: typeof import('./components/common/ToastModal.vue')['default']
|
||||
Toggle: typeof import('./components/common/Toggle.vue')['default']
|
||||
ViewerWithSource: typeof import('./components/LogViewer/ViewerWithSource.vue')['default']
|
||||
WebhookDestinationForm: typeof import('./components/Notification/WebhookDestinationForm.vue')['default']
|
||||
ZigZag: typeof import('./components/LogViewer/ZigZag.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div ref="chartContainer" class="flex items-end gap-[2px]" @mousemove="onContainerHover">
|
||||
<div
|
||||
v-for="(dataPoint, i) in downsampledData"
|
||||
v-for="(bar, i) in downsampledBars"
|
||||
:key="i"
|
||||
class="bar min-h-px flex-1 rounded-t-sm"
|
||||
:class="barClass"
|
||||
:style="{ '--height': `${Math.min(dataPoint, 100)}%` }"
|
||||
:style="{ '--height': `${maxValue > 0 ? (bar.percent / maxValue) * 100 : 0}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,12 +19,17 @@
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface BarDataPoint {
|
||||
percent: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const { chartData, barClass = "" } = defineProps<{
|
||||
chartData: number[];
|
||||
chartData: BarDataPoint[];
|
||||
barClass?: string;
|
||||
}>();
|
||||
|
||||
const hoverIndex = defineEmit<[startIndex: number, endIndex: number]>();
|
||||
const hoverValue = defineEmit<[value: number]>();
|
||||
|
||||
const chartContainer = ref<HTMLElement | null>(null);
|
||||
const { width } = useElementSize(chartContainer);
|
||||
@@ -35,74 +40,96 @@ const GAP = 2;
|
||||
const availableBars = computed(() => Math.floor(width.value / (BAR_WIDTH + GAP)));
|
||||
const bucketSize = computed(() => Math.ceil(chartData.length / availableBars.value));
|
||||
|
||||
const downsampledData = ref<number[]>([]);
|
||||
const changeCounter = ref(-1);
|
||||
const downsampledBars = ref<BarDataPoint[]>([]);
|
||||
const maxValue = computed(() => {
|
||||
const dataMax = Math.max(0, ...downsampledBars.value.map((b) => b.percent));
|
||||
return Math.max(dataMax * 1.25, 1);
|
||||
});
|
||||
// Full recalculate when width/bucket size changes
|
||||
watch([availableBars, bucketSize], () => {
|
||||
recalculate();
|
||||
changeCounter.value = 0;
|
||||
});
|
||||
|
||||
// Watch chartData changes
|
||||
// On data changes, only update the last bar unless a new bucket boundary is crossed
|
||||
const changeCounter = ref(0);
|
||||
let initialized = false;
|
||||
watch(
|
||||
() => chartData,
|
||||
() => chartData.at(-1),
|
||||
() => {
|
||||
// If changeCounter is -1, it means this is the first time the data is loaded
|
||||
if (changeCounter.value === -1) {
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
recalculate();
|
||||
return;
|
||||
}
|
||||
changeCounter.value++;
|
||||
if (changeCounter.value >= bucketSize.value) {
|
||||
// Recalculate when counter reaches bucket size
|
||||
recalculate();
|
||||
changeCounter.value = 0;
|
||||
} else {
|
||||
updateLastBar();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Recalculate when width changes
|
||||
watch([availableBars, bucketSize], () => {
|
||||
recalculate();
|
||||
changeCounter.value = -1;
|
||||
});
|
||||
defineExpose({ recalculate });
|
||||
|
||||
function averageBucket(bucket: BarDataPoint[]): BarDataPoint {
|
||||
const percent = bucket.reduce((sum, d) => sum + d.percent, 0) / bucket.length;
|
||||
const value = bucket.reduce((sum, d) => sum + d.value, 0) / bucket.length;
|
||||
return { percent, value };
|
||||
}
|
||||
|
||||
function recalculate() {
|
||||
if (chartData.length <= availableBars.value || availableBars.value === 0) {
|
||||
downsampledData.value = [...chartData];
|
||||
if (availableBars.value === 0) return;
|
||||
|
||||
if (chartData.length <= availableBars.value) {
|
||||
downsampledBars.value = [...chartData];
|
||||
return;
|
||||
}
|
||||
|
||||
const size = bucketSize.value;
|
||||
const result = [];
|
||||
const result: BarDataPoint[] = [];
|
||||
const numBuckets = Math.ceil(chartData.length / size);
|
||||
|
||||
// Create complete buckets
|
||||
const numCompleteBuckets = Math.floor(chartData.length / size);
|
||||
|
||||
for (let i = 0; i < numCompleteBuckets; i++) {
|
||||
for (let i = 0; i < numBuckets; i++) {
|
||||
const start = i * size;
|
||||
const end = start + size;
|
||||
const bucket = chartData.slice(start, end);
|
||||
const avg = bucket.reduce((sum, val) => sum + val, 0) / bucket.length;
|
||||
result.push(avg);
|
||||
const end = Math.min(start + size, chartData.length);
|
||||
result.push(averageBucket(chartData.slice(start, end)));
|
||||
}
|
||||
|
||||
// Show only the last N bars that fit on screen
|
||||
downsampledData.value = result.slice(-availableBars.value);
|
||||
downsampledBars.value = result.slice(-availableBars.value);
|
||||
}
|
||||
|
||||
function updateLastBar() {
|
||||
if (downsampledBars.value.length === 0) return;
|
||||
|
||||
const size = bucketSize.value;
|
||||
const lastBucketStart = (Math.ceil(chartData.length / size) - 1) * size;
|
||||
const bucket = chartData.slice(lastBucketStart);
|
||||
|
||||
downsampledBars.value[downsampledBars.value.length - 1] = averageBucket(bucket);
|
||||
}
|
||||
|
||||
function onContainerHover(event: MouseEvent) {
|
||||
if (!chartContainer.value) return;
|
||||
|
||||
const rect = chartContainer.value.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const bars = chartContainer.value.children;
|
||||
if (bars.length === 0) return;
|
||||
|
||||
// Calculate which bar the mouse is over based on position
|
||||
const barWidth = width.value / downsampledData.value.length;
|
||||
const index = Math.floor(x / barWidth);
|
||||
const mouseX = event.clientX;
|
||||
let index = 0;
|
||||
|
||||
// Ensure index is within bounds
|
||||
if (index < 0 || index >= downsampledData.value.length) return;
|
||||
// Find the bar whose column contains the mouse x position
|
||||
for (let i = 0; i < bars.length; i++) {
|
||||
const rect = bars[i].getBoundingClientRect();
|
||||
if (mouseX >= rect.left) {
|
||||
index = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Map downsampled index back to original data index range
|
||||
const numCompleteBuckets = Math.floor(chartData.length / bucketSize.value);
|
||||
const offset = Math.max(0, numCompleteBuckets - availableBars.value);
|
||||
const startIndex = (offset + index) * bucketSize.value;
|
||||
const endIndex = Math.min(startIndex + bucketSize.value - 1, chartData.length - 1);
|
||||
hoverIndex(startIndex, endIndex);
|
||||
hoverValue(downsampledBars.value[index].value);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<BarChart class="h-4 flex-1" :chart-data="chartData" :bar-class="barClass" />
|
||||
<span class="w-fit text-right text-sm">{{ displayValue }}</span>
|
||||
<template v-if="mode === 'chart'">
|
||||
<BarChart class="h-4 flex-1" :chart-data="chartData" :bar-class="barClass" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<progress class="progress flex-1" :class="progressClass" :value="averageValue" max="100"></progress>
|
||||
</template>
|
||||
<span class="min-w-12 text-right text-sm tabular-nums">{{ displayValue }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,15 +14,21 @@
|
||||
import type { Container } from "@/models/Container";
|
||||
import type { Host } from "@/stores/hosts";
|
||||
|
||||
const { container, type, host } = defineProps<{
|
||||
const {
|
||||
container,
|
||||
type,
|
||||
host,
|
||||
mode = "chart",
|
||||
} = defineProps<{
|
||||
container: Container;
|
||||
type: "cpu" | "mem";
|
||||
host: Host;
|
||||
mode?: "chart" | "progress";
|
||||
}>();
|
||||
|
||||
function totalCores(): number {
|
||||
if (container.cpuLimit && container.cpuLimit > 0) {
|
||||
return 1;
|
||||
return container.cpuLimit;
|
||||
}
|
||||
return host.nCPU ?? 1;
|
||||
}
|
||||
@@ -25,9 +36,15 @@ function totalCores(): number {
|
||||
const chartData = computed(() => {
|
||||
if (type === "cpu") {
|
||||
const cores = totalCores();
|
||||
return container.statsHistory.map((stat) => Math.min(stat.cpu / cores, 100));
|
||||
return container.statsHistory.map((stat) => {
|
||||
const percent = Math.min(stat.cpu / cores, 100);
|
||||
return { percent, value: stat.cpu };
|
||||
});
|
||||
}
|
||||
return container.statsHistory.map((stat) => Math.min(stat.memory, 100));
|
||||
return container.statsHistory.map((stat) => {
|
||||
const percent = Math.min(stat.memory, 100);
|
||||
return { percent, value: stat.memoryUsage };
|
||||
});
|
||||
});
|
||||
|
||||
const averageValue = computed(() => {
|
||||
@@ -52,4 +69,12 @@ const barClass = computed(() => {
|
||||
if (value <= 90) return "bg-warning";
|
||||
return "bg-error";
|
||||
});
|
||||
|
||||
const progressClass = computed(() => {
|
||||
const value = averageValue.value;
|
||||
if (value <= 50) return "progress-success";
|
||||
if (value <= 70) return "progress-secondary";
|
||||
if (value <= 90) return "progress-warning";
|
||||
return "progress-error";
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -35,14 +35,32 @@
|
||||
v-else
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 text-right" v-show="containers.length > pageSizes[0]">
|
||||
{{ $t("label.per-page") }}
|
||||
<div class="flex flex-1 items-center justify-end gap-2">
|
||||
<div v-show="containers.length > pageSizes[0]">
|
||||
{{ $t("label.per-page") }}
|
||||
|
||||
<DropdownMenu
|
||||
class="dropdown-left btn-xs md:btn-sm"
|
||||
v-model="perPage"
|
||||
:options="pageSizes.map((i) => ({ label: i.toLocaleString(), value: i }))"
|
||||
/>
|
||||
<DropdownMenu
|
||||
class="dropdown-left btn-xs md:btn-sm"
|
||||
v-model="perPage"
|
||||
:options="pageSizes.map((i) => ({ label: i.toLocaleString(), value: i }))"
|
||||
/>
|
||||
</div>
|
||||
<div class="join max-md:hidden">
|
||||
<button
|
||||
class="btn join-item btn-xs md:btn-sm"
|
||||
:class="statMode === 'chart' ? 'btn-active' : 'btn-ghost'"
|
||||
@click="statMode = 'chart'"
|
||||
>
|
||||
<mdi:chart-bar />
|
||||
</button>
|
||||
<button
|
||||
class="btn join-item btn-xs md:btn-sm"
|
||||
:class="statMode === 'progress' ? 'btn-active' : 'btn-ghost'"
|
||||
@click="statMode = 'progress'"
|
||||
>
|
||||
<mdi:poll class="scale-x-[-1] rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-box border-base-content/10 overflow-x-auto border">
|
||||
@@ -66,7 +84,12 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-base-300/30">
|
||||
<tr v-for="container in paginated" :key="container.id" v-memo="[container.id]" class="hover:bg-base-100/80!">
|
||||
<tr
|
||||
v-for="container in paginated"
|
||||
:key="container.id"
|
||||
v-memo="[container.id, statMode]"
|
||||
class="hover:bg-base-100/80!"
|
||||
>
|
||||
<td v-if="isVisible('name')" class="max-w-80 truncate">
|
||||
<router-link :to="{ name: '/container/[id]', params: { id: container.id } }" :title="container.name">
|
||||
{{ container.name }}
|
||||
@@ -78,10 +101,10 @@
|
||||
<RelativeTime :date="container.created" />
|
||||
</td>
|
||||
<td v-if="isVisible('cpu')">
|
||||
<ContainerStatCell :container="container" type="cpu" :host="hosts[container.host]" />
|
||||
<ContainerStatCell :container="container" type="cpu" :host="hosts[container.host]" :mode="statMode" />
|
||||
</td>
|
||||
<td v-if="isVisible('mem')">
|
||||
<ContainerStatCell :container="container" type="mem" :host="hosts[container.host]" />
|
||||
<ContainerStatCell :container="container" type="mem" :host="hosts[container.host]" :mode="statMode" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -167,6 +190,7 @@ const { containers } = defineProps<{
|
||||
}>();
|
||||
type keys = keyof typeof fields;
|
||||
|
||||
const statMode = useStorage<"chart" | "progress">("DOZZLE_TABLE_STAT_MODE", "chart");
|
||||
const perPage = useStorage("DOZZLE_TABLE_PAGE_SIZE", 15);
|
||||
const pageSizes = [15, 30, 50, 100];
|
||||
|
||||
|
||||
@@ -9,24 +9,18 @@
|
||||
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 shadow-sm"
|
||||
@click="hideMenu"
|
||||
>
|
||||
<li v-if="!historical">
|
||||
<a @click="clear()">
|
||||
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
|
||||
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="enableDownload">
|
||||
<a :href="downloadUrl" download>
|
||||
<octicon:download-24 />
|
||||
{{ isFiltered ? $t("toolbar.download-filtered") : $t("toolbar.download") }}
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="!historical">
|
||||
<a @click="showSearch = true">
|
||||
<mdi:magnify /> {{ $t("toolbar.search") }}
|
||||
<KeyShortcut char="f" />
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="!historical">
|
||||
<a @click="clear()">
|
||||
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
|
||||
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="hasComplexLogs">
|
||||
<a @click="showDrawer(LogAnalytics, { container }, 'lg')">
|
||||
<ph:file-sql /> SQL Analytics
|
||||
@@ -109,6 +103,26 @@
|
||||
</details>
|
||||
</li>
|
||||
|
||||
<li class="line"></li>
|
||||
<li v-if="enableDownload">
|
||||
<a :href="downloadUrl" download>
|
||||
<octicon:download-24 />
|
||||
{{ isFiltered ? $t("toolbar.download-filtered") : $t("toolbar.download") }}
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="isSupported">
|
||||
<a @click="copyLogs()">
|
||||
<mdi:content-copy />
|
||||
{{ isFiltered ? $t("toolbar.copy-filtered-logs") : $t("toolbar.copy-logs") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a @click="copyPermalink()">
|
||||
<material-symbols:link />
|
||||
{{ $t("toolbar.copy-permalink") }}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Container Actions (Enabled via config) -->
|
||||
<template v-if="enableActions && !historical">
|
||||
<li class="line"></li>
|
||||
@@ -178,6 +192,107 @@ const { container, historical = false } = defineProps<{ container: Container; hi
|
||||
const clear = defineEmit();
|
||||
const { actionStates, start, stop, restart } = useContainerActions(toRef(() => container));
|
||||
|
||||
const router = useRouter();
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
const { t } = useI18n();
|
||||
const { showToast, removeToast } = useToast();
|
||||
|
||||
async function copyPermalink() {
|
||||
const url = router.resolve({
|
||||
name: "/show",
|
||||
query: { name: container.name, host: container.host },
|
||||
}).href;
|
||||
|
||||
const resolved = new URL(url, window.location.origin);
|
||||
|
||||
if (!isSupported.value) {
|
||||
showToast(
|
||||
{
|
||||
title: t("error.copy-not-supported-hint"),
|
||||
message: resolved.href,
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 10000 },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await copy(resolved.href);
|
||||
|
||||
if (copied.value) {
|
||||
showToast(
|
||||
{
|
||||
title: t("toasts.copied.title"),
|
||||
message: t("toasts.copied.message"),
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 2000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLogs() {
|
||||
const params = new URLSearchParams();
|
||||
if (streamConfig.value.stdout) params.append("stdout", "1");
|
||||
if (streamConfig.value.stderr) params.append("stderr", "1");
|
||||
params.append("everything", "1");
|
||||
|
||||
const { debouncedSearchFilter } = useSearchFilter();
|
||||
if (debouncedSearchFilter.value) {
|
||||
params.append("filter", debouncedSearchFilter.value);
|
||||
}
|
||||
|
||||
const selectedLevels = Array.from(levels.value);
|
||||
if (selectedLevels.length > 0 && selectedLevels.length < allLevels.length) {
|
||||
selectedLevels.forEach((level) => params.append("levels", level));
|
||||
}
|
||||
|
||||
const url = withBase(`/api/hosts/${container.host}/containers/${container.id}/logs?${params.toString()}`);
|
||||
|
||||
const toastId = "copy-logs";
|
||||
showToast(
|
||||
{
|
||||
id: toastId,
|
||||
title: t("toolbar.copying-logs"),
|
||||
message: "",
|
||||
type: "info",
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
const blobPromise = fetch(url, { headers: { Accept: "text/plain" } })
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
return response.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
removeToast(toastId);
|
||||
showToast(
|
||||
{
|
||||
title: t("toasts.copied.title"),
|
||||
message: t("toasts.copied.message"),
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 2000 },
|
||||
);
|
||||
return blob;
|
||||
})
|
||||
.catch((err) => {
|
||||
removeToast(toastId);
|
||||
showToast(
|
||||
{
|
||||
title: "Error",
|
||||
message: err.message,
|
||||
type: "error",
|
||||
},
|
||||
{ expire: 5000 },
|
||||
);
|
||||
throw err;
|
||||
});
|
||||
|
||||
await navigator.clipboard.write([new ClipboardItem({ "text/plain": blobPromise })]);
|
||||
}
|
||||
|
||||
onKeyStroke("f", (e) => {
|
||||
if (hasComplexLogs.value) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
|
||||
@@ -203,7 +318,12 @@ if (enableShell) {
|
||||
}
|
||||
|
||||
const containerRef = computed(() => [container]);
|
||||
const { downloadUrl, isFiltered } = useDownloadUrl(containerRef, streamConfig, levels);
|
||||
const { downloadUrl, isFiltered } = useDownloadUrl(
|
||||
containerRef,
|
||||
streamConfig,
|
||||
levels,
|
||||
toRef(() => container.name),
|
||||
);
|
||||
|
||||
const disableRestart = computed(() => actionStates.stop || actionStates.start || actionStates.restart);
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="@container flex flex-1 items-center gap-1.5 md:gap-2">
|
||||
<div class="@container flex min-w-0 flex-1 items-center gap-1.5 md:gap-2">
|
||||
<label class="swap swap-rotate size-4">
|
||||
<input type="checkbox" v-model="pinned" />
|
||||
<carbon:star-filled class="swap-on text-secondary" />
|
||||
<carbon:star class="swap-off" />
|
||||
</label>
|
||||
<div class="inline-flex items-center text-sm">
|
||||
<div class="breadcrumbs overflow-x-visible p-0 font-mono">
|
||||
<div class="inline-flex min-w-0 items-center text-sm">
|
||||
<div class="breadcrumbs min-w-0 overflow-x-visible p-0 font-mono">
|
||||
<ul>
|
||||
<li v-if="config.hosts.length > 1" class="font-thin max-md:hidden">
|
||||
{{ container.hostLabel }}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{{ $t("label.container", group.containers.length) }}
|
||||
</ContainerDropdown>
|
||||
<MultiContainerStat class="ml-auto" :containers="group.containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" :name="group.name" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
|
||||
@@ -80,7 +80,7 @@ const hostContainers = computed(() =>
|
||||
|
||||
function toContainerCores(container: Container): number {
|
||||
if (container.cpuLimit && container.cpuLimit > 0) {
|
||||
return 1;
|
||||
return container.cpuLimit;
|
||||
}
|
||||
return props.host.nCPU ?? 1;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,12 @@
|
||||
</router-link>
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="item in containers" :class="item.state" :key="item.id">
|
||||
<li
|
||||
v-for="item in containers"
|
||||
:class="[item.state, { 'highlight-new': item.isNew }]"
|
||||
:key="item.id"
|
||||
@animationend="item.isNew = false"
|
||||
>
|
||||
<Popup>
|
||||
<router-link
|
||||
:to="{ name: '/container/[id]', params: { id: item.id } }"
|
||||
@@ -88,7 +93,9 @@
|
||||
:title="item.name"
|
||||
class="group auto-cols-[content_max_auto_max-content_max-content]"
|
||||
>
|
||||
<svg-spinners:ring-resize v-if="item.isNew" class="text-secondary w-2" />
|
||||
<div
|
||||
v-else
|
||||
class="status data-[state=exited]:status-error data-[state=running]:status-success"
|
||||
:data-state="item.state"
|
||||
></div>
|
||||
@@ -248,4 +255,17 @@ li.exited {
|
||||
li.deleted {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
li.highlight-new {
|
||||
animation: highlight-fade 3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes highlight-fade {
|
||||
from {
|
||||
background-color: oklch(from var(--color-secondary) l c h / 0.25);
|
||||
}
|
||||
to {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</Tag>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" :name="host.name" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</ContainerDropdown>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="namespace.containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" :name="namespace.name" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</ContainerDropdown>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="owner.containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" :name="owner.name" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
<slot name="more-items"></slot>
|
||||
<Announcements />
|
||||
|
||||
<router-link
|
||||
:to="{ name: '/notifications' }"
|
||||
:aria-label="$t('title.notifications')"
|
||||
data-testid="notifications"
|
||||
class="btn btn-circle btn-sm"
|
||||
>
|
||||
<mdi:bell class="size-6" />
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
:to="{ name: '/settings' }"
|
||||
:aria-label="$t('title.settings')"
|
||||
|
||||
@@ -73,6 +73,12 @@
|
||||
{{ $t("action.show-details") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a @click="createAlert()">
|
||||
<mdi:bell />
|
||||
{{ $t("action.create-alert") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -82,6 +88,7 @@ import stripAnsi from "strip-ansi";
|
||||
import { Container } from "@/models/Container";
|
||||
import { LogEntry, SimpleLogEntry, ComplexLogEntry, GroupedLogEntry, JSONObject } from "@/models/LogEntry";
|
||||
import LogDetails from "./LogDetails.vue";
|
||||
import AlertForm from "@/components/Notification/AlertForm.vue";
|
||||
|
||||
const { logEntry, container } = defineProps<{
|
||||
logEntry: LogEntry<string | JSONObject>;
|
||||
@@ -147,6 +154,22 @@ async function copyPermalink() {
|
||||
}
|
||||
}
|
||||
|
||||
function createAlert() {
|
||||
const containerExpr = `name contains "${container.name}"`;
|
||||
let logExpr = "";
|
||||
if (logEntry.level && logEntry.level !== "unknown") {
|
||||
logExpr = `level == "${logEntry.level}"`;
|
||||
}
|
||||
|
||||
const nameParts = [container.name];
|
||||
if (logEntry.level && logEntry.level !== "unknown") {
|
||||
nameParts.push(logEntry.level);
|
||||
}
|
||||
const name = nameParts.join(" ");
|
||||
|
||||
showDrawer(AlertForm, { prefill: { name, containerExpression: containerExpr, logExpression: logExpr } }, "lg");
|
||||
}
|
||||
|
||||
function hideMenu(e: MouseEvent) {
|
||||
if (e.target instanceof HTMLAnchorElement) {
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
<RandomColorTag
|
||||
v-if="showContainerName"
|
||||
class="w-30 shrink-0 select-none group-[.compact]:flex-1 md:w-40"
|
||||
:value="container.name"
|
||||
:value="container.id"
|
||||
truncateRight
|
||||
/>
|
||||
>
|
||||
{{ container.name }}
|
||||
</RandomColorTag>
|
||||
<LogDate
|
||||
v-if="showTimestamp"
|
||||
:date="logEntry.date"
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type JSONObject, LogEntry } from "@/models/LogEntry";
|
||||
import type { LogEntry, LogMessage } from "@/models/LogEntry";
|
||||
|
||||
const { progress, currentDate } = useScrollContext();
|
||||
|
||||
const { messages } = defineProps<{
|
||||
messages: LogEntry<string | string[] | JSONObject>[];
|
||||
messages: LogEntry<LogMessage>[];
|
||||
}>();
|
||||
|
||||
const { containers } = useLoggingContext();
|
||||
|
||||
@@ -94,9 +94,11 @@ const { showSearch } = useSearchFilter();
|
||||
const { enableDownload } = config;
|
||||
const clear = defineEmit();
|
||||
|
||||
const { name } = defineProps<{ name?: string }>();
|
||||
|
||||
const { streamConfig, showHostname, showContainerName, containers, levels } = useLoggingContext();
|
||||
|
||||
const { downloadUrl, isFiltered } = useDownloadUrl(containers, streamConfig, levels);
|
||||
const { downloadUrl, isFiltered } = useDownloadUrl(containers, streamConfig, levels, name);
|
||||
|
||||
const hideMenu = (e: MouseEvent) => {
|
||||
if (e.target instanceof HTMLAnchorElement) {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<template>
|
||||
<div class="flex gap-1 md:gap-4">
|
||||
<div class="grid min-w-15 grid-cols-[auto_1fr] items-center gap-0.5 text-xs leading-none max-md:hidden">
|
||||
<div
|
||||
class="grid hidden min-w-15 grid-cols-[auto_1fr_auto_1fr] items-center gap-0.5 text-xs leading-none sm:grid md:grid-cols-[auto_1fr]"
|
||||
>
|
||||
<PhArrowUp class="text-primary" />
|
||||
<span class="tabular-nums">{{ formatBytes(networkRate.tx, { short: true, decimals: 1 }) }}/s</span>
|
||||
<PhArrowDown class="text-secondary" />
|
||||
<span class="tabular-nums">{{ formatBytes(networkRate.rx, { short: true, decimals: 1 }) }}/s</span>
|
||||
</div>
|
||||
<StatMonitor
|
||||
ref="cpuMonitorRef"
|
||||
:data="cpuData"
|
||||
:icon="PhCpu"
|
||||
:stat-value="Math.max(0, totalStat.cpu).toFixed(2) + '%'"
|
||||
@@ -17,6 +20,7 @@
|
||||
:formatter="(value: number) => value.toFixed(2) + '%'"
|
||||
/>
|
||||
<StatMonitor
|
||||
ref="memoryMonitorRef"
|
||||
:data="memoryData"
|
||||
:icon="PhMemory"
|
||||
:stat-value="formatBytes(totalStat.memoryUsage)"
|
||||
@@ -32,6 +36,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { Stat } from "@/models/Container";
|
||||
import { Container } from "@/models/Container";
|
||||
import StatMonitor from "@/components/LogViewer/StatMonitor.vue";
|
||||
// @ts-ignore
|
||||
import PhCpu from "~icons/ph/cpu";
|
||||
// @ts-ignore
|
||||
@@ -41,6 +46,8 @@ const { containers } = defineProps<{
|
||||
containers: Container[];
|
||||
}>();
|
||||
|
||||
const cpuMonitorRef = ref<InstanceType<typeof StatMonitor> | null>(null);
|
||||
const memoryMonitorRef = ref<InstanceType<typeof StatMonitor> | null>(null);
|
||||
const totalStat = ref<Stat>({ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 });
|
||||
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
|
||||
const { hosts } = useHosts();
|
||||
@@ -50,7 +57,7 @@ const roundCPU = (num: number) => (Number.isInteger(num) ? num.toFixed(0) : num.
|
||||
|
||||
function toContainerCores(container: Container): number {
|
||||
if (container.cpuLimit && container.cpuLimit > 0) {
|
||||
return 1;
|
||||
return container.cpuLimit;
|
||||
}
|
||||
const hostInfo = hosts.value[container.host];
|
||||
return hostInfo?.nCPU ?? 1;
|
||||
@@ -82,6 +89,10 @@ watch(
|
||||
}
|
||||
totalStat.value = initial[0];
|
||||
reset({ initial: initial.reverse() });
|
||||
nextTick(() => {
|
||||
cpuMonitorRef.value?.recalculate();
|
||||
memoryMonitorRef.value?.recalculate();
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -115,7 +126,7 @@ const limits = computed(() => {
|
||||
totalCpu += hostTotalCpu;
|
||||
} else {
|
||||
// All containers have limits, sum them up (capped at host total)
|
||||
const sumCpu = hostContainers.reduce((sum, c) => sum + 1, 0);
|
||||
const sumCpu = hostContainers.reduce((sum, c) => sum + (c.cpuLimit || 0), 0);
|
||||
totalCpu += Math.min(sumCpu, hostTotalCpu);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="tag grid overflow-hidden rounded-sm text-center text-sm text-white">
|
||||
<div class="random-color col-start-1 row-start-1 brightness-75"></div>
|
||||
<div class="col-start-1 row-start-1 truncate px-2 brightness-100" :class="truncateRight ? '[direction:rtl]' : ''">
|
||||
{{ value }}
|
||||
<slot>{{ value }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
<template>
|
||||
<div class="relative" @mouseenter="mouseOver = true" @mouseleave="mouseOver = false" :class="textClass">
|
||||
<div
|
||||
class="relative"
|
||||
@mouseenter="mouseOver = true"
|
||||
@mouseleave="
|
||||
mouseOver = false;
|
||||
hoveredValue = null;
|
||||
"
|
||||
:class="textClass"
|
||||
>
|
||||
<div class="overflow-hidden rounded-xs border px-px pt-1 pb-px max-md:hidden" :class="containerClass">
|
||||
<BarChart
|
||||
ref="barChartRef"
|
||||
:chart-data="chartData"
|
||||
:bar-class="`${barClass} opacity-70 hover:opacity-100`"
|
||||
class="h-8 w-44"
|
||||
@hover-index="(startIndex: number, endIndex: number) => onHoverIndexChange(startIndex, endIndex)"
|
||||
@hover-value="(value: number) => (hoveredValue = value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="bg-base-200 flex gap-1 rounded-sm p-px text-xs md:absolute md:-top-2 md:-left-0.5">
|
||||
@@ -20,6 +29,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Component } from "vue";
|
||||
import BarChart from "@/components/BarChart.vue";
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -31,7 +41,7 @@ const {
|
||||
barClass = "bg-primary",
|
||||
formatter,
|
||||
} = defineProps<{
|
||||
data: Point<unknown>[];
|
||||
data: Point<number>[];
|
||||
icon: Component;
|
||||
statValue: string | number;
|
||||
limit?: string | number;
|
||||
@@ -41,25 +51,24 @@ const {
|
||||
formatter?: (value: number) => string;
|
||||
}>();
|
||||
|
||||
const chartData = computed(() => data.map((point) => (point.y as number) ?? 0));
|
||||
const chartData = computed(() =>
|
||||
data.map((point) => ({
|
||||
percent: point.y ?? 0,
|
||||
value: point.value ?? point.y ?? 0,
|
||||
})),
|
||||
);
|
||||
const barChartRef = ref<InstanceType<typeof BarChart> | null>(null);
|
||||
const mouseOver = ref(false);
|
||||
const hoveredRange = ref<{ start: number; end: number } | null>(null);
|
||||
const hoveredValue = ref<number | null>(null);
|
||||
|
||||
function onHoverIndexChange(startIndex: number, endIndex: number) {
|
||||
hoveredRange.value = { start: startIndex, end: endIndex };
|
||||
}
|
||||
defineExpose({ recalculate: () => barChartRef.value?.recalculate() });
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (mouseOver.value && hoveredRange.value !== null) {
|
||||
const { start, end } = hoveredRange.value;
|
||||
const points = data.slice(start, end + 1);
|
||||
const sum = points.reduce((acc, point) => acc + ((point.value as number) ?? (point.y as number) ?? 0), 0);
|
||||
const avg = sum / points.length;
|
||||
|
||||
if (mouseOver.value && hoveredValue.value !== null) {
|
||||
if (formatter) {
|
||||
return formatter(avg);
|
||||
return formatter(hoveredValue.value);
|
||||
}
|
||||
return avg.toFixed(2);
|
||||
return hoveredValue.value.toFixed(2);
|
||||
}
|
||||
return statValue;
|
||||
});
|
||||
|
||||
@@ -21,6 +21,9 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
<li><a><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"></path>
|
||||
</svg> action.create-alert</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
@@ -59,6 +62,9 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
<li><a><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"></path>
|
||||
</svg> action.create-alert</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
@@ -97,6 +103,9 @@ exports[`<ContainerEventSource /> > render html correctly > should render messag
|
||||
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
|
||||
</svg> action.copy-link</a></li>
|
||||
<!--v-if-->
|
||||
<li><a><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
|
||||
<path fill="currentColor" d="M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"></path>
|
||||
</svg> action.create-alert</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
|
||||
@@ -8,17 +8,13 @@
|
||||
<div class="text-base-content/60 mb-1 text-xs tabular-nums max-md:hidden">
|
||||
avg {{ formatValue(average) }} • pk {{ formatValue(peak) }}
|
||||
</div>
|
||||
<BarChart class="h-8" :chartData="percentData" :barClass="barClass" />
|
||||
<BarChart class="h-8" :chart-data="chartData" :bar-class="barClass" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue";
|
||||
|
||||
export interface MetricDataPoint {
|
||||
percent: number; // value 0 - 100
|
||||
value: number;
|
||||
}
|
||||
import type { BarDataPoint } from "@/components/BarChart.vue";
|
||||
|
||||
const {
|
||||
label,
|
||||
@@ -33,15 +29,13 @@ const {
|
||||
label: string;
|
||||
icon: Component;
|
||||
value: string | number;
|
||||
chartData: MetricDataPoint[];
|
||||
chartData: BarDataPoint[];
|
||||
containerClass?: string;
|
||||
textClass?: string;
|
||||
barClass?: string;
|
||||
formatValue?: (value: number) => string;
|
||||
}>();
|
||||
|
||||
const percentData = computed(() => chartData.map((d) => d.percent));
|
||||
|
||||
const peak = computed(() => (chartData.length > 0 ? Math.max(...chartData.map((d) => d.value)) : 0));
|
||||
|
||||
const average = computed(() => {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-sm" :class="{ 'opacity-60': !alert.enabled }">
|
||||
<div class="card-body gap-4 p-5">
|
||||
<!-- Header -->
|
||||
<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:bell-ring-outline v-else-if="alert.eventExpression" class="text-info" />
|
||||
<mdi:text-box-outline v-else class="text-info" />
|
||||
<span>{{ alert.name }}</span> <span class="text-sm font-light">→</span>
|
||||
<div class="group/dispatch dropdown dropdown-hover">
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="border-base-content/0 hover:border-base-content/20 flex cursor-pointer items-center gap-1 rounded border px-1.5 py-0.5 text-xs font-light transition-colors"
|
||||
:class="{ 'text-warning': !alert.dispatcher }"
|
||||
>
|
||||
<template v-if="alert.dispatcher">
|
||||
<mdi:webhook v-if="alert.dispatcher.type === 'webhook'" />
|
||||
<mdi:cloud v-else />
|
||||
{{ alert.dispatcher.name }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<mdi:alert-outline />
|
||||
{{ $t("notifications.alert.dispatcher-deleted") }}
|
||||
</template>
|
||||
<mdi:chevron-down class="text-[0.6rem] opacity-0 transition-opacity group-hover/dispatch:opacity-100" />
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-50 w-48 p-2 shadow-lg">
|
||||
<li v-for="dest in dispatchers" :key="dest.id">
|
||||
<a
|
||||
class="flex items-center gap-2"
|
||||
:class="{ active: dest.id === alert.dispatcher?.id }"
|
||||
@click="changeDispatcher(dest.id)"
|
||||
>
|
||||
<mdi:webhook v-if="dest.type === 'webhook'" />
|
||||
<mdi:cloud v-else />
|
||||
{{ dest.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</h4>
|
||||
<span v-if="!alert.enabled" class="badge badge-warning badge-sm">{{ $t("notifications.alert.paused") }}</span>
|
||||
</div>
|
||||
<input type="checkbox" class="toggle toggle-primary" :checked="alert.enabled" @change="toggleEnabled" />
|
||||
</div>
|
||||
|
||||
<!-- Expressions -->
|
||||
<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>
|
||||
<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.sample-window") }}</span>
|
||||
<span>{{ formatDuration(alert.sampleWindow || 15, locale || undefined) }}</span>
|
||||
<span>{{ $t("notifications.alert.cooldown") }}</span>
|
||||
<span>{{ formatDuration(alert.cooldown || 300, locale || undefined) }}</span>
|
||||
</template>
|
||||
<template v-else-if="alert.eventExpression">
|
||||
<span>{{ $t("notifications.alert.event-filter") }}</span>
|
||||
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.eventExpression }}</code>
|
||||
<template v-if="alert.cooldown">
|
||||
<span>{{ $t("notifications.alert.cooldown") }}</span>
|
||||
<span>{{ formatDuration(alert.cooldown, locale || undefined) }}</span>
|
||||
</template>
|
||||
</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 -->
|
||||
<div class="border-base-content/10 text-base-content/80 flex items-center justify-between border-t pt-3 text-xs">
|
||||
<div class="flex items-center gap-4">
|
||||
<span>
|
||||
{{ $t("notifications.alert.containers-count", { count: alert.triggeredContainers }) }}
|
||||
</span>
|
||||
<span>
|
||||
{{ $t("notifications.alert.triggered-count", { count: alert.triggerCount }) }}
|
||||
</span>
|
||||
<span v-if="alert.lastTriggeredAt">
|
||||
{{ $t("notifications.alert.last-triggered", { time: formatTimeAgo(alert.lastTriggeredAt) }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button class="btn btn-ghost btn-square" @click="editAlert">
|
||||
<mdi:pencil-outline />
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-square" @click="deleteAlert" :disabled="isDeleting">
|
||||
<span v-if="isDeleting" class="loading loading-spinner loading-xs"></span>
|
||||
<mdi:trash-can-outline v-else />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Dispatcher, NotificationRule } from "@/types/notifications";
|
||||
import AlertForm from "./AlertForm.vue";
|
||||
|
||||
const { alert, onUpdated } = defineProps<{
|
||||
alert: NotificationRule;
|
||||
onUpdated?: () => void;
|
||||
}>();
|
||||
|
||||
const showDrawer = useDrawer();
|
||||
const isDeleting = ref(false);
|
||||
const dispatchers = ref<Dispatcher[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await fetch(withBase("/api/notifications/dispatchers"));
|
||||
dispatchers.value = await res.json();
|
||||
});
|
||||
|
||||
async function changeDispatcher(id: number) {
|
||||
await fetch(withBase(`/api/notifications/rules/${alert.id}`), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dispatcherId: id }),
|
||||
});
|
||||
onUpdated?.();
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
if (date.getFullYear() === 0) return "-";
|
||||
return toRelativeTime(date, undefined);
|
||||
}
|
||||
|
||||
async function toggleEnabled() {
|
||||
await fetch(withBase(`/api/notifications/rules/${alert.id}`), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: !alert.enabled }),
|
||||
});
|
||||
onUpdated?.();
|
||||
}
|
||||
|
||||
function editAlert() {
|
||||
showDrawer(AlertForm, { alert, onCreated: onUpdated }, "lg");
|
||||
}
|
||||
|
||||
async function deleteAlert() {
|
||||
isDeleting.value = true;
|
||||
try {
|
||||
await fetch(withBase(`/api/notifications/rules/${alert.id}`), { method: "DELETE" });
|
||||
onUpdated?.();
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div class="space-y-4 p-4">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold">
|
||||
{{ isEditing ? $t("notifications.alert-form.edit-title") : $t("notifications.alert-form.create-title") }}
|
||||
</h2>
|
||||
<p class="text-base-content/60">{{ $t("notifications.alert-form.description") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Alert Name -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.alert-name") }}</legend>
|
||||
<input
|
||||
ref="alertNameInput"
|
||||
v-model="alertName"
|
||||
type="text"
|
||||
class="input focus:input-primary w-full text-base"
|
||||
:class="alertName.trim() ? 'input-primary' : ''"
|
||||
required
|
||||
:placeholder="$t('notifications.alert-form.alert-name-placeholder')"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<!-- Alert Type Toggle -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.alert-type") }}</legend>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="alertType === 'log' ? 'btn-primary' : 'btn-outline'"
|
||||
@click="alertType = 'log'"
|
||||
>
|
||||
<mdi:text-box-outline class="mr-1" />
|
||||
{{ $t("notifications.alert-form.log-alert") }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="alertType === 'metric' ? 'btn-primary' : 'btn-outline'"
|
||||
@click="alertType = 'metric'"
|
||||
>
|
||||
<mdi:chart-line class="mr-1" />
|
||||
{{ $t("notifications.alert-form.metric-alert") }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="alertType === 'event' ? 'btn-primary' : 'btn-outline'"
|
||||
@click="alertType = 'event'"
|
||||
>
|
||||
<mdi:bell-ring-outline class="mr-1" />
|
||||
{{ $t("notifications.alert-form.event-alert") }}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Container Filter -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.container-filter") }}</legend>
|
||||
<div
|
||||
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
|
||||
:class="
|
||||
containerExpression.trim() && !containerResult?.error
|
||||
? 'input-primary'
|
||||
: { 'input-error!': containerResult?.error }
|
||||
"
|
||||
>
|
||||
<div ref="containerEditorRef" class="w-full"></div>
|
||||
</div>
|
||||
<div v-if="containerResult" class="fieldset-label">
|
||||
<span v-if="containerResult.error" class="text-error">{{ containerResult.error }}</span>
|
||||
<span v-else-if="containerResult.containers?.length" class="text-success">
|
||||
<mdi:check class="inline" />
|
||||
{{
|
||||
$t("notifications.alert-form.containers-match", {
|
||||
count: containerResult.containers.length,
|
||||
names: containerResult.containers.map((c) => c.name).join(", "),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-else class="text-warning">
|
||||
<mdi:alert class="inline" />
|
||||
{{ $t("notifications.alert-form.no-containers-match") }}
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Type-specific fields -->
|
||||
<KeepAlive>
|
||||
<LogAlertFields
|
||||
v-if="alertType === 'log'"
|
||||
ref="fieldsRef"
|
||||
:alert="alert"
|
||||
:prefill="prefill"
|
||||
:container-expression="containerExpression"
|
||||
:is-loading="isLoading"
|
||||
:validate-preview="validatePreview"
|
||||
/>
|
||||
<MetricAlertFields
|
||||
v-else-if="alertType === 'metric'"
|
||||
ref="fieldsRef"
|
||||
:alert="alert"
|
||||
:prefill="prefill"
|
||||
:container-expression="containerExpression"
|
||||
:is-loading="isLoading"
|
||||
:validate-preview="validatePreview"
|
||||
/>
|
||||
<EventAlertFields
|
||||
v-else
|
||||
ref="fieldsRef"
|
||||
:alert="alert"
|
||||
:prefill="prefill"
|
||||
:container-expression="containerExpression"
|
||||
:is-loading="isLoading"
|
||||
:validate-preview="validatePreview"
|
||||
/>
|
||||
</KeepAlive>
|
||||
|
||||
<!-- Destination -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.destination") }}</legend>
|
||||
<details class="dropdown w-full" ref="destinationDropdown">
|
||||
<summary class="btn btn-outline w-full justify-between" :class="{ 'btn-primary': selectedDestination }">
|
||||
<span class="flex items-center gap-2">
|
||||
<template v-if="selectedDestination">
|
||||
<mdi:webhook v-if="selectedDestination.type === 'webhook'" />
|
||||
<mdi:cloud v-else />
|
||||
{{ selectedDestination.name }}
|
||||
</template>
|
||||
<span v-else class="text-base-content/60">{{ $t("notifications.alert-form.select-destination") }}</span>
|
||||
</span>
|
||||
<carbon:caret-down />
|
||||
</summary>
|
||||
<ul class="dropdown-content menu bg-base-200 rounded-box z-50 mt-1 w-full border p-2 shadow-sm">
|
||||
<li v-for="dest in destinations" :key="dest.id">
|
||||
<a
|
||||
@click="
|
||||
dispatcherId = dest.id;
|
||||
destinationDropdown?.removeAttribute('open');
|
||||
"
|
||||
:class="{ active: dispatcherId === dest.id }"
|
||||
>
|
||||
<mdi:webhook v-if="dest.type === 'webhook'" />
|
||||
<mdi:cloud v-else />
|
||||
{{ dest.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
<div v-if="!destinations.length" class="fieldset-label">
|
||||
<span class="text-warning">
|
||||
<mdi:alert class="inline" />
|
||||
{{ $t("notifications.alert-form.no-destinations") }}
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="saveError" class="alert alert-error">
|
||||
<span>{{ saveError }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<button class="btn" @click="close?.()">{{ $t("notifications.alert-form.cancel") }}</button>
|
||||
<button class="btn btn-primary" :disabled="!canSave" @click="save">
|
||||
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
|
||||
{{ isEditing ? $t("notifications.alert-form.save") : $t("notifications.alert-form.create") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useAlertForm } from "@/composable/alertForm";
|
||||
import LogAlertFields from "./LogAlertFields.vue";
|
||||
import MetricAlertFields from "./MetricAlertFields.vue";
|
||||
import EventAlertFields from "./EventAlertFields.vue";
|
||||
import type { NotificationRule } from "@/types/notifications";
|
||||
|
||||
const props = defineProps<{
|
||||
close?: () => void;
|
||||
onCreated?: () => void;
|
||||
alert?: NotificationRule;
|
||||
prefill?: {
|
||||
name?: string;
|
||||
containerExpression?: string;
|
||||
logExpression?: string;
|
||||
metricExpression?: string;
|
||||
eventExpression?: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const {
|
||||
isEditing,
|
||||
alertName,
|
||||
containerExpression,
|
||||
dispatcherId,
|
||||
destinations,
|
||||
selectedDestination,
|
||||
containerResult,
|
||||
isLoading,
|
||||
isSaving,
|
||||
saveError,
|
||||
baseCanSave,
|
||||
setupContainerEditor,
|
||||
saveAlert,
|
||||
validatePreview,
|
||||
} = useAlertForm(props);
|
||||
|
||||
// Template refs
|
||||
const alertNameInput = ref<HTMLInputElement>();
|
||||
const containerEditorRef = ref<HTMLElement>();
|
||||
const destinationDropdown = ref<HTMLDetailsElement>();
|
||||
const fieldsRef = ref<
|
||||
InstanceType<typeof LogAlertFields> | InstanceType<typeof MetricAlertFields> | InstanceType<typeof EventAlertFields>
|
||||
>();
|
||||
useFocus(alertNameInput, { initialValue: true });
|
||||
|
||||
// Alert type
|
||||
const alertType = ref<"log" | "metric" | "event">(
|
||||
props.alert?.metricExpression ? "metric" : props.alert?.eventExpression ? "event" : "log",
|
||||
);
|
||||
|
||||
const canSave = computed(() => baseCanSave.value && (fieldsRef.value?.canSave ?? false));
|
||||
|
||||
async function save() {
|
||||
if (!canSave.value || !fieldsRef.value) return;
|
||||
await saveAlert(fieldsRef.value.typeFields);
|
||||
}
|
||||
|
||||
// Container editor
|
||||
setupContainerEditor(containerEditorRef);
|
||||
</script>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Cloud linked (when editing with prefix) -->
|
||||
<fieldset v-if="destination?.prefix" class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.api-key") }}</legend>
|
||||
<div class="join w-full">
|
||||
<input
|
||||
type="text"
|
||||
:value="destination.prefix + '**************************************'"
|
||||
readonly
|
||||
disabled
|
||||
class="input join-item w-full font-mono"
|
||||
:class="cloudStatusError ? 'input-error' : 'input-success'"
|
||||
/>
|
||||
<span class="join-item btn pointer-events-none" :class="cloudStatusError ? 'btn-error' : 'btn-success'">
|
||||
<mdi:alert-circle v-if="cloudStatusError" class="text-lg" />
|
||||
<mdi:check v-else class="text-lg" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Cloud Status -->
|
||||
<div v-if="isLoadingCloudStatus" class="mt-3 flex items-center gap-2">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span class="text-base-content/60 text-sm">{{ $t("notifications.destination-form.cloud-checking") }}</span>
|
||||
</div>
|
||||
<div v-else-if="cloudStatusError" class="mt-3">
|
||||
<div class="alert alert-error">
|
||||
<mdi:alert-circle class="text-lg" />
|
||||
<span>{{ $t("notifications.destination-form.cloud-relink") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="cloudStatus" class="mt-3 space-y-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-base-content/60">{{ $t("notifications.destination-form.cloud-plan") }}</span>
|
||||
<span class="badge badge-primary badge-sm capitalize">{{ cloudStatus.plan.name }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between text-sm">
|
||||
<span class="text-base-content/60">{{ $t("notifications.destination-form.cloud-usage") }}</span>
|
||||
<span
|
||||
>{{ cloudStatus.usage.events_used.toLocaleString() }} /
|
||||
{{ cloudStatus.usage.events_limit.toLocaleString() }}</span
|
||||
>
|
||||
</div>
|
||||
<progress
|
||||
class="progress w-full"
|
||||
:class="usagePercent > 90 ? 'progress-error' : usagePercent > 70 ? 'progress-warning' : 'progress-primary'"
|
||||
:value="cloudStatus.usage.events_used"
|
||||
:max="cloudStatus.usage.events_limit"
|
||||
></progress>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-base-content/60 mt-2 text-sm">
|
||||
{{ $t("notifications.destination-form.cloud-settings-hint") }}
|
||||
<a :href="cloudSettingsUrl" target="_blank" class="link link-primary">
|
||||
{{ $t("notifications.destination-form.cloud-settings-link") }}
|
||||
</a>
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<!-- Link Dozzle Cloud (when creating or not linked) -->
|
||||
<div v-else class="card card-border border-primary/30 bg-primary/5">
|
||||
<div class="card-body items-center text-center">
|
||||
<mdi:cloud-outline class="text-primary text-4xl" />
|
||||
<h3 class="card-title">{{ $t("notifications.destination-form.link-cloud") }}</h3>
|
||||
<p class="text-base-content/60 text-sm">{{ $t("notifications.destination-form.cloud-description") }}</p>
|
||||
<a :href="cloudLinkUrl" class="btn btn-primary btn-lg mt-2">
|
||||
<mdi:link-variant class="text-lg" />
|
||||
{{ $t("notifications.destination-form.link-cloud-button") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 pt-4">
|
||||
<div class="flex-1"></div>
|
||||
<button class="btn btn-primary" @click="close?.()">
|
||||
{{ $t("notifications.destination-form.close") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Dispatcher } from "@/types/notifications";
|
||||
|
||||
const { destination, close } = defineProps<{
|
||||
destination?: Dispatcher;
|
||||
close?: () => void;
|
||||
}>();
|
||||
|
||||
const callbackUrl = `${window.location.origin}${withBase("/")}`;
|
||||
const cloudLinkUrl = `${__CLOUD_URL__}/link?appUrl=${encodeURIComponent(callbackUrl)}`;
|
||||
const cloudSettingsUrl = `${__CLOUD_URL__}/settings`;
|
||||
|
||||
// Cloud status
|
||||
interface CloudStatus {
|
||||
user: { email: string; name: string };
|
||||
plan: { name: string; events_per_month: number; retention_days: number };
|
||||
usage: { events_used: number; events_limit: number; period: string };
|
||||
}
|
||||
|
||||
const cloudStatus = ref<CloudStatus | null>(null);
|
||||
const cloudStatusError = ref(false);
|
||||
const isLoadingCloudStatus = ref(false);
|
||||
|
||||
const usagePercent = computed(() => {
|
||||
if (!cloudStatus.value) return 0;
|
||||
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
|
||||
});
|
||||
|
||||
async function fetchCloudStatus() {
|
||||
isLoadingCloudStatus.value = true;
|
||||
cloudStatusError.value = false;
|
||||
try {
|
||||
const res = await fetch(withBase("/api/cloud/status"));
|
||||
if (!res.ok) {
|
||||
cloudStatusError.value = true;
|
||||
return;
|
||||
}
|
||||
cloudStatus.value = await res.json();
|
||||
} catch {
|
||||
cloudStatusError.value = true;
|
||||
} finally {
|
||||
isLoadingCloudStatus.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (destination?.prefix) {
|
||||
fetchCloudStatus();
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.cooldown-label") }}</legend>
|
||||
<input v-model.number="model" type="range" min="0" max="3600" step="10" class="range range-primary" />
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
<template v-if="model === 0">{{ $t("notifications.alert-form.no-cooldown") }}</template>
|
||||
<template v-else>{{
|
||||
$t("notifications.alert-form.cooldown-hint", { duration: formatDuration(model, locale || undefined) })
|
||||
}}</template>
|
||||
</p>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const model = defineModel<number>({ required: true });
|
||||
</script>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 hover:border-primary cursor-pointer border border-transparent" @click="editDestination">
|
||||
<div class="card-body gap-2 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<mdi:webhook v-if="destination.type === 'webhook'" class="text-lg" />
|
||||
<mdi:cloud v-else class="text-primary-content text-lg" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold">{{ destination.name }}</h4>
|
||||
<p class="text-base-content/60 text-sm">
|
||||
{{
|
||||
destination.type === "webhook"
|
||||
? $t("notifications.destination.http-webhook")
|
||||
: $t("notifications.destination.dozzle-cloud")
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="dropdown dropdown-end" @click.stop>
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<ion:ellipsis-vertical />
|
||||
</label>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content rounded-box bg-base-100 border-base-content/20 z-50 w-40 border p-1 shadow-sm"
|
||||
>
|
||||
<li>
|
||||
<a @click="editDestination">{{ $t("notifications.destination.edit") }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="text-error" @click="deleteDestination">{{ $t("notifications.destination.delete") }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Dispatcher } from "@/types/notifications";
|
||||
import DestinationForm from "./DestinationForm.vue";
|
||||
|
||||
const { destination, onUpdated, existingDispatchers } = defineProps<{
|
||||
destination: Dispatcher;
|
||||
onUpdated?: () => void;
|
||||
existingDispatchers: Dispatcher[];
|
||||
}>();
|
||||
|
||||
const showDrawer = useDrawer();
|
||||
|
||||
function editDestination() {
|
||||
showDrawer(
|
||||
DestinationForm,
|
||||
{
|
||||
destination,
|
||||
onCreated: onUpdated,
|
||||
existingDispatchers,
|
||||
},
|
||||
"md",
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteDestination() {
|
||||
await fetch(withBase(`/api/notifications/dispatchers/${destination.id}`), { method: "DELETE" });
|
||||
onUpdated?.();
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="space-y-4 p-4">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold">
|
||||
{{
|
||||
isEditing
|
||||
? $t("notifications.destination-form.edit-title")
|
||||
: $t("notifications.destination-form.create-title")
|
||||
}}
|
||||
</h2>
|
||||
<p class="text-base-content/60">{{ $t("notifications.destination-form.description") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Link Success Alert -->
|
||||
<div v-if="showLinkSuccess" class="alert alert-success">
|
||||
<mdi:check-circle class="text-lg" />
|
||||
<div>
|
||||
<div class="font-semibold">{{ $t("notifications.cloud-link-success.title") }}</div>
|
||||
<div class="text-sm">{{ $t("notifications.cloud-link-success.message") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type Selection -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.type") }}</legend>
|
||||
<div class="space-y-3">
|
||||
<label
|
||||
class="card card-border 20 cursor-pointer transition-colors"
|
||||
:class="type === 'webhook' ? 'border-primary bg-primary/10' : ''"
|
||||
>
|
||||
<div class="card-body flex-row items-center gap-3 p-4">
|
||||
<input type="radio" v-model="type" value="webhook" class="radio radio-primary" />
|
||||
<div>
|
||||
<div class="font-semibold">{{ $t("notifications.destination-form.webhook-title") }}</div>
|
||||
<div class="text-base-content/60 text-sm">
|
||||
{{ $t("notifications.destination-form.webhook-description") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
class="card card-border border-base-content/20 transition-colors"
|
||||
:class="[
|
||||
type === 'cloud' ? 'border-primary bg-primary/10' : '',
|
||||
hasExistingCloudDestination && type !== 'cloud' ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
|
||||
]"
|
||||
>
|
||||
<div class="card-body flex-row items-center gap-3 p-4">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="type"
|
||||
value="cloud"
|
||||
class="radio radio-primary"
|
||||
:disabled="hasExistingCloudDestination && type !== 'cloud'"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-semibold">{{ $t("notifications.destination-form.cloud-title") }}</div>
|
||||
<div class="text-base-content/60 text-sm">
|
||||
{{ $t("notifications.destination-form.cloud-description") }}
|
||||
</div>
|
||||
<div v-if="hasExistingCloudDestination && type !== 'cloud'" class="text-warning mt-1 text-xs">
|
||||
{{ $t("notifications.destination-form.cloud-exists") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Type-specific form -->
|
||||
<WebhookDestinationForm
|
||||
v-if="type === 'webhook'"
|
||||
:destination="destination"
|
||||
:close="close"
|
||||
:on-created="onCreated"
|
||||
:is-editing="isEditing"
|
||||
/>
|
||||
<CloudDestinationForm v-else :destination="destination" :close="close" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Dispatcher } from "@/types/notifications";
|
||||
import WebhookDestinationForm from "./WebhookDestinationForm.vue";
|
||||
import CloudDestinationForm from "./CloudDestinationForm.vue";
|
||||
|
||||
const {
|
||||
close,
|
||||
onCreated,
|
||||
destination,
|
||||
existingDispatchers = [],
|
||||
showLinkSuccess = false,
|
||||
} = defineProps<{
|
||||
close?: () => void;
|
||||
onCreated?: () => void;
|
||||
destination?: Dispatcher;
|
||||
existingDispatchers?: Dispatcher[];
|
||||
showLinkSuccess?: boolean;
|
||||
}>();
|
||||
|
||||
const isEditing = !!destination;
|
||||
const type = ref<"webhook" | "cloud">((destination?.type as "webhook" | "cloud") ?? "webhook");
|
||||
|
||||
const hasExistingCloudDestination = computed(() => {
|
||||
const others = isEditing ? existingDispatchers.filter((d) => d.id !== destination!.id) : existingDispatchers;
|
||||
return others.some((d) => d.type === "cloud");
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.event-filter") }}</legend>
|
||||
<div
|
||||
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
|
||||
:class="eventExpression.trim() && !eventError ? 'input-primary' : { 'input-error!': eventError }"
|
||||
>
|
||||
<div ref="editorRef" class="w-full"></div>
|
||||
</div>
|
||||
<div v-if="eventError || eventExpression" class="fieldset-label">
|
||||
<span v-if="eventError" class="text-error">{{ eventError }}</span>
|
||||
<span v-else class="text-success">
|
||||
<mdi:check class="inline" />
|
||||
{{ $t("notifications.alert-form.expression-valid") }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
{{
|
||||
$t("notifications.alert-form.event-fields-hint", {
|
||||
fields: "name (start, stop, die, restart, health_status), attributes (exitCode, signal, etc.)",
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<CooldownField v-model="cooldown" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { createEventHints } from "@/composable/exprEditor";
|
||||
import type { NotificationRule, PreviewResult } from "@/types/notifications";
|
||||
|
||||
const props = defineProps<{
|
||||
alert?: NotificationRule;
|
||||
prefill?: { eventExpression?: string };
|
||||
containerExpression: string;
|
||||
isLoading: boolean;
|
||||
validatePreview: (extra: Record<string, unknown>) => Promise<{ data: PreviewResult | null }>;
|
||||
}>();
|
||||
|
||||
const eventExpression = ref(props.alert?.eventExpression ?? props.prefill?.eventExpression ?? "");
|
||||
const eventError = ref<string | null>(null);
|
||||
const cooldown = ref(props.alert?.cooldown ?? 10);
|
||||
|
||||
const canSave = computed(() => !!eventExpression.value.trim() && !eventError.value);
|
||||
const typeFields = computed(() => ({
|
||||
eventExpression: eventExpression.value,
|
||||
logExpression: "",
|
||||
metricExpression: "",
|
||||
cooldown: cooldown.value,
|
||||
sampleWindow: 0,
|
||||
}));
|
||||
|
||||
defineExpose({ canSave, typeFields });
|
||||
|
||||
// Validation
|
||||
async function validate() {
|
||||
if (!props.containerExpression && !eventExpression.value) {
|
||||
eventError.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await props.validatePreview({
|
||||
eventExpression: eventExpression.value || undefined,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
eventError.value = data.eventError ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedValidate = useDebounceFn(validate, 500);
|
||||
watch(
|
||||
[() => props.containerExpression, eventExpression],
|
||||
() => {
|
||||
debouncedValidate();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Editor
|
||||
const editorRef = ref<HTMLElement>();
|
||||
useExprEditorField(editorRef, {
|
||||
placeholder: 'name == "die"',
|
||||
initialValue: props.alert?.eventExpression ?? props.prefill?.eventExpression ?? "",
|
||||
getHints: () => createEventHints(),
|
||||
onChange: (v) => (eventExpression.value = v),
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.log-filter") }}</legend>
|
||||
<div
|
||||
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
|
||||
:class="logExpression.trim() && !logError ? 'input-primary' : { 'input-error!': logError }"
|
||||
>
|
||||
<div ref="editorRef" class="w-full"></div>
|
||||
</div>
|
||||
<div v-if="logError || logExpression" class="fieldset-label">
|
||||
<span v-if="logError" class="text-error">{{ logError }}</span>
|
||||
<span v-else-if="logMessages.length" class="text-success">
|
||||
<mdi:check class="inline" />
|
||||
{{ $t("notifications.alert-form.logs-match", { count: logTotalCount }) }}
|
||||
</span>
|
||||
<span v-else-if="!props.isLoading" class="text-warning">
|
||||
<mdi:alert class="inline" />
|
||||
{{ $t("notifications.alert-form.no-logs-match") }}
|
||||
</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 { 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 editorRef = ref<HTMLElement>();
|
||||
useExprEditorField(editorRef, {
|
||||
placeholder: 'level == "error" && message contains "timeout"',
|
||||
initialValue: props.alert?.logExpression ?? props.prefill?.logExpression ?? "",
|
||||
getHints: () => createLogHints(messageKeys.value),
|
||||
onChange: (v) => (logExpression.value = v),
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.metric-filter") }}</legend>
|
||||
<div
|
||||
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
|
||||
:class="metricExpression.trim() && !metricError ? 'input-primary' : { 'input-error!': metricError }"
|
||||
>
|
||||
<div ref="editorRef" class="w-full"></div>
|
||||
</div>
|
||||
<div v-if="metricError || metricExpression" class="fieldset-label">
|
||||
<span v-if="metricError" class="text-error">{{ metricError }}</span>
|
||||
<span v-else class="text-success">
|
||||
<mdi:check class="inline" />
|
||||
{{ $t("notifications.alert-form.expression-valid") }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
{{
|
||||
$t("notifications.alert-form.metric-fields-hint", {
|
||||
fields: "cpu (CPU %), memory (memory %), memoryUsage (bytes)",
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.sample-window-label") }}</legend>
|
||||
<input v-model.number="sampleWindow" type="range" min="15" max="300" step="15" class="range range-primary" />
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
{{
|
||||
$t("notifications.alert-form.sample-window-hint", {
|
||||
duration: formatDuration(sampleWindow, locale || undefined),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<CooldownField v-model="cooldown" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { 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 sampleWindow = ref(props.alert?.sampleWindow ?? 15);
|
||||
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,
|
||||
sampleWindow: sampleWindow.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 editorRef = ref<HTMLElement>();
|
||||
useExprEditorField(editorRef, {
|
||||
placeholder: "cpu > 80 || memory > 90",
|
||||
initialValue: props.alert?.metricExpression ?? props.prefill?.metricExpression ?? "",
|
||||
getHints: () => createMetricHints(),
|
||||
onChange: (v) => (metricExpression.value = v),
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Name -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.name") }}</legend>
|
||||
<input
|
||||
ref="nameInput"
|
||||
v-model="name"
|
||||
type="text"
|
||||
class="input focus:input-primary w-full text-base"
|
||||
required
|
||||
:class="{ 'input-primary': name.trim().length > 0 }"
|
||||
:placeholder="$t('notifications.destination-form.name-placeholder')"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<!-- Webhook URL -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.webhook-url") }}</legend>
|
||||
<input
|
||||
v-model="webhookUrl"
|
||||
type="url"
|
||||
class="input focus:input-primary w-full text-base"
|
||||
:class="{ 'input-primary': isValidUrl, 'input-error': webhookUrl.trim() && !isValidUrl }"
|
||||
:placeholder="$t('notifications.destination-form.webhook-url-placeholder')"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<!-- Payload Format (create mode only) -->
|
||||
<fieldset v-if="!isEditing" class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.payload-format") }}</legend>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="format in ['slack', 'discord', 'ntfy', 'custom'] as const"
|
||||
:key="format"
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="payloadFormat === format ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="selectPayloadFormat(format)"
|
||||
>
|
||||
{{ $t(`notifications.destination-form.format-${format}`) }}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Template -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">
|
||||
{{ $t("notifications.destination-form.template") }}
|
||||
<span class="text-base-content/60 ml-2 text-sm font-normal">{{
|
||||
$t("notifications.destination-form.template-hint")
|
||||
}}</span>
|
||||
</legend>
|
||||
<div
|
||||
ref="templateEditorRef"
|
||||
class="border-base-content/20 focus-within:border-primary min-h-48 w-full overflow-auto rounded-lg border"
|
||||
></div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Custom Headers -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">
|
||||
{{ $t("notifications.destination-form.headers") }}
|
||||
<span class="text-base-content/60 ml-2 text-sm font-normal">{{
|
||||
$t("notifications.destination-form.headers-hint")
|
||||
}}</span>
|
||||
</legend>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(header, index) in headers" :key="header.key" class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="header.name"
|
||||
type="text"
|
||||
class="input focus:input-primary flex-1 text-base"
|
||||
:placeholder="$t('notifications.destination-form.header-name')"
|
||||
/>
|
||||
<input
|
||||
v-model="header.value"
|
||||
type="text"
|
||||
class="input focus:input-primary flex-1 text-base"
|
||||
:placeholder="$t('notifications.destination-form.header-value')"
|
||||
/>
|
||||
<button type="button" class="btn btn-ghost btn-sm btn-square" @click="headers.splice(index, 1)">
|
||||
<carbon:close />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
@click="headers.push({ name: '', value: '', key: headerKeyCounter++ })"
|
||||
>
|
||||
<carbon:add />
|
||||
{{ $t("notifications.destination-form.add-header") }}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="alert alert-error">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Test Result -->
|
||||
<div v-if="testResult" class="alert" :class="testResult.success ? 'alert-success' : 'alert-error'">
|
||||
<span v-if="testResult.success">
|
||||
{{ $t("notifications.destination-form.test-success") }}
|
||||
<span v-if="testResult.statusCode" class="opacity-70">({{ testResult.statusCode }})</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ testResult.error }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 pt-4">
|
||||
<button class="btn" @click="testDestination" :disabled="!canTest || !isValidUrl || isTesting">
|
||||
<span v-if="isTesting" class="loading loading-spinner loading-sm"></span>
|
||||
{{ $t("notifications.destination-form.test") }}
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button class="btn" @click="close?.()">
|
||||
{{ $t("notifications.destination-form.cancel") }}
|
||||
</button>
|
||||
<button class="btn btn-primary" :disabled="!canSave" @click="saveDestination">
|
||||
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
|
||||
{{ isEditing ? $t("notifications.destination-form.save") : $t("notifications.destination-form.add") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Dispatcher, TestWebhookResult } from "@/types/notifications";
|
||||
import { createTemplateEditor } from "@/composable/templateEditor";
|
||||
import { PAYLOAD_TEMPLATES, type PayloadFormat } from "./payloadTemplates";
|
||||
|
||||
const { close, onCreated, destination, isEditing } = defineProps<{
|
||||
close?: () => void;
|
||||
onCreated?: () => void;
|
||||
destination?: Dispatcher;
|
||||
isEditing: boolean;
|
||||
}>();
|
||||
|
||||
const nameInput = ref<HTMLInputElement>();
|
||||
const templateEditorRef = ref<HTMLElement>();
|
||||
const name = ref(destination?.name ?? "");
|
||||
useFocus(nameInput, { initialValue: true });
|
||||
const webhookUrl = ref(destination?.url ?? "");
|
||||
const payloadFormat = ref<PayloadFormat>(isEditing ? "custom" : "slack");
|
||||
const template = ref(isEditing ? (destination?.template ?? "") : PAYLOAD_TEMPLATES[payloadFormat.value]);
|
||||
let headerKeyCounter = 0;
|
||||
const headers = ref<{ name: string; value: string; key: number }[]>(
|
||||
destination?.headers
|
||||
? Object.entries(destination.headers).map(([name, value]) => ({ name, value, key: headerKeyCounter++ }))
|
||||
: [],
|
||||
);
|
||||
const isTesting = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const testResult = ref<TestWebhookResult | null>(null);
|
||||
|
||||
let templateEditorView: Awaited<ReturnType<typeof createTemplateEditor>> | undefined;
|
||||
|
||||
function selectPayloadFormat(format: PayloadFormat) {
|
||||
payloadFormat.value = format;
|
||||
template.value = PAYLOAD_TEMPLATES[format];
|
||||
setEditorContent(template.value);
|
||||
}
|
||||
|
||||
function setEditorContent(value: string) {
|
||||
if (!templateEditorView) return;
|
||||
templateEditorView.dispatch({
|
||||
changes: { from: 0, to: templateEditorView.state.doc.length, insert: value },
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!templateEditorRef.value) return;
|
||||
templateEditorView = await createTemplateEditor({
|
||||
parent: templateEditorRef.value,
|
||||
initialValue: template.value,
|
||||
onChange: (v) => (template.value = v),
|
||||
});
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
templateEditorView?.destroy();
|
||||
});
|
||||
|
||||
function headersToRecord(): Record<string, string> | undefined {
|
||||
const filtered = headers.value.filter((h) => h.name.trim() && h.value.trim());
|
||||
if (filtered.length === 0) return undefined;
|
||||
return Object.fromEntries(filtered.map((h) => [h.name.trim(), h.value.trim()]));
|
||||
}
|
||||
|
||||
const canTest = computed(() => webhookUrl.value.trim().length > 0);
|
||||
|
||||
const isValidUrl = computed(() => {
|
||||
try {
|
||||
new URL(webhookUrl.value.trim());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (isSaving.value) return false;
|
||||
if (!name.value.trim()) return false;
|
||||
if (!isValidUrl.value) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
async function testDestination() {
|
||||
if (!canTest.value) return;
|
||||
|
||||
isTesting.value = true;
|
||||
testResult.value = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(withBase("/api/notifications/test-webhook"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: webhookUrl.value.trim(),
|
||||
template: template.value.trim() || undefined,
|
||||
headers: headersToRecord(),
|
||||
}),
|
||||
});
|
||||
|
||||
const data: TestWebhookResult = await res.json();
|
||||
testResult.value = data;
|
||||
} catch (e) {
|
||||
testResult.value = { success: false, error: e instanceof Error ? e.message : "Test failed" };
|
||||
} finally {
|
||||
isTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDestination() {
|
||||
if (!canSave.value) return;
|
||||
|
||||
isSaving.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const input = {
|
||||
name: name.value.trim(),
|
||||
type: "webhook",
|
||||
url: webhookUrl.value.trim(),
|
||||
template: template.value.trim() || undefined,
|
||||
headers: headersToRecord(),
|
||||
};
|
||||
|
||||
const url = isEditing
|
||||
? withBase(`/api/notifications/dispatchers/${destination!.id}`)
|
||||
: withBase("/api/notifications/dispatchers");
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: isEditing ? "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 destination");
|
||||
}
|
||||
|
||||
onCreated?.();
|
||||
close?.();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Failed to save destination";
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,63 @@
|
||||
export type PayloadFormat = "slack" | "discord" | "ntfy" | "custom";
|
||||
|
||||
export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
|
||||
slack: JSON.stringify(
|
||||
{
|
||||
text: "{{ .Container.Name }}",
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "*{{ .Container.Name }}*\n{{ .Detail }}",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: "Host: {{ .Container.HostName }} | Image: {{ .Container.Image }}",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
discord: JSON.stringify(
|
||||
{
|
||||
content: "{{ .Container.Name }}",
|
||||
embeds: [
|
||||
{
|
||||
title: "{{ .Container.Name }}",
|
||||
description: "{{ .Detail }}",
|
||||
fields: [
|
||||
{ name: "Host", value: "{{ .Container.HostName }}", inline: true },
|
||||
{ name: "Image", value: "{{ .Container.Image }}", inline: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
ntfy: JSON.stringify(
|
||||
{
|
||||
topic: "dozzle-{{ .Container.HostName }}",
|
||||
title: "{{ .Container.Name }}",
|
||||
message: "{{ .Detail }}",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
custom: JSON.stringify(
|
||||
{
|
||||
container: "{{ .Container.Name }}",
|
||||
message: "{{ .Detail }}",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
};
|
||||
@@ -6,7 +6,7 @@
|
||||
>
|
||||
<slot name="header"></slot>
|
||||
</header>
|
||||
<main :data-scrolling="scrollable ? true : undefined" class="snap-y overflow-auto">
|
||||
<main :data-scrolling="scrollable ? true : undefined" class="min-h-[300px] snap-y overflow-auto">
|
||||
<div class="invisible relative md:visible" v-show="scrollContext.paused">
|
||||
<div class="absolute top-4 right-44">
|
||||
<ScrollProgress
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<ph:stack-simple />
|
||||
<ContainerDropdown :containers="service.containers">{{ service.name }}</ContainerDropdown>
|
||||
<MultiContainerStat class="ml-auto" :containers="service.containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" :name="service.name" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</ContainerDropdown>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="stack.containers" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
|
||||
<MultiContainerActionToolbar class="max-md:hidden" :name="stack.name" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<mdi:close class="swap-on" />
|
||||
</button>
|
||||
</form>
|
||||
<slot v-if="open"></slot>
|
||||
<slot v-if="open" :close="close"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
@@ -28,11 +28,16 @@ const { width } = defineProps<{
|
||||
width: DrawerWidth;
|
||||
}>();
|
||||
|
||||
function close() {
|
||||
panel.value?.close();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open: () => {
|
||||
open.value = true;
|
||||
panel.value?.showModal();
|
||||
},
|
||||
close,
|
||||
});
|
||||
|
||||
useEventListener(panel, "close", () => (open.value = false));
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Container } from "@/models/Container";
|
||||
import type { ContainerJson } from "@/types/Container";
|
||||
import type { Dispatcher, NotificationRule, PreviewResult } from "@/types/notifications";
|
||||
import { createContainerHints } from "@/composable/exprEditor";
|
||||
|
||||
export interface AlertFormOptions {
|
||||
close?: () => void;
|
||||
onCreated?: () => void;
|
||||
alert?: NotificationRule;
|
||||
prefill?: {
|
||||
name?: string;
|
||||
containerExpression?: string;
|
||||
logExpression?: string;
|
||||
metricExpression?: string;
|
||||
eventExpression?: 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,
|
||||
);
|
||||
|
||||
function setupContainerEditor(editorRef: Ref<HTMLElement | undefined>) {
|
||||
useExprEditorField(editorRef, {
|
||||
placeholder: 'name contains "api"',
|
||||
initialValue: options.alert?.containerExpression ?? options.prefill?.containerExpression ?? "",
|
||||
getHints: () => createContainerHints(containerNames.value, imageNames.value, hostNames.value),
|
||||
onChange: (v) => (containerExpression.value = v),
|
||||
});
|
||||
}
|
||||
|
||||
async function saveAlert(typeSpecificFields: Record<string, unknown>) {
|
||||
isSaving.value = true;
|
||||
saveError.value = null;
|
||||
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,
|
||||
setupContainerEditor,
|
||||
saveAlert,
|
||||
validatePreview,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export function useDownloadUrl(
|
||||
containers: Ref<Container[]> | ComputedRef<Container[]>,
|
||||
streamConfig: { stdout: boolean; stderr: boolean } | Ref<{ stdout: boolean; stderr: boolean }>,
|
||||
levels: Ref<Set<string>>,
|
||||
name?: Ref<string> | ComputedRef<string> | string,
|
||||
) {
|
||||
const { debouncedSearchFilter } = useSearchFilter();
|
||||
|
||||
@@ -27,6 +28,11 @@ export function useDownloadUrl(
|
||||
selectedLevels.forEach((level) => params.append("levels", level));
|
||||
}
|
||||
|
||||
const nameValue = toValue(name);
|
||||
if (nameValue) {
|
||||
params.append("name", nameValue);
|
||||
}
|
||||
|
||||
const containerIds = toValue(containers)
|
||||
.map((c) => c.host + "~" + c.id)
|
||||
.join(",");
|
||||
|
||||
@@ -260,7 +260,12 @@ export async function loadBetween(
|
||||
params: Ref<URLSearchParams>,
|
||||
from: Date,
|
||||
to: Date,
|
||||
{ lastSeenId, min, maxStart }: { lastSeenId?: number; min?: number; maxStart?: number } = {},
|
||||
{
|
||||
lastSeenId,
|
||||
startId,
|
||||
min,
|
||||
maxStart,
|
||||
}: { lastSeenId?: number; startId?: number; min?: number; maxStart?: number } = {},
|
||||
) {
|
||||
const url = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs`);
|
||||
const abortController = new AbortController();
|
||||
@@ -279,6 +284,9 @@ export async function loadBetween(
|
||||
if (lastSeenId) {
|
||||
loadMoreParams.append("lastSeenId", String(lastSeenId));
|
||||
}
|
||||
if (startId) {
|
||||
loadMoreParams.append("startId", String(startId));
|
||||
}
|
||||
return withBase(`${url.value}?${loadMoreParams.toString()}`);
|
||||
});
|
||||
const stopWatcher = watchOnce(urlWithMoreParams, () => abortController.abort("stream changed"));
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { Completion } from "@codemirror/autocomplete";
|
||||
|
||||
export interface ExprEditorOptions {
|
||||
parent: HTMLElement;
|
||||
placeholder: string;
|
||||
initialValue: string;
|
||||
getHints: () => Completion[];
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
// Common operators for expr language
|
||||
const exprOperators: Completion[] = [
|
||||
{ label: "==", detail: "equals", type: "operator" },
|
||||
{ label: "!=", detail: "not equals", type: "operator" },
|
||||
{ label: "contains", detail: "string contains", type: "keyword" },
|
||||
{ label: "startsWith", detail: "string starts with", type: "keyword" },
|
||||
{ label: "endsWith", detail: "string ends with", type: "keyword" },
|
||||
{ label: "matches", detail: "regex match", type: "keyword" },
|
||||
{ label: "&&", detail: "logical AND", type: "operator" },
|
||||
{ label: "||", detail: "logical OR", type: "operator" },
|
||||
{ label: "!", detail: "logical NOT", type: "operator" },
|
||||
{ label: "in", detail: "membership test", type: "keyword" },
|
||||
{ label: "not in", detail: "negative membership", type: "keyword" },
|
||||
];
|
||||
|
||||
export function createContainerHints(
|
||||
containerNames: string[],
|
||||
imageNames: string[],
|
||||
hostNames: string[],
|
||||
): Completion[] {
|
||||
return [
|
||||
{ label: "name", detail: "container name", type: "property" },
|
||||
{ label: "id", detail: "container ID", type: "property" },
|
||||
{ label: "image", detail: "container image", type: "property" },
|
||||
{ label: "state", detail: "running, exited, etc.", type: "property" },
|
||||
{ label: "health", detail: "healthy, unhealthy, none", type: "property" },
|
||||
{ label: "host", detail: "docker host", type: "property" },
|
||||
{ label: "labels", detail: "container labels map", type: "property" },
|
||||
...exprOperators,
|
||||
{ label: '"running"', detail: "state value", type: "string" },
|
||||
{ label: '"exited"', detail: "state value", type: "string" },
|
||||
{ label: '"created"', detail: "state value", type: "string" },
|
||||
{ label: '"paused"', detail: "state value", type: "string" },
|
||||
{ label: '"healthy"', detail: "health value", type: "string" },
|
||||
{ label: '"unhealthy"', detail: "health value", type: "string" },
|
||||
{ label: '"none"', detail: "health value", type: "string" },
|
||||
...containerNames.map((name) => ({ label: `"${name}"`, detail: "container name", type: "string" }) as Completion),
|
||||
...imageNames.map((image) => ({ label: `"${image}"`, detail: "image name", type: "string" }) as Completion),
|
||||
...hostNames.map((host) => ({ label: `"${host}"`, detail: "host name", type: "string" }) as Completion),
|
||||
];
|
||||
}
|
||||
|
||||
export function createLogHints(messageKeys?: string[]): Completion[] {
|
||||
return [
|
||||
{ label: "message", detail: "log message content", type: "property" },
|
||||
{ label: "level", detail: "log level", type: "property" },
|
||||
{ label: "stream", detail: "stdout or stderr", type: "property" },
|
||||
{ label: "type", detail: "log type", type: "property" },
|
||||
{ label: "timestamp", detail: "unix timestamp", type: "property" },
|
||||
{ label: "id", detail: "log entry ID", type: "property" },
|
||||
...(messageKeys ?? []).map(
|
||||
(key) => ({ label: `message.${key}`, detail: "message field", type: "property" }) as Completion,
|
||||
),
|
||||
...exprOperators,
|
||||
{ label: '"error"', detail: "level value", type: "string" },
|
||||
{ label: '"warn"', detail: "level value", type: "string" },
|
||||
{ label: '"info"', detail: "level value", type: "string" },
|
||||
{ label: '"debug"', detail: "level value", type: "string" },
|
||||
{ label: '"trace"', detail: "level value", type: "string" },
|
||||
{ label: '"stdout"', detail: "stream value", type: "string" },
|
||||
{ label: '"stderr"', detail: "stream value", type: "string" },
|
||||
{ label: 'level == "error"', detail: "match error logs", type: "text", boost: 10 },
|
||||
{ label: 'message contains ""', detail: "search in message", type: "text", boost: 10 },
|
||||
{ label: 'stream == "stderr"', detail: "match stderr", type: "text", boost: 10 },
|
||||
];
|
||||
}
|
||||
|
||||
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 },
|
||||
];
|
||||
}
|
||||
|
||||
export function createEventHints(): Completion[] {
|
||||
return [
|
||||
{ label: "name", detail: "event name", type: "property" },
|
||||
{ label: "attributes", detail: "event attributes map", type: "property" },
|
||||
...exprOperators,
|
||||
{ label: '"start"', detail: "container started", type: "string" },
|
||||
{ label: '"stop"', detail: "container stopped", type: "string" },
|
||||
{ label: '"die"', detail: "container died", type: "string" },
|
||||
{ label: '"restart"', detail: "container restarted", type: "string" },
|
||||
{ label: '"health_status"', detail: "health check changed", type: "string" },
|
||||
{ label: 'name == "die"', detail: "match container death", type: "text", boost: 10 },
|
||||
{ label: 'name == "health_status"', detail: "match health changes", type: "text", boost: 10 },
|
||||
{ label: 'name in ["stop", "die"]', detail: "match stop or death", type: "text", boost: 10 },
|
||||
];
|
||||
}
|
||||
|
||||
function createAutocomplete(getHints: () => Completion[]) {
|
||||
return (context: any) => {
|
||||
const word = context.matchBefore(/[\w"=!&|]+/);
|
||||
if (!word && !context.explicit) return null;
|
||||
|
||||
const currentWord = word ? word.text.toLowerCase() : "";
|
||||
const hints = getHints();
|
||||
const filtered = currentWord ? hints.filter((h) => h.label.toLowerCase().includes(currentWord)) : hints;
|
||||
|
||||
return { from: word ? word.from : context.pos, options: filtered };
|
||||
};
|
||||
}
|
||||
|
||||
export async function createExprEditor(options: ExprEditorOptions) {
|
||||
const [
|
||||
{ EditorView, keymap, placeholder },
|
||||
{ EditorState },
|
||||
{ autocompletion, completionKeymap },
|
||||
{ HighlightStyle, syntaxHighlighting },
|
||||
{ tags },
|
||||
] = await Promise.all([
|
||||
import("@codemirror/view"),
|
||||
import("@codemirror/state"),
|
||||
import("@codemirror/autocomplete"),
|
||||
import("@codemirror/language"),
|
||||
import("@lezer/highlight"),
|
||||
]);
|
||||
|
||||
const editorTheme = EditorView.theme({
|
||||
"&": {
|
||||
backgroundColor: "var(--color-base-100)",
|
||||
color: "var(--color-base-content)",
|
||||
},
|
||||
".cm-content": {
|
||||
caretColor: "var(--color-primary)",
|
||||
},
|
||||
".cm-cursor": {
|
||||
borderLeftColor: "var(--color-primary)",
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
|
||||
backgroundColor: "var(--color-base-300)",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "color-mix(in oklch, var(--color-base-200) 50%, transparent)",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "var(--color-base-200)",
|
||||
color: "color-mix(in oklch, var(--color-base-content) 50%, transparent)",
|
||||
border: "none",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "var(--color-base-300)",
|
||||
},
|
||||
});
|
||||
|
||||
const highlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "var(--color-primary)" },
|
||||
{ tag: tags.operator, color: "var(--color-secondary)" },
|
||||
{ tag: tags.string, color: "var(--color-success)" },
|
||||
{ tag: tags.number, color: "var(--color-warning)" },
|
||||
{ tag: tags.bool, color: "var(--color-warning)" },
|
||||
{ tag: tags.propertyName, color: "var(--color-info)" },
|
||||
{ tag: tags.variableName, color: "var(--color-base-content)" },
|
||||
{
|
||||
tag: tags.comment,
|
||||
color: "color-mix(in oklch, var(--color-base-content) 50%, transparent)",
|
||||
fontStyle: "italic",
|
||||
},
|
||||
]);
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: options.initialValue,
|
||||
extensions: [
|
||||
EditorView.lineWrapping,
|
||||
placeholder(options.placeholder),
|
||||
autocompletion({
|
||||
override: [createAutocomplete(options.getHints)],
|
||||
activateOnTyping: true,
|
||||
}),
|
||||
keymap.of(completionKeymap),
|
||||
editorTheme,
|
||||
syntaxHighlighting(highlightStyle),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged && options.onChange) {
|
||||
options.onChange(update.view.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return new EditorView({ state, parent: options.parent });
|
||||
}
|
||||
@@ -96,6 +96,7 @@ export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalCon
|
||||
const item = messages.value.at(-2)!;
|
||||
const { logs, signal } = await loadBetween(container, params, item.date, new Date(), {
|
||||
maxStart: 100,
|
||||
startId: item.id,
|
||||
});
|
||||
|
||||
if (signal.aborted) {
|
||||
|
||||
@@ -39,7 +39,7 @@ export function useProfileStorage<K extends keyof Profile>(
|
||||
}
|
||||
}
|
||||
|
||||
if (config.user) {
|
||||
if (config.user || config.authProvider === "none") {
|
||||
watch(
|
||||
storage,
|
||||
(value) => {
|
||||
@@ -56,6 +56,8 @@ export function useProfileStorage<K extends keyof Profile>(
|
||||
return value;
|
||||
}
|
||||
}),
|
||||
}).catch((e) => {
|
||||
console.error(`Failed to sync ${key} to profile`, e);
|
||||
});
|
||||
},
|
||||
{ deep: true },
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
export interface TemplateEditorOptions {
|
||||
parent: HTMLElement;
|
||||
initialValue: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export async function createTemplateEditor(options: TemplateEditorOptions) {
|
||||
const [{ EditorView }, { EditorState }, { json }, { HighlightStyle, syntaxHighlighting }, { tags }] =
|
||||
await Promise.all([
|
||||
import("@codemirror/view"),
|
||||
import("@codemirror/state"),
|
||||
import("@codemirror/lang-json"),
|
||||
import("@codemirror/language"),
|
||||
import("@lezer/highlight"),
|
||||
]);
|
||||
|
||||
const editorTheme = EditorView.theme({
|
||||
"&": {
|
||||
backgroundColor: "var(--color-base-100)",
|
||||
color: "var(--color-base-content)",
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
".cm-content": {
|
||||
caretColor: "var(--color-primary)",
|
||||
fontFamily: "ui-monospace, monospace",
|
||||
},
|
||||
".cm-cursor": {
|
||||
borderLeftColor: "var(--color-primary)",
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
|
||||
backgroundColor: "var(--color-base-300)",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "color-mix(in oklch, var(--color-base-200) 50%, transparent)",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "var(--color-base-200)",
|
||||
color: "color-mix(in oklch, var(--color-base-content) 50%, transparent)",
|
||||
border: "none",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "var(--color-base-300)",
|
||||
},
|
||||
});
|
||||
|
||||
const highlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.propertyName, color: "var(--color-info)" },
|
||||
{ tag: tags.string, color: "var(--color-success)" },
|
||||
{ tag: tags.number, color: "var(--color-warning)" },
|
||||
{ tag: tags.bool, color: "var(--color-warning)" },
|
||||
{ tag: tags.null, color: "var(--color-secondary)" },
|
||||
{ tag: tags.punctuation, color: "var(--color-base-content)" },
|
||||
]);
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: options.initialValue,
|
||||
extensions: [
|
||||
EditorView.lineWrapping,
|
||||
json(),
|
||||
editorTheme,
|
||||
syntaxHighlighting(highlightStyle),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged && options.onChange) {
|
||||
options.onChange(update.view.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return new EditorView({ state, parent: options.parent });
|
||||
}
|
||||
@@ -3,7 +3,7 @@ type Toast = {
|
||||
createdAt: Date;
|
||||
title?: string;
|
||||
message: string;
|
||||
type: "success" | "error" | "warning" | "info";
|
||||
type: "error" | "warning" | "info";
|
||||
action?: {
|
||||
label: string;
|
||||
handler: () => void;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createExprEditor } from "@/composable/exprEditor";
|
||||
|
||||
type ExprEditorOptions = Parameters<typeof createExprEditor>[0];
|
||||
|
||||
export function useExprEditorField(
|
||||
editorRef: Ref<HTMLElement | undefined>,
|
||||
options: Omit<ExprEditorOptions, "parent">,
|
||||
) {
|
||||
let editorView: Awaited<ReturnType<typeof createExprEditor>> | undefined;
|
||||
|
||||
onMounted(async () => {
|
||||
if (editorRef.value) {
|
||||
editorView = await createExprEditor({
|
||||
parent: editorRef.value,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
editorView?.destroy();
|
||||
});
|
||||
}
|
||||
@@ -42,10 +42,10 @@
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
<SideDrawer ref="drawer" :width="drawerWidth">
|
||||
<SideDrawer ref="drawer" :width="drawerWidth" v-slot="{ close }">
|
||||
<Suspense :timeout="0">
|
||||
<component :is="drawerComponent" v-bind="drawerProperties" />
|
||||
<template #fallback> Loading dependencies... </template>
|
||||
<component :is="drawerComponent" v-bind="drawerProperties" :close="close" />
|
||||
<template #fallback> <span class="loading loading-spinner loading-sm"></span></template>
|
||||
</Suspense>
|
||||
</SideDrawer>
|
||||
<ToastModal />
|
||||
|
||||
+42
-1
@@ -8,7 +8,7 @@
|
||||
--color-red: oklch(64% 0.218 28.85);
|
||||
--color-purple: oklch(51.49% 0.215 321.03);
|
||||
--color-blue: oklch(65% 0.171 249.5);
|
||||
--color-orange: oklch(70% 0.186 48.13);
|
||||
--color-orange: oklch(85% 0.186 48.13);
|
||||
}
|
||||
|
||||
@utility pt-safe {
|
||||
@@ -151,3 +151,44 @@ body {
|
||||
.splitpanes--vertical .splitpanes__pane {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* CodeMirror autocomplete tooltip styles */
|
||||
.cm-tooltip {
|
||||
@apply bg-base-200! border-base-content/40! min-w-96 rounded-sm border shadow-md;
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete ul {
|
||||
@apply font-sans;
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete ul li {
|
||||
@apply my-1 px-2;
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete ul li[aria-selected] {
|
||||
@apply bg-primary/20 text-base-content!;
|
||||
}
|
||||
|
||||
.cm-completionLabel {
|
||||
@apply text-base-content!;
|
||||
}
|
||||
|
||||
.cm-completionDetail {
|
||||
@apply text-base-content/60! ml-2 italic;
|
||||
}
|
||||
|
||||
.cm-completionMatchedText {
|
||||
@apply text-primary! font-bold no-underline;
|
||||
}
|
||||
|
||||
.cm-completionIcon {
|
||||
@apply mr-2 opacity-70;
|
||||
}
|
||||
|
||||
.cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
+56
-11
@@ -1,5 +1,4 @@
|
||||
import type { ContainerHealth, ContainerStat, ContainerState } from "@/types/Container";
|
||||
import { useExponentialMovingAverage, useSimpleRefHistory } from "@/utils";
|
||||
import type { ContainerHealth, ContainerJson, ContainerStat, ContainerState } from "@/types/Container";
|
||||
import { Ref } from "vue";
|
||||
|
||||
export type Stat = Omit<ContainerStat, "id">;
|
||||
@@ -50,14 +49,14 @@ export class Container {
|
||||
stats: Stat[],
|
||||
public readonly group?: string,
|
||||
public health?: ContainerHealth,
|
||||
public isNew: boolean = false,
|
||||
) {
|
||||
this._stat = ref(
|
||||
stats.at(-1) || ({ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 } as Stat),
|
||||
);
|
||||
const { history } = useSimpleRefHistory(this._stat, { capacity: 300, deep: true, initial: stats });
|
||||
this._statsHistory = history;
|
||||
const { movingAverage } = useExponentialMovingAverage(this._stat, 0.2);
|
||||
this.movingAverageStat = movingAverage;
|
||||
const defaultStat = { cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 } as Stat;
|
||||
this._stat = ref(stats.at(-1) || defaultStat);
|
||||
const recentStats = stats.slice(-300);
|
||||
const padding = Array(300 - recentStats.length).fill(defaultStat);
|
||||
this._statsHistory = ref([...padding, ...recentStats]);
|
||||
this.movingAverageStat = ref(stats.at(-1) || defaultStat);
|
||||
|
||||
this._name = name;
|
||||
}
|
||||
@@ -85,6 +84,7 @@ export class Container {
|
||||
get namespace() {
|
||||
return (
|
||||
this.labels["dev.dozzle.group"] ||
|
||||
this.labels["coolify.projectName"] ||
|
||||
this.labels["com.docker.stack.namespace"] ||
|
||||
this.labels["com.docker.compose.project"]
|
||||
);
|
||||
@@ -115,11 +115,56 @@ export class Container {
|
||||
}
|
||||
|
||||
public updateStat(stat: Stat) {
|
||||
// When Container is inside a reactive array, refs get unwrapped
|
||||
if (isRef(this._stat)) {
|
||||
this._stat.value = stat;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
this._stat = stat;
|
||||
(this._stat as unknown as Stat) = stat;
|
||||
}
|
||||
|
||||
// Update history directly (no watcher needed)
|
||||
const history = isRef(this._statsHistory) ? this._statsHistory.value : (this._statsHistory as unknown as Stat[]);
|
||||
history.push(stat);
|
||||
if (history.length > 300) {
|
||||
history.shift();
|
||||
}
|
||||
|
||||
// Calculate EMA directly (no watcher needed)
|
||||
const alpha = 0.2;
|
||||
const prev = isRef(this.movingAverageStat)
|
||||
? this.movingAverageStat.value
|
||||
: (this.movingAverageStat as unknown as Stat);
|
||||
const newEma = {
|
||||
cpu: alpha * stat.cpu + (1 - alpha) * prev.cpu,
|
||||
memory: alpha * stat.memory + (1 - alpha) * prev.memory,
|
||||
memoryUsage: alpha * stat.memoryUsage + (1 - alpha) * prev.memoryUsage,
|
||||
networkRxTotal: stat.networkRxTotal,
|
||||
networkTxTotal: stat.networkTxTotal,
|
||||
};
|
||||
if (isRef(this.movingAverageStat)) {
|
||||
this.movingAverageStat.value = newEma;
|
||||
} else {
|
||||
(this.movingAverageStat as unknown as Stat) = newEma;
|
||||
}
|
||||
}
|
||||
|
||||
static fromJSON(c: ContainerJson): Container {
|
||||
return new Container(
|
||||
c.id,
|
||||
new Date(c.created),
|
||||
new Date(c.startedAt),
|
||||
new Date(c.finishedAt),
|
||||
c.image,
|
||||
c.name,
|
||||
c.command,
|
||||
c.host,
|
||||
c.labels,
|
||||
c.state,
|
||||
c.cpuLimit,
|
||||
c.memoryLimit,
|
||||
c.stats ?? [],
|
||||
c.group,
|
||||
c.health,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<PageWithLinks>
|
||||
<section>
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold">{{ $t("notifications.title") }}</h2>
|
||||
<p class="text-base-content/60">{{ $t("notifications.description") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Destinations Section -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-base-content/60 mb-4 font-semibold tracking-wide uppercase">
|
||||
{{ $t("notifications.destinations") }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<DestinationCard
|
||||
v-for="dest in dispatchers"
|
||||
:key="dest.id"
|
||||
:destination="dest"
|
||||
:on-updated="fetchAll"
|
||||
:existing-dispatchers="dispatchers"
|
||||
class="w-full md:w-72"
|
||||
/>
|
||||
<!-- Add Destination Card -->
|
||||
<button
|
||||
class="card card-border border-base-content/30 hover:border-base-content/50 w-full cursor-pointer border-dashed transition-colors md:w-72"
|
||||
@click="openAddDestination"
|
||||
>
|
||||
<div class="card-body items-center justify-center gap-1 p-4">
|
||||
<mdi:plus class="text-2xl" />
|
||||
<span class="text-base-content/60 text-sm">{{ $t("notifications.add-destination") }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alerts Section -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h3 class="text-base-content/60 font-semibold tracking-wide uppercase">{{ $t("notifications.alerts") }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<div class="tabs tabs-box mb-6">
|
||||
<button class="tab" :class="{ 'tab-active': filter === 'all' }" @click="filter = 'all'">
|
||||
{{ $t("notifications.filter.all", { count: alerts.length }) }}
|
||||
</button>
|
||||
<button class="tab" :class="{ 'tab-active': filter === 'enabled' }" @click="filter = 'enabled'">
|
||||
{{ $t("notifications.filter.enabled", { count: enabledCount }) }}
|
||||
</button>
|
||||
<button class="tab" :class="{ 'tab-active': filter === 'paused' }" @click="filter = 'paused'">
|
||||
{{ $t("notifications.filter.paused", { count: pausedCount }) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Alerts List -->
|
||||
<div class="space-y-4">
|
||||
<AlertCard v-for="alert in filteredAlerts" :key="alert.id" :alert="alert" :on-updated="fetchAlerts" />
|
||||
<button
|
||||
class="card card-border border-base-content/30 hover:border-base-content/50 w-full cursor-pointer border-dashed transition-colors"
|
||||
@click="openCreateAlert"
|
||||
>
|
||||
<div class="card-body items-center justify-center gap-1 p-4">
|
||||
<mdi:plus class="text-2xl" />
|
||||
<span class="text-base-content/60 text-sm">{{ $t("notifications.add-alert") }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PageWithLinks>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { NotificationRule, Dispatcher } from "@/types/notifications";
|
||||
import AlertForm from "@/components/Notification/AlertForm.vue";
|
||||
import DestinationForm from "@/components/Notification/DestinationForm.vue";
|
||||
|
||||
const showDrawer = useDrawer();
|
||||
const router = useRouter();
|
||||
|
||||
// State
|
||||
const alerts = ref<NotificationRule[]>([]);
|
||||
const dispatchers = ref<Dispatcher[]>([]);
|
||||
|
||||
async function fetchAlerts() {
|
||||
const res = await fetch(withBase("/api/notifications/rules"));
|
||||
alerts.value = await res.json();
|
||||
}
|
||||
|
||||
async function fetchDispatchers() {
|
||||
const res = await fetch(withBase("/api/notifications/dispatchers"));
|
||||
dispatchers.value = await res.json();
|
||||
}
|
||||
|
||||
async function fetchAll() {
|
||||
await Promise.all([fetchAlerts(), fetchDispatchers()]);
|
||||
}
|
||||
|
||||
// Handle cloudLinkSuccess hash param
|
||||
onMounted(async () => {
|
||||
await fetchAll();
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith("#cloudLinkSuccess=")) {
|
||||
const id = Number(hash.replace("#cloudLinkSuccess=", ""));
|
||||
if (!isNaN(id)) {
|
||||
const destination = dispatchers.value.find((d) => d.id === id);
|
||||
if (destination) {
|
||||
showDrawer(
|
||||
DestinationForm,
|
||||
{
|
||||
destination,
|
||||
existingDispatchers: dispatchers.value,
|
||||
showLinkSuccess: true,
|
||||
},
|
||||
"md",
|
||||
);
|
||||
}
|
||||
}
|
||||
router.replace({ hash: "" });
|
||||
}
|
||||
});
|
||||
|
||||
// Local state
|
||||
const filter = ref<"all" | "enabled" | "paused">("all");
|
||||
|
||||
const enabledCount = computed(() => alerts.value.filter((a) => a.enabled).length);
|
||||
const pausedCount = computed(() => alerts.value.filter((a) => !a.enabled).length);
|
||||
|
||||
const filteredAlerts = computed(() => {
|
||||
if (filter.value === "enabled") return alerts.value.filter((a) => a.enabled);
|
||||
if (filter.value === "paused") return alerts.value.filter((a) => !a.enabled);
|
||||
return alerts.value;
|
||||
});
|
||||
|
||||
function openCreateAlert() {
|
||||
showDrawer(AlertForm, { onCreated: fetchAlerts }, "lg");
|
||||
}
|
||||
|
||||
function openAddDestination() {
|
||||
showDrawer(
|
||||
DestinationForm,
|
||||
{
|
||||
onCreated: fetchDispatchers,
|
||||
existingDispatchers: dispatchers.value,
|
||||
},
|
||||
"md",
|
||||
);
|
||||
}
|
||||
</script>
|
||||
+10
-6
@@ -3,16 +3,20 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const store = useContainerStore();
|
||||
const { visibleContainers } = storeToRefs(store);
|
||||
const { containers } = storeToRefs(store);
|
||||
|
||||
watch(visibleContainers, (newValue) => {
|
||||
watch(containers, (newValue) => {
|
||||
if (newValue) {
|
||||
if (route.query.name) {
|
||||
const [container, _] = visibleContainers.value.filter((c) => c.name == route.query.name);
|
||||
if (container) {
|
||||
router.push({ name: "/container/[id]", params: { id: container.id } });
|
||||
const name = route.query.name as string;
|
||||
const host = route.query.host as string | undefined;
|
||||
const matches = containers.value
|
||||
.filter((c) => c.name == name && (!host || c.host == host))
|
||||
.sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime());
|
||||
if (matches.length > 0) {
|
||||
router.push({ name: "/container/[id]", params: { id: matches[0].id } });
|
||||
} else {
|
||||
console.error(`No containers found matching name=${route.query.name}. Redirecting to /`);
|
||||
console.error(`No containers found matching name=${name}${host ? ` host=${host}` : ""}. Redirecting to /`);
|
||||
router.push({ name: "/" });
|
||||
}
|
||||
} else {
|
||||
|
||||
Vendored
+2
@@ -4,3 +4,5 @@ declare module "*.vue" {
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
declare const __CLOUD_URL__: string;
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
type Announcement = {
|
||||
interface Release {
|
||||
name: string;
|
||||
announcement: boolean;
|
||||
createdAt: Date;
|
||||
body: string;
|
||||
mentionsCount: number;
|
||||
tag: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
htmlUrl: string;
|
||||
latest: boolean;
|
||||
mentionsCount: number;
|
||||
features: number;
|
||||
bugFixes: number;
|
||||
breaking: number;
|
||||
}
|
||||
|
||||
type Announcement = Omit<Release, "createdAt"> & {
|
||||
announcement: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
const releases = ref<Announcement[]>([]);
|
||||
@@ -20,8 +24,14 @@ async function fetchReleases() {
|
||||
fetched = true;
|
||||
|
||||
try {
|
||||
const { data } = await useFetch(withBase("/api/releases")).get().json<Announcement[]>();
|
||||
releases.value = data.value || [];
|
||||
const res = await fetch(withBase("/api/releases"));
|
||||
const data: Release[] = await res.json();
|
||||
releases.value =
|
||||
data?.map((r) => ({
|
||||
...r,
|
||||
createdAt: new Date(r.createdAt),
|
||||
announcement: false,
|
||||
})) || [];
|
||||
} catch (error) {
|
||||
console.error("Error while fetching releases:\n", error);
|
||||
fetched = false;
|
||||
@@ -35,10 +45,7 @@ if (config.releaseCheckMode === "automatic") {
|
||||
const otherAnnouncements = [] as Announcement[];
|
||||
|
||||
const announcements = computed(() => {
|
||||
const newReleases =
|
||||
releases.value?.map((release) => ({ ...release, createdAt: new Date(release.createdAt), announcement: false })) ??
|
||||
[];
|
||||
return [...newReleases, ...otherAnnouncements].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
return [...releases.value, ...otherAnnouncements].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
});
|
||||
|
||||
const mostRecent = computed(() => announcements.value?.[0]);
|
||||
|
||||
+12
-22
@@ -135,33 +135,23 @@ export const useContainerStore = defineStore("container", () => {
|
||||
|
||||
existingContainers.forEach((c) => {
|
||||
const existing = allContainersById.value[c.id];
|
||||
if (ready.value && existing.state !== "running" && c.state === "running") {
|
||||
existing.isNew = true;
|
||||
}
|
||||
existing.state = c.state;
|
||||
existing.health = c.health;
|
||||
existing.name = c.name;
|
||||
});
|
||||
|
||||
containers.value = [
|
||||
...containers.value,
|
||||
...newContainers.map((c) => {
|
||||
return new Container(
|
||||
c.id,
|
||||
new Date(c.created),
|
||||
new Date(c.startedAt),
|
||||
new Date(c.finishedAt),
|
||||
c.image,
|
||||
c.name,
|
||||
c.command,
|
||||
c.host,
|
||||
c.labels,
|
||||
c.state,
|
||||
c.cpuLimit,
|
||||
c.memoryLimit,
|
||||
c.stats,
|
||||
c.group,
|
||||
c.health,
|
||||
);
|
||||
}),
|
||||
];
|
||||
const mapped = newContainers.map((c) => {
|
||||
const container = Container.fromJSON(c);
|
||||
if (ready.value) {
|
||||
container.isNew = true;
|
||||
}
|
||||
return container;
|
||||
});
|
||||
|
||||
containers.value = [...containers.value, ...mapped];
|
||||
};
|
||||
|
||||
const currentContainer = (id: Ref<string>) => computed(() => allContainersById.value[id.value]);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Node v25+ ships a built-in localStorage that lacks the Web Storage API
|
||||
// (getItem/setItem/removeItem). Replace it with a spec-compliant shim so
|
||||
// libraries like @vue/devtools-kit work correctly in tests.
|
||||
const store = new Map<string, string>();
|
||||
|
||||
globalThis.localStorage = {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, String(value)),
|
||||
removeItem: (key: string) => store.delete(key),
|
||||
clear: () => store.clear(),
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
key: (index: number) => [...store.keys()][index] ?? null,
|
||||
} as Storage;
|
||||
Vendored
+32
-13
@@ -1,26 +1,30 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// oxfmt-ignore
|
||||
// @ts-nocheck
|
||||
// noinspection ES6UnusedImports
|
||||
// Generated by unplugin-vue-router. !! DO NOT MODIFY THIS FILE !!
|
||||
// Generated by vue-router. !! DO NOT MODIFY THIS FILE !!
|
||||
// It's recommended to commit this file.
|
||||
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
|
||||
|
||||
declare module 'vue-router/auto-resolver' {
|
||||
export type ParamParserCustom = never
|
||||
import type {
|
||||
RouteRecordInfo,
|
||||
ParamValue,
|
||||
ParamValueOneOrMore,
|
||||
ParamValueZeroOrMore,
|
||||
ParamValueZeroOrOne,
|
||||
} from 'vue-router'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface TypesConfig {
|
||||
ParamParsers:
|
||||
| never
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'vue-router/auto-routes' {
|
||||
import type {
|
||||
RouteRecordInfo,
|
||||
ParamValue,
|
||||
ParamValueOneOrMore,
|
||||
ParamValueZeroOrMore,
|
||||
ParamValueZeroOrOne,
|
||||
} from 'vue-router'
|
||||
|
||||
/**
|
||||
* Route name map generated by unplugin-vue-router
|
||||
* Route name map generated by vue-router
|
||||
*/
|
||||
export interface RouteNamedMap {
|
||||
'/': RouteRecordInfo<
|
||||
@@ -86,6 +90,13 @@ declare module 'vue-router/auto-routes' {
|
||||
{ name: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/notifications': RouteRecordInfo<
|
||||
'/notifications',
|
||||
'/notifications',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/owner/[name]': RouteRecordInfo<
|
||||
'/owner/[name]',
|
||||
'/owner/:name',
|
||||
@@ -124,7 +135,7 @@ declare module 'vue-router/auto-routes' {
|
||||
}
|
||||
|
||||
/**
|
||||
* Route file to route info map by unplugin-vue-router.
|
||||
* Route file to route info map by vue-router.
|
||||
* Used by the \`sfc-typed-router\` Volar plugin to automatically type \`useRoute()\`.
|
||||
*
|
||||
* Each key is a file path relative to the project root with 2 properties:
|
||||
@@ -188,6 +199,12 @@ declare module 'vue-router/auto-routes' {
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/notifications.vue': {
|
||||
routes:
|
||||
| '/notifications'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'assets/pages/owner/[name].vue': {
|
||||
routes:
|
||||
| '/owner/[name]'
|
||||
@@ -231,3 +248,5 @@ declare module 'vue-router/auto-routes' {
|
||||
? Info['routes']
|
||||
: keyof RouteNamedMap
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
export interface NotificationRule {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
containerExpression: string;
|
||||
logExpression: string;
|
||||
metricExpression?: string;
|
||||
eventExpression?: string;
|
||||
cooldown?: number;
|
||||
sampleWindow?: number;
|
||||
triggerCount: number;
|
||||
triggeredContainers: number;
|
||||
lastTriggeredAt: string | null;
|
||||
dispatcher: Dispatcher | null;
|
||||
}
|
||||
|
||||
export interface Dispatcher {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
url?: string;
|
||||
template?: string;
|
||||
headers?: Record<string, string>;
|
||||
prefix?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface NotificationRuleInput {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
dispatcherId: number;
|
||||
logExpression: string;
|
||||
containerExpression: string;
|
||||
metricExpression?: string;
|
||||
eventExpression?: string;
|
||||
cooldown?: number;
|
||||
sampleWindow?: number;
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
containerError?: string;
|
||||
logError?: string;
|
||||
metricError?: string;
|
||||
eventError?: string;
|
||||
matchedContainers: {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
host: string;
|
||||
}[];
|
||||
matchedLogs: {
|
||||
id: number;
|
||||
t: string;
|
||||
m: unknown;
|
||||
rm: string;
|
||||
ts: number;
|
||||
l: string;
|
||||
s: string;
|
||||
}[];
|
||||
totalLogs: number;
|
||||
messageKeys?: string[];
|
||||
}
|
||||
|
||||
export interface TestWebhookResult {
|
||||
success: boolean;
|
||||
statusCode?: number;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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) {
|
||||
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 };
|
||||
}
|
||||
+2
-1
@@ -75,6 +75,7 @@ services:
|
||||
start_interval: 5s
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./e2e/data/notifications.yml:/data/notifications.yml:ro
|
||||
build:
|
||||
context: .
|
||||
proxy:
|
||||
@@ -95,7 +96,7 @@ services:
|
||||
|
||||
playwright:
|
||||
container_name: playwright
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
|
||||
@@ -8,3 +8,4 @@ node_modules
|
||||
.idea/
|
||||
*.log
|
||||
!.vscode
|
||||
superpowers
|
||||
|
||||
@@ -37,6 +37,7 @@ export default defineConfig({
|
||||
nav: [
|
||||
{ text: "Home", link: "/" },
|
||||
{ text: "Guide", link: "/guide/what-is-dozzle", activeMatch: "/guide/" },
|
||||
{ text: "Dozzle Cloud", link: "https://cloud.dozzle.dev" },
|
||||
{
|
||||
text: `v${pkg.version}`,
|
||||
items: [
|
||||
@@ -68,6 +69,13 @@ export default defineConfig({
|
||||
{ text: "Podman", link: "/guide/podman" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Notifications",
|
||||
items: [
|
||||
{ text: "Alerts & Webhooks", link: "/guide/alerts-and-webhooks" },
|
||||
{ text: "Dozzle Cloud", link: "/guide/dozzle-cloud" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Advanced Configuration",
|
||||
items: [
|
||||
|
||||
@@ -3,15 +3,8 @@
|
||||
class="banner fixed top-0 right-0 left-0 z-(--vp-z-index-layout-top) flex items-center overflow-hidden bg-[oklch(74%_0.16_232.661)] p-4 font-bold text-gray-800 dark:bg-[oklch(60%_0.126_221.723)] dark:text-white"
|
||||
>
|
||||
<div class="mx-auto flex items-center gap-2 lg:gap-4">
|
||||
<span class="animate-bounce">🚀</span>K8s users, I need your help! I have added K8s support to Dozzle and I need
|
||||
your feedback! 🙏
|
||||
<a
|
||||
href="https://github.com/amir20/dozzle/discussions/3614"
|
||||
target="_blank"
|
||||
class="btn btn-sm btn-primary text-white!"
|
||||
>
|
||||
See the discussion
|
||||
</a>
|
||||
<span class="animate-bounce">🎉</span>Dozzle v10 is here! Alerts, webhooks, and Dozzle Cloud are now available.
|
||||
<a href="/guide/alerts-and-webhooks" class="btn btn-sm btn-primary text-white!"> Learn more </a>
|
||||
</div>
|
||||
<button class="btn btn-circle btn-ghost btn-xs ml-auto" @click="dismiss">
|
||||
<svg class="swap-on fill-current" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512">
|
||||
|
||||
@@ -17,7 +17,7 @@ export default {
|
||||
"home-hero-image": () => h(HeroVideo),
|
||||
"sidebar-nav-after": () => h(BuyMeCoffee),
|
||||
"home-hero-actions-after": () => h(Stats),
|
||||
// "layout-top": () => h(Banner),
|
||||
"layout-top": () => h(Banner),
|
||||
"home-hero-after": () => h(Supported),
|
||||
});
|
||||
},
|
||||
|
||||
+5
-2
@@ -49,7 +49,7 @@ To connect to an agent, you need to provide the agent's IP address and port. Her
|
||||
::: code-group
|
||||
|
||||
```sh
|
||||
docker run -p 8080:8080 amir20/dozzle:latest --remote-agent agent-ip:7007
|
||||
docker run -p 8080:8080 amir20/dozzle:latest --remote-agent agent:7007
|
||||
```
|
||||
|
||||
```yaml [docker-compose.yml]
|
||||
@@ -64,7 +64,10 @@ services:
|
||||
|
||||
:::
|
||||
|
||||
Note that when connecting remotely, you don't need to mount local Docker socket. The UI will only show the containers that are available on the agent.
|
||||
Note that it is not necessary to mount the local Docker socket when connecting to agents, in which case the UI will only show the containers that are available on the agents.
|
||||
|
||||
> [!TIP]
|
||||
> If you want to include the host containers in the UI as well, mount the `docker.sock` socket as shown in the [getting started](/guide/getting-started) example.
|
||||
|
||||
> [!TIP]
|
||||
> You can connect to multiple agents by providing multiple `DOZZLE_REMOTE_AGENT` environment variables. For example, `DOZZLE_REMOTE_AGENT=agent1:7007,agent2:7007`.
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: Alerts & Webhooks
|
||||
---
|
||||
|
||||
# Alerts & Webhooks
|
||||
|
||||
<Badge type="tip" text="New in v10" />
|
||||
|
||||
Dozzle v10 introduces a powerful alerting system that lets you monitor container logs and receive notifications when specific conditions are met. Alerts use customizable expressions to filter containers and log messages, and can send notifications to webhooks, Slack, Discord, ntfy, or [Dozzle Cloud](/guide/dozzle-cloud).
|
||||
|
||||
## How It Works
|
||||
|
||||
Alerts are configured with two expressions:
|
||||
|
||||
1. **Container filter** — selects which containers to monitor
|
||||
2. **Log filter** — defines which log messages trigger the alert
|
||||
|
||||
When a log entry matches both filters, Dozzle sends a notification to the configured destination.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Alert and destination configurations are stored in the `/data` directory. You must mount this directory as a volume to persist your notification settings across container restarts.
|
||||
|
||||
::: code-group
|
||||
|
||||
```sh
|
||||
docker run -v /var/run/docker.sock:/var/run/docker.sock -v /path/to/data:/data -p 8080:8080 amir20/dozzle:latest
|
||||
```
|
||||
|
||||
```yaml [docker-compose.yml]
|
||||
services:
|
||||
dozzle:
|
||||
image: amir20/dozzle:latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /path/to/data:/data
|
||||
ports:
|
||||
- 8080:8080
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
## Setting Up a Destination
|
||||
|
||||
Before creating alerts, you need to configure at least one notification destination. Navigate to the **Notifications** page in Dozzle and click **Add Destination**.
|
||||
|
||||
### Webhook
|
||||
|
||||
Webhooks send an HTTP POST request to a URL of your choice. Dozzle includes built-in payload templates for popular services:
|
||||
|
||||
- **Slack** — formatted with blocks and markdown
|
||||
- **Discord** — formatted for Discord webhook API
|
||||
- **ntfy** — formatted for [ntfy.sh](https://ntfy.sh) push notifications
|
||||
- **Custom** — generic JSON payload you can customize
|
||||
|
||||
You can also write your own payload template using Go's `text/template` syntax. The following variables are available:
|
||||
|
||||
<div v-pre>
|
||||
|
||||
| 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>
|
||||
|
||||
> [!TIP]
|
||||
> Use the **Test** button to verify your webhook is working before saving.
|
||||
|
||||
### Dozzle Cloud
|
||||
|
||||
You can also send alerts to [Dozzle Cloud](/guide/dozzle-cloud) for centralized monitoring across multiple Dozzle instances. See the [Dozzle Cloud guide](/guide/dozzle-cloud) for more details.
|
||||
|
||||
## Creating an Alert
|
||||
|
||||
Navigate to the **Notifications** page and click **Add Alert**. You'll need to configure:
|
||||
|
||||
### Container Expression
|
||||
|
||||
The container expression selects which containers to monitor. Available properties:
|
||||
|
||||
| Property | Type | Example |
|
||||
| ---------- | ------ | ------------------------------- |
|
||||
| `name` | string | `name contains "api"` |
|
||||
| `image` | string | `image == "nginx:latest"` |
|
||||
| `state` | string | `state == "running"` |
|
||||
| `health` | string | `health == "unhealthy"` |
|
||||
| `hostName` | string | `hostName == "prod-host"` |
|
||||
| `labels` | map | `labels["env"] == "production"` |
|
||||
|
||||
You can combine conditions with `&&` (AND), `||` (OR), and `!` (NOT):
|
||||
|
||||
```
|
||||
name contains "api" && labels["env"] == "production"
|
||||
```
|
||||
|
||||
### Log Expression
|
||||
|
||||
The log expression filters which log messages trigger the alert. Available properties:
|
||||
|
||||
| Property | Type | Example |
|
||||
| --------- | ---------- | -------------------------- |
|
||||
| `message` | string/map | `message contains "error"` |
|
||||
| `level` | string | `level == "error"` |
|
||||
| `stream` | string | `stream == "stderr"` |
|
||||
| `type` | string | `type == "complex"` |
|
||||
|
||||
For JSON logs, you can access nested fields using dot notation:
|
||||
|
||||
```
|
||||
message.status >= 500 && message.path contains "/api"
|
||||
```
|
||||
|
||||
Supported string operators include `contains`, `startsWith`, `endsWith`, and `matches` (regex).
|
||||
|
||||
### Examples
|
||||
|
||||
**Alert on all errors from production containers:**
|
||||
|
||||
```
|
||||
Container: labels["env"] == "production"
|
||||
Log: level == "error"
|
||||
```
|
||||
|
||||
**Alert on HTTP 5xx errors from API containers:**
|
||||
|
||||
```
|
||||
Container: name contains "api"
|
||||
Log: message.status >= 500
|
||||
```
|
||||
|
||||
**Alert on any stderr output from a specific image:**
|
||||
|
||||
```
|
||||
Container: image startsWith "myapp/"
|
||||
Log: stream == "stderr"
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The alert editor includes autocomplete and real-time validation. You can preview matched containers and logs before saving.
|
||||
|
||||
## Managing Alerts
|
||||
|
||||
From the Notifications page, you can:
|
||||
|
||||
- **Enable/disable** alerts without deleting them
|
||||
- **Edit** alert expressions and destinations
|
||||
- **View statistics** including trigger count, matched containers, and last triggered time
|
||||
- **Delete** alerts that are no longer needed
|
||||
@@ -25,7 +25,7 @@ users:
|
||||
admin:
|
||||
email: me@email.net
|
||||
name: Admin
|
||||
# Generate with docker run -it --rm amir20/dozzle generate --name Admin --email me@email.net --password secret admin
|
||||
# Generate with docker run -it --rm amir20/dozzle generate admin --password password --email me@email.net --name "Admin"
|
||||
password: $2a$11$9ho4vY2LdJ/WBopFcsAS0uORC0x2vuFHQgT/yBqZyzclhHsoaIkzK
|
||||
filter:
|
||||
roles:
|
||||
|
||||
@@ -27,3 +27,12 @@ services:
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
## Coolify Integration
|
||||
|
||||
If you're using [Coolify](https://coolify.io/), Dozzle automatically recognizes Coolify's labels as fallbacks:
|
||||
|
||||
- `coolify.serviceName` → Used as container name if `dev.dozzle.name` is not set
|
||||
- `coolify.projectName` → Used for grouping if `dev.dozzle.group` is not set
|
||||
|
||||
No additional configuration is needed for Coolify deployments.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: Dozzle Cloud
|
||||
---
|
||||
|
||||
# Dozzle Cloud
|
||||
|
||||
<Badge type="tip" text="New in v10" />
|
||||
|
||||
[Dozzle Cloud](https://cloud.dozzle.dev) is a companion service that extends your self-hosted Dozzle instances with centralized monitoring, smart alerting, and log intelligence. While Dozzle remains fully open source and self-hosted, Dozzle Cloud adds a managed layer on top for teams that need more visibility across their infrastructure.
|
||||
|
||||
## Why Dozzle Cloud?
|
||||
|
||||
Container logs are noisy. Dozzle Cloud helps you cut through the noise with intelligent summarization and multi-instance aggregation — without requiring additional agents or complex setup.
|
||||
|
||||
## Key Features
|
||||
|
||||
### Log Summaries
|
||||
|
||||
Dozzle Cloud automatically batches related container events into concise summaries. Each summary includes severity levels, source container information, and direct links to the full logs in your Dozzle instance.
|
||||
|
||||
### Pattern Clustering
|
||||
|
||||
Instead of showing duplicate errors, Dozzle Cloud groups similar errors together and displays frequency counts. This makes it easy to identify recurring issues across multiple containers.
|
||||
|
||||
### Smart Alert Distribution
|
||||
|
||||
Receive notifications through multiple channels:
|
||||
|
||||
- **Email**
|
||||
- **Slack**
|
||||
- **ntfy**
|
||||
- **Webhooks**
|
||||
- **Browser push notifications**
|
||||
|
||||
You can enable or disable channels independently with unlimited configurations.
|
||||
|
||||
### Multi-Instance Dashboard
|
||||
|
||||
Monitor all your Dozzle servers from a single dashboard. Connecting is simple — just link your instance with an API key. No additional agents are required.
|
||||
|
||||
### Searchable Event History
|
||||
|
||||
Search across all logged events with full-text search. Filter by container, severity (error, warning, info), and keywords. Retention is configurable from 24 hours to 30 days.
|
||||
|
||||
### Security
|
||||
|
||||
- API keys are hashed with expiration support
|
||||
- GitHub OAuth authentication
|
||||
- Your logs remain your own — Dozzle Cloud is committed to data privacy
|
||||
|
||||
## Connecting to Dozzle Cloud
|
||||
|
||||
To link your Dozzle instance to Dozzle Cloud:
|
||||
|
||||
1. Navigate to the **Notifications** page in Dozzle
|
||||
2. Click **Add Destination** and select **Dozzle Cloud**
|
||||
3. Click **Link Dozzle Cloud** — you'll be redirected to authenticate
|
||||
4. Once linked, your API key is automatically configured
|
||||
|
||||
You can then select Dozzle Cloud as a destination when creating [alerts](/guide/alerts-and-webhooks).
|
||||
|
||||
## Pricing
|
||||
|
||||
Dozzle Cloud offers a free tier with 500 events per month — no credit card required. Visit [cloud.dozzle.dev](https://cloud.dozzle.dev) for more details.
|
||||
@@ -116,6 +116,20 @@ By default, Dozzle only shows running containers. To see stopped containers, you
|
||||
|
||||
In single-user mode, Dozzle stores the settings in the browser's local storage. This means that the settings are only available on the browser where they were set. For Dozzle to enable syncing settings across multiple instances, it needs to know who the user is. In multi-user mode, Dozzle uses the user's username to store the settings on disk and sync them across multiple instances. This information is stored in `/data` directory. If you want to sync settings across multiple instances, you need to [enable](/guide/authentication) multi-user mode and provide a username.
|
||||
|
||||
## Why doesn't Dozzle support notifications for Slack, Discord, Telegram, email, etc. directly?
|
||||
|
||||
By design, Dozzle is unopinionated about where your alerts go. Instead of bundling integrations for specific notification platforms, Dozzle provides **webhooks** with customizable payload templates. This means you can send alerts to _any_ service that accepts HTTP requests — Slack, Discord, Telegram, ntfy, PagerDuty, Opsgenie, or your own internal tools — without waiting for Dozzle to add explicit support.
|
||||
|
||||
There are a few reasons for this approach:
|
||||
|
||||
- **Universality.** Webhooks work with virtually every notification platform. Adding provider-specific integrations would cover only a fraction of what users need, while webhooks cover all of them.
|
||||
- **Maintenance.** Each provider integration brings its own API quirks, authentication flows, rate limits, and breaking changes. Supporting them means Dozzle maintainers become responsible for debugging issues with third-party services — which is outside the scope of a log viewer.
|
||||
- **Simplicity.** Dozzle is a lightweight, focused tool for viewing Docker logs. Keeping the notification layer generic keeps the codebase small and the project sustainable.
|
||||
|
||||
If you need a more opinionated experience with richer provider integrations (e.g., web push notifications, ntfy action buttons), [Dozzle Cloud](/guide/dozzle-cloud) is designed for that.
|
||||
|
||||
For setting up webhooks with your preferred service, see the [Alerts & Webhooks](/guide/alerts-and-webhooks) guide — it includes built-in payload templates for Slack, Discord, and ntfy that you can use as-is or customize.
|
||||
|
||||
## My Dozzle instances are timing out in Swarm Mode or I'm not seeing all my Swarm nodes when behind a load balancer. How do I fix it?
|
||||
|
||||
In Swarm Mode, Dozzle instances may require their own overlay network. If you see inconsistent behavior when connecting to different Dozzle nodes, consider adding a separate overlay network which only contains the Dozzle instances, as shown below:
|
||||
|
||||
@@ -16,7 +16,7 @@ The easiest way to set up Dozzle is to use the CLI and mount `docker.sock` file.
|
||||
::: code-group
|
||||
|
||||
```sh
|
||||
docker run -d -v /var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle:latest
|
||||
docker run -d -v /var/run/docker.sock:/var/run/docker.sock -v dozzle_data:/data -p 8080:8080 amir20/dozzle:latest
|
||||
```
|
||||
|
||||
```yaml [docker-compose.yml]
|
||||
@@ -26,6 +26,7 @@ services:
|
||||
image: amir20/dozzle:latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- dozzle_data:/data
|
||||
ports:
|
||||
- 8080:8080
|
||||
environment:
|
||||
@@ -37,6 +38,8 @@ services:
|
||||
#
|
||||
# Uncomment to enable authentication. See https://dozzle.dev/guide/authentication
|
||||
# - DOZZLE_AUTH_PROVIDER=simple
|
||||
volumes:
|
||||
dozzle_data:
|
||||
```
|
||||
|
||||
:::
|
||||
@@ -45,6 +48,9 @@ services:
|
||||
> Dozzle supports actions, such as stopping, starting, and restarting containers, or attaching to container shells. But they are disabled by default for security reasons. To enable them, uncomment the corresponding environment variables.
|
||||
> Dozzle also supports connecting to remote agents to monitor multiple Docker hosts. See [agent](/guide/agent) to learn more.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Dozzle stores notification settings and other data in `/data` inside the container. To persist these settings across container restarts, you need to mount a volume to `/data`. Without this mount, notification settings will be lost when the container is recreated. See the Docker Compose example above for the recommended volume configuration.
|
||||
|
||||
## Docker Swarm
|
||||
|
||||
Dozzle supports running in Swarm mode by deploying it on every node. To run Dozzle in Swarm mode, you can use the following configuration:
|
||||
|
||||
@@ -84,6 +84,8 @@ spec:
|
||||
```
|
||||
|
||||
This configuration creates a service account, a cluster role, and a cluster role binding to allow Dozzle to access the necessary Kubernetes resources. It also creates a deployment for Dozzle and exposes it via a service.
|
||||
> [!WARNING]
|
||||
> When deploying this with any GitOps tool (like Flux CD or Argo CD) in a specific namespace apart from `default`, make sure to change the **namespace** in the **ClusterRoleBinding Subject**
|
||||
|
||||
All other features are supported as well, including authentication, filtering, and more. You can use the same environment variables as you would in Docker to configure Dozzle in Kubernetes.
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ features:
|
||||
link: /guide/remote-hosts
|
||||
linkText: Learn More
|
||||
icon: 🌐
|
||||
- title: Alerts & Webhooks
|
||||
details: Set up customizable alerts with powerful expressions to monitor container logs and receive notifications via Slack, Discord, ntfy, or webhooks.
|
||||
icon: 🔔
|
||||
linkText: Learn More
|
||||
link: /guide/alerts-and-webhooks
|
||||
- title: SQL Engine
|
||||
details: Use SQL queries to analyze logs inside your browser with WebAssembly and DuckDB.
|
||||
icon: 📊
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
subscriptions:
|
||||
- id: 1
|
||||
name: test-alert
|
||||
enabled: true
|
||||
dispatcherId: 1
|
||||
logExpression: 'message contains "error"'
|
||||
containerExpression: 'name contains "dozzle"'
|
||||
dispatchers:
|
||||
- id: 1
|
||||
name: test-webhook
|
||||
type: webhook
|
||||
url: http://localhost:9999/webhook
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
users:
|
||||
admin:
|
||||
name: Admin
|
||||
password: "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
|
||||
password: "$2y$11$pTGu6dnTT7Uh3ob7uC6X7OkAamlhHpJ0/mEbsmiPyO85pumillZme"
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Parse arguments
|
||||
USE_LOCAL=false
|
||||
POSITIONAL_ARGS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--local)
|
||||
USE_LOCAL=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
POSITIONAL_ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Configuration
|
||||
VM_NAME="${1:-dozzle-agent}"
|
||||
DISTRO="${2:-ubuntu}"
|
||||
AGENT_PORT="${3:-7007}"
|
||||
VM_NAME="${POSITIONAL_ARGS[0]:-dozzle-agent}"
|
||||
DISTRO="${POSITIONAL_ARGS[1]:-ubuntu}"
|
||||
AGENT_PORT="${POSITIONAL_ARGS[2]:-7007}"
|
||||
SHARED_CERT="./shared_cert.pem"
|
||||
SHARED_KEY="./shared_key.pem"
|
||||
DOZZLE_IMAGE="amir20/dozzle:latest"
|
||||
|
||||
if [ "$USE_LOCAL" = true ]; then
|
||||
DOZZLE_IMAGE="amir20/dozzle:local"
|
||||
fi
|
||||
|
||||
echo "🚀 Setting up Dozzle Agent on OrbStack VM: $VM_NAME"
|
||||
if [ "$USE_LOCAL" = true ]; then
|
||||
echo " Using locally built image"
|
||||
fi
|
||||
|
||||
# Verify shared certificates exist
|
||||
if [ ! -f "$SHARED_CERT" ]; then
|
||||
@@ -40,10 +65,7 @@ sleep 3
|
||||
|
||||
# Step 2: Install Docker in the VM
|
||||
echo "🐳 Installing Docker..."
|
||||
if ! orb exec -m "$VM_NAME" bash -c '
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $(whoami)
|
||||
'; then
|
||||
if ! orb exec -m "$VM_NAME" bash -c 'curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $(whoami)'; then
|
||||
echo "❌ Docker installation failed"
|
||||
exit 1
|
||||
fi
|
||||
@@ -62,17 +84,44 @@ cat "$SHARED_KEY" | orb exec -m "$VM_NAME" bash -c 'cat > ~/dozzle-certs/shared_
|
||||
|
||||
echo "✅ Certificates copied"
|
||||
|
||||
# Step 4: Start Dozzle agent
|
||||
# Step 4: Load or pull Dozzle image
|
||||
if [ "$USE_LOCAL" = true ]; then
|
||||
echo "🔨 Building local Docker image..."
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
if ! (cd "$PROJECT_ROOT" && make docker); then
|
||||
echo "❌ Failed to build Docker image"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📦 Loading image into VM..."
|
||||
if ! docker save amir20/dozzle:local | orb exec -m "$VM_NAME" docker load; then
|
||||
echo "❌ Failed to load image into VM"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Local image loaded"
|
||||
else
|
||||
echo "📥 Pulling Dozzle image..."
|
||||
if ! orb exec -m "$VM_NAME" docker pull amir20/dozzle:latest; then
|
||||
echo "❌ Failed to pull image"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 5: Start Dozzle agent
|
||||
echo "🎯 Starting Dozzle agent..."
|
||||
orb exec -m "$VM_NAME" bash -c 'mkdir -p ~/dozzle-data'
|
||||
if ! orb exec -m "$VM_NAME" bash -c "
|
||||
set -e
|
||||
docker pull amir20/dozzle:latest
|
||||
docker run -d --name dozzle-agent \
|
||||
--restart unless-stopped \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/dozzle-certs:/certs \
|
||||
-v ~/dozzle-data:/data \
|
||||
-p $AGENT_PORT:7007 \
|
||||
amir20/dozzle:latest agent \
|
||||
-e DOZZLE_LEVEL=debug \
|
||||
$DOZZLE_IMAGE agent \
|
||||
--cert /certs/shared_cert.pem \
|
||||
--key /certs/shared_key.pem
|
||||
"; then
|
||||
@@ -82,11 +131,11 @@ fi
|
||||
|
||||
echo "✅ Dozzle agent started"
|
||||
|
||||
# Step 5: Wait for agent to be ready
|
||||
# Step 6: Wait for agent to be ready
|
||||
echo "⏳ Waiting for agent to be ready..."
|
||||
sleep 3
|
||||
|
||||
# Step 6: Verify agent is running
|
||||
# Step 7: Verify agent is running
|
||||
echo "🧪 Verifying agent is running..."
|
||||
if orb exec -m "$VM_NAME" docker ps --filter name=dozzle-agent --format "{{.Status}}" | grep -q "Up"; then
|
||||
echo "✅ Agent is running"
|
||||
|
||||
+78
-15
@@ -1,11 +1,13 @@
|
||||
#!/usr/bin/env fish
|
||||
|
||||
# docker network create --driver bridge swarm-net
|
||||
echo "🧹 Removing existing containers and network"
|
||||
docker rm -f manager worker-1 worker-2 2>/dev/null
|
||||
docker network rm swarm-net 2>/dev/null
|
||||
|
||||
echo "Removing existing containers"
|
||||
docker rm -f manager worker-1 worker-2
|
||||
echo "🌐 Creating bridge network"
|
||||
docker network create --driver bridge swarm-net
|
||||
|
||||
echo "Creating manager"
|
||||
echo "🖥️ Creating manager"
|
||||
docker run -d --name manager \
|
||||
--privileged \
|
||||
--network swarm-net \
|
||||
@@ -13,16 +15,25 @@ docker run -d --name manager \
|
||||
-p 2377:2377 \
|
||||
-p 7946:7946 \
|
||||
-p 4789:4789 \
|
||||
-p 8000-9000:8000-9000 \
|
||||
-p 8080:8080 \
|
||||
-v ./examples:/examples \
|
||||
docker
|
||||
|
||||
# Store join command in a variable
|
||||
sleep 2
|
||||
echo "Initializing swarm"
|
||||
set JOIN_COMMAND (docker exec manager docker swarm init | grep "swarm join --token")
|
||||
# Wait for dockerd to be ready inside manager
|
||||
echo "⏳ Waiting for manager dockerd to start..."
|
||||
while not docker exec manager docker info >/dev/null 2>&1
|
||||
sleep 1
|
||||
end
|
||||
|
||||
echo "Creating workers"
|
||||
echo "🐝 Initializing swarm"
|
||||
set MANAGER_IP (docker exec manager hostname -i | string trim)
|
||||
set JOIN_TOKEN (docker exec manager docker swarm init --advertise-addr $MANAGER_IP 2>&1 | string match -r 'SWMTKN-\S+')
|
||||
if test -z "$JOIN_TOKEN"
|
||||
echo "❌ Failed to get swarm join token"
|
||||
exit 1
|
||||
end
|
||||
|
||||
echo "👷 Creating workers"
|
||||
for i in 1 2
|
||||
docker run -d --name worker-$i \
|
||||
--privileged \
|
||||
@@ -31,17 +42,69 @@ for i in 1 2
|
||||
docker
|
||||
end
|
||||
|
||||
sleep 2
|
||||
|
||||
# Wait for worker dockerd to be ready
|
||||
for i in 1 2
|
||||
echo "Joining worker-$i to swarm"
|
||||
docker exec worker-$i sh -c "$JOIN_COMMAND"
|
||||
echo "⏳ Waiting for worker-$i dockerd to start..."
|
||||
while not docker exec worker-$i docker info >/dev/null 2>&1
|
||||
sleep 1
|
||||
end
|
||||
end
|
||||
|
||||
function cleanup
|
||||
for i in 1 2
|
||||
echo "🔗 Joining worker-$i to swarm"
|
||||
docker exec worker-$i docker swarm join --token $JOIN_TOKEN $MANAGER_IP:2377
|
||||
end
|
||||
|
||||
echo "✅ Swarm is ready. Deploying Dozzle..."
|
||||
|
||||
# Create the stack file inside the manager
|
||||
docker exec manager sh -c 'cat > /dozzle-stack.yml << "EOF"
|
||||
services:
|
||||
dozzle:
|
||||
image: amir20/dozzle:local
|
||||
environment:
|
||||
- DOZZLE_MODE=swarm
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
ports:
|
||||
- 8080:8080
|
||||
networks:
|
||||
- dozzle
|
||||
deploy:
|
||||
mode: global
|
||||
networks:
|
||||
dozzle:
|
||||
driver: overlay
|
||||
EOF'
|
||||
|
||||
docker exec manager docker stack deploy -c /dozzle-stack.yml dozzle
|
||||
echo "🚀 Dozzle deployed! Access at http://localhost:8080"
|
||||
|
||||
function swarm-cleanup
|
||||
docker exec manager docker stack rm dozzle 2>/dev/null
|
||||
docker rm -f manager worker-1 worker-2
|
||||
docker network rm swarm-net 2>/dev/null
|
||||
functions -e swarm-cleanup
|
||||
functions -e swarm
|
||||
functions -e swarm-deploy
|
||||
end
|
||||
|
||||
function swarm
|
||||
docker exec manager docker $argv
|
||||
end
|
||||
|
||||
function swarm-deploy
|
||||
echo "🔨 Building local image..."
|
||||
make -C (git rev-parse --show-toplevel) docker
|
||||
echo "📦 Loading image into swarm nodes..."
|
||||
for node in manager worker-1 worker-2
|
||||
docker save amir20/dozzle:local | docker exec -i $node docker load
|
||||
end
|
||||
echo "🚀 Redeploying stack..."
|
||||
docker exec manager docker stack rm dozzle 2>/dev/null
|
||||
sleep 2
|
||||
docker exec manager docker stack deploy -c /dozzle-stack.yml dozzle
|
||||
echo "✅ Deployed local changes!"
|
||||
end
|
||||
|
||||
echo "💡 Functions 'swarm', 'swarm-deploy', and 'swarm-cleanup' are available in this session."
|
||||
|
||||
@@ -8,7 +8,6 @@ require (
|
||||
github.com/docker/go-connections v0.5.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
@@ -17,39 +16,46 @@ require (
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.11.0
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3
|
||||
github.com/expr-lang/expr v1.17.8
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0
|
||||
github.com/go-faker/faker/v4 v4.7.0
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||
github.com/puzpuzpuz/xsync/v4 v4.2.0
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/samber/lo v1.52.0
|
||||
github.com/samber/lo v1.53.0
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/sync v0.19.0
|
||||
google.golang.org/grpc v1.78.0
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
go.yaml.in/yaml/v3 v3.0.4
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/sync v0.20.0
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/protobuf v1.36.11
|
||||
k8s.io/api v0.35.0
|
||||
k8s.io/apimachinery v0.35.0
|
||||
k8s.io/client-go v0.35.0
|
||||
k8s.io/metrics v0.35.0
|
||||
k8s.io/api v0.35.3
|
||||
k8s.io/apimachinery v0.35.3
|
||||
k8s.io/client-go v0.35.3
|
||||
k8s.io/metrics v0.35.3
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/air-verse/air v1.64.5 // indirect
|
||||
github.com/alexflint/go-scalar v1.2.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/bep/godartsass/v2 v2.5.0 // indirect
|
||||
github.com/bep/golibsass v1.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.2 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
@@ -57,27 +63,27 @@ require (
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||
github.com/expr-lang/expr v1.17.7 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/gohugoio/hugo v0.149.1 // indirect
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/gofuzz v1.2.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.3 // indirect
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 // indirect
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.2 // indirect
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
@@ -89,24 +95,28 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.3 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/term v0.38.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/time v0.10.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gotest.tools/v3 v3.0.3 // indirect
|
||||
@@ -115,9 +125,14 @@ require (
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
go 1.25.5
|
||||
go 1.26.1
|
||||
|
||||
tool (
|
||||
github.com/air-verse/air
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc
|
||||
google.golang.org/protobuf/cmd/protoc-gen-go
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user