Compare commits

..

1 Commits

Author SHA1 Message Date
Amir Raminfar f393d0d3e6 WIP: notificatons 2026-01-14 19:17:38 -08:00
128 changed files with 2666 additions and 8885 deletions
@@ -1,30 +0,0 @@
name: "\u2601\uFE0F Dozzle Cloud Bug Report"
labels: ["bug", "cloud"]
description: |
Use this template for issues related to Dozzle Cloud.
body:
- type: checkboxes
attributes:
label: Check for existing issues
description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` on it.
options:
- label: Completed
required: true
- type: input
attributes:
label: Dozzle version
description: The version of Dozzle connected to Dozzle Cloud.
validations:
required: true
- type: textarea
attributes:
label: Describe the bug / provide steps to reproduce it
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
attributes:
label: Screenshots or logs
description: Drag screenshots or paste logs into the text input below.
validations:
required: false
+8 -17
View File
@@ -3,10 +3,6 @@ on:
tags:
- "v*"
name: Test and Release
permissions:
contents: read
jobs:
npm-test:
name: JavaScript Tests
@@ -18,12 +14,12 @@ jobs:
- uses: actions/setup-node@v6
name: Install Node
with:
node-version: 24.13.1
node-version: 24.12.0
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.13.1
node-version: 24.12.0
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
@@ -37,7 +33,7 @@ jobs:
- name: Install Go
uses: actions/setup-go@v6
with:
go-version: 1.26.0
go-version: 1.25.5
check-latest: true
- name: Checkout code
uses: actions/checkout@v6
@@ -60,12 +56,12 @@ jobs:
- uses: actions/setup-node@v6
name: Install Node
with:
node-version: 24.13.1
node-version: 24.12.0
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.13.1
node-version: 24.12.0
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
@@ -96,19 +92,16 @@ jobs:
needs: [go-test, npm-test, int-test]
name: Release
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to DockerHub
uses: docker/login-action@v3.7.0
uses: docker/login-action@v3.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the Container registry
uses: docker/login-action@v3.7.0
uses: docker/login-action@v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -132,7 +125,7 @@ jobs:
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
- name: Build and push
uses: docker/build-push-action@v6.19.0
uses: docker/build-push-action@v6.18.0
with:
push: true
context: .
@@ -146,8 +139,6 @@ jobs:
needs: [buildx]
name: Github Release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v6
+3 -8
View File
@@ -6,11 +6,6 @@ on:
branches:
- master
name: Push container
permissions:
contents: read
packages: write
jobs:
buildx:
name: Push branches and PRs
@@ -20,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.7.0
uses: docker/login-action@v3.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the Container registry
uses: docker/login-action@v3.7.0
uses: docker/login-action@v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -44,7 +39,7 @@ jobs:
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
- name: Build and push
uses: docker/build-push-action@v6.19.0
uses: docker/build-push-action@v6.18.0
with:
context: .
push: true
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24.13.1
node-version: 24.12.0
cache: pnpm # or pnpm / yarn
- name: Setup Pages
uses: actions/configure-pages@v5
+7 -11
View File
@@ -6,10 +6,6 @@ on:
branches:
- master
name: Test
permissions:
contents: read
jobs:
typecheck:
name: Typecheck
@@ -21,12 +17,12 @@ jobs:
- uses: actions/setup-node@v6
name: Install Node
with:
node-version: 24.13.1
node-version: 24.12.0
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.13.1
node-version: 24.12.0
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
@@ -44,7 +40,7 @@ jobs:
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.13.1
node-version: 24.12.0
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
@@ -58,7 +54,7 @@ jobs:
- name: Install Go
uses: actions/setup-go@v6
with:
go-version: "1.26.0"
go-version: "1.25.5"
check-latest: true
- name: Checkout code
uses: actions/checkout@v6
@@ -81,7 +77,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.26.0"
go-version: "1.25.5"
check-latest: true
- name: Generate dependencies
run: make fake_assets shared_key.pem shared_cert.pem
@@ -100,12 +96,12 @@ jobs:
- uses: actions/setup-node@v6
name: Install Node
with:
node-version: 24.13.1
node-version: 24.12.0
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.13.1
node-version: 24.12.0
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
-1
View File
@@ -4,4 +4,3 @@ typed-router.d.ts
docs/.vitepress/cache
docs/.vitepress/dist
dist
assets/types/graphql.ts
+11 -195
View File
@@ -31,6 +31,9 @@ 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
```
@@ -127,18 +130,8 @@ 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)
- **`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)
- **`main.go`** - Application entry point with mode switching (server/swarm/k8s)
### Frontend (Vue 3)
@@ -168,23 +161,17 @@ 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 (injected from backend HTML, frozen immutable)
- `container.ts`: Container state management with EventSource streaming (`/api/events/stream`)
- `config.ts`: App configuration and feature flags
- `container.ts`: Container state management
- `hosts.ts`: Multi-host state
- `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
- `settings.ts`: User preferences
- **`assets/composable/`** - Vue composables (auto-imported)
- `eventStreams.ts`: SSE connection management with buffer-based flushing (250ms debounce)
- `eventStreams.ts`: SSE connection management
- `historicalLogs.ts`: Historical log fetching
- `logContext.ts`: Log filtering and search context (provide/inject pattern)
- `scrollContext.ts`: Scroll state management (paused, progress, currentDate)
- `storage.ts`: LocalStorage abstractions with reactivity
- `logContext.ts`: Log filtering and search context
- `storage.ts`: LocalStorage abstractions
- `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
@@ -198,7 +185,6 @@ 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
@@ -220,8 +206,6 @@ 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
@@ -233,13 +217,6 @@ 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
@@ -270,168 +247,7 @@ The frontend uses file-based routing with these conventions:
### Deployment Modes
- **Server mode** (default): Single or multi-host Docker monitoring
- Uses `RetriableClientManager` with local + remote agent clients
- **Server mode**: Single or multi-host Docker monitoring
- **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
+6 -6
View File
@@ -1,7 +1,7 @@
# Build assets
FROM --platform=$BUILDPLATFORM node:25.6.0-alpine AS node
FROM --platform=$BUILDPLATFORM node:24.12.0-alpine AS node
RUN npm install -g --force corepack && corepack enable
RUN corepack enable
WORKDIR /build
@@ -22,10 +22,10 @@ COPY public ./public
# Build assets
RUN pnpm build
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
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 and graphql
RUN go generate ./...
# Generate protos
RUN go generate
# Build binary
RUN GOEXPERIMENT=jsonv2 GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle
+14 -16
View File
@@ -1,4 +1,7 @@
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:
@@ -27,11 +30,9 @@ build: dist generate
.PHONY: docker
docker: shared_key.pem shared_cert.pem
@docker build --build-arg TAG=local -t amir20/dozzle:local .
@docker build --build-arg TAG=local -t amir20/dozzle .
.PHONY: generate
generate: shared_key.pem shared_cert.pem
@go generate ./...
generate: shared_key.pem shared_cert.pem $(GEN_FILES)
.PHONY: dev
dev: generate fake_assets
@@ -49,24 +50,21 @@ 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:local
docker run -it --rm -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock amir20/dozzle:latest
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"
+38 -39
View File
@@ -4,7 +4,7 @@
# Dozzle - [dozzle.dev](https://dozzle.dev/)
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.
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.
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), a top-like application for monitoring Docker containers. It integrates with Dozzle to link directly to container logs.
> 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.
## 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 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
- 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 🌙
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.
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.
## 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. Mount the Docker Unix socket with `--volume` to `/var/run/docker.sock`:
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`:
$ 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 a Docker Compose example:
Here is the Docker Compose file:
services:
dozzle:
@@ -56,11 +56,11 @@ Here is a Docker Compose example:
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 the 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 documentation at [dozzle.dev](https://dozzle.dev/guide/getting-started).
## Swarm Mode
Dozzle works with Docker Swarm. You can run Dozzle as a global service:
Dozzle works with Docker Swarm mode. You can run Dozzle as a global service with:
$ 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 monitor multiple Docker hosts. Run Dozzle in agent mode with:
Dozzle can be used to monitor multiple Docker hosts. You can 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 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/).
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/).
### Installation on Podman
### Installation on podman
By default, Podman doesn't have a background process, but you can enable the remote socket for Dozzle to work.
By default Podman doesn't have a background process but you can enable this for Dozzle to work.
First, verify if your Podman installation has the remote socket enabled:
Verify first if your podman installation has enabled remote socket:
```
podman info
```
If you see output like this under the remote socket key, it's already enabled:
When you get under the key remote socket output like this, its already enabled:
```
remoteSocket:
@@ -96,40 +96,40 @@ If you see output like this under the remote socket key, it's already enabled:
path: /run/user/1000/podman/podman.sock
```
If it's not enabled, follow [this tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/socket_activation.md) to enable it.
If it's not enabled please follow [this tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/socket_activation.md) to enable it.
Once the Podman remote socket is enabled, you can run Dozzle:
Once you have the podman remote socket you can run Dozzle on podman.
```
podman run --volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock -d -p 8080:8080 docker.io/amir20/dozzle:latest
```
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.
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.
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`.
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`.
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).
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)
## Security
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.
Dozzle supports file based authentication and forward proxy like [Authelia](https://www.authelia.com/). These are documented at https://dozzle.dev/guide/authentication.
## Analytics
## Analytics collected
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).
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).
To disable analytics, use the `--no-analytics` flag.
If you do not want to be tracked at all, see the `--no-analytics` flag below.
## Environment Variables and Configuration
## Environment variables and configuration
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.
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.
## Support
There are many ways to support Dozzle:
There are many ways you can 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're good at writing, send PRs to improve the documentation at [dozzle.dev](https://dozzle.dev/).
- 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/)
- 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,9 +142,8 @@ There are many ways to support Dozzle:
To build and test locally:
1. Install [Node.js](https://nodejs.org/en/download/) and [pnpm](https://pnpm.io/installation).
1. Install [NodeJs](https://nodejs.org/en/download/) and [pnpm](https://pnpm.io/installation).
2. Install [Go](https://go.dev/doc/install).
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.
3. Install tools with `make tools`.
4. Install node modules `pnpm install`.
5. Run `make dev` to start a development server with hot reload.
+6 -22
View File
@@ -27,19 +27,15 @@ 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
@@ -82,6 +78,7 @@ 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
@@ -134,6 +131,7 @@ 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
@@ -208,7 +206,6 @@ 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
@@ -265,7 +262,6 @@ 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
@@ -309,8 +305,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/auto').useRoute
const useRouter: typeof import('vue-router/auto').useRouter
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useSSRWidth: typeof import('@vueuse/core').useSSRWidth
const useScreenOrientation: typeof import('@vueuse/core').useScreenOrientation
const useScreenSafeArea: typeof import('@vueuse/core').useScreenSafeArea
@@ -401,12 +397,6 @@ 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
@@ -446,19 +436,15 @@ 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']>
@@ -627,7 +613,6 @@ 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']>
@@ -684,7 +669,6 @@ 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']>
@@ -728,8 +712,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/auto')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router/auto')['useRouter']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['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,8 +11,6 @@ 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']
@@ -43,8 +41,6 @@ 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']
@@ -84,21 +80,15 @@ 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']
@@ -108,13 +98,8 @@ 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']
@@ -203,12 +203,7 @@ if (enableShell) {
}
const containerRef = computed(() => [container]);
const { downloadUrl, isFiltered } = useDownloadUrl(
containerRef,
streamConfig,
levels,
toRef(() => container.name),
);
const { downloadUrl, isFiltered } = useDownloadUrl(containerRef, streamConfig, levels);
const disableRestart = computed(() => actionStates.stop || actionStates.start || actionStates.restart);
@@ -1,12 +1,12 @@
<template>
<div class="@container flex min-w-0 flex-1 items-center gap-1.5 md:gap-2">
<div class="@container flex 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 min-w-0 items-center text-sm">
<div class="breadcrumbs min-w-0 overflow-x-visible p-0 font-mono">
<div class="inline-flex items-center text-sm">
<div class="breadcrumbs overflow-x-visible p-0 font-mono">
<ul>
<li v-if="config.hosts.length > 1" class="font-thin max-md:hidden">
{{ container.hostLabel }}
@@ -6,7 +6,7 @@
{{ $t("label.container", group.containers.length) }}
</ContainerDropdown>
<MultiContainerStat class="ml-auto" :containers="group.containers" />
<MultiContainerActionToolbar class="max-md:hidden" :name="group.name" @clear="viewer?.clear()" />
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
</div>
</template>
<template #default>
+1 -1
View File
@@ -12,7 +12,7 @@
</Tag>
</div>
<MultiContainerStat class="ml-auto" :containers="containers" />
<MultiContainerActionToolbar class="max-md:hidden" :name="host.name" @clear="viewer?.clear()" />
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
</div>
</template>
<template #default>
+1 -1
View File
@@ -10,7 +10,7 @@
</ContainerDropdown>
</div>
<MultiContainerStat class="ml-auto" :containers="namespace.containers" />
<MultiContainerActionToolbar class="max-md:hidden" :name="namespace.name" @clear="viewer?.clear()" />
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
</div>
</template>
<template #default>
+1 -1
View File
@@ -10,7 +10,7 @@
</ContainerDropdown>
</div>
<MultiContainerStat class="ml-auto" :containers="owner.containers" />
<MultiContainerActionToolbar class="max-md:hidden" :name="owner.name" @clear="viewer?.clear()" />
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
</div>
</template>
<template #default>
-9
View File
@@ -3,15 +3,6 @@
<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,12 +73,6 @@
{{ $t("action.show-details") }}
</a>
</li>
<li>
<a @click="createAlert()">
<mdi:bell />
{{ $t("action.create-alert") }}
</a>
</li>
</ul>
</div>
</template>
@@ -88,7 +82,6 @@ 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>;
@@ -154,22 +147,6 @@ 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 { LogEntry, LogMessage } from "@/models/LogEntry";
import { type JSONObject, LogEntry } from "@/models/LogEntry";
const { progress, currentDate } = useScrollContext();
const { messages } = defineProps<{
messages: LogEntry<LogMessage>[];
messages: LogEntry<string | string[] | JSONObject>[];
}>();
const { containers } = useLoggingContext();
@@ -94,11 +94,9 @@ const { showSearch } = useSearchFilter();
const { enableDownload } = config;
const clear = defineEmit();
const { name } = defineProps<{ name?: string }>();
const { streamConfig, showHostname, showContainerName, containers, levels } = useLoggingContext();
const { downloadUrl, isFiltered } = useDownloadUrl(containers, streamConfig, levels, name);
const { downloadUrl, isFiltered } = useDownloadUrl(containers, streamConfig, levels);
const hideMenu = (e: MouseEvent) => {
if (e.target instanceof HTMLAnchorElement) {
@@ -1,8 +1,6 @@
<template>
<div class="flex gap-1 md:gap-4">
<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]"
>
<div class="grid min-w-15 grid-cols-[auto_1fr] items-center gap-0.5 text-xs leading-none max-md:hidden">
<PhArrowUp class="text-primary" />
<span class="tabular-nums">{{ formatBytes(networkRate.tx, { short: true, decimals: 1 }) }}/s</span>
<PhArrowDown class="text-secondary" />
@@ -117,7 +115,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 + (c.cpuLimit || 0), 0);
const sumCpu = hostContainers.reduce((sum, c) => sum + 1, 0);
totalCpu += Math.min(sumCpu, hostTotalCpu);
}
@@ -21,9 +21,6 @@ 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-->
@@ -62,9 +59,6 @@ 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-->
@@ -103,9 +97,6 @@ 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-->
@@ -1,101 +0,0 @@
<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>
@@ -1,343 +0,0 @@
<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>
@@ -1,68 +0,0 @@
<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>
@@ -1,364 +0,0 @@
<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>
@@ -1,64 +0,0 @@
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,
),
};
@@ -5,7 +5,7 @@
<ph:stack-simple />
<ContainerDropdown :containers="service.containers">{{ service.name }}</ContainerDropdown>
<MultiContainerStat class="ml-auto" :containers="service.containers" />
<MultiContainerActionToolbar class="max-md:hidden" :name="service.name" @clear="viewer?.clear()" />
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
</div>
</template>
<template #default>
+1 -1
View File
@@ -10,7 +10,7 @@
</ContainerDropdown>
</div>
<MultiContainerStat class="ml-auto" :containers="stack.containers" />
<MultiContainerActionToolbar class="max-md:hidden" :name="stack.name" @clear="viewer?.clear()" />
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
</div>
</template>
<template #default>
+1 -6
View File
@@ -11,7 +11,7 @@
<mdi:close class="swap-on" />
</button>
</form>
<slot v-if="open" :close="close"></slot>
<slot v-if="open"></slot>
</div>
</div>
<form method="dialog" class="modal-backdrop">
@@ -28,16 +28,11 @@ 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));
-6
View File
@@ -5,7 +5,6 @@ export function useDownloadUrl(
containers: Ref<Container[]> | ComputedRef<Container[]>,
streamConfig: { stdout: boolean; stderr: boolean } | Ref<{ stdout: boolean; stderr: boolean }>,
levels: Ref<Set<string>>,
name?: Ref<string> | ComputedRef<string> | string,
) {
const { debouncedSearchFilter } = useSearchFilter();
@@ -28,11 +27,6 @@ export function useDownloadUrl(
selectedLevels.forEach((level) => params.append("levels", level));
}
const nameValue = toValue(name);
if (nameValue) {
params.append("name", nameValue);
}
const containerIds = toValue(containers)
.map((c) => c.host + "~" + c.id)
.join(",");
+1 -9
View File
@@ -260,12 +260,7 @@ export async function loadBetween(
params: Ref<URLSearchParams>,
from: Date,
to: Date,
{
lastSeenId,
startId,
min,
maxStart,
}: { lastSeenId?: number; startId?: number; min?: number; maxStart?: number } = {},
{ lastSeenId, min, maxStart }: { lastSeenId?: number; min?: number; maxStart?: number } = {},
) {
const url = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs`);
const abortController = new AbortController();
@@ -284,9 +279,6 @@ 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
@@ -1,166 +0,0 @@
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,7 +96,6 @@ 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
@@ -1,71 +0,0 @@
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: "error" | "warning" | "info";
type: "success" | "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" v-slot="{ close }">
<SideDrawer ref="drawer" :width="drawerWidth">
<Suspense :timeout="0">
<component :is="drawerComponent" v-bind="drawerProperties" :close="close" />
<template #fallback> <span class="loading loading-spinner loading-sm"></span></template>
<component :is="drawerComponent" v-bind="drawerProperties" />
<template #fallback> Loading dependencies... </template>
</Suspense>
</SideDrawer>
<ToastModal />
-41
View File
@@ -151,44 +151,3 @@ 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;
}
+11 -55
View File
@@ -1,4 +1,5 @@
import type { ContainerHealth, ContainerJson, ContainerStat, ContainerState } from "@/types/Container";
import type { ContainerHealth, ContainerStat, ContainerState } from "@/types/Container";
import { useExponentialMovingAverage, useSimpleRefHistory } from "@/utils";
import { Ref } from "vue";
export type Stat = Omit<ContainerStat, "id">;
@@ -50,12 +51,13 @@ export class Container {
public readonly group?: string,
public health?: ContainerHealth,
) {
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._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;
this._name = name;
}
@@ -83,7 +85,6 @@ 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"]
);
@@ -114,56 +115,11 @@ 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 {
(this._stat as unknown as Stat) = stat;
// @ts-ignore
this._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
@@ -1,149 +0,0 @@
<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,5 +4,3 @@ declare module "*.vue" {
const component: DefineComponent<{}, {}, any>;
export default component;
}
declare const __CLOUD_URL__: string;
+11 -18
View File
@@ -1,19 +1,15 @@
interface Release {
type Announcement = {
name: string;
mentionsCount: number;
tag: string;
announcement: boolean;
createdAt: Date;
body: string;
createdAt: string;
tag: 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[]>([]);
@@ -24,14 +20,8 @@ async function fetchReleases() {
fetched = true;
try {
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,
})) || [];
const { data } = await useFetch(withBase("/api/releases")).get().json<Announcement[]>();
releases.value = data.value || [];
} catch (error) {
console.error("Error while fetching releases:\n", error);
fetched = false;
@@ -45,7 +35,10 @@ if (config.releaseCheckMode === "automatic") {
const otherAnnouncements = [] as Announcement[];
const announcements = computed(() => {
return [...releases.value, ...otherAnnouncements].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
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());
});
const mostRecent = computed(() => announcements.value?.[0]);
+22 -1
View File
@@ -140,7 +140,28 @@ export const useContainerStore = defineStore("container", () => {
existing.name = c.name;
});
containers.value = [...containers.value, ...newContainers.map(Container.fromJSON)];
containers.value = [
...containers.value,
...newContainers.map((c) => {
return new Container(
c.id,
new Date(c.created),
new Date(c.startedAt),
new Date(c.finishedAt),
c.image,
c.name,
c.command,
c.host,
c.labels,
c.state,
c.cpuLimit,
c.memoryLimit,
c.stats,
c.group,
c.health,
);
}),
];
};
const currentContainer = (id: Ref<string>) => computed(() => allContainersById.value[id.value]);
+13 -31
View File
@@ -1,29 +1,26 @@
/* eslint-disable */
/* prettier-ignore */
// oxfmt-ignore
// @ts-nocheck
// noinspection ES6UnusedImports
// Generated by vue-router. !! DO NOT MODIFY THIS FILE !!
// Generated by unplugin-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.
import type {
RouteRecordInfo,
ParamValue,
ParamValueOneOrMore,
ParamValueZeroOrMore,
ParamValueZeroOrOne,
} from 'vue-router'
declare module 'vue-router' {
interface TypesConfig {
ParamParsers: never
}
declare module 'vue-router/auto-resolver' {
export type ParamParserCustom = never
}
declare module 'vue-router/auto-routes' {
import type {
RouteRecordInfo,
ParamValue,
ParamValueOneOrMore,
ParamValueZeroOrMore,
ParamValueZeroOrOne,
} from 'vue-router'
/**
* Route name map generated by vue-router
* Route name map generated by unplugin-vue-router
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<
@@ -89,13 +86,6 @@ 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',
@@ -134,7 +124,7 @@ declare module 'vue-router/auto-routes' {
}
/**
* Route file to route info map by vue-router.
* Route file to route info map by unplugin-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:
@@ -198,12 +188,6 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'assets/pages/notifications.vue': {
routes:
| '/notifications'
views:
| never
}
'assets/pages/owner/[name].vue': {
routes:
| '/owner/[name]'
@@ -247,5 +231,3 @@ declare module 'vue-router/auto-routes' {
? Info['routes']
: keyof RouteNamedMap
}
export {}
-56
View File
@@ -1,56 +0,0 @@
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.58.2-jammy
image: mcr.microsoft.com/playwright:v1.57.0-jammy
working_dir: /app
volumes:
- .:/app
-8
View File
@@ -37,7 +37,6 @@ export default defineConfig({
nav: [
{ text: "Home", link: "/" },
{ text: "Guide", link: "/guide/what-is-dozzle", activeMatch: "/guide/" },
{ text: "Dozzle Cloud", link: "https://cloud.dozzle.dev" },
{
text: `v${pkg.version}`,
items: [
@@ -69,13 +68,6 @@ export default defineConfig({
{ text: "Podman", link: "/guide/podman" },
],
},
{
text: "Notifications",
items: [
{ text: "Alerts & Webhooks", link: "/guide/alerts-and-webhooks" },
{ text: "Dozzle Cloud", link: "/guide/dozzle-cloud" },
],
},
{
text: "Advanced Configuration",
items: [
+9 -2
View File
@@ -3,8 +3,15 @@
class="banner fixed top-0 right-0 left-0 z-(--vp-z-index-layout-top) flex items-center overflow-hidden bg-[oklch(74%_0.16_232.661)] p-4 font-bold text-gray-800 dark:bg-[oklch(60%_0.126_221.723)] dark:text-white"
>
<div class="mx-auto flex items-center gap-2 lg:gap-4">
<span class="animate-bounce">🎉</span>Dozzle v10 is here! Alerts, webhooks, and Dozzle Cloud are now available.
<a href="/guide/alerts-and-webhooks" class="btn btn-sm btn-primary text-white!"> Learn more </a>
<span class="animate-bounce">🚀</span>K8s users, I need your help! I have added K8s support to Dozzle and I need
your feedback! 🙏
<a
href="https://github.com/amir20/dozzle/discussions/3614"
target="_blank"
class="btn btn-sm btn-primary text-white!"
>
See the discussion
</a>
</div>
<button class="btn btn-circle btn-ghost btn-xs ml-auto" @click="dismiss">
<svg class="swap-on fill-current" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512">
+1 -1
View File
@@ -17,7 +17,7 @@ export default {
"home-hero-image": () => h(HeroVideo),
"sidebar-nav-after": () => h(BuyMeCoffee),
"home-hero-actions-after": () => h(Stats),
"layout-top": () => h(Banner),
// "layout-top": () => h(Banner),
"home-hero-after": () => h(Supported),
});
},
-155
View File
@@ -1,155 +0,0 @@
---
title: Alerts & Webhooks
---
# Alerts & Webhooks
<Badge type="tip" text="New in v10" />
Dozzle v10 introduces a powerful alerting system that lets you monitor container logs and receive notifications when specific conditions are met. Alerts use customizable expressions to filter containers and log messages, and can send notifications to webhooks, Slack, Discord, ntfy, or [Dozzle Cloud](/guide/dozzle-cloud).
## How It Works
Alerts are configured with two expressions:
1. **Container filter** — selects which containers to monitor
2. **Log filter** — defines which log messages trigger the alert
When a log entry matches both filters, Dozzle sends a notification to the configured destination.
> [!IMPORTANT]
> Alert and destination configurations are stored in the `/data` directory. You must mount this directory as a volume to persist your notification settings across container restarts.
::: code-group
```sh
docker run -v /var/run/docker.sock:/var/run/docker.sock -v /path/to/data:/data -p 8080:8080 amir20/dozzle:latest
```
```yaml [docker-compose.yml]
services:
dozzle:
image: amir20/dozzle:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /path/to/data:/data
ports:
- 8080:8080
```
:::
## Setting Up a Destination
Before creating alerts, you need to configure at least one notification destination. Navigate to the **Notifications** page in Dozzle and click **Add Destination**.
### Webhook
Webhooks send an HTTP POST request to a URL of your choice. Dozzle includes built-in payload templates for popular services:
- **Slack** — formatted with blocks and markdown
- **Discord** — formatted for Discord webhook API
- **ntfy** — formatted for [ntfy.sh](https://ntfy.sh) push notifications
- **Custom** — generic JSON payload you can customize
You can also write your own payload template using Go's `text/template` syntax. The following variables are available:
<div v-pre>
| Variable | Description |
| ------------------------- | --------------------------- |
| `{{.Container.Name}}` | Container name |
| `{{.Container.Image}}` | Container image |
| `{{.Container.HostName}}` | Docker host name |
| `{{.Container.State}}` | Container state |
| `{{.Log.Message}}` | Log message content |
| `{{.Log.Level}}` | Log level |
| `{{.Log.Timestamp}}` | Log timestamp |
| `{{.Log.Stream}}` | Stream type (stdout/stderr) |
| `{{.Subscription.Name}}` | Alert rule name |
</div>
> [!TIP]
> Use the **Test** button to verify your webhook is working before saving.
### Dozzle Cloud
You can also send alerts to [Dozzle Cloud](/guide/dozzle-cloud) for centralized monitoring across multiple Dozzle instances. See the [Dozzle Cloud guide](/guide/dozzle-cloud) for more details.
## Creating an Alert
Navigate to the **Notifications** page and click **Add Alert**. You'll need to configure:
### Container Expression
The container expression selects which containers to monitor. Available properties:
| Property | Type | Example |
| ---------- | ------ | ------------------------------- |
| `name` | string | `name contains "api"` |
| `image` | string | `image == "nginx:latest"` |
| `state` | string | `state == "running"` |
| `health` | string | `health == "unhealthy"` |
| `hostName` | string | `hostName == "prod-host"` |
| `labels` | map | `labels["env"] == "production"` |
You can combine conditions with `&&` (AND), `||` (OR), and `!` (NOT):
```
name contains "api" && labels["env"] == "production"
```
### Log Expression
The log expression filters which log messages trigger the alert. Available properties:
| Property | Type | Example |
| --------- | ---------- | -------------------------- |
| `message` | string/map | `message contains "error"` |
| `level` | string | `level == "error"` |
| `stream` | string | `stream == "stderr"` |
| `type` | string | `type == "complex"` |
For JSON logs, you can access nested fields using dot notation:
```
message.status >= 500 && message.path contains "/api"
```
Supported string operators include `contains`, `startsWith`, `endsWith`, and `matches` (regex).
### Examples
**Alert on all errors from production containers:**
```
Container: labels["env"] == "production"
Log: level == "error"
```
**Alert on HTTP 5xx errors from API containers:**
```
Container: name contains "api"
Log: message.status >= 500
```
**Alert on any stderr output from a specific image:**
```
Container: image startsWith "myapp/"
Log: stream == "stderr"
```
> [!NOTE]
> The alert editor includes autocomplete and real-time validation. You can preview matched containers and logs before saving.
## Managing Alerts
From the Notifications page, you can:
- **Enable/disable** alerts without deleting them
- **Edit** alert expressions and destinations
- **View statistics** including trigger count, matched containers, and last triggered time
- **Delete** alerts that are no longer needed
-9
View File
@@ -27,12 +27,3 @@ 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.
-64
View File
@@ -1,64 +0,0 @@
---
title: Dozzle Cloud
---
# Dozzle Cloud
<Badge type="tip" text="New in v10" />
[Dozzle Cloud](https://cloud.dozzle.dev) is a companion service that extends your self-hosted Dozzle instances with centralized monitoring, smart alerting, and log intelligence. While Dozzle remains fully open source and self-hosted, Dozzle Cloud adds a managed layer on top for teams that need more visibility across their infrastructure.
## Why Dozzle Cloud?
Container logs are noisy. Dozzle Cloud helps you cut through the noise with intelligent summarization and multi-instance aggregation — without requiring additional agents or complex setup.
## Key Features
### Log Summaries
Dozzle Cloud automatically batches related container events into concise summaries. Each summary includes severity levels, source container information, and direct links to the full logs in your Dozzle instance.
### Pattern Clustering
Instead of showing duplicate errors, Dozzle Cloud groups similar errors together and displays frequency counts. This makes it easy to identify recurring issues across multiple containers.
### Smart Alert Distribution
Receive notifications through multiple channels:
- **Email**
- **Slack**
- **ntfy**
- **Webhooks**
- **Browser push notifications**
You can enable or disable channels independently with unlimited configurations.
### Multi-Instance Dashboard
Monitor all your Dozzle servers from a single dashboard. Connecting is simple — just link your instance with an API key. No additional agents are required.
### Searchable Event History
Search across all logged events with full-text search. Filter by container, severity (error, warning, info), and keywords. Retention is configurable from 24 hours to 30 days.
### Security
- API keys are hashed with expiration support
- GitHub OAuth authentication
- Your logs remain your own — Dozzle Cloud is committed to data privacy
## Connecting to Dozzle Cloud
To link your Dozzle instance to Dozzle Cloud:
1. Navigate to the **Notifications** page in Dozzle
2. Click **Add Destination** and select **Dozzle Cloud**
3. Click **Link Dozzle Cloud** — you'll be redirected to authenticate
4. Once linked, your API key is automatically configured
You can then select Dozzle Cloud as a destination when creating [alerts](/guide/alerts-and-webhooks).
## Pricing
Dozzle Cloud offers a free tier with 500 events per month — no credit card required. Visit [cloud.dozzle.dev](https://cloud.dozzle.dev) for more details.
-5
View File
@@ -35,11 +35,6 @@ features:
link: /guide/remote-hosts
linkText: Learn More
icon: 🌐
- title: Alerts & Webhooks
details: Set up customizable alerts with powerful expressions to monitor container logs and receive notifications via Slack, Discord, ntfy, or webhooks.
icon: 🔔
linkText: Learn More
link: /guide/alerts-and-webhooks
- title: SQL Engine
details: Use SQL queries to analyze logs inside your browser with WebAssembly and DuckDB.
icon: 📊
+1 -1
View File
@@ -1,4 +1,4 @@
users:
admin:
name: Admin
password: "$2y$11$pTGu6dnTT7Uh3ob7uC6X7OkAamlhHpJ0/mEbsmiPyO85pumillZme"
password: "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
+12 -61
View File
@@ -1,39 +1,14 @@
#!/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="${POSITIONAL_ARGS[0]:-dozzle-agent}"
DISTRO="${POSITIONAL_ARGS[1]:-ubuntu}"
AGENT_PORT="${POSITIONAL_ARGS[2]:-7007}"
VM_NAME="${1:-dozzle-agent}"
DISTRO="${2:-ubuntu}"
AGENT_PORT="${3:-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
@@ -65,7 +40,10 @@ 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
@@ -84,44 +62,17 @@ cat "$SHARED_KEY" | orb exec -m "$VM_NAME" bash -c 'cat > ~/dozzle-certs/shared_
echo "✅ Certificates copied"
# 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
# Step 4: 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 \
-e DOZZLE_LEVEL=debug \
$DOZZLE_IMAGE agent \
amir20/dozzle:latest agent \
--cert /certs/shared_cert.pem \
--key /certs/shared_key.pem
"; then
@@ -131,11 +82,11 @@ fi
echo "✅ Dozzle agent started"
# Step 6: Wait for agent to be ready
# Step 5: Wait for agent to be ready
echo "⏳ Waiting for agent to be ready..."
sleep 3
# Step 7: Verify agent is running
# Step 6: 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"
+21 -36
View File
@@ -8,6 +8,7 @@ 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
@@ -16,43 +17,37 @@ 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.49.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.39.0 // indirect
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/PuerkitoBio/goquery v1.11.0
github.com/andybalholm/brotli v1.2.0
github.com/expr-lang/expr v1.17.7
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/chi/v5 v5.2.3
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.4.0
github.com/puzpuzpuz/xsync/v4 v4.2.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
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/crypto v0.48.0
golang.org/x/crypto v0.46.0
golang.org/x/sync v0.19.0
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
k8s.io/api v0.35.1
k8s.io/apimachinery v0.35.1
k8s.io/client-go v0.35.1
k8s.io/metrics v0.35.1
k8s.io/api v0.35.0
k8s.io/apimachinery v0.35.0
k8s.io/client-go v0.35.0
k8s.io/metrics v0.35.0
)
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
@@ -62,21 +57,20 @@ 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/expr-lang/expr v1.17.7 // 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/gohugoio/hugo v0.149.1 // indirect
github.com/golang/protobuf v1.5.4 // 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
@@ -95,28 +89,24 @@ 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.62.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.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.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.10.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect
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
@@ -125,14 +115,9 @@ 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.26.0
tool (
github.com/air-verse/air
google.golang.org/grpc/cmd/protoc-gen-go-grpc
google.golang.org/protobuf/cmd/protoc-gen-go
)
go 1.25.5
+401 -216
View File
@@ -1,74 +1,35 @@
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/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.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/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=
@@ -83,12 +44,28 @@ 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/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.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/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=
@@ -97,36 +74,37 @@ 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/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.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
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/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/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/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=
@@ -139,62 +117,43 @@ 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/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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
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/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/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/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/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.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
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=
@@ -202,8 +161,6 @@ 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=
@@ -218,10 +175,6 @@ 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=
@@ -229,12 +182,6 @@ 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=
@@ -248,62 +195,47 @@ 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/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/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/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.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/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/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=
@@ -312,11 +244,13 @@ 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=
@@ -327,76 +261,125 @@ 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=
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.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
go.opentelemetry.io/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/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.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/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/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.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/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/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=
@@ -405,23 +388,57 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/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/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=
@@ -434,10 +451,22 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -447,10 +476,22 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -460,47 +501,102 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
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/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.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
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/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=
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=
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=
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/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.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/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=
@@ -508,35 +604,124 @@ 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/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q=
k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM=
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/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU=
k8s.io/apimachinery v0.35.1/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/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM=
k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA=
k8s.io/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/metrics v0.35.1 h1:MUcrUcWlq81XiripkydzCGsY9zQawDXfP9IICNNcVVw=
k8s.io/metrics v0.35.1/go.mod h1:9x7xWOAOiWzHA0vaqLgSE4PXF3vyT5ts5XIbx8OSjiI=
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=
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-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
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
@@ -1,177 +0,0 @@
# 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
+54 -101
View File
@@ -8,14 +8,12 @@ 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"
@@ -276,17 +274,8 @@ func (c *Client) StreamNewContainers(ctx context.Context, containers chan<- cont
}
}
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)
func (c *Client) FindContainer(ctx context.Context, containerID string) (container.Container, error) {
response, err := c.client.FindContainer(ctx, &pb.FindContainerRequest{ContainerId: containerID})
if err != nil {
return container.Container{}, err
}
@@ -376,41 +365,30 @@ func (c *Client) ContainerAttach(ctx context.Context, containerId string) (*cont
defer stdoutWriter.Close()
for {
select {
case <-ctx.Done():
msg, err := stream.Recv()
if err != nil {
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 {
select {
case <-ctx.Done():
n, err := stdinReader.Read(buffer)
if err != nil {
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
}
}
}()
@@ -434,101 +412,76 @@ func (c *Client) ContainerAttach(ctx context.Context, containerId string) (*cont
}, nil
}
func (c *Client) Exec(ctx context.Context, containerId string, cmd []string, events container.ExecEventReader, stdout io.Writer) error {
func (c *Client) ContainerExec(ctx context.Context, containerId string, cmd []string) (*container.ExecSession, error) {
stream, err := c.client.ContainerExec(ctx)
if err != nil {
return err
return nil, err
}
if err = stream.Send(&pb.ContainerExecRequest{
ContainerId: containerId,
Command: cmd,
}); err != nil {
return err
return nil, err
}
stdoutReader, stdoutWriter := io.Pipe()
stdinReader, stdinWriter := io.Pipe()
var wg sync.WaitGroup
go func() {
defer stdoutWriter.Close()
// Read from gRPC stream and write to stdout
wg.Go(func() {
for {
msg, err := stream.Recv()
if err != nil {
return
}
stdout.Write(msg.Stdout)
}
})
// Read events and convert to gRPC messages
wg.Go(func() {
stdoutWriter.Write(msg.Stdout)
}
}()
go func() {
buffer := make([]byte, 1024)
for {
event, err := events.ReadEvent()
n, err := stdinReader.Read(buffer)
if 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),
},
},
})
if err := stream.Send(&pb.ContainerExecRequest{
Payload: &pb.ContainerExecRequest_Stdin{
Stdin: buffer[:n],
},
}); err != nil {
return
}
}
})
}()
wg.Wait()
return nil
// 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
}
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)
+45 -55
View File
@@ -13,6 +13,7 @@ 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"
@@ -26,67 +27,58 @@ const bufSize = 1024 * 1024
var lis *bufconn.Listener
var certs tls.Certificate
var mockService *MockedClientService
var client *MockedClient
type MockedClientService struct {
type MockedClient struct {
mock.Mock
container.Client
}
func (m *MockedClientService) FindContainer(ctx context.Context, id string, labels container.ContainerLabels) (container.Container, error) {
args := m.Called(ctx, id, labels)
func (m *MockedClient) FindContainer(ctx context.Context, id string) (container.Container, error) {
args := m.Called(ctx, id)
return args.Get(0).(container.Container), args.Error(1)
}
func (m *MockedClientService) ListContainers(ctx context.Context, filter container.ContainerLabels) ([]container.Container, error) {
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) {
args := m.Called(ctx, filter)
return args.Get(0).([]container.Container), args.Error(1)
}
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)
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)
return args.Get(0).(io.ReadCloser), args.Error(1)
}
func (m *MockedClientService) SubscribeStats(ctx context.Context, stats chan<- container.ContainerStat) {
m.Called(ctx, stats)
func (m *MockedClient) ContainerStats(context.Context, string, chan<- container.ContainerStat) error {
return nil
}
func (m *MockedClientService) SubscribeEvents(ctx context.Context, events chan<- container.ContainerEvent) {
m.Called(ctx, events)
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) SubscribeContainersStarted(ctx context.Context, containers chan<- container.Container) {
m.Called(ctx, containers)
func (m *MockedClient) Host() container.Host {
args := m.Called()
return args.Get(0).(container.Host)
}
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) IsSwarmMode() bool {
return false
}
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)
func (m *MockedClient) SystemInfo() system.Info {
return system.Info{ID: "123"}
}
var wantedContainer = container.Container{}
@@ -111,30 +103,28 @@ func init() {
panic(err)
}
mockService = &MockedClientService{}
mockService.On("ListContainers", mock.Anything, mock.Anything).Return([]container.Container{
wantedContainer,
client = &MockedClient{}
client.On("ListContainers", mock.Anything, mock.Anything).Return([]container.Container{
{
ID: "123456",
Name: "test",
Host: "localhost",
State: "running",
},
}, nil)
mockService.On("Host", mock.Anything).Return(container.Host{
client.On("Host").Return(container.Host{
ID: "localhost",
Endpoint: "local",
Name: "local",
}, nil)
})
mockService.On("SubscribeEvents", mock.Anything, mock.AnythingOfType("chan<- container.ContainerEvent")).Return().Run(func(args mock.Arguments) {
client.On("ContainerEvents", mock.Anything, mock.AnythingOfType("chan<- container.ContainerEvent")).Return(nil).Run(func(args mock.Arguments) {
time.Sleep(5 * time.Second)
})
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)
client.On("FindContainer", mock.Anything, "123456").Return(wantedContainer, nil)
server, _ := NewServer(client, certs, "test", container.ContainerLabels{})
go server.Serve(lis)
}
@@ -148,7 +138,7 @@ func TestFindContainer(t *testing.T) {
t.Fatal(err)
}
c, _ := rpc.FindContainer(context.Background(), "123456", container.ContainerLabels{})
c, _ := rpc.FindContainer(context.Background(), "123456")
assert.Equal(t, wantedContainer, c)
}
+86 -187
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.4
// protoc v6.33.3
// source: rpc.proto
package pb
@@ -1270,99 +1270,11 @@ 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\x1fgoogle/protobuf/timestamp.proto\x1a\vtypes.proto\"\xb1\x01\n" +
"\trpc.proto\x12\bprotobuf\x1a\vtypes.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xb1\x01\n" +
"\x15ListContainersRequest\x12C\n" +
"\x06filter\x18\x01 \x03(\v2+.protobuf.ListContainersRequest.FilterEntryR\x06filter\x1aS\n" +
"\vFilterEntry\x12\x10\n" +
@@ -1433,11 +1345,7 @@ 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\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" +
"\x06stdout\x18\x01 \x01(\fR\x06stdout2\xa1\b\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" +
@@ -1451,8 +1359,7 @@ 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\x01\x12s\n" +
"\x18UpdateNotificationConfig\x12).protobuf.UpdateNotificationConfigRequest\x1a*.protobuf.UpdateNotificationConfigResponse\"\x00B\x13Z\x11internal/agent/pbb\x06proto3"
"\x0fContainerAttach\x12 .protobuf.ContainerAttachRequest\x1a!.protobuf.ContainerAttachResponse\"\x00(\x010\x01B\x13Z\x11internal/agent/pbb\x06proto3"
var (
file_rpc_proto_rawDescOnce sync.Once
@@ -1466,100 +1373,92 @@ func file_rpc_proto_rawDescGZIP() []byte {
return file_rpc_proto_rawDescData
}
var file_rpc_proto_msgTypes = make([]protoimpl.MessageInfo, 29)
var file_rpc_proto_msgTypes = make([]protoimpl.MessageInfo, 27)
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
(*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
(*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
}
var file_rpc_proto_depIdxs = []int32{
27, // 0: protobuf.ListContainersRequest.filter:type_name -> protobuf.ListContainersRequest.FilterEntry
29, // 1: protobuf.ListContainersResponse.containers:type_name -> protobuf.Container
28, // 2: protobuf.FindContainerRequest.filter:type_name -> protobuf.FindContainerRequest.FilterEntry
29, // 3: protobuf.FindContainerResponse.container:type_name -> protobuf.Container
30, // 4: protobuf.StreamLogsRequest.since:type_name -> google.protobuf.Timestamp
31, // 5: protobuf.StreamLogsResponse.event:type_name -> protobuf.LogEvent
30, // 6: protobuf.LogsBetweenDatesRequest.since:type_name -> google.protobuf.Timestamp
30, // 7: protobuf.LogsBetweenDatesRequest.until:type_name -> google.protobuf.Timestamp
30, // 8: protobuf.StreamRawBytesRequest.since:type_name -> google.protobuf.Timestamp
30, // 9: protobuf.StreamRawBytesRequest.until:type_name -> google.protobuf.Timestamp
32, // 10: protobuf.StreamEventsResponse.event:type_name -> protobuf.ContainerEvent
33, // 11: protobuf.StreamStatsResponse.stat:type_name -> protobuf.ContainerStat
34, // 12: protobuf.HostInfoResponse.host:type_name -> protobuf.Host
29, // 13: protobuf.StreamContainerStartedResponse.container:type_name -> protobuf.Container
35, // 14: protobuf.ContainerActionRequest.action:type_name -> protobuf.ContainerAction
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
21, // 15: protobuf.ContainerExecRequest.resize:type_name -> protobuf.ResizePayload
21, // 16: protobuf.ContainerAttachRequest.resize:type_name -> protobuf.ResizePayload
36, // 17: protobuf.UpdateNotificationConfigRequest.subscriptions:type_name -> protobuf.NotificationSubscription
37, // 18: protobuf.UpdateNotificationConfigRequest.dispatchers:type_name -> protobuf.NotificationDispatcher
1, // 19: protobuf.ListContainersRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
1, // 20: protobuf.FindContainerRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
0, // 21: protobuf.AgentService.ListContainers:input_type -> protobuf.ListContainersRequest
3, // 22: protobuf.AgentService.FindContainer:input_type -> protobuf.FindContainerRequest
5, // 23: protobuf.AgentService.StreamLogs:input_type -> protobuf.StreamLogsRequest
7, // 24: protobuf.AgentService.LogsBetweenDates:input_type -> protobuf.LogsBetweenDatesRequest
8, // 25: protobuf.AgentService.StreamRawBytes:input_type -> protobuf.StreamRawBytesRequest
10, // 26: protobuf.AgentService.StreamEvents:input_type -> protobuf.StreamEventsRequest
12, // 27: protobuf.AgentService.StreamStats:input_type -> protobuf.StreamStatsRequest
16, // 28: protobuf.AgentService.StreamContainerStarted:input_type -> protobuf.StreamContainerStartedRequest
14, // 29: protobuf.AgentService.HostInfo:input_type -> protobuf.HostInfoRequest
18, // 30: protobuf.AgentService.ContainerAction:input_type -> protobuf.ContainerActionRequest
20, // 31: protobuf.AgentService.ContainerExec:input_type -> protobuf.ContainerExecRequest
23, // 32: protobuf.AgentService.ContainerAttach:input_type -> protobuf.ContainerAttachRequest
25, // 33: protobuf.AgentService.UpdateNotificationConfig:input_type -> protobuf.UpdateNotificationConfigRequest
2, // 34: protobuf.AgentService.ListContainers:output_type -> protobuf.ListContainersResponse
4, // 35: protobuf.AgentService.FindContainer:output_type -> protobuf.FindContainerResponse
6, // 36: protobuf.AgentService.StreamLogs:output_type -> protobuf.StreamLogsResponse
6, // 37: protobuf.AgentService.LogsBetweenDates:output_type -> protobuf.StreamLogsResponse
9, // 38: protobuf.AgentService.StreamRawBytes:output_type -> protobuf.StreamRawBytesResponse
11, // 39: protobuf.AgentService.StreamEvents:output_type -> protobuf.StreamEventsResponse
13, // 40: protobuf.AgentService.StreamStats:output_type -> protobuf.StreamStatsResponse
17, // 41: protobuf.AgentService.StreamContainerStarted:output_type -> protobuf.StreamContainerStartedResponse
15, // 42: protobuf.AgentService.HostInfo:output_type -> protobuf.HostInfoResponse
19, // 43: protobuf.AgentService.ContainerAction:output_type -> protobuf.ContainerActionResponse
22, // 44: protobuf.AgentService.ContainerExec:output_type -> protobuf.ContainerExecResponse
24, // 45: protobuf.AgentService.ContainerAttach:output_type -> protobuf.ContainerAttachResponse
26, // 46: protobuf.AgentService.UpdateNotificationConfig:output_type -> protobuf.UpdateNotificationConfigResponse
34, // [34:47] is the sub-list for method output_type
21, // [21:34] is the sub-list for method input_type
21, // [21:21] is the sub-list for extension type_name
21, // [21:21] is the sub-list for extension extendee
0, // [0:21] is the sub-list for field type_name
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
}
func init() { file_rpc_proto_init() }
@@ -1582,7 +1481,7 @@ func file_rpc_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_rpc_proto_rawDesc), len(file_rpc_proto_rawDesc)),
NumEnums: 0,
NumMessages: 29,
NumMessages: 27,
NumExtensions: 0,
NumServices: 1,
},
+13 -51
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.4
// - protoc v6.33.3
// source: rpc.proto
package pb
@@ -19,19 +19,18 @@ 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_UpdateNotificationConfig_FullMethodName = "/protobuf.AgentService/UpdateNotificationConfig"
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"
)
// AgentServiceClient is the client API for AgentService service.
@@ -50,7 +49,6 @@ 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 {
@@ -241,16 +239,6 @@ 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.
@@ -267,7 +255,6 @@ 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()
}
@@ -314,9 +301,6 @@ 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() {}
@@ -490,24 +474,6 @@ 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)
@@ -531,10 +497,6 @@ var AgentService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ContainerAction",
Handler: _AgentService_ContainerAction_Handler,
},
{
MethodName: "UpdateNotificationConfig",
Handler: _AgentService_UpdateNotificationConfig_Handler,
},
},
Streams: []grpc.StreamDesc{
{
+26 -201
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.4
// protoc v6.33.3
// source: types.proto
package pb
@@ -820,166 +820,6 @@ 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 = "" +
@@ -1058,20 +898,7 @@ const file_types_proto_rawDesc = "" +
"\rdockerVersion\x18\f \x01(\tR\rdockerVersion\x1a9\n" +
"\vLabelsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xd4\x01\n" +
"\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" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01*3\n" +
"\x0fContainerAction\x12\t\n" +
"\x05Start\x10\x00\x12\b\n" +
"\x04Stop\x10\x01\x12\v\n" +
@@ -1090,36 +917,34 @@ func file_types_proto_rawDescGZIP() []byte {
}
var file_types_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_types_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
var file_types_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_types_proto_goTypes = []any{
(ContainerAction)(0), // 0: protobuf.ContainerAction
(*Container)(nil), // 1: protobuf.Container
(*ContainerStat)(nil), // 2: protobuf.ContainerStat
(*LogFragment)(nil), // 3: protobuf.LogFragment
(*LogEvent)(nil), // 4: protobuf.LogEvent
(*SingleMessage)(nil), // 5: protobuf.SingleMessage
(*GroupMessage)(nil), // 6: protobuf.GroupMessage
(*ComplexMessage)(nil), // 7: protobuf.ComplexMessage
(*ContainerEvent)(nil), // 8: protobuf.ContainerEvent
(*Host)(nil), // 9: protobuf.Host
(*NotificationSubscription)(nil), // 10: protobuf.NotificationSubscription
(*NotificationDispatcher)(nil), // 11: protobuf.NotificationDispatcher
nil, // 12: protobuf.Container.LabelsEntry
nil, // 13: protobuf.Host.LabelsEntry
(*timestamppb.Timestamp)(nil), // 14: google.protobuf.Timestamp
(*anypb.Any)(nil), // 15: google.protobuf.Any
(ContainerAction)(0), // 0: protobuf.ContainerAction
(*Container)(nil), // 1: protobuf.Container
(*ContainerStat)(nil), // 2: protobuf.ContainerStat
(*LogFragment)(nil), // 3: protobuf.LogFragment
(*LogEvent)(nil), // 4: protobuf.LogEvent
(*SingleMessage)(nil), // 5: protobuf.SingleMessage
(*GroupMessage)(nil), // 6: protobuf.GroupMessage
(*ComplexMessage)(nil), // 7: protobuf.ComplexMessage
(*ContainerEvent)(nil), // 8: protobuf.ContainerEvent
(*Host)(nil), // 9: protobuf.Host
nil, // 10: protobuf.Container.LabelsEntry
nil, // 11: protobuf.Host.LabelsEntry
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
(*anypb.Any)(nil), // 13: google.protobuf.Any
}
var file_types_proto_depIdxs = []int32{
14, // 0: protobuf.Container.created:type_name -> google.protobuf.Timestamp
14, // 1: protobuf.Container.started:type_name -> google.protobuf.Timestamp
12, // 2: protobuf.Container.labels:type_name -> protobuf.Container.LabelsEntry
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
2, // 3: protobuf.Container.stats:type_name -> protobuf.ContainerStat
14, // 4: protobuf.Container.finished:type_name -> google.protobuf.Timestamp
15, // 5: protobuf.LogEvent.message:type_name -> google.protobuf.Any
14, // 6: protobuf.LogEvent.timestamp:type_name -> google.protobuf.Timestamp
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
3, // 7: protobuf.GroupMessage.fragments:type_name -> protobuf.LogFragment
14, // 8: protobuf.ContainerEvent.timestamp:type_name -> google.protobuf.Timestamp
13, // 9: protobuf.Host.labels:type_name -> protobuf.Host.LabelsEntry
12, // 8: protobuf.ContainerEvent.timestamp:type_name -> google.protobuf.Timestamp
11, // 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
@@ -1138,7 +963,7 @@ func file_types_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_types_proto_rawDesc), len(file_types_proto_rawDesc)),
NumEnums: 1,
NumMessages: 13,
NumMessages: 11,
NumExtensions: 0,
NumServices: 0,
},
+143 -177
View File
@@ -6,7 +6,7 @@ import (
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"sync"
"encoding/json"
@@ -14,7 +14,7 @@ import (
"github.com/amir20/dozzle/internal/agent/pb"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/types"
"github.com/amir20/dozzle/internal/docker"
"github.com/rs/zerolog/log"
orderedmap "github.com/wk8/go-ordered-map/v2"
"google.golang.org/grpc"
@@ -26,40 +26,21 @@ 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 {
service ClientService
version string
notificationConfigHandler NotificationConfigHandler
client container.Client
store *container.ContainerStore
version string
pb.UnimplementedAgentServiceServer
}
func newServer(service ClientService, dozzleVersion string, notificationHandler NotificationConfigHandler) pb.AgentServiceServer {
func newServer(client container.Client, dozzleVersion string, labels container.ContainerLabels) pb.AgentServiceServer {
statsCollector := docker.NewDockerStatsCollector(client, labels)
return &server{
service: service,
version: dozzleVersion,
notificationConfigHandler: notificationHandler,
client: client,
version: dozzleVersion,
store: container.NewContainerStore(context.Background(), client, statsCollector, labels),
}
}
@@ -69,18 +50,20 @@ func (s *server) StreamLogs(in *pb.StreamLogsRequest, out pb.AgentService_Stream
since = in.Since.AsTime()
}
c, err := s.service.FindContainer(out.Context(), in.ContainerId, container.ContainerLabels{})
c, err := s.store.FindContainer(in.ContainerId, container.ContainerLabels{})
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)
}()
reader, err := s.client.ContainerLogs(out.Context(), in.ContainerId, since, container.StdType(in.StreamTypes))
if err != nil {
return err
}
for event := range events {
dockerReader := docker.NewLogReader(reader, c.Tty)
g := container.NewEventGenerator(out.Context(), dockerReader, c)
for event := range g.Events {
if event != nil {
out.Send(&pb.StreamLogsResponse{
Event: logEventToPb(event),
@@ -88,23 +71,31 @@ func (s *server) StreamLogs(in *pb.StreamLogsRequest, out pb.AgentService_Stream
}
}
return nil
select {
case e := <-g.Errors:
return e
default:
return nil
}
}
func (s *server) LogsBetweenDates(in *pb.LogsBetweenDatesRequest, out pb.AgentService_LogsBetweenDatesServer) error {
c, err := s.service.FindContainer(out.Context(), in.ContainerId, container.ContainerLabels{})
reader, err := s.client.ContainerLogsBetweenDates(out.Context(), in.ContainerId, in.Since.AsTime(), in.Until.AsTime(), container.StdType(in.StreamTypes))
if err != nil {
return err
}
events, err := s.service.LogsBetweenDates(out.Context(), c, in.Since.AsTime(), in.Until.AsTime(), container.StdType(in.StreamTypes))
c, err := s.client.FindContainer(out.Context(), in.ContainerId)
if err != nil {
return err
}
dockerReader := docker.NewLogReader(reader, c.Tty)
g := container.NewEventGenerator(out.Context(), dockerReader, c)
for {
select {
case event, ok := <-events:
case event, ok := <-g.Events:
if !ok {
// Channel closed, exit cleanly
return nil
@@ -112,6 +103,8 @@ 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
}
@@ -119,24 +112,16 @@ func (s *server) LogsBetweenDates(in *pb.LogsBetweenDatesRequest, out pb.AgentSe
}
func (s *server) StreamRawBytes(in *pb.StreamRawBytesRequest, out pb.AgentService_StreamRawBytesServer) error {
c, err := s.service.FindContainer(out.Context(), in.ContainerId, container.ContainerLabels{})
if err != nil {
return err
}
reader, err := s.client.ContainerLogsBetweenDates(out.Context(), in.ContainerId, in.Since.AsTime(), in.Until.AsTime(), container.StdType(in.StreamTypes))
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
}
@@ -157,7 +142,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.service.SubscribeEvents(out.Context(), events)
s.store.SubscribeEvents(out.Context(), events)
for {
select {
@@ -179,7 +164,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.service.SubscribeStats(out.Context(), stats)
s.store.SubscribeStats(out.Context(), stats)
for {
select {
@@ -208,13 +193,13 @@ func (s *server) FindContainer(ctx context.Context, in *pb.FindContainerRequest)
}
}
c, err := s.service.FindContainer(ctx, in.ContainerId, labels)
container, err := s.store.FindContainer(in.ContainerId, labels)
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
proto := c.ToProto()
c := container.ToProto()
return &pb.FindContainerResponse{
Container: &proto,
Container: &c,
}, nil
}
@@ -226,15 +211,15 @@ func (s *server) ListContainers(ctx context.Context, in *pb.ListContainersReques
}
}
containers, err := s.service.ListContainers(ctx, labels)
containers, err := s.store.ListContainers(labels)
if err != nil {
return nil, err
}
var pbContainers []*pb.Container
for _, c := range containers {
proto := c.ToProto()
pbContainers = append(pbContainers, &proto)
for _, container := range containers {
c := container.ToProto()
pbContainers = append(pbContainers, &c)
}
return &pb.ListContainersResponse{
@@ -243,10 +228,7 @@ func (s *server) ListContainers(ctx context.Context, in *pb.ListContainersReques
}
func (s *server) HostInfo(ctx context.Context, in *pb.HostInfoRequest) (*pb.HostInfoResponse, error) {
host, err := s.service.Host(ctx)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
host := s.client.Host()
return &pb.HostInfoResponse{
Host: &pb.Host{
Id: host.ID,
@@ -262,7 +244,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.service.SubscribeContainersStarted(out.Context(), containers)
go s.store.SubscribeNewContainers(out.Context(), containers)
for {
select {
@@ -293,12 +275,8 @@ func (s *server) ContainerAction(ctx context.Context, in *pb.ContainerActionRequ
return nil, status.Error(codes.InvalidArgument, "invalid action")
}
c, err := s.service.FindContainer(ctx, in.ContainerId, container.ContainerLabels{})
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
err := s.client.ContainerActions(ctx, action, in.ContainerId)
err = s.service.ContainerAction(ctx, c, action)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
@@ -306,63 +284,63 @@ 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())
}
c, err := s.service.FindContainer(stream.Context(), request.ContainerId, container.ContainerLabels{})
cancelCtx, cancel := context.WithCancel(stream.Context())
session, err := s.client.ContainerExec(cancelCtx, request.ContainerId, request.Command)
if err != nil {
return status.Error(codes.NotFound, err.Error())
}
reader := &protoEventReader{recv: func() (terminalMessage, error) { return stream.Recv() }}
writer := &terminalStreamWriter{send: func(p []byte) error { return stream.Send(&pb.ContainerExecResponse{Stdout: p}) }}
if err := s.service.Exec(stream.Context(), c, request.Command, reader, writer); err != nil {
cancel()
return status.Error(codes.Internal, err.Error())
}
var wg sync.WaitGroup
// 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()
return nil
}
@@ -372,73 +350,61 @@ func (s *server) ContainerAttach(stream pb.AgentService_ContainerAttachServer) e
return status.Error(codes.Internal, err.Error())
}
c, err := s.service.FindContainer(stream.Context(), request.ContainerId, container.ContainerLabels{})
cancelCtx, cancel := context.WithCancel(stream.Context())
session, err := s.client.ContainerAttach(cancelCtx, request.ContainerId)
if err != nil {
return status.Error(codes.NotFound, err.Error())
}
reader := &protoEventReader{recv: func() (terminalMessage, error) { return stream.Recv() }}
writer := &terminalStreamWriter{send: func(p []byte) error { return stream.Send(&pb.ContainerAttachResponse{Stdout: p}) }}
if err := s.service.Attach(stream.Context(), c, reader, writer); err != nil {
cancel()
return status.Error(codes.Internal, err.Error())
}
var wg sync.WaitGroup
// 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()
return nil
}
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) {
func NewServer(client container.Client, certificates tls.Certificate, dozzleVersion string, labels container.ContainerLabels) (*grpc.Server, error) {
caCertPool := x509.NewCertPool()
c, err := x509.ParseCertificate(certificates.Certificate[0])
if err != nil {
@@ -457,7 +423,7 @@ func NewServer(service ClientService, certificates tls.Certificate, dozzleVersio
creds := credentials.NewTLS(tlsConfig)
grpcServer := grpc.NewServer(grpc.Creds(creds))
pb.RegisterAgentServiceServer(grpcServer, newServer(service, dozzleVersion, notificationHandler))
pb.RegisterAgentServiceServer(grpcServer, newServer(client, dozzleVersion, labels))
return grpcServer, nil
}
+1 -3
View File
@@ -46,9 +46,7 @@ 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.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
log.Fatal().Str("filter", r.Header.Get(p.headerFilter)).Msg("Failed to parse container filter")
}
userRoles := All
if strings.TrimSpace(r.Header.Get(p.headerRoles)) != "" {
-56
View File
@@ -1,56 +0,0 @@
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)
}
+10 -3
View File
@@ -3,6 +3,8 @@ package auth
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/url"
@@ -170,9 +172,15 @@ func (u *UserDatabase) FindByPassword(username, password string) *User {
return user
}
func sha256sum(s string) string {
bytes := sha256.Sum256([]byte(s))
return hex.EncodeToString(bytes[:])
}
func CompareHashAndPassword(hash, password string) bool {
if len(hash) == 64 {
log.Fatal().Msg("sha256 passwords are no longer supported. Please use bcrypt. See https://github.com/amir20/dozzle/security/advisories/GHSA-w7qr-q9fh-fj35 for more details.")
log.Warn().Msg("Using sha256sum for password comparison. Consider using a more secure hash algorithm to protected against brute-force attacks. See https://github.com/amir20/dozzle/security/advisories/GHSA-w7qr-q9fh-fj35 for more details.")
return hash == sha256sum(password)
}
if len(hash) == 60 {
@@ -204,8 +212,7 @@ func UserFromContext(ctx context.Context) *User {
if filter, ok := claims["filter"].(string); ok {
containerFilter, err = container.ParseContainerFilter(filter)
if err != nil {
log.Warn().Err(err).Str("filter", filter).Msg("Failed to parse container filter")
return nil
log.Fatal().Err(err).Str("filter", filter).Msg("Failed to parse container filter")
}
}
roles := None
-5
View File
@@ -41,11 +41,6 @@ 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)
+37 -42
View File
@@ -171,52 +171,47 @@ func (s *ContainerStore) FindContainer(id string, labels ContainerLabels) (Conta
}
}
var updated bool
container, found := s.containers.Compute(id, func(c *Container, loaded bool) (*Container, xsync.ComputeOp) {
if !loaded {
return nil, xsync.CancelOp
}
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 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,
}
if !found {
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
}
}
return *container, nil
} else {
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 {
+3 -3
View File
@@ -16,7 +16,7 @@ var logLevels = [][]string{
{"warn", "warning", "wrn"},
{"info", "inf"},
{"debug", "dbg"},
{"trace", "verbose", "ver", "vbs"},
{"trace"},
{"fatal", "sev", "severe", "crit", "critical"},
}
@@ -44,7 +44,7 @@ func init() {
func guessLogLevel(logEvent *LogEvent) string {
switch value := logEvent.Message.(type) {
case string:
value = StripANSI(value)
value = stripANSI(value)
value = timestampRegex.ReplaceAllString(value, "")
for _, levelGroup := range logLevels {
first := levelGroup[0]
@@ -125,7 +125,7 @@ func guessLogLevel(logEvent *LogEvent) string {
}
func normalizeLogLevel(level string) string {
level = StripANSI(level)
level = stripANSI(level)
level = strings.ToLower(level)
if _, ok := SupportedLogLevels[level]; ok {
return level
+1 -1
View File
@@ -8,6 +8,6 @@ const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)
var re = regexp.MustCompile(ansi)
func StripANSI(str string) string {
func stripANSI(str string) string {
return re.ReplaceAllString(str, "")
}
-8
View File
@@ -386,8 +386,6 @@ 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], "/")
}
@@ -395,8 +393,6 @@ 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],
@@ -416,8 +412,6 @@ 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, "/")
}
@@ -425,8 +419,6 @@ 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,105 +260,3 @@ 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
@@ -1,115 +0,0 @@
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
}
@@ -1,13 +0,0 @@
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
@@ -1,115 +0,0 @@
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
@@ -1,204 +0,0 @@
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
}
+254 -509
View File
@@ -3,578 +3,323 @@ package notification
import (
"context"
"fmt"
"io"
"slices"
"strings"
"sync/atomic"
"sync"
"time"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/internal/notification/dispatcher"
"github.com/amir20/dozzle/types"
container_support "github.com/amir20/dozzle/internal/support/container"
"github.com/expr-lang/expr"
"github.com/puzpuzpuz/xsync/v4"
"github.com/expr-lang/expr/vm"
"github.com/rs/zerolog/log"
"go.yaml.in/yaml/v3"
"golang.org/x/sync/semaphore"
)
// Manager manages notification subscriptions and dispatches notifications
// Manager handles notification subscriptions and matching
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
subscriptions []*compiledSubscription
mu sync.RWMutex
activeStreams map[string]*activeStream // containerID -> stream info
containerCache map[string]Container // containerID -> notification.Container
containerService ContainerService
dispatcher Dispatcher
}
// activeStream tracks a single log stream and all subscriptions watching it
type activeStream struct {
container Container // notification.Container
subscriptions []*compiledSubscription // All subscriptions watching this container
cancel context.CancelFunc // Cancel function for the stream
}
// compiledSubscription wraps a Subscription with compiled expr programs
type compiledSubscription struct {
subscription *Subscription
containerProgram *vm.Program // Always non-nil
logProgram *vm.Program // Always non-nil
}
// ContainerService provides access to containers and log streaming
type ContainerService interface {
ListAllContainers(labels container.ContainerLabels) ([]container.Container, []error)
FindContainer(host, id string, labels container.ContainerLabels) (*container_support.ContainerService, error)
SubscribeContainersStarted(ctx context.Context, containers chan<- container.Container, filter container_support.ContainerFilter)
}
// 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),
func NewManager(containerService ContainerService, dispatcher Dispatcher) *Manager {
return &Manager{
subscriptions: make([]*compiledSubscription, 0),
activeStreams: make(map[string]*activeStream),
containerCache: make(map[string]Container),
containerService: containerService,
dispatcher: dispatcher,
}
// 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)
// LoadSubscriptions loads and compiles multiple subscriptions (replaces existing)
func (m *Manager) LoadSubscriptions(subs []*Subscription) error {
m.mu.Lock()
m.subscriptions = make([]*compiledSubscription, 0, len(subs))
m.mu.Unlock()
for _, sub := range subs {
if err := m.AddSubscription(sub); err != nil {
return err
}
}
log.Info().Int("count", len(subs)).Msg("loaded notification subscriptions")
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
// AddSubscription adds a single subscription
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
cs, err := m.compileSubscription(sub)
if err != nil {
return err
}
// 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")
}
}
m.mu.Lock()
m.subscriptions = append(m.subscriptions, cs)
m.mu.Unlock()
log.Info().Str("subscription", sub.Name).Msg("added notification subscription")
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")
// compileSubscription compiles expr programs for a subscription
func (m *Manager) compileSubscription(sub *Subscription) (*compiledSubscription, error) {
if sub.ContainerFilter == "" {
return nil, fmt.Errorf("container_filter is required for subscription %q", sub.Name)
}
if sub.LogFilter == "" {
return nil, fmt.Errorf("log_filter is required for subscription %q", sub.Name)
}
// 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")
cs := &compiledSubscription{
subscription: sub,
}
// Compile container filter (required)
containerProgram, err := expr.Compile(sub.ContainerFilter, expr.Env(Container{}))
if err != nil {
log.Error().Err(err).Str("subscription", sub.Name).Msg("failed to compile container filter")
return nil, fmt.Errorf("failed to compile container_filter for %q: %w", sub.Name, err)
}
cs.containerProgram = containerProgram
// Compile log filter (required)
logProgram, err := expr.Compile(sub.LogFilter, expr.Env(Log{}))
if err != nil {
log.Error().Err(err).Str("subscription", sub.Name).Msg("failed to compile log filter")
return nil, fmt.Errorf("failed to compile log_filter for %q: %w", sub.Name, err)
}
cs.logProgram = logProgram
return cs, nil
}
// Start begins monitoring containers and streaming logs for matching subscriptions
// Only starts if there are subscriptions configured
func (m *Manager) Start(ctx context.Context) error {
m.mu.RLock()
hasSubscriptions := len(m.subscriptions) > 0
m.mu.RUnlock()
if !hasSubscriptions {
log.Debug().Msg("no subscriptions configured, skipping notification manager start")
return nil
}
// Subscribe to new containers that match our filters
newContainers := make(chan container.Container)
m.containerService.SubscribeContainersStarted(ctx, newContainers, func(c *container.Container) bool {
matchingSubs := m.getMatchingSubscriptions(c)
return len(matchingSubs) > 0
})
// Handle new containers in background
go func() {
for c := range newContainers {
m.startContainerStream(ctx, &c)
}
}()
// Get all existing containers and start streaming matching ones
containers, errs := m.containerService.ListAllContainers(nil)
for _, err := range errs {
log.Warn().Err(err).Msg("error listing containers for notifications")
}
// Check each container and start streaming only if it matches any subscription
for _, c := range containers {
if c.State == "running" {
matchingSubs := m.getMatchingSubscriptions(&c)
if len(matchingSubs) > 0 {
m.startContainerStream(ctx, &c)
}
}
}
}
// 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
}
// startContainerStream starts a single log stream for a container
// All matching subscriptions will receive logs from this one stream
func (m *Manager) startContainerStream(ctx context.Context, c *container.Container) {
// Check if already streaming
m.mu.RLock()
_, exists := m.activeStreams[c.ID]
m.mu.RUnlock()
// 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 exists {
return
}
if !ok {
return fmt.Errorf("subscription not found")
// Find which subscriptions match this container
matchingSubs := m.getMatchingSubscriptions(c)
if len(matchingSubs) == 0 {
return
}
log.Debug().Int("id", id).Interface("updates", updates).Msg("Updated subscription")
log.Debug().
Str("container", c.Name).
Int("subscriptions", len(matchingSubs)).
Msg("starting log stream for notification subscriptions")
// 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")
}
// Convert to notification.Container and cache it
notifContainer := NewContainer(c)
m.mu.Lock()
m.containerCache[c.ID] = notifContainer
m.mu.Unlock()
streamCtx, cancel := context.WithCancel(ctx)
stream := &activeStream{
container: notifContainer,
subscriptions: matchingSubs,
cancel: cancel,
}
return nil
}
m.mu.Lock()
m.activeStreams[c.ID] = stream
m.mu.Unlock()
// 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
}
go func() {
defer func() {
m.mu.Lock()
delete(m.activeStreams, c.ID)
m.mu.Unlock()
}()
// 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")
}
logs := make(chan *container.LogEvent)
// 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 {
// Stream logs in separate goroutine
go func() {
containerService, err := m.containerService.FindContainer(c.Host, c.ID, nil)
if err != nil {
log.Error().Err(err).Msg("error finding container for notification streaming")
return
}
m.processLogEvent(logEvent)
err = containerService.StreamLogs(streamCtx, time.Now(), container.STDOUT|container.STDERR, logs)
if err != nil && err != context.Canceled {
log.Error().Err(err).Msg("error streaming logs for notification")
}
close(logs)
}()
// Process logs - check against all matching subscriptions
for logEvent := range logs {
for _, cs := range matchingSubs {
m.processLogEvent(logEvent, c.ID, cs)
}
}
}
}()
}
// 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()
// getMatchingSubscriptions returns subscriptions that match a container
func (m *Manager) getMatchingSubscriptions(c *container.Container) []*compiledSubscription {
m.mu.RLock()
defer m.mu.RUnlock()
c, host, err := m.listener.FindContainerWithHost(ctx, logEvent.ContainerID, nil)
containerCtx := NewContainer(c)
matching := make([]*compiledSubscription, 0)
for _, cs := range m.subscriptions {
if m.matchesContainer(cs.containerProgram, containerCtx) {
matching = append(matching, cs)
}
}
return matching
}
// matchesContainer evaluates the container filter expression
func (m *Manager) matchesContainer(program *vm.Program, containerCtx Container) bool {
result, err := expr.Run(program, containerCtx)
if err != nil {
log.Error().Err(err).Str("containerID", logEvent.ContainerID).Msg("Failed to find container")
log.Error().Err(err).Msg("error evaluating container filter")
return false
}
match, ok := result.(bool)
if !ok {
log.Error().Msg("container filter did not return boolean")
return false
}
return match
}
// processLogEvent evaluates a log event against the subscription's log filter
func (m *Manager) processLogEvent(logEvent *container.LogEvent, containerID string, cs *compiledSubscription) {
// Lookup notification.Container from cache
m.mu.RLock()
notifContainer, ok := m.containerCache[containerID]
m.mu.RUnlock()
if !ok {
log.Warn().Str("container", containerID).Msg("container not in cache")
return
}
// Skip logs from Dozzle itself to avoid feedback loops
if strings.Contains(c.Image, "dozzle") {
return
}
logCtx := NewLog(logEvent, notifContainer)
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")
// Evaluate log filter (program is never nil)
if m.matchesLog(cs.logProgram, logCtx) {
m.triggerWebhook(cs.subscription, logCtx)
}
}
// 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(),
// matchesLog evaluates the log filter expression
func (m *Manager) matchesLog(program *vm.Program, logCtx Log) bool {
result, err := expr.Run(program, logCtx)
if err != nil {
log.Error().Err(err).Msg("error evaluating log filter")
return false
}
encoder := yaml.NewEncoder(w)
defer encoder.Close()
match, ok := result.(bool)
if !ok {
log.Error().Msg("log filter did not return boolean")
return false
}
return encoder.Encode(config)
return match
}
// 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)
// triggerWebhook sends the notification
func (m *Manager) triggerWebhook(sub *Subscription, logCtx Log) {
payload := WebhookPayload{
SubscriptionName: sub.Name,
Timestamp: time.Now(),
Container: logCtx.Container,
Log: LogEvent{
Message: logCtx.Message,
Level: logCtx.Level,
Timestamp: logCtx.Timestamp,
},
}
// 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
m.dispatcher.Dispatch(sub, payload)
}
+87
View File
@@ -0,0 +1,87 @@
package notification
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// Config represents the YAML configuration structure
type Config struct {
Dispatcher DispatcherConfig `yaml:"dispatcher"`
Subscriptions []*Subscription `yaml:"subscriptions"`
}
// LoadFromFile loads config and returns a fully configured Manager
func LoadFromFile(path string, containerService ContainerService) (*Manager, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // No config file is fine, return nil
}
return nil, fmt.Errorf("failed to read notifications config: %w", err)
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse notifications config: %w", err)
}
// Validate dispatcher config
if config.Dispatcher.Type == "" {
config.Dispatcher.Type = "simple" // Default to simple
}
if config.Dispatcher.URL == "" {
return nil, fmt.Errorf("dispatcher.url is required")
}
// Validate subscriptions
for _, sub := range config.Subscriptions {
if sub.Name == "" {
return nil, fmt.Errorf("subscription missing name")
}
if sub.ContainerFilter == "" {
return nil, fmt.Errorf("subscription %q missing container_filter", sub.Name)
}
if sub.LogFilter == "" {
return nil, fmt.Errorf("subscription %q missing log_filter", sub.Name)
}
}
// Create dispatcher based on type
var dispatcher Dispatcher
switch config.Dispatcher.Type {
case "simple":
dispatcher = NewWebhookDispatcher(&config.Dispatcher)
default:
return nil, fmt.Errorf("unsupported dispatcher type: %s", config.Dispatcher.Type)
}
// Create and configure manager
manager := NewManager(containerService, dispatcher)
if err := manager.LoadSubscriptions(config.Subscriptions); err != nil {
return nil, err
}
return manager, nil
}
// SaveToFile saves dispatcher config and subscriptions to a YAML file
func SaveToFile(path string, dispatcher *DispatcherConfig, subs []*Subscription) error {
config := Config{
Dispatcher: *dispatcher,
Subscriptions: subs,
}
data, err := yaml.Marshal(&config)
if err != nil {
return fmt.Errorf("failed to marshal notifications config: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("failed to write notifications config: %w", err)
}
return nil
}
+87 -147
View File
@@ -2,163 +2,103 @@ 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 container.StripANSI(v)
case []container.LogFragment:
var parts []string
for _, fragment := range v {
parts = append(parts, container.StripANSI(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
// DispatcherConfig represents global 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"`
// Type determines the dispatcher type (webhook, dozzle-service, etc.)
Type string `yaml:"type"` // "webhook" is default
// URL is the endpoint for the dispatcher service
URL string `yaml:"url"`
// APIKey for authenticating with the dispatcher service
APIKey string `yaml:"api_key,omitempty"`
// Headers to include in all requests
Headers map[string]string `yaml:"headers,omitempty"`
}
// Config represents the persisted notification configuration
type Config struct {
Subscriptions []*Subscription `json:"subscriptions" yaml:"subscriptions"`
Dispatchers []DispatcherConfig `json:"dispatchers" yaml:"dispatchers"`
// Subscription represents a notification rule
type Subscription struct {
// Name is a human-readable name for this subscription
Name string `yaml:"name"`
// ContainerFilter is an expr expression to match containers (REQUIRED)
// Expression receives a Container object with: Name, Labels, Host, State
// Example: Name == "nginx" || Labels["app"] == "web"
ContainerFilter string `yaml:"container_filter"`
// LogFilter is an expr expression to match log events (REQUIRED)
// Expression receives a Log object with: Message, Level, Timestamp, Container
// Example: Level == "error" || Message contains "panic"
LogFilter string `yaml:"log_filter"`
}
// 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
// Container represents container information for expr evaluation (internal type)
type Container struct {
Name string `expr:"Name"`
Labels map[string]string `expr:"Labels"`
Host string `expr:"Host"`
State string `expr:"State"`
}
// 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
// Log represents log event information for expr evaluation
type Log struct {
Message any `expr:"Message"`
Level string `expr:"Level"`
Timestamp time.Time `expr:"Timestamp"`
Container Container `expr:"Container"`
}
// WebhookPayload is the structure sent to webhook endpoints
type WebhookPayload struct {
SubscriptionName string `json:"subscription_name"`
Timestamp time.Time `json:"timestamp"`
Container Container `json:"container"`
Log LogEvent `json:"log"`
}
// LogEvent represents the log in the webhook payload
type LogEvent struct {
Message any `json:"message"`
Level string `json:"level"`
Timestamp time.Time `json:"timestamp"`
}
// NewContainer converts container.Container to notification.Container
func NewContainer(c *container.Container) Container {
return Container{
Name: c.Name,
Labels: c.Labels,
Host: c.Host,
State: c.State,
}
}
// NewLog creates a Log from container.LogEvent and notification.Container
func NewLog(logEvent *container.LogEvent, notifContainer Container) Log {
timestamp := time.Unix(0, logEvent.Timestamp)
// Extract message - keep as any type for expr flexibility
var message any = logEvent.Message
// For grouped logs, join fragments into single string for easier matching
if fragments, ok := logEvent.Message.([]container.LogFragment); ok {
parts := make([]string, len(fragments))
for i, frag := range fragments {
parts[i] = frag.Message
}
message = strings.Join(parts, "\n")
}
return Log{
Message: message,
Level: logEvent.Level,
Timestamp: timestamp,
Container: notifContainer,
}
}
-270
View File
@@ -1,270 +0,0 @@
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)
})
}
}
+83
View File
@@ -0,0 +1,83 @@
package notification
import (
"bytes"
"encoding/json"
"net/http"
"time"
"github.com/rs/zerolog/log"
)
// Dispatcher is the interface for sending notifications
type Dispatcher interface {
Dispatch(subscription *Subscription, payload WebhookPayload)
}
// WebhookDispatcher handles async webhook delivery
type WebhookDispatcher struct {
client *http.Client
config *DispatcherConfig
}
// NewWebhookDispatcher creates a new webhook dispatcher
func NewWebhookDispatcher(config *DispatcherConfig) *WebhookDispatcher {
return &WebhookDispatcher{
client: &http.Client{
Timeout: 10 * time.Second,
},
config: config,
}
}
// Dispatch sends a webhook payload asynchronously
func (d *WebhookDispatcher) Dispatch(subscription *Subscription, payload WebhookPayload) {
url := d.config.URL
go func() {
body, err := json.Marshal(payload)
if err != nil {
log.Error().Err(err).Str("url", url).Msg("failed to marshal webhook payload")
return
}
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
log.Error().Err(err).Str("url", url).Msg("failed to create webhook request")
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Dozzle-Notifications/1.0")
// Add API key if configured
if d.config.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+d.config.APIKey)
}
// Add custom headers
for key, value := range d.config.Headers {
req.Header.Set(key, value)
}
resp, err := d.client.Do(req)
if err != nil {
log.Error().Err(err).Str("url", url).Msg("failed to send webhook")
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
log.Warn().
Int("status", resp.StatusCode).
Str("url", url).
Str("subscription", payload.SubscriptionName).
Msg("webhook returned error status")
} else {
log.Debug().
Int("status", resp.StatusCode).
Str("url", url).
Str("subscription", payload.SubscriptionName).
Msg("webhook delivered successfully")
}
}()
}
+3 -69
View File
@@ -12,10 +12,6 @@ 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"
)
@@ -23,37 +19,6 @@ 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")
@@ -78,43 +43,12 @@ 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")
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)
server, err := agent.NewServer(client, certs, args.Version(), args.Filter)
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())
+59 -18
View File
@@ -2,20 +2,21 @@ package container_support
import (
"context"
"encoding/json"
"io"
"sync/atomic"
"sync"
"time"
"github.com/amir20/dozzle/internal/agent"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/types"
"github.com/docker/docker/pkg/stdcopy"
"github.com/rs/zerolog/log"
)
type agentService struct {
client *agent.Client
host atomic.Pointer[container.Host]
host container.Host
}
func NewAgentService(client *agent.Client) ClientService {
@@ -25,7 +26,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, labels)
return a.client.FindContainer(ctx, id)
}
func (a *agentService) RawLogs(ctx context.Context, container container.Container, from time.Time, to time.Time, stdTypes container.StdType) (io.ReadCloser, error) {
@@ -48,16 +49,13 @@ 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 {
if cached := a.host.Load(); cached != nil {
h := *cached
h.Available = false
return h, err
}
return container.Host{Available: false}, err
host := a.host
host.Available = false
return host, err
}
a.host.Store(&host)
return host, nil
a.host = host
return a.host, err
}
func (a *agentService) SubscribeStats(ctx context.Context, stats chan<- container.ContainerStat) {
@@ -76,14 +74,57 @@ func (a *agentService) ContainerAction(ctx context.Context, container container.
return a.client.ContainerAction(ctx, container.ID, action)
}
func (a *agentService) Attach(ctx context.Context, c container.Container, events container.ExecEventReader, stdout io.Writer) error {
func (a *agentService) Attach(ctx context.Context, container container.Container, stdin io.Reader, stdout io.Writer) error {
panic("not implemented")
}
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) 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)
func (a *agentService) UpdateNotificationConfig(ctx context.Context, subscriptions []types.SubscriptionConfig, dispatchers []types.DispatcherConfig) error {
return a.client.UpdateNotificationConfig(ctx, subscriptions, dispatchers)
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
}
+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, container.ExecEventReader, io.Writer) error
Exec(context.Context, container.Container, []string, container.ExecEventReader, io.Writer) error
Attach(context.Context, container.Container, io.Reader, io.Writer) error
Exec(context.Context, container.Container, []string, io.Reader, 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, events container.ExecEventReader, stdout io.Writer) error {
return c.clientService.Attach(ctx, c.Container, events, stdout)
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) Exec(ctx context.Context, cmd []string, events container.ExecEventReader, stdout io.Writer) error {
return c.clientService.Exec(ctx, c.Container, cmd, 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)
}
+12 -10
View File
@@ -2,6 +2,7 @@ package docker_support
import (
"context"
"encoding/json"
"io"
"sync"
"time"
@@ -111,7 +112,7 @@ func (d *DockerClientService) SubscribeContainersStarted(ctx context.Context, co
d.store.SubscribeNewContainers(ctx, containers)
}
func (d *DockerClientService) Attach(ctx context.Context, c container.Container, events container.ExecEventReader, stdout io.Writer) error {
func (d *DockerClientService) Attach(ctx context.Context, c container.Container, stdin io.Reader, stdout io.Writer) error {
cancelCtx, cancel := context.WithCancel(ctx)
session, err := d.client.ContainerAttach(cancelCtx, c.ID)
if err != nil {
@@ -122,12 +123,13 @@ func (d *DockerClientService) Attach(ctx context.Context, c container.Container,
var wg sync.WaitGroup
wg.Go(func() {
decoder := json.NewDecoder(stdin)
loop:
for {
event, err := events.ReadEvent()
if err != nil {
var event container.ExecEvent
if err := decoder.Decode(&event); err != nil {
if err != io.EOF {
log.Error().Err(err).Msg("error while reading event")
log.Error().Err(err).Msg("error while decoding event from ws")
}
break
}
@@ -168,7 +170,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, events container.ExecEventReader, stdout io.Writer) error {
func (d *DockerClientService) Exec(ctx context.Context, c container.Container, cmd []string, stdin io.Reader, stdout io.Writer) error {
cancelCtx, cancel := context.WithCancel(ctx)
session, err := d.client.ContainerExec(cancelCtx, c.ID, cmd)
if err != nil {
@@ -179,12 +181,13 @@ func (d *DockerClientService) Exec(ctx context.Context, c container.Container, c
var wg sync.WaitGroup
wg.Go(func() {
decoder := json.NewDecoder(stdin)
loop:
for {
event, err := events.ReadEvent()
if err != nil {
var event container.ExecEvent
if err := decoder.Decode(&event); err != nil {
if err != io.EOF {
log.Error().Err(err).Msg("error while reading event")
log.Error().Err(err).Msg("error while decoding event from ws")
}
break
}
@@ -208,8 +211,7 @@ func (d *DockerClientService) Exec(ctx context.Context, c container.Container, c
})
wg.Go(func() {
// TTY mode outputs raw bytes without Docker's multiplexing headers.
if _, err := io.Copy(stdout, session.Reader); err != nil {
if _, err := stdcopy.StdCopy(stdout, stdout, session.Reader); err != nil {
log.Error().Err(err).Msg("error while writing to ws")
}
cancel()
+8 -175
View File
@@ -3,15 +3,10 @@ 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"
)
@@ -32,13 +27,11 @@ 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
notificationManager *notification.Manager
manager ClientManager
timeout time.Duration
}
func NewMultiHostService(manager ClientManager, timeout time.Duration) *MultiHostService {
@@ -129,6 +122,12 @@ func (m *MultiHostService) SubscribeEventsAndStats(ctx context.Context, events c
}
}
func (m *MultiHostService) SubscribeEvents(ctx context.Context, events chan<- container.ContainerEvent) {
for _, client := range m.manager.List() {
client.SubscribeEvents(ctx, events)
}
}
func (m *MultiHostService) SubscribeContainersStarted(ctx context.Context, containers chan<- container.Container, filter container_support.ContainerFilter) {
newContainers := make(chan container.Container)
for _, client := range m.manager.List() {
@@ -175,172 +174,6 @@ 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,17 +195,3 @@ 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,17 +236,3 @@ 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
}
+4 -42
View File
@@ -7,8 +7,6 @@ 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"
)
@@ -98,6 +96,10 @@ func (m *K8sClusterService) SubscribeEventsAndStats(ctx context.Context, events
m.client.SubscribeStats(ctx, stats)
}
func (m *K8sClusterService) SubscribeEvents(ctx context.Context, events chan<- container.ContainerEvent) {
m.client.SubscribeEvents(ctx, events)
}
func (m *K8sClusterService) SubscribeContainersStarted(ctx context.Context, containers chan<- container.Container, filter container_support.ContainerFilter) {
newContainers := make(chan container.Container)
m.client.SubscribeContainersStarted(ctx, newContainers)
@@ -133,43 +135,3 @@ 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{}
}
+11 -8
View File
@@ -2,6 +2,7 @@ package k8s_support
import (
"context"
"encoding/json"
"io"
"sync"
@@ -92,7 +93,7 @@ func (k *K8sClientService) SubscribeContainersStarted(ctx context.Context, conta
k.store.SubscribeNewContainers(ctx, containers)
}
func (k *K8sClientService) Attach(ctx context.Context, c container.Container, events container.ExecEventReader, stdout io.Writer) error {
func (k *K8sClientService) Attach(ctx context.Context, c container.Container, stdin io.Reader, stdout io.Writer) error {
cancelCtx, cancel := context.WithCancel(ctx)
session, err := k.client.ContainerAttach(cancelCtx, c.ID)
if err != nil {
@@ -106,12 +107,13 @@ func (k *K8sClientService) Attach(ctx context.Context, c container.Container, ev
defer session.Writer.Close()
defer cancel()
decoder := json.NewDecoder(stdin)
loop:
for {
event, err := events.ReadEvent()
if err != nil {
var event container.ExecEvent
if err := decoder.Decode(&event); err != nil {
if err != io.EOF {
log.Error().Err(err).Msg("error reading event")
log.Error().Err(err).Msg("error decoding event")
}
break
}
@@ -141,7 +143,7 @@ func (k *K8sClientService) Attach(ctx context.Context, c container.Container, ev
return nil
}
func (k *K8sClientService) Exec(ctx context.Context, c container.Container, cmd []string, events container.ExecEventReader, stdout io.Writer) error {
func (k *K8sClientService) Exec(ctx context.Context, c container.Container, cmd []string, stdin io.Reader, stdout io.Writer) error {
cancelCtx, cancel := context.WithCancel(ctx)
session, err := k.client.ContainerExec(cancelCtx, c.ID, cmd)
if err != nil {
@@ -155,12 +157,13 @@ func (k *K8sClientService) Exec(ctx context.Context, c container.Container, cmd
defer session.Writer.Close()
defer cancel()
decoder := json.NewDecoder(stdin)
loop:
for {
event, err := events.ReadEvent()
if err != nil {
var event container.ExecEvent
if err := decoder.Decode(&event); err != nil {
if err != io.EOF {
log.Error().Err(err).Msg("error reading event")
log.Error().Err(err).Msg("error decoding event")
}
break
}
+3 -3
View File
@@ -30,7 +30,7 @@ func Test_createRoutes_simple_redirect(t *testing.T) {
Users: map[string]*auth.User{
"amir": {
Username: "amir",
Password: "$2a$10$4Tvzu0ms9shlv4B8pIfqI.TM9CoqsamsAznP91A1NGuwg/68SGS1m",
Password: "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8",
},
},
}, time.Second*100),
@@ -55,7 +55,7 @@ func Test_createRoutes_simple_valid_token(t *testing.T) {
Users: map[string]*auth.User{
"amir": {
Username: "amir",
Password: "$2a$10$4Tvzu0ms9shlv4B8pIfqI.TM9CoqsamsAznP91A1NGuwg/68SGS1m",
Password: "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8",
},
},
}, time.Second*100),
@@ -100,7 +100,7 @@ func Test_createRoutes_simple_bad_password(t *testing.T) {
Users: map[string]*auth.User{
"amir": {
Username: "amir",
Password: "$2a$10$4Tvzu0ms9shlv4B8pIfqI.TM9CoqsamsAznP91A1NGuwg/68SGS1m",
Password: "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8",
},
},
}, time.Second*100),
-101
View File
@@ -1,101 +0,0 @@
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)
}
+1 -16
View File
@@ -110,23 +110,8 @@ func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
})
}
// Determine zip filename from optional name param or default
zipName := "container-logs"
if name := r.URL.Query().Get("name"); name != "" {
// Sanitize: keep only alphanumeric, hyphens, underscores, dots
sanitized := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
return r
}
return '-'
}, name)
if sanitized != "" {
zipName = sanitized
}
}
// Set headers for zip file
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s-%s.zip", zipName, nowFmt))
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=container-logs-%s.zip", nowFmt))
w.Header().Set("Content-Type", "application/zip")
// Create zip writer
-20
View File
@@ -127,17 +127,6 @@ 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")
@@ -146,7 +135,6 @@ 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
@@ -180,14 +168,6 @@ 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
@@ -1,604 +0,0 @@
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
@@ -0,0 +1,38 @@
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)
}
}

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