Compare commits

...

67 Commits

Author SHA1 Message Date
Amir Raminfar cc82f9f993 chore: release v10.0.0 2026-02-10 16:14:21 -08:00
renovate[bot] e48aa3139c fix(deps): update all non-major dependencies (#4423)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 23:03:25 +00:00
Amir Raminfar aef834ba06 chore: ignore dozzle 2026-02-09 13:08:31 -08:00
renovate[bot] 3678332ab2 chore(deps): update pnpm to v10.29.2 (#4419)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 02:02:38 +00:00
renovate[bot] 30614269c7 fix(deps): update dependency @codemirror/view to ^6.39.13 (#4418)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-08 18:17:33 +00:00
Amir Raminfar 6b2af1cf3a chore: fixes bugs on destination (#4416) 2026-02-08 16:00:52 +00:00
Amir Raminfar 80220f2388 chore: clean up 2026-02-08 07:33:22 -08:00
renovate[bot] 98ab88aa71 chore(deps): update all non-major dependencies (#4415)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-08 05:31:41 +00:00
Amir Raminfar c5af5a6f5e feat: adds json sytanx to template (#4414) 2026-02-07 21:26:19 -08:00
Jordy b8f224c13d feat: Expand trace level aliases in level_guesser.go (#4413) 2026-02-07 13:40:44 -08:00
Amir Raminfar 40f335265f feat: adds shortcut for creating alerts (#4411) 2026-02-07 08:16:12 -08:00
renovate[bot] 9ac5fcc2c4 chore(deps): update all non-major dependencies (#4410)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-07 03:18:48 +00:00
renovate[bot] 8d38034c86 chore(deps): update dependency jsdom to v28 (#4409)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-06 15:10:28 +00:00
Amir Raminfar 8c231d7ba7 chore: implements circuit break when too many requests recevied (#4406) 2026-02-06 07:05:19 -08:00
renovate[bot] 0a1d854168 fix(deps): update all non-major dependencies (#4408)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-06 15:03:24 +00:00
Amir Raminfar 425d9e4fdd perf: improves performance by removing watcher on containers (#4394) 2026-02-02 08:47:14 -08:00
renovate[bot] 21676d9917 fix(deps): update all non-major dependencies (#4404)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-02 00:39:31 +00:00
Amir Raminfar c923980ae6 chore: adds host name (#4403) 2026-02-01 12:31:59 -08:00
Amir Raminfar 87ea90966e perf: implements a semaphore when too many requests happen in notificaiton manager (#4402) 2026-02-01 08:53:54 -08:00
Amir Raminfar cd15f91447 fix: fixes broken UI on smaller screens (#4400) 2026-01-31 13:03:11 -08:00
Amir Raminfar 8800bc8c37 chore: fixes typecheck (#4401) 2026-01-31 12:53:45 -08:00
Amir Raminfar ecdcd5fb26 fix: fixes alert edit not loading destination correctly (#4399) 2026-01-31 15:18:43 +00:00
Amir Raminfar ff5867eaca fix: fixes scrolling down in historical logs (#4398) 2026-01-31 07:00:35 -08:00
Amir Raminfar e5422513f7 chore: fixes typecheck 2026-01-30 11:36:53 -08:00
Amir Raminfar cd164e62c5 chore: clean up 2026-01-30 11:11:41 -08:00
Amir Raminfar 03d6c6a41a chore: fixes preview 2026-01-29 16:58:20 -08:00
Amir Raminfar 7131a6bdf8 chore: clean up 2026-01-29 09:20:02 -08:00
Amir Raminfar 4c523d5185 chore: improves README. Fixes #4395 2026-01-29 07:15:48 -08:00
Amir Raminfar 69b4d8b651 chore: makes the experience better for cloud linking (#4393) 2026-01-29 07:05:16 -08:00
renovate[bot] dc41e018ca chore(deps): update docker/login-action action to v3.7.0 (#4392)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-28 15:51:45 +00:00
renovate[bot] ca3cd93e9a chore(deps): update all non-major dependencies (#4391)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 21:02:32 +00:00
Amir Raminfar 9224a4d1f9 chore: removes graphql (#4389) 2026-01-26 17:34:06 +00:00
Amir Raminfar c826564e3f Merge remote-tracking branch 'origin/v9.x' 2026-01-26 09:18:29 -08:00
Amir Raminfar 8b4f4d1b00 feat: supports dozzle cloud (#4381) 2026-01-26 07:57:35 -08:00
Amir Raminfar 463fea4634 chore: release v9.0.3 2026-01-26 07:54:41 -08:00
Javier Pérez 4db74360e6 feat: add Coolify labels as fallbacks for container name and group (#4384) 2026-01-26 07:54:14 -08:00
MirzaSamadAhmedBaig 58caca2d68 fix: Avoid fatal on invalid container filters in auth (#4379)
Signed-off-by: Mirza-Samad-Ahmed-Baig <Mirzasamadahmedbaig@gmail.com>
2026-01-26 07:50:37 -08:00
Amir Raminfar 98a4901256 fix: fixes incorrect user scope for agent.find 2026-01-26 07:49:17 -08:00
renovate[bot] 4a6461ce2a fix(deps): update module github.com/puzpuzpuz/xsync/v4 to v4.4.0 (#4388)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-26 00:40:55 +00:00
Amir Raminfar 620e59aa24 fix: fixes incorrect user scope for agent.find 2026-01-25 15:03:38 -08:00
Amir Raminfar c13aca6d2c chore: fixes race issue (#4385) 2026-01-25 07:16:22 -08:00
Robert Breker f3afbf843c fix: use io.Copy for exec TTY output instead of stdcopy (#4382) 2026-01-24 16:56:48 -08:00
Amir Raminfar 10e4dfd17f chore: adds notification to types 2026-01-24 09:17:09 -08:00
MirzaSamadAhmedBaig efcadfb98a fix: Avoid fatal on invalid container filters in auth (#4379)
Signed-off-by: Mirza-Samad-Ahmed-Baig <Mirzasamadahmedbaig@gmail.com>
2026-01-23 11:39:18 -08:00
renovate[bot] f831dc6ecd fix(deps): update all non-major dependencies (#4378)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-23 19:25:58 +00:00
Amir Raminfar 0ff0d486b1 feat: supports notifications for agents (#4372) 2026-01-22 16:54:53 -08:00
Amir Raminfar a7405ac2d6 chore: updates node (#4374) 2026-01-22 20:02:00 +00:00
Amir Raminfar 3f4baea664 chore: uses go tools instead (#4373) 2026-01-22 19:33:57 +00:00
Amir Raminfar 0d71261358 fix: fixes json objects not working for expr (#4371) 2026-01-22 01:16:00 +00:00
Amir Raminfar 1411c70c9b feat: adds test connection for destination (#4369) 2026-01-21 20:39:11 +00:00
Amir Raminfar 6c2c58345c chore: cleans up types for notifications (#4368) 2026-01-21 20:28:30 +00:00
Amir Raminfar af0407157b chore: cleans up types for notificaitons (#4367) 2026-01-21 10:18:48 -08:00
Amir Raminfar 9f6d24523c chore: css clean up 2026-01-20 14:41:06 -08:00
Amir Raminfar abeb86d285 feat: supports go templates for webhooks (#4366) 2026-01-20 14:21:46 -08:00
Amir Raminfar 4954a020f9 chore: updates info to debug 2026-01-19 19:33:20 -08:00
Amir Raminfar 5b96267f1f chore: updates info to debug 2026-01-19 19:30:52 -08:00
renovate[bot] e2109257e4 fix(deps): update all non-major dependencies (#4362)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 03:26:18 +00:00
Amir Raminfar 9ac4762f1d chore: migrates releases to gql (#4361) 2026-01-20 03:18:27 +00:00
Amir Raminfar 321bf43f1c feat: implements web hooks and dispatchers with a complete new notificaiton page (#4349) 2026-01-19 17:30:36 -08:00
Amir Raminfar bca061fa7b chore: release v9.0.2 2026-01-19 10:06:06 -08:00
Amir Raminfar 8599b602dc chore: release v9.0.2 2026-01-19 10:05:41 -08:00
renovate[bot] d60a119f9e fix(deps): update all non-major dependencies to ^1.2.52 (#4359)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-19 00:37:28 +00:00
Amir Raminfar 4b3e44fcdc fix: fixes cpu limit not working correctly (#4356) 2026-01-18 06:12:40 -08:00
Amir Raminfar 51fce48137 feat: shows net stats on mobile (#4357) 2026-01-17 16:17:23 +00:00
Amir Raminfar c1432c33a2 chore: updates deps 2026-01-16 06:53:21 -08:00
renovate[bot] 22d659f509 fix(deps): update dependency unplugin-auto-import to v21 (#4352)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-16 06:52:26 -08:00
renovate[bot] 9c529af633 fix(deps): update all non-major dependencies (#4351)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-16 14:48:07 +00:00
106 changed files with 8590 additions and 2029 deletions
+7 -7
View File
@@ -14,12 +14,12 @@ jobs:
- uses: actions/setup-node@v6
name: Install Node
with:
node-version: 24.12.0
node-version: 24.13.0
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.12.0
node-version: 24.13.0
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
@@ -33,7 +33,7 @@ jobs:
- name: Install Go
uses: actions/setup-go@v6
with:
go-version: 1.25.5
go-version: 1.25.7
check-latest: true
- name: Checkout code
uses: actions/checkout@v6
@@ -56,12 +56,12 @@ jobs:
- uses: actions/setup-node@v6
name: Install Node
with:
node-version: 24.12.0
node-version: 24.13.0
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.12.0
node-version: 24.13.0
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
@@ -96,12 +96,12 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to DockerHub
uses: docker/login-action@v3.6.0
uses: docker/login-action@v3.7.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the Container registry
uses: docker/login-action@v3.6.0
uses: docker/login-action@v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+2 -2
View File
@@ -15,12 +15,12 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to DockerHub
uses: docker/login-action@v3.6.0
uses: docker/login-action@v3.7.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the Container registry
uses: docker/login-action@v3.6.0
uses: docker/login-action@v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24.12.0
node-version: 24.13.0
cache: pnpm # or pnpm / yarn
- name: Setup Pages
uses: actions/configure-pages@v5
+7 -7
View File
@@ -17,12 +17,12 @@ jobs:
- uses: actions/setup-node@v6
name: Install Node
with:
node-version: 24.12.0
node-version: 24.13.0
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.12.0
node-version: 24.13.0
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
@@ -40,7 +40,7 @@ jobs:
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.12.0
node-version: 24.13.0
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
@@ -54,7 +54,7 @@ jobs:
- name: Install Go
uses: actions/setup-go@v6
with:
go-version: "1.25.5"
go-version: "1.25.7"
check-latest: true
- name: Checkout code
uses: actions/checkout@v6
@@ -77,7 +77,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.25.5"
go-version: "1.25.7"
check-latest: true
- name: Generate dependencies
run: make fake_assets shared_key.pem shared_cert.pem
@@ -96,12 +96,12 @@ jobs:
- uses: actions/setup-node@v6
name: Install Node
with:
node-version: 24.12.0
node-version: 24.13.0
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.12.0
node-version: 24.13.0
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
+1
View File
@@ -4,3 +4,4 @@ typed-router.d.ts
docs/.vitepress/cache
docs/.vitepress/dist
dist
assets/types/graphql.ts
+195 -11
View File
@@ -31,9 +31,6 @@ The application supports multiple deployment modes: standalone server, Docker Sw
# Install dependencies
pnpm install
# Install Go tools (protobuf, air hot-reloader)
make tools
# Generate certificates and protobuf files
make generate
```
@@ -130,8 +127,18 @@ The Go backend is organized into these key packages:
- Role-based authorization (`roles.go`)
- **`internal/container/`** - Container domain models and interfaces
- `event_generator.go`: Log parsing and grouping logic (multi-line, JSON detection)
- **`main.go`** - Application entry point with mode switching (server/swarm/k8s)
- **`internal/notification/`** - Alert and notification system
- `manager.go`: Notification rule evaluation and dispatching
- `log_listener.go`: Log pattern matching for alerts
- `dispatcher/`: Notification channel implementations (email, webhook, etc.)
- **`graph/`** - GraphQL API layer
- `schema.graphqls`: GraphQL schema definitions
- `*.resolvers.go`: GraphQL resolver implementations
- **`main.go`** - Application entry point with mode switching (server/swarm/k8s/agent)
### Frontend (Vue 3)
@@ -161,17 +168,23 @@ The frontend uses file-based routing with these conventions:
- `ContainerTable.vue`: Container table with historical stat visualization
- **`assets/stores/`** - Pinia stores (auto-imported)
- `config.ts`: App configuration and feature flags
- `container.ts`: Container state management
- `config.ts`: App configuration and feature flags (injected from backend HTML, frozen immutable)
- `container.ts`: Container state management with EventSource streaming (`/api/events/stream`)
- `hosts.ts`: Multi-host state
- `settings.ts`: User preferences
- `settings.ts`: User preferences (localStorage-backed via profileStorage)
- `pinned.ts`: Pinned container logs for side-by-side viewing
- `swarm.ts`, `k8s.ts`: Deployment mode-specific state
- `announcements.ts`: Feature announcements
- **`assets/composable/`** - Vue composables (auto-imported)
- `eventStreams.ts`: SSE connection management
- `eventStreams.ts`: SSE connection management with buffer-based flushing (250ms debounce)
- `historicalLogs.ts`: Historical log fetching
- `logContext.ts`: Log filtering and search context
- `storage.ts`: LocalStorage abstractions
- `logContext.ts`: Log filtering and search context (provide/inject pattern)
- `scrollContext.ts`: Scroll state management (paused, progress, currentDate)
- `storage.ts`: LocalStorage abstractions with reactivity
- `visible.ts`: Log filtering by visible keys for complex logs
- `containerActions.ts`: Container control operations
- `duckdb.ts`: DuckDB WASM for SQL queries on logs
- **`assets/modules/`** - Vue plugins
- `router.ts`: Vue Router configuration
@@ -185,6 +198,7 @@ The frontend uses file-based routing with these conventions:
3. **Stats**: Real-time CPU/memory stats streamed via SSE alongside events
4. **Actions**: POST to `/api/hosts/{host}/containers/{id}/actions/{action}` (start/stop/restart)
5. **Terminal**: WebSocket connections for container attach/exec at `/api/hosts/{host}/containers/{id}/attach`
6. **GraphQL**: POST to `/api/graphql` for queries and mutations (container metadata, historical logs, notifications)
### Build System
@@ -206,6 +220,8 @@ The frontend uses file-based routing with these conventions:
- `ComplexLogEntry`: Structured JSON logs (`JSONObject`)
- `GroupedLogEntry`: Multi-line grouped logs (`string[]`)
- **Type consistency**: Use `LogMessage` type alias instead of `string | string[] | JSONObject` for log entry messages
- **Log Entry Factory Pattern**: Use `LogEntry.create(logEvent)` to instantiate the correct entry type based on `logEvent.t` field
- **EventSource Buffering**: Log streams use buffer-based flushing (250ms debounce, 1000ms max) to batch UI updates
- **Charts/Visualizations**: Custom lightweight implementations (no D3.js)
- `BarChart.vue`: Self-contained bar chart with responsive downsampling
- Downsampling algorithm: Averages data into buckets based on available screen width
@@ -217,6 +233,13 @@ The frontend uses file-based routing with these conventions:
- Certificate generation is required (`make generate` creates shared_key.pem and shared_cert.pem)
- Protocol buffer generation happens via `go generate` directive in `main.go`
- Docker client uses API version negotiation for compatibility
- **GraphQL API**: Uses gqlgen with schema in `graph/schema.graphqls`, generated code in `graph/generated.go`
- Run `pnpm codegen` to regenerate GraphQL types
- Resolvers follow-schema layout in `graph/*.resolvers.go`
- **Service Layer Architecture**:
- `ClientService` interface abstracts Docker/K8s/Agent backends
- `MultiHostService` orchestrates multi-host operations
- `ClientManager` implementations: `RetriableClientManager` (server mode), `SwarmClientManager` (swarm mode)
### Authentication
@@ -247,7 +270,168 @@ The frontend uses file-based routing with these conventions:
### Deployment Modes
- **Server mode**: Single or multi-host Docker monitoring
- **Server mode** (default): Single or multi-host Docker monitoring
- Uses `RetriableClientManager` with local + remote agent clients
- **Swarm mode**: Automatic discovery of Swarm nodes via Docker API
- Creates gRPC agent server on each node (port 7007)
- Uses `SwarmClientManager` for node discovery
- **K8s mode**: Pod log monitoring in Kubernetes cluster
- Implements `container.Client` interface via Kubernetes API
- **Agent mode**: Lightweight gRPC agent for remote log collection
- Run with `dozzle agent` or `pnpm run agent:dev`
- Listens on port 7007 with TLS certificate authentication
## Key Architectural Patterns
### Backend Abstraction Layers
The backend follows a clean layered architecture:
```
HTTP Handlers (internal/web)
HostService Interface (MultiHostService)
ClientService Interface (per host)
container.Client Interface
Implementation (DockerClient, K8sClient, AgentClient)
```
**When adding new container operations:**
1. Define method in `container.Client` interface (`internal/container/client.go`)
2. Implement in `internal/docker/client.go` (and `internal/k8s/client.go` if applicable)
3. Add wrapper method in `ClientService` interface (`internal/support/container/service.go`)
4. Add HTTP handler in `internal/web/` with appropriate route
### Frontend Data Flow
**Real-time Log Viewing:**
1. User navigates to `/container/{id}` route
2. Page component calls `useContainerStream(container)` composable
3. Composable creates EventSource connection to `/api/hosts/{host}/containers/{id}/logs/stream`
4. Backend streams `LogEvent` objects via SSE
5. Frontend buffers events (250ms debounce, max 1000ms)
6. Batched buffer flushes update reactive `messages` array
7. `LogViewer.vue` renders using appropriate component (`SimpleLogItem`, `ComplexLogItem`, `GroupedLogItem`)
8. When messages exceed `maxLogs` (400), oldest entries replaced or marked as `SkippedLogsEntry`
**Stats Streaming:**
1. `container.ts` store connects to `/api/events/stream` on app init
2. Backend multiplexes container events and stats into single SSE stream
3. `container-stat` events update `Container._stat` and append to `_statsHistory`
4. EMA calculation provides smoothed `movingAverageStat` (alpha=0.2)
5. `ContainerTable.vue` displays mini bar charts using `statsHistory` with downsampling
### Protocol Buffer Flow (Agent Mode)
1. Main server creates `agent.NewClient(endpoint, certs)` for each remote host
2. AgentClient implements `container.Client` interface
3. Method calls translate to gRPC requests defined in `protos/rpc.proto`
4. Remote agent receives gRPC call, delegates to local `DockerClient`
5. Streaming RPCs (logs, stats, events) use bidirectional channels
6. Responses converted back to domain models via `FromProto()` methods
### Log Parsing Pipeline
1. Docker API returns multiplexed stream (8-byte headers + payload)
2. `log_reader.go` parses headers, extracts stdout/stderr type
3. `event_generator.go` receives raw log lines
4. Detection logic identifies:
- JSON structure → `ComplexLogEntry`
- Multi-line patterns (stack traces) → `GroupedLogEntry`
- Single lines → `SimpleLogEntry`
5. Log level extraction via regex patterns
6. `LogEvent` serialized to JSON and sent via SSE
7. Frontend deserializes and renders with appropriate component
## Adding New Features
### Adding a New HTTP Route
1. Define route in `internal/web/routes.go` using chi router:
```go
r.Get("/api/custom-endpoint", h.customHandler)
```
2. Implement handler method in appropriate file (e.g., `actions.go`, `logs.go`)
3. Use `hostService` to find container/host via `FindContainer()` or `FindHost()`
4. Return JSON response or establish SSE/WebSocket stream
### Adding a New Log View Type
1. Create route file in `assets/pages/` (e.g., `custom/[id].vue`)
2. Create composable in `assets/composable/eventStreams.ts` (e.g., `useCustomStream()`)
3. Composable should:
- Build API URL with appropriate filters
- Create EventSource connection
- Handle buffering and message batching
- Return reactive `messages` array and control methods
4. Use `LogViewer.vue` component to render messages
5. Add backend API endpoint if needed (see above)
### Adding a New GraphQL Query/Mutation
1. Define in `graph/schema.graphqls`
2. Run `pnpm codegen` to regenerate types
3. Implement resolver in `graph/schema.resolvers.go`
4. Use `hostService` from resolver context to access backend services
5. Frontend calls via urql client (auto-imported via `@urql/vue`)
### Adding Container Stats/Metrics
1. Add field to `Stat` type in `internal/container/types.go`
2. Update `stats_collector.go` to extract metric from Docker API response
3. Add calculation logic in `docker/calculation.go` if needed
4. Ensure protobuf definition includes field in `protos/rpc.proto`
5. Frontend automatically receives updates via existing SSE stream
6. Update `Container` model in `assets/models/Container.ts` if UI needs access
### Working with Notifications/Alerts
**Backend** (`internal/notification/`):
- `manager.go`: Rule evaluation engine, manages alert state
- `log_listener.go`: Subscribes to container log streams, evaluates rules against incoming logs
- `types.go`: Alert rule definitions (log pattern matching, thresholds)
- `dispatcher/`: Notification channel implementations
**Frontend** (`assets/pages/notifications.vue`, `assets/components/Notification/`):
- `AlertForm.vue`, `DestinationForm.vue`: UI for creating rules
- Rules stored via GraphQL mutations
- Alert state displayed in notification cards
**Adding a new notification channel:**
1. Implement dispatcher interface in `internal/notification/dispatcher/`
2. Register in `manager.go` dispatcher factory
3. Add UI form in `assets/components/Notification/DestinationForm.vue`
4. Add GraphQL schema fields if needed
## Common Development Patterns
### Testing
- Always run Go tests with race detector: `go test -race`
- Frontend tests require `TZ=UTC` for timestamp consistency
- Integration tests use Playwright with `make int` (runs docker-compose setup)
- Use `testify/assert` for Go test assertions
### Hot Reload Development
- `make dev` runs both backend (air) and frontend (vite) with hot reload
- `DEV=true` disables embedded asset serving
- `LIVE_FS=true` serves assets from filesystem instead of embedded
- Backend changes trigger air restart automatically
- Frontend changes trigger vite HMR
### Debugging
- Backend logs: Set `--level debug` flag or `DOZZLE_LEVEL=debug` env var
- Frontend: Vue DevTools browser extension
- GraphQL: Use GraphQL Playground at `/api/graphql` (when enabled)
- SSE streams: Browser DevTools Network tab shows EventSource connections
+5 -5
View File
@@ -1,7 +1,7 @@
# Build assets
FROM --platform=$BUILDPLATFORM node:24.12.0-alpine AS node
FROM --platform=$BUILDPLATFORM node:25.6.0-alpine AS node
RUN corepack enable
RUN npm install -g --force corepack && corepack enable
WORKDIR /build
@@ -25,7 +25,7 @@ RUN pnpm build
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
# install gRPC dependencies
RUN apk add --no-cache ca-certificates protoc protobuf-dev\
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
@@ -50,8 +50,8 @@ COPY --from=node /build/dist ./dist
ARG TAG=dev
ARG TARGETOS TARGETARCH
# Generate protos
RUN go generate
# 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
+16 -14
View File
@@ -1,7 +1,4 @@
PROTO_DIR := protos
GEN_DIR := internal/agent/pb
PROTO_FILES := $(wildcard $(PROTO_DIR)/*.proto)
GEN_FILES := $(patsubst $(PROTO_DIR)/%.proto,$(GEN_DIR)/%.pb.go,$(PROTO_FILES))
.PHONY: clean
clean:
@@ -30,9 +27,11 @@ build: dist generate
.PHONY: docker
docker: shared_key.pem shared_cert.pem
@docker build --build-arg TAG=local -t amir20/dozzle .
@docker build --build-arg TAG=local -t amir20/dozzle:local .
generate: shared_key.pem shared_cert.pem $(GEN_FILES)
.PHONY: generate
generate: shared_key.pem shared_cert.pem
@go generate ./...
.PHONY: dev
dev: generate fake_assets
@@ -50,21 +49,24 @@ shared_cert.pem: shared_key.pem
@openssl x509 -req -in shared_request.csr -signkey shared_key.pem -out shared_cert.pem -days 1825
@rm shared_request.csr
$(GEN_DIR)/%.pb.go: $(PROTO_DIR)/%.proto
@go generate
.PHONY: push
push: docker
@docker tag amir20/dozzle:latest amir20/dozzle:local-test
@docker push amir20/dozzle:local-test
tools:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
go install github.com/air-verse/air@latest
run: docker
docker run -it --rm -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock amir20/dozzle:latest
docker run -it --rm -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock amir20/dozzle:local
preview: build
pnpm preview
.PHONY: agent-reload
agent-reload: docker
@VM_NAME=$${VM_NAME:-dozzle-agent}; \
echo "📦 Loading image into VM $$VM_NAME..."; \
docker save amir20/dozzle:local | orb exec -m $$VM_NAME docker load; \
echo "🔄 Recreating agent..."; \
orb exec -m $$VM_NAME docker stop dozzle-agent || true; \
orb exec -m $$VM_NAME docker rm dozzle-agent || true; \
orb exec -m $$VM_NAME docker run -d --name dozzle-agent -p 7007:7007 -v /var/run/docker.sock:/var/run/docker.sock -v ~/dozzle-certs:/certs -v ~/dozzle-data:/data -e DOZZLE_LEVEL=debug amir20/dozzle:local agent --cert /certs/shared_cert.pem --key /certs/shared_key.pem; \
echo "✅ Agent reloaded"
+39 -38
View File
@@ -4,7 +4,7 @@
# Dozzle - [dozzle.dev](https://dozzle.dev/)
Dozzle is a small lightweight application with a web based interface to monitor Docker logs. It doesnt store any log files. It is for live monitoring of your container logs only.
Dozzle is a lightweight, web-based application for monitoring Docker logs in real time. It doesn't store any log files—it's designed purely for live log viewing.
https://github.com/user-attachments/assets/66a7b4b2-d6c9-4fca-ab04-aef6cd7c0c31
@@ -14,22 +14,22 @@ https://github.com/user-attachments/assets/66a7b4b2-d6c9-4fca-ab04-aef6cd7c0c31
![Test](https://github.com/amir20/dozzle/workflows/Test/badge.svg)
> [!NOTE]
> If you like Dozzle, check out [`dtop`](https://github.com/amir20/dtop) which is a top like application for monitoring Docker containers. It integrates with Dozzle to allow for linking directly to container logs.
> If you like Dozzle, check out [`dtop`](https://github.com/amir20/dtop), a top-like application for monitoring Docker containers. It integrates with Dozzle to link directly to container logs.
## Features
- Intelligent fuzzy search for container names 🤖
- Search logs using regex 🔦
- Search logs using [SQL queries](https://dozzle.dev/guide/sql-engine) 📊
- Small memory footprint 🏎
- Intelligent fuzzy search for container names
- Search logs using regex
- Search logs using [SQL queries](https://dozzle.dev/guide/sql-engine)
- Small memory footprint
- Split screen for viewing multiple logs
- Live stats with memory and CPU usage
- Multi-user [authentication](https://dozzle.dev/guide/authentication) with support for proxy forward authorization 🚨
- [Swarm](https://dozzle.dev/guide/swarm-mode) mode support 🐳
- [Agent](https://dozzle.dev/guide/agent) mode for monitoring multiple Docker hosts 🕵️‍♂️
- Dark mode 🌙
- Multi-user [authentication](https://dozzle.dev/guide/authentication) with support for forward proxy authorization
- [Swarm mode](https://dozzle.dev/guide/swarm-mode) support
- [Agent mode](https://dozzle.dev/guide/agent) for monitoring multiple Docker hosts
- Dark mode
Dozzle has been tested with hundreds of containers. However, it doesn't support offline searching. Products like [Loggly](https://www.loggly.com), [Papertrail](https://papertrailapp.com) or [Kibana](https://www.elastic.co/products/kibana) are more suited for full search capabilities.
Dozzle has been tested with hundreds of containers. However, it doesn't support offline searching. Products like [Loggly](https://www.loggly.com), [Papertrail](https://papertrailapp.com), or [Kibana](https://www.elastic.co/products/kibana) are better suited for full search capabilities.
## Getting Started
@@ -39,13 +39,13 @@ Dozzle is a small container (7 MB compressed). Pull the latest release with:
### Running Dozzle
The simplest way to use dozzle is to run the docker container. Also, mount the Docker Unix socket with `--volume` to `/var/run/docker.sock`:
The simplest way to use Dozzle is to run the Docker container. Mount the Docker Unix socket with `--volume` to `/var/run/docker.sock`:
$ docker run --name dozzle -d --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle:latest
Dozzle will be available at [http://localhost:8080/](http://localhost:8080/).
Here is the Docker Compose file:
Here is a Docker Compose example:
services:
dozzle:
@@ -56,11 +56,11 @@ Here is the Docker Compose file:
ports:
- 8080:8080
For advanced options like [authentication](https://dozzle.dev/guide/authentication), [remote hosts](https://dozzle.dev/guide/remote-hosts) or common [questions](https://dozzle.dev/guide/faq) see documentation at [dozzle.dev](https://dozzle.dev/guide/getting-started).
For advanced options like [authentication](https://dozzle.dev/guide/authentication), [remote hosts](https://dozzle.dev/guide/remote-hosts), or common [questions](https://dozzle.dev/guide/faq), see the documentation at [dozzle.dev](https://dozzle.dev/guide/getting-started).
## Swarm Mode
Dozzle works with Docker Swarm mode. You can run Dozzle as a global service with:
Dozzle works with Docker Swarm. You can run Dozzle as a global service:
$ docker service create --name dozzle --env DOZZLE_MODE=swarm --mode global --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock -p 8080:8080 amir20/dozzle:latest
@@ -68,7 +68,7 @@ See the [Swarm Mode](https://dozzle.dev/guide/swarm-mode) documentation for more
## Agent Mode
Dozzle can be used to monitor multiple Docker hosts. You can run Dozzle in agent mode with:
Dozzle can monitor multiple Docker hosts. Run Dozzle in agent mode with:
$ docker run -v /var/run/docker.sock:/var/run/docker.sock -p 7007:7007 amir20/dozzle:latest agent
@@ -76,19 +76,19 @@ See the [Agent Mode](https://dozzle.dev/guide/agent) documentation for more deta
## Technical Details
Dozzle users automatic API negotiation which works with most Docker configurations. Dozzle also works with [Colima](https://github.com/abiosoft/colima) and [Podman](https://podman.io/).
Dozzle uses automatic API negotiation, which works with most Docker configurations. Dozzle also works with [Colima](https://github.com/abiosoft/colima) and [Podman](https://podman.io/).
### Installation on podman
### Installation on Podman
By default Podman doesn't have a background process but you can enable this for Dozzle to work.
By default, Podman doesn't have a background process, but you can enable the remote socket for Dozzle to work.
Verify first if your podman installation has enabled remote socket:
First, verify if your Podman installation has the remote socket enabled:
```
podman info
```
When you get under the key remote socket output like this, its already enabled:
If you see output like this under the remote socket key, it's already enabled:
```
remoteSocket:
@@ -96,40 +96,40 @@ When you get under the key remote socket output like this, its already enabled:
path: /run/user/1000/podman/podman.sock
```
If it's not enabled please follow [this tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/socket_activation.md) to enable it.
If it's not enabled, follow [this tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/socket_activation.md) to enable it.
Once you have the podman remote socket you can run Dozzle on podman.
Once the Podman remote socket is enabled, you can run Dozzle:
```
podman run --volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock -d -p 8080:8080 docker.io/amir20/dozzle:latest
```
Additionally you have to create a fake engine-id to prevent `host not found` errors. Podman doesn't generate an engine-id like Docker by itself due to its daemonless architecture.
Additionally, you need to create a fake engine-id to prevent `host not found` errors. Podman doesn't generate an engine-id like Docker does, due to its daemonless architecture.
Under `/var/lib/docker` create a file named `engine-id`. On a system with Podman you will have to create the folder path as well. Inside the file place the UUID, for instance using `uuidgen > engine-id`. After that the file should have an identifier that looks like this: `b9f1d7fc-b459-4b6e-9f7a-e3d1cd2e14a9`.
Create a file named `engine-id` under `/var/lib/docker`. On a system with Podman, you'll need to create the folder path as well. Place a UUID inside the file, for example using `uuidgen > engine-id`. The file should contain an identifier like: `b9f1d7fc-b459-4b6e-9f7a-e3d1cd2e14a9`.
For more details check [Podman Infos](docs/guide/podman.md) or the [FAQ](docs/guide/faq.md#i-am-seeing-host-not-found-error-in-the-logs-how-do-i-fix-it)
For more details, see [Podman Info](docs/guide/podman.md) or the [FAQ](docs/guide/faq.md#i-am-seeing-host-not-found-error-in-the-logs-how-do-i-fix-it).
## Security
Dozzle supports file based authentication and forward proxy like [Authelia](https://www.authelia.com/). These are documented at https://dozzle.dev/guide/authentication.
Dozzle supports file-based authentication and forward proxy authentication with tools like [Authelia](https://www.authelia.com/). See the documentation at https://dozzle.dev/guide/authentication.
## Analytics collected
## Analytics
Dozzle collects anonymous user configurations using Google Analytics. Why? Dozzle is an open source project with no funding. As a result, there is no time to do user studies of Dozzle. Analytics is collected to prioritize features and fixes based on how people use Dozzle. This data is completely public and can be viewed live using [ Data Studio dashboard](https://datastudio.google.com/s/naeIu0MiWsY).
Dozzle collects anonymous user configurations using Google Analytics. Why? Dozzle is an open source project with no funding, so there's no time for formal user studies. Analytics help prioritize features and fixes based on how people use Dozzle. This data is completely public and can be viewed live on the [Data Studio dashboard](https://datastudio.google.com/s/naeIu0MiWsY).
If you do not want to be tracked at all, see the `--no-analytics` flag below.
To disable analytics, use the `--no-analytics` flag.
## Environment variables and configuration
## Environment Variables and Configuration
Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can use the CLI flags or environment variables. See documentation at [https://dozzle.dev/guide/supported-env-vars](https://dozzle.dev/guide/supported-env-vars) for more details.
Dozzle follows the [12-factor](https://12factor.net/) model. Configuration can be done via CLI flags or environment variables. See the documentation at [dozzle.dev/guide/supported-env-vars](https://dozzle.dev/guide/supported-env-vars) for more details.
## Support
There are many ways you can support Dozzle:
There are many ways to support Dozzle:
- Use it! Write about it! Star it! If you love Dozzle, drop me a line and tell me what you love.
- Blog about Dozzle to spread the word. If you are good at writing send PRs to improve the documentation at [dozzle.dev](https://dozzle.dev/)
- Blog about Dozzle to spread the word. If you're good at writing, send PRs to improve the documentation at [dozzle.dev](https://dozzle.dev/).
- Sponsor my work at https://www.buymeacoffee.com/amirraminfar
<a href="https://www.buymeacoffee.com/amirraminfar" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
@@ -142,8 +142,9 @@ There are many ways you can support Dozzle:
To build and test locally:
1. Install [NodeJs](https://nodejs.org/en/download/) and [pnpm](https://pnpm.io/installation).
1. Install [Node.js](https://nodejs.org/en/download/) and [pnpm](https://pnpm.io/installation).
2. Install [Go](https://go.dev/doc/install).
3. Install tools with `make tools`.
4. Install node modules `pnpm install`.
5. Run `make dev` to start a development server with hot reload.
3. Install [protoc](https://grpc.io/docs/protoc-installation/).
4. Install Go tools with `go install tool`.
5. Install Node modules with `pnpm install`.
6. Run `make dev` to start a development server with hot reload.
+22 -6
View File
@@ -27,15 +27,19 @@ declare global {
const controlledComputed: typeof import('@vueuse/core').controlledComputed
const controlledRef: typeof import('@vueuse/core').controlledRef
const createApp: typeof import('vue').createApp
const createContainerHints: typeof import('./composable/exprEditor').createContainerHints
const createDrawer: typeof import('./composable/drawer').createDrawer
const 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 createPinia: typeof import('pinia').createPinia
const createReactiveFn: typeof import('@vueuse/core').createReactiveFn
const createRef: typeof import('@vueuse/core').createRef
const createReusableTemplate: typeof import('@vueuse/core').createReusableTemplate
const createSharedComposable: typeof import('@vueuse/core').createSharedComposable
const createTemplateEditor: typeof import('./composable/templateEditor').createTemplateEditor
const createTemplatePromise: typeof import('@vueuse/core').createTemplatePromise
const createUnrefFn: typeof import('@vueuse/core').createUnrefFn
const customRef: typeof import('vue').customRef
@@ -78,7 +82,6 @@ declare global {
const locale: typeof import('./stores/settings').locale
const loggingContextKey: typeof import('./composable/logContext').loggingContextKey
const makeDestructurable: typeof import('@vueuse/core').makeDestructurable
const manualResetRef: typeof import('@vueuse/core')['manualResetRef']
const mapActions: typeof import('pinia').mapActions
const mapGetters: typeof import('pinia').mapGetters
const mapState: typeof import('pinia').mapState
@@ -131,7 +134,6 @@ declare global {
const refWithControl: typeof import('@vueuse/core').refWithControl
const resolveComponent: typeof import('vue').resolveComponent
const resolveRef: typeof import('@vueuse/core').resolveRef
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const scrollContextKey: typeof import('./composable/scrollContext').scrollContextKey
const search: typeof import('./stores/settings').search
const sessionHost: typeof import('./composable/storage').sessionHost
@@ -206,6 +208,7 @@ declare global {
const useCountdown: typeof import('@vueuse/core').useCountdown
const useCounter: typeof import('@vueuse/core').useCounter
const useCssModule: typeof import('vue').useCssModule
const useCssSupports: typeof import('@vueuse/core').useCssSupports
const useCssVar: typeof import('@vueuse/core').useCssVar
const useCssVars: typeof import('vue').useCssVars
const useCurrentElement: typeof import('@vueuse/core').useCurrentElement
@@ -262,6 +265,7 @@ declare global {
const useK8sStore: typeof import('./stores/k8s').useK8sStore
const useKeyModifier: typeof import('@vueuse/core').useKeyModifier
const useLastChanged: typeof import('@vueuse/core').useLastChanged
const useLink: typeof import('vue-router/auto').useLink
const useLocalStorage: typeof import('@vueuse/core').useLocalStorage
const useLoggingContext: typeof import('./composable/logContext').useLoggingContext
const useMagicKeys: typeof import('@vueuse/core').useMagicKeys
@@ -305,8 +309,8 @@ declare global {
const useRafFn: typeof import('@vueuse/core').useRafFn
const useRefHistory: typeof import('@vueuse/core').useRefHistory
const useResizeObserver: typeof import('@vueuse/core').useResizeObserver
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useRoute: typeof import('vue-router/auto').useRoute
const useRouter: typeof import('vue-router/auto').useRouter
const useSSRWidth: typeof import('@vueuse/core').useSSRWidth
const useScreenOrientation: typeof import('@vueuse/core').useScreenOrientation
const useScreenSafeArea: typeof import('@vueuse/core').useScreenSafeArea
@@ -397,6 +401,12 @@ declare global {
export type { LogStreamSource } from './composable/eventStreams'
import('./composable/eventStreams')
// @ts-ignore
export type { ExprEditorOptions } from './composable/exprEditor'
import('./composable/exprEditor')
// @ts-ignore
export type { TemplateEditorOptions } from './composable/templateEditor'
import('./composable/templateEditor')
// @ts-ignore
export type { Config, Profile } from './stores/config'
import('./stores/config')
// @ts-ignore
@@ -436,15 +446,19 @@ declare module 'vue' {
readonly controlledComputed: UnwrapRef<typeof import('@vueuse/core')['controlledComputed']>
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly createContainerHints: UnwrapRef<typeof import('./composable/exprEditor')['createContainerHints']>
readonly createDrawer: UnwrapRef<typeof import('./composable/drawer')['createDrawer']>
readonly 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 createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
readonly createRef: UnwrapRef<typeof import('@vueuse/core')['createRef']>
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
readonly createTemplateEditor: UnwrapRef<typeof import('./composable/templateEditor')['createTemplateEditor']>
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
@@ -613,6 +627,7 @@ declare module 'vue' {
readonly useCountdown: UnwrapRef<typeof import('@vueuse/core')['useCountdown']>
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssSupports: UnwrapRef<typeof import('@vueuse/core')['useCssSupports']>
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useCurrentElement: UnwrapRef<typeof import('@vueuse/core')['useCurrentElement']>
@@ -669,6 +684,7 @@ declare module 'vue' {
readonly useK8sStore: UnwrapRef<typeof import('./stores/k8s')['useK8sStore']>
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
readonly useLink: UnwrapRef<typeof import('vue-router/auto')['useLink']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useLoggingContext: UnwrapRef<typeof import('./composable/logContext')['useLoggingContext']>
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
@@ -712,8 +728,8 @@ declare module 'vue' {
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useRoute: UnwrapRef<typeof import('vue-router/auto')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router/auto')['useRouter']>
readonly useSSRWidth: UnwrapRef<typeof import('@vueuse/core')['useSSRWidth']>
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
+15
View File
@@ -11,6 +11,8 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AlertCard: typeof import('./components/Notification/AlertCard.vue')['default']
AlertForm: typeof import('./components/Notification/AlertForm.vue')['default']
Announcements: typeof import('./components/Announcements.vue')['default']
BarChart: typeof import('./components/BarChart.vue')['default']
'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default']
@@ -41,6 +43,8 @@ declare module 'vue' {
ContainerTable: typeof import('./components/ContainerTable.vue')['default']
ContainerTitle: typeof import('./components/ContainerViewer/ContainerTitle.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']
EventSource: typeof import('./components/LogViewer/EventSource.vue')['default']
@@ -80,15 +84,21 @@ declare module 'vue' {
'MaterialSymbols:terminal': typeof import('~icons/material-symbols/terminal')['default']
'MaterialSymbolsLight:collapseAll': typeof import('~icons/material-symbols-light/collapse-all')['default']
'Mdi:account': typeof import('~icons/mdi/account')['default']
'Mdi:alert': typeof import('~icons/mdi/alert')['default']
'Mdi: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:check': typeof import('~icons/mdi/check')['default']
'Mdi:checkCircle': typeof import('~icons/mdi/check-circle')['default']
'Mdi:chevronDoubleDown': typeof import('~icons/mdi/chevron-double-down')['default']
'Mdi:chevronDown': typeof import('~icons/mdi/chevron-down')['default']
'Mdi:chevronLeft': typeof import('~icons/mdi/chevron-left')['default']
'Mdi:chevronRight': typeof import('~icons/mdi/chevron-right')['default']
'Mdi:close': typeof import('~icons/mdi/close')['default']
'Mdi:cloud': typeof import('~icons/mdi/cloud')['default']
'Mdi:cloudOutline': typeof import('~icons/mdi/cloud-outline')['default']
'Mdi:cog': typeof import('~icons/mdi/cog')['default']
'Mdi:docker': typeof import('~icons/mdi/docker')['default']
'Mdi:gauge': typeof import('~icons/mdi/gauge')['default']
@@ -98,8 +108,13 @@ declare module 'vue' {
'Mdi:key': typeof import('~icons/mdi/key')['default']
'Mdi:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default']
'Mdi:lightningBolt': typeof import('~icons/mdi/lightning-bolt')['default']
'Mdi:linkVariant': typeof import('~icons/mdi/link-variant')['default']
'Mdi:magnify': typeof import('~icons/mdi/magnify')['default']
'Mdi:pencilOutline': typeof import('~icons/mdi/pencil-outline')['default']
'Mdi:plus': typeof import('~icons/mdi/plus')['default']
'Mdi:satelliteVariant': typeof import('~icons/mdi/satellite-variant')['default']
'Mdi:trashCanOutline': typeof import('~icons/mdi/trash-can-outline')['default']
'Mdi:webhook': typeof import('~icons/mdi/webhook')['default']
MetricCard: typeof import('./components/MetricCard.vue')['default']
MobileMenu: typeof import('./components/common/MobileMenu.vue')['default']
MultiContainerActionToolbar: typeof import('./components/LogViewer/MultiContainerActionToolbar.vue')['default']
@@ -1,12 +1,12 @@
<template>
<div class="@container flex flex-1 items-center gap-1.5 md:gap-2">
<div class="@container flex min-w-0 flex-1 items-center gap-1.5 md:gap-2">
<label class="swap swap-rotate size-4">
<input type="checkbox" v-model="pinned" />
<carbon:star-filled class="swap-on text-secondary" />
<carbon:star class="swap-off" />
</label>
<div class="inline-flex items-center text-sm">
<div class="breadcrumbs overflow-x-visible p-0 font-mono">
<div class="inline-flex min-w-0 items-center text-sm">
<div class="breadcrumbs min-w-0 overflow-x-visible p-0 font-mono">
<ul>
<li v-if="config.hosts.length > 1" class="font-thin max-md:hidden">
{{ container.hostLabel }}
+9
View File
@@ -3,6 +3,15 @@
<slot name="more-items"></slot>
<Announcements />
<router-link
:to="{ name: '/notifications' }"
:aria-label="$t('title.notifications')"
data-testid="notifications"
class="btn btn-circle btn-sm"
>
<mdi:bell class="size-6" />
</router-link>
<router-link
:to="{ name: '/settings' }"
:aria-label="$t('title.settings')"
@@ -73,6 +73,12 @@
{{ $t("action.show-details") }}
</a>
</li>
<li>
<a @click="createAlert()">
<mdi:bell />
{{ $t("action.create-alert") }}
</a>
</li>
</ul>
</div>
</template>
@@ -82,6 +88,7 @@ import stripAnsi from "strip-ansi";
import { Container } from "@/models/Container";
import { LogEntry, SimpleLogEntry, ComplexLogEntry, GroupedLogEntry, JSONObject } from "@/models/LogEntry";
import LogDetails from "./LogDetails.vue";
import AlertForm from "@/components/Notification/AlertForm.vue";
const { logEntry, container } = defineProps<{
logEntry: LogEntry<string | JSONObject>;
@@ -147,6 +154,22 @@ async function copyPermalink() {
}
}
function createAlert() {
const containerExpr = `name contains "${container.name}"`;
let logExpr = "";
if (logEntry.level && logEntry.level !== "unknown") {
logExpr = `level == "${logEntry.level}"`;
}
const nameParts = [container.name];
if (logEntry.level && logEntry.level !== "unknown") {
nameParts.push(logEntry.level);
}
const name = nameParts.join(" ");
showDrawer(AlertForm, { prefill: { name, containerExpression: containerExpr, logExpression: logExpr } }, "lg");
}
function hideMenu(e: MouseEvent) {
if (e.target instanceof HTMLAnchorElement) {
setTimeout(() => {
+2 -2
View File
@@ -14,12 +14,12 @@
</template>
<script lang="ts" setup>
import { type JSONObject, LogEntry } from "@/models/LogEntry";
import type { LogEntry, LogMessage } from "@/models/LogEntry";
const { progress, currentDate } = useScrollContext();
const { messages } = defineProps<{
messages: LogEntry<string | string[] | JSONObject>[];
messages: LogEntry<LogMessage>[];
}>();
const { containers } = useLoggingContext();
@@ -1,6 +1,8 @@
<template>
<div class="flex gap-1 md:gap-4">
<div class="grid min-w-15 grid-cols-[auto_1fr] items-center gap-0.5 text-xs leading-none max-md:hidden">
<div
class="grid hidden min-w-15 grid-cols-[auto_1fr_auto_1fr] items-center gap-0.5 text-xs leading-none sm:grid md:grid-cols-[auto_1fr]"
>
<PhArrowUp class="text-primary" />
<span class="tabular-nums">{{ formatBytes(networkRate.tx, { short: true, decimals: 1 }) }}/s</span>
<PhArrowDown class="text-secondary" />
@@ -115,7 +117,7 @@ const limits = computed(() => {
totalCpu += hostTotalCpu;
} else {
// All containers have limits, sum them up (capped at host total)
const sumCpu = hostContainers.reduce((sum, c) => sum + 1, 0);
const sumCpu = hostContainers.reduce((sum, c) => sum + (c.cpuLimit || 0), 0);
totalCpu += Math.min(sumCpu, hostTotalCpu);
}
@@ -21,6 +21,9 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
</svg> action.copy-link</a></li>
<!--v-if-->
<li><a><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<path fill="currentColor" d="M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"></path>
</svg> action.create-alert</a></li>
</ul>
</div>
<!--v-if-->
@@ -59,6 +62,9 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
</svg> action.copy-link</a></li>
<!--v-if-->
<li><a><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<path fill="currentColor" d="M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"></path>
</svg> action.create-alert</a></li>
</ul>
</div>
<!--v-if-->
@@ -97,6 +103,9 @@ exports[`<ContainerEventSource /> > render html correctly > should render messag
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
</svg> action.copy-link</a></li>
<!--v-if-->
<li><a><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<path fill="currentColor" d="M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"></path>
</svg> action.create-alert</a></li>
</ul>
</div>
<!--v-if-->
@@ -0,0 +1,101 @@
<template>
<div class="card bg-base-100 shadow-sm" :class="{ 'opacity-60': !alert.enabled }">
<div class="card-body gap-4 p-5">
<!-- Header -->
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<h4 class="flex items-center gap-2 text-lg font-semibold">
<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>
</h4>
<span v-if="!alert.enabled" class="badge badge-warning badge-sm">{{ $t("notifications.alert.paused") }}</span>
</div>
<input type="checkbox" class="toggle toggle-primary" :checked="alert.enabled" @change="toggleEnabled" />
</div>
<!-- Expressions -->
<div class="text-base-content/80 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
<span>{{ $t("notifications.alert.containers") }}</span>
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.containerExpression }}</code>
<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>
</div>
<!-- Footer -->
<div class="border-base-content/10 text-base-content/80 flex items-center justify-between border-t pt-3 text-xs">
<div class="flex items-center gap-4">
<span>
{{ $t("notifications.alert.containers-count", { count: alert.triggeredContainers }) }}
</span>
<span>
{{ $t("notifications.alert.triggered-count", { count: alert.triggerCount }) }}
</span>
<span v-if="alert.lastTriggeredAt">
{{ $t("notifications.alert.last-triggered", { time: formatTimeAgo(alert.lastTriggeredAt) }) }}
</span>
</div>
<div class="flex items-center gap-1">
<button class="btn btn-ghost btn-square" @click="editAlert">
<mdi:pencil-outline />
</button>
<button class="btn btn-ghost btn-square" @click="deleteAlert" :disabled="isDeleting">
<span v-if="isDeleting" class="loading loading-spinner loading-xs"></span>
<mdi:trash-can-outline v-else />
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { NotificationRule } from "@/types/notifications";
import AlertForm from "./AlertForm.vue";
const { alert, onUpdated } = defineProps<{
alert: NotificationRule;
onUpdated?: () => void;
}>();
const showDrawer = useDrawer();
const isDeleting = ref(false);
function formatTimeAgo(dateStr: string): string {
const date = new Date(dateStr);
if (date.getFullYear() === 0) return "-";
return toRelativeTime(date, undefined);
}
async function toggleEnabled() {
await fetch(withBase(`/api/notifications/rules/${alert.id}`), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !alert.enabled }),
});
onUpdated?.();
}
function editAlert() {
showDrawer(AlertForm, { alert, onCreated: onUpdated }, "lg");
}
async function deleteAlert() {
isDeleting.value = true;
try {
await fetch(withBase(`/api/notifications/rules/${alert.id}`), { method: "DELETE" });
onUpdated?.();
} finally {
isDeleting.value = false;
}
}
</script>
@@ -0,0 +1,343 @@
<template>
<div class="space-y-4 p-4">
<div class="mb-6">
<h2 class="text-2xl font-bold">
{{ isEditing ? $t("notifications.alert-form.edit-title") : $t("notifications.alert-form.create-title") }}
</h2>
<p class="text-base-content/60">{{ $t("notifications.alert-form.description") }}</p>
</div>
<!-- Alert Name -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.alert-name") }}</legend>
<input
ref="alertNameInput"
v-model="alertName"
type="text"
class="input focus:input-primary w-full text-base"
:class="alertName.trim() ? 'input-primary' : ''"
required
:placeholder="$t('notifications.alert-form.alert-name-placeholder')"
/>
</fieldset>
<!-- 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="
containerExpression.trim() && !containerResult?.error
? 'input-primary'
: { 'input-error!': containerResult?.error }
"
>
<div ref="containerEditorRef" class="w-full"></div>
</div>
<div v-if="containerResult" class="fieldset-label">
<span v-if="containerResult.error" class="text-error">{{ containerResult.error }}</span>
<span v-else-if="containerResult.containers?.length" class="text-success">
<mdi:check class="inline" />
{{
$t("notifications.alert-form.containers-match", {
count: containerResult.containers.length,
names: containerResult.containers.map((c) => c.name).join(", "),
})
}}
</span>
<span v-else class="text-warning">
<mdi:alert class="inline" />
{{ $t("notifications.alert-form.no-containers-match") }}
</span>
</div>
</fieldset>
<!-- 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>
<!-- Destination -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.destination") }}</legend>
<details class="dropdown w-full" ref="destinationDropdown">
<summary class="btn btn-outline w-full justify-between" :class="{ 'btn-primary': selectedDestination }">
<span class="flex items-center gap-2">
<template v-if="selectedDestination">
<mdi:webhook v-if="selectedDestination.type === 'webhook'" />
<mdi:cloud v-else />
{{ selectedDestination.name }}
</template>
<span v-else class="text-base-content/60">{{ $t("notifications.alert-form.select-destination") }}</span>
</span>
<carbon:caret-down />
</summary>
<ul class="dropdown-content menu bg-base-200 rounded-box z-50 mt-1 w-full border p-2 shadow-sm">
<li v-for="dest in destinations" :key="dest.id">
<a
@click="
dispatcherId = dest.id;
destinationDropdown?.removeAttribute('open');
"
:class="{ active: dispatcherId === dest.id }"
>
<mdi:webhook v-if="dest.type === 'webhook'" />
<mdi:cloud v-else />
{{ dest.name }}
</a>
</li>
</ul>
</details>
<div v-if="!destinations.length" class="fieldset-label">
<span class="text-warning">
<mdi:alert class="inline" />
{{ $t("notifications.alert-form.no-destinations") }}
</span>
</div>
</fieldset>
<!-- 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>
</div>
<!-- Actions -->
<div class="flex justify-end gap-2 pt-4">
<button class="btn" @click="close?.()">{{ $t("notifications.alert-form.cancel") }}</button>
<button class="btn btn-primary" :disabled="!canSave" @click="saveAlert">
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
{{ isEditing ? $t("notifications.alert-form.save") : $t("notifications.alert-form.create") }}
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { 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 type { Dispatcher, NotificationRule, PreviewResult } from "@/types/notifications";
const { close, onCreated, alert, prefill } = defineProps<{
close?: () => void;
onCreated?: () => void;
alert?: NotificationRule;
prefill?: { name?: string; containerExpression?: string; logExpression?: string };
}>();
// Fetch dispatchers
const destinations = ref<Dispatcher[]>([]);
onMounted(async () => {
const res = await fetch(withBase("/api/notifications/dispatchers"));
destinations.value = await res.json();
});
// Container store for autocomplete hints
const containerStore = useContainerStore();
const { containers } = storeToRefs(containerStore);
const containerNames = computed(() => [
...new Set(containers.value.filter((c) => c.state === "running").map((c) => c.name)),
]);
const imageNames = computed(() => [...new Set(containers.value.map((c) => c.image))]);
const hostNames = computed(() => [...new Set(containers.value.map((c) => c.host))]);
// 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));
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,
);
async function saveAlert() {
if (!canSave.value) return;
isSaving.value = true;
saveError.value = null;
try {
const input = {
name: alertName.value.trim(),
containerExpression: containerExpression.value,
logExpression: logExpression.value,
dispatcherId: dispatcherId.value!,
enabled: true,
};
const url = isEditing.value
? withBase(`/api/notifications/rules/${alert!.id}`)
: withBase("/api/notifications/rules");
const res = await fetch(url, {
method: isEditing.value ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to save alert");
}
onCreated?.();
close?.();
} catch (e) {
saveError.value = e instanceof Error ? e.message : "Failed to save alert";
} finally {
isSaving.value = false;
}
}
async function 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();
});
</script>
@@ -0,0 +1,68 @@
<template>
<div class="card bg-base-100 hover:border-primary cursor-pointer border border-transparent" @click="editDestination">
<div class="card-body gap-2 p-4">
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg">
<mdi:webhook v-if="destination.type === 'webhook'" class="text-lg" />
<mdi:cloud v-else class="text-primary-content text-lg" />
</div>
<div class="flex-1">
<h4 class="font-semibold">{{ destination.name }}</h4>
<p class="text-base-content/60 text-sm">
{{
destination.type === "webhook"
? $t("notifications.destination.http-webhook")
: $t("notifications.destination.dozzle-cloud")
}}
</p>
</div>
<div class="dropdown dropdown-end" @click.stop>
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
<ion:ellipsis-vertical />
</label>
<ul
tabindex="0"
class="menu dropdown-content rounded-box bg-base-100 border-base-content/20 z-50 w-40 border p-1 shadow-sm"
>
<li>
<a @click="editDestination">{{ $t("notifications.destination.edit") }}</a>
</li>
<li>
<a class="text-error" @click="deleteDestination">{{ $t("notifications.destination.delete") }}</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Dispatcher } from "@/types/notifications";
import DestinationForm from "./DestinationForm.vue";
const { destination, onUpdated, existingDispatchers } = defineProps<{
destination: Dispatcher;
onUpdated?: () => void;
existingDispatchers: Dispatcher[];
}>();
const showDrawer = useDrawer();
function editDestination() {
showDrawer(
DestinationForm,
{
destination,
onCreated: onUpdated,
existingDispatchers,
},
"md",
);
}
async function deleteDestination() {
await fetch(withBase(`/api/notifications/dispatchers/${destination.id}`), { method: "DELETE" });
onUpdated?.();
}
</script>
@@ -0,0 +1,364 @@
<template>
<div class="space-y-4 p-4">
<div class="mb-6">
<h2 class="text-2xl font-bold">
{{
isEditing
? $t("notifications.destination-form.edit-title")
: $t("notifications.destination-form.create-title")
}}
</h2>
<p class="text-base-content/60">{{ $t("notifications.destination-form.description") }}</p>
</div>
<!-- Link Success Alert -->
<div v-if="showLinkSuccess" class="alert alert-success">
<mdi:check-circle class="text-lg" />
<div>
<div class="font-semibold">{{ $t("notifications.cloud-link-success.title") }}</div>
<div class="text-sm">{{ $t("notifications.cloud-link-success.message") }}</div>
</div>
</div>
<!-- Type Selection -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.type") }}</legend>
<div class="space-y-3">
<label
class="card card-border 20 cursor-pointer transition-colors"
:class="type === 'webhook' ? 'border-primary bg-primary/10' : ''"
>
<div class="card-body flex-row items-center gap-3 p-4">
<input type="radio" v-model="type" value="webhook" class="radio radio-primary" />
<div>
<div class="font-semibold">{{ $t("notifications.destination-form.webhook-title") }}</div>
<div class="text-base-content/60 text-sm">
{{ $t("notifications.destination-form.webhook-description") }}
</div>
</div>
</div>
</label>
<label
class="card card-border border-base-content/20 transition-colors"
:class="[
type === 'cloud' ? 'border-primary bg-primary/10' : '',
hasExistingCloudDestination && type !== 'cloud' ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
]"
>
<div class="card-body flex-row items-center gap-3 p-4">
<input
type="radio"
v-model="type"
value="cloud"
class="radio radio-primary"
:disabled="hasExistingCloudDestination && type !== 'cloud'"
/>
<div>
<div class="font-semibold">{{ $t("notifications.destination-form.cloud-title") }}</div>
<div class="text-base-content/60 text-sm">
{{ $t("notifications.destination-form.cloud-description") }}
</div>
<div v-if="hasExistingCloudDestination && type !== 'cloud'" class="text-warning mt-1 text-xs">
{{ $t("notifications.destination-form.cloud-exists") }}
</div>
</div>
</div>
</label>
</div>
</fieldset>
<!-- 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>
</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,
existingDispatchers = [],
showLinkSuccess = false,
} = 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`;
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;
}
}
</script>
@@ -0,0 +1,64 @@
export type PayloadFormat = "slack" | "discord" | "ntfy" | "custom";
export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
slack: JSON.stringify(
{
text: "{{ .Container.Name }}",
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: "*{{ .Container.Name }}*\n{{ .Log.Message }}",
},
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: "Host: {{ .Container.HostName }} | Image: {{ .Container.Image }}",
},
],
},
],
},
null,
2,
),
discord: JSON.stringify(
{
content: "{{ .Container.Name }}",
embeds: [
{
title: "{{ .Container.Name }}",
description: "{{ .Log.Message }}",
fields: [
{ name: "Host", value: "{{ .Container.HostName }}", inline: true },
{ name: "Image", value: "{{ .Container.Image }}", inline: true },
],
},
],
},
null,
2,
),
ntfy: JSON.stringify(
{
topic: "dozzle-{{ .Container.HostName }}",
title: "{{ .Container.Name }}",
message: "{{ .Log.Message }}",
},
null,
2,
),
custom: JSON.stringify(
{
container: "{{ .Container.Name }}",
level: "{{ .Log.Level }}",
message: "{{ .Log.Message }}",
},
null,
2,
),
};
+6 -1
View File
@@ -11,7 +11,7 @@
<mdi:close class="swap-on" />
</button>
</form>
<slot v-if="open"></slot>
<slot v-if="open" :close="close"></slot>
</div>
</div>
<form method="dialog" class="modal-backdrop">
@@ -28,11 +28,16 @@ const { width } = defineProps<{
width: DrawerWidth;
}>();
function close() {
panel.value?.close();
}
defineExpose({
open: () => {
open.value = true;
panel.value?.showModal();
},
close,
});
useEventListener(panel, "close", () => (open.value = false));
+9 -1
View File
@@ -260,7 +260,12 @@ export async function loadBetween(
params: Ref<URLSearchParams>,
from: Date,
to: Date,
{ lastSeenId, min, maxStart }: { lastSeenId?: number; min?: number; maxStart?: number } = {},
{
lastSeenId,
startId,
min,
maxStart,
}: { lastSeenId?: number; startId?: number; min?: number; maxStart?: number } = {},
) {
const url = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs`);
const abortController = new AbortController();
@@ -279,6 +284,9 @@ export async function loadBetween(
if (lastSeenId) {
loadMoreParams.append("lastSeenId", String(lastSeenId));
}
if (startId) {
loadMoreParams.append("startId", String(startId));
}
return withBase(`${url.value}?${loadMoreParams.toString()}`);
});
const stopWatcher = watchOnce(urlWithMoreParams, () => abortController.abort("stream changed"));
+166
View File
@@ -0,0 +1,166 @@
import type { Completion } from "@codemirror/autocomplete";
export interface ExprEditorOptions {
parent: HTMLElement;
placeholder: string;
initialValue: string;
getHints: () => Completion[];
onChange?: (value: string) => void;
}
// Common operators for expr language
const exprOperators: Completion[] = [
{ label: "==", detail: "equals", type: "operator" },
{ label: "!=", detail: "not equals", type: "operator" },
{ label: "contains", detail: "string contains", type: "keyword" },
{ label: "startsWith", detail: "string starts with", type: "keyword" },
{ label: "endsWith", detail: "string ends with", type: "keyword" },
{ label: "matches", detail: "regex match", type: "keyword" },
{ label: "&&", detail: "logical AND", type: "operator" },
{ label: "||", detail: "logical OR", type: "operator" },
{ label: "!", detail: "logical NOT", type: "operator" },
{ label: "in", detail: "membership test", type: "keyword" },
{ label: "not in", detail: "negative membership", type: "keyword" },
];
export function createContainerHints(
containerNames: string[],
imageNames: string[],
hostNames: string[],
): Completion[] {
return [
{ label: "name", detail: "container name", type: "property" },
{ label: "id", detail: "container ID", type: "property" },
{ label: "image", detail: "container image", type: "property" },
{ label: "state", detail: "running, exited, etc.", type: "property" },
{ label: "health", detail: "healthy, unhealthy, none", type: "property" },
{ label: "host", detail: "docker host", type: "property" },
{ label: "labels", detail: "container labels map", type: "property" },
...exprOperators,
{ label: '"running"', detail: "state value", type: "string" },
{ label: '"exited"', detail: "state value", type: "string" },
{ label: '"created"', detail: "state value", type: "string" },
{ label: '"paused"', detail: "state value", type: "string" },
{ label: '"healthy"', detail: "health value", type: "string" },
{ label: '"unhealthy"', detail: "health value", type: "string" },
{ label: '"none"', detail: "health value", type: "string" },
...containerNames.map((name) => ({ label: `"${name}"`, detail: "container name", type: "string" }) as Completion),
...imageNames.map((image) => ({ label: `"${image}"`, detail: "image name", type: "string" }) as Completion),
...hostNames.map((host) => ({ label: `"${host}"`, detail: "host name", type: "string" }) as Completion),
];
}
export function createLogHints(): Completion[] {
return [
{ label: "message", detail: "log message content", type: "property" },
{ label: "level", detail: "log level", type: "property" },
{ label: "stream", detail: "stdout or stderr", type: "property" },
{ label: "type", detail: "log type", type: "property" },
{ label: "timestamp", detail: "unix timestamp", type: "property" },
{ label: "id", detail: "log entry ID", type: "property" },
...exprOperators,
{ label: '"error"', detail: "level value", type: "string" },
{ label: '"warn"', detail: "level value", type: "string" },
{ label: '"info"', detail: "level value", type: "string" },
{ label: '"debug"', detail: "level value", type: "string" },
{ label: '"trace"', detail: "level value", type: "string" },
{ label: '"stdout"', detail: "stream value", type: "string" },
{ label: '"stderr"', detail: "stream value", type: "string" },
{ label: 'level == "error"', detail: "match error logs", type: "text", boost: 10 },
{ label: 'message contains ""', detail: "search in message", type: "text", boost: 10 },
{ label: 'stream == "stderr"', detail: "match stderr", type: "text", boost: 10 },
];
}
function createAutocomplete(getHints: () => Completion[]) {
return (context: any) => {
const word = context.matchBefore(/[\w"=!&|]+/);
if (!word && !context.explicit) return null;
const currentWord = word ? word.text.toLowerCase() : "";
const hints = getHints();
const filtered = currentWord ? hints.filter((h) => h.label.toLowerCase().includes(currentWord)) : hints;
return { from: word ? word.from : context.pos, options: filtered };
};
}
export async function createExprEditor(options: ExprEditorOptions) {
const [
{ EditorView, keymap, placeholder },
{ EditorState },
{ autocompletion, completionKeymap },
{ HighlightStyle, syntaxHighlighting },
{ tags },
] = await Promise.all([
import("@codemirror/view"),
import("@codemirror/state"),
import("@codemirror/autocomplete"),
import("@codemirror/language"),
import("@lezer/highlight"),
]);
const editorTheme = EditorView.theme({
"&": {
backgroundColor: "var(--color-base-100)",
color: "var(--color-base-content)",
},
".cm-content": {
caretColor: "var(--color-primary)",
},
".cm-cursor": {
borderLeftColor: "var(--color-primary)",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
backgroundColor: "var(--color-base-300)",
},
".cm-activeLine": {
backgroundColor: "color-mix(in oklch, var(--color-base-200) 50%, transparent)",
},
".cm-gutters": {
backgroundColor: "var(--color-base-200)",
color: "color-mix(in oklch, var(--color-base-content) 50%, transparent)",
border: "none",
},
".cm-activeLineGutter": {
backgroundColor: "var(--color-base-300)",
},
});
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: "var(--color-primary)" },
{ tag: tags.operator, color: "var(--color-secondary)" },
{ tag: tags.string, color: "var(--color-success)" },
{ tag: tags.number, color: "var(--color-warning)" },
{ tag: tags.bool, color: "var(--color-warning)" },
{ tag: tags.propertyName, color: "var(--color-info)" },
{ tag: tags.variableName, color: "var(--color-base-content)" },
{
tag: tags.comment,
color: "color-mix(in oklch, var(--color-base-content) 50%, transparent)",
fontStyle: "italic",
},
]);
const state = EditorState.create({
doc: options.initialValue,
extensions: [
EditorView.lineWrapping,
placeholder(options.placeholder),
autocompletion({
override: [createAutocomplete(options.getHints)],
activateOnTyping: true,
}),
keymap.of(completionKeymap),
editorTheme,
syntaxHighlighting(highlightStyle),
EditorView.updateListener.of((update) => {
if (update.docChanged && options.onChange) {
options.onChange(update.view.state.doc.toString());
}
}),
],
});
return new EditorView({ state, parent: options.parent });
}
+1
View File
@@ -96,6 +96,7 @@ export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalCon
const item = messages.value.at(-2)!;
const { logs, signal } = await loadBetween(container, params, item.date, new Date(), {
maxStart: 100,
startId: item.id,
});
if (signal.aborted) {
+71
View File
@@ -0,0 +1,71 @@
export interface TemplateEditorOptions {
parent: HTMLElement;
initialValue: string;
onChange?: (value: string) => void;
}
export async function createTemplateEditor(options: TemplateEditorOptions) {
const [{ EditorView }, { EditorState }, { json }, { HighlightStyle, syntaxHighlighting }, { tags }] =
await Promise.all([
import("@codemirror/view"),
import("@codemirror/state"),
import("@codemirror/lang-json"),
import("@codemirror/language"),
import("@lezer/highlight"),
]);
const editorTheme = EditorView.theme({
"&": {
backgroundColor: "var(--color-base-100)",
color: "var(--color-base-content)",
fontSize: "0.875rem",
},
".cm-content": {
caretColor: "var(--color-primary)",
fontFamily: "ui-monospace, monospace",
},
".cm-cursor": {
borderLeftColor: "var(--color-primary)",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
backgroundColor: "var(--color-base-300)",
},
".cm-activeLine": {
backgroundColor: "color-mix(in oklch, var(--color-base-200) 50%, transparent)",
},
".cm-gutters": {
backgroundColor: "var(--color-base-200)",
color: "color-mix(in oklch, var(--color-base-content) 50%, transparent)",
border: "none",
},
".cm-activeLineGutter": {
backgroundColor: "var(--color-base-300)",
},
});
const highlightStyle = HighlightStyle.define([
{ tag: tags.propertyName, color: "var(--color-info)" },
{ tag: tags.string, color: "var(--color-success)" },
{ tag: tags.number, color: "var(--color-warning)" },
{ tag: tags.bool, color: "var(--color-warning)" },
{ tag: tags.null, color: "var(--color-secondary)" },
{ tag: tags.punctuation, color: "var(--color-base-content)" },
]);
const state = EditorState.create({
doc: options.initialValue,
extensions: [
EditorView.lineWrapping,
json(),
editorTheme,
syntaxHighlighting(highlightStyle),
EditorView.updateListener.of((update) => {
if (update.docChanged && options.onChange) {
options.onChange(update.view.state.doc.toString());
}
}),
],
});
return new EditorView({ state, parent: options.parent });
}
+1 -1
View File
@@ -3,7 +3,7 @@ type Toast = {
createdAt: Date;
title?: string;
message: string;
type: "success" | "error" | "warning" | "info";
type: "error" | "warning" | "info";
action?: {
label: string;
handler: () => void;
+3 -3
View File
@@ -42,10 +42,10 @@
<button>close</button>
</form>
</dialog>
<SideDrawer ref="drawer" :width="drawerWidth">
<SideDrawer ref="drawer" :width="drawerWidth" v-slot="{ close }">
<Suspense :timeout="0">
<component :is="drawerComponent" v-bind="drawerProperties" />
<template #fallback> Loading dependencies... </template>
<component :is="drawerComponent" v-bind="drawerProperties" :close="close" />
<template #fallback> <span class="loading loading-spinner loading-sm"></span></template>
</Suspense>
</SideDrawer>
<ToastModal />
+41
View File
@@ -151,3 +151,44 @@ body {
.splitpanes--vertical .splitpanes__pane {
transition: none !important;
}
/* CodeMirror autocomplete tooltip styles */
.cm-tooltip {
@apply bg-base-200! border-base-content/40! min-w-96 rounded-sm border shadow-md;
}
.cm-tooltip-autocomplete ul {
@apply font-sans;
}
.cm-tooltip-autocomplete ul li {
@apply my-1 px-2;
}
.cm-tooltip-autocomplete ul li[aria-selected] {
@apply bg-primary/20 text-base-content!;
}
.cm-completionLabel {
@apply text-base-content!;
}
.cm-completionDetail {
@apply text-base-content/60! ml-2 italic;
}
.cm-completionMatchedText {
@apply text-primary! font-bold no-underline;
}
.cm-completionIcon {
@apply mr-2 opacity-70;
}
.cm-editor.cm-focused {
outline: none;
}
.cm-scroller {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}
+55 -11
View File
@@ -1,5 +1,4 @@
import type { ContainerHealth, ContainerStat, ContainerState } from "@/types/Container";
import { useExponentialMovingAverage, useSimpleRefHistory } from "@/utils";
import type { ContainerHealth, ContainerJson, ContainerStat, ContainerState } from "@/types/Container";
import { Ref } from "vue";
export type Stat = Omit<ContainerStat, "id">;
@@ -51,13 +50,12 @@ export class Container {
public readonly group?: string,
public health?: ContainerHealth,
) {
this._stat = ref(
stats.at(-1) || ({ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 } as Stat),
);
const { history } = useSimpleRefHistory(this._stat, { capacity: 300, deep: true, initial: stats });
this._statsHistory = history;
const { movingAverage } = useExponentialMovingAverage(this._stat, 0.2);
this.movingAverageStat = movingAverage;
const defaultStat = { cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 } as Stat;
this._stat = ref(stats.at(-1) || defaultStat);
const recentStats = stats.slice(-300);
const padding = Array(300 - recentStats.length).fill(defaultStat);
this._statsHistory = ref([...padding, ...recentStats]);
this.movingAverageStat = ref(stats.at(-1) || defaultStat);
this._name = name;
}
@@ -85,6 +83,7 @@ export class Container {
get namespace() {
return (
this.labels["dev.dozzle.group"] ||
this.labels["coolify.projectName"] ||
this.labels["com.docker.stack.namespace"] ||
this.labels["com.docker.compose.project"]
);
@@ -115,11 +114,56 @@ export class Container {
}
public updateStat(stat: Stat) {
// When Container is inside a reactive array, refs get unwrapped
if (isRef(this._stat)) {
this._stat.value = stat;
} else {
// @ts-ignore
this._stat = stat;
(this._stat as unknown as Stat) = stat;
}
// Update history directly (no watcher needed)
const history = isRef(this._statsHistory) ? this._statsHistory.value : (this._statsHistory as unknown as Stat[]);
history.push(stat);
if (history.length > 300) {
history.shift();
}
// Calculate EMA directly (no watcher needed)
const alpha = 0.2;
const prev = isRef(this.movingAverageStat)
? this.movingAverageStat.value
: (this.movingAverageStat as unknown as Stat);
const newEma = {
cpu: alpha * stat.cpu + (1 - alpha) * prev.cpu,
memory: alpha * stat.memory + (1 - alpha) * prev.memory,
memoryUsage: alpha * stat.memoryUsage + (1 - alpha) * prev.memoryUsage,
networkRxTotal: stat.networkRxTotal,
networkTxTotal: stat.networkTxTotal,
};
if (isRef(this.movingAverageStat)) {
this.movingAverageStat.value = newEma;
} else {
(this.movingAverageStat as unknown as Stat) = newEma;
}
}
static fromJSON(c: ContainerJson): Container {
return new Container(
c.id,
new Date(c.created),
new Date(c.startedAt),
new Date(c.finishedAt),
c.image,
c.name,
c.command,
c.host,
c.labels,
c.state,
c.cpuLimit,
c.memoryLimit,
c.stats ?? [],
c.group,
c.health,
);
}
}
+149
View File
@@ -0,0 +1,149 @@
<template>
<PageWithLinks>
<section>
<!-- Header -->
<div class="mb-8">
<h2 class="text-2xl font-bold">{{ $t("notifications.title") }}</h2>
<p class="text-base-content/60">{{ $t("notifications.description") }}</p>
</div>
<!-- Destinations Section -->
<div class="mb-8">
<h3 class="text-base-content/60 mb-4 font-semibold tracking-wide uppercase">
{{ $t("notifications.destinations") }}
</h3>
<div class="flex flex-wrap gap-4">
<DestinationCard
v-for="dest in dispatchers"
:key="dest.id"
:destination="dest"
:on-updated="fetchAll"
:existing-dispatchers="dispatchers"
class="w-full md:w-72"
/>
<!-- Add Destination Card -->
<button
class="card card-border border-base-content/30 hover:border-base-content/50 w-full cursor-pointer border-dashed transition-colors md:w-72"
@click="openAddDestination"
>
<div class="card-body items-center justify-center gap-1 p-4">
<mdi:plus class="text-2xl" />
<span class="text-base-content/60 text-sm">{{ $t("notifications.add-destination") }}</span>
</div>
</button>
</div>
</div>
<!-- Alerts Section -->
<div>
<div class="mb-4 flex items-center justify-between">
<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 -->
<div class="tabs tabs-box mb-6">
<button class="tab" :class="{ 'tab-active': filter === 'all' }" @click="filter = 'all'">
{{ $t("notifications.filter.all", { count: alerts.length }) }}
</button>
<button class="tab" :class="{ 'tab-active': filter === 'enabled' }" @click="filter = 'enabled'">
{{ $t("notifications.filter.enabled", { count: enabledCount }) }}
</button>
<button class="tab" :class="{ 'tab-active': filter === 'paused' }" @click="filter = 'paused'">
{{ $t("notifications.filter.paused", { count: pausedCount }) }}
</button>
</div>
<!-- Alerts List -->
<div v-if="!alerts.length" class="text-base-content/60 py-4">
{{ $t("notifications.no-alerts") }}
</div>
<div v-else class="space-y-4">
<AlertCard v-for="alert in filteredAlerts" :key="alert.id" :alert="alert" :on-updated="fetchAlerts" />
</div>
</div>
</section>
</PageWithLinks>
</template>
<script lang="ts" setup>
import type { NotificationRule, Dispatcher } from "@/types/notifications";
import AlertForm from "@/components/Notification/AlertForm.vue";
import DestinationForm from "@/components/Notification/DestinationForm.vue";
import DestinationCard from "@/components/Notification/DestinationCard.vue";
const showDrawer = useDrawer();
const router = useRouter();
// State
const alerts = ref<NotificationRule[]>([]);
const dispatchers = ref<Dispatcher[]>([]);
async function fetchAlerts() {
const res = await fetch(withBase("/api/notifications/rules"));
alerts.value = await res.json();
}
async function fetchDispatchers() {
const res = await fetch(withBase("/api/notifications/dispatchers"));
dispatchers.value = await res.json();
}
async function fetchAll() {
await Promise.all([fetchAlerts(), fetchDispatchers()]);
}
// Handle cloudLinkSuccess hash param
onMounted(async () => {
await fetchAll();
const hash = window.location.hash;
if (hash.startsWith("#cloudLinkSuccess=")) {
const id = Number(hash.replace("#cloudLinkSuccess=", ""));
if (!isNaN(id)) {
const destination = dispatchers.value.find((d) => d.id === id);
if (destination) {
showDrawer(
DestinationForm,
{
destination,
existingDispatchers: dispatchers.value,
showLinkSuccess: true,
},
"md",
);
}
}
router.replace({ hash: "" });
}
});
// Local state
const filter = ref<"all" | "enabled" | "paused">("all");
const enabledCount = computed(() => alerts.value.filter((a) => a.enabled).length);
const pausedCount = computed(() => alerts.value.filter((a) => !a.enabled).length);
const filteredAlerts = computed(() => {
if (filter.value === "enabled") return alerts.value.filter((a) => a.enabled);
if (filter.value === "paused") return alerts.value.filter((a) => !a.enabled);
return alerts.value;
});
function openCreateAlert() {
showDrawer(AlertForm, { onCreated: fetchAlerts }, "lg");
}
function openAddDestination() {
showDrawer(
DestinationForm,
{
onCreated: fetchDispatchers,
existingDispatchers: dispatchers.value,
},
"md",
);
}
</script>
+2
View File
@@ -4,3 +4,5 @@ declare module "*.vue" {
const component: DefineComponent<{}, {}, any>;
export default component;
}
declare const __CLOUD_URL__: string;
+18 -11
View File
@@ -1,15 +1,19 @@
type Announcement = {
interface Release {
name: string;
announcement: boolean;
createdAt: Date;
body: string;
mentionsCount: number;
tag: string;
body: string;
createdAt: string;
htmlUrl: string;
latest: boolean;
mentionsCount: number;
features: number;
bugFixes: number;
breaking: number;
}
type Announcement = Omit<Release, "createdAt"> & {
announcement: boolean;
createdAt: Date;
};
const releases = ref<Announcement[]>([]);
@@ -20,8 +24,14 @@ async function fetchReleases() {
fetched = true;
try {
const { data } = await useFetch(withBase("/api/releases")).get().json<Announcement[]>();
releases.value = data.value || [];
const res = await fetch(withBase("/api/releases"));
const data: Release[] = await res.json();
releases.value =
data?.map((r) => ({
...r,
createdAt: new Date(r.createdAt),
announcement: false,
})) || [];
} catch (error) {
console.error("Error while fetching releases:\n", error);
fetched = false;
@@ -35,10 +45,7 @@ if (config.releaseCheckMode === "automatic") {
const otherAnnouncements = [] as Announcement[];
const announcements = computed(() => {
const newReleases =
releases.value?.map((release) => ({ ...release, createdAt: new Date(release.createdAt), announcement: false })) ??
[];
return [...newReleases, ...otherAnnouncements].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return [...releases.value, ...otherAnnouncements].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
});
const mostRecent = computed(() => announcements.value?.[0]);
+1 -22
View File
@@ -140,28 +140,7 @@ export const useContainerStore = defineStore("container", () => {
existing.name = c.name;
});
containers.value = [
...containers.value,
...newContainers.map((c) => {
return new Container(
c.id,
new Date(c.created),
new Date(c.startedAt),
new Date(c.finishedAt),
c.image,
c.name,
c.command,
c.host,
c.labels,
c.state,
c.cpuLimit,
c.memoryLimit,
c.stats,
c.group,
c.health,
);
}),
];
containers.value = [...containers.value, ...newContainers.map(Container.fromJSON)];
};
const currentContainer = (id: Ref<string>) => computed(() => allContainersById.value[id.value]);
+31 -13
View File
@@ -1,26 +1,29 @@
/* eslint-disable */
/* prettier-ignore */
// oxfmt-ignore
// @ts-nocheck
// noinspection ES6UnusedImports
// Generated by unplugin-vue-router. !! DO NOT MODIFY THIS FILE !!
// Generated by vue-router. !! DO NOT MODIFY THIS FILE !!
// It's recommended to commit this file.
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
declare module 'vue-router/auto-resolver' {
export type ParamParserCustom = never
import type {
RouteRecordInfo,
ParamValue,
ParamValueOneOrMore,
ParamValueZeroOrMore,
ParamValueZeroOrOne,
} from 'vue-router'
declare module 'vue-router' {
interface TypesConfig {
ParamParsers: never
}
}
declare module 'vue-router/auto-routes' {
import type {
RouteRecordInfo,
ParamValue,
ParamValueOneOrMore,
ParamValueZeroOrMore,
ParamValueZeroOrOne,
} from 'vue-router'
/**
* Route name map generated by unplugin-vue-router
* Route name map generated by vue-router
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<
@@ -86,6 +89,13 @@ declare module 'vue-router/auto-routes' {
{ name: ParamValue<false> },
| never
>,
'/notifications': RouteRecordInfo<
'/notifications',
'/notifications',
Record<never, never>,
Record<never, never>,
| never
>,
'/owner/[name]': RouteRecordInfo<
'/owner/[name]',
'/owner/:name',
@@ -124,7 +134,7 @@ declare module 'vue-router/auto-routes' {
}
/**
* Route file to route info map by unplugin-vue-router.
* Route file to route info map by vue-router.
* Used by the \`sfc-typed-router\` Volar plugin to automatically type \`useRoute()\`.
*
* Each key is a file path relative to the project root with 2 properties:
@@ -188,6 +198,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'assets/pages/notifications.vue': {
routes:
| '/notifications'
views:
| never
}
'assets/pages/owner/[name].vue': {
routes:
| '/owner/[name]'
@@ -231,3 +247,5 @@ declare module 'vue-router/auto-routes' {
? Info['routes']
: keyof RouteNamedMap
}
export {}
+56
View File
@@ -0,0 +1,56 @@
export interface NotificationRule {
id: number;
name: string;
enabled: boolean;
containerExpression: string;
logExpression: string;
triggerCount: number;
triggeredContainers: number;
lastTriggeredAt: string | null;
dispatcher: Dispatcher | null;
}
export interface Dispatcher {
id: number;
name: string;
type: string;
url?: string;
template?: string;
prefix?: string;
expiresAt?: string;
}
export interface NotificationRuleInput {
name: string;
enabled: boolean;
dispatcherId: number;
logExpression: string;
containerExpression: string;
}
export interface PreviewResult {
containerError?: string;
logError?: string;
matchedContainers: {
id: string;
name: string;
image: string;
host: string;
}[];
matchedLogs: {
id: number;
t: string;
m: unknown;
rm: string;
ts: number;
l: string;
s: string;
}[];
totalLogs: number;
}
export interface TestWebhookResult {
success: boolean;
statusCode?: number;
error?: string;
}
+1 -1
View File
@@ -95,7 +95,7 @@ services:
playwright:
container_name: playwright
image: mcr.microsoft.com/playwright:v1.57.0-jammy
image: mcr.microsoft.com/playwright:v1.58.2-jammy
working_dir: /app
volumes:
- .:/app
+9
View File
@@ -27,3 +27,12 @@ services:
```
:::
## Coolify Integration
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.projectName` → Used for grouping if `dev.dozzle.group` is not set
No additional configuration is needed for Coolify deployments.
+61 -12
View File
@@ -1,14 +1,39 @@
#!/bin/bash
set -e
# Parse arguments
USE_LOCAL=false
POSITIONAL_ARGS=()
while [[ $# -gt 0 ]]; do
case $1 in
--local)
USE_LOCAL=true
shift
;;
*)
POSITIONAL_ARGS+=("$1")
shift
;;
esac
done
# Configuration
VM_NAME="${1:-dozzle-agent}"
DISTRO="${2:-ubuntu}"
AGENT_PORT="${3:-7007}"
VM_NAME="${POSITIONAL_ARGS[0]:-dozzle-agent}"
DISTRO="${POSITIONAL_ARGS[1]:-ubuntu}"
AGENT_PORT="${POSITIONAL_ARGS[2]:-7007}"
SHARED_CERT="./shared_cert.pem"
SHARED_KEY="./shared_key.pem"
DOZZLE_IMAGE="amir20/dozzle:latest"
if [ "$USE_LOCAL" = true ]; then
DOZZLE_IMAGE="amir20/dozzle:local"
fi
echo "🚀 Setting up Dozzle Agent on OrbStack VM: $VM_NAME"
if [ "$USE_LOCAL" = true ]; then
echo " Using locally built image"
fi
# Verify shared certificates exist
if [ ! -f "$SHARED_CERT" ]; then
@@ -40,10 +65,7 @@ sleep 3
# Step 2: Install Docker in the VM
echo "🐳 Installing Docker..."
if ! orb exec -m "$VM_NAME" bash -c '
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $(whoami)
'; then
if ! orb exec -m "$VM_NAME" bash -c 'curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $(whoami)'; then
echo "❌ Docker installation failed"
exit 1
fi
@@ -62,17 +84,44 @@ cat "$SHARED_KEY" | orb exec -m "$VM_NAME" bash -c 'cat > ~/dozzle-certs/shared_
echo "✅ Certificates copied"
# Step 4: Start Dozzle agent
# Step 4: Load or pull Dozzle image
if [ "$USE_LOCAL" = true ]; then
echo "🔨 Building local Docker image..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
if ! (cd "$PROJECT_ROOT" && make docker); then
echo "❌ Failed to build Docker image"
exit 1
fi
echo "📦 Loading image into VM..."
if ! docker save amir20/dozzle:local | orb exec -m "$VM_NAME" docker load; then
echo "❌ Failed to load image into VM"
exit 1
fi
echo "✅ Local image loaded"
else
echo "📥 Pulling Dozzle image..."
if ! orb exec -m "$VM_NAME" docker pull amir20/dozzle:latest; then
echo "❌ Failed to pull image"
exit 1
fi
fi
# Step 5: Start Dozzle agent
echo "🎯 Starting Dozzle agent..."
orb exec -m "$VM_NAME" bash -c 'mkdir -p ~/dozzle-data'
if ! orb exec -m "$VM_NAME" bash -c "
set -e
docker pull amir20/dozzle:latest
docker run -d --name dozzle-agent \
--restart unless-stopped \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/dozzle-certs:/certs \
-v ~/dozzle-data:/data \
-p $AGENT_PORT:7007 \
amir20/dozzle:latest agent \
-e DOZZLE_LEVEL=debug \
$DOZZLE_IMAGE agent \
--cert /certs/shared_cert.pem \
--key /certs/shared_key.pem
"; then
@@ -82,11 +131,11 @@ fi
echo "✅ Dozzle agent started"
# Step 5: Wait for agent to be ready
# Step 6: Wait for agent to be ready
echo "⏳ Waiting for agent to be ready..."
sleep 3
# Step 6: Verify agent is running
# Step 7: Verify agent is running
echo "🧪 Verifying agent is running..."
if orb exec -m "$VM_NAME" docker ps --filter name=dozzle-agent --format "{{.Status}}" | grep -q "Up"; then
echo "✅ Agent is running"
+32 -16
View File
@@ -8,7 +8,6 @@ require (
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1
github.com/gogo/protobuf v1.3.2 // indirect
github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
@@ -17,24 +16,26 @@ require (
github.com/spf13/afero v1.15.0
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.41.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/go-chi/chi/v5 v5.2.3
github.com/expr-lang/expr v1.17.7
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/jwtauth/v5 v5.3.3
github.com/go-faker/faker/v4 v4.7.0
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/puzpuzpuz/xsync/v4 v4.2.0
github.com/puzpuzpuz/xsync/v4 v4.4.0
github.com/rs/zerolog v1.34.0
github.com/samber/lo v1.52.0
github.com/wk8/go-ordered-map/v2 v2.1.8
github.com/yuin/goldmark v1.7.16
golang.org/x/crypto v0.46.0
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
google.golang.org/protobuf v1.36.11
@@ -45,9 +46,13 @@ require (
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/air-verse/air v1.64.5 // indirect
github.com/alexflint/go-scalar v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/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/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
@@ -57,19 +62,21 @@ require (
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
@@ -88,24 +95,28 @@ require (
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/tdewolff/parse/v2 v2.8.3 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
go.opentelemetry.io/otel v1.38.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.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.10.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.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/grpc/cmd/protoc-gen-go-grpc v1.6.1 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gotest.tools/v3 v3.0.3 // indirect
@@ -114,9 +125,14 @@ require (
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
go 1.25.5
go 1.25.7
tool (
github.com/air-verse/air
google.golang.org/grpc/cmd/protoc-gen-go-grpc
google.golang.org/protobuf/cmd/protoc-gen-go
)
+210 -401
View File
@@ -1,35 +1,74 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8=
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
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.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/alexflint/go-arg v1.5.1 h1:nBuWUCpuRy0snAG+uIJ6N0UvYxpxA0/ghA/AaHxlT8Y=
github.com/alexflint/go-arg v1.5.1/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8=
github.com/alexflint/go-arg v1.6.0 h1:wPP9TwTPO54fUVQl4nZoxbFfKCcy5E6HBCumj1XVRSo=
github.com/alexflint/go-arg v1.6.0/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8=
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/air-verse/air v1.64.5/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.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/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=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/beme/abide v0.0.0-20190723115211-635a09831760 h1:FvTM5NSN5HYvfKpgL+8x73U5v063vHsd7AX05eV1DnM=
github.com/beme/abide v0.0.0-20190723115211-635a09831760/go.mod h1:6+8gCKsZnxzhGTmKRh4BSkLos9CbWRJNcrp55We4SqQ=
github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bep/gitmap v1.9.0 h1:2pyb1ex+cdwF6c4tsrhEgEKfyNfxE34d5K+s2sa9byc=
github.com/bep/gitmap v1.9.0/go.mod h1:Juq6e1qqCRvc1W7nzgadPGI9IGV13ZncEebg5atj4Vo=
github.com/bep/goat v0.5.0 h1:S8jLXHCVy/EHIoCY+btKkmcxcXFd34a0Q63/0D4TKeA=
github.com/bep/goat v0.5.0/go.mod h1:Md9x7gRxiWKs85yHlVTvHQw9rg86Bm+Y4SuYE8CTH7c=
github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7xg=
github.com/bep/godartsass/v2 v2.5.0/go.mod h1:rjsi1YSXAl/UbsGL85RLDEjRKdIKUlMQHr6ChUNYOFU=
github.com/bep/golibsass v1.2.0 h1:nyZUkKP/0psr8nT6GR2cnmt99xS93Ji82ZD9AgOK6VI=
github.com/bep/golibsass v1.2.0/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA=
github.com/bep/goportabletext v0.1.0 h1:8dqym2So1cEqVZiBa4ZnMM1R9l/DnC1h4ONg4J5kujw=
github.com/bep/goportabletext v0.1.0/go.mod h1:6lzSTsSue75bbcyvVc0zqd1CdApuT+xkZQ6Re5DzZFg=
github.com/bep/gowebp v0.4.0 h1:QihuVnvIKbRoeBNQkN0JPMM8ClLmD6V2jMftTFwSK3Q=
github.com/bep/gowebp v0.4.0/go.mod h1:95gtYkAA8iIn1t3HkAPurRCVGV/6NhgaHJ1urz0iIwc=
github.com/bep/helpers v0.6.0 h1:qtqMCK8XPFNM9hp5Ztu9piPjxNNkk8PIyUVjg6v8Bsw=
github.com/bep/helpers v0.6.0/go.mod h1:IOZlgx5PM/R/2wgyCatfsgg5qQ6rNZJNDpWGXqDR044=
github.com/bep/imagemeta v0.12.0 h1:ARf+igs5B7pf079LrqRnwzQ/wEB8Q9v4NSDRZO1/F5k=
github.com/bep/imagemeta v0.12.0/go.mod h1:23AF6O+4fUi9avjiydpKLStUNtJr5hJB4rarG18JpN8=
github.com/bep/lazycache v0.8.0 h1:lE5frnRjxaOFbkPZ1YL6nijzOPPz6zeXasJq8WpG4L8=
github.com/bep/lazycache v0.8.0/go.mod h1:BQ5WZepss7Ko91CGdWz8GQZi/fFnCcyWupv8gyTeKwk=
github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ=
github.com/bep/logg v0.4.0/go.mod h1:Ccp9yP3wbR1mm++Kpxet91hAZBEQgmWgFgnXX3GkIV0=
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/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=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
@@ -44,28 +83,12 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I=
github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v28.2.1+incompatible h1:aTSWVTDStpHbnRu0xBcGoJEjRf5EQKt6nik6Vif8sWw=
github.com/docker/docker v28.2.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=
github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v28.3.0+incompatible h1:ffS62aKWupCWdvcee7nBU9fhnmknOqDPaJAMtfK0ImQ=
github.com/docker/docker v28.3.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v28.3.1+incompatible h1:20+BmuA9FXlCX4ByQ0vYJcUEnOmRM6XljDnFWR+jCyY=
github.com/docker/docker v28.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v28.3.2+incompatible h1:wn66NJ6pWB1vBZIilP8G3qQPqHy5XymfYn5vsqeA5oA=
github.com/docker/docker v28.3.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v28.4.0+incompatible h1:KVC7bz5zJY/4AZe/78BIvCnPsLaC9T/zh72xnlrTTOk=
github.com/docker/docker v28.4.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v28.5.0+incompatible h1:ZdSQoRUE9XxhFI/B8YLvhnEFMmYN9Pp8Egd2qcaFk1E=
github.com/docker/docker v28.5.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
@@ -74,35 +97,36 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU=
github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
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/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=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
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-faker/faker/v4 v4.6.0 h1:6aOPzNptRiDwD14HuAnEtlTa+D1IfFuEHO8+vEFwjTs=
github.com/go-faker/faker/v4 v4.6.0/go.mod h1:ZmrHuVtTTm2Em9e0Du6CJ9CADaLEzGXW62z1YqFH0m0=
github.com/go-faker/faker/v4 v4.6.1 h1:xUyVpAjEtB04l6XFY0V/29oR332rOSPWV4lU8RwDt4k=
github.com/go-faker/faker/v4 v4.6.1/go.mod h1:arSdxNCSt7mOhdk8tEolvHeIJ7eX4OX80wXjKKvkKBY=
github.com/go-faker/faker/v4 v4.6.2 h1:IR1uQUYotFZnuTL7Iuy0FDGtHM5Rt1Q+2nipH9gnqKs=
github.com/go-faker/faker/v4 v4.6.2/go.mod h1:u1dIRP5neLB6kTzgyVjdBOV5R1uP7BdxkcWk7tiKQXk=
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=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -115,43 +139,62 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
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=
github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
github.com/gohugoio/httpcache v0.7.0 h1:ukPnn04Rgvx48JIinZvZetBfHaWE7I01JR2Q2RrQ3Vs=
github.com/gohugoio/httpcache v0.7.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
github.com/gohugoio/hugo v0.149.1 h1:uWOc8Ve4h4e48FyYhBquRoHCJviyxA5yGrFJLT48yio=
github.com/gohugoio/hugo v0.149.1/go.mod h1:HS6BP6e8FGxungP4CHC3zeLDvhBLnTJIjHJZWTZjs7o=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0 h1:dco+7YiOryRoPOMXwwaf+kktZSCtlFtreNdiJbETvYE=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0/go.mod h1:CRrxQTKeM3imw+UoS4EHKyrqB7Zp6sAJiqHit+aMGTE=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1 h1:nUzXfRTszLliZuN0JTKeunXTRaiFX6ksaWP0puLLYAY=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1/go.mod h1:Wy8ThAA8p2/w1DY05vEzq6EIeI2mzDjvHsu7ULBVwog=
github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XGDc=
github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4=
github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo=
github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw=
github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -159,6 +202,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
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/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
@@ -173,6 +218,10 @@ github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNB
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE=
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=
@@ -180,6 +229,12 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
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=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE=
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
@@ -193,47 +248,62 @@ github.com/moby/term v0.0.0-20201216013528-df9cb8a40635/go.mod h1:FBS0z0QWA44HXy
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc=
github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
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/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=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v4 v4.0.0 h1:F1za+MBXzDQtQq+OVgFsojSX4w66rsNDmQNebPFAncA=
github.com/puzpuzpuz/xsync/v4 v4.0.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/puzpuzpuz/xsync/v4 v4.1.0 h1:x9eHRl4QhZFIPJ17yl4KKW9xLyVWbb3/Yq4SXpjF71U=
github.com/puzpuzpuz/xsync/v4 v4.1.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/puzpuzpuz/xsync/v4 v4.2.0 h1:dlxm77dZj2c3rxq0/XNvvUKISAmovoXF4a4qM6Wvkr0=
github.com/puzpuzpuz/xsync/v4 v4.2.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/puzpuzpuz/xsync/v4 v4.4.0 h1:vlSN6/CkEY0pY8KaB0yqo/pCLZvp9nhdbBdjipT4gWo=
github.com/puzpuzpuz/xsync/v4 v4.4.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
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.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY=
github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc=
github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI=
github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
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/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
@@ -242,13 +312,11 @@ github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -259,125 +327,76 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tdewolff/minify/v2 v2.24.2 h1:vnY3nTulEAbCAAlxTxPPDkzG24rsq31SOzp63yT+7mo=
github.com/tdewolff/minify/v2 v2.24.2/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE=
github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I=
github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
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.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.10 h1:S+LrtBjRmqMac2UdtB6yyCEJm+UILZ2fefI4p7o0QpI=
github.com/yuin/goldmark v1.7.10/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo=
github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.14 h1:9F3UqVQdZ5GG5y6TU0l1TbbDhZmqfevaOcinQt88Qi8=
github.com/yuin/goldmark v1.7.14/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
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.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
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/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.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
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.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
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/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.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
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.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
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/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=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
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/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/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-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
@@ -386,57 +405,23 @@ 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.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE=
golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
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/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/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=
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.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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=
@@ -449,22 +434,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.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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/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=
@@ -474,22 +447,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.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
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/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=
@@ -499,102 +460,47 @@ 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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
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/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=
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
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.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24=
google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2 h1:DMTIbak9GhdaSxEjvVzAeNZvyc03I61duqNbnm3SU0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
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.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM=
google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8=
google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
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/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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/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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
@@ -602,124 +508,27 @@ 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.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls=
k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k=
k8s.io/api v0.33.0 h1:yTgZVn1XEe6opVpP1FylmNrIFWuDqe2H0V8CT5gxfIU=
k8s.io/api v0.33.0/go.mod h1:CTO61ECK/KU7haa3qq8sarQ0biLq2ju405IZAd9zsiM=
k8s.io/api v0.33.1 h1:tA6Cf3bHnLIrUK4IqEgb2v++/GYUtqiu9sRVk3iBXyw=
k8s.io/api v0.33.1/go.mod h1:87esjTn9DRSRTD4fWMXamiXxJhpOIREjWOSjsW1kEHw=
k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY=
k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs=
k8s.io/api v0.33.3 h1:SRd5t//hhkI1buzxb288fy2xvjubstenEKL9K51KBI8=
k8s.io/api v0.33.3/go.mod h1:01Y/iLUjNBM3TAvypct7DIj0M0NIZc+PzAHCIo0CYGE=
k8s.io/api v0.33.4 h1:oTzrFVNPXBjMu0IlpA2eDDIU49jsuEorGHB4cvKupkk=
k8s.io/api v0.33.4/go.mod h1:VHQZ4cuxQ9sCUMESJV5+Fe8bGnqAARZ08tSTdHWfeAc=
k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE=
k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug=
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY=
k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw=
k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4=
k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk=
k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=
k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U=
k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
k8s.io/apimachinery v0.33.0 h1:1a6kHrJxb2hs4t8EE5wuR/WxKDwGN1FKH3JvDtA0CIQ=
k8s.io/apimachinery v0.33.0/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
k8s.io/apimachinery v0.33.1 h1:mzqXWV8tW9Rw4VeW9rEkqvnxj59k1ezDUl20tFK/oM4=
k8s.io/apimachinery v0.33.1/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY=
k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
k8s.io/apimachinery v0.33.3 h1:4ZSrmNa0c/ZpZJhAgRdcsFcZOw1PQU1bALVQ0B3I5LA=
k8s.io/apimachinery v0.33.3/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
k8s.io/apimachinery v0.33.4 h1:SOf/JW33TP0eppJMkIgQ+L6atlDiP/090oaX0y9pd9s=
k8s.io/apimachinery v0.33.4/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0=
k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=
k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE=
k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU=
k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY=
k8s.io/client-go v0.33.0 h1:UASR0sAYVUzs2kYuKn/ZakZlcs2bEHaizrrHUZg0G98=
k8s.io/client-go v0.33.0/go.mod h1:kGkd+l/gNGg8GYWAPr0xF1rRKvVWvzh9vmZAMXtaKOg=
k8s.io/client-go v0.33.1 h1:ZZV/Ks2g92cyxWkRRnfUDsnhNn28eFpt26aGc8KbXF4=
k8s.io/client-go v0.33.1/go.mod h1:JAsUrl1ArO7uRVFWfcj6kOomSlCv+JpvIsp6usAGefA=
k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E=
k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo=
k8s.io/client-go v0.33.3 h1:M5AfDnKfYmVJif92ngN532gFqakcGi6RvaOF16efrpA=
k8s.io/client-go v0.33.3/go.mod h1:luqKBQggEf3shbxHY4uVENAxrDISLOarxpTKMiUuujg=
k8s.io/client-go v0.33.4 h1:TNH+CSu8EmXfitntjUPwaKVPN0AYMbc9F1bBS8/ABpw=
k8s.io/client-go v0.33.4/go.mod h1:LsA0+hBG2DPwovjd931L/AoaezMPX9CmBgyVyBZmbCY=
k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo=
k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY=
k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=
k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A=
k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM=
k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
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-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8XWMxCxzQx42DY8QKYJrDLg=
k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas=
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4=
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
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.32.3 h1:2vsBvw0v8rIIlczZ/lZ8Kcqk9tR6Fks9h+dtFNbc2a4=
k8s.io/metrics v0.32.3/go.mod h1:9R1Wk5cb+qJpCQon9h52mgkVCcFeYxcY+YkumfwHVCU=
k8s.io/metrics v0.33.0 h1:sKe5sC9qb1RakMhs8LWYNuN2ne6OTCWexj8Jos3rO2Y=
k8s.io/metrics v0.33.0/go.mod h1:XewckTFXmE2AJiP7PT3EXaY7hi7bler3t2ZLyOdQYzU=
k8s.io/metrics v0.33.1 h1:Ypd5ITCf+fM+LDNFk7hESXTc3vh02CQYGiwRoVRaGsM=
k8s.io/metrics v0.33.1/go.mod h1:wK8cFTK5ykBdhL0Wy4RZwLH28XM7j/Klc+NQrMRWVxg=
k8s.io/metrics v0.33.2 h1:gNCBmtnUMDMCRg9Ly5ehxP3OdKISMsOnh1vzk01iCgE=
k8s.io/metrics v0.33.2/go.mod h1:yxoAosKGRsZisv3BGekC5W6T1J8XSV+PoUEevACRv7c=
k8s.io/metrics v0.33.3 h1:9CcqBz15JZfISqwca33gdHS8I6XfsK1vA8WUdEnG70g=
k8s.io/metrics v0.33.3/go.mod h1:Aw+cdg4AYHw0HvUY+lCyq40FOO84awrqvJRTw0cmXDs=
k8s.io/metrics v0.33.4 h1:eJ6UdTpKTUQVZbKpUdm5ve39aPpAvvNwLrs13oQcWKc=
k8s.io/metrics v0.33.4/go.mod h1:NO/lgFtyIPTurz56debdSh5qRqRfpO8MlkMpau1Ue8U=
k8s.io/metrics v0.34.0 h1:nYSfG2+tnL6/MRC2I+sGHjtNEGoEoM/KktgGOoQFwws=
k8s.io/metrics v0.34.0/go.mod h1:KCuXmotE0v4AvoARKUP8NC4lUnbK/Du1mluGdor5h4M=
k8s.io/metrics v0.34.1 h1:374Rexmp1xxgRt64Bi0TsjAM8cA/Y8skwCoPdjtIslE=
k8s.io/metrics v0.34.1/go.mod h1:Drf5kPfk2NJrlpcNdSiAAHn/7Y9KqxpRNagByM7Ei80=
k8s.io/metrics v0.34.2 h1:zao91FNDVPRGIiHLO2vqqe21zZVPien1goyzn0hsz90=
k8s.io/metrics v0.34.2/go.mod h1:Ydulln+8uZZctUM8yrUQX4rfq/Ay6UzsuXf24QJ37Vc=
k8s.io/metrics v0.34.3 h1:zKco9A0q7Ibl3alcO1kqRandTt4GKwKGOBflYJTIBHc=
k8s.io/metrics v0.34.3/go.mod h1:BWmkYCQ9x4I120OmCtMUeuXn0VTGkJLwBErneDL5aSQ=
k8s.io/metrics v0.35.0 h1:xVFoqtAGm2dMNJAcB5TFZJPCen0uEqqNt52wW7ABbX8=
k8s.io/metrics v0.35.0/go.mod h1:g2Up4dcBygZi2kQSEQVDByFs+VUwepJMzzQLJJLpq4M=
k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0=
k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v4 v4.5.0 h1:nbCitCK2hfnhyiKo6uf2HxUPTCodY6Qaf85SbDIaMBk=
sigs.k8s.io/structured-merge-diff/v4 v4.5.0/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4=
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc=
sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+177
View File
@@ -0,0 +1,177 @@
# Where are all the schema files located? globs are supported eg src/**/*.graphqls
schema:
- graph/*.graphqls
# Where should the generated server code go?
exec:
package: graph
layout: single-file # Only other option is "follow-schema," ie multi-file.
# Only for single-file layout:
filename: graph/generated.go
# Only for follow-schema layout:
# dir: graph
# filename_template: "{name}.generated.go"
# Optional: Maximum number of goroutines in concurrency to use per child resolvers(default: unlimited)
# worker_limit: 1000
# Uncomment to enable federation
# federation:
# filename: graph/federation.go
# package: graph
# version: 2
# options:
# computed_requires: true
# Where should any generated models go?
model:
filename: graph/model/models_gen.go
package: model
# Optional: Pass in a path to a new gotpl template to use for generating the models
# model_template: [your/path/model.gotpl]
# Where should the resolver implementations go?
resolver:
package: graph
layout: follow-schema # Only other option is "single-file."
# Only for single-file layout:
# filename: graph/resolver.go
# Only for follow-schema layout:
dir: graph
filename_template: "{name}.resolvers.go"
# Optional: turn on to not generate template comments above resolvers
# omit_template_comment: false
# Optional: Pass in a path to a new gotpl template to use for generating resolvers
# resolver_template: [your/path/resolver.gotpl]
# Optional: turn on to avoid rewriting existing resolver(s) when generating
# preserve_resolver: false
# Optional: turn on use ` + "`" + `gqlgen:"fieldName"` + "`" + ` tags in your models
# struct_tag: json
# Optional: turn on to use []Thing instead of []*Thing
# omit_slice_element_pointers: false
# Optional: turn on to omit Is<Name>() methods to interface and unions
# omit_interface_checks: true
# Optional: turn on to skip generation of ComplexityRoot struct content and Complexity function
# omit_complexity: false
# Optional: turn on to not generate any file notice comments in generated files
# omit_gqlgen_file_notice: false
# Optional: turn on to exclude the gqlgen version in the generated file notice. No effect if `omit_gqlgen_file_notice` is true.
# omit_gqlgen_version_in_file_notice: false
# Optional: turn on to exclude root models such as Query and Mutation from the generated models file.
# omit_root_models: false
# Optional: turn on to exclude resolver fields from the generated models file.
# omit_resolver_fields: false
# Optional: turn off to make struct-type struct fields not use pointers
# e.g. type Thing struct { FieldA OtherThing } instead of { FieldA *OtherThing }
# struct_fields_always_pointers: true
# Optional: turn off to make resolvers return values instead of pointers for structs
# resolvers_always_return_pointers: true
# Optional: turn on to return pointers instead of values in unmarshalInput
# return_pointers_in_unmarshalinput: false
# Optional: wrap nullable input fields with Omittable
# nullable_input_omittable: true
# Optional: set to speed up generation time by not performing a final validation pass.
# skip_validation: true
# Optional: set to skip running `go mod tidy` when generating server code
# skip_mod_tidy: true
# Optional: if this is set to true, argument directives that
# decorate a field with a null value will still be called.
#
# This enables argumment directives to not just mutate
# argument values but to set them even if they're null.
call_argument_directives_with_null: true
# This enables gql server to use function syntax for execution context
# instead of generating receiver methods of the execution context.
# use_function_syntax_for_execution_context: true
# Optional: set build tags that will be used to load packages
# go_build_tags:
# - private
# - enterprise
# Optional: set to modify the initialisms regarded for Go names
# go_initialisms:
# replace_defaults: false # if true, the default initialisms will get dropped in favor of the new ones instead of being added
# initialisms: # List of initialisms to for Go names
# - 'CC'
# - 'BCC'
# gqlgen will search for any type names in the schema in these go packages
# if they match it will use them, otherwise it will generate them.
autobind:
# - "github.com/amir20/dozzle/graph/model"
# This section declares type mapping between the GraphQL and go type systems
#
# The first line in each type will be used as defaults for resolver arguments and
# modelgen, the others will be allowed when binding to fields. Configure them to
# your liking
models:
ID:
model:
- github.com/99designs/gqlgen/graphql.ID
- github.com/99designs/gqlgen/graphql.Int
- github.com/99designs/gqlgen/graphql.Int64
- github.com/99designs/gqlgen/graphql.Int32
# gqlgen provides a default GraphQL UUID convenience wrapper for github.com/google/uuid
# but you can override this to provide your own GraphQL UUID implementation
UUID:
model:
- github.com/99designs/gqlgen/graphql.UUID
# The GraphQL spec explicitly states that the Int type is a signed 32-bit
# integer. Using Go int or int64 to represent it can lead to unexpected
# behavior, and some GraphQL tools like Apollo Router will fail when
# communicating numbers that overflow 32-bits.
#
# You may choose to use the custom, built-in Int64 scalar to represent 64-bit
# integers, or ignore the spec and bind Int to graphql.Int / graphql.Int64
# (the default behavior of gqlgen). This is fine in simple use cases when you
# do not need to worry about interoperability and only expect small numbers.
Int:
model:
- github.com/99designs/gqlgen/graphql.Int32
Int64:
model:
- github.com/99designs/gqlgen/graphql.Int
- github.com/99designs/gqlgen/graphql.Int64
Time:
model:
- github.com/99designs/gqlgen/graphql.Time
Any:
model:
- github.com/99designs/gqlgen/graphql.Any
Map:
model:
- github.com/99designs/gqlgen/graphql.Map
Container:
model:
- github.com/amir20/dozzle/internal/container.Container
LogEvent:
model:
- github.com/amir20/dozzle/internal/container.LogEvent
Release:
model:
- github.com/amir20/dozzle/internal/releases.Release
+100 -53
View File
@@ -8,12 +8,14 @@ import (
"errors"
"fmt"
"io"
"sync"
"time"
"encoding/json"
"github.com/amir20/dozzle/internal/agent/pb"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/types"
"github.com/rs/zerolog/log"
orderedmap "github.com/wk8/go-ordered-map/v2"
"google.golang.org/grpc"
@@ -274,8 +276,17 @@ func (c *Client) StreamNewContainers(ctx context.Context, containers chan<- cont
}
}
func (c *Client) FindContainer(ctx context.Context, containerID string) (container.Container, error) {
response, err := c.client.FindContainer(ctx, &pb.FindContainerRequest{ContainerId: containerID})
func (c *Client) FindContainer(ctx context.Context, containerID string, labels container.ContainerLabels) (container.Container, error) {
in := &pb.FindContainerRequest{ContainerId: containerID}
if labels != nil {
in.Filter = make(map[string]*pb.RepeatedString)
for k, v := range labels {
in.Filter[k] = &pb.RepeatedString{Values: v}
}
}
response, err := c.client.FindContainer(ctx, in)
if err != nil {
return container.Container{}, err
}
@@ -365,30 +376,41 @@ func (c *Client) ContainerAttach(ctx context.Context, containerId string) (*cont
defer stdoutWriter.Close()
for {
msg, err := stream.Recv()
if err != nil {
select {
case <-ctx.Done():
return
}
default:
msg, err := stream.Recv()
if err != nil {
return
}
stdoutWriter.Write(msg.Stdout)
stdoutWriter.Write(msg.Stdout)
}
}
}()
go func() {
defer stdinReader.Close()
buffer := make([]byte, 1024)
for {
n, err := stdinReader.Read(buffer)
if err != nil {
select {
case <-ctx.Done():
return
}
default:
n, err := stdinReader.Read(buffer)
if err != nil {
return
}
if err := stream.Send(&pb.ContainerAttachRequest{
Payload: &pb.ContainerAttachRequest_Stdin{
Stdin: buffer[:n],
},
}); err != nil {
return
if err := stream.Send(&pb.ContainerAttachRequest{
Payload: &pb.ContainerAttachRequest_Stdin{
Stdin: buffer[:n],
},
}); err != nil {
return
}
}
}
}()
@@ -412,76 +434,101 @@ func (c *Client) ContainerAttach(ctx context.Context, containerId string) (*cont
}, nil
}
func (c *Client) ContainerExec(ctx context.Context, containerId string, cmd []string) (*container.ExecSession, error) {
func (c *Client) Exec(ctx context.Context, containerId string, cmd []string, events container.ExecEventReader, stdout io.Writer) error {
stream, err := c.client.ContainerExec(ctx)
if err != nil {
return nil, err
return err
}
if err = stream.Send(&pb.ContainerExecRequest{
ContainerId: containerId,
Command: cmd,
}); err != nil {
return nil, err
return err
}
stdoutReader, stdoutWriter := io.Pipe()
stdinReader, stdinWriter := io.Pipe()
go func() {
defer stdoutWriter.Close()
var wg sync.WaitGroup
// Read from gRPC stream and write to stdout
wg.Go(func() {
for {
msg, err := stream.Recv()
if err != nil {
return
}
stdoutWriter.Write(msg.Stdout)
stdout.Write(msg.Stdout)
}
}()
go func() {
buffer := make([]byte, 1024)
})
// Read events and convert to gRPC messages
wg.Go(func() {
for {
n, err := stdinReader.Read(buffer)
event, err := events.ReadEvent()
if err != nil {
return
}
if err := stream.Send(&pb.ContainerExecRequest{
Payload: &pb.ContainerExecRequest_Stdin{
Stdin: buffer[:n],
},
}); err != nil {
return
switch event.Type {
case "userinput":
stream.Send(&pb.ContainerExecRequest{
Payload: &pb.ContainerExecRequest_Stdin{
Stdin: []byte(event.Data),
},
})
case "resize":
stream.Send(&pb.ContainerExecRequest{
Payload: &pb.ContainerExecRequest_Resize{
Resize: &pb.ResizePayload{
Width: uint32(event.Width),
Height: uint32(event.Height),
},
},
})
}
}
}()
})
// Create resize closure that sends via gRPC
resizeFn := func(width uint, height uint) error {
return stream.Send(&pb.ContainerExecRequest{
Payload: &pb.ContainerExecRequest_Resize{
Resize: &pb.ResizePayload{
Width: uint32(width),
Height: uint32(height),
},
},
})
}
return &container.ExecSession{
Writer: stdinWriter,
Reader: stdoutReader,
Resize: resizeFn,
}, nil
wg.Wait()
return nil
}
func (c *Client) Close() error {
return c.conn.Close()
}
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{
Id: int32(sub.ID),
Name: sub.Name,
Enabled: sub.Enabled,
DispatcherId: int32(sub.DispatcherID),
LogExpression: sub.LogExpression,
ContainerExpression: sub.ContainerExpression,
}
}
pbDispatchers := make([]*pb.NotificationDispatcher, len(dispatchers))
for i, d := range dispatchers {
pbDispatchers[i] = &pb.NotificationDispatcher{
Id: int32(d.ID),
Name: d.Name,
Type: d.Type,
Url: d.URL,
Template: d.Template,
}
}
_, err := c.client.UpdateNotificationConfig(ctx, &pb.UpdateNotificationConfigRequest{
Subscriptions: pbSubs,
Dispatchers: pbDispatchers,
})
return err
}
func jsonBytesToOrderedMap(b []byte) *orderedmap.OrderedMap[string, any] {
var data *orderedmap.OrderedMap[string, any]
reader := bytes.NewReader(b)
+55 -45
View File
@@ -13,7 +13,6 @@ import (
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/internal/utils"
"github.com/docker/docker/api/types/system"
"github.com/go-faker/faker/v4"
"github.com/go-faker/faker/v4/pkg/options"
"github.com/stretchr/testify/assert"
@@ -27,58 +26,67 @@ const bufSize = 1024 * 1024
var lis *bufconn.Listener
var certs tls.Certificate
var client *MockedClient
var mockService *MockedClientService
type MockedClient struct {
type MockedClientService struct {
mock.Mock
container.Client
}
func (m *MockedClient) FindContainer(ctx context.Context, id string) (container.Container, error) {
args := m.Called(ctx, id)
func (m *MockedClientService) FindContainer(ctx context.Context, id string, labels container.ContainerLabels) (container.Container, error) {
args := m.Called(ctx, id, labels)
return args.Get(0).(container.Container), args.Error(1)
}
func (m *MockedClient) ContainerActions(ctx context.Context, action container.ContainerAction, containerID string) error {
args := m.Called(ctx, action, containerID)
return args.Error(0)
}
func (m *MockedClient) ContainerEvents(ctx context.Context, events chan<- container.ContainerEvent) error {
args := m.Called(ctx, events)
return args.Error(0)
}
func (m *MockedClient) ListContainers(ctx context.Context, filter container.ContainerLabels) ([]container.Container, error) {
func (m *MockedClientService) ListContainers(ctx context.Context, filter container.ContainerLabels) ([]container.Container, error) {
args := m.Called(ctx, filter)
return args.Get(0).([]container.Container), args.Error(1)
}
func (m *MockedClient) ContainerLogs(ctx context.Context, id string, since time.Time, stdType container.StdType) (io.ReadCloser, error) {
args := m.Called(ctx, id, since, stdType)
func (m *MockedClientService) Host(ctx context.Context) (container.Host, error) {
args := m.Called(ctx)
return args.Get(0).(container.Host), args.Error(1)
}
func (m *MockedClientService) ContainerAction(ctx context.Context, c container.Container, action container.ContainerAction) error {
args := m.Called(ctx, c, action)
return args.Error(0)
}
func (m *MockedClientService) LogsBetweenDates(ctx context.Context, c container.Container, from time.Time, to time.Time, stdTypes container.StdType) (<-chan *container.LogEvent, error) {
args := m.Called(ctx, c, from, to, stdTypes)
return args.Get(0).(<-chan *container.LogEvent), args.Error(1)
}
func (m *MockedClientService) RawLogs(ctx context.Context, c container.Container, from time.Time, to time.Time, stdTypes container.StdType) (io.ReadCloser, error) {
args := m.Called(ctx, c, from, to, stdTypes)
return args.Get(0).(io.ReadCloser), args.Error(1)
}
func (m *MockedClient) ContainerStats(context.Context, string, chan<- container.ContainerStat) error {
return nil
func (m *MockedClientService) SubscribeStats(ctx context.Context, stats chan<- container.ContainerStat) {
m.Called(ctx, stats)
}
func (m *MockedClient) ContainerLogsBetweenDates(ctx context.Context, id string, from time.Time, to time.Time, stdType container.StdType) (io.ReadCloser, error) {
args := m.Called(ctx, id, from, to, stdType)
return args.Get(0).(io.ReadCloser), args.Error(1)
func (m *MockedClientService) SubscribeEvents(ctx context.Context, events chan<- container.ContainerEvent) {
m.Called(ctx, events)
}
func (m *MockedClient) Host() container.Host {
args := m.Called()
return args.Get(0).(container.Host)
func (m *MockedClientService) SubscribeContainersStarted(ctx context.Context, containers chan<- container.Container) {
m.Called(ctx, containers)
}
func (m *MockedClient) IsSwarmMode() bool {
return false
func (m *MockedClientService) StreamLogs(ctx context.Context, c container.Container, from time.Time, stdTypes container.StdType, events chan<- *container.LogEvent) error {
args := m.Called(ctx, c, from, stdTypes, events)
return args.Error(0)
}
func (m *MockedClient) SystemInfo() system.Info {
return system.Info{ID: "123"}
func (m *MockedClientService) Attach(ctx context.Context, c container.Container, events container.ExecEventReader, stdout io.Writer) error {
args := m.Called(ctx, c, events, stdout)
return args.Error(0)
}
func (m *MockedClientService) Exec(ctx context.Context, c container.Container, cmd []string, events container.ExecEventReader, stdout io.Writer) error {
args := m.Called(ctx, c, cmd, events, stdout)
return args.Error(0)
}
var wantedContainer = container.Container{}
@@ -103,28 +111,30 @@ func init() {
panic(err)
}
client = &MockedClient{}
client.On("ListContainers", mock.Anything, mock.Anything).Return([]container.Container{
{
ID: "123456",
Name: "test",
Host: "localhost",
State: "running",
},
mockService = &MockedClientService{}
mockService.On("ListContainers", mock.Anything, mock.Anything).Return([]container.Container{
wantedContainer,
}, nil)
client.On("Host").Return(container.Host{
mockService.On("Host", mock.Anything).Return(container.Host{
ID: "localhost",
Endpoint: "local",
Name: "local",
})
}, nil)
client.On("ContainerEvents", mock.Anything, mock.AnythingOfType("chan<- container.ContainerEvent")).Return(nil).Run(func(args mock.Arguments) {
mockService.On("SubscribeEvents", mock.Anything, mock.AnythingOfType("chan<- container.ContainerEvent")).Return().Run(func(args mock.Arguments) {
time.Sleep(5 * time.Second)
})
client.On("FindContainer", mock.Anything, "123456").Return(wantedContainer, nil)
server, _ := NewServer(client, certs, "test", container.ContainerLabels{})
mockService.On("SubscribeStats", mock.Anything, mock.AnythingOfType("chan<- container.ContainerStat")).Return()
mockService.On("SubscribeContainersStarted", mock.Anything, mock.AnythingOfType("chan<- container.Container")).Return()
mockService.On("FindContainer", mock.Anything, "123456", mock.Anything).Return(wantedContainer, nil)
mockService.On("Client").Return(nil)
server, _ := NewServer(mockService, certs, "test", nil)
go server.Serve(lis)
}
@@ -138,7 +148,7 @@ func TestFindContainer(t *testing.T) {
t.Fatal(err)
}
c, _ := rpc.FindContainer(context.Background(), "123456")
c, _ := rpc.FindContainer(context.Background(), "123456", container.ContainerLabels{})
assert.Equal(t, wantedContainer, c)
}
+187 -86
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.33.3
// protoc v6.33.4
// source: rpc.proto
package pb
@@ -1270,11 +1270,99 @@ func (x *ContainerAttachResponse) GetStdout() []byte {
return nil
}
type UpdateNotificationConfigRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Subscriptions []*NotificationSubscription `protobuf:"bytes,1,rep,name=subscriptions,proto3" json:"subscriptions,omitempty"`
Dispatchers []*NotificationDispatcher `protobuf:"bytes,2,rep,name=dispatchers,proto3" json:"dispatchers,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateNotificationConfigRequest) Reset() {
*x = UpdateNotificationConfigRequest{}
mi := &file_rpc_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpdateNotificationConfigRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateNotificationConfigRequest) ProtoMessage() {}
func (x *UpdateNotificationConfigRequest) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[25]
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 UpdateNotificationConfigRequest.ProtoReflect.Descriptor instead.
func (*UpdateNotificationConfigRequest) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{25}
}
func (x *UpdateNotificationConfigRequest) GetSubscriptions() []*NotificationSubscription {
if x != nil {
return x.Subscriptions
}
return nil
}
func (x *UpdateNotificationConfigRequest) GetDispatchers() []*NotificationDispatcher {
if x != nil {
return x.Dispatchers
}
return nil
}
type UpdateNotificationConfigResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateNotificationConfigResponse) Reset() {
*x = UpdateNotificationConfigResponse{}
mi := &file_rpc_proto_msgTypes[26]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpdateNotificationConfigResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateNotificationConfigResponse) ProtoMessage() {}
func (x *UpdateNotificationConfigResponse) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[26]
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 UpdateNotificationConfigResponse.ProtoReflect.Descriptor instead.
func (*UpdateNotificationConfigResponse) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{26}
}
var File_rpc_proto protoreflect.FileDescriptor
const file_rpc_proto_rawDesc = "" +
"\n" +
"\trpc.proto\x12\bprotobuf\x1a\vtypes.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xb1\x01\n" +
"\trpc.proto\x12\bprotobuf\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\vtypes.proto\"\xb1\x01\n" +
"\x15ListContainersRequest\x12C\n" +
"\x06filter\x18\x01 \x03(\v2+.protobuf.ListContainersRequest.FilterEntryR\x06filter\x1aS\n" +
"\vFilterEntry\x12\x10\n" +
@@ -1345,7 +1433,11 @@ const file_rpc_proto_rawDesc = "" +
"\x06resize\x18\x03 \x01(\v2\x17.protobuf.ResizePayloadH\x00R\x06resizeB\t\n" +
"\apayload\"1\n" +
"\x17ContainerAttachResponse\x12\x16\n" +
"\x06stdout\x18\x01 \x01(\fR\x06stdout2\xa1\b\n" +
"\x06stdout\x18\x01 \x01(\fR\x06stdout\"\xaf\x01\n" +
"\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" +
"\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" +
@@ -1359,7 +1451,8 @@ const file_rpc_proto_rawDesc = "" +
"\bHostInfo\x12\x19.protobuf.HostInfoRequest\x1a\x1a.protobuf.HostInfoResponse\"\x00\x12X\n" +
"\x0fContainerAction\x12 .protobuf.ContainerActionRequest\x1a!.protobuf.ContainerActionResponse\"\x00\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\x01B\x13Z\x11internal/agent/pbb\x06proto3"
"\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"
var (
file_rpc_proto_rawDescOnce sync.Once
@@ -1373,92 +1466,100 @@ func file_rpc_proto_rawDescGZIP() []byte {
return file_rpc_proto_rawDescData
}
var file_rpc_proto_msgTypes = make([]protoimpl.MessageInfo, 27)
var file_rpc_proto_msgTypes = make([]protoimpl.MessageInfo, 29)
var file_rpc_proto_goTypes = []any{
(*ListContainersRequest)(nil), // 0: protobuf.ListContainersRequest
(*RepeatedString)(nil), // 1: protobuf.RepeatedString
(*ListContainersResponse)(nil), // 2: protobuf.ListContainersResponse
(*FindContainerRequest)(nil), // 3: protobuf.FindContainerRequest
(*FindContainerResponse)(nil), // 4: protobuf.FindContainerResponse
(*StreamLogsRequest)(nil), // 5: protobuf.StreamLogsRequest
(*StreamLogsResponse)(nil), // 6: protobuf.StreamLogsResponse
(*LogsBetweenDatesRequest)(nil), // 7: protobuf.LogsBetweenDatesRequest
(*StreamRawBytesRequest)(nil), // 8: protobuf.StreamRawBytesRequest
(*StreamRawBytesResponse)(nil), // 9: protobuf.StreamRawBytesResponse
(*StreamEventsRequest)(nil), // 10: protobuf.StreamEventsRequest
(*StreamEventsResponse)(nil), // 11: protobuf.StreamEventsResponse
(*StreamStatsRequest)(nil), // 12: protobuf.StreamStatsRequest
(*StreamStatsResponse)(nil), // 13: protobuf.StreamStatsResponse
(*HostInfoRequest)(nil), // 14: protobuf.HostInfoRequest
(*HostInfoResponse)(nil), // 15: protobuf.HostInfoResponse
(*StreamContainerStartedRequest)(nil), // 16: protobuf.StreamContainerStartedRequest
(*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
nil, // 25: protobuf.ListContainersRequest.FilterEntry
nil, // 26: protobuf.FindContainerRequest.FilterEntry
(*Container)(nil), // 27: protobuf.Container
(*timestamppb.Timestamp)(nil), // 28: google.protobuf.Timestamp
(*LogEvent)(nil), // 29: protobuf.LogEvent
(*ContainerEvent)(nil), // 30: protobuf.ContainerEvent
(*ContainerStat)(nil), // 31: protobuf.ContainerStat
(*Host)(nil), // 32: protobuf.Host
(ContainerAction)(0), // 33: protobuf.ContainerAction
(*ListContainersRequest)(nil), // 0: protobuf.ListContainersRequest
(*RepeatedString)(nil), // 1: protobuf.RepeatedString
(*ListContainersResponse)(nil), // 2: protobuf.ListContainersResponse
(*FindContainerRequest)(nil), // 3: protobuf.FindContainerRequest
(*FindContainerResponse)(nil), // 4: protobuf.FindContainerResponse
(*StreamLogsRequest)(nil), // 5: protobuf.StreamLogsRequest
(*StreamLogsResponse)(nil), // 6: protobuf.StreamLogsResponse
(*LogsBetweenDatesRequest)(nil), // 7: protobuf.LogsBetweenDatesRequest
(*StreamRawBytesRequest)(nil), // 8: protobuf.StreamRawBytesRequest
(*StreamRawBytesResponse)(nil), // 9: protobuf.StreamRawBytesResponse
(*StreamEventsRequest)(nil), // 10: protobuf.StreamEventsRequest
(*StreamEventsResponse)(nil), // 11: protobuf.StreamEventsResponse
(*StreamStatsRequest)(nil), // 12: protobuf.StreamStatsRequest
(*StreamStatsResponse)(nil), // 13: protobuf.StreamStatsResponse
(*HostInfoRequest)(nil), // 14: protobuf.HostInfoRequest
(*HostInfoResponse)(nil), // 15: protobuf.HostInfoResponse
(*StreamContainerStartedRequest)(nil), // 16: protobuf.StreamContainerStartedRequest
(*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
}
var file_rpc_proto_depIdxs = []int32{
25, // 0: protobuf.ListContainersRequest.filter:type_name -> protobuf.ListContainersRequest.FilterEntry
27, // 1: protobuf.ListContainersResponse.containers:type_name -> protobuf.Container
26, // 2: protobuf.FindContainerRequest.filter:type_name -> protobuf.FindContainerRequest.FilterEntry
27, // 3: protobuf.FindContainerResponse.container:type_name -> protobuf.Container
28, // 4: protobuf.StreamLogsRequest.since:type_name -> google.protobuf.Timestamp
29, // 5: protobuf.StreamLogsResponse.event:type_name -> protobuf.LogEvent
28, // 6: protobuf.LogsBetweenDatesRequest.since:type_name -> google.protobuf.Timestamp
28, // 7: protobuf.LogsBetweenDatesRequest.until:type_name -> google.protobuf.Timestamp
28, // 8: protobuf.StreamRawBytesRequest.since:type_name -> google.protobuf.Timestamp
28, // 9: protobuf.StreamRawBytesRequest.until:type_name -> google.protobuf.Timestamp
30, // 10: protobuf.StreamEventsResponse.event:type_name -> protobuf.ContainerEvent
31, // 11: protobuf.StreamStatsResponse.stat:type_name -> protobuf.ContainerStat
32, // 12: protobuf.HostInfoResponse.host:type_name -> protobuf.Host
27, // 13: protobuf.StreamContainerStartedResponse.container:type_name -> protobuf.Container
33, // 14: protobuf.ContainerActionRequest.action:type_name -> protobuf.ContainerAction
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
1, // 17: protobuf.ListContainersRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
1, // 18: protobuf.FindContainerRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
0, // 19: protobuf.AgentService.ListContainers:input_type -> protobuf.ListContainersRequest
3, // 20: protobuf.AgentService.FindContainer:input_type -> protobuf.FindContainerRequest
5, // 21: protobuf.AgentService.StreamLogs:input_type -> protobuf.StreamLogsRequest
7, // 22: protobuf.AgentService.LogsBetweenDates:input_type -> protobuf.LogsBetweenDatesRequest
8, // 23: protobuf.AgentService.StreamRawBytes:input_type -> protobuf.StreamRawBytesRequest
10, // 24: protobuf.AgentService.StreamEvents:input_type -> protobuf.StreamEventsRequest
12, // 25: protobuf.AgentService.StreamStats:input_type -> protobuf.StreamStatsRequest
16, // 26: protobuf.AgentService.StreamContainerStarted:input_type -> protobuf.StreamContainerStartedRequest
14, // 27: protobuf.AgentService.HostInfo:input_type -> protobuf.HostInfoRequest
18, // 28: protobuf.AgentService.ContainerAction:input_type -> protobuf.ContainerActionRequest
20, // 29: protobuf.AgentService.ContainerExec:input_type -> protobuf.ContainerExecRequest
23, // 30: protobuf.AgentService.ContainerAttach:input_type -> protobuf.ContainerAttachRequest
2, // 31: protobuf.AgentService.ListContainers:output_type -> protobuf.ListContainersResponse
4, // 32: protobuf.AgentService.FindContainer:output_type -> protobuf.FindContainerResponse
6, // 33: protobuf.AgentService.StreamLogs:output_type -> protobuf.StreamLogsResponse
6, // 34: protobuf.AgentService.LogsBetweenDates:output_type -> protobuf.StreamLogsResponse
9, // 35: protobuf.AgentService.StreamRawBytes:output_type -> protobuf.StreamRawBytesResponse
11, // 36: protobuf.AgentService.StreamEvents:output_type -> protobuf.StreamEventsResponse
13, // 37: protobuf.AgentService.StreamStats:output_type -> protobuf.StreamStatsResponse
17, // 38: protobuf.AgentService.StreamContainerStarted:output_type -> protobuf.StreamContainerStartedResponse
15, // 39: protobuf.AgentService.HostInfo:output_type -> protobuf.HostInfoResponse
19, // 40: protobuf.AgentService.ContainerAction:output_type -> protobuf.ContainerActionResponse
22, // 41: protobuf.AgentService.ContainerExec:output_type -> protobuf.ContainerExecResponse
24, // 42: protobuf.AgentService.ContainerAttach:output_type -> protobuf.ContainerAttachResponse
31, // [31:43] is the sub-list for method output_type
19, // [19:31] is the sub-list for method input_type
19, // [19:19] is the sub-list for extension type_name
19, // [19:19] is the sub-list for extension extendee
0, // [0:19] is the sub-list for field type_name
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
}
func init() { file_rpc_proto_init() }
@@ -1481,7 +1582,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: 27,
NumMessages: 29,
NumExtensions: 0,
NumServices: 1,
},
+51 -13
View File
@@ -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.3
// - protoc v6.33.4
// source: rpc.proto
package pb
@@ -19,18 +19,19 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
AgentService_ListContainers_FullMethodName = "/protobuf.AgentService/ListContainers"
AgentService_FindContainer_FullMethodName = "/protobuf.AgentService/FindContainer"
AgentService_StreamLogs_FullMethodName = "/protobuf.AgentService/StreamLogs"
AgentService_LogsBetweenDates_FullMethodName = "/protobuf.AgentService/LogsBetweenDates"
AgentService_StreamRawBytes_FullMethodName = "/protobuf.AgentService/StreamRawBytes"
AgentService_StreamEvents_FullMethodName = "/protobuf.AgentService/StreamEvents"
AgentService_StreamStats_FullMethodName = "/protobuf.AgentService/StreamStats"
AgentService_StreamContainerStarted_FullMethodName = "/protobuf.AgentService/StreamContainerStarted"
AgentService_HostInfo_FullMethodName = "/protobuf.AgentService/HostInfo"
AgentService_ContainerAction_FullMethodName = "/protobuf.AgentService/ContainerAction"
AgentService_ContainerExec_FullMethodName = "/protobuf.AgentService/ContainerExec"
AgentService_ContainerAttach_FullMethodName = "/protobuf.AgentService/ContainerAttach"
AgentService_ListContainers_FullMethodName = "/protobuf.AgentService/ListContainers"
AgentService_FindContainer_FullMethodName = "/protobuf.AgentService/FindContainer"
AgentService_StreamLogs_FullMethodName = "/protobuf.AgentService/StreamLogs"
AgentService_LogsBetweenDates_FullMethodName = "/protobuf.AgentService/LogsBetweenDates"
AgentService_StreamRawBytes_FullMethodName = "/protobuf.AgentService/StreamRawBytes"
AgentService_StreamEvents_FullMethodName = "/protobuf.AgentService/StreamEvents"
AgentService_StreamStats_FullMethodName = "/protobuf.AgentService/StreamStats"
AgentService_StreamContainerStarted_FullMethodName = "/protobuf.AgentService/StreamContainerStarted"
AgentService_HostInfo_FullMethodName = "/protobuf.AgentService/HostInfo"
AgentService_ContainerAction_FullMethodName = "/protobuf.AgentService/ContainerAction"
AgentService_ContainerExec_FullMethodName = "/protobuf.AgentService/ContainerExec"
AgentService_ContainerAttach_FullMethodName = "/protobuf.AgentService/ContainerAttach"
AgentService_UpdateNotificationConfig_FullMethodName = "/protobuf.AgentService/UpdateNotificationConfig"
)
// AgentServiceClient is the client API for AgentService service.
@@ -49,6 +50,7 @@ type AgentServiceClient interface {
ContainerAction(ctx context.Context, in *ContainerActionRequest, opts ...grpc.CallOption) (*ContainerActionResponse, 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)
}
type agentServiceClient struct {
@@ -239,6 +241,16 @@ func (c *agentServiceClient) ContainerAttach(ctx context.Context, opts ...grpc.C
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type AgentService_ContainerAttachClient = grpc.BidiStreamingClient[ContainerAttachRequest, ContainerAttachResponse]
func (c *agentServiceClient) UpdateNotificationConfig(ctx context.Context, in *UpdateNotificationConfigRequest, opts ...grpc.CallOption) (*UpdateNotificationConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateNotificationConfigResponse)
err := c.cc.Invoke(ctx, AgentService_UpdateNotificationConfig_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.
@@ -255,6 +267,7 @@ type AgentServiceServer interface {
ContainerAction(context.Context, *ContainerActionRequest) (*ContainerActionResponse, error)
ContainerExec(grpc.BidiStreamingServer[ContainerExecRequest, ContainerExecResponse]) error
ContainerAttach(grpc.BidiStreamingServer[ContainerAttachRequest, ContainerAttachResponse]) error
UpdateNotificationConfig(context.Context, *UpdateNotificationConfigRequest) (*UpdateNotificationConfigResponse, error)
mustEmbedUnimplementedAgentServiceServer()
}
@@ -301,6 +314,9 @@ func (UnimplementedAgentServiceServer) ContainerExec(grpc.BidiStreamingServer[Co
func (UnimplementedAgentServiceServer) ContainerAttach(grpc.BidiStreamingServer[ContainerAttachRequest, ContainerAttachResponse]) error {
return status.Error(codes.Unimplemented, "method ContainerAttach not implemented")
}
func (UnimplementedAgentServiceServer) UpdateNotificationConfig(context.Context, *UpdateNotificationConfigRequest) (*UpdateNotificationConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateNotificationConfig not implemented")
}
func (UnimplementedAgentServiceServer) mustEmbedUnimplementedAgentServiceServer() {}
func (UnimplementedAgentServiceServer) testEmbeddedByValue() {}
@@ -474,6 +490,24 @@ func _AgentService_ContainerAttach_Handler(srv interface{}, stream grpc.ServerSt
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type AgentService_ContainerAttachServer = grpc.BidiStreamingServer[ContainerAttachRequest, ContainerAttachResponse]
func _AgentService_UpdateNotificationConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateNotificationConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServiceServer).UpdateNotificationConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AgentService_UpdateNotificationConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServiceServer).UpdateNotificationConfig(ctx, req.(*UpdateNotificationConfigRequest))
}
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)
@@ -497,6 +531,10 @@ var AgentService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ContainerAction",
Handler: _AgentService_ContainerAction_Handler,
},
{
MethodName: "UpdateNotificationConfig",
Handler: _AgentService_UpdateNotificationConfig_Handler,
},
},
Streams: []grpc.StreamDesc{
{
+201 -26
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.33.3
// protoc v6.33.4
// source: types.proto
package pb
@@ -820,6 +820,166 @@ func (x *Host) GetDockerVersion() string {
return ""
}
type NotificationSubscription struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
Enabled bool `protobuf:"varint,3,opt,name=enabled,proto3" json:"enabled,omitempty"`
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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NotificationSubscription) Reset() {
*x = NotificationSubscription{}
mi := &file_types_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NotificationSubscription) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NotificationSubscription) ProtoMessage() {}
func (x *NotificationSubscription) ProtoReflect() protoreflect.Message {
mi := &file_types_proto_msgTypes[9]
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 NotificationSubscription.ProtoReflect.Descriptor instead.
func (*NotificationSubscription) Descriptor() ([]byte, []int) {
return file_types_proto_rawDescGZIP(), []int{9}
}
func (x *NotificationSubscription) GetId() int32 {
if x != nil {
return x.Id
}
return 0
}
func (x *NotificationSubscription) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *NotificationSubscription) GetEnabled() bool {
if x != nil {
return x.Enabled
}
return false
}
func (x *NotificationSubscription) GetDispatcherId() int32 {
if x != nil {
return x.DispatcherId
}
return 0
}
func (x *NotificationSubscription) GetLogExpression() string {
if x != nil {
return x.LogExpression
}
return ""
}
func (x *NotificationSubscription) GetContainerExpression() string {
if x != nil {
return x.ContainerExpression
}
return ""
}
type NotificationDispatcher struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NotificationDispatcher) Reset() {
*x = NotificationDispatcher{}
mi := &file_types_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NotificationDispatcher) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NotificationDispatcher) ProtoMessage() {}
func (x *NotificationDispatcher) ProtoReflect() protoreflect.Message {
mi := &file_types_proto_msgTypes[10]
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 NotificationDispatcher.ProtoReflect.Descriptor instead.
func (*NotificationDispatcher) Descriptor() ([]byte, []int) {
return file_types_proto_rawDescGZIP(), []int{10}
}
func (x *NotificationDispatcher) GetId() int32 {
if x != nil {
return x.Id
}
return 0
}
func (x *NotificationDispatcher) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *NotificationDispatcher) GetType() string {
if x != nil {
return x.Type
}
return ""
}
func (x *NotificationDispatcher) GetUrl() string {
if x != nil {
return x.Url
}
return ""
}
func (x *NotificationDispatcher) GetTemplate() string {
if x != nil {
return x.Template
}
return ""
}
var File_types_proto protoreflect.FileDescriptor
const file_types_proto_rawDesc = "" +
@@ -898,7 +1058,20 @@ 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*3\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xd4\x01\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" +
"\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" +
"\x0fContainerAction\x12\t\n" +
"\x05Start\x10\x00\x12\b\n" +
"\x04Stop\x10\x01\x12\v\n" +
@@ -917,34 +1090,36 @@ func file_types_proto_rawDescGZIP() []byte {
}
var file_types_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_types_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_types_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
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
nil, // 10: protobuf.Container.LabelsEntry
nil, // 11: protobuf.Host.LabelsEntry
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
(*anypb.Any)(nil), // 13: 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
nil, // 12: protobuf.Container.LabelsEntry
nil, // 13: protobuf.Host.LabelsEntry
(*timestamppb.Timestamp)(nil), // 14: google.protobuf.Timestamp
(*anypb.Any)(nil), // 15: google.protobuf.Any
}
var file_types_proto_depIdxs = []int32{
12, // 0: protobuf.Container.created:type_name -> google.protobuf.Timestamp
12, // 1: protobuf.Container.started:type_name -> google.protobuf.Timestamp
10, // 2: protobuf.Container.labels:type_name -> protobuf.Container.LabelsEntry
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
2, // 3: protobuf.Container.stats:type_name -> protobuf.ContainerStat
12, // 4: protobuf.Container.finished:type_name -> google.protobuf.Timestamp
13, // 5: protobuf.LogEvent.message:type_name -> google.protobuf.Any
12, // 6: protobuf.LogEvent.timestamp:type_name -> google.protobuf.Timestamp
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
3, // 7: protobuf.GroupMessage.fragments:type_name -> protobuf.LogFragment
12, // 8: protobuf.ContainerEvent.timestamp:type_name -> google.protobuf.Timestamp
11, // 9: protobuf.Host.labels:type_name -> protobuf.Host.LabelsEntry
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
@@ -963,7 +1138,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: 11,
NumMessages: 13,
NumExtensions: 0,
NumServices: 0,
},
+176 -142
View File
@@ -6,7 +6,7 @@ import (
"crypto/tls"
"crypto/x509"
"fmt"
"sync"
"io"
"encoding/json"
@@ -14,7 +14,7 @@ import (
"github.com/amir20/dozzle/internal/agent/pb"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/internal/docker"
"github.com/amir20/dozzle/types"
"github.com/rs/zerolog/log"
orderedmap "github.com/wk8/go-ordered-map/v2"
"google.golang.org/grpc"
@@ -26,21 +26,40 @@ import (
"google.golang.org/grpc/status"
)
// NotificationConfigHandler handles notification config updates received from the main server
type NotificationConfigHandler interface {
HandleNotificationConfig(subscriptions []types.SubscriptionConfig, dispatchers []types.DispatcherConfig) error
}
// ClientService is the interface for container operations used by the agent server
type ClientService interface {
FindContainer(ctx context.Context, id string, labels container.ContainerLabels) (container.Container, error)
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
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)
SubscribeEvents(context.Context, chan<- container.ContainerEvent)
SubscribeContainersStarted(context.Context, chan<- container.Container)
StreamLogs(context.Context, container.Container, time.Time, container.StdType, chan<- *container.LogEvent) error
Attach(context.Context, container.Container, container.ExecEventReader, io.Writer) error
Exec(context.Context, container.Container, []string, container.ExecEventReader, io.Writer) error
}
type server struct {
client container.Client
store *container.ContainerStore
version string
service ClientService
version string
notificationConfigHandler NotificationConfigHandler
pb.UnimplementedAgentServiceServer
}
func newServer(client container.Client, dozzleVersion string, labels container.ContainerLabels) pb.AgentServiceServer {
statsCollector := docker.NewDockerStatsCollector(client, labels)
func newServer(service ClientService, dozzleVersion string, notificationHandler NotificationConfigHandler) pb.AgentServiceServer {
return &server{
client: client,
version: dozzleVersion,
store: container.NewContainerStore(context.Background(), client, statsCollector, labels),
service: service,
version: dozzleVersion,
notificationConfigHandler: notificationHandler,
}
}
@@ -50,20 +69,18 @@ func (s *server) StreamLogs(in *pb.StreamLogsRequest, out pb.AgentService_Stream
since = in.Since.AsTime()
}
c, err := s.store.FindContainer(in.ContainerId, container.ContainerLabels{})
c, err := s.service.FindContainer(out.Context(), in.ContainerId, container.ContainerLabels{})
if err != nil {
return err
}
reader, err := s.client.ContainerLogs(out.Context(), in.ContainerId, since, container.StdType(in.StreamTypes))
if err != nil {
return err
}
events := make(chan *container.LogEvent)
go func() {
defer close(events)
s.service.StreamLogs(out.Context(), c, since, container.StdType(in.StreamTypes), events)
}()
dockerReader := docker.NewLogReader(reader, c.Tty)
g := container.NewEventGenerator(out.Context(), dockerReader, c)
for event := range g.Events {
for event := range events {
if event != nil {
out.Send(&pb.StreamLogsResponse{
Event: logEventToPb(event),
@@ -71,31 +88,23 @@ func (s *server) StreamLogs(in *pb.StreamLogsRequest, out pb.AgentService_Stream
}
}
select {
case e := <-g.Errors:
return e
default:
return nil
}
return nil
}
func (s *server) LogsBetweenDates(in *pb.LogsBetweenDatesRequest, out pb.AgentService_LogsBetweenDatesServer) error {
reader, err := s.client.ContainerLogsBetweenDates(out.Context(), in.ContainerId, in.Since.AsTime(), in.Until.AsTime(), container.StdType(in.StreamTypes))
c, err := s.service.FindContainer(out.Context(), in.ContainerId, container.ContainerLabels{})
if err != nil {
return err
}
c, err := s.client.FindContainer(out.Context(), in.ContainerId)
events, err := s.service.LogsBetweenDates(out.Context(), c, in.Since.AsTime(), in.Until.AsTime(), container.StdType(in.StreamTypes))
if err != nil {
return err
}
dockerReader := docker.NewLogReader(reader, c.Tty)
g := container.NewEventGenerator(out.Context(), dockerReader, c)
for {
select {
case event, ok := <-g.Events:
case event, ok := <-events:
if !ok {
// Channel closed, exit cleanly
return nil
@@ -103,8 +112,6 @@ func (s *server) LogsBetweenDates(in *pb.LogsBetweenDatesRequest, out pb.AgentSe
out.Send(&pb.StreamLogsResponse{
Event: logEventToPb(event),
})
case e := <-g.Errors:
return e
case <-out.Context().Done():
return nil
}
@@ -112,16 +119,24 @@ func (s *server) LogsBetweenDates(in *pb.LogsBetweenDatesRequest, out pb.AgentSe
}
func (s *server) StreamRawBytes(in *pb.StreamRawBytesRequest, out pb.AgentService_StreamRawBytesServer) error {
reader, err := s.client.ContainerLogsBetweenDates(out.Context(), in.ContainerId, in.Since.AsTime(), in.Until.AsTime(), container.StdType(in.StreamTypes))
c, err := s.service.FindContainer(out.Context(), in.ContainerId, container.ContainerLabels{})
if err != nil {
return err
}
reader, err := s.service.RawLogs(out.Context(), c, in.Since.AsTime(), in.Until.AsTime(), container.StdType(in.StreamTypes))
if err != nil {
return err
}
defer reader.Close()
buf := make([]byte, 1024)
for {
n, err := reader.Read(buf)
if err != nil {
if err == io.EOF {
return nil
}
return err
}
@@ -142,7 +157,7 @@ func (s *server) StreamRawBytes(in *pb.StreamRawBytesRequest, out pb.AgentServic
func (s *server) StreamEvents(in *pb.StreamEventsRequest, out pb.AgentService_StreamEventsServer) error {
events := make(chan container.ContainerEvent)
s.store.SubscribeEvents(out.Context(), events)
s.service.SubscribeEvents(out.Context(), events)
for {
select {
@@ -164,7 +179,7 @@ func (s *server) StreamEvents(in *pb.StreamEventsRequest, out pb.AgentService_St
func (s *server) StreamStats(in *pb.StreamStatsRequest, out pb.AgentService_StreamStatsServer) error {
stats := make(chan container.ContainerStat)
s.store.SubscribeStats(out.Context(), stats)
s.service.SubscribeStats(out.Context(), stats)
for {
select {
@@ -193,13 +208,13 @@ func (s *server) FindContainer(ctx context.Context, in *pb.FindContainerRequest)
}
}
container, err := s.store.FindContainer(in.ContainerId, labels)
c, err := s.service.FindContainer(ctx, in.ContainerId, labels)
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
c := container.ToProto()
proto := c.ToProto()
return &pb.FindContainerResponse{
Container: &c,
Container: &proto,
}, nil
}
@@ -211,15 +226,15 @@ func (s *server) ListContainers(ctx context.Context, in *pb.ListContainersReques
}
}
containers, err := s.store.ListContainers(labels)
containers, err := s.service.ListContainers(ctx, labels)
if err != nil {
return nil, err
}
var pbContainers []*pb.Container
for _, container := range containers {
c := container.ToProto()
pbContainers = append(pbContainers, &c)
for _, c := range containers {
proto := c.ToProto()
pbContainers = append(pbContainers, &proto)
}
return &pb.ListContainersResponse{
@@ -228,7 +243,10 @@ func (s *server) ListContainers(ctx context.Context, in *pb.ListContainersReques
}
func (s *server) HostInfo(ctx context.Context, in *pb.HostInfoRequest) (*pb.HostInfoResponse, error) {
host := s.client.Host()
host, err := s.service.Host(ctx)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.HostInfoResponse{
Host: &pb.Host{
Id: host.ID,
@@ -244,7 +262,7 @@ func (s *server) HostInfo(ctx context.Context, in *pb.HostInfoRequest) (*pb.Host
func (s *server) StreamContainerStarted(in *pb.StreamContainerStartedRequest, out pb.AgentService_StreamContainerStartedServer) error {
containers := make(chan container.Container)
go s.store.SubscribeNewContainers(out.Context(), containers)
go s.service.SubscribeContainersStarted(out.Context(), containers)
for {
select {
@@ -275,8 +293,12 @@ func (s *server) ContainerAction(ctx context.Context, in *pb.ContainerActionRequ
return nil, status.Error(codes.InvalidArgument, "invalid action")
}
err := s.client.ContainerActions(ctx, action, in.ContainerId)
c, err := s.service.FindContainer(ctx, in.ContainerId, container.ContainerLabels{})
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
err = s.service.ContainerAction(ctx, c, action)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
@@ -284,62 +306,62 @@ func (s *server) ContainerAction(ctx context.Context, in *pb.ContainerActionRequ
return &pb.ContainerActionResponse{}, nil
}
// terminalMessage represents a message from a terminal gRPC stream (exec or attach)
type terminalMessage interface {
GetStdin() []byte
GetResize() *pb.ResizePayload
}
// protoEventReader converts gRPC protobuf messages directly to ExecEvents (no JSON)
type protoEventReader struct {
recv func() (terminalMessage, error)
}
func (r *protoEventReader) ReadEvent() (*container.ExecEvent, error) {
msg, err := r.recv()
if err != nil {
return nil, err
}
if stdin := msg.GetStdin(); stdin != nil {
return &container.ExecEvent{Type: "userinput", Data: string(stdin)}, nil
} else if resize := msg.GetResize(); resize != nil {
return &container.ExecEvent{Type: "resize", Width: uint(resize.Width), Height: uint(resize.Height)}, nil
}
// Skip unknown message types
return r.ReadEvent()
}
// terminalStreamWriter adapts a gRPC terminal stream to io.Writer
type terminalStreamWriter struct {
send func([]byte) error
}
func (w *terminalStreamWriter) Write(p []byte) (int, error) {
if err := w.send(p); err != nil {
return 0, err
}
return len(p), nil
}
func (s *server) ContainerExec(stream pb.AgentService_ContainerExecServer) error {
request, err := stream.Recv()
if err != nil {
return status.Error(codes.Internal, err.Error())
}
cancelCtx, cancel := context.WithCancel(stream.Context())
session, err := s.client.ContainerExec(cancelCtx, request.ContainerId, request.Command)
c, err := s.service.FindContainer(stream.Context(), request.ContainerId, container.ContainerLabels{})
if err != nil {
cancel()
return status.Error(codes.Internal, err.Error())
return status.Error(codes.NotFound, err.Error())
}
var wg sync.WaitGroup
reader := &protoEventReader{recv: func() (terminalMessage, error) { return stream.Recv() }}
writer := &terminalStreamWriter{send: func(p []byte) error { return stream.Send(&pb.ContainerExecResponse{Stdout: p}) }}
// Read from container and send to client
wg.Go(func() {
defer cancel()
buffer := make([]byte, 1024)
for {
n, err := session.Reader.Read(buffer)
if err != nil {
return
}
if err := stream.Send(&pb.ContainerExecResponse{Stdout: buffer[:n]}); err != nil {
return
}
}
})
// Read from client stream and handle stdin/resize
wg.Go(func() {
defer cancel()
defer session.Writer.Close()
for {
req, err := stream.Recv()
if err != nil {
return
}
switch payload := req.Payload.(type) {
case *pb.ContainerExecRequest_Stdin:
if _, err := session.Writer.Write(payload.Stdin); err != nil {
log.Error().Err(err).Msg("error writing stdin to container")
return
}
case *pb.ContainerExecRequest_Resize:
if err := session.Resize(uint(payload.Resize.Width), uint(payload.Resize.Height)); err != nil {
log.Error().Err(err).Msg("error resizing terminal")
}
}
}
})
wg.Wait()
if err := s.service.Exec(stream.Context(), c, request.Command, reader, writer); err != nil {
return status.Error(codes.Internal, err.Error())
}
return nil
}
@@ -350,61 +372,73 @@ func (s *server) ContainerAttach(stream pb.AgentService_ContainerAttachServer) e
return status.Error(codes.Internal, err.Error())
}
cancelCtx, cancel := context.WithCancel(stream.Context())
session, err := s.client.ContainerAttach(cancelCtx, request.ContainerId)
c, err := s.service.FindContainer(stream.Context(), request.ContainerId, container.ContainerLabels{})
if err != nil {
cancel()
return status.Error(codes.Internal, err.Error())
return status.Error(codes.NotFound, err.Error())
}
var wg sync.WaitGroup
reader := &protoEventReader{recv: func() (terminalMessage, error) { return stream.Recv() }}
writer := &terminalStreamWriter{send: func(p []byte) error { return stream.Send(&pb.ContainerAttachResponse{Stdout: p}) }}
// Read from container and send to client
wg.Go(func() {
defer cancel()
buffer := make([]byte, 1024)
for {
n, err := session.Reader.Read(buffer)
if err != nil {
return
}
if err := stream.Send(&pb.ContainerAttachResponse{Stdout: buffer[:n]}); err != nil {
return
}
}
})
// Read from client stream and handle stdin/resize
wg.Go(func() {
defer cancel()
defer session.Writer.Close()
for {
req, err := stream.Recv()
if err != nil {
return
}
switch payload := req.Payload.(type) {
case *pb.ContainerAttachRequest_Stdin:
if _, err := session.Writer.Write(payload.Stdin); err != nil {
log.Error().Err(err).Msg("error writing stdin to container")
return
}
case *pb.ContainerAttachRequest_Resize:
if err := session.Resize(uint(payload.Resize.Width), uint(payload.Resize.Height)); err != nil {
log.Error().Err(err).Msg("error resizing terminal")
}
}
}
})
wg.Wait()
if err := s.service.Attach(stream.Context(), c, reader, writer); err != nil {
return status.Error(codes.Internal, err.Error())
}
return nil
}
func NewServer(client container.Client, certificates tls.Certificate, dozzleVersion string, labels container.ContainerLabels) (*grpc.Server, error) {
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
if len(req.Subscriptions) > maxSubscriptions {
return nil, status.Errorf(codes.InvalidArgument, "too many subscriptions: %d (max %d)", len(req.Subscriptions), maxSubscriptions)
}
if len(req.Dispatchers) > maxDispatchers {
return nil, status.Errorf(codes.InvalidArgument, "too many dispatchers: %d (max %d)", len(req.Dispatchers), maxDispatchers)
}
// Convert proto subscriptions to types
subscriptions := make([]types.SubscriptionConfig, len(req.Subscriptions))
for i, sub := range req.Subscriptions {
subscriptions[i] = types.SubscriptionConfig{
ID: int(sub.Id),
Name: sub.Name,
Enabled: sub.Enabled,
DispatcherID: int(sub.DispatcherId),
LogExpression: sub.LogExpression,
ContainerExpression: sub.ContainerExpression,
}
}
// Convert proto dispatchers to types
dispatchers := make([]types.DispatcherConfig, len(req.Dispatchers))
for i, d := range req.Dispatchers {
dispatchers[i] = types.DispatcherConfig{
ID: int(d.Id),
Name: d.Name,
Type: d.Type,
URL: d.Url,
Template: d.Template,
}
}
// Call the handler (handler is responsible for persisting if needed)
if err := s.notificationConfigHandler.HandleNotificationConfig(subscriptions, dispatchers); err != nil {
log.Error().Err(err).Msg("Failed to handle notification config")
return nil, status.Error(codes.Internal, err.Error())
}
log.Info().Int("subscriptions", len(subscriptions)).Int("dispatchers", len(dispatchers)).Msg("Updated notification config from main server")
return &pb.UpdateNotificationConfigResponse{}, 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])
if err != nil {
@@ -423,7 +457,7 @@ func NewServer(client container.Client, certificates tls.Certificate, dozzleVers
creds := credentials.NewTLS(tlsConfig)
grpcServer := grpc.NewServer(grpc.Creds(creds))
pb.RegisterAgentServiceServer(grpcServer, newServer(client, dozzleVersion, labels))
pb.RegisterAgentServiceServer(grpcServer, newServer(service, dozzleVersion, notificationHandler))
return grpcServer, nil
}
+3 -1
View File
@@ -46,7 +46,9 @@ func (p *proxyAuthContext) AuthMiddleware(next http.Handler) http.Handler {
if r.Header.Get(p.headerUser) != "" {
containerFilter, err := container.ParseContainerFilter(r.Header.Get(p.headerFilter))
if err != nil {
log.Fatal().Str("filter", r.Header.Get(p.headerFilter)).Msg("Failed to parse container filter")
log.Warn().Err(err).Str("filter", r.Header.Get(p.headerFilter)).Msg("Failed to parse container filter")
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
userRoles := All
if strings.TrimSpace(r.Header.Get(p.headerRoles)) != "" {
+56
View File
@@ -0,0 +1,56 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/jwtauth/v5"
"github.com/stretchr/testify/require"
)
func TestForwardProxyAuthRejectsInvalidFilter(t *testing.T) {
auth := NewForwardProxyAuth("Remote-User", "Remote-Email", "Remote-Name", "Remote-Filter", "Remote-Roles")
called := false
handler := auth.AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Remote-User", "alice")
req.Header.Set("Remote-Filter", "invalid-filter")
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
require.False(t, called)
}
func TestUserFromContextInvalidFilterReturnsNil(t *testing.T) {
tokenAuth := jwtauth.New("HS256", []byte("secret"), nil)
_, tokenString, err := tokenAuth.Encode(map[string]interface{}{
"username": "alice",
"email": "alice@example.com",
"name": "Alice",
"filter": "invalid-filter",
})
require.NoError(t, err)
handler := jwtauth.Verifier(tokenAuth)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if UserFromContext(r.Context()) == nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+tokenString)
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
require.Equal(t, http.StatusUnauthorized, resp.Code)
}
+2 -1
View File
@@ -212,7 +212,8 @@ func UserFromContext(ctx context.Context) *User {
if filter, ok := claims["filter"].(string); ok {
containerFilter, err = container.ParseContainerFilter(filter)
if err != nil {
log.Fatal().Err(err).Str("filter", filter).Msg("Failed to parse container filter")
log.Warn().Err(err).Str("filter", filter).Msg("Failed to parse container filter")
return nil
}
}
roles := None
+5
View File
@@ -41,6 +41,11 @@ type ExecEvent struct {
Height uint `json:"height,omitempty"`
}
// ExecEventReader provides structured exec events (userinput, resize)
type ExecEventReader interface {
ReadEvent() (*ExecEvent, error)
}
type Client interface {
ListContainers(context.Context, ContainerLabels) ([]Container, error)
FindContainer(context.Context, string) (Container, error)
+42 -37
View File
@@ -171,47 +171,52 @@ func (s *ContainerStore) FindContainer(id string, labels ContainerLabels) (Conta
}
}
if container, ok := s.containers.Load(id); ok {
if !container.FullyLoaded {
log.Debug().Str("id", id).Msg("container is not fully loaded, fetching it")
if newContainer, ok := s.containers.Compute(id, func(c *Container, loaded bool) (*Container, xsync.ComputeOp) {
if loaded {
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
if newContainer, err := s.client.FindContainer(ctx, id); err == nil {
return &newContainer, xsync.UpdateOp
} else {
log.Error().Err(err).Msg("failed to fetch container")
return c, xsync.CancelOp
}
}
return c, xsync.CancelOp
}); ok {
go func() {
event := ContainerEvent{
Name: "update",
Host: newContainer.Host,
ActorID: id,
Container: newContainer,
}
s.subscribers.Range(func(c context.Context, events chan<- ContainerEvent) bool {
select {
case events <- event:
case <-c.Done():
s.subscribers.Delete(c)
}
return true
})
}()
return *newContainer, nil
}
var updated bool
container, found := s.containers.Compute(id, func(c *Container, loaded bool) (*Container, xsync.ComputeOp) {
if !loaded {
return nil, xsync.CancelOp
}
return *container, nil
} else {
if c.FullyLoaded {
return c, xsync.CancelOp
}
log.Debug().Str("id", id).Msg("container is not fully loaded, fetching it")
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
if newContainer, err := s.client.FindContainer(ctx, id); err == nil {
updated = true
return &newContainer, xsync.UpdateOp
} else {
log.Error().Err(err).Msg("failed to fetch container")
return c, xsync.CancelOp
}
})
if !found {
log.Warn().Str("id", id).Msg("container not found")
return Container{}, ErrContainerNotFound
}
if updated {
go func() {
event := ContainerEvent{
Name: "update",
Host: container.Host,
ActorID: id,
Container: container,
}
s.subscribers.Range(func(c context.Context, events chan<- ContainerEvent) bool {
select {
case events <- event:
case <-c.Done():
s.subscribers.Delete(c)
}
return true
})
}()
}
return *container, nil
}
func (s *ContainerStore) Client() Client {
+1 -1
View File
@@ -16,7 +16,7 @@ var logLevels = [][]string{
{"warn", "warning", "wrn"},
{"info", "inf"},
{"debug", "dbg"},
{"trace"},
{"trace", "verbose", "ver", "vbs"},
{"fatal", "sev", "severe", "crit", "critical"},
}
+8
View File
@@ -386,6 +386,8 @@ func newContainer(c docker.Summary, host string) container.Container {
name := "no name"
if c.Labels["dev.dozzle.name"] != "" {
name = c.Labels["dev.dozzle.name"]
} else if c.Labels["coolify.resourceName"] != "" {
name = c.Labels["coolify.resourceName"]
} else if len(c.Names) > 0 {
name = strings.TrimPrefix(c.Names[0], "/")
}
@@ -393,6 +395,8 @@ func newContainer(c docker.Summary, host string) container.Container {
group := ""
if c.Labels["dev.dozzle.group"] != "" {
group = c.Labels["dev.dozzle.group"]
} else if c.Labels["coolify.projectName"] != "" {
group = c.Labels["coolify.projectName"]
}
return container.Container{
ID: c.ID[:12],
@@ -412,6 +416,8 @@ func newContainerFromJSON(c docker.InspectResponse, host string) container.Conta
name := "no name"
if c.Config.Labels["dev.dozzle.name"] != "" {
name = c.Config.Labels["dev.dozzle.name"]
} else if c.Config.Labels["coolify.resourceName"] != "" {
name = c.Config.Labels["coolify.resourceName"]
} else if len(c.Name) > 0 {
name = strings.TrimPrefix(c.Name, "/")
}
@@ -419,6 +425,8 @@ func newContainerFromJSON(c docker.InspectResponse, host string) container.Conta
group := ""
if c.Config.Labels["dev.dozzle.group"] != "" {
group = c.Config.Labels["dev.dozzle.group"]
} else if c.Config.Labels["coolify.projectName"] != "" {
group = c.Config.Labels["coolify.projectName"]
}
container := container.Container{
+102
View File
@@ -260,3 +260,105 @@ func Test_dockerClient_ContainerActions_error(t *testing.T) {
proxy.AssertExpectations(t)
}
func Test_newContainer_labelPriority(t *testing.T) {
tests := []struct {
name string
labels map[string]string
containerName string
expectedName string
expectedGroup string
}{
{
name: "dozzle labels take priority",
labels: map[string]string{"dev.dozzle.name": "dozzle-name", "coolify.resourceName": "coolify-name", "dev.dozzle.group": "dozzle-group", "coolify.projectName": "coolify-project"},
containerName: "/docker-name",
expectedName: "dozzle-name",
expectedGroup: "dozzle-group",
},
{
name: "coolify labels as fallback",
labels: map[string]string{"coolify.resourceName": "coolify-name", "coolify.projectName": "coolify-project"},
containerName: "/docker-name",
expectedName: "coolify-name",
expectedGroup: "coolify-project",
},
{
name: "docker name as final fallback",
labels: map[string]string{},
containerName: "/docker-name",
expectedName: "docker-name",
expectedGroup: "",
},
{
name: "coolify name with dozzle group",
labels: map[string]string{"coolify.resourceName": "coolify-name", "dev.dozzle.group": "dozzle-group"},
containerName: "/docker-name",
expectedName: "coolify-name",
expectedGroup: "dozzle-group",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
summary := docker.Summary{
ID: "abcdefghijklmnopqrst",
Names: []string{tt.containerName},
Labels: tt.labels,
}
c := newContainer(summary, "localhost")
assert.Equal(t, tt.expectedName, c.Name)
assert.Equal(t, tt.expectedGroup, c.Group)
})
}
}
func Test_newContainerFromJSON_labelPriority(t *testing.T) {
tests := []struct {
name string
labels map[string]string
containerName string
expectedName string
expectedGroup string
}{
{
name: "dozzle labels take priority",
labels: map[string]string{"dev.dozzle.name": "dozzle-name", "coolify.resourceName": "coolify-name", "dev.dozzle.group": "dozzle-group", "coolify.projectName": "coolify-project"},
containerName: "/docker-name",
expectedName: "dozzle-name",
expectedGroup: "dozzle-group",
},
{
name: "coolify labels as fallback",
labels: map[string]string{"coolify.resourceName": "coolify-name", "coolify.projectName": "coolify-project"},
containerName: "/docker-name",
expectedName: "coolify-name",
expectedGroup: "coolify-project",
},
{
name: "docker name as final fallback",
labels: map[string]string{},
containerName: "/docker-name",
expectedName: "docker-name",
expectedGroup: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
state := &docker.State{Status: "running", StartedAt: time.Now().Format(time.RFC3339Nano)}
json := docker.InspectResponse{
ContainerJSONBase: &docker.ContainerJSONBase{
ID: "abcdefghijklmnopqrst",
Name: tt.containerName,
State: state,
HostConfig: &docker.HostConfig{},
},
Config: &docker.Config{Labels: tt.labels},
}
c := newContainerFromJSON(json, "localhost")
assert.Equal(t, tt.expectedName, c.Name)
assert.Equal(t, tt.expectedGroup, c.Group)
})
}
}
+115
View File
@@ -0,0 +1,115 @@
package dispatcher
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"sync/atomic"
"time"
"github.com/amir20/dozzle/types"
"github.com/rs/zerolog/log"
)
// CloudDispatcher sends notifications to Dozzle Cloud
type CloudDispatcher struct {
Name string
URL string
APIKey string
Prefix string
ExpiresAt *time.Time
client *http.Client
blockedUntil atomic.Int64
}
// NewCloudDispatcher creates a new cloud dispatcher
func NewCloudDispatcher(name string, apiKey string, prefix string, expiresAt *time.Time) (*CloudDispatcher, error) {
url := os.Getenv("DOLIGENCE_URL")
if url == "" {
url = "https://doligence.dozzle.dev"
}
url = url + "/api/events"
if apiKey == "" {
return nil, fmt.Errorf("API key is required for cloud dispatcher")
}
return &CloudDispatcher{
Name: name,
URL: url,
APIKey: apiKey,
Prefix: prefix,
ExpiresAt: expiresAt,
client: &http.Client{
Timeout: 10 * time.Second,
},
}, nil
}
const defaultRetryAfter = 60 * time.Second
// Send sends a notification to Dozzle Cloud
func (c *CloudDispatcher) Send(ctx context.Context, notification types.Notification) error {
if blockedUntil := c.blockedUntil.Load(); blockedUntil > 0 && time.Now().UnixNano() < blockedUntil {
t := time.Unix(0, blockedUntil)
log.Debug().
Str("cloud", c.Name).
Time("blocked_until", t).
Msg("circuit breaker open, skipping cloud request")
return fmt.Errorf("cloud dispatcher rate limited, retry after %s", t.Format(time.RFC3339))
}
payload, err := json.Marshal(notification)
if err != nil {
return fmt.Errorf("failed to marshal notification: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", c.APIKey)
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("failed to send to cloud: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
retryAfter := defaultRetryAfter
if ra := resp.Header.Get("Retry-After"); ra != "" {
if seconds, err := strconv.Atoi(ra); err == nil {
retryAfter = time.Duration(seconds) * time.Second
}
}
c.blockedUntil.Store(time.Now().Add(retryAfter).UnixNano())
log.Warn().
Str("cloud", c.Name).
Dur("retry_after", retryAfter).
Msg("rate limited by cloud, circuit breaker tripped")
return fmt.Errorf("cloud rate limited, backing off for %s", retryAfter)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
limitedReader := io.LimitReader(resp.Body, 1024*1024)
responseBody, _ := io.ReadAll(limitedReader)
log.Debug().
Str("cloud", c.Name).
Str("url", c.URL).
Int("status_code", resp.StatusCode).
Str("payload", string(payload)).
Str("response_body", string(responseBody)).
Msg("cloud returned non-success status code")
return fmt.Errorf("cloud returned status code %d: %s", resp.StatusCode, string(responseBody))
}
return nil
}
@@ -0,0 +1,13 @@
package dispatcher
import (
"context"
"github.com/amir20/dozzle/types"
)
// Dispatcher is responsible for sending notifications to external systems
type Dispatcher interface {
// Send sends a notification to the configured destination
Send(ctx context.Context, notification types.Notification) error
}
+115
View File
@@ -0,0 +1,115 @@
package dispatcher
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"text/template"
"time"
"github.com/amir20/dozzle/types"
"github.com/rs/zerolog/log"
)
// WebhookDispatcher sends notifications to a webhook URL
type WebhookDispatcher struct {
Name string
URL string
Template *template.Template
TemplateText string // Original template string for serialization
client *http.Client
}
// NewWebhookDispatcher creates a new webhook dispatcher
// If templateStr is empty, the notification will be marshaled as JSON directly
func NewWebhookDispatcher(name, url, templateStr string) (*WebhookDispatcher, error) {
w := &WebhookDispatcher{
Name: name,
URL: url,
TemplateText: templateStr,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
if templateStr != "" {
tmpl, err := template.New("webhook").Parse(templateStr)
if err != nil {
return nil, fmt.Errorf("failed to parse template: %w", err)
}
w.Template = tmpl
}
return w, nil
}
// TestResult contains the result of a webhook test
type TestResult struct {
Success bool
StatusCode int
Error string
}
// Send sends a notification to the webhook URL
func (w *WebhookDispatcher) Send(ctx context.Context, notification types.Notification) error {
result := w.SendTest(ctx, notification)
if !result.Success {
return fmt.Errorf("webhook notification failed: %s", result.Error)
}
return nil
}
// SendTest sends a notification and returns detailed result for testing
func (w *WebhookDispatcher) SendTest(ctx context.Context, notification types.Notification) TestResult {
var payload []byte
var err error
if w.Template != nil {
var buf bytes.Buffer
if err := w.Template.Execute(&buf, notification); err != nil {
return TestResult{Success: false, Error: fmt.Sprintf("failed to execute template: %v", err)}
}
payload = buf.Bytes()
} else {
payload, err = json.Marshal(notification)
if err != nil {
return TestResult{Success: false, Error: fmt.Sprintf("failed to marshal notification: %v", err)}
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.URL, bytes.NewReader(payload))
if err != nil {
return TestResult{Success: false, Error: fmt.Sprintf("failed to create request: %v", err)}
}
req.Header.Set("Content-Type", "application/json")
resp, err := w.client.Do(req)
if err != nil {
return TestResult{Success: false, Error: fmt.Sprintf("failed to send webhook: %v", err)}
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Limit response body to 1MB to prevent memory exhaustion
limitedReader := io.LimitReader(resp.Body, 1024*1024)
responseBody, _ := io.ReadAll(limitedReader)
log.Debug().
Str("webhook", w.Name).
Str("url", w.URL).
Int("status_code", resp.StatusCode).
Str("payload", string(payload)).
Str("response_body", string(responseBody)).
Msg("webhook returned non-success status code")
return TestResult{
Success: false,
StatusCode: resp.StatusCode,
Error: fmt.Sprintf("webhook returned status code %d: %s", resp.StatusCode, string(responseBody)),
}
}
return TestResult{Success: true, StatusCode: resp.StatusCode}
}
+204
View File
@@ -0,0 +1,204 @@
package notification
import (
"context"
"errors"
"fmt"
"io"
"time"
"github.com/amir20/dozzle/internal/container"
container_support "github.com/amir20/dozzle/internal/support/container"
"github.com/puzpuzpuz/xsync/v4"
"github.com/rs/zerolog/log"
)
// ContainerMatcher is an interface for checking if a container should be listened to
type ContainerMatcher interface {
ShouldListenToContainer(c container.Container) bool
}
// ContainerLogListener manages active log streams for containers across multiple clients
type ContainerLogListener struct {
clients []container_support.ClientService
containerClients *xsync.Map[string, container_support.ClientService] // containerID -> owning client
activeStreams *xsync.Map[string, context.CancelFunc] // containerID -> cancel function
matcher ContainerMatcher
logChannel chan *container.LogEvent
ctx context.Context
}
// NewContainerLogListener creates a new listener for multiple clients
func NewContainerLogListener(ctx context.Context, clients []container_support.ClientService) *ContainerLogListener {
return &ContainerLogListener{
clients: clients,
containerClients: xsync.NewMap[string, container_support.ClientService](),
activeStreams: xsync.NewMap[string, context.CancelFunc](),
logChannel: make(chan *container.LogEvent, 1000),
ctx: ctx,
}
}
// Start begins listening for container events and processes log streams
func (l *ContainerLogListener) Start(matcher ContainerMatcher) error {
l.matcher = matcher
// Subscribe to new containers from all clients
containerChan := make(chan container.Container, 10)
// Get all current containers from all clients
for _, client := range l.clients {
containers, err := client.ListContainers(l.ctx, nil)
if err != nil {
log.Warn().Err(err).Msg("Failed to list containers from client")
continue
}
// Start listening to containers that match
for _, c := range containers {
if l.matcher.ShouldListenToContainer(c) {
l.startListening(c, client)
}
}
// Subscribe to new containers from this client
client.SubscribeContainersStarted(l.ctx, containerChan)
}
go func() {
for {
select {
case <-l.ctx.Done():
return
case c := <-containerChan:
if l.matcher.ShouldListenToContainer(c) {
l.startListeningByID(c)
}
}
}
}()
return nil
}
// UpdateStreams updates which containers to listen to based on current matcher rules
func (l *ContainerLogListener) UpdateStreams() error {
// Get all current containers from all clients
for _, client := range l.clients {
containers, err := client.ListContainers(l.ctx, nil)
if err != nil {
log.Warn().Err(err).Msg("Failed to list containers from client")
continue
}
// Check each container against matcher
for _, c := range containers {
shouldListen := l.matcher.ShouldListenToContainer(c)
isListening := l.isListening(c.ID)
if shouldListen && !isListening {
l.startListening(c, client)
} else if !shouldListen && isListening {
l.stopListening(c.ID)
}
}
}
return nil
}
// startListening starts listening to a container's logs with a known client
func (l *ContainerLogListener) startListening(c container.Container, client container_support.ClientService) {
streamCtx, cancel := context.WithCancel(l.ctx)
// Only store if not already present
_, loaded := l.activeStreams.LoadOrStore(c.ID, cancel)
if loaded {
cancel() // Already listening, cancel the new context
return
}
l.containerClients.Store(c.ID, client)
go func() {
log.Debug().Str("containerID", c.ID).Str("name", c.Name).Msg("Started listening to container")
if err := client.StreamLogs(streamCtx, c, time.Now(), container.STDALL, l.logChannel); err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) {
log.Error().Err(err).Str("containerID", c.ID).Msg("Error streaming logs")
}
}()
}
// startListeningByID finds the client for a container and starts listening
func (l *ContainerLogListener) startListeningByID(c container.Container) {
for _, client := range l.clients {
if found, err := client.FindContainer(l.ctx, c.ID, nil); err == nil {
l.startListening(found, client)
return
}
}
log.Warn().Str("containerID", c.ID).Msg("Could not find client for container")
}
// stopListening stops listening to a container's logs
func (l *ContainerLogListener) stopListening(containerID string) {
if cancel, exists := l.activeStreams.LoadAndDelete(containerID); exists {
cancel()
l.containerClients.Delete(containerID)
log.Debug().Str("containerID", containerID).Msg("Stopped listening to container")
}
}
// isListening returns true if listening to a container
func (l *ContainerLogListener) isListening(containerID string) bool {
_, exists := l.activeStreams.Load(containerID)
return exists
}
// FindContainer finds a container by ID using the client that owns it
func (l *ContainerLogListener) FindContainer(ctx context.Context, id string, labels container.ContainerLabels) (container.Container, error) {
client, exists := l.containerClients.Load(id)
if !exists {
return container.Container{}, fmt.Errorf("container %s not found in any client", id)
}
return client.FindContainer(ctx, id, labels)
}
// FindContainerWithHost finds a container and its host by container ID
func (l *ContainerLogListener) FindContainerWithHost(ctx context.Context, id string, labels container.ContainerLabels) (container.Container, container.Host, error) {
client, exists := l.containerClients.Load(id)
if !exists {
return container.Container{}, container.Host{}, fmt.Errorf("container %s not found in any client", id)
}
c, err := client.FindContainer(ctx, id, labels)
if err != nil {
return container.Container{}, container.Host{}, err
}
host, err := client.Host(ctx)
if err != nil {
return container.Container{}, container.Host{}, fmt.Errorf("failed to get host for container %s: %w", id, err)
}
return c, host, nil
}
// LogChannel returns the channel for log events
func (l *ContainerLogListener) LogChannel() <-chan *container.LogEvent {
return l.logChannel
}
// ListContainers returns all containers from all clients
func (l *ContainerLogListener) ListContainers() []container.Container {
var result []container.Container
for _, client := range l.clients {
containers, err := client.ListContainers(l.ctx, nil)
if err != nil {
log.Warn().Err(err).Msg("Failed to list containers from client")
continue
}
result = append(result, containers...)
}
return result
}
+580
View File
@@ -0,0 +1,580 @@
package notification
import (
"context"
"fmt"
"io"
"slices"
"strings"
"sync/atomic"
"time"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/internal/notification/dispatcher"
"github.com/amir20/dozzle/types"
"github.com/expr-lang/expr"
"github.com/puzpuzpuz/xsync/v4"
"github.com/rs/zerolog/log"
"go.yaml.in/yaml/v3"
"golang.org/x/sync/semaphore"
)
// Manager manages notification subscriptions and dispatches notifications
type Manager struct {
subscriptions *xsync.Map[int, *Subscription]
dispatchers *xsync.Map[int, dispatcher.Dispatcher]
subscriptionCounter atomic.Int32
dispatcherCounter atomic.Int32
listener *ContainerLogListener
ctx context.Context
cancel context.CancelFunc
sendSem *semaphore.Weighted
}
// NewManager creates a new notification manager
func NewManager(listener *ContainerLogListener) *Manager {
ctx, cancel := context.WithCancel(context.Background())
m := &Manager{
subscriptions: xsync.NewMap[int, *Subscription](),
dispatchers: xsync.NewMap[int, dispatcher.Dispatcher](),
listener: listener,
ctx: ctx,
cancel: cancel,
sendSem: semaphore.NewWeighted(5),
}
// Start processing log events from the listener
go m.processLogEvents()
return m
}
// Start initializes the manager and starts the log listener
func (m *Manager) Start() error {
if m.listener != nil {
return m.listener.Start(m)
}
return nil
}
// ShouldListenToContainer implements ContainerMatcher interface
func (m *Manager) ShouldListenToContainer(c container.Container) bool {
// Pass empty host for matching - host fields aren't used in container expressions
notificationContainer := FromContainerModel(c, container.Host{})
shouldListen := false
m.subscriptions.Range(func(_ int, sub *Subscription) bool {
if sub.Enabled && sub.MatchesContainer(notificationContainer) {
shouldListen = true
return false
}
return true
})
return shouldListen
}
// AddSubscription adds a new subscription with compiled expressions
func (m *Manager) AddSubscription(sub *Subscription) error {
// Auto-increment ID using atomic counter
sub.ID = int(m.subscriptionCounter.Add(1))
sub.Enabled = true
// Compile container expression if provided
if sub.ContainerExpression != "" {
program, err := expr.Compile(sub.ContainerExpression, expr.Env(types.NotificationContainer{}))
if err != nil {
return fmt.Errorf("failed to compile container expression: %w", err)
}
sub.ContainerProgram = program
}
// Compile log expression if provided
if sub.LogExpression != "" {
program, err := expr.Compile(sub.LogExpression, expr.Env(types.NotificationLog{}))
if err != nil {
return fmt.Errorf("failed to compile log expression: %w", err)
}
sub.LogProgram = program
}
m.subscriptions.Store(sub.ID, sub)
log.Debug().Str("name", sub.Name).Int("id", sub.ID).Msg("Added subscription")
// Update listener to start/stop streams based on new subscription
if m.listener != nil {
if err := m.listener.UpdateStreams(); err != nil {
log.Error().Err(err).Msg("Failed to update listener streams")
}
}
return nil
}
// RemoveSubscription removes a subscription by ID
func (m *Manager) RemoveSubscription(id int) {
if sub, ok := m.subscriptions.LoadAndDelete(id); ok {
log.Debug().Int("id", id).Str("name", sub.Name).Msg("Removed subscription")
// Update listener to stop streams that are no longer needed
if m.listener != nil {
if err := m.listener.UpdateStreams(); err != nil {
log.Error().Err(err).Msg("Failed to update listener streams")
}
}
}
}
// ReplaceSubscription replaces a subscription with new data
func (m *Manager) ReplaceSubscription(sub *Subscription) error {
// Compile container expression if provided
if sub.ContainerExpression != "" {
program, err := expr.Compile(sub.ContainerExpression, expr.Env(types.NotificationContainer{}))
if err != nil {
return fmt.Errorf("failed to compile container expression: %w", err)
}
sub.ContainerProgram = program
}
// Compile log expression if provided
if sub.LogExpression != "" {
program, err := expr.Compile(sub.LogExpression, expr.Env(types.NotificationLog{}))
if err != nil {
return fmt.Errorf("failed to compile log expression: %w", err)
}
sub.LogProgram = program
}
// Preserve enabled state from existing subscription if it exists
if existing, ok := m.subscriptions.Load(sub.ID); ok {
sub.Enabled = existing.Enabled
} else {
sub.Enabled = true
}
m.subscriptions.Store(sub.ID, sub)
log.Debug().Str("name", sub.Name).Int("id", sub.ID).Msg("Replaced subscription")
// Update listener to start/stop streams based on new subscription
if m.listener != nil {
if err := m.listener.UpdateStreams(); err != nil {
log.Error().Err(err).Msg("Failed to update listener streams")
}
}
return nil
}
// UpdateSubscription updates a subscription with the provided fields
func (m *Manager) UpdateSubscription(id int, updates map[string]any) error {
var updateErr error
_, ok := m.subscriptions.Compute(id, func(sub *Subscription, loaded bool) (*Subscription, xsync.ComputeOp) {
if !loaded {
updateErr = fmt.Errorf("subscription not found")
return nil, xsync.CancelOp
}
// Clone the subscription
updated := &Subscription{
ID: sub.ID,
Name: sub.Name,
Enabled: sub.Enabled,
DispatcherID: sub.DispatcherID,
ContainerExpression: sub.ContainerExpression,
ContainerProgram: sub.ContainerProgram,
LogExpression: sub.LogExpression,
LogProgram: sub.LogProgram,
}
// Apply updates to the clone
for key, value := range updates {
switch key {
case "name":
if name, ok := value.(string); ok {
updated.Name = name
}
case "enabled":
if enabled, ok := value.(bool); ok {
updated.Enabled = enabled
}
case "dispatcherId":
if dispatcherID, ok := value.(int); ok {
updated.DispatcherID = dispatcherID
}
case "containerExpression":
if exprStr, ok := value.(string); ok {
program, err := expr.Compile(exprStr, expr.Env(types.NotificationContainer{}))
if err != nil {
updateErr = fmt.Errorf("failed to compile container expression: %w", err)
return nil, xsync.CancelOp
}
updated.ContainerExpression = exprStr
updated.ContainerProgram = program
}
case "logExpression":
if exprStr, ok := value.(string); ok {
if exprStr != "" {
program, err := expr.Compile(exprStr, expr.Env(types.NotificationLog{}))
if err != nil {
updateErr = fmt.Errorf("failed to compile log expression: %w", err)
return nil, xsync.CancelOp
}
updated.LogExpression = exprStr
updated.LogProgram = program
}
}
}
}
return updated, xsync.UpdateOp
})
if updateErr != nil {
return updateErr
}
if !ok {
return fmt.Errorf("subscription not found")
}
log.Debug().Int("id", id).Interface("updates", updates).Msg("Updated subscription")
// Update listener streams in case expressions changed
if m.listener != nil {
if err := m.listener.UpdateStreams(); err != nil {
log.Error().Err(err).Msg("Failed to update listener streams")
}
}
return nil
}
// AddDispatcher adds a dispatcher and returns its auto-generated ID
func (m *Manager) AddDispatcher(d dispatcher.Dispatcher) int {
id := int(m.dispatcherCounter.Add(1))
m.dispatchers.Store(id, d)
log.Debug().Int("id", id).Msg("Added dispatcher")
return id
}
// UpdateDispatcher updates a dispatcher by ID
func (m *Manager) UpdateDispatcher(id int, d dispatcher.Dispatcher) {
m.dispatchers.Store(id, d)
log.Debug().Int("id", id).Msg("Updated dispatcher")
}
// RemoveDispatcher removes a dispatcher by ID
func (m *Manager) RemoveDispatcher(id int) {
if _, ok := m.dispatchers.LoadAndDelete(id); ok {
log.Debug().Int("id", id).Msg("Removed dispatcher")
}
}
// Subscriptions returns all subscriptions sorted by ID
func (m *Manager) Subscriptions() []*Subscription {
result := make([]*Subscription, 0)
m.subscriptions.Range(func(_ int, sub *Subscription) bool {
result = append(result, sub)
return true
})
slices.SortFunc(result, func(a, b *Subscription) int {
return a.ID - b.ID
})
return result
}
// Dispatchers returns all dispatchers as DispatcherConfig sorted by ID
func (m *Manager) Dispatchers() []DispatcherConfig {
result := make([]DispatcherConfig, 0)
m.dispatchers.Range(func(id int, d dispatcher.Dispatcher) bool {
switch v := d.(type) {
case *dispatcher.WebhookDispatcher:
result = append(result, DispatcherConfig{
ID: id,
Name: v.Name,
Type: "webhook",
URL: v.URL,
Template: v.TemplateText,
})
case *dispatcher.CloudDispatcher:
result = append(result, DispatcherConfig{
ID: id,
Name: v.Name,
Type: "cloud",
APIKey: v.APIKey,
Prefix: v.Prefix,
ExpiresAt: v.ExpiresAt,
})
}
return true
})
slices.SortFunc(result, func(a, b DispatcherConfig) int {
return a.ID - b.ID
})
return result
}
// processLogEvents processes log events from the listener channel
func (m *Manager) processLogEvents() {
for {
select {
case <-m.ctx.Done():
return
case logEvent := <-m.listener.LogChannel():
if logEvent == nil {
return
}
m.processLogEvent(logEvent)
}
}
}
// processLogEvent processes a single log event and sends notifications for matching subscriptions
func (m *Manager) processLogEvent(logEvent *container.LogEvent) {
// Get container and host from log event's ContainerID
ctx, cancel := context.WithTimeout(m.ctx, 5*time.Second)
defer cancel()
c, host, err := m.listener.FindContainerWithHost(ctx, logEvent.ContainerID, nil)
if err != nil {
log.Error().Err(err).Str("containerID", logEvent.ContainerID).Msg("Failed to find container")
return
}
// Skip logs from Dozzle itself to avoid feedback loops
if strings.Contains(c.Image, "dozzle") {
return
}
notificationContainer := FromContainerModel(c, host)
notificationLog := FromLogEvent(*logEvent)
m.subscriptions.Range(func(_ int, sub *Subscription) bool {
// Skip disabled subscriptions
if !sub.Enabled {
return true
}
// Check container filter
if !sub.MatchesContainer(notificationContainer) {
return true
}
sub.AddTriggeredContainer(notificationContainer.ID)
// Check log filter
if !sub.MatchesLog(notificationLog) {
return true
}
// Update stats
sub.TriggerCount.Add(1)
now := time.Now()
sub.LastTriggeredAt.Store(&now)
log.Debug().Str("containerID", notificationContainer.ID).Interface("log", notificationLog.Message).Msg("Matched subscription")
// Create notification
notification := types.Notification{
ID: fmt.Sprintf("%s-%d", c.ID, time.Now().UnixNano()),
Container: notificationContainer,
Log: notificationLog,
Subscription: types.SubscriptionConfig{
ID: sub.ID,
Name: sub.Name,
Enabled: sub.Enabled,
DispatcherID: sub.DispatcherID,
LogExpression: sub.LogExpression,
ContainerExpression: sub.ContainerExpression,
},
Timestamp: time.Now(),
}
// Send to the subscription's dispatcher
if d, ok := m.dispatchers.Load(sub.DispatcherID); ok {
go m.sendNotification(d, notification, sub.DispatcherID)
}
return true
})
}
// sendNotification sends a notification using the dispatcher
func (m *Manager) sendNotification(d dispatcher.Dispatcher, notification types.Notification, id int) {
acquireCtx, acquireCancel := context.WithTimeout(m.ctx, time.Minute)
defer acquireCancel()
if err := m.sendSem.Acquire(acquireCtx, 1); err != nil {
log.Warn().Err(err).Int("subscription", id).Msg("Notification dropped: too many pending")
return
}
defer m.sendSem.Release(1)
ctx, cancel := context.WithTimeout(m.ctx, 30*time.Second)
defer cancel()
if err := d.Send(ctx, notification); err != nil {
log.Error().Err(err).Int("subscription", id).Msg("Failed to send notification")
}
}
// WriteConfig writes the current configuration to a writer in YAML format
func (m *Manager) WriteConfig(w io.Writer) error {
config := Config{
Subscriptions: m.Subscriptions(),
Dispatchers: m.Dispatchers(),
}
encoder := yaml.NewEncoder(w)
defer encoder.Close()
return encoder.Encode(config)
}
// LoadConfig reads configuration from a reader in YAML format and loads it
func (m *Manager) LoadConfig(r io.Reader) error {
var config Config
decoder := yaml.NewDecoder(r)
if err := decoder.Decode(&config); err != nil {
return fmt.Errorf("failed to decode config: %w", err)
}
// Convert to types for HandleNotificationConfig
subscriptions := make([]types.SubscriptionConfig, len(config.Subscriptions))
for i, sub := range config.Subscriptions {
subscriptions[i] = types.SubscriptionConfig{
ID: sub.ID,
Name: sub.Name,
Enabled: sub.Enabled,
DispatcherID: sub.DispatcherID,
LogExpression: sub.LogExpression,
ContainerExpression: sub.ContainerExpression,
}
}
dispatchers := make([]types.DispatcherConfig, len(config.Dispatchers))
for i, d := range config.Dispatchers {
dispatchers[i] = types.DispatcherConfig{
ID: d.ID,
Name: d.Name,
Type: d.Type,
URL: d.URL,
Template: d.Template,
APIKey: d.APIKey,
Prefix: d.Prefix,
ExpiresAt: d.ExpiresAt,
}
}
return m.HandleNotificationConfig(subscriptions, dispatchers)
}
// HandleNotificationConfig implements agent.NotificationConfigHandler interface
// It atomically replaces all subscriptions and dispatchers with new state from the main server
func (m *Manager) HandleNotificationConfig(subscriptions []types.SubscriptionConfig, dispatchers []types.DispatcherConfig) error {
// Clear existing state (with nil checks for defensive programming)
if m.subscriptions != nil {
m.subscriptions.Clear()
} else {
m.subscriptions = xsync.NewMap[int, *Subscription]()
}
if m.dispatchers != nil {
m.dispatchers.Clear()
} else {
m.dispatchers = xsync.NewMap[int, dispatcher.Dispatcher]()
}
// Find max IDs to initialize counters
var maxSubID, maxDispatcherID int
for _, sub := range subscriptions {
if sub.ID > maxSubID {
maxSubID = sub.ID
}
}
for _, d := range dispatchers {
if d.ID > maxDispatcherID {
maxDispatcherID = d.ID
}
}
m.subscriptionCounter.Store(int32(maxSubID))
m.dispatcherCounter.Store(int32(maxDispatcherID))
// Load subscriptions (convert from types.SubscriptionConfig to Subscription)
for _, sub := range subscriptions {
s := &Subscription{
ID: sub.ID,
Name: sub.Name,
Enabled: sub.Enabled,
DispatcherID: sub.DispatcherID,
LogExpression: sub.LogExpression,
ContainerExpression: sub.ContainerExpression,
}
if err := m.loadSubscription(s); err != nil {
return fmt.Errorf("failed to load subscription %s: %w", sub.Name, err)
}
}
// Load dispatchers
for _, dc := range dispatchers {
d, err := createDispatcher(DispatcherConfig{
ID: dc.ID,
Name: dc.Name,
Type: dc.Type,
URL: dc.URL,
Template: dc.Template,
APIKey: dc.APIKey,
Prefix: dc.Prefix,
ExpiresAt: dc.ExpiresAt,
})
if err != nil {
return fmt.Errorf("failed to create dispatcher %s: %w", dc.Name, err)
}
m.dispatchers.Store(dc.ID, d)
log.Debug().Int("id", dc.ID).Msg("Loaded dispatcher from state sync")
}
// Update listener to start/stop streams based on new subscriptions
if m.listener != nil {
if err := m.listener.UpdateStreams(); err != nil {
return fmt.Errorf("failed to update listener streams: %w", err)
}
}
log.Debug().Int("subscriptions", len(subscriptions)).Int("dispatchers", len(dispatchers)).Msg("Replaced notification state")
return nil
}
// createDispatcher creates a dispatcher from a DispatcherConfig
func createDispatcher(config DispatcherConfig) (dispatcher.Dispatcher, error) {
switch config.Type {
case "webhook":
return dispatcher.NewWebhookDispatcher(config.Name, config.URL, config.Template)
case "cloud":
return dispatcher.NewCloudDispatcher(config.Name, config.APIKey, config.Prefix, config.ExpiresAt)
default:
return nil, fmt.Errorf("unknown dispatcher type: %s", config.Type)
}
}
// loadSubscription loads a subscription with its existing ID (used when loading from config)
func (m *Manager) loadSubscription(sub *Subscription) error {
// Compile container expression if provided
if sub.ContainerExpression != "" {
program, err := expr.Compile(sub.ContainerExpression, expr.Env(types.NotificationContainer{}))
if err != nil {
return fmt.Errorf("failed to compile container expression: %w", err)
}
sub.ContainerProgram = program
}
// Compile log expression if provided
if sub.LogExpression != "" {
program, err := expr.Compile(sub.LogExpression, expr.Env(types.NotificationLog{}))
if err != nil {
return fmt.Errorf("failed to compile log expression: %w", err)
}
sub.LogProgram = program
}
m.subscriptions.Store(sub.ID, sub)
log.Debug().Str("name", sub.Name).Int("id", sub.ID).Msg("Loaded subscription")
return nil
}
+164
View File
@@ -0,0 +1,164 @@
package notification
import (
"strings"
"sync/atomic"
"time"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/types"
"github.com/expr-lang/expr"
"github.com/expr-lang/expr/vm"
"github.com/puzpuzpuz/xsync/v4"
"github.com/rs/zerolog/log"
orderedmap "github.com/wk8/go-ordered-map/v2"
)
// FromContainerModel converts internal container.Container to types.NotificationContainer
func FromContainerModel(c container.Container, host container.Host) types.NotificationContainer {
return types.NotificationContainer{
ID: c.ID,
Name: c.Name,
Image: c.Image,
State: c.State,
Health: c.Health,
HostID: host.ID,
HostName: host.Name,
Labels: c.Labels,
}
}
// FromLogEvent converts container.LogEvent to types.NotificationLog
func FromLogEvent(l container.LogEvent) types.NotificationLog {
message := extractMessage(l)
return types.NotificationLog{
ID: l.Id,
Message: message,
Timestamp: l.Timestamp,
Level: l.Level,
Stream: l.Stream,
Type: string(l.Type),
}
}
// extractMessage extracts and joins message from LogEvent
// For grouped logs (fragments), joins them into a single string
// For complex logs (JSON/objects), converts to a regular map for expr compatibility
// For simple logs, returns the string as-is
func extractMessage(l container.LogEvent) any {
switch v := l.Message.(type) {
case string:
return v
case []container.LogFragment:
var parts []string
for _, fragment := range v {
parts = append(parts, fragment.Message)
}
return strings.Join(parts, "")
case *orderedmap.OrderedMap[string, any]:
// Convert OrderedMap to regular map for expr compatibility
result := make(map[string]any)
for pair := v.Oldest(); pair != nil; pair = pair.Next() {
result[pair.Key] = pair.Value
}
return result
case *orderedmap.OrderedMap[string, string]:
// Convert OrderedMap[string, string] to regular map for expr compatibility
result := make(map[string]any)
for pair := v.Oldest(); pair != nil; pair = pair.Next() {
result[pair.Key] = pair.Value
}
return result
default:
// For other complex objects, keep the original structure
return v
}
}
// Subscription represents a subscription to log streams with filtering
type Subscription struct {
ID int `json:"id" yaml:"id"`
Name string `json:"name" yaml:"name"`
Enabled bool `json:"enabled" yaml:"enabled"`
DispatcherID int `json:"dispatcherId" yaml:"dispatcherId"`
LogExpression string `json:"logExpression" yaml:"logExpression"`
ContainerExpression string `json:"containerExpression" yaml:"containerExpression"`
// Compiled log filter expression
LogProgram *vm.Program `json:"-" yaml:"-"` // Compiled log filter expression
ContainerProgram *vm.Program `json:"-" yaml:"-"` // Compiled container filter expression
// Runtime stats (not persisted)
TriggerCount atomic.Int64 `json:"-" yaml:"-"`
LastTriggeredAt atomic.Pointer[time.Time] `json:"-" yaml:"-"`
TriggeredContainerIDs *xsync.Map[string, struct{}] `json:"-" yaml:"-"` // unique container IDs that triggered
}
// TriggeredContainersCount returns the number of unique containers that triggered this subscription
func (s *Subscription) TriggeredContainersCount() int {
if s.TriggeredContainerIDs == nil {
return 0
}
return s.TriggeredContainerIDs.Size()
}
// AddTriggeredContainer adds a container ID to the triggered set
func (s *Subscription) AddTriggeredContainer(id string) {
if s.TriggeredContainerIDs == nil {
s.TriggeredContainerIDs = xsync.NewMap[string, struct{}]()
}
s.TriggeredContainerIDs.Store(id, struct{}{})
}
// DispatcherConfig represents a dispatcher configuration
type DispatcherConfig struct {
ID int `json:"id" yaml:"id"`
Name string `json:"name" yaml:"name"`
Type string `json:"type" yaml:"type"` // "webhook", "cloud"
URL string `json:"url,omitempty" yaml:"url,omitempty"`
Template string `json:"template,omitempty" yaml:"template,omitempty"` // Go template for custom payload format
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"` // API key for cloud dispatcher
Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"` // API key prefix for cloud dispatcher
ExpiresAt *time.Time `json:"expiresAt,omitempty" yaml:"expiresAt,omitempty"`
}
// Config represents the persisted notification configuration
type Config struct {
Subscriptions []*Subscription `json:"subscriptions" yaml:"subscriptions"`
Dispatchers []DispatcherConfig `json:"dispatchers" yaml:"dispatchers"`
}
// MatchesContainer checks if a container matches this subscription's container filter
func (s *Subscription) MatchesContainer(c types.NotificationContainer) bool {
if s.ContainerProgram == nil {
return false
}
result, err := expr.Run(s.ContainerProgram, c)
if err != nil {
log.Warn().Err(err).Str("expression", s.ContainerExpression).Msg("container expression evaluation error")
return false
}
match, ok := result.(bool)
return ok && match
}
// MatchesLog checks if a log matches this subscription's log filter
func (s *Subscription) MatchesLog(l types.NotificationLog) bool {
if s.LogProgram == nil {
return false
}
result, err := expr.Run(s.LogProgram, l)
if err != nil {
// Type mismatches are expected when expression doesn't match log type
// e.g., "message contains X" on JSON logs or "message.field" on string logs
log.Debug().Err(err).Str("expression", s.LogExpression).Msg("log expression evaluation error")
return false
}
match, ok := result.(bool)
return ok && match
}
+270
View File
@@ -0,0 +1,270 @@
package notification
import (
"testing"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/types"
"github.com/expr-lang/expr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
orderedmap "github.com/wk8/go-ordered-map/v2"
)
func TestSubscription_MatchesLog(t *testing.T) {
tests := []struct {
name string
expression string
log types.NotificationLog
want bool
}{
{
name: "matches message substring",
expression: `message contains "error"`,
log: types.NotificationLog{
Message: "an error occurred",
},
want: true,
},
{
name: "does not match message substring",
expression: `message contains "error"`,
log: types.NotificationLog{
Message: "everything is fine",
},
want: false,
},
{
name: "matches log level",
expression: `level == "error"`,
log: types.NotificationLog{
Level: "error",
},
want: true,
},
{
name: "matches stream",
expression: `stream == "stderr"`,
log: types.NotificationLog{
Stream: "stderr",
},
want: true,
},
{
name: "complex expression with AND",
expression: `level == "error" && message contains "fatal"`,
log: types.NotificationLog{
Level: "error",
Message: "fatal exception",
},
want: true,
},
{
name: "complex expression fails AND",
expression: `level == "error" && message contains "fatal"`,
log: types.NotificationLog{
Level: "error",
Message: "normal error",
},
want: false,
},
{
name: "complex expression with OR",
expression: `level == "error" || message contains "warning"`,
log: types.NotificationLog{
Level: "info",
Message: "warning: something happened",
},
want: true,
},
{
name: "matches type",
expression: `type == "stdout"`,
log: types.NotificationLog{
Type: "stdout",
},
want: true,
},
{
name: "matches complex message number field",
expression: `message.number == 123`,
log: types.NotificationLog{
Message: map[string]any{
"number": 123,
},
},
want: true,
},
{
name: "matches complex message string field",
expression: `message.status == "error"`,
log: types.NotificationLog{
Message: map[string]any{
"status": "error",
},
},
want: true,
},
{
name: "matches complex message nested field",
expression: `message.user.name == "admin"`,
log: types.NotificationLog{
Message: map[string]any{
"user": map[string]any{
"name": "admin",
},
},
},
want: true,
},
{
name: "does not match complex message wrong value",
expression: `message.number == 123`,
log: types.NotificationLog{
Message: map[string]any{
"number": 456,
},
},
want: false,
},
{
name: "complex message with multiple conditions",
expression: `message.level == "error" && message.code >= 400`,
log: types.NotificationLog{
Message: map[string]any{
"level": "error",
"code": 500,
},
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
program, err := expr.Compile(tt.expression, expr.Env(types.NotificationLog{}), expr.AsBool())
require.NoError(t, err, "failed to compile expression")
sub := &Subscription{
LogProgram: program,
}
got := sub.MatchesLog(tt.log)
assert.Equal(t, tt.want, got)
})
}
}
func TestSubscription_MatchesLog_NilProgram(t *testing.T) {
sub := &Subscription{
LogProgram: nil,
}
log := types.NotificationLog{
Message: "test",
}
got := sub.MatchesLog(log)
assert.False(t, got, "should return false when LogProgram is nil")
}
func TestSubscription_MatchesLog_InvalidExpression(t *testing.T) {
// Create a program that will cause a runtime error by dividing by zero
program, err := expr.Compile(`1 / 0 == 1`, expr.AsBool())
require.NoError(t, err)
sub := &Subscription{
LogProgram: program,
}
log := types.NotificationLog{
Message: "test",
}
got := sub.MatchesLog(log)
assert.False(t, got, "should return false when expression evaluation fails")
}
func TestFromLogEvent_OrderedMapConversion(t *testing.T) {
tests := []struct {
name string
expression string
logEvent container.LogEvent
want bool
}{
{
name: "orderedmap string any - matches number field",
expression: `message.value > 0`,
logEvent: container.LogEvent{
Message: orderedmap.New[string, any](
orderedmap.WithInitialData(
orderedmap.Pair[string, any]{Key: "value", Value: 123},
orderedmap.Pair[string, any]{Key: "name", Value: "test"},
),
),
},
want: true,
},
{
name: "orderedmap string any - matches string field",
expression: `message.name == "test"`,
logEvent: container.LogEvent{
Message: orderedmap.New[string, any](
orderedmap.WithInitialData(
orderedmap.Pair[string, any]{Key: "value", Value: 123},
orderedmap.Pair[string, any]{Key: "name", Value: "test"},
),
),
},
want: true,
},
{
name: "orderedmap string string - matches field",
expression: `message.level == "error"`,
logEvent: container.LogEvent{
Message: orderedmap.New[string, string](
orderedmap.WithInitialData(
orderedmap.Pair[string, string]{Key: "level", Value: "error"},
orderedmap.Pair[string, string]{Key: "msg", Value: "something failed"},
),
),
},
want: true,
},
{
name: "orderedmap string string - does not match",
expression: `message.level == "error"`,
logEvent: container.LogEvent{
Message: orderedmap.New[string, string](
orderedmap.WithInitialData(
orderedmap.Pair[string, string]{Key: "level", Value: "info"},
orderedmap.Pair[string, string]{Key: "msg", Value: "all good"},
),
),
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Convert LogEvent to NotificationLog (this is where OrderedMap conversion happens)
notificationLog := FromLogEvent(tt.logEvent)
// Verify the message is now a regular map
_, isMap := notificationLog.Message.(map[string]any)
assert.True(t, isMap, "message should be converted to map[string]any")
// Compile and run the expression
program, err := expr.Compile(tt.expression, expr.Env(types.NotificationLog{}), expr.AsBool())
require.NoError(t, err, "failed to compile expression")
sub := &Subscription{
LogProgram: program,
}
got := sub.MatchesLog(notificationLog)
assert.Equal(t, tt.want, got)
})
}
}
+69 -3
View File
@@ -12,6 +12,10 @@ import (
"github.com/amir20/dozzle/internal/agent"
"github.com/amir20/dozzle/internal/docker"
"github.com/amir20/dozzle/internal/notification"
container_support "github.com/amir20/dozzle/internal/support/container"
docker_support "github.com/amir20/dozzle/internal/support/docker"
"github.com/amir20/dozzle/types"
"github.com/rs/zerolog/log"
)
@@ -19,6 +23,37 @@ type AgentCmd struct {
Addr string `arg:"--agent-addr,env:DOZZLE_AGENT_ADDR" default:":7007" help:"sets the host:port to bind for the agent"`
}
// persistingNotificationHandler wraps a notification manager and saves config to disk after updates
type persistingNotificationHandler struct {
manager *notification.Manager
configPath string
}
func (h *persistingNotificationHandler) HandleNotificationConfig(subscriptions []types.SubscriptionConfig, dispatchers []types.DispatcherConfig) error {
// Update the manager
if err := h.manager.HandleNotificationConfig(subscriptions, dispatchers); err != nil {
return err
}
// Save to disk
if err := os.MkdirAll("./data", 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
file, err := os.Create(h.configPath)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
defer file.Close()
if err := h.manager.WriteConfig(file); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
log.Debug().Str("path", h.configPath).Msg("Saved notification config to disk")
return nil
}
func (a *AgentCmd) Run(args Args, embeddedCerts embed.FS) error {
if args.Mode != "server" {
return fmt.Errorf("agent command is only available in server mode")
@@ -43,12 +78,43 @@ func (a *AgentCmd) Run(args Args, embeddedCerts embed.FS) error {
io.WriteString(tempFile, listener.Addr().String())
log.Debug().Str("file", tempFile.Name()).Msg("Created temp file")
go StartEvent(args, "", client, "agent")
server, err := agent.NewServer(client, certs, args.Version(), args.Filter)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Create shared client service (single ContainerStore for both agent server and notifications)
clientService := docker_support.NewDockerClientService(client, args.Filter)
// Create notification manager using the shared client service
const notificationConfigPath = "./data/notifications.yml"
clients := []container_support.ClientService{clientService}
notificationManager := notification.NewManager(notification.NewContainerLogListener(ctx, clients))
// Load existing notification config if available
if file, err := os.Open(notificationConfigPath); err == nil {
if err := notificationManager.LoadConfig(file); err != nil {
log.Warn().Err(err).Msg("Failed to load notification config, starting fresh")
} else {
log.Info().Str("path", notificationConfigPath).Msg("Loaded notification config from disk")
}
file.Close()
}
if err := notificationManager.Start(); err != nil {
return fmt.Errorf("failed to start notification manager: %w", err)
}
// Create handler that wraps manager and persists config to disk
notificationHandler := &persistingNotificationHandler{
manager: notificationManager,
configPath: notificationConfigPath,
}
// Create agent server using the same shared client service
server, err := agent.NewServer(clientService, certs, args.Version(), notificationHandler)
if err != nil {
return fmt.Errorf("failed to create agent server: %w", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
log.Info().Msgf("Dozzle agent version %s", args.Version())
log.Info().Msgf("Agent listening on %s", listener.Addr().String())
+19 -60
View File
@@ -2,21 +2,20 @@ package container_support
import (
"context"
"encoding/json"
"io"
"sync"
"sync/atomic"
"time"
"github.com/amir20/dozzle/internal/agent"
"github.com/amir20/dozzle/internal/container"
"github.com/docker/docker/pkg/stdcopy"
"github.com/amir20/dozzle/types"
"github.com/rs/zerolog/log"
)
type agentService struct {
client *agent.Client
host container.Host
host atomic.Pointer[container.Host]
}
func NewAgentService(client *agent.Client) ClientService {
@@ -26,7 +25,7 @@ func NewAgentService(client *agent.Client) ClientService {
}
func (a *agentService) FindContainer(ctx context.Context, id string, labels container.ContainerLabels) (container.Container, error) {
return a.client.FindContainer(ctx, id)
return a.client.FindContainer(ctx, id, labels)
}
func (a *agentService) RawLogs(ctx context.Context, container container.Container, from time.Time, to time.Time, stdTypes container.StdType) (io.ReadCloser, error) {
@@ -49,13 +48,16 @@ func (a *agentService) ListContainers(ctx context.Context, labels container.Cont
func (a *agentService) Host(ctx context.Context) (container.Host, error) {
host, err := a.client.Host(ctx)
if err != nil {
host := a.host
host.Available = false
return host, err
if cached := a.host.Load(); cached != nil {
h := *cached
h.Available = false
return h, err
}
return container.Host{Available: false}, err
}
a.host = host
return a.host, err
a.host.Store(&host)
return host, nil
}
func (a *agentService) SubscribeStats(ctx context.Context, stats chan<- container.ContainerStat) {
@@ -74,57 +76,14 @@ func (a *agentService) ContainerAction(ctx context.Context, container container.
return a.client.ContainerAction(ctx, container.ID, action)
}
func (a *agentService) Attach(ctx context.Context, container container.Container, stdin io.Reader, stdout io.Writer) error {
func (a *agentService) Attach(ctx context.Context, c container.Container, events container.ExecEventReader, stdout io.Writer) error {
panic("not implemented")
}
func (a *agentService) Exec(ctx context.Context, c container.Container, cmd []string, stdin io.Reader, stdout io.Writer) error {
cancelCtx, cancel := context.WithCancel(ctx)
session, err := a.client.ContainerExec(cancelCtx, c.ID, cmd)
if err != nil {
cancel()
return err
}
var wg sync.WaitGroup
wg.Go(func() {
decoder := json.NewDecoder(stdin)
loop:
for {
var event container.ExecEvent
if err := decoder.Decode(&event); err != nil {
if err != io.EOF {
log.Error().Err(err).Msg("error decoding event from ws using agent")
}
break
}
switch event.Type {
case "userinput":
if _, err := session.Writer.Write([]byte(event.Data)); err != nil {
log.Error().Err(err).Msg("error writing to container using agent")
break loop
}
case "resize":
if err := session.Resize(event.Width, event.Height); err != nil {
log.Error().Err(err).Msg("error resizing terminal using agent")
}
}
}
cancel()
session.Writer.Close()
})
wg.Go(func() {
if _, err := stdcopy.StdCopy(stdout, stdout, session.Reader); err != nil {
log.Error().Err(err).Msg("error while writing to ws using agent")
}
cancel()
})
wg.Wait()
return nil
func (a *agentService) Exec(ctx context.Context, c container.Container, cmd []string, events container.ExecEventReader, stdout io.Writer) error {
return a.client.Exec(ctx, c.ID, cmd, events, stdout)
}
func (a *agentService) UpdateNotificationConfig(ctx context.Context, subscriptions []types.SubscriptionConfig, dispatchers []types.DispatcherConfig) error {
return a.client.UpdateNotificationConfig(ctx, subscriptions, dispatchers)
}
+2 -2
View File
@@ -27,6 +27,6 @@ type ClientService interface {
StreamLogs(context.Context, container.Container, time.Time, container.StdType, chan<- *container.LogEvent) error
// Terminal
Attach(context.Context, container.Container, io.Reader, io.Writer) error
Exec(context.Context, container.Container, []string, io.Reader, io.Writer) error
Attach(context.Context, container.Container, container.ExecEventReader, io.Writer) error
Exec(context.Context, container.Container, []string, container.ExecEventReader, io.Writer) error
}
@@ -36,10 +36,10 @@ func (c *ContainerService) Action(ctx context.Context, action container.Containe
return c.clientService.ContainerAction(ctx, c.Container, action)
}
func (c *ContainerService) Attach(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
return c.clientService.Attach(ctx, c.Container, stdin, stdout)
func (c *ContainerService) Attach(ctx context.Context, events container.ExecEventReader, stdout io.Writer) error {
return c.clientService.Attach(ctx, c.Container, events, stdout)
}
func (c *ContainerService) Exec(ctx context.Context, cmd []string, stdin io.Reader, stdout io.Writer) error {
return c.clientService.Exec(ctx, c.Container, cmd, stdin, stdout)
func (c *ContainerService) Exec(ctx context.Context, cmd []string, events container.ExecEventReader, stdout io.Writer) error {
return c.clientService.Exec(ctx, c.Container, cmd, events, stdout)
}
+10 -12
View File
@@ -2,7 +2,6 @@ package docker_support
import (
"context"
"encoding/json"
"io"
"sync"
"time"
@@ -112,7 +111,7 @@ func (d *DockerClientService) SubscribeContainersStarted(ctx context.Context, co
d.store.SubscribeNewContainers(ctx, containers)
}
func (d *DockerClientService) Attach(ctx context.Context, c container.Container, stdin io.Reader, stdout io.Writer) error {
func (d *DockerClientService) Attach(ctx context.Context, c container.Container, events container.ExecEventReader, stdout io.Writer) error {
cancelCtx, cancel := context.WithCancel(ctx)
session, err := d.client.ContainerAttach(cancelCtx, c.ID)
if err != nil {
@@ -123,13 +122,12 @@ func (d *DockerClientService) Attach(ctx context.Context, c container.Container,
var wg sync.WaitGroup
wg.Go(func() {
decoder := json.NewDecoder(stdin)
loop:
for {
var event container.ExecEvent
if err := decoder.Decode(&event); err != nil {
event, err := events.ReadEvent()
if err != nil {
if err != io.EOF {
log.Error().Err(err).Msg("error while decoding event from ws")
log.Error().Err(err).Msg("error while reading event")
}
break
}
@@ -170,7 +168,7 @@ func (d *DockerClientService) Attach(ctx context.Context, c container.Container,
return nil
}
func (d *DockerClientService) Exec(ctx context.Context, c container.Container, cmd []string, stdin io.Reader, stdout io.Writer) error {
func (d *DockerClientService) Exec(ctx context.Context, c container.Container, cmd []string, events container.ExecEventReader, stdout io.Writer) error {
cancelCtx, cancel := context.WithCancel(ctx)
session, err := d.client.ContainerExec(cancelCtx, c.ID, cmd)
if err != nil {
@@ -181,13 +179,12 @@ func (d *DockerClientService) Exec(ctx context.Context, c container.Container, c
var wg sync.WaitGroup
wg.Go(func() {
decoder := json.NewDecoder(stdin)
loop:
for {
var event container.ExecEvent
if err := decoder.Decode(&event); err != nil {
event, err := events.ReadEvent()
if err != nil {
if err != io.EOF {
log.Error().Err(err).Msg("error while decoding event from ws")
log.Error().Err(err).Msg("error while reading event")
}
break
}
@@ -211,7 +208,8 @@ func (d *DockerClientService) Exec(ctx context.Context, c container.Container, c
})
wg.Go(func() {
if _, err := stdcopy.StdCopy(stdout, stdout, session.Reader); err != nil {
// TTY mode outputs raw bytes without Docker's multiplexing headers.
if _, err := io.Copy(stdout, session.Reader); err != nil {
log.Error().Err(err).Msg("error while writing to ws")
}
cancel()
+175 -2
View File
@@ -3,10 +3,15 @@ package docker_support
import (
"context"
"fmt"
"os"
"sync"
"time"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/internal/notification"
"github.com/amir20/dozzle/internal/notification/dispatcher"
container_support "github.com/amir20/dozzle/internal/support/container"
"github.com/amir20/dozzle/types"
"github.com/rs/zerolog/log"
lop "github.com/samber/lo/parallel"
)
@@ -27,11 +32,13 @@ type ClientManager interface {
Subscribe(ctx context.Context, channel chan<- container.Host)
Hosts(ctx context.Context) []container.Host
LocalClients() []container.Client
LocalClientServices() []container_support.ClientService
}
type MultiHostService struct {
manager ClientManager
timeout time.Duration
manager ClientManager
timeout time.Duration
notificationManager *notification.Manager
}
func NewMultiHostService(manager ClientManager, timeout time.Duration) *MultiHostService {
@@ -168,6 +175,172 @@ func (m *MultiHostService) LocalClients() []container.Client {
return m.manager.LocalClients()
}
func (m *MultiHostService) LocalClientServices() []container_support.ClientService {
return m.manager.LocalClientServices()
}
func (m *MultiHostService) TotalClients() int {
return len(m.manager.List())
}
const notificationConfigPath = "./data/notifications.yml"
// StartNotificationManager initializes and starts the notification manager
func (m *MultiHostService) StartNotificationManager(ctx context.Context) error {
clients := m.manager.LocalClientServices()
listener := notification.NewContainerLogListener(ctx, clients)
m.notificationManager = notification.NewManager(listener)
// Start first so matcher is available for LoadConfig
if err := m.notificationManager.Start(); err != nil {
return err
}
// Load config if exists
if file, err := os.Open(notificationConfigPath); err == nil {
defer file.Close()
if err := m.notificationManager.LoadConfig(file); err != nil {
log.Warn().Err(err).Msg("Could not load notification config")
} else {
log.Debug().Str("path", notificationConfigPath).Msg("Loaded notification config")
}
}
return nil
}
func (m *MultiHostService) saveNotificationConfig() {
if err := os.MkdirAll("./data", 0755); err != nil {
log.Error().Err(err).Msg("Could not create data directory")
return
}
file, err := os.Create(notificationConfigPath)
if err != nil {
log.Error().Err(err).Msg("Could not create notification config file")
return
}
defer file.Close()
if err := m.notificationManager.WriteConfig(file); err != nil {
log.Error().Err(err).Msg("Could not write notification config")
}
// Broadcast to all agents
m.broadcastNotificationConfig()
}
// NotificationConfigUpdater is an interface for clients that support notification config updates
type NotificationConfigUpdater interface {
UpdateNotificationConfig(ctx context.Context, subscriptions []types.SubscriptionConfig, dispatchers []types.DispatcherConfig) error
}
// broadcastNotificationConfig sends current notification config to all agent clients
func (m *MultiHostService) broadcastNotificationConfig() {
notifSubs := m.notificationManager.Subscriptions()
notifDispatchers := m.notificationManager.Dispatchers()
// Convert notification.Subscription to types.SubscriptionConfig
subscriptions := make([]types.SubscriptionConfig, len(notifSubs))
for i, sub := range notifSubs {
subscriptions[i] = types.SubscriptionConfig{
ID: sub.ID,
Name: sub.Name,
Enabled: sub.Enabled,
DispatcherID: sub.DispatcherID,
LogExpression: sub.LogExpression,
ContainerExpression: sub.ContainerExpression,
}
}
// Convert notification.DispatcherConfig to types.DispatcherConfig
dispatchers := make([]types.DispatcherConfig, len(notifDispatchers))
for i, d := range notifDispatchers {
dispatchers[i] = types.DispatcherConfig{
ID: d.ID,
Name: d.Name,
Type: d.Type,
URL: d.URL,
Template: d.Template,
}
}
var wg sync.WaitGroup
for _, client := range m.manager.List() {
// Check if client supports notification config updates (agents do, local docker clients don't)
if updater, ok := client.(NotificationConfigUpdater); ok {
wg.Go(func() {
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
defer cancel()
if err := updater.UpdateNotificationConfig(ctx, subscriptions, dispatchers); err != nil {
log.Error().Err(err).Msg("Failed to broadcast notification config to agent")
} else {
log.Debug().Int("subscriptions", len(subscriptions)).Int("dispatchers", len(dispatchers)).Msg("Broadcasted notification config to agent")
}
})
}
}
wg.Wait()
}
// AddSubscription adds a subscription to local manager and broadcasts to agents
func (m *MultiHostService) AddSubscription(sub *notification.Subscription) error {
if err := m.notificationManager.AddSubscription(sub); err != nil {
return err
}
m.saveNotificationConfig()
return nil
}
// RemoveSubscription removes a subscription from local manager and broadcasts to agents
func (m *MultiHostService) RemoveSubscription(id int) {
m.notificationManager.RemoveSubscription(id)
m.saveNotificationConfig()
}
// AddDispatcher adds a dispatcher and returns its auto-generated ID
func (m *MultiHostService) AddDispatcher(d dispatcher.Dispatcher) int {
id := m.notificationManager.AddDispatcher(d)
m.saveNotificationConfig()
return id
}
// UpdateDispatcher updates a dispatcher by ID
func (m *MultiHostService) UpdateDispatcher(id int, d dispatcher.Dispatcher) {
m.notificationManager.UpdateDispatcher(id, d)
m.saveNotificationConfig()
}
// RemoveDispatcher removes a dispatcher by ID
func (m *MultiHostService) RemoveDispatcher(id int) {
m.notificationManager.RemoveDispatcher(id)
m.saveNotificationConfig()
}
// ReplaceSubscription replaces a subscription with new data
func (m *MultiHostService) ReplaceSubscription(sub *notification.Subscription) error {
if err := m.notificationManager.ReplaceSubscription(sub); err != nil {
return err
}
m.saveNotificationConfig()
return nil
}
// UpdateSubscription updates a subscription with the provided fields
func (m *MultiHostService) UpdateSubscription(id int, updates map[string]any) error {
if err := m.notificationManager.UpdateSubscription(id, updates); err != nil {
return err
}
m.saveNotificationConfig()
return nil
}
// Subscriptions returns all subscriptions
func (m *MultiHostService) Subscriptions() []*notification.Subscription {
return m.notificationManager.Subscriptions()
}
// Dispatchers returns all dispatchers
func (m *MultiHostService) Dispatchers() []notification.DispatcherConfig {
return m.notificationManager.Dispatchers()
}
@@ -195,3 +195,17 @@ func (m *RetriableClientManager) LocalClients() []container.Client {
return clients
}
func (m *RetriableClientManager) LocalClientServices() []container_support.ClientService {
services := m.List()
result := make([]container_support.ClientService, 0)
for _, service := range services {
if _, ok := service.(*DockerClientService); ok {
result = append(result, service)
}
}
return result
}
@@ -236,3 +236,17 @@ func (m *SwarmClientManager) String() string {
func (m *SwarmClientManager) LocalClients() []container.Client {
return []container.Client{m.localClient}
}
func (m *SwarmClientManager) LocalClientServices() []container_support.ClientService {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]container_support.ClientService, 0)
for _, service := range m.clients {
if _, ok := service.(*DockerClientService); ok {
result = append(result, service)
}
}
return result
}
@@ -7,6 +7,8 @@ import (
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/internal/k8s"
"github.com/amir20/dozzle/internal/notification"
"github.com/amir20/dozzle/internal/notification/dispatcher"
container_support "github.com/amir20/dozzle/internal/support/container"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
@@ -131,3 +133,43 @@ func (m *K8sClusterService) SubscribeAvailableHosts(ctx context.Context, hosts c
func (m *K8sClusterService) LocalClients() []container.Client {
return []container.Client{m.client.client}
}
func (m *K8sClusterService) LocalClientServices() []container_support.ClientService {
return []container_support.ClientService{m.client}
}
// Notification methods - not yet implemented for k8s
func (m *K8sClusterService) AddSubscription(sub *notification.Subscription) error {
// TODO Implement notification subscription for k8s mode
return fmt.Errorf("notifications not supported in k8s mode")
}
func (m *K8sClusterService) RemoveSubscription(id int) {
}
func (m *K8sClusterService) ReplaceSubscription(sub *notification.Subscription) error {
return fmt.Errorf("notifications not supported in k8s mode")
}
func (m *K8sClusterService) UpdateSubscription(id int, updates map[string]any) error {
return fmt.Errorf("notifications not supported in k8s mode")
}
func (m *K8sClusterService) Subscriptions() []*notification.Subscription {
return []*notification.Subscription{}
}
func (m *K8sClusterService) AddDispatcher(d dispatcher.Dispatcher) int {
return 0
}
func (m *K8sClusterService) UpdateDispatcher(id int, d dispatcher.Dispatcher) {
}
func (m *K8sClusterService) RemoveDispatcher(id int) {
}
func (m *K8sClusterService) Dispatchers() []notification.DispatcherConfig {
return []notification.DispatcherConfig{}
}
+8 -11
View File
@@ -2,7 +2,6 @@ package k8s_support
import (
"context"
"encoding/json"
"io"
"sync"
@@ -93,7 +92,7 @@ func (k *K8sClientService) SubscribeContainersStarted(ctx context.Context, conta
k.store.SubscribeNewContainers(ctx, containers)
}
func (k *K8sClientService) Attach(ctx context.Context, c container.Container, stdin io.Reader, stdout io.Writer) error {
func (k *K8sClientService) Attach(ctx context.Context, c container.Container, events container.ExecEventReader, stdout io.Writer) error {
cancelCtx, cancel := context.WithCancel(ctx)
session, err := k.client.ContainerAttach(cancelCtx, c.ID)
if err != nil {
@@ -107,13 +106,12 @@ func (k *K8sClientService) Attach(ctx context.Context, c container.Container, st
defer session.Writer.Close()
defer cancel()
decoder := json.NewDecoder(stdin)
loop:
for {
var event container.ExecEvent
if err := decoder.Decode(&event); err != nil {
event, err := events.ReadEvent()
if err != nil {
if err != io.EOF {
log.Error().Err(err).Msg("error decoding event")
log.Error().Err(err).Msg("error reading event")
}
break
}
@@ -143,7 +141,7 @@ func (k *K8sClientService) Attach(ctx context.Context, c container.Container, st
return nil
}
func (k *K8sClientService) Exec(ctx context.Context, c container.Container, cmd []string, stdin io.Reader, stdout io.Writer) error {
func (k *K8sClientService) Exec(ctx context.Context, c container.Container, cmd []string, events container.ExecEventReader, stdout io.Writer) error {
cancelCtx, cancel := context.WithCancel(ctx)
session, err := k.client.ContainerExec(cancelCtx, c.ID, cmd)
if err != nil {
@@ -157,13 +155,12 @@ func (k *K8sClientService) Exec(ctx context.Context, c container.Container, cmd
defer session.Writer.Close()
defer cancel()
decoder := json.NewDecoder(stdin)
loop:
for {
var event container.ExecEvent
if err := decoder.Decode(&event); err != nil {
event, err := events.ReadEvent()
if err != nil {
if err != io.EOF {
log.Error().Err(err).Msg("error decoding event")
log.Error().Err(err).Msg("error reading event")
}
break
}
+101
View File
@@ -0,0 +1,101 @@
package web
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/amir20/dozzle/internal/notification/dispatcher"
"github.com/rs/zerolog/log"
)
type exchangeTokenResponse struct {
Key string `json:"key"`
Prefix string `json:"prefix"`
ExpiresAt *string `json:"expiresAt,omitempty"`
}
func (h *handler) cloudCallback(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "missing token parameter", http.StatusBadRequest)
return
}
cloudURL := os.Getenv("DOLIGENCE_URL")
if cloudURL == "" {
cloudURL = "https://doligence.dozzle.dev"
}
exchangeURL := fmt.Sprintf("%s/api/exchange-token", cloudURL)
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, exchangeURL, nil)
if err != nil {
log.Error().Err(err).Msg("Failed to create request")
http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
q := req.URL.Query()
q.Set("token", token)
req.URL.RawQuery = q.Encode()
resp, err := client.Do(req)
if err != nil {
log.Error().Err(err).Msg("Failed to exchange token")
http.Error(w, "failed to exchange token", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
log.Error().Int("status", resp.StatusCode).Str("body", string(body)).Msg("Token exchange failed")
http.Error(w, "token exchange failed", http.StatusInternalServerError)
return
}
var tokenResp exchangeTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
log.Error().Err(err).Msg("Failed to decode token response")
http.Error(w, "failed to decode token response", http.StatusInternalServerError)
return
}
if tokenResp.Key == "" {
log.Error().Msg("Empty key received")
http.Error(w, "empty key received", http.StatusInternalServerError)
return
}
var expiresAt *time.Time
if tokenResp.ExpiresAt != nil {
parsed, err := time.Parse(time.RFC3339, *tokenResp.ExpiresAt)
if err != nil {
log.Warn().Err(err).Str("expiresAt", *tokenResp.ExpiresAt).Msg("Failed to parse expiresAt, ignoring")
} else {
expiresAt = &parsed
}
}
name := "Dozzle Cloud"
cloudDispatcher, err := dispatcher.NewCloudDispatcher(name, tokenResp.Key, tokenResp.Prefix, expiresAt)
if err != nil {
log.Error().Err(err).Msg("Failed to create cloud dispatcher")
http.Error(w, "failed to create cloud dispatcher", http.StatusInternalServerError)
return
}
id := h.hostService.AddDispatcher(cloudDispatcher)
base := h.config.Base
if base == "/" {
base = ""
}
redirectURL := fmt.Sprintf("%s/notifications#cloudLinkSuccess=%d", base, id)
http.Redirect(w, r, redirectURL, http.StatusFound)
}
+20
View File
@@ -127,6 +127,17 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
lastSeenId = uint32(num)
}
startId := uint32(0)
if r.URL.Query().Has("startId") {
from = from.Add(-50 * time.Millisecond) // Add a little buffer to ensure we find the start event
num, err := strconv.ParseUint(r.URL.Query().Get("startId"), 10, 32)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
startId = uint32(num)
}
encoder := json.NewEncoder(w)
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
w.Header().Set("Content-Encoding", "gzip")
@@ -135,6 +146,7 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
encoder = json.NewEncoder(writer)
}
startIdFound := startId == 0 // If no startId, consider it already found
for {
if minimum > 0 && buffer.Len() >= minimum {
break
@@ -168,6 +180,14 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
continue
}
if !startIdFound {
if event.Id == startId {
log.Debug().Uint32("startId", startId).Msg("found start id, will include subsequent events")
startIdFound = true
}
continue
}
if lastSeenId != 0 && event.Id == lastSeenId {
log.Debug().Uint32("lastSeenId", lastSeenId).Msg("found last seen id")
break
+604
View File
@@ -0,0 +1,604 @@
package web
import (
"context"
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/amir20/dozzle/internal/cache"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/internal/notification"
"github.com/amir20/dozzle/internal/notification/dispatcher"
"github.com/amir20/dozzle/internal/releases"
"github.com/amir20/dozzle/types"
"github.com/expr-lang/expr"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
)
// Response types for JSON serialization
type NotificationRuleResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
ContainerExpression string `json:"containerExpression"`
LogExpression string `json:"logExpression"`
TriggerCount int64 `json:"triggerCount"`
TriggeredContainers int `json:"triggeredContainers"`
LastTriggeredAt *time.Time `json:"lastTriggeredAt"`
Dispatcher *DispatcherResponse `json:"dispatcher"`
}
type DispatcherResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
URL *string `json:"url,omitempty"`
Template *string `json:"template,omitempty"`
Prefix *string `json:"prefix,omitempty"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
type NotificationRuleInput struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
DispatcherID int `json:"dispatcherId"`
LogExpression string `json:"logExpression"`
ContainerExpression string `json:"containerExpression"`
}
type NotificationRuleUpdateInput struct {
Name *string `json:"name,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
DispatcherID *int `json:"dispatcherId,omitempty"`
LogExpression *string `json:"logExpression,omitempty"`
ContainerExpression *string `json:"containerExpression,omitempty"`
}
type DispatcherInput struct {
Name string `json:"name"`
Type string `json:"type"`
URL *string `json:"url,omitempty"`
Template *string `json:"template,omitempty"`
}
type PreviewInput struct {
ContainerExpression string `json:"containerExpression"`
LogExpression *string `json:"logExpression,omitempty"`
}
type PreviewResult struct {
ContainerError *string `json:"containerError,omitempty"`
LogError *string `json:"logError,omitempty"`
MatchedContainers []container.Container `json:"matchedContainers"`
MatchedLogs []container.LogEvent `json:"matchedLogs"`
TotalLogs int `json:"totalLogs"`
}
type TestWebhookInput struct {
URL string `json:"url"`
Template *string `json:"template,omitempty"`
}
type TestWebhookResult struct {
Success bool `json:"success"`
StatusCode *int `json:"statusCode,omitempty"`
Error *string `json:"error,omitempty"`
}
// Helper functions
func subscriptionToResponse(sub *notification.Subscription, dispatchers []notification.DispatcherConfig) *NotificationRuleResponse {
var lastTriggeredAt *time.Time
if t := sub.LastTriggeredAt.Load(); t != nil && !t.IsZero() {
lastTriggeredAt = t
}
var disp *DispatcherResponse
for _, d := range dispatchers {
if d.ID == sub.DispatcherID {
disp = dispatcherConfigToResponse(&d)
break
}
}
return &NotificationRuleResponse{
ID: sub.ID,
Name: sub.Name,
Enabled: sub.Enabled,
Dispatcher: disp,
LogExpression: sub.LogExpression,
ContainerExpression: sub.ContainerExpression,
TriggerCount: sub.TriggerCount.Load(),
LastTriggeredAt: lastTriggeredAt,
TriggeredContainers: sub.TriggeredContainersCount(),
}
}
func dispatcherConfigToResponse(d *notification.DispatcherConfig) *DispatcherResponse {
var url *string
if d.URL != "" {
url = &d.URL
}
var template *string
if d.Template != "" {
template = &d.Template
}
var prefix *string
if d.Prefix != "" {
prefix = &d.Prefix
}
return &DispatcherResponse{
ID: d.ID,
Name: d.Name,
Type: d.Type,
URL: url,
Template: template,
Prefix: prefix,
ExpiresAt: d.ExpiresAt,
}
}
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Error().Err(err).Msg("failed to encode JSON response")
}
}
func writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}
// Notification Rules handlers
func (h *handler) listNotificationRules(w http.ResponseWriter, r *http.Request) {
subscriptions := h.hostService.Subscriptions()
dispatchers := h.hostService.Dispatchers()
rules := make([]*NotificationRuleResponse, len(subscriptions))
for i, sub := range subscriptions {
rules[i] = subscriptionToResponse(sub, dispatchers)
}
writeJSON(w, http.StatusOK, rules)
}
func (h *handler) getNotificationRule(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
dispatchers := h.hostService.Dispatchers()
for _, sub := range h.hostService.Subscriptions() {
if sub.ID == id {
writeJSON(w, http.StatusOK, subscriptionToResponse(sub, dispatchers))
return
}
}
writeError(w, http.StatusNotFound, "notification rule not found")
}
func (h *handler) createNotificationRule(w http.ResponseWriter, r *http.Request) {
var input NotificationRuleInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
sub := &notification.Subscription{
Name: input.Name,
Enabled: input.Enabled,
DispatcherID: input.DispatcherID,
LogExpression: input.LogExpression,
ContainerExpression: input.ContainerExpression,
}
if err := h.hostService.AddSubscription(sub); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, subscriptionToResponse(sub, h.hostService.Dispatchers()))
}
func (h *handler) replaceNotificationRule(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var input NotificationRuleInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
sub := &notification.Subscription{
ID: id,
Name: input.Name,
Enabled: input.Enabled,
DispatcherID: input.DispatcherID,
LogExpression: input.LogExpression,
ContainerExpression: input.ContainerExpression,
}
if err := h.hostService.ReplaceSubscription(sub); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, subscriptionToResponse(sub, h.hostService.Dispatchers()))
}
func (h *handler) updateNotificationRule(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var input NotificationRuleUpdateInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
updates := make(map[string]any)
if input.Name != nil {
updates["name"] = *input.Name
}
if input.Enabled != nil {
updates["enabled"] = *input.Enabled
}
if input.DispatcherID != nil {
updates["dispatcherId"] = *input.DispatcherID
}
if input.LogExpression != nil {
updates["logExpression"] = *input.LogExpression
}
if input.ContainerExpression != nil {
updates["containerExpression"] = *input.ContainerExpression
}
if err := h.hostService.UpdateSubscription(id, updates); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Fetch the updated subscription
dispatchers := h.hostService.Dispatchers()
for _, sub := range h.hostService.Subscriptions() {
if sub.ID == id {
writeJSON(w, http.StatusOK, subscriptionToResponse(sub, dispatchers))
return
}
}
writeError(w, http.StatusNotFound, "notification rule not found")
}
func (h *handler) deleteNotificationRule(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
h.hostService.RemoveSubscription(id)
w.WriteHeader(http.StatusNoContent)
}
// Dispatcher handlers
func (h *handler) listDispatchers(w http.ResponseWriter, r *http.Request) {
dispatchers := h.hostService.Dispatchers()
result := make([]*DispatcherResponse, len(dispatchers))
for i, d := range dispatchers {
result[i] = dispatcherConfigToResponse(&d)
}
writeJSON(w, http.StatusOK, result)
}
func (h *handler) getDispatcher(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
for _, d := range h.hostService.Dispatchers() {
if d.ID == id {
writeJSON(w, http.StatusOK, dispatcherConfigToResponse(&d))
return
}
}
writeError(w, http.StatusNotFound, "dispatcher not found")
}
func (h *handler) createDispatcher(w http.ResponseWriter, r *http.Request) {
var input DispatcherInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
var d dispatcher.Dispatcher
switch input.Type {
case "webhook":
url := ""
if input.URL != nil {
url = *input.URL
}
templateStr := ""
if input.Template != nil {
templateStr = *input.Template
}
webhook, err := dispatcher.NewWebhookDispatcher(input.Name, url, templateStr)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
d = webhook
default:
writeError(w, http.StatusBadRequest, "unknown dispatcher type")
return
}
id := h.hostService.AddDispatcher(d)
writeJSON(w, http.StatusCreated, &DispatcherResponse{
ID: id,
Name: input.Name,
Type: input.Type,
URL: input.URL,
Template: input.Template,
})
}
func (h *handler) updateDispatcher(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var input DispatcherInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
var d dispatcher.Dispatcher
switch input.Type {
case "webhook":
url := ""
if input.URL != nil {
url = *input.URL
}
templateStr := ""
if input.Template != nil {
templateStr = *input.Template
}
webhook, err := dispatcher.NewWebhookDispatcher(input.Name, url, templateStr)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
d = webhook
default:
writeError(w, http.StatusBadRequest, "unknown dispatcher type")
return
}
h.hostService.UpdateDispatcher(id, d)
writeJSON(w, http.StatusOK, &DispatcherResponse{
ID: id,
Name: input.Name,
Type: input.Type,
URL: input.URL,
Template: input.Template,
})
}
func (h *handler) deleteDispatcher(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
h.hostService.RemoveDispatcher(id)
w.WriteHeader(http.StatusNoContent)
}
// Preview and test handlers
func (h *handler) previewExpression(w http.ResponseWriter, r *http.Request) {
var input PreviewInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
result := &PreviewResult{
MatchedContainers: []container.Container{},
MatchedLogs: []container.LogEvent{},
}
sub := &notification.Subscription{
ContainerExpression: input.ContainerExpression,
}
if input.LogExpression != nil {
sub.LogExpression = *input.LogExpression
}
// Compile container expression
if sub.ContainerExpression != "" {
program, err := expr.Compile(sub.ContainerExpression, expr.Env(types.NotificationContainer{}))
if err != nil {
errStr := err.Error()
result.ContainerError = &errStr
} else {
sub.ContainerProgram = program
}
}
// Compile log expression
if sub.LogExpression != "" {
program, err := expr.Compile(sub.LogExpression, expr.Env(types.NotificationLog{}))
if err != nil {
errStr := err.Error()
result.LogError = &errStr
} else {
sub.LogProgram = program
}
}
// Find matching running containers
if sub.ContainerProgram != nil {
containers, _ := h.hostService.ListAllContainers(container.ContainerLabels{})
for _, c := range containers {
if c.State != "running" {
continue
}
// Pass empty host for matching - host fields aren't used in container expressions
nc := notification.FromContainerModel(c, container.Host{})
if sub.MatchesContainer(nc) {
result.MatchedContainers = append(result.MatchedContainers, c)
}
}
}
// Fetch real logs from matched containers
if sub.LogProgram != nil && len(result.MatchedContainers) > 0 {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
const maxLogs = 10
totalMatched := 0
for _, c := range result.MatchedContainers {
if len(result.MatchedLogs) >= maxLogs {
break
}
containerService, err := h.hostService.FindContainer(c.Host, c.ID, container.ContainerLabels{})
if err != nil {
continue
}
from := time.Now().Add(-2 * time.Hour)
to := time.Now()
logChan, err := containerService.LogsBetweenDates(ctx, from, to, container.STDALL)
if err != nil {
continue
}
for logEvent := range logChan {
if logEvent == nil {
continue
}
notificationLog := notification.FromLogEvent(*logEvent)
if sub.MatchesLog(notificationLog) {
totalMatched++
if len(result.MatchedLogs) < maxLogs {
result.MatchedLogs = append(result.MatchedLogs, *logEvent)
}
}
}
}
result.TotalLogs = totalMatched
}
writeJSON(w, http.StatusOK, result)
}
func (h *handler) testWebhook(w http.ResponseWriter, r *http.Request) {
var input TestWebhookInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
templateStr := ""
if input.Template != nil {
templateStr = *input.Template
}
webhook, err := dispatcher.NewWebhookDispatcher("test", input.URL, templateStr)
if err != nil {
errStr := err.Error()
writeJSON(w, http.StatusOK, &TestWebhookResult{
Success: false,
Error: &errStr,
})
return
}
mockNotification := types.Notification{
ID: "test-notification",
Timestamp: time.Now(),
Container: types.NotificationContainer{
ID: "abc123",
Name: "test-container",
Image: "nginx:latest",
State: "running",
Health: "healthy",
HostID: "localhost",
HostName: "localhost",
Labels: map[string]string{"env": "test"},
},
Log: types.NotificationLog{
ID: 1,
Message: "This is a test log message from Dozzle",
Timestamp: time.Now().UnixMilli(),
Level: "info",
Stream: "stdout",
Type: "simple",
},
}
result := webhook.SendTest(r.Context(), mockNotification)
var statusCode *int
if result.StatusCode > 0 {
statusCode = &result.StatusCode
}
var errStr *string
if result.Error != "" {
errStr = &result.Error
}
writeJSON(w, http.StatusOK, &TestWebhookResult{
Success: result.Success,
StatusCode: statusCode,
Error: errStr,
})
}
// Releases handler
var releasesCache *cache.Cache[[]releases.Release]
func (h *handler) getReleases(w http.ResponseWriter, r *http.Request) {
if releasesCache == nil {
releasesCache = cache.New(func() ([]releases.Release, error) {
return releases.Fetch(h.config.Version)
}, time.Hour)
}
result, err := releasesCache.Get()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
-38
View File
@@ -1,38 +0,0 @@
package web
import (
"encoding/json"
"net/http"
"time"
"github.com/amir20/dozzle/internal/cache"
"github.com/amir20/dozzle/internal/releases"
"github.com/rs/zerolog/log"
)
var cachedReleases *cache.Cache[[]releases.Release]
func (h *handler) releases(w http.ResponseWriter, r *http.Request) {
if cachedReleases == nil {
cachedReleases = cache.New(func() ([]releases.Release, error) {
return releases.Fetch(h.config.Version)
}, time.Hour)
}
releases, err, hit := cachedReleases.GetWithHit()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
log.Debug().Err(err).Msg("error fetching releases")
return
}
w.Header().Set("Content-Type", "application/json")
if hit {
w.Header().Set("X-Cache", "HIT")
}
if err := json.NewEncoder(w).Encode(releases); err != nil {
log.Error().Err(err).Msg("error encoding releases")
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
+43 -1
View File
@@ -10,6 +10,8 @@ import (
"github.com/amir20/dozzle/internal/auth"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/internal/notification"
"github.com/amir20/dozzle/internal/notification/dispatcher"
container_support "github.com/amir20/dozzle/internal/support/container"
"github.com/go-chi/chi/v5"
@@ -72,6 +74,17 @@ type HostService interface {
LocalHost() (container.Host, error)
SubscribeAvailableHosts(ctx context.Context, hosts chan<- container.Host)
LocalClients() []container.Client
LocalClientServices() []container_support.ClientService
// Notification methods
AddSubscription(sub *notification.Subscription) error
RemoveSubscription(id int)
ReplaceSubscription(sub *notification.Subscription) error
UpdateSubscription(id int, updates map[string]any) error
Subscriptions() []*notification.Subscription
AddDispatcher(d dispatcher.Dispatcher) int
UpdateDispatcher(id int, d dispatcher.Dispatcher)
RemoveDispatcher(id int)
Dispatchers() []notification.DispatcherConfig
}
type handler struct {
@@ -116,6 +129,8 @@ func createRouter(h *handler) *chi.Mux {
if h.config.Authorization.Provider != NONE {
r.Use(auth.RequireAuthentication)
}
// Log streams
r.Get("/hosts/{host}/containers/{id}/logs/stream", h.streamContainerLogs)
r.Get("/hosts/{host}/logs/stream", h.streamHostLogs)
r.Get("/hosts/{host}/containers/{id}/logs", h.fetchLogsBetweenDates)
@@ -124,6 +139,8 @@ func createRouter(h *handler) *chi.Mux {
r.Get("/labels/{labels}/logs/stream", h.streamLogsWithLabels)
r.Get("/groups/{group}/logs/stream", h.streamGroupedLogs)
r.Get("/events/stream", h.streamEvents)
// Action
if h.config.EnableActions {
r.Post("/hosts/{host}/containers/{id}/actions/{action}", h.containerActions)
}
@@ -131,7 +148,7 @@ func createRouter(h *handler) *chi.Mux {
r.Get("/hosts/{host}/containers/{id}/attach", h.attach)
r.Get("/hosts/{host}/containers/{id}/exec", h.exec)
}
r.Get("/releases", h.releases)
if !h.config.DisableAvatars {
r.Get("/profile/avatar", h.avatar)
}
@@ -140,6 +157,28 @@ func createRouter(h *handler) *chi.Mux {
if log.Debug().Enabled() {
r.Get("/debug/store", h.debugStore)
}
// Notifications API
r.Route("/notifications", func(r chi.Router) {
r.Get("/rules", h.listNotificationRules)
r.Post("/rules", h.createNotificationRule)
r.Get("/rules/{id}", h.getNotificationRule)
r.Put("/rules/{id}", h.replaceNotificationRule)
r.Patch("/rules/{id}", h.updateNotificationRule)
r.Delete("/rules/{id}", h.deleteNotificationRule)
r.Get("/dispatchers", h.listDispatchers)
r.Post("/dispatchers", h.createDispatcher)
r.Get("/dispatchers/{id}", h.getDispatcher)
r.Put("/dispatchers/{id}", h.updateDispatcher)
r.Delete("/dispatchers/{id}", h.deleteDispatcher)
r.Post("/preview", h.previewExpression)
r.Post("/test-webhook", h.testWebhook)
})
// Releases API
r.Get("/releases", h.getReleases)
})
// Public API routes
@@ -147,6 +186,9 @@ func createRouter(h *handler) *chi.Mux {
r.Post("/token", h.createToken)
r.Delete("/token", h.deleteToken)
}
// Cloud callback (public, handles OAuth-style code exchange)
r.Get("/cloud/callback", h.cloudCallback)
})
r.Get("/healthcheck", h.healthcheck)
+16 -22
View File
@@ -1,9 +1,12 @@
package web
import (
"encoding/json"
"io"
"net/http"
"github.com/amir20/dozzle/internal/auth"
"github.com/amir20/dozzle/internal/container"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
@@ -48,9 +51,9 @@ func (h *handler) attach(w http.ResponseWriter, r *http.Request) {
return
}
wsReader := &webSocketReader{conn: conn}
eventReader := &jsonEventReader{conn: conn}
wsWriter := &webSocketWriter{conn: conn}
if err = containerService.Attach(r.Context(), wsReader, wsWriter); err != nil {
if err = containerService.Attach(r.Context(), eventReader, wsWriter); err != nil {
log.Error().Err(err).Msg("error while trying to attach to container")
conn.WriteMessage(websocket.TextMessage, []byte("🚨 Error while trying to attach to container\r\n"))
return
@@ -88,9 +91,9 @@ func (h *handler) exec(w http.ResponseWriter, r *http.Request) {
return
}
wsReader := &webSocketReader{conn: conn}
eventReader := &jsonEventReader{conn: conn}
wsWriter := &webSocketWriter{conn: conn}
if err = containerService.Exec(r.Context(), []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}, wsReader, wsWriter); err != nil {
if err = containerService.Exec(r.Context(), []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}, eventReader, wsWriter); err != nil {
log.Error().Err(err).Msg("error while trying to attach to container")
conn.WriteMessage(websocket.TextMessage, []byte("🚨 Error while trying to attach to container\r\n"))
return
@@ -106,30 +109,21 @@ func (w *webSocketWriter) Write(p []byte) (int, error) {
return len(p), err
}
type webSocketReader struct {
conn *websocket.Conn
buffer []byte
// jsonEventReader reads JSON-encoded ExecEvents from a websocket connection
type jsonEventReader struct {
conn *websocket.Conn
}
func (r *webSocketReader) Read(p []byte) (n int, err error) {
if len(r.buffer) > 0 {
n = copy(p, r.buffer)
r.buffer = r.buffer[n:]
return n, nil
}
// Otherwise, read a new message
func (r *jsonEventReader) ReadEvent() (*container.ExecEvent, error) {
_, message, err := r.conn.ReadMessage()
if err != nil {
return 0, err
return nil, io.EOF
}
n = copy(p, message)
// If we couldn't copy the entire message, store the rest in our buffer
if n < len(message) {
r.buffer = message[n:]
var event container.ExecEvent
if err := json.Unmarshal(message, &event); err != nil {
return nil, err
}
return n, nil
return &event, nil
}
+81
View File
@@ -17,6 +17,7 @@ action:
copy-link: Kopier permalink
see-in-context: Se i kontekst
show-details: Vis detaljer
create-alert: Opret advarsel
label:
containers: Containere
container: Ingen containere | 1 container | {count} containere
@@ -155,3 +156,83 @@ analytics:
evaluating_query: Evaluerer forespørgsel...
total_records: I alt {count} poster.
showing_first: Viser de første {count}.
notifications:
title: Notifikationer
description: Konfigurer hvor og hvornår du vil modtage alarmer
destinations: Destinationer
add-destination: Tilføj destination
alerts: Alarmer
add: Tilføj
filter:
all: Alle ({count})
enabled: Aktiveret ({count})
paused: Pauset ({count})
no-alerts: Ingen alarmer konfigureret endnu. Opret en for at komme i gang.
alert:
paused: Pauset
containers: Containere
log-filter: Log filter
destination: Destination
dispatcher-deleted: Dispatcher slettet
containers-count: "{count} containere"
triggered-count: "{count} udløst"
last-triggered: "Sidst: {time}"
destination:
http-webhook: HTTP Webhook
dozzle-cloud: Dozzle Cloud
edit: Rediger
delete: Slet
alert-form:
create-title: Opret Alarm
edit-title: Rediger Alarm
description: Abonner på log hændelser der matcher dine kriterier
alert-name: Alarm Navn
alert-name-placeholder: f.eks. Test API Fejl
container-filter: Container Filter
containers-match: "{count} containere matcher: {names}"
no-containers-match: Ingen containere matcher dette filter
log-filter: Log Filter
logs-match: "{count} logs matcher"
no-logs-match: Ingen logs matcher dette filter
destination: Destination
select-destination: Vælg en destination
no-destinations: Ingen destinationer konfigureret. Tilføj en først.
preview: Forhåndsvisning
cancel: Annuller
save: Gem
create: Opret Alarm
destination-form:
create-title: Tilføj Destination
edit-title: Rediger Destination
description: Hvor skal notifikationer sendes til?
name: Navn
name-placeholder: f.eks. Produktion Slack
type: Type
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, brugerdefineret endpoint
cloud-title: Dozzle Cloud
cloud-description: Push notifikationer, email alarmer og AI-drevne resuméer
webhook-url: Webhook URL
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: API Nøgle
payload-format: Payload Format
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Brugerdefineret
template: Skabelon
template-hint: Go skabelon syntaks
test: Test
test-success: Test succesfuld
cancel: Annuller
close: Luk
save: Gem
add: Tilføj Destination
cloud-exists: Kun én Dozzle Cloud destination kan konfigureres
link-cloud: Link Konto
link-cloud-button: Link Dozzle Cloud
cloud-settings-hint: For at konfigurere dine administrerede kanaler, gå til
cloud-settings-link: Dozzle Cloud Indstillinger
cloud-link-success:
title: Dozzle Cloud Linket
message: Din konto er blevet succesfuldt linket til Dozzle Cloud.
+81
View File
@@ -17,6 +17,7 @@ action:
copy-link: Permalink kopieren
see-in-context: Im Kontext anzeigen
show-details: Details anzeigen
create-alert: Alarm erstellen
label:
containers: Container
container: Keine Container | 1 Container | {count} Container
@@ -155,3 +156,83 @@ analytics:
evaluating_query: Abfrage wird ausgewertet...
total_records: Insgesamt {count} Datensätze.
showing_first: Zeige die ersten {count}.
notifications:
title: Benachrichtigungen
description: Konfigurieren Sie, wo und wann Sie Alarme erhalten möchten
destinations: Ziele
add-destination: Ziel hinzufügen
alerts: Alarme
add: Hinzufügen
filter:
all: Alle ({count})
enabled: Aktiviert ({count})
paused: Pausiert ({count})
no-alerts: Noch keine Alarme konfiguriert. Erstellen Sie einen, um loszulegen.
alert:
paused: Pausiert
containers: Container
log-filter: Log-Filter
destination: Ziel
dispatcher-deleted: Dispatcher gelöscht
containers-count: "{count} Container"
triggered-count: "{count} ausgelöst"
last-triggered: "Zuletzt: {time}"
destination:
http-webhook: HTTP Webhook
dozzle-cloud: Dozzle Cloud
edit: Bearbeiten
delete: Löschen
alert-form:
create-title: Alarm erstellen
edit-title: Alarm bearbeiten
description: Abonnieren Sie Log-Ereignisse, die Ihren Kriterien entsprechen
alert-name: Alarmname
alert-name-placeholder: z.B. Test API Fehler
container-filter: Container-Filter
containers-match: "{count} Container stimmen überein: {names}"
no-containers-match: Keine Container entsprechen diesem Filter
log-filter: Log-Filter
logs-match: "{count} Logs stimmen überein"
no-logs-match: Keine Logs entsprechen diesem Filter
destination: Ziel
select-destination: Wählen Sie ein Ziel
no-destinations: Keine Ziele konfiguriert. Fügen Sie zuerst eines hinzu.
preview: Vorschau
cancel: Abbrechen
save: Speichern
create: Alarm erstellen
destination-form:
create-title: Ziel hinzufügen
edit-title: Ziel bearbeiten
description: Wohin sollen Benachrichtigungen gesendet werden?
name: Name
name-placeholder: z.B. Produktion Slack
type: Typ
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, benutzerdefinierter Endpunkt
cloud-title: Dozzle Cloud
cloud-description: Push-Benachrichtigungen, E-Mail-Alarme und KI-gestützte Zusammenfassungen
webhook-url: Webhook URL
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: API-Schlüssel
payload-format: Payload-Format
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Benutzerdefiniert
template: Vorlage
template-hint: Go-Template-Syntax
test: Testen
test-success: Test erfolgreich
cancel: Abbrechen
close: Schließen
save: Speichern
add: Ziel hinzufügen
cloud-exists: Es kann nur ein Dozzle Cloud Ziel konfiguriert werden
link-cloud: Konto verknüpfen
link-cloud-button: Dozzle Cloud verknüpfen
cloud-settings-hint: Um Ihre verwalteten Kanäle zu konfigurieren, gehen Sie zu
cloud-settings-link: Dozzle Cloud Einstellungen
cloud-link-success:
title: Dozzle Cloud verknüpft
message: Ihr Konto wurde erfolgreich mit Dozzle Cloud verknüpft.
+82
View File
@@ -17,6 +17,7 @@ action:
copy-link: Copy permalink
see-in-context: See in context
show-details: Show details
create-alert: Create alert
label:
containers: Containers
container: No containers | 1 container | {count} containers
@@ -80,6 +81,7 @@ title:
login: Authentication Required
dashboard: 1 container | {count} containers
settings: Settings
notifications: Notifications
button:
logout: Logout
login: Login
@@ -163,3 +165,83 @@ analytics:
evaluating_query: Evaluating query...
total_records: Total {count} records.
showing_first: Showing first {count}.
notifications:
title: Notifications
description: Configure where and when to receive alerts
destinations: Destinations
add-destination: Add destination
alerts: Alerts
add: Add
filter:
all: All ({count})
enabled: Enabled ({count})
paused: Paused ({count})
no-alerts: No alerts configured yet. Create one to get started.
alert:
paused: Paused
containers: Containers
log-filter: Log filter
destination: Destination
dispatcher-deleted: Dispatcher deleted
containers-count: "{count} containers"
triggered-count: "{count} triggered"
last-triggered: "Last: {time}"
destination:
http-webhook: HTTP Webhook
dozzle-cloud: Dozzle Cloud
edit: Edit
delete: Delete
alert-form:
create-title: Create Alert
edit-title: Edit Alert
description: Subscribe to log events matching your criteria
alert-name: Alert Name
alert-name-placeholder: e.g., Test API Errors
container-filter: Container Filter
containers-match: "{count} containers match: {names}"
no-containers-match: No containers match this filter
log-filter: Log Filter
logs-match: "{count} logs match"
no-logs-match: No logs match this filter
destination: Destination
select-destination: Select a destination
no-destinations: No destinations configured. Add one first.
preview: Preview
cancel: Cancel
save: Save
create: Create Alert
destination-form:
create-title: Add Destination
edit-title: Edit Destination
description: Where should notifications be sent?
name: Name
name-placeholder: e.g., Production Slack
type: Type
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, custom endpoint
cloud-title: Dozzle Cloud
cloud-description: Push notifications, email alerts, and AI-powered summaries
webhook-url: Webhook URL
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: API Key
payload-format: Payload Format
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Custom
template: Template
template-hint: Go template syntax
test: Test
test-success: Test successful
cancel: Cancel
close: Close
save: Save
add: Add Destination
cloud-exists: Only one Dozzle Cloud destination can be configured
link-cloud: Link Account
link-cloud-button: Link Dozzle Cloud
cloud-settings-hint: To configure your managed channels, go to
cloud-settings-link: Dozzle Cloud Settings
cloud-link-success:
title: Dozzle Cloud Linked
message: Your account has been successfully linked to Dozzle Cloud.
+81
View File
@@ -17,6 +17,7 @@ action:
copy-link: Copiar enlace permanente
see-in-context: Ver en contexto
show-details: Mostrar detalles
create-alert: Crear alerta
label:
containers: Contenedores
container: No contenedores | 1 contenedor | {count} contenedores
@@ -155,3 +156,83 @@ analytics:
evaluating_query: Evaluando consulta...
total_records: Total {count} registros.
showing_first: Mostrando los primeros {count}.
notifications:
title: Notificaciones
description: Configure dónde y cuándo recibir alertas
destinations: Destinos
add-destination: Añadir destino
alerts: Alertas
add: Añadir
filter:
all: Todos ({count})
enabled: Habilitados ({count})
paused: Pausados ({count})
no-alerts: Aún no hay alertas configuradas. Cree una para comenzar.
alert:
paused: Pausado
containers: Contenedores
log-filter: Filtro de registro
destination: Destino
dispatcher-deleted: Dispatcher eliminado
containers-count: "{count} contenedores"
triggered-count: "{count} activados"
last-triggered: "Último: {time}"
destination:
http-webhook: HTTP Webhook
dozzle-cloud: Dozzle Cloud
edit: Editar
delete: Eliminar
alert-form:
create-title: Crear Alerta
edit-title: Editar Alerta
description: Suscríbase a eventos de registro que coincidan con sus criterios
alert-name: Nombre de la Alerta
alert-name-placeholder: ej., Errores de API de Prueba
container-filter: Filtro de Contenedor
containers-match: "{count} contenedores coinciden: {names}"
no-containers-match: Ningún contenedor coincide con este filtro
log-filter: Filtro de Registro
logs-match: "{count} registros coinciden"
no-logs-match: Ningún registro coincide con este filtro
destination: Destino
select-destination: Seleccione un destino
no-destinations: No hay destinos configurados. Añada uno primero.
preview: Vista previa
cancel: Cancelar
save: Guardar
create: Crear Alerta
destination-form:
create-title: Añadir Destino
edit-title: Editar Destino
description: ¿Dónde deben enviarse las notificaciones?
name: Nombre
name-placeholder: ej., Slack de Producción
type: Tipo
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, endpoint personalizado
cloud-title: Dozzle Cloud
cloud-description: Notificaciones push, alertas por correo electrónico y resúmenes con IA
webhook-url: URL del Webhook
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: Clave API
payload-format: Formato de Payload
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Personalizado
template: Plantilla
template-hint: Sintaxis de plantilla Go
test: Probar
test-success: Prueba exitosa
cancel: Cancelar
close: Cerrar
save: Guardar
add: Añadir Destino
cloud-exists: Solo se puede configurar un destino de Dozzle Cloud
link-cloud: Vincular Cuenta
link-cloud-button: Vincular Dozzle Cloud
cloud-settings-hint: Para configurar sus canales administrados, vaya a
cloud-settings-link: Configuración de Dozzle Cloud
cloud-link-success:
title: Dozzle Cloud Vinculado
message: Su cuenta se ha vinculado correctamente a Dozzle Cloud.
+81
View File
@@ -17,6 +17,7 @@ action:
copy-link: Copier le permalien
see-in-context: Voir dans le contexte
show-details: Afficher les détails
create-alert: Créer une alerte
label:
containers: Conteneurs
container: Pas de conteneur | 1 conteneur | {count} conteneurs
@@ -155,3 +156,83 @@ analytics:
evaluating_query: Évaluation de la requête...
total_records: Total de {count} enregistrements.
showing_first: Affichage des {count} premiers.
notifications:
title: Notifications
description: Configurez où et quand recevoir des alertes
destinations: Destinations
add-destination: Ajouter une destination
alerts: Alertes
add: Ajouter
filter:
all: Tous ({count})
enabled: Activés ({count})
paused: En pause ({count})
no-alerts: Aucune alerte configurée. Créez-en une pour commencer.
alert:
paused: En pause
containers: Conteneurs
log-filter: Filtre de journal
destination: Destination
dispatcher-deleted: Dispatcher supprimé
containers-count: "{count} conteneurs"
triggered-count: "{count} déclenchés"
last-triggered: "Dernier: {time}"
destination:
http-webhook: HTTP Webhook
dozzle-cloud: Dozzle Cloud
edit: Modifier
delete: Supprimer
alert-form:
create-title: Créer une Alerte
edit-title: Modifier l'Alerte
description: Abonnez-vous aux événements de journal correspondant à vos critères
alert-name: Nom de l'Alerte
alert-name-placeholder: ex., Erreurs API de Test
container-filter: Filtre de Conteneur
containers-match: "{count} conteneurs correspondent: {names}"
no-containers-match: Aucun conteneur ne correspond à ce filtre
log-filter: Filtre de Journal
logs-match: "{count} journaux correspondent"
no-logs-match: Aucun journal ne correspond à ce filtre
destination: Destination
select-destination: Sélectionnez une destination
no-destinations: Aucune destination configurée. Ajoutez-en une d'abord.
preview: Aperçu
cancel: Annuler
save: Enregistrer
create: Créer l'Alerte
destination-form:
create-title: Ajouter une Destination
edit-title: Modifier la Destination
description: Où les notifications doivent-elles être envoyées?
name: Nom
name-placeholder: ex., Slack Production
type: Type
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, endpoint personnalisé
cloud-title: Dozzle Cloud
cloud-description: Notifications push, alertes par e-mail et résumés alimentés par l'IA
webhook-url: URL du Webhook
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: Clé API
payload-format: Format du Payload
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Personnalisé
template: Modèle
template-hint: Syntaxe de modèle Go
test: Tester
test-success: Test réussi
cancel: Annuler
close: Fermer
save: Enregistrer
add: Ajouter la Destination
cloud-exists: Une seule destination Dozzle Cloud peut être configurée
link-cloud: Lier le Compte
link-cloud-button: Lier Dozzle Cloud
cloud-settings-hint: Pour configurer vos canaux gérés, allez à
cloud-settings-link: Paramètres Dozzle Cloud
cloud-link-success:
title: Dozzle Cloud Lié
message: Votre compte a été lié avec succès à Dozzle Cloud.
+81
View File
@@ -17,6 +17,7 @@ action:
copy-link: Salin permalink
see-in-context: Lihat dalam konteks
show-details: Tampilkan detail
create-alert: Buat peringatan
label:
containers: Kontainer
@@ -167,3 +168,83 @@ analytics:
evaluating_query: Mengevaluasi kueri...
total_records: Total {count} entri.
showing_first: Menampilkan {count} pertama.
notifications:
title: Notifikasi
description: Konfigurasikan di mana dan kapan menerima peringatan
destinations: Tujuan
add-destination: Tambah tujuan
alerts: Peringatan
add: Tambah
filter:
all: Semua ({count})
enabled: Diaktifkan ({count})
paused: Dijeda ({count})
no-alerts: Belum ada peringatan yang dikonfigurasi. Buat satu untuk memulai.
alert:
paused: Dijeda
containers: Kontainer
log-filter: Filter log
destination: Tujuan
dispatcher-deleted: Dispatcher dihapus
containers-count: "{count} kontainer"
triggered-count: "{count} terpicu"
last-triggered: "Terakhir: {time}"
destination:
http-webhook: HTTP Webhook
dozzle-cloud: Dozzle Cloud
edit: Edit
delete: Hapus
alert-form:
create-title: Buat Peringatan
edit-title: Edit Peringatan
description: Berlangganan ke event log yang sesuai dengan kriteria Anda
alert-name: Nama Peringatan
alert-name-placeholder: mis., Kesalahan API Test
container-filter: Filter Kontainer
containers-match: "{count} kontainer cocok: {names}"
no-containers-match: Tidak ada kontainer yang cocok dengan filter ini
log-filter: Filter Log
logs-match: "{count} log cocok"
no-logs-match: Tidak ada log yang cocok dengan filter ini
destination: Tujuan
select-destination: Pilih tujuan
no-destinations: Tidak ada tujuan yang dikonfigurasi. Tambahkan satu terlebih dahulu.
preview: Pratinjau
cancel: Batal
save: Simpan
create: Buat Peringatan
destination-form:
create-title: Tambah Tujuan
edit-title: Edit Tujuan
description: Ke mana notifikasi harus dikirim?
name: Nama
name-placeholder: mis., Slack Produksi
type: Tipe
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, endpoint kustom
cloud-title: Dozzle Cloud
cloud-description: Notifikasi push, peringatan email, dan ringkasan bertenaga AI
webhook-url: URL Webhook
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: Kunci API
payload-format: Format Payload
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Kustom
template: Template
template-hint: Sintaks template Go
test: Test
test-success: Test berhasil
cancel: Batal
close: Tutup
save: Simpan
add: Tambah Tujuan
cloud-exists: Hanya satu tujuan Dozzle Cloud yang dapat dikonfigurasi
link-cloud: Tautkan Akun
link-cloud-button: Tautkan Dozzle Cloud
cloud-settings-hint: Untuk mengonfigurasi saluran terkelola Anda, buka
cloud-settings-link: Pengaturan Dozzle Cloud
cloud-link-success:
title: Dozzle Cloud Tertaut
message: Akun Anda telah berhasil ditautkan ke Dozzle Cloud.
+81
View File
@@ -17,6 +17,7 @@ action:
copy-link: Copia permalink
see-in-context: Vedi nel contesto
show-details: Mostra dettagli
create-alert: Crea avviso
label:
containers: Container
container: Nessun container | 1 container | {count} container
@@ -155,3 +156,83 @@ analytics:
evaluating_query: Valutazione della query...
total_records: Totale {count} record.
showing_first: Mostrati i primi {count}.
notifications:
title: Notifiche
description: Configura dove e quando ricevere avvisi
destinations: Destinazioni
add-destination: Aggiungi destinazione
alerts: Avvisi
add: Aggiungi
filter:
all: Tutti ({count})
enabled: Abilitati ({count})
paused: In pausa ({count})
no-alerts: Nessun avviso configurato. Creane uno per iniziare.
alert:
paused: In pausa
containers: Container
log-filter: Filtro log
destination: Destinazione
dispatcher-deleted: Dispatcher eliminato
containers-count: "{count} container"
triggered-count: "{count} attivati"
last-triggered: "Ultimo: {time}"
destination:
http-webhook: HTTP Webhook
dozzle-cloud: Dozzle Cloud
edit: Modifica
delete: Elimina
alert-form:
create-title: Crea Avviso
edit-title: Modifica Avviso
description: Iscriviti agli eventi di log che corrispondono ai tuoi criteri
alert-name: Nome Avviso
alert-name-placeholder: es., Errori API Test
container-filter: Filtro Container
containers-match: "{count} container corrispondono: {names}"
no-containers-match: Nessun container corrisponde a questo filtro
log-filter: Filtro Log
logs-match: "{count} log corrispondono"
no-logs-match: Nessun log corrisponde a questo filtro
destination: Destinazione
select-destination: Seleziona una destinazione
no-destinations: Nessuna destinazione configurata. Aggiungine una prima.
preview: Anteprima
cancel: Annulla
save: Salva
create: Crea Avviso
destination-form:
create-title: Aggiungi Destinazione
edit-title: Modifica Destinazione
description: Dove devono essere inviate le notifiche?
name: Nome
name-placeholder: es., Slack Produzione
type: Tipo
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, endpoint personalizzato
cloud-title: Dozzle Cloud
cloud-description: Notifiche push, avvisi email e riepiloghi basati su AI
webhook-url: URL Webhook
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: Chiave API
payload-format: Formato Payload
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Personalizzato
template: Template
template-hint: Sintassi template Go
test: Test
test-success: Test riuscito
cancel: Annulla
close: Chiudi
save: Salva
add: Aggiungi Destinazione
cloud-exists: È possibile configurare solo una destinazione Dozzle Cloud
link-cloud: Collega Account
link-cloud-button: Collega Dozzle Cloud
cloud-settings-hint: Per configurare i tuoi canali gestiti, vai a
cloud-settings-link: Impostazioni Dozzle Cloud
cloud-link-success:
title: Dozzle Cloud Collegato
message: Il tuo account è stato collegato con successo a Dozzle Cloud.
+81
View File
@@ -17,6 +17,7 @@ action:
copy-link: 영구 링크 복사
see-in-context: 컨텍스트에서 보기
show-details: 세부 정보 표시
create-alert: 알림 생성
label:
containers: 컨테이너
container: 컨테이너가 없습니다 | 컨테이너 1개 | 컨테이너 {count}개
@@ -158,3 +159,83 @@ analytics:
evaluating_query: 쿼리를 실행하고 있습니다...
total_records: 총 {count}개 기록
showing_first: 처음 {count}개 표시
notifications:
title: 알림
description: 알림을 받을 위치와 시간을 설정합니다
destinations: 목적지
add-destination: 목적지 추가
alerts: 알림
add: 추가
filter:
all: 전체 ({count})
enabled: 활성화됨 ({count})
paused: 일시정지됨 ({count})
no-alerts: 아직 설정된 알림이 없습니다. 시작하려면 하나를 만드세요.
alert:
paused: 일시정지됨
containers: 컨테이너
log-filter: 로그 필터
destination: 목적지
dispatcher-deleted: 디스패처 삭제됨
containers-count: "컨테이너 {count}개"
triggered-count: "{count}회 트리거됨"
last-triggered: "마지막: {time}"
destination:
http-webhook: HTTP 웹훅
dozzle-cloud: Dozzle Cloud
edit: 편집
delete: 삭제
alert-form:
create-title: 알림 만들기
edit-title: 알림 편집
description: 기준에 맞는 로그 이벤트를 구독합니다
alert-name: 알림 이름
alert-name-placeholder: "예: 테스트 API 오류"
container-filter: 컨테이너 필터
containers-match: "컨테이너 {count}개 일치: {names}"
no-containers-match: 이 필터와 일치하는 컨테이너가 없습니다
log-filter: 로그 필터
logs-match: "로그 {count}개 일치"
no-logs-match: 이 필터와 일치하는 로그가 없습니다
destination: 목적지
select-destination: 목적지 선택
no-destinations: 설정된 목적지가 없습니다. 먼저 하나를 추가하세요.
preview: 미리보기
cancel: 취소
save: 저장
create: 알림 만들기
destination-form:
create-title: 목적지 추가
edit-title: 목적지 편집
description: 알림을 어디로 보낼까요?
name: 이름
name-placeholder: "예: 프로덕션 Slack"
type: 유형
webhook-title: HTTP 웹훅
webhook-description: Slack, Discord, 사용자 정의 엔드포인트
cloud-title: Dozzle Cloud
cloud-description: 푸시 알림, 이메일 알림 및 AI 기반 요약
webhook-url: 웹훅 URL
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: API 키
payload-format: 페이로드 형식
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: 사용자 정의
template: 템플릿
template-hint: Go 템플릿 구문
test: 테스트
test-success: 테스트 성공
cancel: 취소
close: 닫기
save: 저장
add: 목적지 추가
cloud-exists: Dozzle Cloud 목적지는 하나만 설정할 수 있습니다
link-cloud: 계정 연결
link-cloud-button: Dozzle Cloud 연결
cloud-settings-hint: 관리 채널을 설정하려면 다음으로 이동하세요
cloud-settings-link: Dozzle Cloud 설정
cloud-link-success:
title: Dozzle Cloud 연결됨
message: 계정이 Dozzle Cloud에 성공적으로 연결되었습니다.
+81
View File
@@ -17,6 +17,7 @@ action:
copy-link: Permalink kopiëren
see-in-context: Bekijk in context
show-details: Toon details
create-alert: Waarschuwing aanmaken
label:
containers: Containers
container: Geen containers | 1 container | {count} containers
@@ -156,3 +157,83 @@ analytics:
evaluating_query: Query wordt geëvalueerd...
total_records: In totaal {count} resultaten.
showing_first: Eerste {count} worden weergegeven.
notifications:
title: Meldingen
description: Configureer waar en wanneer je waarschuwingen ontvangt
destinations: Bestemmingen
add-destination: Bestemming toevoegen
alerts: Waarschuwingen
add: Toevoegen
filter:
all: Alle ({count})
enabled: Ingeschakeld ({count})
paused: Gepauzeerd ({count})
no-alerts: Nog geen waarschuwingen geconfigureerd. Maak er een om te beginnen.
alert:
paused: Gepauzeerd
containers: Containers
log-filter: Logfilter
destination: Bestemming
dispatcher-deleted: Dispatcher verwijderd
containers-count: "{count} containers"
triggered-count: "{count} geactiveerd"
last-triggered: "Laatst: {time}"
destination:
http-webhook: HTTP Webhook
dozzle-cloud: Dozzle Cloud
edit: Bewerken
delete: Verwijderen
alert-form:
create-title: Waarschuwing aanmaken
edit-title: Waarschuwing bewerken
description: Abonneer op loggebeurtenissen die voldoen aan je criteria
alert-name: Waarschuwingsnaam
alert-name-placeholder: bijv. Test API-fouten
container-filter: Containerfilter
containers-match: "{count} containers komen overeen: {names}"
no-containers-match: Geen containers komen overeen met dit filter
log-filter: Logfilter
logs-match: "{count} logs komen overeen"
no-logs-match: Geen logs komen overeen met dit filter
destination: Bestemming
select-destination: Selecteer een bestemming
no-destinations: Geen bestemmingen geconfigureerd. Voeg er eerst een toe.
preview: Voorbeeld
cancel: Annuleren
save: Opslaan
create: Waarschuwing aanmaken
destination-form:
create-title: Bestemming toevoegen
edit-title: Bestemming bewerken
description: Waar moeten meldingen naartoe worden gestuurd?
name: Naam
name-placeholder: bijv. Productie Slack
type: Type
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, aangepast eindpunt
cloud-title: Dozzle Cloud
cloud-description: Pushmeldingen, e-mailwaarschuwingen en AI-samenvattingen
webhook-url: Webhook URL
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: API-sleutel
payload-format: Payload-formaat
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Aangepast
template: Sjabloon
template-hint: Go-sjabloonsyntaxis
test: Testen
test-success: Test geslaagd
cancel: Annuleren
close: Sluiten
save: Opslaan
add: Bestemming toevoegen
cloud-exists: Er kan slechts één Dozzle Cloud-bestemming worden geconfigureerd
link-cloud: Account koppelen
link-cloud-button: Dozzle Cloud koppelen
cloud-settings-hint: Om je beheerde kanalen te configureren, ga naar
cloud-settings-link: Dozzle Cloud-instellingen
cloud-link-success:
title: Dozzle Cloud gekoppeld
message: Je account is succesvol gekoppeld aan Dozzle Cloud.
+81
View File
@@ -17,6 +17,7 @@ action:
copy-link: Skopiuj permalink
see-in-context: Zobacz w kontekście
show-details: Pokaż szczegóły
create-alert: Utwórz alert
label:
service: Brak usług | 1 usługa | {count} usług
host-count: Brak Hostów | 1 Host | {count} Hostów
@@ -162,3 +163,83 @@ analytics:
evaluating_query: Przetwarzanie zapytania...
total_records: Razem {count} rekordów.
showing_first: Pokazywanie pierwszych {count}.
notifications:
title: Powiadomienia
description: Skonfiguruj gdzie i kiedy otrzymywać alerty
destinations: Miejsca docelowe
add-destination: Dodaj miejsce docelowe
alerts: Alerty
add: Dodaj
filter:
all: Wszystkie ({count})
enabled: Włączone ({count})
paused: Wstrzymane ({count})
no-alerts: Brak skonfigurowanych alertów. Utwórz jeden, aby rozpocząć.
alert:
paused: Wstrzymany
containers: Kontenery
log-filter: Filtr logów
destination: Miejsce docelowe
dispatcher-deleted: Dispatcher usunięty
containers-count: "{count} kontenerów"
triggered-count: "{count} wyzwolonych"
last-triggered: "Ostatnio: {time}"
destination:
http-webhook: HTTP Webhook
dozzle-cloud: Dozzle Cloud
edit: Edytuj
delete: Usuń
alert-form:
create-title: Utwórz Alert
edit-title: Edytuj Alert
description: Subskrybuj zdarzenia logów pasujące do twoich kryteriów
alert-name: Nazwa Alertu
alert-name-placeholder: np. Błędy API Testowego
container-filter: Filtr Kontenerów
containers-match: "{count} kontenerów pasuje: {names}"
no-containers-match: Żaden kontener nie pasuje do tego filtra
log-filter: Filtr Logów
logs-match: "{count} logów pasuje"
no-logs-match: Żaden log nie pasuje do tego filtra
destination: Miejsce docelowe
select-destination: Wybierz miejsce docelowe
no-destinations: Brak skonfigurowanych miejsc docelowych. Najpierw dodaj jedno.
preview: Podgląd
cancel: Anuluj
save: Zapisz
create: Utwórz Alert
destination-form:
create-title: Dodaj Miejsce Docelowe
edit-title: Edytuj Miejsce Docelowe
description: Gdzie mają być wysyłane powiadomienia?
name: Nazwa
name-placeholder: np. Produkcyjny Slack
type: Typ
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, własny endpoint
cloud-title: Dozzle Cloud
cloud-description: Powiadomienia push, alerty e-mail i podsumowania AI
webhook-url: URL Webhooka
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: Klucz API
payload-format: Format Payload
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Własny
template: Szablon
template-hint: Składnia szablonu Go
test: Test
test-success: Test zakończony pomyślnie
cancel: Anuluj
close: Zamknij
save: Zapisz
add: Dodaj Miejsce Docelowe
cloud-exists: Można skonfigurować tylko jedno miejsce docelowe Dozzle Cloud
link-cloud: Połącz Konto
link-cloud-button: Połącz Dozzle Cloud
cloud-settings-hint: Aby skonfigurować zarządzane kanały, przejdź do
cloud-settings-link: Ustawienia Dozzle Cloud
cloud-link-success:
title: Dozzle Cloud Połączony
message: Twoje konto zostało pomyślnie połączone z Dozzle Cloud.
+81
View File
@@ -17,6 +17,7 @@ action:
copy-link: Copiar hiperligação permanente
see-in-context: Ver no contexto
show-details: Mostrar detalhes
create-alert: Criar alerta
label:
containers: Contentores
container: Nenhum contentor | 1 contentor | {count} contentores
@@ -164,3 +165,83 @@ analytics:
evaluating_query: A avaliar consulta...
total_records: Total de {count} registos.
showing_first: A mostrar os primeiros {count}.
notifications:
title: Notificações
description: Configure onde e quando receber alertas
destinations: Destinos
add-destination: Adicionar destino
alerts: Alertas
add: Adicionar
filter:
all: Todos ({count})
enabled: Ativados ({count})
paused: Pausados ({count})
no-alerts: Ainda não há alertas configurados. Crie um para começar.
alert:
paused: Pausado
containers: Contentores
log-filter: Filtro de registo
destination: Destino
dispatcher-deleted: Dispatcher eliminado
containers-count: "{count} contentores"
triggered-count: "{count} acionados"
last-triggered: "Último: {time}"
destination:
http-webhook: HTTP Webhook
dozzle-cloud: Dozzle Cloud
edit: Editar
delete: Eliminar
alert-form:
create-title: Criar Alerta
edit-title: Editar Alerta
description: Subscreva eventos de registo que correspondam aos seus critérios
alert-name: Nome do Alerta
alert-name-placeholder: ex., Erros de API de Teste
container-filter: Filtro de Contentor
containers-match: "{count} contentores correspondem: {names}"
no-containers-match: Nenhum contentor corresponde a este filtro
log-filter: Filtro de Registo
logs-match: "{count} registos correspondem"
no-logs-match: Nenhum registo corresponde a este filtro
destination: Destino
select-destination: Selecione um destino
no-destinations: Nenhum destino configurado. Adicione um primeiro.
preview: Pré-visualização
cancel: Cancelar
save: Guardar
create: Criar Alerta
destination-form:
create-title: Adicionar Destino
edit-title: Editar Destino
description: Para onde devem ser enviadas as notificações?
name: Nome
name-placeholder: ex., Slack de Produção
type: Tipo
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, endpoint personalizado
cloud-title: Dozzle Cloud
cloud-description: Notificações push, alertas por email e resumos com IA
webhook-url: URL do Webhook
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: Chave API
payload-format: Formato do Payload
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Personalizado
template: Modelo
template-hint: Sintaxe de modelo Go
test: Testar
test-success: Teste bem-sucedido
cancel: Cancelar
close: Fechar
save: Guardar
add: Adicionar Destino
cloud-exists: Apenas um destino Dozzle Cloud pode ser configurado
link-cloud: Ligar Conta
link-cloud-button: Ligar Dozzle Cloud
cloud-settings-hint: Para configurar os seus canais geridos, vá a
cloud-settings-link: Definições do Dozzle Cloud
cloud-link-success:
title: Dozzle Cloud Ligado
message: A sua conta foi ligada com sucesso ao Dozzle Cloud.
+81
View File
@@ -17,6 +17,7 @@ action:
copy-link: Copiar permalink
see-in-context: Ver no contexto
show-details: Mostrar detalhes
create-alert: Criar alerta
label:
containers: Contêineres
container: Nenhum container | 1 container | {count} containers
@@ -154,3 +155,83 @@ analytics:
evaluating_query: Avaliando consulta...
total_records: Total de {count} registros.
showing_first: Mostrando os primeiros {count}.
notifications:
title: Notificações
description: Configure onde e quando receber alertas
destinations: Destinos
add-destination: Adicionar destino
alerts: Alertas
add: Adicionar
filter:
all: Todos ({count})
enabled: Habilitados ({count})
paused: Pausados ({count})
no-alerts: Nenhum alerta configurado ainda. Crie um para começar.
alert:
paused: Pausado
containers: Containers
log-filter: Filtro de log
destination: Destino
dispatcher-deleted: Dispatcher excluído
containers-count: "{count} containers"
triggered-count: "{count} acionados"
last-triggered: "Último: {time}"
destination:
http-webhook: HTTP Webhook
dozzle-cloud: Dozzle Cloud
edit: Editar
delete: Excluir
alert-form:
create-title: Criar Alerta
edit-title: Editar Alerta
description: Inscreva-se em eventos de log que correspondam aos seus critérios
alert-name: Nome do Alerta
alert-name-placeholder: ex., Erros de API de Teste
container-filter: Filtro de Container
containers-match: "{count} containers correspondem: {names}"
no-containers-match: Nenhum container corresponde a este filtro
log-filter: Filtro de Log
logs-match: "{count} logs correspondem"
no-logs-match: Nenhum log corresponde a este filtro
destination: Destino
select-destination: Selecione um destino
no-destinations: Nenhum destino configurado. Adicione um primeiro.
preview: Prévia
cancel: Cancelar
save: Salvar
create: Criar Alerta
destination-form:
create-title: Adicionar Destino
edit-title: Editar Destino
description: Para onde as notificações devem ser enviadas?
name: Nome
name-placeholder: ex., Slack de Produção
type: Tipo
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, endpoint personalizado
cloud-title: Dozzle Cloud
cloud-description: Notificações push, alertas por email e resumos com IA
webhook-url: URL do Webhook
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: Chave API
payload-format: Formato do Payload
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Personalizado
template: Template
template-hint: Sintaxe de template Go
test: Testar
test-success: Teste bem-sucedido
cancel: Cancelar
close: Fechar
save: Salvar
add: Adicionar Destino
cloud-exists: Apenas um destino Dozzle Cloud pode ser configurado
link-cloud: Vincular Conta
link-cloud-button: Vincular Dozzle Cloud
cloud-settings-hint: Para configurar seus canais gerenciados, vá para
cloud-settings-link: Configurações do Dozzle Cloud
cloud-link-success:
title: Dozzle Cloud Vinculado
message: Sua conta foi vinculada com sucesso ao Dozzle Cloud.
+81
View File
@@ -17,6 +17,7 @@ action:
copy-link: Копировать постоянную ссылку
see-in-context: Посмотреть в контексте
show-details: Показать детали
create-alert: Создать оповещение
label:
containers: Контейнеры
container: Нет контейнеров | 1 контейнер | {count} контейнеров
@@ -155,3 +156,83 @@ analytics:
evaluating_query: Выполнение запроса...
total_records: Всего {count} записей.
showing_first: Показаны первые {count}.
notifications:
title: Уведомления
description: Настройте где и когда получать оповещения
destinations: Назначения
add-destination: Добавить назначение
alerts: Оповещения
add: Добавить
filter:
all: Все ({count})
enabled: Включены ({count})
paused: Приостановлены ({count})
no-alerts: Оповещения ещё не настроены. Создайте одно для начала.
alert:
paused: Приостановлено
containers: Контейнеры
log-filter: Фильтр логов
destination: Назначение
dispatcher-deleted: Диспетчер удалён
containers-count: "{count} контейнеров"
triggered-count: "{count} сработало"
last-triggered: "Последнее: {time}"
destination:
http-webhook: HTTP Webhook
dozzle-cloud: Dozzle Cloud
edit: Редактировать
delete: Удалить
alert-form:
create-title: Создать Оповещение
edit-title: Редактировать Оповещение
description: Подпишитесь на события логов, соответствующие вашим критериям
alert-name: Имя Оповещения
alert-name-placeholder: напр., Ошибки Тестового API
container-filter: Фильтр Контейнеров
containers-match: "{count} контейнеров соответствует: {names}"
no-containers-match: Нет контейнеров, соответствующих этому фильтру
log-filter: Фильтр Логов
logs-match: "{count} логов соответствует"
no-logs-match: Нет логов, соответствующих этому фильтру
destination: Назначение
select-destination: Выберите назначение
no-destinations: Назначения не настроены. Сначала добавьте одно.
preview: Предпросмотр
cancel: Отмена
save: Сохранить
create: Создать Оповещение
destination-form:
create-title: Добавить Назначение
edit-title: Редактировать Назначение
description: Куда отправлять уведомления?
name: Имя
name-placeholder: напр., Продакшн Slack
type: Тип
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, пользовательская конечная точка
cloud-title: Dozzle Cloud
cloud-description: Push-уведомления, email-оповещения и сводки на основе ИИ
webhook-url: URL Webhook
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: API Ключ
payload-format: Формат Payload
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Пользовательский
template: Шаблон
template-hint: Синтаксис шаблона Go
test: Тест
test-success: Тест успешен
cancel: Отмена
close: Закрыть
save: Сохранить
add: Добавить Назначение
cloud-exists: Можно настроить только одно назначение Dozzle Cloud
link-cloud: Связать Аккаунт
link-cloud-button: Связать Dozzle Cloud
cloud-settings-hint: Чтобы настроить управляемые каналы, перейдите в
cloud-settings-link: Настройки Dozzle Cloud
cloud-link-success:
title: Dozzle Cloud Связан
message: Ваш аккаунт успешно связан с Dozzle Cloud.
+81
View File
@@ -17,6 +17,7 @@ action:
copy-link: Kopiraj trajno povezavo
see-in-context: Glej v kontekstu
show-details: Prikaži podrobnosti
create-alert: Ustvari opozorilo
label:
containers: Zabojniki
host-count: Ni Gostiteljev | 1 Gostitelj | {count} Gostiteljev
@@ -160,3 +161,83 @@ analytics:
total_records: Skupaj zapisov {count}.
showing_first: Prikaz prvih {count}.
creating_table: Ustvarjanje začasne tabele...
notifications:
title: Obvestila
description: Nastavite kje in kdaj prejemati opozorila
destinations: Cilji
add-destination: Dodaj cilj
alerts: Opozorila
add: Dodaj
filter:
all: Vsi ({count})
enabled: Omogočeni ({count})
paused: Ustavljeni ({count})
no-alerts: Opozorila še niso nastavljena. Ustvarite enega za začetek.
alert:
paused: Ustavljeno
containers: Zabojniki
log-filter: Filter dnevnika
destination: Cilj
dispatcher-deleted: Pošiljatelj izbrisan
containers-count: "{count} zabojnikov"
triggered-count: "{count} sproženih"
last-triggered: "Zadnje: {time}"
destination:
http-webhook: HTTP Webhook
dozzle-cloud: Dozzle Cloud
edit: Uredi
delete: Izbriši
alert-form:
create-title: Ustvari Opozorilo
edit-title: Uredi Opozorilo
description: Naročite se na dogodke dnevnika, ki ustrezajo vašim kriterijem
alert-name: Ime Opozorila
alert-name-placeholder: npr., Napake Testnega API-ja
container-filter: Filter Zabojnikov
containers-match: "{count} zabojnikov ustreza: {names}"
no-containers-match: Noben zabojnik ne ustreza temu filtru
log-filter: Filter Dnevnika
logs-match: "{count} dnevnikov ustreza"
no-logs-match: Noben dnevnik ne ustreza temu filtru
destination: Cilj
select-destination: Izberite cilj
no-destinations: Cilji niso nastavljeni. Najprej dodajte enega.
preview: Predogled
cancel: Prekliči
save: Shrani
create: Ustvari Opozorilo
destination-form:
create-title: Dodaj Cilj
edit-title: Uredi Cilj
description: Kam naj se pošiljajo obvestila?
name: Ime
name-placeholder: npr., Produkcijski Slack
type: Tip
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, končna točka po meri
cloud-title: Dozzle Cloud
cloud-description: Push obvestila, e-poštna opozorila in povzetki z umetno inteligenco
webhook-url: URL Webhook
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: API Ključ
payload-format: Format Payload
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Po meri
template: Predloga
template-hint: Sintaksa predloge Go
test: Test
test-success: Test uspešen
cancel: Prekliči
close: Zapri
save: Shrani
add: Dodaj Cilj
cloud-exists: Nastavljen je lahko samo en cilj Dozzle Cloud
link-cloud: Poveži Račun
link-cloud-button: Poveži Dozzle Cloud
cloud-settings-hint: Za nastavitev upravljanih kanalov pojdite na
cloud-settings-link: Nastavitve Dozzle Cloud
cloud-link-success:
title: Dozzle Cloud Povezan
message: Vaš račun je bil uspešno povezan z Dozzle Cloud.
+81
View File
@@ -17,6 +17,7 @@ action:
copy-link: Kalıcı bağlantıyı kopyala
see-in-context: Bağlamda gör
show-details: Detayları göster
create-alert: Uyarı oluştur
label:
containers: Konteynerlar
container: Konteyner yok | 1 konteyner | {count} konteyner
@@ -155,3 +156,83 @@ analytics:
evaluating_query: Sorgu değerlendiriliyor...
total_records: Toplam {count} kayıt.
showing_first: İlk {count} gösteriliyor.
notifications:
title: Bildirimler
description: Uyarıları nerede ve ne zaman alacağınızı yapılandırın
destinations: Hedefler
add-destination: Hedef ekle
alerts: Uyarılar
add: Ekle
filter:
all: Tümü ({count})
enabled: Etkin ({count})
paused: Duraklatılmış ({count})
no-alerts: Henüz uyarı yapılandırılmadı. Başlamak için bir tane oluşturun.
alert:
paused: Duraklatıldı
containers: Konteynerler
log-filter: Günlük filtresi
destination: Hedef
dispatcher-deleted: Dağıtıcı silindi
containers-count: "{count} konteyner"
triggered-count: "{count} tetiklendi"
last-triggered: "Son: {time}"
destination:
http-webhook: HTTP Webhook
dozzle-cloud: Dozzle Cloud
edit: Düzenle
delete: Sil
alert-form:
create-title: Uyarı Oluştur
edit-title: Uyarıyı Düzenle
description: Kriterlerinize uyan günlük olaylarına abone olun
alert-name: Uyarı Adı
alert-name-placeholder: ör., Test API Hataları
container-filter: Konteyner Filtresi
containers-match: "{count} konteyner eşleşiyor: {names}"
no-containers-match: Bu filtreyle eşleşen konteyner yok
log-filter: Günlük Filtresi
logs-match: "{count} günlük eşleşiyor"
no-logs-match: Bu filtreyle eşleşen günlük yok
destination: Hedef
select-destination: Bir hedef seçin
no-destinations: Yapılandırılmış hedef yok. Önce bir tane ekleyin.
preview: Önizleme
cancel: İptal
save: Kaydet
create: Uyarı Oluştur
destination-form:
create-title: Hedef Ekle
edit-title: Hedefi Düzenle
description: Bildirimler nereye gönderilmeli?
name: Ad
name-placeholder: ör., Prodüksiyon Slack
type: Tür
webhook-title: HTTP Webhook
webhook-description: Slack, Discord, özel uç nokta
cloud-title: Dozzle Cloud
cloud-description: Push bildirimleri, e-posta uyarıları ve yapay zeka destekli özetler
webhook-url: Webhook URL
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: API Anahtarı
payload-format: Payload Formatı
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: Özel
template: Şablon
template-hint: Go şablon söz dizimi
test: Test
test-success: Test başarılı
cancel: İptal
close: Kapat
save: Kaydet
add: Hedef Ekle
cloud-exists: Yalnızca bir Dozzle Cloud hedefi yapılandırılabilir
link-cloud: Hesabı Bağla
link-cloud-button: Dozzle Cloud'u Bağla
cloud-settings-hint: Yönetilen kanallarınızı yapılandırmak için şuraya gidin
cloud-settings-link: Dozzle Cloud Ayarları
cloud-link-success:
title: Dozzle Cloud Bağlandı
message: Hesabınız Dozzle Cloud'a başarıyla bağlandı.
+81
View File
@@ -17,6 +17,7 @@ action:
copy-link: 複製永久連結
see-in-context: 在內容中查看
show-details: 顯示詳細資訊
create-alert: 建立警報
label:
containers: 容器
container: 無容器 | 1 個容器 | {count} 個容器
@@ -158,3 +159,83 @@ analytics:
evaluating_query: 正在評估查詢...
total_records: 共計 {count} 筆記錄。
showing_first: 顯示前 {count} 筆。
notifications:
title: 通知
description: 設定接收警報的位置和時間
destinations: 目標
add-destination: 新增目標
alerts: 警報
add: 新增
filter:
all: 全部 ({count})
enabled: 已啟用 ({count})
paused: 已暫停 ({count})
no-alerts: 尚未設定任何警報。建立一個以開始使用。
alert:
paused: 已暫停
containers: 容器
log-filter: 日誌篩選器
destination: 目標
dispatcher-deleted: 發送器已刪除
containers-count: "{count} 個容器"
triggered-count: "{count} 次觸發"
last-triggered: "最後觸發: {time}"
destination:
http-webhook: HTTP Webhook
dozzle-cloud: Dozzle Cloud
edit: 編輯
delete: 刪除
alert-form:
create-title: 建立警報
edit-title: 編輯警報
description: 訂閱符合您條件的日誌事件
alert-name: 警報名稱
alert-name-placeholder: 例如,測試 API 錯誤
container-filter: 容器篩選器
containers-match: "{count} 個容器符合: {names}"
no-containers-match: 沒有容器符合此篩選器
log-filter: 日誌篩選器
logs-match: "{count} 筆日誌符合"
no-logs-match: 沒有日誌符合此篩選器
destination: 目標
select-destination: 選擇目標
no-destinations: 尚未設定目標。請先新增一個。
preview: 預覽
cancel: 取消
save: 儲存
create: 建立警報
destination-form:
create-title: 新增目標
edit-title: 編輯目標
description: 通知應發送到哪裡?
name: 名稱
name-placeholder: 例如,正式環境 Slack
type: 類型
webhook-title: HTTP Webhook
webhook-description: Slack、Discord、自訂端點
cloud-title: Dozzle Cloud
cloud-description: 推送通知、電子郵件警報和 AI 摘要
webhook-url: Webhook URL
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: API 金鑰
payload-format: Payload 格式
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: 自訂
template: 範本
template-hint: Go 範本語法
test: 測試
test-success: 測試成功
cancel: 取消
close: 關閉
save: 儲存
add: 新增目標
cloud-exists: 只能設定一個 Dozzle Cloud 目標
link-cloud: 連結帳戶
link-cloud-button: 連結 Dozzle Cloud
cloud-settings-hint: 若要設定您的管理頻道,請前往
cloud-settings-link: Dozzle Cloud 設定
cloud-link-success:
title: Dozzle Cloud 已連結
message: 您的帳戶已成功連結至 Dozzle Cloud。
+81
View File
@@ -17,6 +17,7 @@ action:
copy-link: 复制永久链接
see-in-context: 在上下文中查看
show-details: 显示详细信息
create-alert: 创建告警
label:
containers: 容器
container: 无容器 | 1 容器 | {count} 容器
@@ -155,3 +156,83 @@ analytics:
evaluating_query: 正在评估查询...
total_records: 总共 {count} 条记录。
showing_first: 显示前 {count} 条。
notifications:
title: 通知
description: 配置接收警报的位置和时间
destinations: 目标
add-destination: 添加目标
alerts: 警报
add: 添加
filter:
all: 全部 ({count})
enabled: 已启用 ({count})
paused: 已暂停 ({count})
no-alerts: 尚未配置任何警报。创建一个以开始使用。
alert:
paused: 已暂停
containers: 容器
log-filter: 日志筛选器
destination: 目标
dispatcher-deleted: 发送器已删除
containers-count: "{count} 个容器"
triggered-count: "{count} 次触发"
last-triggered: "最后触发: {time}"
destination:
http-webhook: HTTP Webhook
dozzle-cloud: Dozzle Cloud
edit: 编辑
delete: 删除
alert-form:
create-title: 创建警报
edit-title: 编辑警报
description: 订阅符合您条件的日志事件
alert-name: 警报名称
alert-name-placeholder: 例如,测试 API 错误
container-filter: 容器筛选器
containers-match: "{count} 个容器匹配: {names}"
no-containers-match: 没有容器匹配此筛选器
log-filter: 日志筛选器
logs-match: "{count} 条日志匹配"
no-logs-match: 没有日志匹配此筛选器
destination: 目标
select-destination: 选择目标
no-destinations: 尚未配置目标。请先添加一个。
preview: 预览
cancel: 取消
save: 保存
create: 创建警报
destination-form:
create-title: 添加目标
edit-title: 编辑目标
description: 通知应发送到哪里?
name: 名称
name-placeholder: 例如,生产环境 Slack
type: 类型
webhook-title: HTTP Webhook
webhook-description: Slack、Discord、自定义端点
cloud-title: Dozzle Cloud
cloud-description: 推送通知、邮件警报和 AI 摘要
webhook-url: Webhook URL
webhook-url-placeholder: https://hooks.foo.com/services/...
api-key: API 密钥
payload-format: Payload 格式
format-slack: Slack
format-discord: Discord
format-ntfy: ntfy
format-custom: 自定义
template: 模板
template-hint: Go 模板语法
test: 测试
test-success: 测试成功
cancel: 取消
close: 关闭
save: 保存
add: 添加目标
cloud-exists: 只能配置一个 Dozzle Cloud 目标
link-cloud: 关联账户
link-cloud-button: 关联 Dozzle Cloud
cloud-settings-hint: 要配置您的托管频道,请前往
cloud-settings-link: Dozzle Cloud 设置
cloud-link-success:
title: Dozzle Cloud 已关联
message: 您的账户已成功关联到 Dozzle Cloud。
+15 -5
View File
@@ -53,15 +53,20 @@ func main() {
log.Info().Msgf("Dozzle version %s", args.Version())
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
var hostService web.HostService
if args.Mode == "server" {
multiHostService := cli.CreateMultiHostService(certs, args)
if multiHostService.TotalClients() == 0 {
log.Fatal().Msg("Could not connect to any Docker Engine")
} else {
log.Info().Int("clients", multiHostService.TotalClients()).Msg("Connected to Docker")
}
if err := multiHostService.StartNotificationManager(ctx); err != nil {
log.Fatal().Err(err).Msg("Could not start notification manager")
}
hostService = multiHostService
} else if args.Mode == "swarm" {
localClient, err := docker.NewLocalClient("")
@@ -74,13 +79,20 @@ func main() {
}
agentManager := docker_support.NewRetriableClientManager(args.RemoteAgent, args.Timeout, certs)
manager := docker_support.NewSwarmClientManager(localClient, certs, args.Timeout, agentManager, args.Filter)
hostService = docker_support.NewMultiHostService(manager, args.Timeout)
multiHostService := docker_support.NewMultiHostService(manager, args.Timeout)
if err := multiHostService.StartNotificationManager(ctx); err != nil {
log.Fatal().Err(err).Msg("Could not start notification manager")
}
hostService = multiHostService
log.Info().Msg("Starting in swarm mode")
listener, err := net.Listen("tcp", ":7007")
if err != nil {
log.Fatal().Err(err).Msg("failed to listen")
}
server, err := agent.NewServer(localClient, certs, args.Version(), args.Filter)
// Create client service for agent server in swarm mode
clientService := docker_support.NewDockerClientService(localClient, args.Filter)
// TODO add notification for swarm mode
server, err := agent.NewServer(clientService, certs, args.Version(), nil)
if err != nil {
log.Fatal().Err(err).Msg("failed to create agent")
}
@@ -115,8 +127,6 @@ func main() {
log.Fatal().Err(err).Msg("failed to listen")
}
}()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
stop()
+38 -30
View File
@@ -1,12 +1,12 @@
{
"name": "dozzle",
"version": "9.0.1",
"version": "10.0.0",
"description": "Realtime log viewer for docker containers.",
"homepage": "https://github.com/amir20/dozzle#readme",
"bugs": {
"url": "https://github.com/amir20/dozzle/issues"
},
"packageManager": "pnpm@10.28.0",
"packageManager": "pnpm@10.29.2",
"type": "module",
"repository": {
"type": "git",
@@ -29,28 +29,37 @@
"docs:preview": "vitepress preview docs"
},
"dependencies": {
"@duckdb/duckdb-wasm": "1.33.1-dev16.0",
"@iconify-json/carbon": "^1.2.16",
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.1",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.13",
"@duckdb/duckdb-wasm": "1.33.1-dev18.0",
"@iconify-json/carbon": "^1.2.18",
"@iconify-json/cil": "^1.2.3",
"@iconify-json/ic": "^1.2.4",
"@iconify-json/material-symbols": "^1.2.51",
"@iconify-json/material-symbols": "^1.2.54",
"@iconify-json/mdi": "^1.2.3",
"@iconify-json/mdi-light": "^1.2.2",
"@iconify-json/octicon": "^1.2.19",
"@iconify-json/octicon": "^1.2.20",
"@iconify-json/ph": "^1.2.2",
"@intlify/unplugin-vue-i18n": "^11.0.3",
"@lezer/highlight": "^1.2.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "4.1.18",
"@vueuse/components": "^14.1.0",
"@vueuse/core": "^14.1.0",
"@vueuse/integrations": "^14.1.0",
"@vueuse/router": "14.1.0",
"@vueuse/components": "^14.2.0",
"@vueuse/core": "^14.2.0",
"@vueuse/integrations": "^14.2.0",
"@vueuse/router": "14.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"ansi-to-html": "^0.7.2",
"daisyui": "5.5.14",
"entities": "^7.0.0",
"codemirror": "^6.0.2",
"daisyui": "5.5.18",
"entities": "^7.0.1",
"fuse.js": "^7.1.0",
"lodash.debounce": "^4.0.8",
"pinia": "^3.0.4",
@@ -58,45 +67,44 @@
"splitpanes": "^4.0.4",
"strip-ansi": "^7.1.2",
"tailwindcss": "4.1.18",
"unplugin-auto-import": "^20.3.0",
"unplugin-icons": "^22.5.0",
"unplugin-vue-components": "^30.0.0",
"unplugin-auto-import": "^21.0.0",
"unplugin-icons": "^23.0.1",
"unplugin-vue-components": "^31.0.0",
"unplugin-vue-macros": "^2.14.5",
"unplugin-vue-router": "^0.19.2",
"vite": "7.3.1",
"vite-plugin-vue-layouts": "^0.11.0",
"vite-svg-loader": "^5.1.0",
"vitepress": "1.6.4",
"vue": "^3.5.26",
"vue": "^3.5.28",
"vue-i18n": "^11.2.8",
"vue-router": "^4.6.4"
"vue-router": "^5.0.2"
},
"devDependencies": {
"@apache-arrow/esnext-esm": "^21.1.0",
"@iconify-json/ion": "^1.2.6",
"@iconify-json/material-symbols-light": "^1.2.51",
"@iconify-json/ri": "^1.2.7",
"@iconify-json/material-symbols-light": "^1.2.54",
"@iconify-json/ri": "^1.2.9",
"@pinia/testing": "^1.0.3",
"@playwright/test": "^1.57.0",
"@playwright/test": "^1.58.2",
"@types/lodash.debounce": "^4.0.9",
"@types/node": "^25.0.6",
"@vitejs/plugin-vue": "6.0.3",
"@vue/compiler-sfc": "^3.5.26",
"@types/node": "^25.2.2",
"@vitejs/plugin-vue": "6.0.4",
"@vue/compiler-sfc": "^3.5.28",
"@vue/test-utils": "^2.4.6",
"bumpp": "^10.3.2",
"bumpp": "^10.4.1",
"c8": "^10.1.3",
"concurrently": "^9.2.1",
"eventsourcemock": "^2.0.0",
"jsdom": "^27.4.0",
"jsdom": "^28.0.0",
"lint-staged": "^16.2.7",
"prettier": "^3.7.4",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"simple-git-hooks": "^2.13.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"vitest": "^4.0.16",
"vue-component-type-helpers": "3.2.2",
"vue-tsc": "3.2.2"
"vitest": "^4.0.18",
"vue-component-type-helpers": "3.2.4",
"vue-tsc": "3.2.4"
},
"lint-staged": {
"*.{js,vue,css,ts,html,md}": [
+1021 -802
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More