mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 12:14:44 +00:00
Compare commits
173 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 799d261c59 | |||
| abac4fdf7e | |||
| ac43dca1ac | |||
| 6c4b386283 | |||
| 52e61158cf | |||
| 3aa80b8f07 | |||
| 4616159e27 | |||
| 3dbbe530db | |||
| 27fdd047a5 | |||
| c9e89fb425 | |||
| 1cd0b651c3 | |||
| 7ce3bd8e64 | |||
| 915478c4ab | |||
| d58a33fdac | |||
| 94976bdf58 | |||
| a1aa2253bc | |||
| 09e549b1b1 | |||
| a0877bbadd | |||
| 04515cc1bd | |||
| 3c10eefcfe | |||
| 66d9fa4dd1 | |||
| f195290a30 | |||
| fe8165ec09 | |||
| 09647174ee | |||
| 6d8f3005b0 | |||
| af4412f667 | |||
| f5f736b6ff | |||
| 44239614d1 | |||
| f13bcdba60 | |||
| 92fa5beb07 | |||
| 4c378443e0 | |||
| e608bdf201 | |||
| d873edee0e | |||
| fe83ddcf22 | |||
| 338c953298 | |||
| 2d54f43ef5 | |||
| 4b913ee68a | |||
| dd5cd3ccac | |||
| 5abda9f224 | |||
| 32fac82635 | |||
| cae201d592 | |||
| 284822c631 | |||
| 475a16321d | |||
| 3ab758aabf | |||
| 7966b21249 | |||
| 15e13507b2 | |||
| 3254f36246 | |||
| 3c6bc6b391 | |||
| e079e5fc43 | |||
| 317c141f65 | |||
| 77a7905f2f | |||
| cfcaebe36a | |||
| 7225abec25 | |||
| af241022bf | |||
| 806b6fb0cd | |||
| dca9f17be4 | |||
| 3ce1f9ebdc | |||
| 481455a09b | |||
| 77ddc3ce52 | |||
| 9b44236ad4 | |||
| 591d881294 | |||
| 4cf3952a73 | |||
| 3a309ce8fd | |||
| 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 |
@@ -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,37 @@
|
||||
# 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
|
||||
|
||||
### Container Update (feat/container-update)
|
||||
|
||||
- **progressCh close contract**: Docker and Agent implementations close progressCh via defer; K8s does NOT, causing handler hang
|
||||
- **NetworkSettings nil risk**: `docker.InspectResponse.NetworkSettings` is a pointer; `ContainerCreate` doesn't nil-check before accessing `.Networks`
|
||||
- **Destructive recreate**: stop->remove->create->start has no rollback if create fails after remove
|
||||
- **SSE parsing in frontend**: Uses manual ReadableStream reader, not EventSource; no AbortController cleanup on unmount
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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)
|
||||
- `internal/cloud/client.go` -- Cloud gRPC bidirectional streaming client
|
||||
- `internal/cloud/tools.go` -- Tool definitions and execution dispatch
|
||||
|
||||
## Memory Files
|
||||
|
||||
- [cloud_client_patterns.md](cloud_client_patterns.md) - Cloud gRPC client architecture and known patterns
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Cloud gRPC Client Patterns
|
||||
description: Architecture of internal/cloud/ package - gRPC bidirectional streaming for cloud tool calls
|
||||
type: project
|
||||
---
|
||||
|
||||
Cloud client (`internal/cloud/client.go`) uses a bidirectional gRPC stream (`ToolStream`) to receive tool requests from Dozzle Cloud and send responses.
|
||||
|
||||
Key architecture:
|
||||
|
||||
- `Client.Run()` is the reconnect loop with exponential backoff
|
||||
- `Client.connect()` creates a new `grpc.ClientConn` per reconnect (potential optimization: reuse connection)
|
||||
- `sendMu sync.Mutex` protects concurrent `stream.Send()` calls (tool calls dispatch to goroutines)
|
||||
- `toolSem` (weighted semaphore, max 5) limits concurrent tool execution
|
||||
- `apiKeyFunc` closure provides cloud API key; empty string means no cloud configured
|
||||
- PermissionDenied from server causes permanent stop (no retry)
|
||||
- Tool definitions cached via `sync.Once` + `cachedTools` field (fixed from prior re-serialization issue)
|
||||
- `Notify()` / `startCh` pattern ensures zero overhead for non-cloud users
|
||||
- Tool dispatch in `tools.go` uses typed proto responses (`CallToolResponse` with oneof `Result`)
|
||||
|
||||
Known perf issues found (2026-04):
|
||||
|
||||
- `containsIgnoreCase` in `tools_helpers.go` allocates two lowered strings per call; used in filter loops
|
||||
- `executeInspectContainer` calls `buildHostNameMap` to resolve a single host name
|
||||
- `executeFetchContainerLogs` doesn't drain log channel after early break (potential goroutine leak)
|
||||
- `fmt.Sprintf("%v", event.Message)` in log path uses reflect-based formatting
|
||||
|
||||
**How to apply:** When reviewing future changes to this package, watch for: undrained channels from streaming APIs, per-call allocations in filter loops, and unnecessary API calls for single lookups.
|
||||
@@ -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/
|
||||
|
||||
@@ -13,19 +13,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.13.1
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.13.1
|
||||
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
|
||||
@@ -37,7 +33,7 @@ jobs:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 1.26.0
|
||||
go-version: 1.26.2
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
@@ -55,29 +51,25 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.13.1
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.13.1
|
||||
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
|
||||
@@ -86,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
|
||||
@@ -101,14 +93,14 @@ jobs:
|
||||
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.7.0
|
||||
uses: docker/login-action@v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3.7.0
|
||||
uses: docker/login-action@v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -117,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
|
||||
@@ -132,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.19.0
|
||||
uses: docker/build-push-action@v7.1.0
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
@@ -153,7 +145,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -18,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.7.0
|
||||
uses: docker/login-action@v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3.7.0
|
||||
uses: docker/login-action@v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -34,23 +34,26 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
amir20/dozzle
|
||||
ghcr.io/amir20/dozzle
|
||||
- name: Short SHA
|
||||
id: sha
|
||||
run: echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
- name: Writing certs to file
|
||||
run: |
|
||||
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
|
||||
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6.19.0
|
||||
uses: docker/build-push-action@v7.1.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 }}
|
||||
build-args: TAG=${{ steps.meta.outputs.version }}-${{ steps.sha.outputs.short }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -31,10 +31,10 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.13.1
|
||||
node-version: 24.14.1
|
||||
cache: pnpm # or pnpm / yarn
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@v6
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Build with VitePress
|
||||
@@ -57,4 +57,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
+14
-14
@@ -16,17 +16,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.13.1
|
||||
node-version: 24.14.1
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.13.1
|
||||
node-version: 24.14.1
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -38,13 +38,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.13.1
|
||||
node-version: 24.14.1
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
- name: Install dependencies
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.0"
|
||||
go-version: "1.26.2"
|
||||
check-latest: true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
@@ -81,12 +81,12 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.0"
|
||||
go-version: "1.26.2"
|
||||
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:
|
||||
@@ -95,27 +95,27 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
- uses: actions/setup-node@v6
|
||||
name: Install Node
|
||||
with:
|
||||
node-version: 24.13.1
|
||||
node-version: 24.14.1
|
||||
- run: corepack enable
|
||||
- run: pnpm --version
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.13.1
|
||||
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
|
||||
@@ -124,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/
|
||||
|
||||
@@ -112,6 +112,15 @@ The Go backend is organized into these key packages:
|
||||
- Uses Protocol Buffers (protos defined in `protos/`)
|
||||
- Enables distributed log collection across Docker hosts
|
||||
|
||||
- **`internal/cloud/`** - Dozzle Cloud integration (tool execution engine)
|
||||
- `client.go`: Bidirectional gRPC stream client with auto-reconnect and exponential backoff
|
||||
- `tools.go`: Tool registration, dispatch (`executeTool`), and `ToolHostService` interface
|
||||
- `tools_containers.go`: Container listing, finding, stats, and inspection tools
|
||||
- `tools_logs.go`: Log fetching with level/query/regex filtering (max 100 lines)
|
||||
- `tools_actions.go`: Container start/stop/restart actions (gated by `enableActions`)
|
||||
- `tools_helpers.go`: Proto conversion utilities and host name resolution
|
||||
- Uses `protos/cloud.proto` for service and message definitions
|
||||
|
||||
- **`internal/k8s/`** - Kubernetes client support
|
||||
- Alternative to Docker client for k8s deployments
|
||||
|
||||
@@ -226,6 +235,9 @@ The frontend uses file-based routing with these conventions:
|
||||
- `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
|
||||
|
||||
@@ -336,6 +348,16 @@ Implementation (DockerClient, K8sClient, AgentClient)
|
||||
5. Streaming RPCs (logs, stats, events) use bidirectional channels
|
||||
6. Responses converted back to domain models via `FromProto()` methods
|
||||
|
||||
### Cloud Tool Execution Flow
|
||||
|
||||
1. `cloud.Client.Run()` blocks until `Notify()` signals a cloud dispatcher is configured
|
||||
2. `connect()` establishes bidirectional gRPC stream (`ToolStream` RPC) to cloud endpoint
|
||||
3. Cloud sends `ToolRequest` (ListTools or CallTool), client dispatches via `executeTool()`
|
||||
4. Tool calls run concurrently (max 5 via weighted semaphore), responses sent back on stream
|
||||
5. On disconnect, exponential backoff (1s→30s with jitter) triggers reconnection
|
||||
6. `PermissionDenied` errors stop retrying permanently (invalid API key / no pro plan)
|
||||
7. Tool definitions cached via `sync.Once`; zero overhead for non-cloud users
|
||||
|
||||
### Log Parsing Pipeline
|
||||
|
||||
1. Docker API returns multiplexed stream (8-byte headers + payload)
|
||||
@@ -412,6 +434,16 @@ Implementation (DockerClient, K8sClient, AgentClient)
|
||||
3. Add UI form in `assets/components/Notification/DestinationForm.vue`
|
||||
4. Add GraphQL schema fields if needed
|
||||
|
||||
### Adding a New Cloud Tool
|
||||
|
||||
1. Define the tool in `AvailableTools()` in `internal/cloud/tools.go` with name, description, and parameter schema
|
||||
2. Add a response message type in `protos/cloud.proto` and add it to the `CallToolResponse.result` oneof
|
||||
3. Run `make generate` to regenerate protobuf code
|
||||
4. Add a case in the `executeTool()` switch in `internal/cloud/tools.go`
|
||||
5. Implement the execution function in the appropriate `tools_*.go` file
|
||||
6. Use `ToolHostService` interface methods to access container/host data
|
||||
7. Add tests in `tools_test.go`
|
||||
|
||||
## Common Development Patterns
|
||||
|
||||
### Testing
|
||||
|
||||
+7
-12
@@ -1,5 +1,5 @@
|
||||
# Build assets
|
||||
FROM --platform=$BUILDPLATFORM node:25.6.0-alpine AS node
|
||||
FROM --platform=$BUILDPLATFORM node:25.9.0-alpine AS node
|
||||
|
||||
RUN npm install -g --force corepack && corepack enable
|
||||
|
||||
@@ -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
|
||||
@@ -24,20 +24,17 @@ RUN pnpm build
|
||||
|
||||
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
|
||||
COPY proto ./proto
|
||||
COPY types ./types
|
||||
COPY main.go ./
|
||||
COPY protos ./protos
|
||||
@@ -50,11 +47,9 @@ COPY --from=node /build/dist ./dist
|
||||
ARG TAG=dev
|
||||
ARG TARGETOS TARGETARCH
|
||||
|
||||
# Generate protos and graphql
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Dozzle Major Version Highlights
|
||||
|
||||
A summary of major features introduced with each major version bump since 2020.
|
||||
|
||||
## v2.0.0 — 2020-06-27
|
||||
|
||||
- Theme overhaul using CSS variables
|
||||
- Progress notification bar
|
||||
- Toggle between new light and dark themes
|
||||
|
||||
## v3.0.0 — 2020-09-06
|
||||
|
||||
- Live container stats (CPU/memory) streamed in real time
|
||||
- Pinned tabs and improved mobile/responsive layout
|
||||
- Major UI overhaul
|
||||
|
||||
## v4.0.0 — 2022-08-17
|
||||
|
||||
- First-class JSON log support
|
||||
- Jump-to-context, soft wraps, clear-logs action
|
||||
- Auto color scheme, dark mode polish
|
||||
- Container stats panel, total CPU/mem usage
|
||||
- Healthcheck endpoint and "wait for docker" startup option
|
||||
|
||||
## v5.0.0 — 2023-09-23
|
||||
|
||||
- Multi-host support with parallel client connections
|
||||
- New homepage dashboard with all containers, sortable table, pagination, bar charts
|
||||
- Exponential moving average for stats
|
||||
- Container pinning, keyboard shortcut overlay
|
||||
- stdout/stderr stream separation
|
||||
- i18n: Chinese, German added; locale infrastructure
|
||||
- Refactored UI with faster components
|
||||
|
||||
## v6.0.0 — 2024-01-01
|
||||
|
||||
- **Forward-proxy authentication** (Authelia, etc.) and `users.yml` file-based auth
|
||||
- Container actions: start/stop/restart from the UI
|
||||
- Hot-reload of users.yml without restart
|
||||
- Custom headers for forward-proxy auth
|
||||
- Settings synced to disk for authenticated users
|
||||
- Toast notifications, release list, redirect-to-new-container
|
||||
- Removed legacy auth model (breaking)
|
||||
|
||||
## v7.0.0 — 2024-05-24
|
||||
|
||||
- **Docker Swarm mode** with stacks and services on remote hosts
|
||||
- Host cards on dashboard with per-host stats
|
||||
- Container grouping by stack/compose
|
||||
- Background stats collection (up to 5 min) with idle deactivation
|
||||
- LogFmt parser support
|
||||
- Compact mode, draggable search, alt-click split panes
|
||||
- Many new locales (French, Italian, Polish, Danish, Turkish, …)
|
||||
- `generate` subcommand for users.yml
|
||||
|
||||
## v8.0.0 — 2024-07-05
|
||||
|
||||
- **Swarm mode rebuilt on gRPC agents** (breaking architecture change)
|
||||
- Critical/severe log levels
|
||||
- Stacks and services in fuzzy search
|
||||
- Improved search with full match scrolling
|
||||
- Container start events shown inline
|
||||
|
||||
## v9.0.0 — 2026-01-06
|
||||
|
||||
- **Kubernetes mode** with k8s-specific menu
|
||||
- **User roles** and `dozzle_*` role mapping; logout URL for forward proxy
|
||||
- Historical stats on homepage, hosts, and containers
|
||||
- Permanent links to specific past log lines
|
||||
- Grouping by `dev.dozzle.group` label, log message grouping
|
||||
- Shell resize support, action toolbar in menu
|
||||
- Settings page, collapsible side menu sections
|
||||
- Parallel container fetching, gRPC compression
|
||||
- CLEF (`@l`) log level extraction
|
||||
- New locales: Korean, Indonesian, Dutch
|
||||
|
||||
## v10.0.0 — 2026-02-10
|
||||
|
||||
- **Dozzle Cloud** integration (bidirectional gRPC tool execution)
|
||||
- **Notifications & alerts**: full notifications page, webhooks, dispatchers, Go template support, test connections
|
||||
- Notifications work across agents
|
||||
- Per-container network usage stats (with mobile view)
|
||||
- Coolify label fallbacks for container name/group
|
||||
- Alert creation shortcut, JSON syntax in templates
|
||||
@@ -26,7 +26,7 @@ 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: generate
|
||||
@docker build --build-arg TAG=local -t amir20/dozzle:local .
|
||||
|
||||
.PHONY: generate
|
||||
@@ -51,12 +51,14 @@ shared_cert.pem: shared_key.pem
|
||||
|
||||
.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
|
||||
|
||||
.PHONY: run
|
||||
run: docker
|
||||
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
|
||||
|
||||
|
||||
Vendored
+21
-2
@@ -29,11 +29,13 @@ declare global {
|
||||
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
|
||||
@@ -56,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 +81,7 @@ declare global {
|
||||
const isRef: typeof import('vue').isRef
|
||||
const isShallow: typeof import('vue').isShallow
|
||||
const lightTheme: typeof import('./stores/settings').lightTheme
|
||||
const loadBetween: typeof import('./composable/eventStreams').loadBetween
|
||||
const loadBetween: typeof import('./composable/loadBetween').loadBetween
|
||||
const locale: typeof import('./stores/settings').locale
|
||||
const loggingContextKey: typeof import('./composable/logContext').loggingContextKey
|
||||
const makeDestructurable: typeof import('@vueuse/core').makeDestructurable
|
||||
@@ -111,6 +114,7 @@ declare global {
|
||||
const onUnmounted: typeof import('vue').onUnmounted
|
||||
const onUpdated: typeof import('vue').onUpdated
|
||||
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||
const parseMessage: typeof import('./composable/loadBetween').parseMessage
|
||||
const pausableWatch: typeof import('@vueuse/core').pausableWatch
|
||||
const persistentVisibleKeysForContainer: typeof import('./composable/storage').persistentVisibleKeysForContainer
|
||||
const pinnedContainers: typeof import('./composable/storage').pinnedContainers
|
||||
@@ -173,6 +177,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
|
||||
@@ -200,6 +205,7 @@ declare global {
|
||||
const useClipboard: typeof import('@vueuse/core').useClipboard
|
||||
const useClipboardItems: typeof import('@vueuse/core').useClipboardItems
|
||||
const useCloned: typeof import('@vueuse/core').useCloned
|
||||
const useCloudConfig: typeof import('./composable/cloudConfig').useCloudConfig
|
||||
const useColorMode: typeof import('@vueuse/core').useColorMode
|
||||
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
|
||||
const useContainerActions: typeof import('./composable/containerActions').useContainerActions
|
||||
@@ -238,6 +244,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
|
||||
@@ -267,6 +274,7 @@ declare global {
|
||||
const useLastChanged: typeof import('@vueuse/core').useLastChanged
|
||||
const useLink: typeof import('vue-router/auto').useLink
|
||||
const useLocalStorage: typeof import('@vueuse/core').useLocalStorage
|
||||
const useLogLoader: typeof import('./composable/logLoader').useLogLoader
|
||||
const useLoggingContext: typeof import('./composable/logContext').useLoggingContext
|
||||
const useMagicKeys: typeof import('@vueuse/core').useMagicKeys
|
||||
const useManualRefHistory: typeof import('@vueuse/core').useManualRefHistory
|
||||
@@ -395,6 +403,9 @@ declare global {
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
// @ts-ignore
|
||||
export type { AlertFormOptions, ContainerResult } from './composable/alertForm'
|
||||
import('./composable/alertForm')
|
||||
// @ts-ignore
|
||||
export type { DrawerWidth } from './composable/drawer'
|
||||
import('./composable/drawer')
|
||||
// @ts-ignore
|
||||
@@ -448,11 +459,13 @@ declare module 'vue' {
|
||||
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']>
|
||||
@@ -475,6 +488,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']>
|
||||
@@ -497,7 +511,7 @@ declare module 'vue' {
|
||||
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
||||
readonly isShallow: UnwrapRef<typeof import('vue')['isShallow']>
|
||||
readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']>
|
||||
readonly loadBetween: UnwrapRef<typeof import('./composable/eventStreams')['loadBetween']>
|
||||
readonly loadBetween: UnwrapRef<typeof import('./composable/loadBetween')['loadBetween']>
|
||||
readonly locale: UnwrapRef<typeof import('./stores/settings')['locale']>
|
||||
readonly loggingContextKey: UnwrapRef<typeof import('./composable/logContext')['loggingContextKey']>
|
||||
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
|
||||
@@ -530,6 +544,7 @@ declare module 'vue' {
|
||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
|
||||
readonly parseMessage: UnwrapRef<typeof import('./composable/loadBetween')['parseMessage']>
|
||||
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
|
||||
readonly persistentVisibleKeysForContainer: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeysForContainer']>
|
||||
readonly pinnedContainers: UnwrapRef<typeof import('./composable/storage')['pinnedContainers']>
|
||||
@@ -592,6 +607,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']>
|
||||
@@ -619,6 +635,7 @@ declare module 'vue' {
|
||||
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
|
||||
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
|
||||
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
|
||||
readonly useCloudConfig: UnwrapRef<typeof import('./composable/cloudConfig')['useCloudConfig']>
|
||||
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
|
||||
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
|
||||
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
|
||||
@@ -657,6 +674,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']>
|
||||
@@ -686,6 +704,7 @@ declare module 'vue' {
|
||||
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 useLogLoader: UnwrapRef<typeof import('./composable/logLoader')['useLogLoader']>
|
||||
readonly useLoggingContext: UnwrapRef<typeof import('./composable/logContext')['useLoggingContext']>
|
||||
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
|
||||
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
|
||||
|
||||
Vendored
+23
@@ -15,8 +15,10 @@ declare module 'vue' {
|
||||
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']
|
||||
@@ -25,6 +27,7 @@ declare module 'vue' {
|
||||
'Carbon:star': typeof import('~icons/carbon/star')['default']
|
||||
'Carbon:starFilled': typeof import('~icons/carbon/star-filled')['default']
|
||||
'Carbon:stopFilledAlt': typeof import('~icons/carbon/stop-filled-alt')['default']
|
||||
'Carbon:upgrade': typeof import('~icons/carbon/upgrade')['default']
|
||||
'Carbon:warning': typeof import('~icons/carbon/warning')['default']
|
||||
Carousel: typeof import('./components/common/Carousel.vue')['default']
|
||||
CarouselItem: typeof import('./components/common/CarouselItem.vue')['default']
|
||||
@@ -32,6 +35,9 @@ 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']
|
||||
CloudPopover: typeof import('./components/CloudPopover.vue')['default']
|
||||
CloudSettingsCard: typeof import('./components/CloudSettingsCard.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']
|
||||
@@ -42,11 +48,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']
|
||||
@@ -67,6 +75,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']
|
||||
@@ -85,11 +94,15 @@ declare module 'vue' {
|
||||
'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']
|
||||
@@ -98,8 +111,10 @@ declare module 'vue' {
|
||||
'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:cloudOffOutline': typeof import('~icons/mdi/cloud-off-outline')['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']
|
||||
@@ -109,12 +124,17 @@ declare module 'vue' {
|
||||
'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:linkVariantOff': typeof import('~icons/mdi/link-variant-off')['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:refresh': typeof import('~icons/mdi/refresh')['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']
|
||||
@@ -158,6 +178,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']
|
||||
@@ -165,6 +186,8 @@ 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']
|
||||
WelcomeModal: typeof import('./components/WelcomeModal.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>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<Dropdown class="dropdown-end" @click="onOpen">
|
||||
<template #trigger>
|
||||
<div class="relative">
|
||||
<mdi:cloud
|
||||
class="size-6"
|
||||
:class="
|
||||
!cloudConfig
|
||||
? 'text-base-content/40'
|
||||
: cloudConfig.linked && !cloudStatusError
|
||||
? 'text-info'
|
||||
: cloudStatusError === 'unavailable'
|
||||
? 'text-warning'
|
||||
: 'text-error'
|
||||
"
|
||||
/>
|
||||
<span
|
||||
v-if="cloudConfig?.linked"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full"
|
||||
:class="
|
||||
cloudStatusError === 'auth'
|
||||
? 'bg-error'
|
||||
: cloudStatusError === 'unavailable'
|
||||
? 'bg-warning'
|
||||
: cloudStatusError
|
||||
? 'bg-error'
|
||||
: 'bg-success'
|
||||
"
|
||||
></span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="w-80 space-y-3 p-1">
|
||||
<!-- Not linked -->
|
||||
<template v-if="!cloudConfig">
|
||||
<div class="flex flex-col items-center gap-2 p-2 text-center">
|
||||
<mdi:cloud class="text-base-content/40 text-4xl" />
|
||||
<h3 class="text-base font-bold">{{ $t("cloud.title") }}</h3>
|
||||
<p class="text-base-content/60 text-sm">{{ $t("cloud.description") }}</p>
|
||||
<div class="mt-2 flex w-full gap-2">
|
||||
<a :href="`${cloudUrl}`" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
|
||||
{{ $t("cloud.learn-more") }}
|
||||
</a>
|
||||
<a :href="cloudLinkUrl" class="btn btn-primary btn-sm flex-1">
|
||||
<mdi:link-variant class="text-base" />
|
||||
{{ $t("cloud.link-instance") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Linked -->
|
||||
<template v-else-if="cloudConfig.linked">
|
||||
<!-- Error state -->
|
||||
<div v-if="cloudStatusError" class="space-y-3">
|
||||
<div class="alert" :class="cloudStatusError === 'auth' ? 'alert-error' : 'alert-warning'">
|
||||
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
|
||||
<mdi:cloud-off-outline v-else class="text-lg" />
|
||||
<span class="text-sm">{{
|
||||
cloudStatusError === "auth" ? $t("cloud.error") : $t("cloud.error-unavailable")
|
||||
}}</span>
|
||||
</div>
|
||||
<a v-if="cloudStatusError === 'auth'" :href="cloudLinkUrl" class="btn btn-primary btn-sm w-full">
|
||||
<mdi:link-variant class="text-base" />
|
||||
{{ $t("cloud.relink-instance") }}
|
||||
</a>
|
||||
<button v-else class="btn btn-sm w-full" @click="fetchCloudStatus">
|
||||
<mdi:refresh class="text-base" />
|
||||
{{ $t("button.retry") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="isLoadingCloudStatus" class="flex items-center justify-center gap-2 py-4">
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
</div>
|
||||
|
||||
<!-- Healthy -->
|
||||
<div v-else-if="cloudStatus" class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-bold">{{ $t("cloud.title") }}</h3>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="badge badge-success badge-sm">{{ $t("cloud.connected") }}</span>
|
||||
<span class="badge badge-primary badge-sm capitalize">{{ cloudStatus.plan.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between text-sm">
|
||||
<span class="text-base-content/60">{{ $t("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 class="flex gap-2">
|
||||
<a :href="cloudUrl" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
|
||||
{{ $t("cloud.dashboard") }}
|
||||
</a>
|
||||
<a :href="`${cloudUrl}/settings`" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
|
||||
{{ $t("cloud.settings") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<WelcomeModal ref="welcomeModal" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const cloudUrl = __CLOUD_URL__;
|
||||
const callbackUrl = `${window.location.origin}${withBase("/")}`;
|
||||
const cloudLinkUrl = `${cloudUrl}/link?appUrl=${encodeURIComponent(callbackUrl)}&from=cloud`;
|
||||
|
||||
const { cloudConfig, cloudStatus, cloudStatusError, isLoadingCloudStatus, fetchCloudConfig, fetchCloudStatus } =
|
||||
useCloudConfig();
|
||||
|
||||
const welcomeModal = ref<{ open: () => void }>();
|
||||
const cloudWelcomeShown = useProfileStorage("cloudWelcomeShown", false);
|
||||
|
||||
const usagePercent = computed(() => {
|
||||
if (!cloudStatus.value) return 0;
|
||||
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
|
||||
});
|
||||
|
||||
function onOpen() {
|
||||
if (cloudConfig.value?.linked && !cloudStatus.value && !isLoadingCloudStatus.value) {
|
||||
fetchCloudStatus();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchCloudConfig();
|
||||
|
||||
if (cloudConfig.value?.linked) {
|
||||
fetchCloudStatus();
|
||||
}
|
||||
|
||||
// Handle successful OAuth return — show welcome modal
|
||||
if (window.location.hash === "#cloudLinked" && !cloudWelcomeShown.value) {
|
||||
cloudWelcomeShown.value = true;
|
||||
nextTick(() => welcomeModal.value?.open());
|
||||
history.replaceState(history.state, "", window.location.pathname + window.location.search);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Not linked -->
|
||||
<template v-if="!cloudConfig">
|
||||
<div class="flex items-start gap-4">
|
||||
<mdi:cloud class="text-base-content/40 mt-0.5 text-4xl" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-base-content/70 text-sm">{{ $t("cloud.description") }}</p>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<a :href="`${cloudUrl}`" target="_blank" rel="noreferrer noopener" class="btn btn-sm">
|
||||
{{ $t("cloud.learn-more") }}
|
||||
</a>
|
||||
<a :href="cloudLinkUrl" class="btn btn-primary btn-sm">
|
||||
<mdi:link-variant class="text-base" />
|
||||
{{ $t("cloud.link-instance") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Linked -->
|
||||
<template v-else-if="cloudConfig.linked">
|
||||
<!-- Error state -->
|
||||
<div v-if="cloudStatusError" class="space-y-3">
|
||||
<div class="alert" :class="cloudStatusError === 'auth' ? 'alert-error' : 'alert-warning'">
|
||||
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
|
||||
<mdi:cloud-off-outline v-else class="text-lg" />
|
||||
<span class="text-sm">{{
|
||||
cloudStatusError === "auth" ? $t("cloud.error") : $t("cloud.error-unavailable")
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a v-if="cloudStatusError === 'auth'" :href="cloudLinkUrl" class="btn btn-primary btn-sm">
|
||||
<mdi:link-variant class="text-base" />
|
||||
{{ $t("cloud.relink-instance") }}
|
||||
</a>
|
||||
<button v-else class="btn btn-sm" @click="fetchCloudStatus">
|
||||
<mdi:refresh class="text-base" />
|
||||
{{ $t("button.retry") }}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-error" @click="confirmUnlink">
|
||||
<mdi:link-variant-off class="text-base" />
|
||||
{{ $t("cloud.unlink") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="isLoadingCloudStatus" class="flex items-center gap-2 py-2">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
</div>
|
||||
|
||||
<!-- Healthy -->
|
||||
<div v-else-if="cloudStatus" class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge badge-success">{{ $t("cloud.connected") }}</span>
|
||||
<span class="badge badge-primary capitalize">{{ cloudStatus.plan.name }}</span>
|
||||
<span class="text-base-content/50 text-sm">{{ cloudStatus.user.email }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between text-sm">
|
||||
<span class="text-base-content/60">{{ $t("cloud.usage") }}</span>
|
||||
<span>
|
||||
{{ cloudStatus.usage.events_used.toLocaleString() }} /
|
||||
{{ cloudStatus.usage.events_limit.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
<progress
|
||||
class="progress w-full max-w-xs"
|
||||
:class="usagePercent > 90 ? 'progress-error' : usagePercent > 70 ? 'progress-warning' : 'progress-primary'"
|
||||
:value="cloudStatus.usage.events_used"
|
||||
:max="cloudStatus.usage.events_limit"
|
||||
></progress>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a :href="cloudUrl" target="_blank" rel="noreferrer noopener" class="btn btn-sm">
|
||||
{{ $t("cloud.dashboard") }}
|
||||
</a>
|
||||
<button class="btn btn-sm btn-error" @click="confirmUnlink">
|
||||
{{ $t("cloud.unlink") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Unlink confirmation modal -->
|
||||
<dialog ref="unlinkModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{{ $t("cloud.unlink") }}</h3>
|
||||
<p class="py-4 text-sm">{{ $t("cloud.unlink-confirm") }}</p>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm">{{ $t("button.cancel") }}</button>
|
||||
</form>
|
||||
<button class="btn btn-error btn-sm" :disabled="isUnlinking" @click="doUnlink">
|
||||
<span v-if="isUnlinking" class="loading loading-spinner loading-xs"></span>
|
||||
{{ $t("cloud.unlink") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button></button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const cloudUrl = __CLOUD_URL__;
|
||||
const callbackUrl = `${window.location.origin}${withBase("/")}`;
|
||||
const cloudLinkUrl = `${cloudUrl}/link?appUrl=${encodeURIComponent(callbackUrl)}&from=cloud`;
|
||||
|
||||
const {
|
||||
cloudConfig,
|
||||
cloudStatus,
|
||||
cloudStatusError,
|
||||
isLoadingCloudStatus,
|
||||
fetchCloudConfig,
|
||||
fetchCloudStatus,
|
||||
clearCloudState,
|
||||
} = useCloudConfig();
|
||||
const isUnlinking = ref(false);
|
||||
const unlinkModal = ref<HTMLDialogElement | null>(null);
|
||||
|
||||
const usagePercent = computed(() => {
|
||||
if (!cloudStatus.value) return 0;
|
||||
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
|
||||
});
|
||||
|
||||
function confirmUnlink() {
|
||||
unlinkModal.value?.showModal();
|
||||
}
|
||||
|
||||
async function doUnlink() {
|
||||
isUnlinking.value = true;
|
||||
try {
|
||||
const res = await fetch(withBase("/api/cloud/config"), { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
cloudStatusError.value = "unavailable";
|
||||
return;
|
||||
}
|
||||
clearCloudState();
|
||||
unlinkModal.value?.close();
|
||||
} finally {
|
||||
isUnlinking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchCloudConfig();
|
||||
if (cloudConfig.value?.linked) {
|
||||
fetchCloudStatus();
|
||||
}
|
||||
});
|
||||
</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>
|
||||
@@ -140,6 +154,12 @@
|
||||
{{ $t("toolbar.restart") }}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="update()" :disabled="actionStates.update">
|
||||
<carbon:upgrade />
|
||||
{{ container.isSwarm ? $t("toolbar.update-service") : $t("toolbar.update") }}
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<template v-if="enableShell && !historical">
|
||||
@@ -176,7 +196,108 @@ const showDrawer = useDrawer();
|
||||
|
||||
const { container, historical = false } = defineProps<{ container: Container; historical?: boolean }>();
|
||||
const clear = defineEmit();
|
||||
const { actionStates, start, stop, restart } = useContainerActions(toRef(() => container));
|
||||
const { actionStates, start, stop, restart, update } = 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) {
|
||||
|
||||
@@ -144,7 +144,7 @@ const list = computed(() => {
|
||||
return items;
|
||||
});
|
||||
|
||||
const { results } = useFuse(query, list, {
|
||||
const { results: fuseResults } = useFuse(query, list, {
|
||||
fuseOptions: {
|
||||
keys: ["name", "host"],
|
||||
includeScore: true,
|
||||
@@ -154,6 +154,8 @@ const { results } = useFuse(query, list, {
|
||||
},
|
||||
});
|
||||
|
||||
const results = computed(() => (query.value ? fuseResults.value : []));
|
||||
|
||||
const data = computed(() => {
|
||||
return [...results.value].sort((a: FuseResult<Item>, b: FuseResult<Item>) => {
|
||||
if (a.score === b.score) {
|
||||
|
||||
@@ -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,6 +12,8 @@
|
||||
<mdi:bell class="size-6" />
|
||||
</router-link>
|
||||
|
||||
<CloudPopover />
|
||||
|
||||
<router-link
|
||||
:to="{ name: '/settings' }"
|
||||
:aria-label="$t('title.settings')"
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</button>
|
||||
</UseClipboard>
|
||||
</div>
|
||||
<div class="bg-base-200 max-h-48 overflow-scroll rounded-sm border border-white/20 p-2">
|
||||
<div class="bg-base-200 max-h-125 overflow-scroll rounded-sm border border-white/20 p-2">
|
||||
<pre v-html="syntaxHighlight(entry.rawMessage)"></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<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) + '%'"
|
||||
@@ -19,6 +20,7 @@
|
||||
:formatter="(value: number) => value.toFixed(2) + '%'"
|
||||
/>
|
||||
<StatMonitor
|
||||
ref="memoryMonitorRef"
|
||||
:data="memoryData"
|
||||
:icon="PhMemory"
|
||||
:stat-value="formatBytes(totalStat.memoryUsage)"
|
||||
@@ -34,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
|
||||
@@ -43,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();
|
||||
@@ -52,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;
|
||||
@@ -84,6 +89,10 @@ watch(
|
||||
}
|
||||
totalStat.value = initial[0];
|
||||
reset({ initial: initial.reverse() });
|
||||
nextTick(() => {
|
||||
cpuMonitorRef.value?.recalculate();
|
||||
memoryMonitorRef.value?.recalculate();
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -5,18 +5,42 @@
|
||||
<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>
|
||||
<span class="flex gap-1 text-xs font-light" :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>
|
||||
</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>
|
||||
@@ -27,8 +51,26 @@
|
||||
<div class="text-base-content/80 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<span>{{ $t("notifications.alert.containers") }}</span>
|
||||
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.containerExpression }}</code>
|
||||
<span>{{ $t("notifications.alert.log-filter") }}</span>
|
||||
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.logExpression }}</code>
|
||||
<template v-if="alert.metricExpression">
|
||||
<span>{{ $t("notifications.alert.metric-filter") }}</span>
|
||||
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.metricExpression }}</code>
|
||||
<span>{{ $t("notifications.alert.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 -->
|
||||
@@ -59,7 +101,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { NotificationRule } from "@/types/notifications";
|
||||
import type { Dispatcher, NotificationRule } from "@/types/notifications";
|
||||
import AlertForm from "./AlertForm.vue";
|
||||
|
||||
const { alert, onUpdated } = defineProps<{
|
||||
@@ -69,6 +111,21 @@ const { alert, onUpdated } = defineProps<{
|
||||
|
||||
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);
|
||||
|
||||
@@ -21,11 +21,42 @@
|
||||
/>
|
||||
</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 w-full focus-within:z-50"
|
||||
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
|
||||
:class="
|
||||
containerExpression.trim() && !containerResult?.error
|
||||
? 'input-primary'
|
||||
@@ -52,27 +83,36 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Log Filter -->
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.log-filter") }}</legend>
|
||||
<div
|
||||
class="input focus-within:input-primary w-full focus-within:z-50"
|
||||
:class="logExpression.trim() && !logError ? 'input-primary' : { 'input-error!': logError }"
|
||||
>
|
||||
<div ref="logEditorRef" class="w-full"></div>
|
||||
</div>
|
||||
<div v-if="logError || logExpression" class="fieldset-label">
|
||||
<span v-if="logError" class="text-error">{{ logError }}</span>
|
||||
<span v-else-if="logMessages.length" class="text-success">
|
||||
<mdi:check class="inline" />
|
||||
{{ $t("notifications.alert-form.logs-match", { count: logTotalCount }) }}
|
||||
</span>
|
||||
<span v-else-if="!isLoading" class="text-warning">
|
||||
<mdi:alert class="inline" />
|
||||
{{ $t("notifications.alert-form.no-logs-match") }}
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
<!-- Type-specific fields -->
|
||||
<KeepAlive>
|
||||
<LogAlertFields
|
||||
v-if="alertType === 'log'"
|
||||
ref="fieldsRef"
|
||||
:alert="alert"
|
||||
:prefill="prefill"
|
||||
:container-expression="containerExpression"
|
||||
:is-loading="isLoading"
|
||||
:validate-preview="validatePreview"
|
||||
/>
|
||||
<MetricAlertFields
|
||||
v-else-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">
|
||||
@@ -113,16 +153,6 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Log Preview -->
|
||||
<div v-if="logMessages.length" class="mt-4">
|
||||
<div class="mb-2 text-lg">{{ $t("notifications.alert-form.preview") }}</div>
|
||||
<LogList
|
||||
:messages="logMessages"
|
||||
:last-selected-item="undefined"
|
||||
class="border-base-content/50 h-64 overflow-hidden rounded-lg border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="saveError" class="alert alert-error">
|
||||
<span>{{ saveError }}</span>
|
||||
@@ -131,7 +161,7 @@
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<button class="btn" @click="close?.()">{{ $t("notifications.alert-form.cancel") }}</button>
|
||||
<button class="btn btn-primary" :disabled="!canSave" @click="saveAlert">
|
||||
<button class="btn btn-primary" :disabled="!canSave" @click="save">
|
||||
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
|
||||
{{ isEditing ? $t("notifications.alert-form.save") : $t("notifications.alert-form.create") }}
|
||||
</button>
|
||||
@@ -140,204 +170,64 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type LogEvent, type LogEntry, type LogMessage, asLogEntry } from "@/models/LogEntry";
|
||||
import { Container } from "@/models/Container";
|
||||
import type { ContainerJson } from "@/types/Container";
|
||||
import { createExprEditor, createContainerHints, createLogHints } from "@/composable/exprEditor";
|
||||
import { useAlertForm } from "@/composable/alertForm";
|
||||
import LogAlertFields from "./LogAlertFields.vue";
|
||||
import MetricAlertFields from "./MetricAlertFields.vue";
|
||||
import EventAlertFields from "./EventAlertFields.vue";
|
||||
import type { NotificationRule } from "@/types/notifications";
|
||||
|
||||
import type { Dispatcher, NotificationRule, PreviewResult } from "@/types/notifications";
|
||||
|
||||
const { close, onCreated, alert, prefill } = defineProps<{
|
||||
const props = defineProps<{
|
||||
close?: () => void;
|
||||
onCreated?: () => void;
|
||||
alert?: NotificationRule;
|
||||
prefill?: { name?: string; containerExpression?: string; logExpression?: string };
|
||||
prefill?: {
|
||||
name?: string;
|
||||
containerExpression?: string;
|
||||
logExpression?: string;
|
||||
metricExpression?: string;
|
||||
eventExpression?: string;
|
||||
dispatcherId?: number;
|
||||
};
|
||||
}>();
|
||||
|
||||
// Fetch dispatchers
|
||||
const destinations = ref<Dispatcher[]>([]);
|
||||
onMounted(async () => {
|
||||
const res = await fetch(withBase("/api/notifications/dispatchers"));
|
||||
destinations.value = await res.json();
|
||||
});
|
||||
|
||||
// Container store for autocomplete hints
|
||||
const containerStore = useContainerStore();
|
||||
const { containers } = storeToRefs(containerStore);
|
||||
const containerNames = computed(() => [
|
||||
...new Set(containers.value.filter((c) => c.state === "running").map((c) => c.name)),
|
||||
]);
|
||||
const imageNames = computed(() => [...new Set(containers.value.map((c) => c.image))]);
|
||||
const hostNames = computed(() => [...new Set(containers.value.map((c) => c.host))]);
|
||||
const {
|
||||
isEditing,
|
||||
alertName,
|
||||
containerExpression,
|
||||
dispatcherId,
|
||||
destinations,
|
||||
selectedDestination,
|
||||
containerResult,
|
||||
isLoading,
|
||||
isSaving,
|
||||
saveError,
|
||||
baseCanSave,
|
||||
setupContainerEditor,
|
||||
saveAlert,
|
||||
validatePreview,
|
||||
} = useAlertForm(props);
|
||||
|
||||
// Template refs
|
||||
const alertNameInput = ref<HTMLInputElement>();
|
||||
const containerEditorRef = ref<HTMLElement>();
|
||||
const logEditorRef = ref<HTMLElement>();
|
||||
const destinationDropdown = ref<HTMLDetailsElement>();
|
||||
|
||||
// Form state
|
||||
const isEditing = computed(() => !!alert);
|
||||
const alertName = ref(alert?.name ?? prefill?.name ?? "");
|
||||
const containerExpression = ref(alert?.containerExpression ?? prefill?.containerExpression ?? "");
|
||||
const logExpression = ref(alert?.logExpression ?? prefill?.logExpression ?? "");
|
||||
const dispatcherId = ref(alert?.dispatcher?.id ?? 0);
|
||||
const selectedDestination = computed(() => destinations.value.find((d) => d.id === dispatcherId.value));
|
||||
const fieldsRef = ref<
|
||||
InstanceType<typeof LogAlertFields> | InstanceType<typeof MetricAlertFields> | InstanceType<typeof EventAlertFields>
|
||||
>();
|
||||
useFocus(alertNameInput, { initialValue: true });
|
||||
|
||||
// Validation state
|
||||
interface ContainerResult {
|
||||
error?: string;
|
||||
containers?: Container[];
|
||||
}
|
||||
const containerResult = ref<ContainerResult | null>(null);
|
||||
const logError = ref<string | null>(null);
|
||||
const logTotalCount = ref(0);
|
||||
const logMessages = shallowRef<LogEntry<LogMessage>[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const saveError = ref<string | null>(null);
|
||||
|
||||
const canSave = computed(
|
||||
() =>
|
||||
alertName.value.trim() &&
|
||||
containerExpression.value.trim() &&
|
||||
dispatcherId.value > 0 &&
|
||||
!containerResult.value?.error &&
|
||||
!logError.value &&
|
||||
!isSaving.value,
|
||||
// Alert type
|
||||
const alertType = ref<"log" | "metric" | "event">(
|
||||
props.alert?.metricExpression ? "metric" : props.alert?.eventExpression ? "event" : "log",
|
||||
);
|
||||
|
||||
async function saveAlert() {
|
||||
if (!canSave.value) return;
|
||||
const canSave = computed(() => baseCanSave.value && (fieldsRef.value?.canSave ?? false));
|
||||
|
||||
isSaving.value = true;
|
||||
saveError.value = null;
|
||||
|
||||
try {
|
||||
const input = {
|
||||
name: alertName.value.trim(),
|
||||
containerExpression: containerExpression.value,
|
||||
logExpression: logExpression.value,
|
||||
dispatcherId: dispatcherId.value!,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const url = isEditing.value
|
||||
? withBase(`/api/notifications/rules/${alert!.id}`)
|
||||
: withBase("/api/notifications/rules");
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: isEditing.value ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Failed to save alert");
|
||||
}
|
||||
|
||||
onCreated?.();
|
||||
close?.();
|
||||
} catch (e) {
|
||||
saveError.value = e instanceof Error ? e.message : "Failed to save alert";
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
async function save() {
|
||||
if (!canSave.value || !fieldsRef.value) return;
|
||||
await saveAlert(fieldsRef.value.typeFields);
|
||||
}
|
||||
|
||||
async function validateExpressions() {
|
||||
if (!containerExpression.value && !logExpression.value) {
|
||||
containerResult.value = null;
|
||||
logError.value = null;
|
||||
logTotalCount.value = 0;
|
||||
logMessages.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(withBase("/api/notifications/preview"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
containerExpression: containerExpression.value,
|
||||
logExpression: logExpression.value || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json();
|
||||
throw new Error(errData.error || "Preview failed");
|
||||
}
|
||||
|
||||
const data: PreviewResult = await res.json();
|
||||
|
||||
// Update container result
|
||||
containerResult.value = containerExpression.value
|
||||
? {
|
||||
error: data.containerError ?? undefined,
|
||||
containers: data.matchedContainers?.map((c) => Container.fromJSON(c as ContainerJson)),
|
||||
}
|
||||
: null;
|
||||
|
||||
// Update log result
|
||||
if (logExpression.value && !data.containerError) {
|
||||
logError.value = data.logError ?? null;
|
||||
logTotalCount.value = data.totalLogs;
|
||||
logMessages.value = data.matchedLogs?.map((event) => asLogEntry(event as LogEvent)) ?? [];
|
||||
} else {
|
||||
logError.value = null;
|
||||
logTotalCount.value = 0;
|
||||
logMessages.value = [];
|
||||
}
|
||||
} catch (e) {
|
||||
containerResult.value = { error: e instanceof Error ? e.message : "Unknown error" };
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedValidate = useDebounceFn(validateExpressions, 500);
|
||||
|
||||
watch(
|
||||
[containerExpression, logExpression],
|
||||
() => {
|
||||
isLoading.value = true;
|
||||
debouncedValidate();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
let containerEditorView: Awaited<ReturnType<typeof createExprEditor>> | undefined;
|
||||
let logEditorView: Awaited<ReturnType<typeof createExprEditor>> | undefined;
|
||||
|
||||
onMounted(async () => {
|
||||
if (containerEditorRef.value) {
|
||||
containerEditorView = await createExprEditor({
|
||||
parent: containerEditorRef.value,
|
||||
placeholder: 'name contains "api"',
|
||||
initialValue: alert?.containerExpression ?? prefill?.containerExpression ?? "",
|
||||
getHints: () => createContainerHints(containerNames.value, imageNames.value, hostNames.value),
|
||||
onChange: (v) => (containerExpression.value = v),
|
||||
});
|
||||
}
|
||||
|
||||
if (logEditorRef.value) {
|
||||
logEditorView = await createExprEditor({
|
||||
parent: logEditorRef.value,
|
||||
placeholder: 'level == "error" && message contains "timeout"',
|
||||
initialValue: alert?.logExpression ?? prefill?.logExpression ?? "",
|
||||
getHints: createLogHints,
|
||||
onChange: (v) => (logExpression.value = v),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
containerEditorView?.destroy();
|
||||
logEditorView?.destroy();
|
||||
});
|
||||
// Container editor
|
||||
setupContainerEditor(containerEditorRef);
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<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 === 'auth'
|
||||
? 'input-error'
|
||||
: cloudStatusError === 'unavailable'
|
||||
? 'input-warning'
|
||||
: 'input-success'
|
||||
"
|
||||
/>
|
||||
<span
|
||||
class="join-item btn pointer-events-none"
|
||||
:class="
|
||||
cloudStatusError === 'auth'
|
||||
? 'btn-error'
|
||||
: cloudStatusError === 'unavailable'
|
||||
? 'btn-warning'
|
||||
: 'btn-success'
|
||||
"
|
||||
>
|
||||
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
|
||||
<mdi:cloud-off-outline v-else-if="cloudStatusError === 'unavailable'" 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" :class="cloudStatusError === 'auth' ? 'alert-error' : 'alert-warning'">
|
||||
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
|
||||
<mdi:cloud-off-outline v-else class="text-lg" />
|
||||
<span>{{
|
||||
cloudStatusError === "auth"
|
||||
? $t("notifications.destination-form.cloud-relink")
|
||||
: $t("notifications.destination-form.cloud-unavailable")
|
||||
}}</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)}&from=notifications`;
|
||||
const cloudSettingsUrl = `${__CLOUD_URL__}/settings`;
|
||||
|
||||
const { cloudStatus, cloudStatusError, isLoadingCloudStatus, fetchCloudStatus } = useCloudConfig();
|
||||
|
||||
const usagePercent = computed(() => {
|
||||
if (!cloudStatus.value) return 0;
|
||||
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
|
||||
});
|
||||
|
||||
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>
|
||||
@@ -27,7 +27,7 @@
|
||||
<li>
|
||||
<a @click="editDestination">{{ $t("notifications.destination.edit") }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<li v-if="destination.type !== 'cloud'">
|
||||
<a class="text-error" @click="deleteDestination">{{ $t("notifications.destination.delete") }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -2,30 +2,33 @@
|
||||
<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")
|
||||
}}
|
||||
<template v-if="type === 'cloud'">
|
||||
{{ $t("notifications.destination-form.cloud-title") }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{
|
||||
isEditing
|
||||
? $t("notifications.destination-form.edit-title")
|
||||
: $t("notifications.destination-form.create-title")
|
||||
}}
|
||||
</template>
|
||||
</h2>
|
||||
<p class="text-base-content/60">{{ $t("notifications.destination-form.description") }}</p>
|
||||
<p class="text-base-content/60">
|
||||
<template v-if="type === 'cloud'">
|
||||
{{ $t("notifications.destination-form.cloud-description") }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t("notifications.destination-form.description") }}
|
||||
</template>
|
||||
</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">
|
||||
<!-- Type Selection (only when creating) -->
|
||||
<fieldset v-if="!isEditing" 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="card card-border cursor-pointer transition-colors"
|
||||
:class="type === 'webhook' ? 'border-primary bg-primary/10' : ''"
|
||||
>
|
||||
<div class="card-body flex-row items-center gap-3 p-4">
|
||||
@@ -39,26 +42,21 @@
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
class="card card-border border-base-content/20 transition-colors"
|
||||
class="card card-border cursor-pointer transition-colors"
|
||||
:class="[
|
||||
type === 'cloud' ? 'border-primary bg-primary/10' : '',
|
||||
hasExistingCloudDestination && type !== 'cloud' ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
|
||||
isCloudLinked ? 'cursor-not-allowed opacity-50' : '',
|
||||
]"
|
||||
>
|
||||
<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'"
|
||||
/>
|
||||
<input type="radio" v-model="type" value="cloud" class="radio radio-primary" :disabled="isCloudLinked" />
|
||||
<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">
|
||||
<div v-if="isCloudLinked" class="text-success mt-1 text-xs">
|
||||
<mdi:check class="inline" />
|
||||
{{ $t("notifications.destination-form.cloud-exists") }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,298 +65,34 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Name (only for webhook type) -->
|
||||
<fieldset v-if="type === 'webhook'" 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>
|
||||
|
||||
<!-- Cloud linked success (when editing cloud with prefix) -->
|
||||
<fieldset v-if="type === 'cloud' && 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 input-success w-full font-mono"
|
||||
/>
|
||||
<span class="join-item btn btn-success pointer-events-none">
|
||||
<mdi:check class="text-lg" />
|
||||
</span>
|
||||
</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 (only for cloud type, when creating or not linked) -->
|
||||
<div v-else-if="type === 'cloud'" 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>
|
||||
|
||||
<!-- Webhook URL (only for webhook type) -->
|
||||
<fieldset v-if="type === 'webhook'" 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 (only for webhook type) -->
|
||||
<fieldset v-if="type === 'webhook' && !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 (only for webhook type) -->
|
||||
<fieldset v-if="type === 'webhook'" 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>
|
||||
|
||||
<!-- 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
|
||||
v-if="type === 'webhook'"
|
||||
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" :class="{ 'btn-primary': type === 'cloud' }" @click="close?.()">
|
||||
{{
|
||||
type === "cloud" ? $t("notifications.destination-form.close") : $t("notifications.destination-form.cancel")
|
||||
}}
|
||||
</button>
|
||||
<button v-if="type === 'webhook'" 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>
|
||||
<!-- 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, TestWebhookResult } from "@/types/notifications";
|
||||
import { createTemplateEditor } from "@/composable/templateEditor";
|
||||
import { PAYLOAD_TEMPLATES, type PayloadFormat } from "./payloadTemplates";
|
||||
import type { Dispatcher } from "@/types/notifications";
|
||||
import WebhookDestinationForm from "./WebhookDestinationForm.vue";
|
||||
import CloudDestinationForm from "./CloudDestinationForm.vue";
|
||||
|
||||
const {
|
||||
close,
|
||||
onCreated,
|
||||
destination,
|
||||
existingDispatchers = [],
|
||||
showLinkSuccess = false,
|
||||
} = defineProps<{
|
||||
const { close, onCreated, destination } = defineProps<{
|
||||
close?: () => void;
|
||||
onCreated?: () => void;
|
||||
destination?: Dispatcher;
|
||||
existingDispatchers?: Dispatcher[];
|
||||
showLinkSuccess?: boolean;
|
||||
}>();
|
||||
|
||||
const hasExistingCloudDestination = computed(() => {
|
||||
// When editing, exclude the current destination from the check
|
||||
const others = isEditing ? existingDispatchers.filter((d) => d.id !== destination!.id) : existingDispatchers;
|
||||
return others.some((d) => d.type === "cloud");
|
||||
});
|
||||
|
||||
const isEditing = !!destination;
|
||||
|
||||
const nameInput = ref<HTMLInputElement>();
|
||||
const templateEditorRef = ref<HTMLElement>();
|
||||
const name = ref(destination?.name ?? "");
|
||||
useFocus(nameInput, { initialValue: true });
|
||||
const type = ref<"webhook" | "cloud">((destination?.type as "webhook" | "cloud") ?? "webhook");
|
||||
const webhookUrl = ref(destination?.url ?? "");
|
||||
const payloadFormat = ref<PayloadFormat>(isEditing ? "custom" : "slack");
|
||||
const template = ref(isEditing ? (destination?.template ?? "") : PAYLOAD_TEMPLATES[payloadFormat.value]);
|
||||
const isTesting = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const testResult = ref<TestWebhookResult | null>(null);
|
||||
|
||||
const callbackUrl = `${window.location.origin}${withBase("/")}`;
|
||||
const cloudLinkUrl = `${__CLOUD_URL__}/link?appUrl=${encodeURIComponent(callbackUrl)}`;
|
||||
const cloudSettingsUrl = `${__CLOUD_URL__}/settings`;
|
||||
const { cloudConfig, fetchCloudConfig } = useCloudConfig();
|
||||
const isCloudLinked = computed(() => !!cloudConfig.value?.linked);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
const canTest = computed(() => {
|
||||
if (type.value === "webhook") {
|
||||
return webhookUrl.value.trim().length > 0;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const isValidUrl = computed(() => {
|
||||
try {
|
||||
new URL(webhookUrl.value.trim());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (isSaving.value) return false;
|
||||
if (type.value === "cloud") return false;
|
||||
if (type.value === "webhook") {
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
||||
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: type.value,
|
||||
url: webhookUrl.value.trim(),
|
||||
template: template.value.trim() || undefined,
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
onMounted(() => fetchCloudConfig());
|
||||
</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>
|
||||
@@ -9,7 +9,7 @@ export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "*{{ .Container.Name }}*\n{{ .Log.Message }}",
|
||||
text: "*{{ .Container.Name }}*\n{{ .Detail }}",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -32,7 +32,7 @@ export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
|
||||
embeds: [
|
||||
{
|
||||
title: "{{ .Container.Name }}",
|
||||
description: "{{ .Log.Message }}",
|
||||
description: "{{ .Detail }}",
|
||||
fields: [
|
||||
{ name: "Host", value: "{{ .Container.HostName }}", inline: true },
|
||||
{ name: "Image", value: "{{ .Container.Image }}", inline: true },
|
||||
@@ -47,7 +47,7 @@ export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
|
||||
{
|
||||
topic: "dozzle-{{ .Container.HostName }}",
|
||||
title: "{{ .Container.Name }}",
|
||||
message: "{{ .Log.Message }}",
|
||||
message: "{{ .Detail }}",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -55,8 +55,7 @@ export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
|
||||
custom: JSON.stringify(
|
||||
{
|
||||
container: "{{ .Container.Name }}",
|
||||
level: "{{ .Log.Level }}",
|
||||
message: "{{ .Log.Message }}",
|
||||
message: "{{ .Detail }}",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<dialog ref="modal" class="modal" @close="onClose">
|
||||
<div class="modal-box max-w-md p-8">
|
||||
<!-- Step 1: Feedback -->
|
||||
<template v-if="step === 'step1'">
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
<mdi:check-circle class="text-success text-4xl" />
|
||||
<h3 class="text-xl font-bold">{{ $t("cloud.welcome.title") }}</h3>
|
||||
<p class="text-base-content/60 text-sm">{{ $t("cloud.welcome.subtitle") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="mb-3 text-sm font-medium">{{ $t("cloud.welcome.question") }}</p>
|
||||
|
||||
<textarea
|
||||
v-model="intent"
|
||||
class="textarea textarea-bordered w-full text-sm"
|
||||
rows="3"
|
||||
:placeholder="$t('cloud.welcome.placeholder')"
|
||||
></textarea>
|
||||
|
||||
<p class="text-base-content/60 mt-3 mb-2 text-xs">{{ $t("cloud.welcome.or-pick") }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="option in chipOptions"
|
||||
:key="option.value"
|
||||
class="btn btn-sm"
|
||||
:class="selectedOptions.has(option.value) ? 'btn-primary' : 'btn-outline'"
|
||||
@click="toggleOption(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-block mt-6" :disabled="submitting" @click="submitFeedback">
|
||||
<span v-if="submitting" class="loading loading-spinner loading-xs"></span>
|
||||
{{ $t("cloud.welcome.get-started") }}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-block btn-sm mt-1" :disabled="submitting" @click="skipFeedback">
|
||||
{{ $t("cloud.welcome.skip") }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: Onboarding Checklist -->
|
||||
<template v-else-if="step === 'step2'">
|
||||
<h3 class="text-xl font-bold">{{ $t("cloud.welcome.step2-title") }}</h3>
|
||||
<p class="text-base-content/60 mt-1 text-sm">{{ $t("cloud.welcome.step2-subtitle") }}</p>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div v-for="(item, index) in checklistItems" :key="index" class="flex gap-3">
|
||||
<div
|
||||
class="flex size-7 shrink-0 items-center justify-center rounded-full text-xs font-bold"
|
||||
:class="index === 0 ? 'bg-primary text-primary-content' : 'bg-base-200 text-base-content/60'"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<div>
|
||||
<component
|
||||
:is="item.href ? 'a' : 'p'"
|
||||
class="text-sm font-semibold"
|
||||
:class="item.href ? 'link link-hover' : ''"
|
||||
:href="item.href"
|
||||
:target="item.href ? '_blank' : undefined"
|
||||
:rel="item.href ? 'noreferrer noopener' : undefined"
|
||||
>
|
||||
{{ item.title }}
|
||||
</component>
|
||||
<p class="text-base-content/60 text-xs">{{ item.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-block mt-6" @click="createFirstAlert">
|
||||
{{ $t("cloud.welcome.create-alert") }}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-block btn-sm mt-1" @click="close">
|
||||
{{ $t("cloud.welcome.later") }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button></button>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const cloudUrl = __CLOUD_URL__;
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const modal = ref<HTMLDialogElement>();
|
||||
const step = ref<"step1" | "step2">("step1");
|
||||
const intent = ref("");
|
||||
const selectedOptions = ref(new Set<string>());
|
||||
const submitting = ref(false);
|
||||
let feedbackSent = false;
|
||||
|
||||
const chipOptions = [
|
||||
{ value: "alerts", label: t("cloud.welcome.chip-alerts") },
|
||||
{ value: "daily_summary", label: t("cloud.welcome.chip-summary") },
|
||||
{ value: "multiple_hosts", label: t("cloud.welcome.chip-hosts") },
|
||||
{ value: "something_else", label: t("cloud.welcome.chip-other") },
|
||||
];
|
||||
|
||||
const checklistItems = computed(() => [
|
||||
{
|
||||
title: t("cloud.welcome.checklist-alert-title"),
|
||||
description: t("cloud.welcome.checklist-alert-desc"),
|
||||
},
|
||||
{
|
||||
title: t("cloud.welcome.checklist-notify-title"),
|
||||
description: t("cloud.welcome.checklist-notify-desc"),
|
||||
href: `${cloudUrl}/channels`,
|
||||
},
|
||||
{
|
||||
title: t("cloud.welcome.checklist-agent-title"),
|
||||
description: t("cloud.welcome.checklist-agent-desc"),
|
||||
href: `${cloudUrl}/assistant`,
|
||||
},
|
||||
]);
|
||||
|
||||
function toggleOption(value: string) {
|
||||
const next = new Set(selectedOptions.value);
|
||||
if (next.has(value)) {
|
||||
next.delete(value);
|
||||
} else {
|
||||
next.add(value);
|
||||
}
|
||||
selectedOptions.value = next;
|
||||
}
|
||||
|
||||
async function postFeedback(skipped: boolean) {
|
||||
try {
|
||||
await fetch(withBase("/api/cloud/feedback"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
source: "welcome_modal",
|
||||
intent: skipped ? undefined : intent.value || undefined,
|
||||
selectedOptions: skipped ? undefined : Array.from(selectedOptions.value),
|
||||
skipped,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// Feedback failure should not block the user
|
||||
}
|
||||
}
|
||||
|
||||
const onNotificationsPage = computed(() => route.path === "/notifications");
|
||||
|
||||
async function submitFeedback() {
|
||||
submitting.value = true;
|
||||
feedbackSent = true;
|
||||
await postFeedback(false);
|
||||
submitting.value = false;
|
||||
if (onNotificationsPage.value) {
|
||||
createFirstAlert();
|
||||
} else {
|
||||
step.value = "step2";
|
||||
}
|
||||
}
|
||||
|
||||
async function skipFeedback() {
|
||||
submitting.value = true;
|
||||
feedbackSent = true;
|
||||
await postFeedback(true);
|
||||
submitting.value = false;
|
||||
if (onNotificationsPage.value) {
|
||||
createFirstAlert();
|
||||
} else {
|
||||
step.value = "step2";
|
||||
}
|
||||
}
|
||||
|
||||
function createFirstAlert() {
|
||||
close();
|
||||
router.push({ path: "/notifications", query: { action: "create-alert" } });
|
||||
}
|
||||
|
||||
function open() {
|
||||
step.value = "step1";
|
||||
intent.value = "";
|
||||
selectedOptions.value = new Set();
|
||||
feedbackSent = false;
|
||||
modal.value?.showModal();
|
||||
}
|
||||
|
||||
function close() {
|
||||
modal.value?.close();
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
if (step.value === "step1" && !feedbackSent) {
|
||||
feedbackSent = true;
|
||||
postFeedback(true);
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="toast toast-end whitespace-normal max-md:end-auto max-md:m-0 max-md:max-w-full">
|
||||
<div class="toast toast-end max-md:toast-center max-md:toast-bottom whitespace-normal max-md:w-full max-md:px-2">
|
||||
<div
|
||||
class="alert max-w-xl shadow-sm max-md:rounded-none"
|
||||
class="alert max-w-xl shadow-sm max-md:w-full max-md:rounded-lg"
|
||||
v-for="{ toast, options: { timed } } in toasts"
|
||||
:key="toast.id"
|
||||
:class="{
|
||||
@@ -10,14 +10,14 @@
|
||||
'alert-warning': toast.type === 'warning',
|
||||
}"
|
||||
>
|
||||
<carbon:information class="size-6 shrink-0 stroke-current" v-if="toast.type === 'info'" />
|
||||
<carbon:warning class="size-6 shrink-0 stroke-current" v-else-if="toast.type === 'error'" />
|
||||
<carbon:warning class="size-6 shrink-0 stroke-current" v-else-if="toast.type === 'warning'" />
|
||||
<div>
|
||||
<h3 class="text-lg font-bold" v-if="toast.title">{{ toast.title }}</h3>
|
||||
<div v-html="toast.message" class="[&>a]:underline"></div>
|
||||
<carbon:information class="size-5 shrink-0 stroke-current" v-if="toast.type === 'info'" />
|
||||
<carbon:warning class="size-5 shrink-0 stroke-current" v-else-if="toast.type === 'error'" />
|
||||
<carbon:warning class="size-5 shrink-0 stroke-current" v-else-if="toast.type === 'warning'" />
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-lg font-bold max-md:text-base" v-if="toast.title">{{ toast.title }}</h3>
|
||||
<div v-html="toast.message" class="max-md:text-sm [&>a]:underline"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="shrink-0">
|
||||
<TimedButton
|
||||
v-if="timed"
|
||||
class="btn-primary btn-sm"
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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;
|
||||
dispatcherId?: number;
|
||||
};
|
||||
}
|
||||
|
||||
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 ?? options.prefill?.dispatcherId ?? -1);
|
||||
const isSaving = ref(false);
|
||||
const saveError = ref<string | null>(null);
|
||||
|
||||
// Destinations (cloud dispatcher with id=0 is included by the backend when configured)
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { CloudConfig, CloudStatus } from "@/types/notifications";
|
||||
|
||||
// Shared state across all component instances
|
||||
const cloudConfig = ref<CloudConfig | null>(null);
|
||||
const cloudStatus = ref<CloudStatus | null>(null);
|
||||
const cloudStatusError = ref<"auth" | "unavailable" | false>(false);
|
||||
const isLoadingCloudStatus = ref(false);
|
||||
|
||||
async function fetchCloudConfig() {
|
||||
try {
|
||||
const res = await fetch(withBase("/api/cloud/config"));
|
||||
if (!res.ok) {
|
||||
cloudConfig.value = null;
|
||||
return;
|
||||
}
|
||||
cloudConfig.value = await res.json();
|
||||
} catch {
|
||||
cloudConfig.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCloudStatus() {
|
||||
if (!cloudConfig.value?.linked) return;
|
||||
isLoadingCloudStatus.value = true;
|
||||
cloudStatusError.value = false;
|
||||
try {
|
||||
const res = await fetch(withBase("/api/cloud/status"));
|
||||
if (!res.ok) {
|
||||
cloudStatusError.value = res.status === 401 || res.status === 403 ? "auth" : "unavailable";
|
||||
return;
|
||||
}
|
||||
cloudStatus.value = await res.json();
|
||||
} catch {
|
||||
cloudStatusError.value = "unavailable";
|
||||
} finally {
|
||||
isLoadingCloudStatus.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearCloudState() {
|
||||
cloudConfig.value = null;
|
||||
cloudStatus.value = null;
|
||||
cloudStatusError.value = false;
|
||||
}
|
||||
|
||||
export function useCloudConfig() {
|
||||
return {
|
||||
cloudConfig,
|
||||
cloudStatus,
|
||||
cloudStatusError,
|
||||
isLoadingCloudStatus,
|
||||
fetchCloudConfig,
|
||||
fetchCloudStatus,
|
||||
clearCloudState,
|
||||
};
|
||||
}
|
||||
@@ -2,25 +2,27 @@ import { Container } from "@/models/Container";
|
||||
|
||||
type ContainerActions = "start" | "stop" | "restart";
|
||||
export const useContainerActions = (container: Ref<Container>) => {
|
||||
const { showToast } = useToast();
|
||||
const { showToast, removeToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
const actionStates = reactive({
|
||||
stop: false,
|
||||
restart: false,
|
||||
start: false,
|
||||
update: false,
|
||||
});
|
||||
|
||||
async function actionHandler(action: ContainerActions) {
|
||||
const actionUrl = `/api/hosts/${container.value.host}/containers/${container.value.id}/actions/${action}`;
|
||||
|
||||
const errors = {
|
||||
404: "container not found",
|
||||
500: "unable to complete action",
|
||||
400: "invalid action",
|
||||
404: t("error.container-not-found"),
|
||||
500: t("error.unable-to-complete-action"),
|
||||
400: t("error.invalid-action"),
|
||||
} as Record<number, string>;
|
||||
|
||||
const defaultError = "something went wrong";
|
||||
const toastTitle = "Action Failed";
|
||||
const defaultError = t("error.something-went-wrong");
|
||||
const toastTitle = t("error.action-failed");
|
||||
|
||||
actionStates[action] = true;
|
||||
|
||||
@@ -37,10 +39,103 @@ export const useContainerActions = (container: Ref<Container>) => {
|
||||
actionStates[action] = false;
|
||||
}
|
||||
|
||||
async function update() {
|
||||
const updateUrl = `/api/hosts/${container.value.host}/containers/${container.value.id}/actions/update`;
|
||||
const toastId = "container-update";
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
|
||||
actionStates.update = true;
|
||||
|
||||
showToast(
|
||||
{
|
||||
id: toastId,
|
||||
title: t("toolbar.update"),
|
||||
message: t("toolbar.update-pulling"),
|
||||
type: "info",
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(withBase(updateUrl), { method: "POST" });
|
||||
if (!response.ok) {
|
||||
removeToast(toastId);
|
||||
showToast({ type: "error", message: t("error.unable-to-update"), title: t("error.update-failed") });
|
||||
return;
|
||||
}
|
||||
|
||||
reader = response.body?.getReader();
|
||||
if (!reader) return;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const chunk of lines) {
|
||||
const dataLine = chunk.split("\n").find((l) => l.startsWith("data: "));
|
||||
if (!dataLine) continue;
|
||||
|
||||
const data = JSON.parse(dataLine.slice(6));
|
||||
|
||||
switch (data.status) {
|
||||
case "pulling":
|
||||
break;
|
||||
case "recreating":
|
||||
removeToast(toastId);
|
||||
showToast(
|
||||
{
|
||||
id: toastId,
|
||||
title: t("toolbar.update"),
|
||||
message: t("toolbar.update-recreating"),
|
||||
type: "info",
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
break;
|
||||
case "done":
|
||||
case "up-to-date":
|
||||
removeToast(toastId);
|
||||
showToast(
|
||||
{
|
||||
title: t("toolbar.update"),
|
||||
message: t(`toolbar.update-${data.status}`),
|
||||
type: "info",
|
||||
},
|
||||
{ expire: 3000 },
|
||||
);
|
||||
break;
|
||||
case "error":
|
||||
removeToast(toastId);
|
||||
showToast({
|
||||
type: "error",
|
||||
message: data.error || t("error.unknown-error"),
|
||||
title: t("error.update-failed"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
removeToast(toastId);
|
||||
showToast({ type: "error", message: t("error.something-went-wrong"), title: t("error.update-failed") });
|
||||
} finally {
|
||||
reader?.cancel();
|
||||
actionStates.update = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
actionStates,
|
||||
start: () => actionHandler("start"),
|
||||
stop: () => actionHandler("stop"),
|
||||
restart: () => actionHandler("restart"),
|
||||
update,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,14 +14,11 @@ import {
|
||||
} from "@/models/LogEntry";
|
||||
import { Service, Stack } from "@/models/Stack";
|
||||
import { Container, GroupedContainers } from "@/models/Container";
|
||||
import { parseMessage } from "@/composable/loadBetween";
|
||||
import { useLogLoader } from "@/composable/logLoader";
|
||||
|
||||
const { isSearching, debouncedSearchFilter } = useSearchFilter();
|
||||
|
||||
function parseMessage(data: string): LogEntry<LogMessage> {
|
||||
const e = JSON.parse(data) as LogEvent;
|
||||
return asLogEntry(e);
|
||||
}
|
||||
|
||||
export function useContainerStream(container: Ref<Container>): LogStreamSource {
|
||||
const url = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs/stream`);
|
||||
return useLogStream(url, container);
|
||||
@@ -73,9 +70,23 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
const { paused: scrollingPaused } = useScrollContext();
|
||||
const { streamConfig, hasComplexLogs, levels, loadingMore } = useLoggingContext();
|
||||
const { streamConfig, hasComplexLogs, levels, loadingMore, containers } = useLoggingContext();
|
||||
let initial = true;
|
||||
|
||||
const params = computed(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (streamConfig.value.stdout) params.append("stdout", "1");
|
||||
if (streamConfig.value.stderr) params.append("stderr", "1");
|
||||
if (isSearching.value) params.append("filter", debouncedSearchFilter.value);
|
||||
for (const level of levels.value) {
|
||||
params.append("levels", level);
|
||||
}
|
||||
return params;
|
||||
});
|
||||
|
||||
const allContainers = computed(() => (container ? [container.value] : containers.value));
|
||||
const { loadOlderLogs, loadSkippedLogs } = useLogLoader(messages, allContainers, params, loadingMore);
|
||||
|
||||
function flushNow() {
|
||||
if (messages.value.length + buffer.value.length > config.maxLogs) {
|
||||
if (scrollingPaused.value === true) {
|
||||
@@ -105,7 +116,7 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
// sort the buffer the very first time because of multiple logs in parallel
|
||||
buffer.value.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
|
||||
if (container) {
|
||||
if (container || containers.value.length > 0) {
|
||||
const loadMoreItem = new LoadMoreLogEntry(new Date(), loadOlderLogs);
|
||||
messages.value = [loadMoreItem];
|
||||
}
|
||||
@@ -131,17 +142,6 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
buffer.value = [];
|
||||
}
|
||||
|
||||
const params = computed(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (streamConfig.value.stdout) params.append("stdout", "1");
|
||||
if (streamConfig.value.stderr) params.append("stderr", "1");
|
||||
if (isSearching.value) params.append("filter", debouncedSearchFilter.value);
|
||||
for (const level of levels.value) {
|
||||
params.append("levels", level);
|
||||
}
|
||||
return params;
|
||||
});
|
||||
|
||||
const urlWithParams = computed(() => withBase(`${url.value}?${params.value.toString()}`));
|
||||
|
||||
function connect({ clear } = { clear: true }) {
|
||||
@@ -194,51 +194,6 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
|
||||
watch(urlWithParams, () => connect(), { immediate: true });
|
||||
|
||||
async function loadOlderLogs(entry: LoadMoreLogEntry) {
|
||||
if (!(messages.value[0] instanceof LoadMoreLogEntry)) throw new Error("No loadMoreLogEntry on first item");
|
||||
if (!container) throw new Error("No container");
|
||||
|
||||
const [loader, ...existingLogs] = messages.value;
|
||||
const to = existingLogs[0].date;
|
||||
const lastSeenId = existingLogs[0].id;
|
||||
const last = messages.value[Math.min(messages.value.length - 1, 300)].date;
|
||||
const delta = to.getTime() - last.getTime();
|
||||
const from = new Date(to.getTime() + delta);
|
||||
try {
|
||||
loadingMore.value = true;
|
||||
const { logs: newLogs, signal } = await loadBetween(container, params, from, to, {
|
||||
min: 100,
|
||||
lastSeenId,
|
||||
});
|
||||
if (newLogs && signal.aborted === false) {
|
||||
messages.value = [loader, ...newLogs, ...existingLogs];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSkippedLogs(entry: SkippedLogsEntry) {
|
||||
if (!container) throw new Error("No container");
|
||||
|
||||
const from = entry.firstSkipped.date;
|
||||
const to = entry.lastSkippedLog.date;
|
||||
const lastSeenId = entry.lastSkippedLog.id;
|
||||
try {
|
||||
loadingMore.value = true;
|
||||
const { logs, signal } = await loadBetween(container, params, from, to, { lastSeenId });
|
||||
if (logs && signal.aborted === false) {
|
||||
messages.value = messages.value.slice(logs.length).flatMap((log) => (log === entry ? logs : [log]));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onScopeDispose(() => close());
|
||||
|
||||
watch(messages, () => {
|
||||
@@ -254,52 +209,3 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
|
||||
loading,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadBetween(
|
||||
container: Ref<Container>,
|
||||
params: Ref<URLSearchParams>,
|
||||
from: Date,
|
||||
to: Date,
|
||||
{
|
||||
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();
|
||||
const signal = abortController.signal;
|
||||
|
||||
const urlWithMoreParams = computed(() => {
|
||||
const loadMoreParams = new URLSearchParams(params.value);
|
||||
loadMoreParams.append("from", from.toISOString());
|
||||
loadMoreParams.append("to", to.toISOString());
|
||||
if (min) {
|
||||
loadMoreParams.append("min", String(min));
|
||||
}
|
||||
if (maxStart) {
|
||||
loadMoreParams.append("maxStart", String(maxStart));
|
||||
}
|
||||
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"));
|
||||
const logs = await (await fetch(urlWithMoreParams.value, { signal })).text();
|
||||
stopWatcher();
|
||||
|
||||
if (!logs) return { logs: [], signal };
|
||||
|
||||
return {
|
||||
logs: logs
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => parseMessage(line)),
|
||||
signal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function createContainerHints(
|
||||
];
|
||||
}
|
||||
|
||||
export function createLogHints(): Completion[] {
|
||||
export function createLogHints(messageKeys?: string[]): Completion[] {
|
||||
return [
|
||||
{ label: "message", detail: "log message content", type: "property" },
|
||||
{ label: "level", detail: "log level", type: "property" },
|
||||
@@ -58,6 +58,9 @@ export function createLogHints(): Completion[] {
|
||||
{ label: "type", detail: "log type", type: "property" },
|
||||
{ label: "timestamp", detail: "unix timestamp", type: "property" },
|
||||
{ label: "id", detail: "log entry ID", type: "property" },
|
||||
...(messageKeys ?? []).map(
|
||||
(key) => ({ 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" },
|
||||
@@ -72,6 +75,38 @@ export function createLogHints(): Completion[] {
|
||||
];
|
||||
}
|
||||
|
||||
export function createMetricHints(): Completion[] {
|
||||
return [
|
||||
{ label: "cpu", detail: "CPU usage percent", type: "property" },
|
||||
{ label: "memory", detail: "memory usage percent", type: "property" },
|
||||
{ label: "memoryUsage", detail: "memory usage bytes", type: "property" },
|
||||
...exprOperators,
|
||||
{ label: ">", detail: "greater than", type: "operator" },
|
||||
{ label: "<", detail: "less than", type: "operator" },
|
||||
{ label: ">=", detail: "greater or equal", type: "operator" },
|
||||
{ label: "<=", detail: "less or equal", type: "operator" },
|
||||
{ label: "cpu > 80", detail: "CPU over 80%", type: "text", boost: 10 },
|
||||
{ label: "memory > 90", detail: "memory over 90%", type: "text", boost: 10 },
|
||||
{ label: "cpu > 80 || memory > 90", detail: "CPU or memory high", type: "text", boost: 10 },
|
||||
];
|
||||
}
|
||||
|
||||
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"=!&|]+/);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { HistoricalContainer } from "@/models/Container";
|
||||
import { LogMessage, LoadMoreLogEntry, LogEntry } from "@/models/LogEntry";
|
||||
import { ShallowRef } from "vue";
|
||||
import { loadBetween } from "@/composable/eventStreams";
|
||||
import { loadBetween } from "@/composable/loadBetween";
|
||||
|
||||
export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalContainer>): LogStreamSource {
|
||||
const messages: ShallowRef<LogEntry<LogMessage>[]> = shallowRef([]);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { type Ref } from "vue";
|
||||
import { type LogEvent, type LogMessage, LogEntry, asLogEntry } from "@/models/LogEntry";
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
export function parseMessage(data: string): LogEntry<LogMessage> {
|
||||
const e = JSON.parse(data) as LogEvent;
|
||||
return asLogEntry(e);
|
||||
}
|
||||
|
||||
export async function loadBetween(
|
||||
container: Container | Ref<Container>,
|
||||
params: Ref<URLSearchParams>,
|
||||
from: Date,
|
||||
to: Date,
|
||||
{
|
||||
lastSeenId,
|
||||
startId,
|
||||
min,
|
||||
maxStart,
|
||||
}: { lastSeenId?: number; startId?: number; min?: number; maxStart?: number } = {},
|
||||
) {
|
||||
const c = toValue(container);
|
||||
const url = `/api/hosts/${c.host}/containers/${c.id}/logs`;
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
function buildUrl() {
|
||||
const loadMoreParams = new URLSearchParams(params.value);
|
||||
loadMoreParams.append("from", from.toISOString());
|
||||
loadMoreParams.append("to", to.toISOString());
|
||||
if (min) {
|
||||
loadMoreParams.append("min", String(min));
|
||||
}
|
||||
if (maxStart) {
|
||||
loadMoreParams.append("maxStart", String(maxStart));
|
||||
}
|
||||
if (lastSeenId) {
|
||||
loadMoreParams.append("lastSeenId", String(lastSeenId));
|
||||
}
|
||||
if (startId) {
|
||||
loadMoreParams.append("startId", String(startId));
|
||||
}
|
||||
return withBase(`${url}?${loadMoreParams.toString()}`);
|
||||
}
|
||||
|
||||
const fullUrl = buildUrl();
|
||||
const stopWatcher = watchOnce(params, () => abortController.abort("stream changed"));
|
||||
const logs = await (await fetch(fullUrl, { signal })).text();
|
||||
stopWatcher();
|
||||
|
||||
if (!logs) return { logs: [] as LogEntry<LogMessage>[], signal };
|
||||
|
||||
return {
|
||||
logs: logs
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => parseMessage(line)),
|
||||
signal,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ShallowRef, type Ref } from "vue";
|
||||
import { type LogMessage, LogEntry, LoadMoreLogEntry, SkippedLogsEntry } from "@/models/LogEntry";
|
||||
import { Container } from "@/models/Container";
|
||||
import { loadBetween } from "@/composable/loadBetween";
|
||||
|
||||
// Matches the rolling window size used for stats history
|
||||
const LOG_WINDOW_FOR_DELTA = 300;
|
||||
|
||||
export function useLogLoader(
|
||||
messages: ShallowRef<LogEntry<LogMessage>[]>,
|
||||
containers: Ref<Container[]>,
|
||||
params: Ref<URLSearchParams>,
|
||||
loadingMore: Ref<boolean>,
|
||||
) {
|
||||
async function loadOlderLogs(entry: LoadMoreLogEntry) {
|
||||
if (!(messages.value[0] instanceof LoadMoreLogEntry)) throw new Error("No loadMoreLogEntry on first item");
|
||||
if (containers.value.length === 0) return;
|
||||
|
||||
const [loader, ...existingLogs] = messages.value;
|
||||
if (existingLogs.length === 0) return;
|
||||
|
||||
const containerIDs = new Set(containers.value.map((c) => c.id));
|
||||
const earliestByContainer = new Map<string, LogEntry<LogMessage>>();
|
||||
const countByContainer = new Map<string, number>();
|
||||
const nthByContainer = new Map<string, LogEntry<LogMessage>>();
|
||||
for (const log of existingLogs) {
|
||||
const id = log.containerID;
|
||||
if (!id || !containerIDs.has(id)) continue;
|
||||
if (!earliestByContainer.has(id)) {
|
||||
earliestByContainer.set(id, log);
|
||||
}
|
||||
const count = (countByContainer.get(id) ?? 0) + 1;
|
||||
countByContainer.set(id, count);
|
||||
if (count <= LOG_WINDOW_FOR_DELTA) {
|
||||
nthByContainer.set(id, log);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
loadingMore.value = true;
|
||||
const minPerContainer = Math.ceil(100 / containers.value.length);
|
||||
|
||||
const results = await Promise.all(
|
||||
containers.value.map((c) => {
|
||||
const earliest = earliestByContainer.get(c.id);
|
||||
const to = earliest?.date ?? existingLogs[0].date;
|
||||
const nth = nthByContainer.get(c.id);
|
||||
const delta = to.getTime() - (nth?.date ?? to).getTime();
|
||||
const from = new Date(to.getTime() + (delta !== 0 ? delta : -60_000));
|
||||
return loadBetween(c, params, from, to, {
|
||||
min: minPerContainer,
|
||||
lastSeenId: earliest?.id,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const allNewLogs = results
|
||||
.filter(({ signal }) => !signal.aborted)
|
||||
.flatMap(({ logs }) => logs)
|
||||
.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
|
||||
if (allNewLogs.length > 0) {
|
||||
messages.value = [loader, ...allNewLogs, ...existingLogs];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSkippedLogs(entry: SkippedLogsEntry) {
|
||||
if (containers.value.length === 0) return;
|
||||
|
||||
const from = entry.firstSkipped.date;
|
||||
const to = entry.lastSkippedLog.date;
|
||||
const ownerContainerID = entry.lastSkippedLog.containerID;
|
||||
|
||||
try {
|
||||
loadingMore.value = true;
|
||||
const results = await Promise.all(
|
||||
containers.value.map((c) => {
|
||||
const lastSeenId = c.id === ownerContainerID ? entry.lastSkippedLog.id : undefined;
|
||||
return loadBetween(c, params, from, to, { lastSeenId });
|
||||
}),
|
||||
);
|
||||
const allLogs = results
|
||||
.filter(({ signal }) => !signal.aborted)
|
||||
.flatMap(({ logs }) => logs)
|
||||
.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
|
||||
if (allLogs.length > 0) {
|
||||
const updated = messages.value.flatMap((log) => (log === entry ? allLogs : [log]));
|
||||
messages.value = updated.length > config.maxLogs ? updated.slice(-config.maxLogs) : updated;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { loadOlderLogs, loadSkippedLogs };
|
||||
}
|
||||
@@ -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,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();
|
||||
});
|
||||
}
|
||||
+1
-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 {
|
||||
|
||||
@@ -7,3 +7,7 @@ Object.values(import.meta.glob<{ install: (app: VueApp) => void }>("./modules/*.
|
||||
i.install?.(app),
|
||||
);
|
||||
app.mount("#app");
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register(withBase("/sw.js"));
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export class Container {
|
||||
stats: Stat[],
|
||||
public readonly group?: string,
|
||||
public health?: ContainerHealth,
|
||||
public isNew: boolean = false,
|
||||
) {
|
||||
const defaultStat = { cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 } as Stat;
|
||||
this._stat = ref(stats.at(-1) || defaultStat);
|
||||
|
||||
@@ -12,16 +12,9 @@
|
||||
<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 -->
|
||||
|
||||
<template v-if="dispatchers.length === 0">
|
||||
<p class="text-base-content/60 mb-4 text-sm">{{ $t("notifications.empty-state.description") }}</p>
|
||||
<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"
|
||||
@@ -31,17 +24,36 @@
|
||||
<span class="text-base-content/60 text-sm">{{ $t("notifications.add-destination") }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Alerts Section -->
|
||||
<div>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-base-content/60 font-semibold tracking-wide uppercase">{{ $t("notifications.alerts") }}</h3>
|
||||
<button class="btn btn-primary btn-sm" @click="openCreateAlert">
|
||||
<mdi:plus />
|
||||
{{ $t("notifications.add") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
@@ -58,11 +70,17 @@
|
||||
</div>
|
||||
|
||||
<!-- Alerts List -->
|
||||
<div v-if="!alerts.length" class="text-base-content/60 py-4">
|
||||
{{ $t("notifications.no-alerts") }}
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<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>
|
||||
@@ -73,10 +91,11 @@
|
||||
import type { NotificationRule, Dispatcher } from "@/types/notifications";
|
||||
import AlertForm from "@/components/Notification/AlertForm.vue";
|
||||
import DestinationForm from "@/components/Notification/DestinationForm.vue";
|
||||
import DestinationCard from "@/components/Notification/DestinationCard.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const showDrawer = useDrawer();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// State
|
||||
const alerts = ref<NotificationRule[]>([]);
|
||||
@@ -96,30 +115,29 @@ 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (hash === "#cloudLinked") {
|
||||
router.replace({ hash: "" });
|
||||
}
|
||||
|
||||
if (route.query.action === "create-alert") {
|
||||
router.replace({ query: {} });
|
||||
openCreateAlertPrefilled();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.action,
|
||||
(action) => {
|
||||
if (action === "create-alert") {
|
||||
router.replace({ query: {} });
|
||||
openCreateAlertPrefilled();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Local state
|
||||
const filter = ref<"all" | "enabled" | "paused">("all");
|
||||
|
||||
@@ -136,12 +154,27 @@ function openCreateAlert() {
|
||||
showDrawer(AlertForm, { onCreated: fetchAlerts }, "lg");
|
||||
}
|
||||
|
||||
function openCreateAlertPrefilled() {
|
||||
const cloudDispatcher = dispatchers.value.find((d) => d.type === "cloud");
|
||||
showDrawer(
|
||||
AlertForm,
|
||||
{
|
||||
onCreated: fetchAlerts,
|
||||
prefill: {
|
||||
name: t("notifications.prefill-name"),
|
||||
logExpression: t("notifications.prefill-expression"),
|
||||
...(cloudDispatcher ? { dispatcherId: cloudDispatcher.id } : {}),
|
||||
},
|
||||
},
|
||||
"lg",
|
||||
);
|
||||
}
|
||||
|
||||
function openAddDestination() {
|
||||
showDrawer(
|
||||
DestinationForm,
|
||||
{
|
||||
onCreated: fetchDispatchers,
|
||||
existingDispatchers: dispatchers.value,
|
||||
},
|
||||
"md",
|
||||
);
|
||||
|
||||
@@ -37,6 +37,13 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="has-underline">
|
||||
<h2>{{ $t("cloud.title") }}</h2>
|
||||
</div>
|
||||
<CloudSettingsCard />
|
||||
</section>
|
||||
|
||||
<section class="@container flex flex-col">
|
||||
<div class="has-underline">
|
||||
<h2>{{ $t("settings.display") }}</h2>
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface Profile {
|
||||
visibleKeys?: Map<string, Map<string[], boolean>>;
|
||||
releaseSeen?: string;
|
||||
collapsedGroups?: Set<string>;
|
||||
cloudWelcomeShown?: boolean;
|
||||
}
|
||||
|
||||
const pageConfig = JSON.parse(text);
|
||||
|
||||
@@ -135,12 +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(Container.fromJSON)];
|
||||
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
+2
-1
@@ -17,7 +17,8 @@ import type {
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface TypesConfig {
|
||||
ParamParsers: never
|
||||
ParamParsers:
|
||||
| never
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ export interface NotificationRule {
|
||||
enabled: boolean;
|
||||
containerExpression: string;
|
||||
logExpression: string;
|
||||
metricExpression?: string;
|
||||
eventExpression?: string;
|
||||
cooldown?: number;
|
||||
sampleWindow?: number;
|
||||
triggerCount: number;
|
||||
triggeredContainers: number;
|
||||
lastTriggeredAt: string | null;
|
||||
@@ -16,6 +20,7 @@ export interface Dispatcher {
|
||||
type: string;
|
||||
url?: string;
|
||||
template?: string;
|
||||
headers?: Record<string, string>;
|
||||
prefix?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
@@ -26,11 +31,17 @@ export interface NotificationRuleInput {
|
||||
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;
|
||||
@@ -47,6 +58,7 @@ export interface PreviewResult {
|
||||
s: string;
|
||||
}[];
|
||||
totalLogs: number;
|
||||
messageKeys?: string[];
|
||||
}
|
||||
|
||||
export interface TestWebhookResult {
|
||||
@@ -54,3 +66,15 @@ export interface TestWebhookResult {
|
||||
statusCode?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CloudConfig {
|
||||
prefix: string;
|
||||
expiresAt?: string;
|
||||
linked: boolean;
|
||||
}
|
||||
|
||||
export 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 };
|
||||
}
|
||||
|
||||
@@ -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.58.2-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
|
||||
@@ -8,3 +8,4 @@ node_modules
|
||||
.idea/
|
||||
*.log
|
||||
!.vscode
|
||||
superpowers
|
||||
|
||||
+6
-3
@@ -35,7 +35,7 @@ services:
|
||||
:::
|
||||
|
||||
> [!NOTE] Docker Socket Proxy users
|
||||
> If you are using a remote agent you **CANNOT** add a socket proxy on top of the agent. Dozzle agents **REPLACE** using a proxy, see [Remote Hosts](/guide/remote-hosts.md) for more info and how to use a socket proxy instead of an agent.
|
||||
> If you are using a remote agent you **CANNOT** add a socket proxy on top of the agent. Dozzle agents **REPLACE** using a proxy, see [Remote Hosts](/guide/remote-hosts) for more info and how to use a socket proxy instead of an agent.
|
||||
|
||||
The agent will start and listen on port `7007`. You can connect to the agent using the Dozzle UI by providing the agent's IP address and port. The agent will only show the containers that are available on the host where the agent is running.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -56,17 +56,21 @@ You can also write your own payload template using Go's `text/template` syntax.
|
||||
|
||||
<div v-pre>
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------- | --------------------------- |
|
||||
| `{{.Container.Name}}` | Container name |
|
||||
| `{{.Container.Image}}` | Container image |
|
||||
| `{{.Container.HostName}}` | Docker host name |
|
||||
| `{{.Container.State}}` | Container state |
|
||||
| `{{.Log.Message}}` | Log message content |
|
||||
| `{{.Log.Level}}` | Log level |
|
||||
| `{{.Log.Timestamp}}` | Log timestamp |
|
||||
| `{{.Log.Stream}}` | Stream type (stdout/stderr) |
|
||||
| `{{.Subscription.Name}}` | Alert rule name |
|
||||
| Variable | Description |
|
||||
| ------------------------- | -------------------------------------- |
|
||||
| `{{.Detail}}` | Summary (log message or metric values) |
|
||||
| `{{.Container.Name}}` | Container name |
|
||||
| `{{.Container.Image}}` | Container image |
|
||||
| `{{.Container.HostName}}` | Docker host name |
|
||||
| `{{.Container.State}}` | Container state |
|
||||
| `{{.Log.Message}}` | Log message content |
|
||||
| `{{.Log.Level}}` | Log level |
|
||||
| `{{.Log.Timestamp}}` | Log timestamp |
|
||||
| `{{.Log.Stream}}` | Stream type (stdout/stderr) |
|
||||
| `{{.Stat.CPUPercent}}` | CPU usage percentage |
|
||||
| `{{.Stat.MemoryPercent}}` | Memory usage percentage |
|
||||
| `{{.Stat.MemoryUsage}}` | Memory usage in bytes |
|
||||
| `{{.Subscription.Name}}` | Alert rule name |
|
||||
|
||||
</div>
|
||||
|
||||
@@ -142,6 +146,20 @@ Container: image startsWith "myapp/"
|
||||
Log: stream == "stderr"
|
||||
```
|
||||
|
||||
**Alert on slow API responses from production:**
|
||||
|
||||
```
|
||||
Container: name contains "api" && labels["env"] == "production"
|
||||
Log: message.duration > 5000 && message.path contains "/api"
|
||||
```
|
||||
|
||||
**Alert on authentication failures using regex:**
|
||||
|
||||
```
|
||||
Container: name contains "auth" || name contains "gateway"
|
||||
Log: message matches "(?i)(unauthorized|forbidden|invalid token)"
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The alert editor includes autocomplete and real-time validation. You can preview matched containers and logs before saving.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -32,7 +32,7 @@ services:
|
||||
|
||||
If you're using [Coolify](https://coolify.io/), Dozzle automatically recognizes Coolify's labels as fallbacks:
|
||||
|
||||
- `coolify.resourceName` → Used as container name if `dev.dozzle.name` is not set
|
||||
- `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.
|
||||
|
||||
+32
-17
@@ -10,55 +10,70 @@ title: Dozzle Cloud
|
||||
|
||||
## 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.
|
||||
Container logs are noisy. You could wire up custom webhooks and build your own alerting pipeline — but then you're maintaining alert logic, deduplication, summarization, and delivery infrastructure yourself.
|
||||
|
||||
Dozzle Cloud handles all of that out of the box, and adds capabilities that webhooks simply can't provide — like AI-powered summaries, two-way chat with your containers, and proactive daily digests.
|
||||
|
||||
## 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.
|
||||
Raw log lines aren't useful at 2am. Dozzle Cloud automatically batches related container events into concise, AI-powered 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.
|
||||
Instead of showing the same error 50 times, Dozzle Cloud groups similar errors together and displays frequency counts. This makes it easy to identify recurring issues across multiple containers without the noise.
|
||||
|
||||
### AI Agent
|
||||
|
||||
Talk to your containers. The AI agent lets you ask questions about container health, search historical logs, and get context on what went wrong — all from a chat interface in Telegram or Discord.
|
||||
|
||||
On Pro and Team plans, the agent can also take action: restart, start, or stop containers directly from the conversation. Get an alert, ask what happened, and fix it — without leaving the chat or SSH-ing into a server.
|
||||
|
||||
### Daily Digests
|
||||
|
||||
Webhooks are reactive — they only fire when something breaks. Daily digests give you a proactive summary of what's happening across your infrastructure: top error patterns, event counts, and overall health. Delivered to your inbox at a time you choose, in your timezone.
|
||||
|
||||
### Smart Alert Distribution
|
||||
|
||||
Receive notifications through multiple channels:
|
||||
Receive notifications through multiple channels — and unlike one-way webhooks, some channels support full two-way interaction:
|
||||
|
||||
- **Email**
|
||||
- **Telegram** — alerts plus two-way AI agent chat
|
||||
- **Discord** — alerts plus two-way AI agent chat
|
||||
- **Email** — alerts and daily digests
|
||||
- **Slack**
|
||||
- **ntfy**
|
||||
- **Webhooks**
|
||||
- **Browser push notifications**
|
||||
|
||||
You can enable or disable channels independently with unlimited configurations.
|
||||
You can enable or disable channels independently, scope them to specific Dozzle instances, and set up as many as you need.
|
||||
|
||||
### Notification Muting
|
||||
|
||||
Sometimes you know things are broken and you're working on it. Mute notifications for an hour, eight hours, until tomorrow, or until next week — so you can focus on fixing without the noise.
|
||||
|
||||
### 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.
|
||||
Monitor all your Dozzle servers from a single dashboard. Each instance connects with an API key — no additional agents required. See which instances are online, browse containers, and stream logs in real time.
|
||||
|
||||
### 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.
|
||||
Search across all logged events with full-text search. Filter by container, severity, or keyword. Retention is configurable from 24 hours to 30 days depending on your plan.
|
||||
|
||||
### Security
|
||||
|
||||
- API keys are hashed with expiration support
|
||||
- GitHub OAuth authentication
|
||||
- API keys are hashed with BLAKE2b and support expiration
|
||||
- GitHub and Google 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).
|
||||
1. Open your local Dozzle and click the **cloud** icon in the top bar
|
||||
2. Click **Link instance** — you'll be redirected to authenticate and confirm the connection
|
||||
3. Once linked, configure alert subscriptions in Dozzle to start receiving notifications
|
||||
|
||||
## 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.
|
||||
Dozzle Cloud offers a free tier with 500 events per month — no credit card required. Visit [cloud.dozzle.dev](https://cloud.dozzle.dev) for plan details and pricing.
|
||||
|
||||
+15
-1
@@ -104,7 +104,7 @@ To resolve the error take following steps:
|
||||
|
||||
The engine-id file should now have a UUID inside.
|
||||
|
||||
An example setup for Ansible can be found in [Podman Infos](podman.md)
|
||||
An example setup for Ansible can be found in [Podman](/guide/podman)
|
||||
|
||||
It might be necessary to clean up your existing Dozzle deployment under Podman, stop the container and remove the associated data (container/volumes). After that you can redeploy the Dozzle container and your logs should now show up.
|
||||
|
||||
@@ -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:
|
||||
@@ -79,77 +85,6 @@ See [swarm mode](/guide/swarm-mode) for more information.
|
||||
|
||||
## K8s <Badge type="tip" text="New" />
|
||||
|
||||
Dozzle supports running in Kubernetes. It only needs to be deployed on one node within the cluster.
|
||||
Dozzle supports running in Kubernetes. It only needs to be deployed on one node within the cluster. You'll need to set `DOZZLE_MODE=k8s` and configure RBAC for pod log access.
|
||||
|
||||
<details>
|
||||
<summary>Kubernetes Configuration</summary>
|
||||
|
||||
```yaml [k8s-dozzle.yml]
|
||||
# rbac.yaml
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: pod-viewer
|
||||
---
|
||||
# clusterrole.yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: pod-viewer-role
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods", "pods/log", "nodes"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["metrics.k8s.io"]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list"]
|
||||
---
|
||||
# clusterrolebinding.yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: pod-viewer-binding
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: pod-viewer
|
||||
namespace: default
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: pod-viewer-role
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
---
|
||||
# deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: dozzle
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: dozzle
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: dozzle
|
||||
spec:
|
||||
serviceAccountName: pod-viewer
|
||||
containers:
|
||||
- name: dozzle
|
||||
image: amir20/dozzle:latest
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
env:
|
||||
- name: DOZZLE_MODE
|
||||
value: "k8s"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Apply the configuration using the following command:
|
||||
|
||||
```sh
|
||||
kubectl apply -f k8s-dozzle.yml
|
||||
```
|
||||
|
||||
See [Kubernetes mode](/guide/k8s) for more information.
|
||||
See [Kubernetes mode](/guide/k8s) for the full setup configuration including RBAC, deployment, and service manifests.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Options for generating UUIDs:
|
||||
|
||||
### uuidgen
|
||||
|
||||
:warning: Adjust folder/file permissions if necessary. There isn't any critical info but depending on your existing setup you might want to take additional steps.
|
||||
> [!WARNING] Adjust folder/file permissions if necessary. There isn't any critical info but depending on your existing setup you might want to take additional steps.
|
||||
|
||||
1. Install uuidgen
|
||||
2. Create the folders: `mkdir -p /var/lib/docker`
|
||||
@@ -28,7 +28,7 @@ Options for generating UUIDs:
|
||||
|
||||
### Ansible
|
||||
|
||||
:warning: Depending on your setup you might have to make adjustments for file/folder permissions. The following task snippets would run as the become_user/remote_user of the playbook running these tasks.
|
||||
> [!WARNING] Depending on your setup you might have to make adjustments for file/folder permissions. The following task snippets would run as the become_user/remote_user of the playbook running these tasks.
|
||||
|
||||
If you wish to adjust the user, you have to set individual become/become_user parameters for these tasks.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ This feature is currently in beta and is available to all users. If you have any
|
||||
|
||||
To get started with the SQL Engine, you will need to have a dataset that you can query. Only JSON logs can be queried using SQL. Dozzle leverages the power of WebAssembly to run SQL queries in the browser, which means that your data never leaves your machine.
|
||||
|
||||
To start using the SQL Engine, make sure you have JSON logs and navigate to the dropdown and choose `SQL Analytics`. There is also a keyboard shortcut `^+⇧+f` or `⌘+⇧+f` to quickly open the SQL Engine.
|
||||
To start using the SQL Engine, make sure you have JSON logs and navigate to the dropdown and choose `SQL Analytics`. There is also a keyboard shortcut `Ctrl+Shift+F` (or `Cmd+Shift+F` on macOS) to quickly open the SQL Engine.
|
||||
|
||||
## How Does It Work?
|
||||
|
||||
@@ -48,6 +48,27 @@ SELECT * FROM logs WHERE level = 'error'
|
||||
SELECT level, COUNT(*) FROM logs GROUP BY level
|
||||
```
|
||||
|
||||
### Query nested JSON fields
|
||||
|
||||
```sql
|
||||
SELECT message.path, message.status, message.duration
|
||||
FROM logs
|
||||
WHERE message.status >= 400
|
||||
ORDER BY message.duration DESC
|
||||
```
|
||||
|
||||
### Aggregate by time window
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
date_trunc('minute', timestamp) AS minute,
|
||||
COUNT(*) AS error_count
|
||||
FROM logs
|
||||
WHERE level = 'error'
|
||||
GROUP BY minute
|
||||
ORDER BY minute DESC
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
WebAssembly has some limitations that you should be aware of when using the SQL Engine:
|
||||
|
||||
Generated
-3
@@ -4,9 +4,6 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
esbuild@<=0.24.2: '>=0.25.0'
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
|
||||
@@ -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
|
||||
+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."
|
||||
|
||||
@@ -11,50 +11,51 @@ require (
|
||||
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
|
||||
github.com/opencontainers/image-spec v1.1.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1
|
||||
github.com/sergi/go-diff v1.3.1 // indirect
|
||||
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.49.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.11.0
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/expr-lang/expr v1.17.7
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/andybalholm/brotli v1.2.1
|
||||
github.com/expr-lang/expr v1.17.8
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3
|
||||
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.4.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/samber/lo v1.52.0
|
||||
github.com/rs/zerolog v1.35.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
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
go.yaml.in/yaml/v3 v3.0.4
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/sync v0.19.0
|
||||
google.golang.org/grpc v1.78.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/sync v0.20.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
k8s.io/api v0.35.1
|
||||
k8s.io/apimachinery v0.35.1
|
||||
k8s.io/client-go v0.35.1
|
||||
k8s.io/metrics v0.35.1
|
||||
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/air-verse/air v1.65.1 // 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/bep/godartsass/v2 v2.5.0 // indirect
|
||||
github.com/bep/golibsass v1.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // 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
|
||||
@@ -79,11 +80,10 @@ require (
|
||||
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
|
||||
@@ -106,16 +106,16 @@ require (
|
||||
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.62.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.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
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/term v0.42.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // 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
|
||||
@@ -129,7 +129,7 @@ require (
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
go 1.26.0
|
||||
go 1.26.2
|
||||
|
||||
tool (
|
||||
github.com/air-verse/air
|
||||
|
||||
@@ -8,19 +8,20 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/air-verse/air v1.64.4 h1:P0alz5Jia5NucZew1HYZy69lzPWZHFjLTCKo0VhxME8=
|
||||
github.com/air-verse/air v1.64.4/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/air-verse/air v1.64.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4=
|
||||
github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
github.com/air-verse/air v1.65.0/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
github.com/air-verse/air v1.65.1/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
||||
github.com/alexflint/go-arg v1.6.1 h1:uZogJ6VDBjcuosydKgvYYRhh9sRCusjOvoOLZopBlnA=
|
||||
github.com/alexflint/go-arg v1.6.1/go.mod h1:nQ0LFYftLJ6njcaee0sU+G0iS2+2XJQfA8I062D0LGc=
|
||||
github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
|
||||
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
|
||||
@@ -61,8 +62,8 @@ github.com/bep/overlayfs v0.10.0 h1:wS3eQ6bRsLX+4AAmwGjvoFSAQoeheamxofFiJ2SthSE=
|
||||
github.com/bep/overlayfs v0.10.0/go.mod h1:ouu4nu6fFJaL0sPzNICzxYsBeWwrjiTdFZdK4lI3tro=
|
||||
github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI=
|
||||
github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
|
||||
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -75,7 +76,6 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -101,8 +101,8 @@ github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf
|
||||
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/evanw/esbuild v0.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs=
|
||||
github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
|
||||
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
|
||||
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
@@ -118,12 +118,10 @@ github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
|
||||
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
|
||||
github.com/go-faker/faker/v4 v4.7.0 h1:VboC02cXHl/NuQh5lM2W8b87yp4iFXIu59x4w0RZi4E=
|
||||
github.com/go-faker/faker/v4 v4.7.0/go.mod h1:u1dIRP5neLB6kTzgyVjdBOV5R1uP7BdxkcWk7tiKQXk=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -145,7 +143,6 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e h1:QArsSubW7eDh8APMXkByjQWvuljwPGAGQpJEFn0F0wY=
|
||||
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e/go.mod h1:3Ltoo9Banwq0gOtcOwxuHG6omk+AwsQPADyw2vQYOJQ=
|
||||
github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
|
||||
@@ -204,16 +201,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
|
||||
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
||||
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
|
||||
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
|
||||
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.3 h1:Ud4lb2QuxRClYAmRleF50KrbKIoM1TddXgBrneT5/Jo=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.3/go.mod h1:q6uFgbgZfEmQrfJfrCo90QcQOcXFMfbI/fO0NqRtvZo=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 h1:SDxjGoH7qj0nBXVrcrxX8eD94wEnjR+EEuqqmeqQYlY=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2/go.mod h1:Nwo81sMxE0DcvTB+rJyynNhv/DUu2yZErV7sscw9pHE=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.2 h1:N+XLjTJEzDZRP3S0SezclXFAfopwL+o5vaL+qg6rX1I=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.2/go.mod h1:qO9w1qkQH77a0r9OXNM33YQPnV/evetKYRg58h1rBNE=
|
||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
@@ -222,11 +217,8 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1r
|
||||
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
|
||||
github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
|
||||
github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
@@ -281,6 +273,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
@@ -301,11 +295,10 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
|
||||
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
@@ -346,28 +339,28 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.22.0 h1:9M3+rhx7kZCIQQhQRYaZCdNu1V73tm4TvXs2ntl98C4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.22.0/go.mod h1:noq80iT8rrHP1SfybmPiRGc9dc5M8RPmGvtwo7Oo7tc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0 h1:FyjCyI9jVEfqhUh2MoSkmolPjfh5fp2hnV0b0irxH4Q=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0/go.mod h1:hYwym2nDEeZfG/motx0p7L7J1N1vyzIThemQsb4g2qY=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
@@ -380,10 +373,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||
@@ -393,8 +386,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -405,12 +398,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -418,15 +409,14 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -434,10 +424,10 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -447,10 +437,10 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -460,10 +450,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -473,21 +463,20 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.0 h1:6Al3kEFFP9VJhRz3DID6quisgPnTeZVr4lep9kkxdPA=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.0/go.mod h1:QLvsjh0OIR0TYBeiu2bkWGTJBUNQ64st52iWj/yA93I=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1 h1:/WILD1UcXj/ujCxgoL/DvRgt2CP3txG8+FwkUbb9110=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1/go.mod h1:YNKnb2OAApgYn2oYY47Rn7alMr1zWjb2U8Q0aoGWiNc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
@@ -508,26 +497,18 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
|
||||
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
|
||||
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
|
||||
k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
|
||||
k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=
|
||||
k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q=
|
||||
k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM=
|
||||
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
|
||||
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
|
||||
k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU=
|
||||
k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
|
||||
k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
|
||||
k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
|
||||
k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM=
|
||||
k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA=
|
||||
k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ=
|
||||
k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4=
|
||||
k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8=
|
||||
k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
|
||||
k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg=
|
||||
k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c=
|
||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
||||
k8s.io/metrics v0.35.0 h1:xVFoqtAGm2dMNJAcB5TFZJPCen0uEqqNt52wW7ABbX8=
|
||||
k8s.io/metrics v0.35.0/go.mod h1:g2Up4dcBygZi2kQSEQVDByFs+VUwepJMzzQLJJLpq4M=
|
||||
k8s.io/metrics v0.35.1 h1:MUcrUcWlq81XiripkydzCGsY9zQawDXfP9IICNNcVVw=
|
||||
k8s.io/metrics v0.35.1/go.mod h1:9x7xWOAOiWzHA0vaqLgSE4PXF3vyT5ts5XIbx8OSjiI=
|
||||
k8s.io/metrics v0.35.3 h1:WonA18pEwrtb7a6XfhFg1ZY1Le0RFkcEw7CFApMTZos=
|
||||
k8s.io/metrics v0.35.3/go.mod h1:/O8UBb5QVyAekR2QvL/WWxskpdV1wVSEl4MSLAy4Ql4=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
|
||||
+94
-10
@@ -22,6 +22,7 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/encoding/gzip"
|
||||
"google.golang.org/grpc/keepalive"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
@@ -51,6 +52,11 @@ func NewClient(endpoint string, certificates tls.Certificate, opts ...grpc.DialO
|
||||
opts = append(opts,
|
||||
grpc.WithTransportCredentials(creds),
|
||||
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(10*1024*1024), grpc.UseCompressor(gzip.Name)),
|
||||
grpc.WithKeepaliveParams(keepalive.ClientParameters{
|
||||
Time: 30 * time.Second,
|
||||
Timeout: 10 * time.Second,
|
||||
PermitWithoutStream: true,
|
||||
}),
|
||||
)
|
||||
conn, err := grpc.NewClient(endpoint, opts...)
|
||||
if err != nil {
|
||||
@@ -231,10 +237,12 @@ func (c *Client) StreamStats(ctx context.Context, stats chan<- container.Contain
|
||||
}
|
||||
|
||||
stats <- container.ContainerStat{
|
||||
CPUPercent: resp.Stat.CpuPercent,
|
||||
MemoryPercent: resp.Stat.MemoryPercent,
|
||||
MemoryUsage: resp.Stat.MemoryUsage,
|
||||
ID: resp.Stat.Id,
|
||||
CPUPercent: resp.Stat.CpuPercent,
|
||||
MemoryPercent: resp.Stat.MemoryPercent,
|
||||
MemoryUsage: resp.Stat.MemoryUsage,
|
||||
ID: resp.Stat.Id,
|
||||
NetworkRxTotal: resp.Stat.NetworkRxTotal,
|
||||
NetworkTxTotal: resp.Stat.NetworkTxTotal,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,10 +260,11 @@ func (c *Client) StreamEvents(ctx context.Context, events chan<- container.Conta
|
||||
}
|
||||
|
||||
events <- container.ContainerEvent{
|
||||
ActorID: resp.Event.ActorId,
|
||||
Name: resp.Event.Name,
|
||||
Host: resp.Event.Host,
|
||||
Time: resp.Event.Timestamp.AsTime(),
|
||||
ActorID: resp.Event.ActorId,
|
||||
Name: resp.Event.Name,
|
||||
Host: resp.Event.Host,
|
||||
Time: resp.Event.Timestamp.AsTime(),
|
||||
ActorAttributes: resp.Event.ActorAttributes,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,6 +367,38 @@ func (c *Client) ContainerAction(ctx context.Context, containerId string, action
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) UpdateContainer(ctx context.Context, containerID string, progressCh chan<- container.UpdateProgress) (bool, error) {
|
||||
defer close(progressCh)
|
||||
|
||||
stream, err := c.client.UpdateContainer(ctx, &pb.UpdateContainerRequest{ContainerId: containerID})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
updated := false
|
||||
for {
|
||||
progress, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
return updated, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if progress.Status == "done" {
|
||||
updated = true
|
||||
}
|
||||
|
||||
progressCh <- container.UpdateProgress{
|
||||
Status: progress.Status,
|
||||
Layer: progress.Layer,
|
||||
Current: progress.Current,
|
||||
Total: progress.Total,
|
||||
Error: progress.Error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) ContainerAttach(ctx context.Context, containerId string) (*container.ExecSession, error) {
|
||||
stream, err := c.client.ContainerAttach(ctx)
|
||||
if err != nil {
|
||||
@@ -497,7 +538,6 @@ func (c *Client) Close() error {
|
||||
}
|
||||
|
||||
func (c *Client) UpdateNotificationConfig(ctx context.Context, subscriptions []types.SubscriptionConfig, dispatchers []types.DispatcherConfig) error {
|
||||
// Convert to proto
|
||||
pbSubs := make([]*pb.NotificationSubscription, len(subscriptions))
|
||||
for i, sub := range subscriptions {
|
||||
pbSubs[i] = &pb.NotificationSubscription{
|
||||
@@ -507,6 +547,10 @@ func (c *Client) UpdateNotificationConfig(ctx context.Context, subscriptions []t
|
||||
DispatcherId: int32(sub.DispatcherID),
|
||||
LogExpression: sub.LogExpression,
|
||||
ContainerExpression: sub.ContainerExpression,
|
||||
MetricExpression: sub.MetricExpression,
|
||||
Cooldown: int32(sub.Cooldown),
|
||||
SampleWindow: int32(sub.SampleWindow),
|
||||
EventExpression: sub.EventExpression,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,6 +562,7 @@ func (c *Client) UpdateNotificationConfig(ctx context.Context, subscriptions []t
|
||||
Type: d.Type,
|
||||
Url: d.URL,
|
||||
Template: d.Template,
|
||||
Headers: d.Headers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,10 +570,49 @@ func (c *Client) UpdateNotificationConfig(ctx context.Context, subscriptions []t
|
||||
Subscriptions: pbSubs,
|
||||
Dispatchers: pbDispatchers,
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) UpdateCloudConfig(ctx context.Context, cloudConfig *types.CloudConfig) error {
|
||||
req := &pb.UpdateCloudConfigRequest{}
|
||||
if cloudConfig != nil {
|
||||
req.CloudConfig = &pb.NotificationCloudConfig{
|
||||
ApiKey: cloudConfig.APIKey,
|
||||
Prefix: cloudConfig.Prefix,
|
||||
}
|
||||
if cloudConfig.ExpiresAt != nil {
|
||||
req.CloudConfig.ExpiresAt = timestamppb.New(*cloudConfig.ExpiresAt)
|
||||
}
|
||||
}
|
||||
_, err := c.client.UpdateCloudConfig(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) GetNotificationStats(ctx context.Context) ([]types.SubscriptionStats, error) {
|
||||
resp, err := c.client.GetNotificationStats(ctx, &pb.GetNotificationStatsRequest{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats := make([]types.SubscriptionStats, len(resp.Stats))
|
||||
for i, s := range resp.Stats {
|
||||
var lastTriggered *time.Time
|
||||
if s.LastTriggeredAt != nil {
|
||||
t := s.LastTriggeredAt.AsTime()
|
||||
if !t.IsZero() {
|
||||
lastTriggered = &t
|
||||
}
|
||||
}
|
||||
stats[i] = types.SubscriptionStats{
|
||||
SubscriptionID: int(s.SubscriptionId),
|
||||
TriggerCount: s.TriggerCount,
|
||||
LastTriggeredAt: lastTriggered,
|
||||
TriggeredContainerIDs: s.TriggeredContainerIds,
|
||||
}
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func jsonBytesToOrderedMap(b []byte) *orderedmap.OrderedMap[string, any] {
|
||||
var data *orderedmap.OrderedMap[string, any]
|
||||
reader := bytes.NewReader(b)
|
||||
|
||||
@@ -12,7 +12,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/amir20/dozzle/internal/container"
|
||||
"github.com/amir20/dozzle/internal/notification/dispatcher"
|
||||
"github.com/amir20/dozzle/internal/utils"
|
||||
"github.com/amir20/dozzle/types"
|
||||
"github.com/go-faker/faker/v4"
|
||||
"github.com/go-faker/faker/v4/pkg/options"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -28,6 +30,19 @@ var lis *bufconn.Listener
|
||||
var certs tls.Certificate
|
||||
var mockService *MockedClientService
|
||||
|
||||
type mockNotificationHandler struct{}
|
||||
|
||||
func (m *mockNotificationHandler) HandleNotificationConfig(subscriptions []types.SubscriptionConfig, dispatchers []types.DispatcherConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNotificationHandler) SetCloudDispatcher(d dispatcher.Dispatcher) {}
|
||||
func (m *mockNotificationHandler) ClearCloudDispatcher() {}
|
||||
|
||||
func (m *mockNotificationHandler) GetNotificationStats() []types.SubscriptionStats {
|
||||
return nil
|
||||
}
|
||||
|
||||
type MockedClientService struct {
|
||||
mock.Mock
|
||||
}
|
||||
@@ -89,6 +104,11 @@ func (m *MockedClientService) Exec(ctx context.Context, c container.Container, c
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockedClientService) UpdateContainer(ctx context.Context, c container.Container, progressCh chan<- container.UpdateProgress) (bool, error) {
|
||||
args := m.Called(ctx, c, progressCh)
|
||||
return args.Bool(0), args.Error(1)
|
||||
}
|
||||
|
||||
var wantedContainer = container.Container{}
|
||||
|
||||
func init() {
|
||||
@@ -134,7 +154,7 @@ func init() {
|
||||
|
||||
mockService.On("Client").Return(nil)
|
||||
|
||||
server, _ := NewServer(mockService, certs, "test", nil)
|
||||
server, _ := NewServer(mockService, certs, "test", &mockNotificationHandler{})
|
||||
go server.Serve(lis)
|
||||
}
|
||||
|
||||
|
||||
+413
-100
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.4
|
||||
// protoc v7.34.1
|
||||
// source: rpc.proto
|
||||
|
||||
package pb
|
||||
@@ -942,6 +942,126 @@ func (*ContainerActionResponse) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{19}
|
||||
}
|
||||
|
||||
type UpdateContainerRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ContainerId string `protobuf:"bytes,1,opt,name=containerId,proto3" json:"containerId,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *UpdateContainerRequest) Reset() {
|
||||
*x = UpdateContainerRequest{}
|
||||
mi := &file_rpc_proto_msgTypes[20]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *UpdateContainerRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*UpdateContainerRequest) ProtoMessage() {}
|
||||
|
||||
func (x *UpdateContainerRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_rpc_proto_msgTypes[20]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use UpdateContainerRequest.ProtoReflect.Descriptor instead.
|
||||
func (*UpdateContainerRequest) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{20}
|
||||
}
|
||||
|
||||
func (x *UpdateContainerRequest) GetContainerId() string {
|
||||
if x != nil {
|
||||
return x.ContainerId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type UpdateContainerProgress struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"`
|
||||
Layer string `protobuf:"bytes,2,opt,name=layer,proto3" json:"layer,omitempty"`
|
||||
Current int64 `protobuf:"varint,3,opt,name=current,proto3" json:"current,omitempty"`
|
||||
Total int64 `protobuf:"varint,4,opt,name=total,proto3" json:"total,omitempty"`
|
||||
Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *UpdateContainerProgress) Reset() {
|
||||
*x = UpdateContainerProgress{}
|
||||
mi := &file_rpc_proto_msgTypes[21]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *UpdateContainerProgress) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*UpdateContainerProgress) ProtoMessage() {}
|
||||
|
||||
func (x *UpdateContainerProgress) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_rpc_proto_msgTypes[21]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use UpdateContainerProgress.ProtoReflect.Descriptor instead.
|
||||
func (*UpdateContainerProgress) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{21}
|
||||
}
|
||||
|
||||
func (x *UpdateContainerProgress) GetStatus() string {
|
||||
if x != nil {
|
||||
return x.Status
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UpdateContainerProgress) GetLayer() string {
|
||||
if x != nil {
|
||||
return x.Layer
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UpdateContainerProgress) GetCurrent() int64 {
|
||||
if x != nil {
|
||||
return x.Current
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *UpdateContainerProgress) GetTotal() int64 {
|
||||
if x != nil {
|
||||
return x.Total
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *UpdateContainerProgress) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ContainerExecRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ContainerId string `protobuf:"bytes,1,opt,name=containerId,proto3" json:"containerId,omitempty"`
|
||||
@@ -957,7 +1077,7 @@ type ContainerExecRequest struct {
|
||||
|
||||
func (x *ContainerExecRequest) Reset() {
|
||||
*x = ContainerExecRequest{}
|
||||
mi := &file_rpc_proto_msgTypes[20]
|
||||
mi := &file_rpc_proto_msgTypes[22]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -969,7 +1089,7 @@ func (x *ContainerExecRequest) String() string {
|
||||
func (*ContainerExecRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ContainerExecRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_rpc_proto_msgTypes[20]
|
||||
mi := &file_rpc_proto_msgTypes[22]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -982,7 +1102,7 @@ func (x *ContainerExecRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ContainerExecRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ContainerExecRequest) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{20}
|
||||
return file_rpc_proto_rawDescGZIP(), []int{22}
|
||||
}
|
||||
|
||||
func (x *ContainerExecRequest) GetContainerId() string {
|
||||
@@ -1050,7 +1170,7 @@ type ResizePayload struct {
|
||||
|
||||
func (x *ResizePayload) Reset() {
|
||||
*x = ResizePayload{}
|
||||
mi := &file_rpc_proto_msgTypes[21]
|
||||
mi := &file_rpc_proto_msgTypes[23]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1062,7 +1182,7 @@ func (x *ResizePayload) String() string {
|
||||
func (*ResizePayload) ProtoMessage() {}
|
||||
|
||||
func (x *ResizePayload) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_rpc_proto_msgTypes[21]
|
||||
mi := &file_rpc_proto_msgTypes[23]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1075,7 +1195,7 @@ func (x *ResizePayload) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ResizePayload.ProtoReflect.Descriptor instead.
|
||||
func (*ResizePayload) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{21}
|
||||
return file_rpc_proto_rawDescGZIP(), []int{23}
|
||||
}
|
||||
|
||||
func (x *ResizePayload) GetWidth() uint32 {
|
||||
@@ -1101,7 +1221,7 @@ type ContainerExecResponse struct {
|
||||
|
||||
func (x *ContainerExecResponse) Reset() {
|
||||
*x = ContainerExecResponse{}
|
||||
mi := &file_rpc_proto_msgTypes[22]
|
||||
mi := &file_rpc_proto_msgTypes[24]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1113,7 +1233,7 @@ func (x *ContainerExecResponse) String() string {
|
||||
func (*ContainerExecResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ContainerExecResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_rpc_proto_msgTypes[22]
|
||||
mi := &file_rpc_proto_msgTypes[24]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1126,7 +1246,7 @@ func (x *ContainerExecResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ContainerExecResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ContainerExecResponse) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{22}
|
||||
return file_rpc_proto_rawDescGZIP(), []int{24}
|
||||
}
|
||||
|
||||
func (x *ContainerExecResponse) GetStdout() []byte {
|
||||
@@ -1150,7 +1270,7 @@ type ContainerAttachRequest struct {
|
||||
|
||||
func (x *ContainerAttachRequest) Reset() {
|
||||
*x = ContainerAttachRequest{}
|
||||
mi := &file_rpc_proto_msgTypes[23]
|
||||
mi := &file_rpc_proto_msgTypes[25]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1162,7 +1282,7 @@ func (x *ContainerAttachRequest) String() string {
|
||||
func (*ContainerAttachRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ContainerAttachRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_rpc_proto_msgTypes[23]
|
||||
mi := &file_rpc_proto_msgTypes[25]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1175,7 +1295,7 @@ func (x *ContainerAttachRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ContainerAttachRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ContainerAttachRequest) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{23}
|
||||
return file_rpc_proto_rawDescGZIP(), []int{25}
|
||||
}
|
||||
|
||||
func (x *ContainerAttachRequest) GetContainerId() string {
|
||||
@@ -1235,7 +1355,7 @@ type ContainerAttachResponse struct {
|
||||
|
||||
func (x *ContainerAttachResponse) Reset() {
|
||||
*x = ContainerAttachResponse{}
|
||||
mi := &file_rpc_proto_msgTypes[24]
|
||||
mi := &file_rpc_proto_msgTypes[26]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1247,7 +1367,7 @@ func (x *ContainerAttachResponse) String() string {
|
||||
func (*ContainerAttachResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ContainerAttachResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_rpc_proto_msgTypes[24]
|
||||
mi := &file_rpc_proto_msgTypes[26]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1260,7 +1380,7 @@ func (x *ContainerAttachResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ContainerAttachResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ContainerAttachResponse) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{24}
|
||||
return file_rpc_proto_rawDescGZIP(), []int{26}
|
||||
}
|
||||
|
||||
func (x *ContainerAttachResponse) GetStdout() []byte {
|
||||
@@ -1280,7 +1400,7 @@ type UpdateNotificationConfigRequest struct {
|
||||
|
||||
func (x *UpdateNotificationConfigRequest) Reset() {
|
||||
*x = UpdateNotificationConfigRequest{}
|
||||
mi := &file_rpc_proto_msgTypes[25]
|
||||
mi := &file_rpc_proto_msgTypes[27]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1292,7 +1412,7 @@ func (x *UpdateNotificationConfigRequest) String() string {
|
||||
func (*UpdateNotificationConfigRequest) ProtoMessage() {}
|
||||
|
||||
func (x *UpdateNotificationConfigRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_rpc_proto_msgTypes[25]
|
||||
mi := &file_rpc_proto_msgTypes[27]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1305,7 +1425,7 @@ func (x *UpdateNotificationConfigRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use UpdateNotificationConfigRequest.ProtoReflect.Descriptor instead.
|
||||
func (*UpdateNotificationConfigRequest) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{25}
|
||||
return file_rpc_proto_rawDescGZIP(), []int{27}
|
||||
}
|
||||
|
||||
func (x *UpdateNotificationConfigRequest) GetSubscriptions() []*NotificationSubscription {
|
||||
@@ -1330,7 +1450,7 @@ type UpdateNotificationConfigResponse struct {
|
||||
|
||||
func (x *UpdateNotificationConfigResponse) Reset() {
|
||||
*x = UpdateNotificationConfigResponse{}
|
||||
mi := &file_rpc_proto_msgTypes[26]
|
||||
mi := &file_rpc_proto_msgTypes[28]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -1342,7 +1462,7 @@ func (x *UpdateNotificationConfigResponse) String() string {
|
||||
func (*UpdateNotificationConfigResponse) ProtoMessage() {}
|
||||
|
||||
func (x *UpdateNotificationConfigResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_rpc_proto_msgTypes[26]
|
||||
mi := &file_rpc_proto_msgTypes[28]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -1355,7 +1475,167 @@ func (x *UpdateNotificationConfigResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use UpdateNotificationConfigResponse.ProtoReflect.Descriptor instead.
|
||||
func (*UpdateNotificationConfigResponse) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{26}
|
||||
return file_rpc_proto_rawDescGZIP(), []int{28}
|
||||
}
|
||||
|
||||
type UpdateCloudConfigRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
CloudConfig *NotificationCloudConfig `protobuf:"bytes,1,opt,name=cloudConfig,proto3" json:"cloudConfig,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *UpdateCloudConfigRequest) Reset() {
|
||||
*x = UpdateCloudConfigRequest{}
|
||||
mi := &file_rpc_proto_msgTypes[29]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *UpdateCloudConfigRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*UpdateCloudConfigRequest) ProtoMessage() {}
|
||||
|
||||
func (x *UpdateCloudConfigRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_rpc_proto_msgTypes[29]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use UpdateCloudConfigRequest.ProtoReflect.Descriptor instead.
|
||||
func (*UpdateCloudConfigRequest) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{29}
|
||||
}
|
||||
|
||||
func (x *UpdateCloudConfigRequest) GetCloudConfig() *NotificationCloudConfig {
|
||||
if x != nil {
|
||||
return x.CloudConfig
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type UpdateCloudConfigResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *UpdateCloudConfigResponse) Reset() {
|
||||
*x = UpdateCloudConfigResponse{}
|
||||
mi := &file_rpc_proto_msgTypes[30]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *UpdateCloudConfigResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*UpdateCloudConfigResponse) ProtoMessage() {}
|
||||
|
||||
func (x *UpdateCloudConfigResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_rpc_proto_msgTypes[30]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use UpdateCloudConfigResponse.ProtoReflect.Descriptor instead.
|
||||
func (*UpdateCloudConfigResponse) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{30}
|
||||
}
|
||||
|
||||
type GetNotificationStatsRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GetNotificationStatsRequest) Reset() {
|
||||
*x = GetNotificationStatsRequest{}
|
||||
mi := &file_rpc_proto_msgTypes[31]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *GetNotificationStatsRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*GetNotificationStatsRequest) ProtoMessage() {}
|
||||
|
||||
func (x *GetNotificationStatsRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_rpc_proto_msgTypes[31]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use GetNotificationStatsRequest.ProtoReflect.Descriptor instead.
|
||||
func (*GetNotificationStatsRequest) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{31}
|
||||
}
|
||||
|
||||
type GetNotificationStatsResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Stats []*NotificationSubscriptionStats `protobuf:"bytes,1,rep,name=stats,proto3" json:"stats,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GetNotificationStatsResponse) Reset() {
|
||||
*x = GetNotificationStatsResponse{}
|
||||
mi := &file_rpc_proto_msgTypes[32]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *GetNotificationStatsResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*GetNotificationStatsResponse) ProtoMessage() {}
|
||||
|
||||
func (x *GetNotificationStatsResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_rpc_proto_msgTypes[32]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use GetNotificationStatsResponse.ProtoReflect.Descriptor instead.
|
||||
func (*GetNotificationStatsResponse) Descriptor() ([]byte, []int) {
|
||||
return file_rpc_proto_rawDescGZIP(), []int{32}
|
||||
}
|
||||
|
||||
func (x *GetNotificationStatsResponse) GetStats() []*NotificationSubscriptionStats {
|
||||
if x != nil {
|
||||
return x.Stats
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_rpc_proto protoreflect.FileDescriptor
|
||||
@@ -1415,7 +1695,15 @@ const file_rpc_proto_rawDesc = "" +
|
||||
"\x16ContainerActionRequest\x12 \n" +
|
||||
"\vcontainerId\x18\x01 \x01(\tR\vcontainerId\x121\n" +
|
||||
"\x06action\x18\x02 \x01(\x0e2\x19.protobuf.ContainerActionR\x06action\"\x19\n" +
|
||||
"\x17ContainerActionResponse\"\xa8\x01\n" +
|
||||
"\x17ContainerActionResponse\":\n" +
|
||||
"\x16UpdateContainerRequest\x12 \n" +
|
||||
"\vcontainerId\x18\x01 \x01(\tR\vcontainerId\"\x8d\x01\n" +
|
||||
"\x17UpdateContainerProgress\x12\x16\n" +
|
||||
"\x06status\x18\x01 \x01(\tR\x06status\x12\x14\n" +
|
||||
"\x05layer\x18\x02 \x01(\tR\x05layer\x12\x18\n" +
|
||||
"\acurrent\x18\x03 \x01(\x03R\acurrent\x12\x14\n" +
|
||||
"\x05total\x18\x04 \x01(\x03R\x05total\x12\x14\n" +
|
||||
"\x05error\x18\x05 \x01(\tR\x05error\"\xa8\x01\n" +
|
||||
"\x14ContainerExecRequest\x12 \n" +
|
||||
"\vcontainerId\x18\x01 \x01(\tR\vcontainerId\x12\x18\n" +
|
||||
"\acommand\x18\x02 \x03(\tR\acommand\x12\x16\n" +
|
||||
@@ -1437,7 +1725,13 @@ const file_rpc_proto_rawDesc = "" +
|
||||
"\x1fUpdateNotificationConfigRequest\x12H\n" +
|
||||
"\rsubscriptions\x18\x01 \x03(\v2\".protobuf.NotificationSubscriptionR\rsubscriptions\x12B\n" +
|
||||
"\vdispatchers\x18\x02 \x03(\v2 .protobuf.NotificationDispatcherR\vdispatchers\"\"\n" +
|
||||
" UpdateNotificationConfigResponse2\x96\t\n" +
|
||||
" UpdateNotificationConfigResponse\"_\n" +
|
||||
"\x18UpdateCloudConfigRequest\x12C\n" +
|
||||
"\vcloudConfig\x18\x01 \x01(\v2!.protobuf.NotificationCloudConfigR\vcloudConfig\"\x1b\n" +
|
||||
"\x19UpdateCloudConfigResponse\"\x1d\n" +
|
||||
"\x1bGetNotificationStatsRequest\"]\n" +
|
||||
"\x1cGetNotificationStatsResponse\x12=\n" +
|
||||
"\x05stats\x18\x01 \x03(\v2'.protobuf.NotificationSubscriptionStatsR\x05stats2\xbb\v\n" +
|
||||
"\fAgentService\x12U\n" +
|
||||
"\x0eListContainers\x12\x1f.protobuf.ListContainersRequest\x1a .protobuf.ListContainersResponse\"\x00\x12R\n" +
|
||||
"\rFindContainer\x12\x1e.protobuf.FindContainerRequest\x1a\x1f.protobuf.FindContainerResponse\"\x00\x12K\n" +
|
||||
@@ -1449,10 +1743,13 @@ const file_rpc_proto_rawDesc = "" +
|
||||
"\vStreamStats\x12\x1c.protobuf.StreamStatsRequest\x1a\x1d.protobuf.StreamStatsResponse\"\x000\x01\x12o\n" +
|
||||
"\x16StreamContainerStarted\x12'.protobuf.StreamContainerStartedRequest\x1a(.protobuf.StreamContainerStartedResponse\"\x000\x01\x12C\n" +
|
||||
"\bHostInfo\x12\x19.protobuf.HostInfoRequest\x1a\x1a.protobuf.HostInfoResponse\"\x00\x12X\n" +
|
||||
"\x0fContainerAction\x12 .protobuf.ContainerActionRequest\x1a!.protobuf.ContainerActionResponse\"\x00\x12V\n" +
|
||||
"\x0fContainerAction\x12 .protobuf.ContainerActionRequest\x1a!.protobuf.ContainerActionResponse\"\x00\x12Z\n" +
|
||||
"\x0fUpdateContainer\x12 .protobuf.UpdateContainerRequest\x1a!.protobuf.UpdateContainerProgress\"\x000\x01\x12V\n" +
|
||||
"\rContainerExec\x12\x1e.protobuf.ContainerExecRequest\x1a\x1f.protobuf.ContainerExecResponse\"\x00(\x010\x01\x12\\\n" +
|
||||
"\x0fContainerAttach\x12 .protobuf.ContainerAttachRequest\x1a!.protobuf.ContainerAttachResponse\"\x00(\x010\x01\x12s\n" +
|
||||
"\x18UpdateNotificationConfig\x12).protobuf.UpdateNotificationConfigRequest\x1a*.protobuf.UpdateNotificationConfigResponse\"\x00B\x13Z\x11internal/agent/pbb\x06proto3"
|
||||
"\x18UpdateNotificationConfig\x12).protobuf.UpdateNotificationConfigRequest\x1a*.protobuf.UpdateNotificationConfigResponse\"\x00\x12^\n" +
|
||||
"\x11UpdateCloudConfig\x12\".protobuf.UpdateCloudConfigRequest\x1a#.protobuf.UpdateCloudConfigResponse\"\x00\x12g\n" +
|
||||
"\x14GetNotificationStats\x12%.protobuf.GetNotificationStatsRequest\x1a&.protobuf.GetNotificationStatsResponse\"\x00B\x13Z\x11internal/agent/pbb\x06proto3"
|
||||
|
||||
var (
|
||||
file_rpc_proto_rawDescOnce sync.Once
|
||||
@@ -1466,7 +1763,7 @@ func file_rpc_proto_rawDescGZIP() []byte {
|
||||
return file_rpc_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_rpc_proto_msgTypes = make([]protoimpl.MessageInfo, 29)
|
||||
var file_rpc_proto_msgTypes = make([]protoimpl.MessageInfo, 35)
|
||||
var file_rpc_proto_goTypes = []any{
|
||||
(*ListContainersRequest)(nil), // 0: protobuf.ListContainersRequest
|
||||
(*RepeatedString)(nil), // 1: protobuf.RepeatedString
|
||||
@@ -1488,78 +1785,94 @@ var file_rpc_proto_goTypes = []any{
|
||||
(*StreamContainerStartedResponse)(nil), // 17: protobuf.StreamContainerStartedResponse
|
||||
(*ContainerActionRequest)(nil), // 18: protobuf.ContainerActionRequest
|
||||
(*ContainerActionResponse)(nil), // 19: protobuf.ContainerActionResponse
|
||||
(*ContainerExecRequest)(nil), // 20: protobuf.ContainerExecRequest
|
||||
(*ResizePayload)(nil), // 21: protobuf.ResizePayload
|
||||
(*ContainerExecResponse)(nil), // 22: protobuf.ContainerExecResponse
|
||||
(*ContainerAttachRequest)(nil), // 23: protobuf.ContainerAttachRequest
|
||||
(*ContainerAttachResponse)(nil), // 24: protobuf.ContainerAttachResponse
|
||||
(*UpdateNotificationConfigRequest)(nil), // 25: protobuf.UpdateNotificationConfigRequest
|
||||
(*UpdateNotificationConfigResponse)(nil), // 26: protobuf.UpdateNotificationConfigResponse
|
||||
nil, // 27: protobuf.ListContainersRequest.FilterEntry
|
||||
nil, // 28: protobuf.FindContainerRequest.FilterEntry
|
||||
(*Container)(nil), // 29: protobuf.Container
|
||||
(*timestamppb.Timestamp)(nil), // 30: google.protobuf.Timestamp
|
||||
(*LogEvent)(nil), // 31: protobuf.LogEvent
|
||||
(*ContainerEvent)(nil), // 32: protobuf.ContainerEvent
|
||||
(*ContainerStat)(nil), // 33: protobuf.ContainerStat
|
||||
(*Host)(nil), // 34: protobuf.Host
|
||||
(ContainerAction)(0), // 35: protobuf.ContainerAction
|
||||
(*NotificationSubscription)(nil), // 36: protobuf.NotificationSubscription
|
||||
(*NotificationDispatcher)(nil), // 37: protobuf.NotificationDispatcher
|
||||
(*UpdateContainerRequest)(nil), // 20: protobuf.UpdateContainerRequest
|
||||
(*UpdateContainerProgress)(nil), // 21: protobuf.UpdateContainerProgress
|
||||
(*ContainerExecRequest)(nil), // 22: protobuf.ContainerExecRequest
|
||||
(*ResizePayload)(nil), // 23: protobuf.ResizePayload
|
||||
(*ContainerExecResponse)(nil), // 24: protobuf.ContainerExecResponse
|
||||
(*ContainerAttachRequest)(nil), // 25: protobuf.ContainerAttachRequest
|
||||
(*ContainerAttachResponse)(nil), // 26: protobuf.ContainerAttachResponse
|
||||
(*UpdateNotificationConfigRequest)(nil), // 27: protobuf.UpdateNotificationConfigRequest
|
||||
(*UpdateNotificationConfigResponse)(nil), // 28: protobuf.UpdateNotificationConfigResponse
|
||||
(*UpdateCloudConfigRequest)(nil), // 29: protobuf.UpdateCloudConfigRequest
|
||||
(*UpdateCloudConfigResponse)(nil), // 30: protobuf.UpdateCloudConfigResponse
|
||||
(*GetNotificationStatsRequest)(nil), // 31: protobuf.GetNotificationStatsRequest
|
||||
(*GetNotificationStatsResponse)(nil), // 32: protobuf.GetNotificationStatsResponse
|
||||
nil, // 33: protobuf.ListContainersRequest.FilterEntry
|
||||
nil, // 34: protobuf.FindContainerRequest.FilterEntry
|
||||
(*Container)(nil), // 35: protobuf.Container
|
||||
(*timestamppb.Timestamp)(nil), // 36: google.protobuf.Timestamp
|
||||
(*LogEvent)(nil), // 37: protobuf.LogEvent
|
||||
(*ContainerEvent)(nil), // 38: protobuf.ContainerEvent
|
||||
(*ContainerStat)(nil), // 39: protobuf.ContainerStat
|
||||
(*Host)(nil), // 40: protobuf.Host
|
||||
(ContainerAction)(0), // 41: protobuf.ContainerAction
|
||||
(*NotificationSubscription)(nil), // 42: protobuf.NotificationSubscription
|
||||
(*NotificationDispatcher)(nil), // 43: protobuf.NotificationDispatcher
|
||||
(*NotificationCloudConfig)(nil), // 44: protobuf.NotificationCloudConfig
|
||||
(*NotificationSubscriptionStats)(nil), // 45: protobuf.NotificationSubscriptionStats
|
||||
}
|
||||
var file_rpc_proto_depIdxs = []int32{
|
||||
27, // 0: protobuf.ListContainersRequest.filter:type_name -> protobuf.ListContainersRequest.FilterEntry
|
||||
29, // 1: protobuf.ListContainersResponse.containers:type_name -> protobuf.Container
|
||||
28, // 2: protobuf.FindContainerRequest.filter:type_name -> protobuf.FindContainerRequest.FilterEntry
|
||||
29, // 3: protobuf.FindContainerResponse.container:type_name -> protobuf.Container
|
||||
30, // 4: protobuf.StreamLogsRequest.since:type_name -> google.protobuf.Timestamp
|
||||
31, // 5: protobuf.StreamLogsResponse.event:type_name -> protobuf.LogEvent
|
||||
30, // 6: protobuf.LogsBetweenDatesRequest.since:type_name -> google.protobuf.Timestamp
|
||||
30, // 7: protobuf.LogsBetweenDatesRequest.until:type_name -> google.protobuf.Timestamp
|
||||
30, // 8: protobuf.StreamRawBytesRequest.since:type_name -> google.protobuf.Timestamp
|
||||
30, // 9: protobuf.StreamRawBytesRequest.until:type_name -> google.protobuf.Timestamp
|
||||
32, // 10: protobuf.StreamEventsResponse.event:type_name -> protobuf.ContainerEvent
|
||||
33, // 11: protobuf.StreamStatsResponse.stat:type_name -> protobuf.ContainerStat
|
||||
34, // 12: protobuf.HostInfoResponse.host:type_name -> protobuf.Host
|
||||
29, // 13: protobuf.StreamContainerStartedResponse.container:type_name -> protobuf.Container
|
||||
35, // 14: protobuf.ContainerActionRequest.action:type_name -> protobuf.ContainerAction
|
||||
21, // 15: protobuf.ContainerExecRequest.resize:type_name -> protobuf.ResizePayload
|
||||
21, // 16: protobuf.ContainerAttachRequest.resize:type_name -> protobuf.ResizePayload
|
||||
36, // 17: protobuf.UpdateNotificationConfigRequest.subscriptions:type_name -> protobuf.NotificationSubscription
|
||||
37, // 18: protobuf.UpdateNotificationConfigRequest.dispatchers:type_name -> protobuf.NotificationDispatcher
|
||||
1, // 19: protobuf.ListContainersRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
|
||||
1, // 20: protobuf.FindContainerRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
|
||||
0, // 21: protobuf.AgentService.ListContainers:input_type -> protobuf.ListContainersRequest
|
||||
3, // 22: protobuf.AgentService.FindContainer:input_type -> protobuf.FindContainerRequest
|
||||
5, // 23: protobuf.AgentService.StreamLogs:input_type -> protobuf.StreamLogsRequest
|
||||
7, // 24: protobuf.AgentService.LogsBetweenDates:input_type -> protobuf.LogsBetweenDatesRequest
|
||||
8, // 25: protobuf.AgentService.StreamRawBytes:input_type -> protobuf.StreamRawBytesRequest
|
||||
10, // 26: protobuf.AgentService.StreamEvents:input_type -> protobuf.StreamEventsRequest
|
||||
12, // 27: protobuf.AgentService.StreamStats:input_type -> protobuf.StreamStatsRequest
|
||||
16, // 28: protobuf.AgentService.StreamContainerStarted:input_type -> protobuf.StreamContainerStartedRequest
|
||||
14, // 29: protobuf.AgentService.HostInfo:input_type -> protobuf.HostInfoRequest
|
||||
18, // 30: protobuf.AgentService.ContainerAction:input_type -> protobuf.ContainerActionRequest
|
||||
20, // 31: protobuf.AgentService.ContainerExec:input_type -> protobuf.ContainerExecRequest
|
||||
23, // 32: protobuf.AgentService.ContainerAttach:input_type -> protobuf.ContainerAttachRequest
|
||||
25, // 33: protobuf.AgentService.UpdateNotificationConfig:input_type -> protobuf.UpdateNotificationConfigRequest
|
||||
2, // 34: protobuf.AgentService.ListContainers:output_type -> protobuf.ListContainersResponse
|
||||
4, // 35: protobuf.AgentService.FindContainer:output_type -> protobuf.FindContainerResponse
|
||||
6, // 36: protobuf.AgentService.StreamLogs:output_type -> protobuf.StreamLogsResponse
|
||||
6, // 37: protobuf.AgentService.LogsBetweenDates:output_type -> protobuf.StreamLogsResponse
|
||||
9, // 38: protobuf.AgentService.StreamRawBytes:output_type -> protobuf.StreamRawBytesResponse
|
||||
11, // 39: protobuf.AgentService.StreamEvents:output_type -> protobuf.StreamEventsResponse
|
||||
13, // 40: protobuf.AgentService.StreamStats:output_type -> protobuf.StreamStatsResponse
|
||||
17, // 41: protobuf.AgentService.StreamContainerStarted:output_type -> protobuf.StreamContainerStartedResponse
|
||||
15, // 42: protobuf.AgentService.HostInfo:output_type -> protobuf.HostInfoResponse
|
||||
19, // 43: protobuf.AgentService.ContainerAction:output_type -> protobuf.ContainerActionResponse
|
||||
22, // 44: protobuf.AgentService.ContainerExec:output_type -> protobuf.ContainerExecResponse
|
||||
24, // 45: protobuf.AgentService.ContainerAttach:output_type -> protobuf.ContainerAttachResponse
|
||||
26, // 46: protobuf.AgentService.UpdateNotificationConfig:output_type -> protobuf.UpdateNotificationConfigResponse
|
||||
34, // [34:47] is the sub-list for method output_type
|
||||
21, // [21:34] is the sub-list for method input_type
|
||||
21, // [21:21] is the sub-list for extension type_name
|
||||
21, // [21:21] is the sub-list for extension extendee
|
||||
0, // [0:21] is the sub-list for field type_name
|
||||
33, // 0: protobuf.ListContainersRequest.filter:type_name -> protobuf.ListContainersRequest.FilterEntry
|
||||
35, // 1: protobuf.ListContainersResponse.containers:type_name -> protobuf.Container
|
||||
34, // 2: protobuf.FindContainerRequest.filter:type_name -> protobuf.FindContainerRequest.FilterEntry
|
||||
35, // 3: protobuf.FindContainerResponse.container:type_name -> protobuf.Container
|
||||
36, // 4: protobuf.StreamLogsRequest.since:type_name -> google.protobuf.Timestamp
|
||||
37, // 5: protobuf.StreamLogsResponse.event:type_name -> protobuf.LogEvent
|
||||
36, // 6: protobuf.LogsBetweenDatesRequest.since:type_name -> google.protobuf.Timestamp
|
||||
36, // 7: protobuf.LogsBetweenDatesRequest.until:type_name -> google.protobuf.Timestamp
|
||||
36, // 8: protobuf.StreamRawBytesRequest.since:type_name -> google.protobuf.Timestamp
|
||||
36, // 9: protobuf.StreamRawBytesRequest.until:type_name -> google.protobuf.Timestamp
|
||||
38, // 10: protobuf.StreamEventsResponse.event:type_name -> protobuf.ContainerEvent
|
||||
39, // 11: protobuf.StreamStatsResponse.stat:type_name -> protobuf.ContainerStat
|
||||
40, // 12: protobuf.HostInfoResponse.host:type_name -> protobuf.Host
|
||||
35, // 13: protobuf.StreamContainerStartedResponse.container:type_name -> protobuf.Container
|
||||
41, // 14: protobuf.ContainerActionRequest.action:type_name -> protobuf.ContainerAction
|
||||
23, // 15: protobuf.ContainerExecRequest.resize:type_name -> protobuf.ResizePayload
|
||||
23, // 16: protobuf.ContainerAttachRequest.resize:type_name -> protobuf.ResizePayload
|
||||
42, // 17: protobuf.UpdateNotificationConfigRequest.subscriptions:type_name -> protobuf.NotificationSubscription
|
||||
43, // 18: protobuf.UpdateNotificationConfigRequest.dispatchers:type_name -> protobuf.NotificationDispatcher
|
||||
44, // 19: protobuf.UpdateCloudConfigRequest.cloudConfig:type_name -> protobuf.NotificationCloudConfig
|
||||
45, // 20: protobuf.GetNotificationStatsResponse.stats:type_name -> protobuf.NotificationSubscriptionStats
|
||||
1, // 21: protobuf.ListContainersRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
|
||||
1, // 22: protobuf.FindContainerRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
|
||||
0, // 23: protobuf.AgentService.ListContainers:input_type -> protobuf.ListContainersRequest
|
||||
3, // 24: protobuf.AgentService.FindContainer:input_type -> protobuf.FindContainerRequest
|
||||
5, // 25: protobuf.AgentService.StreamLogs:input_type -> protobuf.StreamLogsRequest
|
||||
7, // 26: protobuf.AgentService.LogsBetweenDates:input_type -> protobuf.LogsBetweenDatesRequest
|
||||
8, // 27: protobuf.AgentService.StreamRawBytes:input_type -> protobuf.StreamRawBytesRequest
|
||||
10, // 28: protobuf.AgentService.StreamEvents:input_type -> protobuf.StreamEventsRequest
|
||||
12, // 29: protobuf.AgentService.StreamStats:input_type -> protobuf.StreamStatsRequest
|
||||
16, // 30: protobuf.AgentService.StreamContainerStarted:input_type -> protobuf.StreamContainerStartedRequest
|
||||
14, // 31: protobuf.AgentService.HostInfo:input_type -> protobuf.HostInfoRequest
|
||||
18, // 32: protobuf.AgentService.ContainerAction:input_type -> protobuf.ContainerActionRequest
|
||||
20, // 33: protobuf.AgentService.UpdateContainer:input_type -> protobuf.UpdateContainerRequest
|
||||
22, // 34: protobuf.AgentService.ContainerExec:input_type -> protobuf.ContainerExecRequest
|
||||
25, // 35: protobuf.AgentService.ContainerAttach:input_type -> protobuf.ContainerAttachRequest
|
||||
27, // 36: protobuf.AgentService.UpdateNotificationConfig:input_type -> protobuf.UpdateNotificationConfigRequest
|
||||
29, // 37: protobuf.AgentService.UpdateCloudConfig:input_type -> protobuf.UpdateCloudConfigRequest
|
||||
31, // 38: protobuf.AgentService.GetNotificationStats:input_type -> protobuf.GetNotificationStatsRequest
|
||||
2, // 39: protobuf.AgentService.ListContainers:output_type -> protobuf.ListContainersResponse
|
||||
4, // 40: protobuf.AgentService.FindContainer:output_type -> protobuf.FindContainerResponse
|
||||
6, // 41: protobuf.AgentService.StreamLogs:output_type -> protobuf.StreamLogsResponse
|
||||
6, // 42: protobuf.AgentService.LogsBetweenDates:output_type -> protobuf.StreamLogsResponse
|
||||
9, // 43: protobuf.AgentService.StreamRawBytes:output_type -> protobuf.StreamRawBytesResponse
|
||||
11, // 44: protobuf.AgentService.StreamEvents:output_type -> protobuf.StreamEventsResponse
|
||||
13, // 45: protobuf.AgentService.StreamStats:output_type -> protobuf.StreamStatsResponse
|
||||
17, // 46: protobuf.AgentService.StreamContainerStarted:output_type -> protobuf.StreamContainerStartedResponse
|
||||
15, // 47: protobuf.AgentService.HostInfo:output_type -> protobuf.HostInfoResponse
|
||||
19, // 48: protobuf.AgentService.ContainerAction:output_type -> protobuf.ContainerActionResponse
|
||||
21, // 49: protobuf.AgentService.UpdateContainer:output_type -> protobuf.UpdateContainerProgress
|
||||
24, // 50: protobuf.AgentService.ContainerExec:output_type -> protobuf.ContainerExecResponse
|
||||
26, // 51: protobuf.AgentService.ContainerAttach:output_type -> protobuf.ContainerAttachResponse
|
||||
28, // 52: protobuf.AgentService.UpdateNotificationConfig:output_type -> protobuf.UpdateNotificationConfigResponse
|
||||
30, // 53: protobuf.AgentService.UpdateCloudConfig:output_type -> protobuf.UpdateCloudConfigResponse
|
||||
32, // 54: protobuf.AgentService.GetNotificationStats:output_type -> protobuf.GetNotificationStatsResponse
|
||||
39, // [39:55] is the sub-list for method output_type
|
||||
23, // [23:39] is the sub-list for method input_type
|
||||
23, // [23:23] is the sub-list for extension type_name
|
||||
23, // [23:23] is the sub-list for extension extendee
|
||||
0, // [0:23] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_rpc_proto_init() }
|
||||
@@ -1568,11 +1881,11 @@ func file_rpc_proto_init() {
|
||||
return
|
||||
}
|
||||
file_types_proto_init()
|
||||
file_rpc_proto_msgTypes[20].OneofWrappers = []any{
|
||||
file_rpc_proto_msgTypes[22].OneofWrappers = []any{
|
||||
(*ContainerExecRequest_Stdin)(nil),
|
||||
(*ContainerExecRequest_Resize)(nil),
|
||||
}
|
||||
file_rpc_proto_msgTypes[23].OneofWrappers = []any{
|
||||
file_rpc_proto_msgTypes[25].OneofWrappers = []any{
|
||||
(*ContainerAttachRequest_Stdin)(nil),
|
||||
(*ContainerAttachRequest_Resize)(nil),
|
||||
}
|
||||
@@ -1582,7 +1895,7 @@ func file_rpc_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_rpc_proto_rawDesc), len(file_rpc_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 29,
|
||||
NumMessages: 35,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.0
|
||||
// - protoc v6.33.4
|
||||
// - protoc v7.34.1
|
||||
// source: rpc.proto
|
||||
|
||||
package pb
|
||||
@@ -29,9 +29,12 @@ const (
|
||||
AgentService_StreamContainerStarted_FullMethodName = "/protobuf.AgentService/StreamContainerStarted"
|
||||
AgentService_HostInfo_FullMethodName = "/protobuf.AgentService/HostInfo"
|
||||
AgentService_ContainerAction_FullMethodName = "/protobuf.AgentService/ContainerAction"
|
||||
AgentService_UpdateContainer_FullMethodName = "/protobuf.AgentService/UpdateContainer"
|
||||
AgentService_ContainerExec_FullMethodName = "/protobuf.AgentService/ContainerExec"
|
||||
AgentService_ContainerAttach_FullMethodName = "/protobuf.AgentService/ContainerAttach"
|
||||
AgentService_UpdateNotificationConfig_FullMethodName = "/protobuf.AgentService/UpdateNotificationConfig"
|
||||
AgentService_UpdateCloudConfig_FullMethodName = "/protobuf.AgentService/UpdateCloudConfig"
|
||||
AgentService_GetNotificationStats_FullMethodName = "/protobuf.AgentService/GetNotificationStats"
|
||||
)
|
||||
|
||||
// AgentServiceClient is the client API for AgentService service.
|
||||
@@ -48,9 +51,12 @@ type AgentServiceClient interface {
|
||||
StreamContainerStarted(ctx context.Context, in *StreamContainerStartedRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StreamContainerStartedResponse], error)
|
||||
HostInfo(ctx context.Context, in *HostInfoRequest, opts ...grpc.CallOption) (*HostInfoResponse, error)
|
||||
ContainerAction(ctx context.Context, in *ContainerActionRequest, opts ...grpc.CallOption) (*ContainerActionResponse, error)
|
||||
UpdateContainer(ctx context.Context, in *UpdateContainerRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[UpdateContainerProgress], error)
|
||||
ContainerExec(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ContainerExecRequest, ContainerExecResponse], error)
|
||||
ContainerAttach(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ContainerAttachRequest, ContainerAttachResponse], error)
|
||||
UpdateNotificationConfig(ctx context.Context, in *UpdateNotificationConfigRequest, opts ...grpc.CallOption) (*UpdateNotificationConfigResponse, error)
|
||||
UpdateCloudConfig(ctx context.Context, in *UpdateCloudConfigRequest, opts ...grpc.CallOption) (*UpdateCloudConfigResponse, error)
|
||||
GetNotificationStats(ctx context.Context, in *GetNotificationStatsRequest, opts ...grpc.CallOption) (*GetNotificationStatsResponse, error)
|
||||
}
|
||||
|
||||
type agentServiceClient struct {
|
||||
@@ -215,9 +221,28 @@ func (c *agentServiceClient) ContainerAction(ctx context.Context, in *ContainerA
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *agentServiceClient) UpdateContainer(ctx context.Context, in *UpdateContainerRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[UpdateContainerProgress], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[6], AgentService_UpdateContainer_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[UpdateContainerRequest, UpdateContainerProgress]{ClientStream: stream}
|
||||
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := x.ClientStream.CloseSend(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentService_UpdateContainerClient = grpc.ServerStreamingClient[UpdateContainerProgress]
|
||||
|
||||
func (c *agentServiceClient) ContainerExec(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ContainerExecRequest, ContainerExecResponse], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[6], AgentService_ContainerExec_FullMethodName, cOpts...)
|
||||
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[7], AgentService_ContainerExec_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -230,7 +255,7 @@ type AgentService_ContainerExecClient = grpc.BidiStreamingClient[ContainerExecRe
|
||||
|
||||
func (c *agentServiceClient) ContainerAttach(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ContainerAttachRequest, ContainerAttachResponse], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[7], AgentService_ContainerAttach_FullMethodName, cOpts...)
|
||||
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[8], AgentService_ContainerAttach_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -251,6 +276,26 @@ func (c *agentServiceClient) UpdateNotificationConfig(ctx context.Context, in *U
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *agentServiceClient) UpdateCloudConfig(ctx context.Context, in *UpdateCloudConfigRequest, opts ...grpc.CallOption) (*UpdateCloudConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UpdateCloudConfigResponse)
|
||||
err := c.cc.Invoke(ctx, AgentService_UpdateCloudConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *agentServiceClient) GetNotificationStats(ctx context.Context, in *GetNotificationStatsRequest, opts ...grpc.CallOption) (*GetNotificationStatsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetNotificationStatsResponse)
|
||||
err := c.cc.Invoke(ctx, AgentService_GetNotificationStats_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AgentServiceServer is the server API for AgentService service.
|
||||
// All implementations must embed UnimplementedAgentServiceServer
|
||||
// for forward compatibility.
|
||||
@@ -265,9 +310,12 @@ type AgentServiceServer interface {
|
||||
StreamContainerStarted(*StreamContainerStartedRequest, grpc.ServerStreamingServer[StreamContainerStartedResponse]) error
|
||||
HostInfo(context.Context, *HostInfoRequest) (*HostInfoResponse, error)
|
||||
ContainerAction(context.Context, *ContainerActionRequest) (*ContainerActionResponse, error)
|
||||
UpdateContainer(*UpdateContainerRequest, grpc.ServerStreamingServer[UpdateContainerProgress]) error
|
||||
ContainerExec(grpc.BidiStreamingServer[ContainerExecRequest, ContainerExecResponse]) error
|
||||
ContainerAttach(grpc.BidiStreamingServer[ContainerAttachRequest, ContainerAttachResponse]) error
|
||||
UpdateNotificationConfig(context.Context, *UpdateNotificationConfigRequest) (*UpdateNotificationConfigResponse, error)
|
||||
UpdateCloudConfig(context.Context, *UpdateCloudConfigRequest) (*UpdateCloudConfigResponse, error)
|
||||
GetNotificationStats(context.Context, *GetNotificationStatsRequest) (*GetNotificationStatsResponse, error)
|
||||
mustEmbedUnimplementedAgentServiceServer()
|
||||
}
|
||||
|
||||
@@ -308,6 +356,9 @@ func (UnimplementedAgentServiceServer) HostInfo(context.Context, *HostInfoReques
|
||||
func (UnimplementedAgentServiceServer) ContainerAction(context.Context, *ContainerActionRequest) (*ContainerActionResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ContainerAction not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) UpdateContainer(*UpdateContainerRequest, grpc.ServerStreamingServer[UpdateContainerProgress]) error {
|
||||
return status.Error(codes.Unimplemented, "method UpdateContainer not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) ContainerExec(grpc.BidiStreamingServer[ContainerExecRequest, ContainerExecResponse]) error {
|
||||
return status.Error(codes.Unimplemented, "method ContainerExec not implemented")
|
||||
}
|
||||
@@ -317,6 +368,12 @@ func (UnimplementedAgentServiceServer) ContainerAttach(grpc.BidiStreamingServer[
|
||||
func (UnimplementedAgentServiceServer) UpdateNotificationConfig(context.Context, *UpdateNotificationConfigRequest) (*UpdateNotificationConfigResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UpdateNotificationConfig not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) UpdateCloudConfig(context.Context, *UpdateCloudConfigRequest) (*UpdateCloudConfigResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UpdateCloudConfig not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) GetNotificationStats(context.Context, *GetNotificationStatsRequest) (*GetNotificationStatsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetNotificationStats not implemented")
|
||||
}
|
||||
func (UnimplementedAgentServiceServer) mustEmbedUnimplementedAgentServiceServer() {}
|
||||
func (UnimplementedAgentServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@@ -476,6 +533,17 @@ func _AgentService_ContainerAction_Handler(srv interface{}, ctx context.Context,
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AgentService_UpdateContainer_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
m := new(UpdateContainerRequest)
|
||||
if err := stream.RecvMsg(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return srv.(AgentServiceServer).UpdateContainer(m, &grpc.GenericServerStream[UpdateContainerRequest, UpdateContainerProgress]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentService_UpdateContainerServer = grpc.ServerStreamingServer[UpdateContainerProgress]
|
||||
|
||||
func _AgentService_ContainerExec_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(AgentServiceServer).ContainerExec(&grpc.GenericServerStream[ContainerExecRequest, ContainerExecResponse]{ServerStream: stream})
|
||||
}
|
||||
@@ -508,6 +576,42 @@ func _AgentService_UpdateNotificationConfig_Handler(srv interface{}, ctx context
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AgentService_UpdateCloudConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdateCloudConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AgentServiceServer).UpdateCloudConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AgentService_UpdateCloudConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AgentServiceServer).UpdateCloudConfig(ctx, req.(*UpdateCloudConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AgentService_GetNotificationStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetNotificationStatsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AgentServiceServer).GetNotificationStats(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AgentService_GetNotificationStats_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AgentServiceServer).GetNotificationStats(ctx, req.(*GetNotificationStatsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// AgentService_ServiceDesc is the grpc.ServiceDesc for AgentService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -535,6 +639,14 @@ var AgentService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "UpdateNotificationConfig",
|
||||
Handler: _AgentService_UpdateNotificationConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdateCloudConfig",
|
||||
Handler: _AgentService_UpdateCloudConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetNotificationStats",
|
||||
Handler: _AgentService_GetNotificationStats_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
@@ -567,6 +679,11 @@ var AgentService_ServiceDesc = grpc.ServiceDesc{
|
||||
Handler: _AgentService_StreamContainerStarted_Handler,
|
||||
ServerStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "UpdateContainer",
|
||||
Handler: _AgentService_UpdateContainer_Handler,
|
||||
ServerStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "ContainerExec",
|
||||
Handler: _AgentService_ContainerExec_Handler,
|
||||
|
||||
+298
-46
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.4
|
||||
// protoc v7.34.1
|
||||
// source: types.proto
|
||||
|
||||
package pb
|
||||
@@ -93,6 +93,11 @@ type Container struct {
|
||||
MemoryLimit uint64 `protobuf:"varint,17,opt,name=memoryLimit,proto3" json:"memoryLimit,omitempty"`
|
||||
CpuLimit float64 `protobuf:"fixed64,18,opt,name=cpuLimit,proto3" json:"cpuLimit,omitempty"`
|
||||
FullyLoaded bool `protobuf:"varint,19,opt,name=fullyLoaded,proto3" json:"fullyLoaded,omitempty"`
|
||||
Env []string `protobuf:"bytes,20,rep,name=env,proto3" json:"env,omitempty"`
|
||||
Ports []string `protobuf:"bytes,21,rep,name=ports,proto3" json:"ports,omitempty"`
|
||||
Mounts []string `protobuf:"bytes,22,rep,name=mounts,proto3" json:"mounts,omitempty"`
|
||||
RestartPolicy string `protobuf:"bytes,23,opt,name=restartPolicy,proto3" json:"restartPolicy,omitempty"`
|
||||
NetworkMode string `protobuf:"bytes,24,opt,name=networkMode,proto3" json:"networkMode,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -260,6 +265,41 @@ func (x *Container) GetFullyLoaded() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *Container) GetEnv() []string {
|
||||
if x != nil {
|
||||
return x.Env
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Container) GetPorts() []string {
|
||||
if x != nil {
|
||||
return x.Ports
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Container) GetMounts() []string {
|
||||
if x != nil {
|
||||
return x.Mounts
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Container) GetRestartPolicy() string {
|
||||
if x != nil {
|
||||
return x.RestartPolicy
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Container) GetNetworkMode() string {
|
||||
if x != nil {
|
||||
return x.NetworkMode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ContainerStat struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
@@ -621,13 +661,14 @@ func (x *ComplexMessage) GetData() []byte {
|
||||
}
|
||||
|
||||
type ContainerEvent struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ActorId string `protobuf:"bytes,1,opt,name=actorId,proto3" json:"actorId,omitempty"`
|
||||
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
|
||||
Host string `protobuf:"bytes,3,opt,name=host,proto3" json:"host,omitempty"`
|
||||
Timestamp *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ActorId string `protobuf:"bytes,1,opt,name=actorId,proto3" json:"actorId,omitempty"`
|
||||
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
|
||||
Host string `protobuf:"bytes,3,opt,name=host,proto3" json:"host,omitempty"`
|
||||
Timestamp *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
|
||||
ActorAttributes map[string]string `protobuf:"bytes,5,rep,name=actorAttributes,proto3" json:"actorAttributes,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ContainerEvent) Reset() {
|
||||
@@ -688,6 +729,13 @@ func (x *ContainerEvent) GetTimestamp() *timestamppb.Timestamp {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ContainerEvent) GetActorAttributes() map[string]string {
|
||||
if x != nil {
|
||||
return x.ActorAttributes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Host struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
@@ -828,6 +876,10 @@ type NotificationSubscription struct {
|
||||
DispatcherId int32 `protobuf:"varint,4,opt,name=dispatcherId,proto3" json:"dispatcherId,omitempty"`
|
||||
LogExpression string `protobuf:"bytes,5,opt,name=logExpression,proto3" json:"logExpression,omitempty"`
|
||||
ContainerExpression string `protobuf:"bytes,6,opt,name=containerExpression,proto3" json:"containerExpression,omitempty"`
|
||||
MetricExpression string `protobuf:"bytes,7,opt,name=metricExpression,proto3" json:"metricExpression,omitempty"`
|
||||
Cooldown int32 `protobuf:"varint,8,opt,name=cooldown,proto3" json:"cooldown,omitempty"`
|
||||
SampleWindow int32 `protobuf:"varint,9,opt,name=sampleWindow,proto3" json:"sampleWindow,omitempty"`
|
||||
EventExpression string `protobuf:"bytes,10,opt,name=eventExpression,proto3" json:"eventExpression,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -904,6 +956,34 @@ func (x *NotificationSubscription) GetContainerExpression() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NotificationSubscription) GetMetricExpression() string {
|
||||
if x != nil {
|
||||
return x.MetricExpression
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NotificationSubscription) GetCooldown() int32 {
|
||||
if x != nil {
|
||||
return x.Cooldown
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *NotificationSubscription) GetSampleWindow() int32 {
|
||||
if x != nil {
|
||||
return x.SampleWindow
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *NotificationSubscription) GetEventExpression() string {
|
||||
if x != nil {
|
||||
return x.EventExpression
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type NotificationDispatcher struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
@@ -911,6 +991,7 @@ type NotificationDispatcher struct {
|
||||
Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"`
|
||||
Url string `protobuf:"bytes,4,opt,name=url,proto3" json:"url,omitempty"`
|
||||
Template string `protobuf:"bytes,5,opt,name=template,proto3" json:"template,omitempty"`
|
||||
Headers map[string]string `protobuf:"bytes,6,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -980,11 +1061,146 @@ func (x *NotificationDispatcher) GetTemplate() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NotificationDispatcher) GetHeaders() map[string]string {
|
||||
if x != nil {
|
||||
return x.Headers
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NotificationCloudConfig struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ApiKey string `protobuf:"bytes,1,opt,name=apiKey,proto3" json:"apiKey,omitempty"`
|
||||
Prefix string `protobuf:"bytes,2,opt,name=prefix,proto3" json:"prefix,omitempty"`
|
||||
ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=expiresAt,proto3" json:"expiresAt,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *NotificationCloudConfig) Reset() {
|
||||
*x = NotificationCloudConfig{}
|
||||
mi := &file_types_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *NotificationCloudConfig) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*NotificationCloudConfig) ProtoMessage() {}
|
||||
|
||||
func (x *NotificationCloudConfig) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_types_proto_msgTypes[11]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use NotificationCloudConfig.ProtoReflect.Descriptor instead.
|
||||
func (*NotificationCloudConfig) Descriptor() ([]byte, []int) {
|
||||
return file_types_proto_rawDescGZIP(), []int{11}
|
||||
}
|
||||
|
||||
func (x *NotificationCloudConfig) GetApiKey() string {
|
||||
if x != nil {
|
||||
return x.ApiKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NotificationCloudConfig) GetPrefix() string {
|
||||
if x != nil {
|
||||
return x.Prefix
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NotificationCloudConfig) GetExpiresAt() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.ExpiresAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NotificationSubscriptionStats struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
SubscriptionId int32 `protobuf:"varint,1,opt,name=subscriptionId,proto3" json:"subscriptionId,omitempty"`
|
||||
TriggerCount int64 `protobuf:"varint,2,opt,name=triggerCount,proto3" json:"triggerCount,omitempty"`
|
||||
LastTriggeredAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=lastTriggeredAt,proto3" json:"lastTriggeredAt,omitempty"`
|
||||
TriggeredContainerIds []string `protobuf:"bytes,4,rep,name=triggeredContainerIds,proto3" json:"triggeredContainerIds,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *NotificationSubscriptionStats) Reset() {
|
||||
*x = NotificationSubscriptionStats{}
|
||||
mi := &file_types_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *NotificationSubscriptionStats) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*NotificationSubscriptionStats) ProtoMessage() {}
|
||||
|
||||
func (x *NotificationSubscriptionStats) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_types_proto_msgTypes[12]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use NotificationSubscriptionStats.ProtoReflect.Descriptor instead.
|
||||
func (*NotificationSubscriptionStats) Descriptor() ([]byte, []int) {
|
||||
return file_types_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *NotificationSubscriptionStats) GetSubscriptionId() int32 {
|
||||
if x != nil {
|
||||
return x.SubscriptionId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *NotificationSubscriptionStats) GetTriggerCount() int64 {
|
||||
if x != nil {
|
||||
return x.TriggerCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *NotificationSubscriptionStats) GetLastTriggeredAt() *timestamppb.Timestamp {
|
||||
if x != nil {
|
||||
return x.LastTriggeredAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *NotificationSubscriptionStats) GetTriggeredContainerIds() []string {
|
||||
if x != nil {
|
||||
return x.TriggeredContainerIds
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_types_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_types_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\vtypes.proto\x12\bprotobuf\x1a\x19google/protobuf/any.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa2\x05\n" +
|
||||
"\vtypes.proto\x12\bprotobuf\x1a\x19google/protobuf/any.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xaa\x06\n" +
|
||||
"\tContainer\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" +
|
||||
@@ -1005,7 +1221,12 @@ const file_types_proto_rawDesc = "" +
|
||||
"\bfinished\x18\x10 \x01(\v2\x1a.google.protobuf.TimestampR\bfinished\x12 \n" +
|
||||
"\vmemoryLimit\x18\x11 \x01(\x04R\vmemoryLimit\x12\x1a\n" +
|
||||
"\bcpuLimit\x18\x12 \x01(\x01R\bcpuLimit\x12 \n" +
|
||||
"\vfullyLoaded\x18\x13 \x01(\bR\vfullyLoaded\x1a9\n" +
|
||||
"\vfullyLoaded\x18\x13 \x01(\bR\vfullyLoaded\x12\x10\n" +
|
||||
"\x03env\x18\x14 \x03(\tR\x03env\x12\x14\n" +
|
||||
"\x05ports\x18\x15 \x03(\tR\x05ports\x12\x16\n" +
|
||||
"\x06mounts\x18\x16 \x03(\tR\x06mounts\x12$\n" +
|
||||
"\rrestartPolicy\x18\x17 \x01(\tR\rrestartPolicy\x12 \n" +
|
||||
"\vnetworkMode\x18\x18 \x01(\tR\vnetworkMode\x1a9\n" +
|
||||
"\vLabelsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xd7\x01\n" +
|
||||
@@ -1036,12 +1257,16 @@ const file_types_proto_rawDesc = "" +
|
||||
"\fGroupMessage\x123\n" +
|
||||
"\tfragments\x18\x01 \x03(\v2\x15.protobuf.LogFragmentR\tfragments\"$\n" +
|
||||
"\x0eComplexMessage\x12\x12\n" +
|
||||
"\x04data\x18\x01 \x01(\fR\x04data\"\x8c\x01\n" +
|
||||
"\x04data\x18\x01 \x01(\fR\x04data\"\xa9\x02\n" +
|
||||
"\x0eContainerEvent\x12\x18\n" +
|
||||
"\aactorId\x18\x01 \x01(\tR\aactorId\x12\x12\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" +
|
||||
"\x04host\x18\x03 \x01(\tR\x04host\x128\n" +
|
||||
"\ttimestamp\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\"\xaf\x03\n" +
|
||||
"\ttimestamp\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12W\n" +
|
||||
"\x0factorAttributes\x18\x05 \x03(\v2-.protobuf.ContainerEvent.ActorAttributesEntryR\x0factorAttributes\x1aB\n" +
|
||||
"\x14ActorAttributesEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xaf\x03\n" +
|
||||
"\x04Host\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\x12 \n" +
|
||||
@@ -1058,20 +1283,39 @@ const file_types_proto_rawDesc = "" +
|
||||
"\rdockerVersion\x18\f \x01(\tR\rdockerVersion\x1a9\n" +
|
||||
"\vLabelsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xd4\x01\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xea\x02\n" +
|
||||
"\x18NotificationSubscription\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\x05R\x02id\x12\x12\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x18\n" +
|
||||
"\aenabled\x18\x03 \x01(\bR\aenabled\x12\"\n" +
|
||||
"\fdispatcherId\x18\x04 \x01(\x05R\fdispatcherId\x12$\n" +
|
||||
"\rlogExpression\x18\x05 \x01(\tR\rlogExpression\x120\n" +
|
||||
"\x13containerExpression\x18\x06 \x01(\tR\x13containerExpression\"~\n" +
|
||||
"\x13containerExpression\x18\x06 \x01(\tR\x13containerExpression\x12*\n" +
|
||||
"\x10metricExpression\x18\a \x01(\tR\x10metricExpression\x12\x1a\n" +
|
||||
"\bcooldown\x18\b \x01(\x05R\bcooldown\x12\"\n" +
|
||||
"\fsampleWindow\x18\t \x01(\x05R\fsampleWindow\x12(\n" +
|
||||
"\x0feventExpression\x18\n" +
|
||||
" \x01(\tR\x0feventExpression\"\x95\x02\n" +
|
||||
"\x16NotificationDispatcher\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\x05R\x02id\x12\x12\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" +
|
||||
"\x04type\x18\x03 \x01(\tR\x04type\x12\x10\n" +
|
||||
"\x03url\x18\x04 \x01(\tR\x03url\x12\x1a\n" +
|
||||
"\btemplate\x18\x05 \x01(\tR\btemplate*3\n" +
|
||||
"\btemplate\x18\x05 \x01(\tR\btemplate\x12G\n" +
|
||||
"\aheaders\x18\x06 \x03(\v2-.protobuf.NotificationDispatcher.HeadersEntryR\aheaders\x1a:\n" +
|
||||
"\fHeadersEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\a\x10\bJ\x04\b\b\x10\tJ\x04\b\t\x10\n" +
|
||||
"\"\x83\x01\n" +
|
||||
"\x17NotificationCloudConfig\x12\x16\n" +
|
||||
"\x06apiKey\x18\x01 \x01(\tR\x06apiKey\x12\x16\n" +
|
||||
"\x06prefix\x18\x02 \x01(\tR\x06prefix\x128\n" +
|
||||
"\texpiresAt\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"\xe7\x01\n" +
|
||||
"\x1dNotificationSubscriptionStats\x12&\n" +
|
||||
"\x0esubscriptionId\x18\x01 \x01(\x05R\x0esubscriptionId\x12\"\n" +
|
||||
"\ftriggerCount\x18\x02 \x01(\x03R\ftriggerCount\x12D\n" +
|
||||
"\x0flastTriggeredAt\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x0flastTriggeredAt\x124\n" +
|
||||
"\x15triggeredContainerIds\x18\x04 \x03(\tR\x15triggeredContainerIds*3\n" +
|
||||
"\x0fContainerAction\x12\t\n" +
|
||||
"\x05Start\x10\x00\x12\b\n" +
|
||||
"\x04Stop\x10\x01\x12\v\n" +
|
||||
@@ -1090,41 +1334,49 @@ func file_types_proto_rawDescGZIP() []byte {
|
||||
}
|
||||
|
||||
var file_types_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||
var file_types_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
|
||||
var file_types_proto_msgTypes = make([]protoimpl.MessageInfo, 17)
|
||||
var file_types_proto_goTypes = []any{
|
||||
(ContainerAction)(0), // 0: protobuf.ContainerAction
|
||||
(*Container)(nil), // 1: protobuf.Container
|
||||
(*ContainerStat)(nil), // 2: protobuf.ContainerStat
|
||||
(*LogFragment)(nil), // 3: protobuf.LogFragment
|
||||
(*LogEvent)(nil), // 4: protobuf.LogEvent
|
||||
(*SingleMessage)(nil), // 5: protobuf.SingleMessage
|
||||
(*GroupMessage)(nil), // 6: protobuf.GroupMessage
|
||||
(*ComplexMessage)(nil), // 7: protobuf.ComplexMessage
|
||||
(*ContainerEvent)(nil), // 8: protobuf.ContainerEvent
|
||||
(*Host)(nil), // 9: protobuf.Host
|
||||
(*NotificationSubscription)(nil), // 10: protobuf.NotificationSubscription
|
||||
(*NotificationDispatcher)(nil), // 11: protobuf.NotificationDispatcher
|
||||
nil, // 12: protobuf.Container.LabelsEntry
|
||||
nil, // 13: protobuf.Host.LabelsEntry
|
||||
(*timestamppb.Timestamp)(nil), // 14: google.protobuf.Timestamp
|
||||
(*anypb.Any)(nil), // 15: google.protobuf.Any
|
||||
(ContainerAction)(0), // 0: protobuf.ContainerAction
|
||||
(*Container)(nil), // 1: protobuf.Container
|
||||
(*ContainerStat)(nil), // 2: protobuf.ContainerStat
|
||||
(*LogFragment)(nil), // 3: protobuf.LogFragment
|
||||
(*LogEvent)(nil), // 4: protobuf.LogEvent
|
||||
(*SingleMessage)(nil), // 5: protobuf.SingleMessage
|
||||
(*GroupMessage)(nil), // 6: protobuf.GroupMessage
|
||||
(*ComplexMessage)(nil), // 7: protobuf.ComplexMessage
|
||||
(*ContainerEvent)(nil), // 8: protobuf.ContainerEvent
|
||||
(*Host)(nil), // 9: protobuf.Host
|
||||
(*NotificationSubscription)(nil), // 10: protobuf.NotificationSubscription
|
||||
(*NotificationDispatcher)(nil), // 11: protobuf.NotificationDispatcher
|
||||
(*NotificationCloudConfig)(nil), // 12: protobuf.NotificationCloudConfig
|
||||
(*NotificationSubscriptionStats)(nil), // 13: protobuf.NotificationSubscriptionStats
|
||||
nil, // 14: protobuf.Container.LabelsEntry
|
||||
nil, // 15: protobuf.ContainerEvent.ActorAttributesEntry
|
||||
nil, // 16: protobuf.Host.LabelsEntry
|
||||
nil, // 17: protobuf.NotificationDispatcher.HeadersEntry
|
||||
(*timestamppb.Timestamp)(nil), // 18: google.protobuf.Timestamp
|
||||
(*anypb.Any)(nil), // 19: google.protobuf.Any
|
||||
}
|
||||
var file_types_proto_depIdxs = []int32{
|
||||
14, // 0: protobuf.Container.created:type_name -> google.protobuf.Timestamp
|
||||
14, // 1: protobuf.Container.started:type_name -> google.protobuf.Timestamp
|
||||
12, // 2: protobuf.Container.labels:type_name -> protobuf.Container.LabelsEntry
|
||||
18, // 0: protobuf.Container.created:type_name -> google.protobuf.Timestamp
|
||||
18, // 1: protobuf.Container.started:type_name -> google.protobuf.Timestamp
|
||||
14, // 2: protobuf.Container.labels:type_name -> protobuf.Container.LabelsEntry
|
||||
2, // 3: protobuf.Container.stats:type_name -> protobuf.ContainerStat
|
||||
14, // 4: protobuf.Container.finished:type_name -> google.protobuf.Timestamp
|
||||
15, // 5: protobuf.LogEvent.message:type_name -> google.protobuf.Any
|
||||
14, // 6: protobuf.LogEvent.timestamp:type_name -> google.protobuf.Timestamp
|
||||
18, // 4: protobuf.Container.finished:type_name -> google.protobuf.Timestamp
|
||||
19, // 5: protobuf.LogEvent.message:type_name -> google.protobuf.Any
|
||||
18, // 6: protobuf.LogEvent.timestamp:type_name -> google.protobuf.Timestamp
|
||||
3, // 7: protobuf.GroupMessage.fragments:type_name -> protobuf.LogFragment
|
||||
14, // 8: protobuf.ContainerEvent.timestamp:type_name -> google.protobuf.Timestamp
|
||||
13, // 9: protobuf.Host.labels:type_name -> protobuf.Host.LabelsEntry
|
||||
10, // [10:10] is the sub-list for method output_type
|
||||
10, // [10:10] is the sub-list for method input_type
|
||||
10, // [10:10] is the sub-list for extension type_name
|
||||
10, // [10:10] is the sub-list for extension extendee
|
||||
0, // [0:10] is the sub-list for field type_name
|
||||
18, // 8: protobuf.ContainerEvent.timestamp:type_name -> google.protobuf.Timestamp
|
||||
15, // 9: protobuf.ContainerEvent.actorAttributes:type_name -> protobuf.ContainerEvent.ActorAttributesEntry
|
||||
16, // 10: protobuf.Host.labels:type_name -> protobuf.Host.LabelsEntry
|
||||
17, // 11: protobuf.NotificationDispatcher.headers:type_name -> protobuf.NotificationDispatcher.HeadersEntry
|
||||
18, // 12: protobuf.NotificationCloudConfig.expiresAt:type_name -> google.protobuf.Timestamp
|
||||
18, // 13: protobuf.NotificationSubscriptionStats.lastTriggeredAt:type_name -> google.protobuf.Timestamp
|
||||
14, // [14:14] is the sub-list for method output_type
|
||||
14, // [14:14] is the sub-list for method input_type
|
||||
14, // [14:14] is the sub-list for extension type_name
|
||||
14, // [14:14] is the sub-list for extension extendee
|
||||
0, // [0:14] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_types_proto_init() }
|
||||
@@ -1138,7 +1390,7 @@ func file_types_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_types_proto_rawDesc), len(file_types_proto_rawDesc)),
|
||||
NumEnums: 1,
|
||||
NumMessages: 13,
|
||||
NumMessages: 17,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
+96
-10
@@ -14,12 +14,14 @@ import (
|
||||
|
||||
"github.com/amir20/dozzle/internal/agent/pb"
|
||||
"github.com/amir20/dozzle/internal/container"
|
||||
"github.com/amir20/dozzle/internal/notification/dispatcher"
|
||||
"github.com/amir20/dozzle/types"
|
||||
"github.com/rs/zerolog/log"
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/keepalive"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
@@ -29,6 +31,9 @@ import (
|
||||
// NotificationConfigHandler handles notification config updates received from the main server
|
||||
type NotificationConfigHandler interface {
|
||||
HandleNotificationConfig(subscriptions []types.SubscriptionConfig, dispatchers []types.DispatcherConfig) error
|
||||
SetCloudDispatcher(d dispatcher.Dispatcher)
|
||||
ClearCloudDispatcher()
|
||||
GetNotificationStats() []types.SubscriptionStats
|
||||
}
|
||||
|
||||
// ClientService is the interface for container operations used by the agent server
|
||||
@@ -37,6 +42,7 @@ type ClientService interface {
|
||||
ListContainers(ctx context.Context, filter container.ContainerLabels) ([]container.Container, error)
|
||||
Host(ctx context.Context) (container.Host, error)
|
||||
ContainerAction(ctx context.Context, container container.Container, action container.ContainerAction) error
|
||||
UpdateContainer(ctx context.Context, container container.Container, progressCh chan<- container.UpdateProgress) (bool, error)
|
||||
LogsBetweenDates(ctx context.Context, container container.Container, from time.Time, to time.Time, stdTypes container.StdType) (<-chan *container.LogEvent, error)
|
||||
RawLogs(ctx context.Context, container container.Container, from time.Time, to time.Time, stdTypes container.StdType) (io.ReadCloser, error)
|
||||
SubscribeStats(context.Context, chan<- container.ContainerStat)
|
||||
@@ -56,6 +62,10 @@ type server struct {
|
||||
}
|
||||
|
||||
func newServer(service ClientService, dozzleVersion string, notificationHandler NotificationConfigHandler) pb.AgentServiceServer {
|
||||
if notificationHandler == nil {
|
||||
log.Fatal().Msg("No notification config handler registered")
|
||||
}
|
||||
|
||||
return &server{
|
||||
service: service,
|
||||
version: dozzleVersion,
|
||||
@@ -164,10 +174,11 @@ func (s *server) StreamEvents(in *pb.StreamEventsRequest, out pb.AgentService_St
|
||||
case event := <-events:
|
||||
out.Send(&pb.StreamEventsResponse{
|
||||
Event: &pb.ContainerEvent{
|
||||
ActorId: event.ActorID,
|
||||
Name: event.Name,
|
||||
Host: event.Host,
|
||||
Timestamp: timestamppb.New(event.Time),
|
||||
ActorId: event.ActorID,
|
||||
Name: event.Name,
|
||||
Host: event.Host,
|
||||
Timestamp: timestamppb.New(event.Time),
|
||||
ActorAttributes: event.ActorAttributes,
|
||||
},
|
||||
})
|
||||
case <-out.Context().Done():
|
||||
@@ -306,6 +317,35 @@ func (s *server) ContainerAction(ctx context.Context, in *pb.ContainerActionRequ
|
||||
return &pb.ContainerActionResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *server) UpdateContainer(req *pb.UpdateContainerRequest, out pb.AgentService_UpdateContainerServer) error {
|
||||
c, err := s.service.FindContainer(out.Context(), req.ContainerId, container.ContainerLabels{})
|
||||
if err != nil {
|
||||
return status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
|
||||
progressCh := make(chan container.UpdateProgress)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
_, err := s.service.UpdateContainer(out.Context(), c, progressCh)
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
for progress := range progressCh {
|
||||
if err := out.Send(&pb.UpdateContainerProgress{
|
||||
Status: progress.Status,
|
||||
Layer: progress.Layer,
|
||||
Current: progress.Current,
|
||||
Total: progress.Total,
|
||||
Error: progress.Error,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return <-errCh
|
||||
}
|
||||
|
||||
// terminalMessage represents a message from a terminal gRPC stream (exec or attach)
|
||||
type terminalMessage interface {
|
||||
GetStdin() []byte
|
||||
@@ -388,11 +428,6 @@ func (s *server) ContainerAttach(stream pb.AgentService_ContainerAttachServer) e
|
||||
}
|
||||
|
||||
func (s *server) UpdateNotificationConfig(ctx context.Context, req *pb.UpdateNotificationConfigRequest) (*pb.UpdateNotificationConfigResponse, error) {
|
||||
if s.notificationConfigHandler == nil {
|
||||
log.Warn().Msg("No notification config handler registered, ignoring config update")
|
||||
return &pb.UpdateNotificationConfigResponse{}, nil
|
||||
}
|
||||
|
||||
// Validate request sizes to prevent memory exhaustion
|
||||
const maxSubscriptions = 1000
|
||||
const maxDispatchers = 100
|
||||
@@ -413,6 +448,10 @@ func (s *server) UpdateNotificationConfig(ctx context.Context, req *pb.UpdateNot
|
||||
DispatcherID: int(sub.DispatcherId),
|
||||
LogExpression: sub.LogExpression,
|
||||
ContainerExpression: sub.ContainerExpression,
|
||||
MetricExpression: sub.MetricExpression,
|
||||
Cooldown: int(sub.Cooldown),
|
||||
SampleWindow: int(sub.SampleWindow),
|
||||
EventExpression: sub.EventExpression,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +464,7 @@ func (s *server) UpdateNotificationConfig(ctx context.Context, req *pb.UpdateNot
|
||||
Type: d.Type,
|
||||
URL: d.Url,
|
||||
Template: d.Template,
|
||||
Headers: d.Headers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,6 +478,46 @@ func (s *server) UpdateNotificationConfig(ctx context.Context, req *pb.UpdateNot
|
||||
return &pb.UpdateNotificationConfigResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *server) UpdateCloudConfig(ctx context.Context, req *pb.UpdateCloudConfigRequest) (*pb.UpdateCloudConfigResponse, error) {
|
||||
if cc := req.CloudConfig; cc != nil && cc.ApiKey != "" {
|
||||
var expiresAt *time.Time
|
||||
if cc.ExpiresAt != nil {
|
||||
t := cc.ExpiresAt.AsTime()
|
||||
expiresAt = &t
|
||||
}
|
||||
d, err := dispatcher.NewCloudDispatcher("Dozzle Cloud", cc.ApiKey, cc.Prefix, expiresAt)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create cloud dispatcher from broadcast config")
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
s.notificationConfigHandler.SetCloudDispatcher(d)
|
||||
log.Info().Msg("Updated cloud config from main server")
|
||||
} else {
|
||||
s.notificationConfigHandler.ClearCloudDispatcher()
|
||||
log.Info().Msg("Cleared cloud config from main server")
|
||||
}
|
||||
return &pb.UpdateCloudConfigResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *server) GetNotificationStats(ctx context.Context, req *pb.GetNotificationStatsRequest) (*pb.GetNotificationStatsResponse, error) {
|
||||
stats := s.notificationConfigHandler.GetNotificationStats()
|
||||
|
||||
pbStats := make([]*pb.NotificationSubscriptionStats, len(stats))
|
||||
for i, s := range stats {
|
||||
pbStat := &pb.NotificationSubscriptionStats{
|
||||
SubscriptionId: int32(s.SubscriptionID),
|
||||
TriggerCount: s.TriggerCount,
|
||||
TriggeredContainerIds: s.TriggeredContainerIDs,
|
||||
}
|
||||
if s.LastTriggeredAt != nil {
|
||||
pbStat.LastTriggeredAt = timestamppb.New(*s.LastTriggeredAt)
|
||||
}
|
||||
pbStats[i] = pbStat
|
||||
}
|
||||
|
||||
return &pb.GetNotificationStatsResponse{Stats: pbStats}, nil
|
||||
}
|
||||
|
||||
func NewServer(service ClientService, certificates tls.Certificate, dozzleVersion string, notificationHandler NotificationConfigHandler) (*grpc.Server, error) {
|
||||
caCertPool := x509.NewCertPool()
|
||||
c, err := x509.ParseCertificate(certificates.Certificate[0])
|
||||
@@ -456,7 +536,13 @@ func NewServer(service ClientService, certificates tls.Certificate, dozzleVersio
|
||||
// Create the gRPC server with the credentials
|
||||
creds := credentials.NewTLS(tlsConfig)
|
||||
|
||||
grpcServer := grpc.NewServer(grpc.Creds(creds))
|
||||
grpcServer := grpc.NewServer(
|
||||
grpc.Creds(creds),
|
||||
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
|
||||
MinTime: 15 * time.Second,
|
||||
PermitWithoutStream: true,
|
||||
}),
|
||||
)
|
||||
pb.RegisterAgentServiceServer(grpcServer, newServer(service, dozzleVersion, notificationHandler))
|
||||
|
||||
return grpcServer, nil
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/amir20/dozzle/internal/container"
|
||||
"github.com/amir20/dozzle/internal/notification/dispatcher"
|
||||
"github.com/amir20/dozzle/internal/support/cli"
|
||||
pb "github.com/amir20/dozzle/proto/cloud"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/sync/semaphore"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/keepalive"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
initialBackoff = 1 * time.Second
|
||||
maxBackoff = 30 * time.Second
|
||||
backoffFactor = 2
|
||||
jitterFraction = 0.1
|
||||
maxConcurrent = 5
|
||||
maxConcurrentStreams = 10
|
||||
unauthenticatedPause = 1 * time.Hour
|
||||
)
|
||||
|
||||
// Client manages the gRPC connection to Dozzle Cloud
|
||||
type Client struct {
|
||||
enableActions bool
|
||||
labels container.ContainerLabels
|
||||
hostService ToolHostService
|
||||
apiKeyFunc func() string
|
||||
target string
|
||||
plaintext bool
|
||||
toolSem *semaphore.Weighted
|
||||
streamSem *semaphore.Weighted
|
||||
cachedTools []*pb.ToolDefinition
|
||||
toolsOnce sync.Once
|
||||
startCh chan struct{}
|
||||
activeStreams sync.Map // requestID -> context.CancelFunc
|
||||
}
|
||||
|
||||
// NewClient creates a new cloud gRPC client.
|
||||
// apiKeyFunc is called to get the current cloud API key — it may return ""
|
||||
// if no cloud dispatcher is configured yet, in which case the client waits.
|
||||
func NewClient(enableActions bool, labels container.ContainerLabels, hostService ToolHostService, apiKeyFunc func() string) *Client {
|
||||
cloudURL := os.Getenv("AGENT_URL")
|
||||
if cloudURL == "" {
|
||||
cloudURL = "https://agent.doligence.dozzle.dev"
|
||||
}
|
||||
|
||||
// Support plaintext for local dev (AGENT_URL=http://localhost:7008)
|
||||
plaintext := strings.HasPrefix(cloudURL, "http://")
|
||||
|
||||
target := cloudURL
|
||||
target = strings.TrimPrefix(target, "https://")
|
||||
target = strings.TrimPrefix(target, "http://")
|
||||
if !strings.Contains(target, ":") {
|
||||
if plaintext {
|
||||
target = target + ":80"
|
||||
} else {
|
||||
target = target + ":443"
|
||||
}
|
||||
}
|
||||
|
||||
return &Client{
|
||||
enableActions: enableActions,
|
||||
labels: labels,
|
||||
hostService: hostService,
|
||||
apiKeyFunc: apiKeyFunc,
|
||||
target: target,
|
||||
plaintext: plaintext,
|
||||
toolSem: semaphore.NewWeighted(maxConcurrent),
|
||||
streamSem: semaphore.NewWeighted(maxConcurrentStreams),
|
||||
startCh: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// Notify signals the client to attempt a connection. Safe to call multiple times.
|
||||
// Use this when a cloud dispatcher is added or when the status page is viewed.
|
||||
func (c *Client) Notify() {
|
||||
select {
|
||||
case c.startCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Run blocks until signaled via Notify(), then connects to the cloud gRPC endpoint
|
||||
// and processes tool requests. Reconnects automatically on failure.
|
||||
// Does nothing until Notify() is called — zero overhead for non-cloud users.
|
||||
// Blocks until ctx is cancelled.
|
||||
func (c *Client) Run(ctx context.Context) {
|
||||
// Wait for signal to start
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-c.startCh:
|
||||
}
|
||||
|
||||
backoff := initialBackoff
|
||||
|
||||
backoffTimer := time.NewTimer(0)
|
||||
backoffTimer.Stop()
|
||||
defer backoffTimer.Stop()
|
||||
|
||||
for {
|
||||
apiKey := c.apiKeyFunc()
|
||||
if apiKey == "" {
|
||||
// Cloud dispatcher was removed — go back to waiting for signal
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-c.startCh:
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
wasConnected, err := c.connect(ctx, apiKey)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if wasConnected {
|
||||
backoff = initialBackoff
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if isPermissionDenied(err) {
|
||||
log.Debug().Msg("cloud account does not have pro plan, waiting for upgrade")
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-c.startCh:
|
||||
}
|
||||
backoff = initialBackoff
|
||||
continue
|
||||
}
|
||||
if isUnauthenticated(err) {
|
||||
log.Warn().Err(err).Dur("pause", unauthenticatedPause).Msg("invalid API key, pausing before retry")
|
||||
backoffTimer.Reset(unauthenticatedPause)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-backoffTimer.C:
|
||||
}
|
||||
backoff = initialBackoff
|
||||
continue
|
||||
}
|
||||
log.Warn().Err(err).Dur("backoff", backoff).Msg("cloud connection failed, reconnecting")
|
||||
}
|
||||
|
||||
jitter := time.Duration(float64(backoff) * jitterFraction * rand.Float64())
|
||||
backoffTimer.Reset(backoff + jitter)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-backoffTimer.C:
|
||||
}
|
||||
|
||||
backoff = min(backoff*backoffFactor, maxBackoff)
|
||||
}
|
||||
}
|
||||
|
||||
// connect establishes a gRPC stream to the cloud and processes requests.
|
||||
// Returns wasConnected=true if the stream was successfully established and
|
||||
// at least one message was received before disconnecting.
|
||||
func (c *Client) connect(ctx context.Context, apiKey string) (wasConnected bool, err error) {
|
||||
var creds grpc.DialOption
|
||||
if c.plaintext {
|
||||
creds = grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
} else {
|
||||
creds = grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, ""))
|
||||
}
|
||||
|
||||
conn, err := grpc.NewClient(c.target, creds, grpc.WithUserAgent(dispatcher.UserAgent),
|
||||
grpc.WithKeepaliveParams(keepalive.ClientParameters{
|
||||
Time: 30 * time.Second,
|
||||
Timeout: 10 * time.Second,
|
||||
PermitWithoutStream: true,
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to dial cloud: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client := pb.NewCloudToolServiceClient(conn)
|
||||
|
||||
md := metadata.Pairs("x-api-key", apiKey)
|
||||
streamCtx := metadata.NewOutgoingContext(ctx, md)
|
||||
|
||||
stream, err := client.ToolStream(streamCtx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to open tool stream: %w", err)
|
||||
}
|
||||
|
||||
log.Debug().Str("target", c.target).Msg("connected to cloud tool service")
|
||||
|
||||
streamLifetime, streamCancel := context.WithCancel(stream.Context())
|
||||
var sendMu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
sendResp := func(resp *pb.ToolResponse) error {
|
||||
sendMu.Lock()
|
||||
defer sendMu.Unlock()
|
||||
return stream.Send(resp)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Cancel all active log streams before shutting down
|
||||
c.activeStreams.Range(func(key, value any) bool {
|
||||
if cancel, ok := value.(context.CancelFunc); ok {
|
||||
cancel()
|
||||
}
|
||||
c.activeStreams.Delete(key)
|
||||
return true
|
||||
})
|
||||
streamCancel()
|
||||
wg.Wait()
|
||||
}()
|
||||
|
||||
for {
|
||||
req, err := stream.Recv()
|
||||
if err != nil {
|
||||
return wasConnected, fmt.Errorf("stream recv error: %w", err)
|
||||
}
|
||||
wasConnected = true
|
||||
|
||||
// Handle cancel stream request
|
||||
if cancelReq, ok := req.Type.(*pb.ToolRequest_CancelStream); ok {
|
||||
streamReqID := cancelReq.CancelStream.StreamRequestId
|
||||
if cancel, loaded := c.activeStreams.LoadAndDelete(streamReqID); loaded {
|
||||
if cancelFn, ok := cancel.(context.CancelFunc); ok {
|
||||
log.Debug().Str("request_id", streamReqID).Msg("cancelling active stream")
|
||||
cancelFn()
|
||||
}
|
||||
} else {
|
||||
log.Warn().Str("request_id", streamReqID).Msg("cancel_stream: no active stream found")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle stream_logs specially — long-lived, separate semaphore from tool calls
|
||||
if callReq, ok := req.Type.(*pb.ToolRequest_CallTool); ok && callReq.CallTool.Name == "stream_logs" {
|
||||
if !c.streamSem.TryAcquire(1) {
|
||||
resp := &pb.ToolResponse{
|
||||
RequestId: req.RequestId,
|
||||
Type: &pb.ToolResponse_CallTool{
|
||||
CallTool: &pb.CallToolResponse{
|
||||
Success: false,
|
||||
Error: "too many concurrent log streams",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := sendResp(resp); err != nil {
|
||||
return wasConnected, fmt.Errorf("stream send error: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
logStreamCtx, logStreamCancel := context.WithCancel(streamLifetime)
|
||||
c.activeStreams.Store(req.RequestId, logStreamCancel)
|
||||
reqID := req.RequestId
|
||||
argsJSON := callReq.CallTool.ArgumentsJson
|
||||
wg.Go(func() {
|
||||
defer func() {
|
||||
c.streamSem.Release(1)
|
||||
c.activeStreams.Delete(reqID)
|
||||
logStreamCancel()
|
||||
}()
|
||||
log.Debug().Str("request_id", reqID).Msg("starting stream_logs")
|
||||
if err := executeStreamLogs(logStreamCtx, reqID, argsJSON, c.hostService, c.labels, sendResp); err != nil {
|
||||
if streamLifetime.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Debug().Err(err).Str("request_id", reqID).Msg("stream_logs ended")
|
||||
}
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// List tools is fast — handle inline. Tool calls run concurrently with a semaphore.
|
||||
if _, ok := req.Type.(*pb.ToolRequest_CallTool); ok {
|
||||
if !c.toolSem.TryAcquire(1) {
|
||||
resp := &pb.ToolResponse{
|
||||
RequestId: req.RequestId,
|
||||
Type: &pb.ToolResponse_CallTool{
|
||||
CallTool: &pb.CallToolResponse{
|
||||
Success: false,
|
||||
Error: "too many concurrent tool calls",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := sendResp(resp); err != nil {
|
||||
return wasConnected, fmt.Errorf("stream send error: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
wg.Go(func() {
|
||||
defer c.toolSem.Release(1)
|
||||
resp := c.handleRequest(streamLifetime, req)
|
||||
if streamLifetime.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := sendResp(resp); err != nil {
|
||||
log.Debug().Err(err).Msg("failed to send tool response")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
resp := c.handleRequest(streamLifetime, req)
|
||||
if err := sendResp(resp); err != nil {
|
||||
return wasConnected, fmt.Errorf("stream send error: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleRequest(ctx context.Context, req *pb.ToolRequest) *pb.ToolResponse {
|
||||
resp := &pb.ToolResponse{
|
||||
RequestId: req.RequestId,
|
||||
}
|
||||
|
||||
switch t := req.Type.(type) {
|
||||
case *pb.ToolRequest_ListTools:
|
||||
log.Debug().Str("request_id", req.RequestId).Msg("cloud requested tool list")
|
||||
resp.Type = &pb.ToolResponse_ListTools{
|
||||
ListTools: &pb.ListToolsResponse{
|
||||
Tools: c.tools(),
|
||||
Version: cli.Version,
|
||||
},
|
||||
}
|
||||
|
||||
case *pb.ToolRequest_CallTool:
|
||||
log.Debug().Str("request_id", req.RequestId).Str("tool", t.CallTool.Name).Str("args", t.CallTool.ArgumentsJson).Msg("cloud tool call received")
|
||||
callResp := ExecuteTool(ctx, t.CallTool.Name, t.CallTool.ArgumentsJson, c.enableActions, c.hostService, c.labels)
|
||||
if !callResp.Success {
|
||||
log.Debug().Str("error", callResp.Error).Str("request_id", req.RequestId).Str("tool", t.CallTool.Name).Msg("cloud tool call failed")
|
||||
} else {
|
||||
log.Debug().Str("request_id", req.RequestId).Str("tool", t.CallTool.Name).Msg("cloud tool call completed")
|
||||
}
|
||||
resp.Type = &pb.ToolResponse_CallTool{
|
||||
CallTool: callResp,
|
||||
}
|
||||
|
||||
default:
|
||||
log.Warn().Msg("received unknown tool request type")
|
||||
resp.Type = &pb.ToolResponse_CallTool{
|
||||
CallTool: &pb.CallToolResponse{
|
||||
Success: false,
|
||||
Error: "unknown request type",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func (c *Client) tools() []*pb.ToolDefinition {
|
||||
c.toolsOnce.Do(func() {
|
||||
c.cachedTools = AvailableTools(c.enableActions)
|
||||
})
|
||||
return c.cachedTools
|
||||
}
|
||||
|
||||
func isPermissionDenied(err error) bool {
|
||||
return hasGRPCCode(err, codes.PermissionDenied)
|
||||
}
|
||||
|
||||
func isUnauthenticated(err error) bool {
|
||||
return hasGRPCCode(err, codes.Unauthenticated)
|
||||
}
|
||||
|
||||
func hasGRPCCode(err error, code codes.Code) bool {
|
||||
for e := err; e != nil; e = errors.Unwrap(e) {
|
||||
if s, ok := status.FromError(e); ok && s.Code() == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/amir20/dozzle/internal/container"
|
||||
container_support "github.com/amir20/dozzle/internal/support/container"
|
||||
pb "github.com/amir20/dozzle/proto/cloud"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestNewClient_DefaultURL(t *testing.T) {
|
||||
t.Setenv("AGENT_URL", "")
|
||||
client := NewClient(true, nil, nil, func() string { return "test-key" })
|
||||
assert.Equal(t, "agent.doligence.dozzle.dev:443", client.target)
|
||||
}
|
||||
|
||||
func TestNewClient_CustomURL(t *testing.T) {
|
||||
t.Setenv("AGENT_URL", "https://custom.cloud.dev")
|
||||
client := NewClient(true, nil, nil, func() string { return "test-key" })
|
||||
assert.Equal(t, "custom.cloud.dev:443", client.target)
|
||||
assert.False(t, client.plaintext)
|
||||
}
|
||||
|
||||
func TestNewClient_PlaintextURL(t *testing.T) {
|
||||
t.Setenv("AGENT_URL", "http://localhost:7008")
|
||||
client := NewClient(true, nil, nil, func() string { return "test-key" })
|
||||
assert.Equal(t, "localhost:7008", client.target)
|
||||
assert.True(t, client.plaintext)
|
||||
}
|
||||
|
||||
func TestHandleRequest_ListTools(t *testing.T) {
|
||||
client := &Client{
|
||||
enableActions: true,
|
||||
}
|
||||
|
||||
req := &pb.ToolRequest{
|
||||
RequestId: "req-1",
|
||||
Type: &pb.ToolRequest_ListTools{
|
||||
ListTools: &pb.ListToolsRequest{},
|
||||
},
|
||||
}
|
||||
|
||||
resp := client.handleRequest(context.Background(), req)
|
||||
|
||||
assert.Equal(t, "req-1", resp.RequestId)
|
||||
listResp := resp.GetListTools()
|
||||
assert.NotNil(t, listResp)
|
||||
assert.Len(t, listResp.Tools, 12) // list_hosts + find_containers + list_running/all + get_stats + fetch_logs + stream_logs + inspect_container + 3 actions + update
|
||||
}
|
||||
|
||||
func TestHandleRequest_ListTools_ActionsDisabled(t *testing.T) {
|
||||
client := &Client{
|
||||
enableActions: false,
|
||||
}
|
||||
|
||||
req := &pb.ToolRequest{
|
||||
RequestId: "req-2",
|
||||
Type: &pb.ToolRequest_ListTools{
|
||||
ListTools: &pb.ListToolsRequest{},
|
||||
},
|
||||
}
|
||||
|
||||
resp := client.handleRequest(context.Background(), req)
|
||||
|
||||
listResp := resp.GetListTools()
|
||||
assert.Len(t, listResp.Tools, 8) // list_hosts + find_containers + list_running/all + get_stats + fetch_logs + stream_logs + inspect_container
|
||||
}
|
||||
|
||||
func TestHandleRequest_CallTool_ListContainers(t *testing.T) {
|
||||
mockHost := &MockHostService{}
|
||||
mockHost.On("ListAllContainers", container.ContainerLabels(nil)).Return([]container.Container{
|
||||
{ID: "abc", Name: "nginx", Image: "nginx:latest", State: "running", Host: "local"},
|
||||
}, nil)
|
||||
mockHost.On("Hosts").Return([]container.Host{{ID: "local", Name: "my-server"}})
|
||||
|
||||
client := &Client{
|
||||
hostService: mockHost,
|
||||
}
|
||||
|
||||
req := &pb.ToolRequest{
|
||||
RequestId: "req-3",
|
||||
Type: &pb.ToolRequest_CallTool{
|
||||
CallTool: &pb.CallToolRequest{
|
||||
Name: "list_running_containers",
|
||||
ArgumentsJson: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp := client.handleRequest(context.Background(), req)
|
||||
|
||||
callResp := resp.GetCallTool()
|
||||
assert.True(t, callResp.Success)
|
||||
result := callResp.GetListContainers()
|
||||
assert.NotNil(t, result)
|
||||
assert.Len(t, result.Containers, 1)
|
||||
assert.Equal(t, "nginx", result.Containers[0].Name)
|
||||
}
|
||||
|
||||
func TestHandleRequest_CallTool_UnknownTool(t *testing.T) {
|
||||
mockHost := &MockHostService{}
|
||||
|
||||
client := &Client{
|
||||
hostService: mockHost,
|
||||
}
|
||||
|
||||
req := &pb.ToolRequest{
|
||||
RequestId: "req-4",
|
||||
Type: &pb.ToolRequest_CallTool{
|
||||
CallTool: &pb.CallToolRequest{
|
||||
Name: "nonexistent",
|
||||
ArgumentsJson: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp := client.handleRequest(context.Background(), req)
|
||||
|
||||
callResp := resp.GetCallTool()
|
||||
assert.False(t, callResp.Success)
|
||||
assert.Contains(t, callResp.Error, "unknown tool")
|
||||
}
|
||||
|
||||
func TestHandleRequest_CallTool_RestartContainer(t *testing.T) {
|
||||
mockClient := &MockClientService{}
|
||||
mockClient.On("ContainerAction", mock.Anything, mock.Anything, container.Restart).Return(nil)
|
||||
|
||||
cs := container_support.NewContainerService(mockClient, container.Container{ID: "abc123"})
|
||||
|
||||
mockHost := &MockHostService{}
|
||||
mockHost.On("FindContainer", "local", "abc123", container.ContainerLabels(nil)).Return(cs, nil)
|
||||
|
||||
client := &Client{
|
||||
hostService: mockHost,
|
||||
enableActions: true,
|
||||
}
|
||||
|
||||
req := &pb.ToolRequest{
|
||||
RequestId: "req-5",
|
||||
Type: &pb.ToolRequest_CallTool{
|
||||
CallTool: &pb.CallToolRequest{
|
||||
Name: "restart_container",
|
||||
ArgumentsJson: `{"container_id": "abc123", "host_id": "local"}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp := client.handleRequest(context.Background(), req)
|
||||
|
||||
callResp := resp.GetCallTool()
|
||||
assert.True(t, callResp.Success)
|
||||
mockClient.AssertCalled(t, "ContainerAction", mock.Anything, mock.Anything, container.Restart)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user