Compare commits

...

173 Commits

Author SHA1 Message Date
Amir Raminfar 799d261c59 chore: release v10.3.2
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-04-13 14:22:42 -07:00
Amir Raminfar abac4fdf7e feat: welcome modal after cloud linking (#4624)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:20:59 +00:00
Amir Raminfar ac43dca1ac fix: distinguish cloud unavailable from auth errors (#4627)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:47:48 +00:00
Amir Raminfar 6c4b386283 fix: healthcheck should not fail when a remote host is unavailable (#4626)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:10:36 -07:00
renovate[bot] 52e61158cf fix(deps): update all non-major dependencies (#4625)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 16:22:46 +00:00
Amir Raminfar 3aa80b8f07 fix: use fixed path for agent healthcheck address file (#4617)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:29:39 -07:00
renovate[bot] 4616159e27 chore(deps): update pnpm/action-setup action to v6 (#4621)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 15:29:03 +00:00
Amir Raminfar 3dbbe530db fix: re-establish cloud connection after pro plan upgrade (#4619)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 08:25:19 -07:00
renovate[bot] 27fdd047a5 fix(deps): update all non-major dependencies (#4620)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 00:36:10 +00:00
Amir Raminfar c9e89fb425 docs: updates docs 2026-04-12 15:46:05 -07:00
Amir Raminfar 1cd0b651c3 chore: updates cloud 2026-04-12 15:37:27 -07:00
Amir Raminfar 7ce3bd8e64 reverts public/apple-touch-icon.png 2026-04-12 12:22:22 -07:00
Serhiy 915478c4ab Delete .DS_Store 2026-04-12 08:19:54 +03:00
Serhiy Bobrov d58a33fdac transparent favicons 2026-04-12 08:16:07 +03:00
Amir Raminfar 94976bdf58 chore: release v10.3.1
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-04-11 19:49:49 -07:00
Amir Raminfar a1aa2253bc fix: grouped log alerts failing with type mismatch (#4612)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:49:14 -07:00
Amir Raminfar 09e549b1b1 chore: -30 seconds while streaming
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-04-11 07:15:12 -07:00
renovate[bot] a0877bbadd fix(deps): update all non-major dependencies (#4610)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-11 14:10:46 +00:00
Amir Raminfar 04515cc1bd chore: removes locking of esbuild 2026-04-11 07:03:44 -07:00
Amir Raminfar 3c10eefcfe chore: updates node modules (#4613) 2026-04-11 13:55:15 +00:00
Amir Raminfar 66d9fa4dd1 chore: debug logs
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-04-10 19:38:44 -07:00
Amir Raminfar f195290a30 chore: fixes pb code
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-04-10 14:30:10 -07:00
Amir Raminfar fe8165ec09 feat: Add streaming log support for Cloud dashboard (#4605)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:26:56 -07:00
Amir Raminfar 09647174ee chore: remove noisy debug log from Host() getter
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:57:59 -07:00
Amir Raminfar 6d8f3005b0 feat: improve Dozzle Cloud discoverability (#4609)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:46:40 +00:00
Amir Raminfar af4412f667 feat: add PWA support with dynamic base URL (#4608)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:09:13 -07:00
Uğur Tafralı f5f736b6ff fix: Fix memory stats not showing on cgroup v2 (#4607)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-04-10 06:48:25 -07:00
Amir Raminfar 44239614d1 chore: release v10.3.0
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-04-07 07:02:26 -07:00
Amir Raminfar f13bcdba60 fix: prevent Dozzle from attempting to update its own container (#4600)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:18:47 +00:00
Amir Raminfar 92fa5beb07 chore: Send Dozzle version in cloud tool registration (#4599)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:14:29 -07:00
Amir Raminfar 4c378443e0 feat: include short SHA in dev/PR version strings (#4598)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:04:21 -07:00
Amir Raminfar e608bdf201 chore: updates animation for upgrade 2026-04-06 11:42:55 -07:00
renovate[bot] d873edee0e fix(deps): update all non-major dependencies (#4596)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 18:42:46 +00:00
renovate[bot] fe83ddcf22 fix(deps): update dependency vite to v8.0.5 [security] (#4595)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 18:41:13 +00:00
Amir Raminfar 338c953298 chore: adds major version change log 2026-04-06 11:38:47 -07:00
Amir Raminfar 2d54f43ef5 fix: improve toast layout on mobile 📱 (#4593)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:36:38 +00:00
Amir Raminfar 4b913ee68a chore: fixes restart for container hostname 2026-04-06 07:07:16 -07:00
Amir Raminfar dd5cd3ccac feat: add descriptive messages to cloud action responses (#4592)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:17:22 -07:00
Amir Raminfar 5abda9f224 fix: resolve race condition in TestContainerStore_die (#4591)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:47:54 +00:00
Amir Raminfar 32fac82635 refactor: use Go structs for cloud tool schemas (#4590)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:33:39 +00:00
Amir Raminfar cae201d592 chore: adds logging 2026-04-05 13:18:36 -07:00
Amir Raminfar 284822c631 feat: add container update action with image pull and recreate 🚀 (#4588)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:59:44 +00:00
Amir Raminfar 475a16321d feat: adds scroll up for multiple container views 🥳 (#4450)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:53:40 +00:00
renovate[bot] 3ab758aabf fix(deps): update all non-major dependencies (#4586)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-05 18:17:57 +00:00
Amir Raminfar 7966b21249 fix: guard FuzzySearchModal for fuse.js 7.3.0 empty query change (#4587)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:11:07 +00:00
Amir Raminfar 15e13507b2 chore: mod tidy 2026-04-05 10:30:38 -07:00
Amir Raminfar 3254f36246 chore: go clean up 2026-04-04 20:11:43 -07:00
Amir Raminfar 3c6bc6b391 Add host_id to ContainerStatEntry proto for LLM tool routing
The LLM agent needs host_id to call targeted container tools (fetch_logs,
inspect, start/stop/restart). Stats responses only had host name, causing
the LLM to pass the wrong value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:04:54 -07:00
Amir Raminfar e079e5fc43 Remove Env from InspectContainerResult to prevent leaking container environment variables to cloud
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:44:36 -07:00
Amir Raminfar 317c141f65 chore: Add fetch_container_logs, inspect_container, and find_containers image filter (#4584)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:31:20 +00:00
Amir Raminfar 77a7905f2f chore: clean up and fixes 2026-04-04 10:55:16 -07:00
Amir Raminfar cfcaebe36a refactor: replace JSON strings with typed proto messages in cloud tools (#4583)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:54:58 -07:00
Amir Raminfar 7225abec25 fix: add gRPC keepalive to cloud client to prevent proxy idle disconnects (#4582) 2026-04-03 19:17:40 -07:00
Amir Raminfar af241022bf refactor: move cloud proto to public proto/cloud package (#4580)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:18:47 +00:00
renovate[bot] 806b6fb0cd fix(deps): update all non-major dependencies (#4579)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-03 14:17:22 +00:00
Amir Raminfar dca9f17be4 chore: updte claude 2026-04-03 07:10:05 -07:00
Amir Raminfar 3ce1f9ebdc feat: expose container actions to Dozzle Cloud via gRPC (#4578)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 06:53:26 -07:00
Amir Raminfar 481455a09b fix: increase log details max height (#4574)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 09:33:52 -07:00
Amir Raminfar 77ddc3ce52 fix: preserve grouped log fragments as array in notifications (#4573)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 14:42:35 -07:00
renovate[bot] 9b44236ad4 chore(deps): update actions/configure-pages action to v6 (#4571)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-29 11:17:05 -07:00
renovate[bot] 591d881294 fix(deps): update all non-major dependencies (#4570)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-29 18:02:45 +00:00
Amir Raminfar 4cf3952a73 fix: add missing fields to protobuf agent conversions (#4568)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:27:27 -07:00
renovate[bot] 3a309ce8fd chore(deps): update actions/deploy-pages action to v5 (#4567)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 12:39:48 -07:00
Amir Raminfar 922d6de14e chore: release v10.2.1 2026-03-26 12:39:31 -07:00
renovate[bot] e271551a5d fix(deps): update all non-major dependencies (#4566)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 14:49:39 +00:00
Amir Raminfar f60fbc254a fix: notification config not broadcast to swarm nodes (#4563)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:49:04 -07:00
Amir Raminfar fd943272f0 feat: support log.level JSON key for level detection (#4564)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:46:08 +00:00
Amir Raminfar 2380c87bb0 feat: add swarm-deploy function for local image testing
Adds a helper function to build, load, and redeploy local Dozzle images
across all swarm nodes. Uses local tag instead of latest.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:56:45 -07:00
Amir Raminfar 166037617f chore: cleans up swarm script
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-03-25 08:07:12 -07:00
Amir Raminfar aa428b6c2c chore: updates typescript
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
2026-03-23 10:49:02 -07:00
Amir Raminfar 9c9d3613d6 chore: release v10.2.0 2026-03-23 10:32:23 -07:00
renovate[bot] 6bb3c4256a fix(deps): update dependency unplugin-vue-components to v32 (#4560)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-23 16:33:55 +00:00
renovate[bot] 21302b4173 fix(deps): update all non-major dependencies (#4559)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-23 16:27:37 +00:00
Amir Raminfar dc38dde2d8 fix: mobile UI improvements for notifications and container table (#4558)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:19:59 -07:00
Amir Raminfar 2975a9ceda feat: add Docker event notifications (#4556)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 17:00:20 +00:00
Amir Raminfar 6e0c028439 chore: fixes types
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-03-21 08:43:15 -07:00
dependabot[bot] e7c83b6a2f chore(deps): bump github.com/buger/jsonparser from 1.1.1 to 1.1.2 (#4555)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-21 08:42:03 -07:00
renovate[bot] 4f7695d494 fix(deps): update all non-major dependencies (#4554)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-21 15:33:52 +00:00
Amir Raminfar fc1e86fc14 feat: always persist user profile to disk (#4550)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:29:04 +00:00
Amir Raminfar 6e3e82e806 chore: remove redundant GOEXPERIMENT=jsonv2 (#4553)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:12:26 +00:00
Amir Raminfar 8957fe3a6f fix: correct Makefile dependencies and missing .PHONY declarations (#4552)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:08:53 +00:00
Amir Raminfar abdcbe835e fix: don't break log group when next line has the same level (#4549)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 06:57:32 -07:00
renovate[bot] 0db989b193 fix(deps): update module google.golang.org/grpc to v1.79.3 (#4546)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-18 14:29:11 +00:00
Amir Raminfar 177d721a49 chore: release v10.1.2
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-03-17 14:28:48 -07:00
renovate[bot] e1f4ed2919 fix(deps): update all non-major dependencies (#4543)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 19:19:53 +00:00
renovate[bot] 824bfc9f0d fix(deps): update dependency entities to v8 (#4545)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 19:19:30 +00:00
renovate[bot] 111076fccf chore(deps): update pnpm/action-setup action to v5 (#4544)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 19:18:44 +00:00
Amir Raminfar 4d8213a66f chore: adds more tests for log levels (#4542)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:11:22 -07:00
Ivo(sh) Musil 106dfe18c7 fix: fixes the date cutting in continued log events, see #4123 (#4538)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-03-17 09:46:21 -07:00
renovate[bot] 72af56f42d chore(deps): update dependency jsdom to v29 (#4537)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-16 10:15:44 -07:00
renovate[bot] f00f3bdde5 chore(deps): update dependency bumpp to v11 (#4536)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-16 06:34:06 -07:00
renovate[bot] 72c9b66286 fix(deps): update all non-major dependencies (#4535)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-16 00:47:46 +00:00
renovate[bot] e0109a7f4f fix(deps): update dependency vite to v8 (#4534)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
2026-03-13 02:44:11 +00:00
renovate[bot] 790c8724ee fix(deps): update all non-major dependencies (#4533)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-13 02:37:31 +00:00
Amir Raminfar 3167f0a9a2 refactor: simplify level_guesser (#4532)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:47:30 -07:00
renovate[bot] 4251076211 fix(deps): update all non-major dependencies (#4530)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-10 18:55:47 +00:00
Amir Raminfar e1797ad5c0 fix: don't skip orphaned lines near container start (#4529)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:24:24 -07:00
Amir Raminfar 68d7720ae6 chore: release v10.1.1
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-03-09 14:35:50 -07:00
Amir Raminfar fce15c8ec9 fix: emit orphaned log lines when no real entry follows (#4527)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:35:10 -07:00
Amir Raminfar 330e952ca4 fix: sync notification stats across agents (#4525)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:31:21 -07:00
renovate[bot] 1cd1b4a0af fix(deps): update all non-major dependencies to ^3.5.30 (#4522)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 17:35:37 +00:00
renovate[bot] 6f1390f412 fix(deps): update all non-major dependencies (#4521)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 15:26:16 +00:00
Amir Raminfar 72ea5eed2c chore: release v10.1.0 2026-03-08 08:26:02 -07:00
Amir Raminfar 8121eb1aad fix: skip orphaned continuation lines and improve group chaining (#4518)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 21:50:27 +00:00
Amir Raminfar 45b7a6243e fix: preserve notification runtime stats on update (#4520)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 21:10:34 +00:00
Amir Raminfar 91ad1cb8d3 feat: add custom HTTP headers for webhook notifications (#4517)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:43:24 +00:00
Amir Raminfar 302ba4b5a0 docs: updates doc to include data 2026-03-07 08:29:37 -08:00
Amir Raminfar ebc64d8641 fix: set correct timestamp on downloaded zip log files (#4516) 2026-03-07 07:36:53 -08:00
renovate[bot] dc8d87c7da chore(deps): update docker/setup-buildx-action action to v4 (#4513)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-03-06 12:09:38 -08:00
renovate[bot] ada81017de chore(deps): update docker/metadata-action action to v6 (#4512) 2026-03-06 12:09:17 -08:00
renovate[bot] 4c6dfa7b72 chore(deps): update docker/login-action action to v4 (#4511) 2026-03-06 12:09:02 -08:00
renovate[bot] 32ea1bedfd chore(deps): update docker/build-push-action action to v7 (#4510) 2026-03-06 12:08:35 -08:00
renovate[bot] 1a29adc90f chore(deps): update docker/bake-action action to v7 (#4509) 2026-03-06 12:07:13 -08:00
renovate[bot] 3bc5745147 fix(deps): update all non-major dependencies (#4508)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-06 19:58:00 +00:00
Lukas Gierth 7e92cfee96 docs: Update k8s.md with GitOps namespace warning (#4502)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
2026-03-04 15:34:34 -08:00
Amir Raminfar b68fb45123 chore: release v10.0.7
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
2026-03-02 09:36:52 -08:00
Amir Raminfar 3c88036c46 chore: uses animated svg 2026-03-02 09:36:33 -08:00
renovate[bot] 63ed910d81 fix(deps): update all non-major dependencies (#4501)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 17:34:47 +00:00
Amir Raminfar 93d9c88424 feat: Introduces sample window size for metric alerting (#4498)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:28:54 -08:00
Amir Raminfar 0991df5554 chore: fixes icon when loading 2026-03-02 08:35:01 -08:00
renovate[bot] 57c54ed6dd fix(deps): update all non-major dependencies (#4500)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 00:21:19 +00:00
Amir Raminfar e410804727 chore: optimize Docker build and CI workflows (#4499)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:17:18 -08:00
Amir Raminfar c7e5615264 feat: bases colors for containers off id instead of name (#4496) 2026-03-01 22:58:22 +00:00
Amir Raminfar 66126a1d79 feat: shows a subtle background affect when a new container is added (#4494)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
2026-02-28 08:18:40 -08:00
Amir Raminfar 7371b86779 chore: set User-Agent header on outgoing HTTP requests (#4491)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 03:28:16 +00:00
Amir Raminfar f2cb9ae672 chore: release v10.0.6
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-02-27 10:19:16 -08:00
Amir Raminfar d01b161808 chore: clean up 2026-02-27 10:19:05 -08:00
renovate[bot] c8b736a2d5 fix(deps): update all non-major dependencies to v0.35.2 (#4490)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-27 16:54:39 +00:00
Amir Raminfar d3e237a562 fix: fixes incorrect bar charts (#4487) 2026-02-27 16:49:27 +00:00
Amir Raminfar bdfeb0a880 chore: release v10.0.5
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-02-26 12:46:54 -08:00
renovate[bot] 89fdbd55bb chore(deps): update dependency c8 to v11 (#4485)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-26 19:30:51 +00:00
renovate[bot] 228030b257 fix(deps): update all non-major dependencies (#4483)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-26 19:30:11 +00:00
renovate[bot] 27d2fdefd6 chore(deps): update actions/upload-artifact action to v7 (#4484)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-26 19:29:24 +00:00
Amir Raminfar 5d38eb7127 fix: makes the bar chart with a large limit more sensetive (#4476) 2026-02-26 19:23:31 +00:00
Marvin A. Ruder e66dda89e1 fix: add network stats to agent client (#4482)
Signed-off-by: Marvin A. Ruder <signed@mruder.dev>
2026-02-26 11:02:13 -08:00
Amir Raminfar b0da9637be chore: remove settings.local.json from git tracking
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
File was already in .gitignore but still tracked.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:16:21 -08:00
Amir Raminfar a3fc0ea992 chore: adds keep-alive flags (#4474) 2026-02-23 15:07:28 -08:00
Amir Raminfar 54469dc163 fix: normalize CPU % against container cpuLimit and add soft ceiling to BarChart (#4464)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:04:03 -08:00
Amir Raminfar 5b412456cd test: add notification config to agent integration test (#4473)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 14:56:36 +00:00
Amir Raminfar 475cd916f2 chore: updates gitignore 2026-02-23 06:37:03 -08:00
Amir Raminfar 9982e6e2a6 chore: updates settings 2026-02-23 06:36:05 -08:00
renovate[bot] 798981b334 fix(deps): update all non-major dependencies (#4471)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-23 01:02:41 +00:00
Richard Coker 76e94a7ad4 docs: update commented users.yaml generation command (#4470) 2026-02-22 12:20:19 -08:00
Amir Raminfar 4351493914 chore: ignore .claude/settings.local.json
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:14:45 -08:00
Amir Raminfar a074aa9511 chore: release v10.0.4 2026-02-21 12:11:11 -08:00
Amir Raminfar 78114eb2d2 fix: call Start() before LoadConfig() in agent mode to prevent nil pointer panic (#4468)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-21 12:10:51 -08:00
Javier Pérez 5ff6d100d4 fix: use coolify.serviceName for unique container names in multi-container apps (#4466) 2026-02-21 06:34:38 -08:00
Amir Raminfar 94cfa6da1c chore: updates to settings 2026-02-20 16:24:58 -08:00
renovate[bot] ffa38bcb2f fix(deps): update all non-major dependencies (#4463)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-20 20:32:46 +00:00
Amir Raminfar 8ab06d9a24 chore: release v10.0.3 2026-02-20 12:14:19 -08:00
Amir Raminfar c3a4583ba7 fix: fixes multiple bugs with alerting 🔔 (#4459) 2026-02-20 17:37:58 +00:00
Amir Raminfar bf16c1042d fix: fixes context menu not showing with little rows. see #4332 (#4462) 2026-02-20 17:01:32 +00:00
Amir Raminfar 1174c1d58e chore: moves container events to trace 2026-02-20 07:10:40 -08:00
renovate[bot] f3c4e806ae fix(deps): update all non-major dependencies (#4460)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 21:46:40 +00:00
Amir Raminfar 374865589a fix: fixes new containers not subsrcribing when first starting (#4458) 2026-02-19 10:13:32 -08:00
Richard Hull ec7d15c6bc style: brighten orange theme color (#4457) 2026-02-18 19:56:36 -08:00
Amir Raminfar 9e128d8de8 chore: makes dispatcher configurable 2026-02-17 13:57:27 -08:00
Amir Raminfar 94ea7b39e9 chore: adds type to notifications 2026-02-17 06:25:47 -08:00
Amir Raminfar bcc8adc81b chore: fix translation 2026-02-16 16:21:34 -08:00
Amir Raminfar a79ffdaf50 feat: add metric-based alerts for container CPU/memory thresholds (#4454)
Co-authored-by: Dhaval Patel <dhavu262@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:55:35 +00:00
Amir Raminfar 51a92ba448 chore: fixes javascript tests 2026-02-16 11:47:14 -08:00
Amir Raminfar 8057bffd33 feat: supports copying logs to clipboard (#4451) 2026-02-16 01:09:50 +00:00
Amir Raminfar f30e8e748f chore: clean up (#4449) 2026-02-15 16:42:40 -08:00
Amir Raminfar 7622db0c32 chore: updates docs 2026-02-15 09:08:52 -08:00
Amir Raminfar d6109e39c3 feat: supports host when forwarding to permanent link and provide it as an option in drop down. (#4441) 2026-02-15 15:17:37 +00:00
renovate[bot] 6e38c193ef chore(deps): update dependency jsdom to ^28.1.0 (#4446)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-15 15:14:51 +00:00
Amir Raminfar a300d84b31 fix: fixes broken shell when reading large content. tty was never set (#4443) 2026-02-15 07:14:27 -08:00
Amir Raminfar ed950481ad feat: auto completes json keys in preview (#4440) 2026-02-14 15:58:41 -08:00
renovate[bot] 7df0243533 fix(deps): update all non-major dependencies (#4439)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-14 19:03:28 +00:00
Amir Raminfar 00132ef1cf chore: release v10.0.2 2026-02-14 10:37:57 -08:00
Amir Raminfar fed03cb4db feat: adds better cloud error (#4438) 2026-02-14 18:37:10 +00:00
Amir Raminfar 6840e5b55c fix: resolve staticcheck SA5008 ambiguous json struct tag
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:28:57 -08:00
Paul Close e9c9eca4ae docs: Update Agent Mode documentation to clarify host mappings (#4435)
Co-authored-by: Paul Close <paul@macchiato.home>
2026-02-13 16:20:17 -08:00
191 changed files with 15927 additions and 3751 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/dozzle"
cmd = "GOEXPERIMENT=jsonv2 go build -race -o ./tmp/dozzle ."
cmd = "go build -race -o ./tmp/dozzle ."
delay = 1000
exclude_dir = [
"assets",
+37
View File
@@ -0,0 +1,37 @@
# Bug Hunter Agent Memory
## Codebase Patterns
### Notification System Architecture
- `notification.Manager` owns subscriptions (xsync.Map) and dispatchers (xsync.Map)
- Two processing goroutines: `processLogEvents` and `processStatEvents` started in `NewManager`
- Stats listener has start/stop lifecycle; log listener is always-on once started
- `MultiHostService` wraps Manager and handles config persistence + agent broadcast
### Known Bug-Prone Areas
- **broadcastNotificationConfig**: Has historically missed fields when converting between internal and types packages (APIKey, Prefix, ExpiresAt were missed)
- **TriggeredContainerIDs lazy init**: Race condition risk - initialized lazily in AddTriggeredContainer without sync
- **Channel close handling**: enrich() in stats_listener doesn't check for closed channel, can hot-spin
- **LogAlertFields canSave**: Allows empty logExpression (no error = valid), creates dead subscriptions
### Type Mapping Gotchas
- `container.ContainerStat` -> `types.NotificationStat`: field names differ (CPUPercent vs cpu expr tag)
- `notification.DispatcherConfig` -> `types.DispatcherConfig`: must copy ALL fields including APIKey, Prefix, ExpiresAt
- Frontend `NotificationRule.cooldown` is optional, backend defaults to 300 via `GetCooldownSeconds()`
### Concurrency Model
- xsync.Map used throughout for concurrent access (subscriptions, dispatchers, activeStreams, containers)
- Subscription fields use atomic types: TriggerCount (atomic.Int64), LastTriggeredAt (atomic.Pointer)
- MetricCooldowns uses xsync.Map for per-container cooldown tracking
- sendSem (semaphore.Weighted=5) limits concurrent notification sends
### Container Update (feat/container-update)
- **progressCh close contract**: Docker and Agent implementations close progressCh via defer; K8s does NOT, causing handler hang
- **NetworkSettings nil risk**: `docker.InspectResponse.NetworkSettings` is a pointer; `ContainerCreate` doesn't nil-check before accessing `.Networks`
- **Destructive recreate**: stop->remove->create->start has no rollback if create fails after remove
- **SSE parsing in frontend**: Uses manual ReadableStream reader, not EventSource; no AbortController cleanup on unmount
@@ -0,0 +1,36 @@
# Go Performance Reviewer Memory
## Hot Paths Identified
- **Stats processing pipeline**: `ContainerStatsListener.enrich()` -> channel -> `Manager.processStatEvents()` -> `processStatEvent()`. Runs continuously for every container stat tick (~1/sec per container).
- **Log processing pipeline**: `ContainerLogListener` -> `logChannel` (buffered 1000) -> `Manager.processLogEvents()` -> `processLogEvent()`. Every log line from matched containers flows here.
- Both pipelines do `expr.Run()` per subscription per event -- compiled programs are cached but still run per-event.
## Architecture Notes
- `ContainerStore.SubscribeStats` and `SubscribeEvents` both call `statsCollector.Start/Stop` -- multiple subscribers share the same collector. Stop-on-cancel can interfere across subscribers.
- `xsync.Map` (from puzpuzpuz/xsync/v4) used extensively for concurrent maps. No built-in TTL/eviction.
- `ContainerStatEvent` carries full `Container` + `Host` structs by value through channels. `Container` is ~200+ bytes (strings, map, RingBuffer pointer, times).
- TTL caches in both listeners (`cachedContainerInfo`) lack eviction -- grow unboundedly with container churn.
## Existing Patterns
- Semaphore-based concurrency limiting: `sendSem` (weighted 5) for notification dispatch, `maxFetchParallelism` (30) for container fetching.
- Lazy stats collection: stats collector starts on first subscriber, stops when last unsubscribes (but the multi-subscriber Stop race exists).
- `sync.Pool` not currently used in notification/stats paths.
- Cooldown tracking via `xsync.Map[string, time.Time]` per subscription -- also no eviction.
## Key File Paths
- `internal/container/container_store.go` -- Container store with stats collector lifecycle
- `internal/notification/processing.go` -- Log + stat event processing (hot path)
- `internal/notification/stats_listener.go` -- Stats subscription and enrichment
- `internal/notification/log_listener.go` -- Log stream management
- `internal/notification/manager.go` -- Subscription CRUD and listener orchestration
- `internal/notification/types.go` -- Subscription matching (expr evaluation)
- `internal/cloud/client.go` -- Cloud gRPC bidirectional streaming client
- `internal/cloud/tools.go` -- Tool definitions and execution dispatch
## Memory Files
- [cloud_client_patterns.md](cloud_client_patterns.md) - Cloud gRPC client architecture and known patterns
@@ -0,0 +1,28 @@
---
name: Cloud gRPC Client Patterns
description: Architecture of internal/cloud/ package - gRPC bidirectional streaming for cloud tool calls
type: project
---
Cloud client (`internal/cloud/client.go`) uses a bidirectional gRPC stream (`ToolStream`) to receive tool requests from Dozzle Cloud and send responses.
Key architecture:
- `Client.Run()` is the reconnect loop with exponential backoff
- `Client.connect()` creates a new `grpc.ClientConn` per reconnect (potential optimization: reuse connection)
- `sendMu sync.Mutex` protects concurrent `stream.Send()` calls (tool calls dispatch to goroutines)
- `toolSem` (weighted semaphore, max 5) limits concurrent tool execution
- `apiKeyFunc` closure provides cloud API key; empty string means no cloud configured
- PermissionDenied from server causes permanent stop (no retry)
- Tool definitions cached via `sync.Once` + `cachedTools` field (fixed from prior re-serialization issue)
- `Notify()` / `startCh` pattern ensures zero overhead for non-cloud users
- Tool dispatch in `tools.go` uses typed proto responses (`CallToolResponse` with oneof `Result`)
Known perf issues found (2026-04):
- `containsIgnoreCase` in `tools_helpers.go` allocates two lowered strings per call; used in filter loops
- `executeInspectContainer` calls `buildHostNameMap` to resolve a single host name
- `executeFetchContainerLogs` doesn't drain log channel after early break (potential goroutine leak)
- `fmt.Sprintf("%v", event.Message)` in log path uses reflect-based formatting
**How to apply:** When reviewing future changes to this package, watch for: undrained channels from streaming APIs, per-call allocations in filter loops, and unnecessary API calls for single lookups.
+152
View File
@@ -0,0 +1,152 @@
---
name: bug-hunter
description: "Use this agent when you need to review recently written or modified code for bugs, logic errors, edge cases, and unexpected behavior. This includes both Go backend code and Vue/TypeScript frontend code. This agent should be used after writing new features, fixing bugs, or refactoring code to catch issues before they reach production.\\n\\nExamples:\\n\\n- User writes a new HTTP handler in Go:\\n user: \"I just added a new endpoint for container health checks\"\\n assistant: \"Let me review the new code for potential bugs and edge cases.\"\\n <uses Task tool to launch bug-hunter agent to review the recently changed files>\\n\\n- User implements a new Vue composable:\\n user: \"I created a new composable for managing WebSocket reconnection\"\\n assistant: \"I'll launch the bug hunter to review your new composable for edge cases and potential issues.\"\\n <uses Task tool to launch bug-hunter agent to analyze the composable>\\n\\n- User modifies log parsing logic:\\n user: \"I updated the event_generator.go to handle a new log format\"\\n assistant: \"Let me have the bug hunter review your changes to ensure all edge cases in log parsing are covered.\"\\n <uses Task tool to launch bug-hunter agent to review the modified parsing code>\\n\\n- After a chunk of code is written during a feature implementation:\\n assistant: \"I've finished implementing the notification dispatcher. Let me run the bug hunter to check for issues.\"\\n <uses Task tool to launch bug-hunter agent proactively to review the new code>"
model: opus
color: blue
memory: project
---
You are an elite bug-hunting code reviewer with deep expertise in both Go backend systems and Vue 3/TypeScript frontend applications. You have extensive experience finding subtle bugs, race conditions, nil pointer dereferences, unhandled edge cases, type safety issues, and logic errors that slip past typical review. You think adversarially — always asking "what could go wrong here?"
## Your Mission
Review recently written or modified code to find bugs, logic errors, and uncovered edge cases. You focus on code that could fail at runtime, produce incorrect results, or behave unexpectedly under specific conditions.
## Review Process
1. **Identify Changed/New Code**: Use git status, git diff, or examine the files the user points you to. Focus on recently written code, not the entire codebase.
2. **Analyze Each File Systematically**: For every file with changes, examine:
- Control flow paths (all branches of if/else, switch, select)
- Error handling (are errors checked? propagated correctly? logged?)
- Nil/undefined checks (pointer dereference in Go, optional chaining in TS)
- Concurrency safety (goroutine leaks, race conditions, channel operations)
- Resource cleanup (deferred closes, stream cleanup, event listener removal)
- Boundary conditions (empty arrays, zero values, max values, negative numbers)
- Type safety (type assertions in Go, type narrowing in TS)
- API contract adherence (correct HTTP status codes, proper SSE format, GraphQL schema alignment)
3. **Cross-Layer Analysis**: Check that frontend and backend changes are consistent:
- API request/response shapes match between Go handlers and TypeScript types
- SSE event names and payload structures align
- GraphQL schema, resolvers, and frontend queries are in sync
- WebSocket message formats match on both sides
## Go-Specific Bug Patterns to Check
- **Nil pointer dereference**: Especially after type assertions, map lookups, and interface conversions
- **Goroutine leaks**: Goroutines blocked on channels that are never closed or written to
- **Race conditions**: Shared state accessed from multiple goroutines without synchronization
- **Deferred closure in loops**: `defer` inside loops won't execute until function returns
- **Error shadowing**: Using `:=` that shadows an outer `err` variable
- **Slice/map initialization**: Operating on nil slices/maps (nil map write panics)
- **Context cancellation**: Not respecting context.Done() in long-running operations
- **HTTP response body leaks**: Not closing response bodies after HTTP calls
- **Integer overflow**: Especially in stats calculations with uint64
- **String/byte slice sharing**: Modifying a slice that shares underlying array
- **Channel operations on nil channels**: Blocking forever on nil channel send/receive
- **Mutex copy**: Passing sync.Mutex by value instead of pointer
## Vue/TypeScript-Specific Bug Patterns to Check
- **Reactive reference unwrapping**: Using `.value` correctly with `ref()` vs `reactive()`
- **Computed dependency tracking**: Missing reactive dependencies in computed properties
- **Watch cleanup**: Not cleaning up watchers, event listeners, or timers in `onUnmounted`
- **SSE/EventSource cleanup**: Connections not properly closed on component unmount
- **Array reactivity**: Using index assignment instead of reactive methods
- **Optional chaining gaps**: Accessing nested properties without null checks
- **Promise error handling**: Unhandled promise rejections, missing `.catch()` or try/catch
- **Type narrowing issues**: Assuming a type without proper guards
- **Stale closure references**: Callbacks capturing outdated reactive values
- **Memory leaks**: Growing arrays without bounds (check maxLogs enforcement, statsHistory rolling window)
- **Race conditions in async operations**: Component unmounted before async operation completes
- **Incorrect v-if/v-show usage**: Rendering components that depend on data not yet loaded
- **Event buffer overflow**: Not handling backpressure in SSE streams
## Project-Specific Concerns
- **EMA calculations**: Alpha=0.2 for stats smoothing — verify division by zero, NaN handling
- **Rolling window (300 items)**: Ensure proper eviction and no off-by-one errors
- **Log entry type discrimination**: `LogEntry.create()` must handle all `logEvent.t` values
- **Multi-host operations**: Container/host lookups via `FindContainer()`/`FindHost()` may return nil
- **Docker API version negotiation**: Calls may fail on older Docker versions
- **Protobuf serialization**: `FromProto()` methods must handle nil/empty fields
- **Authentication modes**: Code must work correctly in all three auth modes (none, simple, forward-proxy)
- **SSE buffering**: 250ms debounce with 1000ms max — verify timer cleanup
- **CPU normalization**: Division by `cpuLimit` or `nCPU` — check for zero values
## Output Format
Follow ultra-brief mode as specified in the project guidelines:
- Critical issues only (bugs, security, blockers)
- Brief bullet points, no lengthy explanations
- Skip verbose sections (no "Strengths", "Summary", etc.)
- Include file:line references when relevant
- Maximum ~10-15 lines per response
For each bug found, report:
- **File:line** — Brief description of the bug
- **Severity**: 🔴 Critical (will crash/corrupt) | 🟡 Warning (could fail under specific conditions) | 🟠 Edge case (uncovered scenario)
- One-line fix suggestion if obvious
If no bugs are found, say so concisely. Do not fabricate issues.
## Approach
1. First, determine what code was recently changed (check git diff or ask the user)
2. Read the changed files carefully
3. For each file, apply the relevant bug pattern checklist above
4. Cross-reference frontend and backend changes for consistency
5. Report findings in the ultra-brief format
Do NOT review code style, naming conventions, or suggest refactors unless they mask a bug. Focus exclusively on correctness.
**Update your agent memory** as you discover recurring bug patterns, common error-prone code paths, areas of the codebase with historical issues, and edge cases specific to this project's architecture. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
Examples of what to record:
- Recurring nil-check omissions in specific packages
- Components that frequently have cleanup issues
- API endpoints with known edge case gaps
- Stats calculation patterns that are error-prone
- Log parsing paths that have caused issues before
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `/Users/araminfar/Workspace/dozzle/.claude/agent-memory/bug-hunter/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- Use the Write and Edit tools to update your memory files
What to save:
- Stable patterns and conventions confirmed across multiple interactions
- Key architectural decisions, important file paths, and project structure
- User preferences for workflow, tools, and communication style
- Solutions to recurring problems and debugging insights
What NOT to save:
- Session-specific context (current task details, in-progress work, temporary state)
- Information that might be incomplete — verify against project docs before writing
- Anything that duplicates or contradicts existing CLAUDE.md instructions
- Speculative or unverified conclusions from reading a single file
Explicit user requests:
- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions
- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time.
+145
View File
@@ -0,0 +1,145 @@
---
name: go-perf-reviewer
description: "Use this agent when Go code has been written or modified and needs review for performance issues, inefficient patterns, or unnecessary resource loading. This agent focuses exclusively on Go backend code and should be triggered after changes to `.go` files.\\n\\nExamples:\\n\\n- user: \"I just added a new endpoint that fetches container stats\"\\n assistant: \"Let me use the go-perf-reviewer agent to check the new endpoint for performance issues.\"\\n <commentary>Since Go code was written for a new endpoint, use the Task tool to launch the go-perf-reviewer agent to review the changes for inefficient patterns.</commentary>\\n\\n- user: \"Can you review my changes to the Docker client wrapper?\"\\n assistant: \"I'll launch the go-perf-reviewer agent to analyze your Docker client changes for performance concerns.\"\\n <commentary>The user is asking for a review of Go code changes. Use the Task tool to launch the go-perf-reviewer agent to identify any performance anti-patterns.</commentary>\\n\\n- user: \"I refactored the log streaming pipeline\"\\n assistant: \"Let me run the go-perf-reviewer agent against your refactored streaming code to catch any performance regressions.\"\\n <commentary>Streaming code is performance-critical. Use the Task tool to launch the go-perf-reviewer agent to ensure the refactor doesn't introduce inefficiencies.</commentary>"
model: opus
color: green
memory: project
---
You are an expert Go performance engineer with deep knowledge of runtime internals, memory allocation patterns, garbage collector behavior, and idiomatic high-performance Go. You specialize in reviewing Go code for performance anti-patterns in long-running server applications that interact with Docker, gRPC, and streaming APIs.
**Core Philosophy**: Dozzle must be lean and lazy by default. It should never load, allocate, compute, or fetch anything until it is actually needed. Every byte of memory and every CPU cycle matters in a lightweight monitoring tool.
## Your Review Process
1. **Examine only the changed Go files**. Use available tools to read the recent changes or diffs.
2. **Identify specific performance issues** — do not comment on style, naming, or correctness unless it directly causes a performance problem.
3. **Provide actionable feedback** with file:line references and concrete fix suggestions.
## Patterns to Flag
### Memory & Allocation
- Unnecessary allocations in hot paths (loops, stream handlers, per-request code)
- Slice/map pre-allocation missing when size is known or estimable
- `append` in tight loops without pre-sized slices
- String concatenation with `+` instead of `strings.Builder` in loops
- Returning large structs by value instead of pointer when appropriate
- Unnecessary copies of large data (e.g., ranging over slice of structs by value)
- `[]byte``string` conversions that could be avoided
- Creating closures in loops that capture loop variables unnecessarily
- Allocating buffers per-call that could be pooled via `sync.Pool`
### Lazy Loading & Eager Initialization
- **Top priority**: Loading data, making API calls, or initializing resources before they are actually needed
- Fetching all containers/stats when only a subset is requested
- Initializing clients, connections, or caches at startup that may never be used
- Reading entire files/streams into memory when streaming/pagination would suffice
- Computing derived data eagerly when it could be computed on demand
- Pre-populating maps/caches with all possible entries instead of lazy-filling
### Concurrency & Goroutines
- Goroutine leaks: goroutines without proper cancellation via `context.Context`
- Missing `defer cancel()` after `context.WithCancel/WithTimeout`
- Unbounded goroutine spawning without semaphore/worker pool
- Channel misuse: unbuffered channels causing unnecessary blocking, or oversized buffered channels wasting memory
- Holding locks longer than necessary; lock contention in hot paths
- Using `sync.Mutex` where `sync.RWMutex` would reduce contention
### I/O & Streaming
- Not using `bufio.Reader`/`bufio.Writer` for I/O operations
- Reading entire HTTP response bodies into memory (`io.ReadAll`) when streaming is possible
- Not closing response bodies, readers, or connections (resource leaks)
- Blocking I/O without timeouts or context cancellation
- Serializing/deserializing JSON repeatedly when it could be done once
- Using `encoding/json` in ultra-hot paths where a faster serializer is warranted
### Docker/gRPC Specific
- Making redundant Docker API calls (e.g., inspecting a container multiple times)
- Not using Docker API filters to narrow results server-side
- Fetching all container logs when `tail` or `since` parameters should limit scope
- gRPC streams not properly drained or closed
- Creating new Docker/gRPC clients per request instead of reusing
### General Go Anti-Patterns
- `reflect` usage in hot paths
- `fmt.Sprintf` for simple string operations where direct concatenation suffices
- `interface{}` / `any` boxing causing heap escapes
- Unnecessary use of `defer` in tight loops (small but real overhead)
- `time.After` in select loops (creates new timer each iteration; use `time.NewTimer` + `Reset`)
- Regex compilation inside functions instead of package-level `var` with `regexp.MustCompile`
- Sorting large slices repeatedly instead of maintaining sorted order
## Output Format
Use ultra-brief mode as specified by the project:
- Critical performance issues only
- Brief bullet points with file:line references
- Concrete suggestion for each issue
- Maximum ~10-15 lines per response
- No praise sections, no summaries, no fluff
Example output:
```
- `internal/docker/client.go:142` — `io.ReadAll(resp.Body)` reads entire log stream into memory. Stream with `bufio.Scanner` instead.
- `internal/web/logs.go:87` — New `json.Encoder` created per log line in hot loop. Reuse encoder or use `sync.Pool`.
- `internal/support/docker/manager.go:53` — All hosts initialized eagerly at startup. Defer client creation until first access.
```
If no performance issues are found, state that clearly in one line.
**Update your agent memory** as you discover performance patterns, hot paths, allocation-heavy code paths, and architectural decisions in this codebase. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
Examples of what to record:
- Hot paths identified (log streaming, stats collection, event processing)
- Existing `sync.Pool` usage or buffer reuse patterns
- Known allocation-heavy areas
- Docker API call patterns and caching strategies
- gRPC streaming patterns used in agent mode
- Areas where lazy loading is already implemented vs. missing
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `/Users/araminfar/Workspace/dozzle/.claude/agent-memory/go-perf-reviewer/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- Use the Write and Edit tools to update your memory files
What to save:
- Stable patterns and conventions confirmed across multiple interactions
- Key architectural decisions, important file paths, and project structure
- User preferences for workflow, tools, and communication style
- Solutions to recurring problems and debugging insights
What NOT to save:
- Session-specific context (current task details, in-progress work, temporary state)
- Information that might be incomplete — verify against project docs before writing
- Anything that duplicates or contradicts existing CLAUDE.md instructions
- Speculative or unverified conclusions from reading a single file
Explicit user requests:
- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions
- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time.
+25
View File
@@ -0,0 +1,25 @@
{
"permissions": {
"allow": [
"Bash(gh pr view:*)",
"Bash(gh api:*)",
"Bash(gh issue view:*)",
"Bash(gh pr list:*)",
"WebFetch(domain:github.com)",
"Bash(make test:*)",
"Bash(go test:*)",
"Bash(go build:*)",
"Bash(pnpm test:*)",
"Bash(pnpm build:*)",
"Bash(pnpm typecheck:*)",
"Bash(pnpm list:*)",
"Bash(node --version:*)",
"Bash(git rebase:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git checkout:*)",
"Bash(git push:*)",
"Bash(xargs ls:*)"
]
}
}
-1
View File
@@ -6,4 +6,3 @@ dist
.git
e2e
docs
internal/agent/pb/
+16 -24
View File
@@ -13,19 +13,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
name: Install pnpm
- uses: actions/setup-node@v6
name: Install Node
with:
node-version: 24.13.1
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.13.1
node-version: 24.14.1
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- run: corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline
- name: Run Tests
@@ -37,7 +33,7 @@ jobs:
- name: Install Go
uses: actions/setup-go@v6
with:
go-version: 1.26.0
go-version: 1.26.2
check-latest: true
- name: Checkout code
uses: actions/checkout@v6
@@ -55,29 +51,25 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
name: Install pnpm
- uses: actions/setup-node@v6
name: Install Node
with:
node-version: 24.13.1
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.13.1
node-version: 24.14.1
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- run: corepack enable
- name: Install dependencies
run: pnpm install
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Writing certs to file
run: |
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
- name: Build
uses: docker/bake-action@v6
uses: docker/bake-action@v7
with:
source: .
load: true
@@ -86,7 +78,7 @@ jobs:
*.cache-to=type=gha,mode=max
- name: Run Playwright tests
run: docker compose up --exit-code-from playwright
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report
@@ -101,14 +93,14 @@ jobs:
packages: write
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to DockerHub
uses: docker/login-action@v3.7.0
uses: docker/login-action@v4.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the Container registry
uses: docker/login-action@v3.7.0
uses: docker/login-action@v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -117,7 +109,7 @@ jobs:
uses: actions/checkout@v6
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
amir20/dozzle
@@ -132,7 +124,7 @@ jobs:
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
- name: Build and push
uses: docker/build-push-action@v6.19.0
uses: docker/build-push-action@v7.1.0
with:
push: true
context: .
@@ -153,7 +145,7 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
name: Install pnpm
- name: Install Node
uses: actions/setup-node@v6
+10 -7
View File
@@ -18,14 +18,14 @@ jobs:
if: ${{ !github.event.repository.fork && !github.event.pull_request.head.repo.fork && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'amir20/dozzle') }}
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to DockerHub
uses: docker/login-action@v3.7.0
uses: docker/login-action@v4.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the Container registry
uses: docker/login-action@v3.7.0
uses: docker/login-action@v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -34,23 +34,26 @@ jobs:
uses: actions/checkout@v6
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
amir20/dozzle
ghcr.io/amir20/dozzle
- name: Short SHA
id: sha
run: echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Writing certs to file
run: |
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
- name: Build and push
uses: docker/build-push-action@v6.19.0
uses: docker/build-push-action@v7.1.0
with:
context: .
push: true
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
platforms: linux/amd64,linux/arm64/v8
tags: ${{ steps.meta.outputs.tags }}
build-args: TAG=${{ steps.meta.outputs.version }}
build-args: TAG=${{ steps.meta.outputs.version }}-${{ steps.sha.outputs.short }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+3 -3
View File
@@ -31,10 +31,10 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24.13.1
node-version: 24.14.1
cache: pnpm # or pnpm / yarn
- name: Setup Pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@v6
- name: Install dependencies
run: pnpm install
- name: Build with VitePress
@@ -57,4 +57,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
+14 -14
View File
@@ -16,17 +16,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
name: Install pnpm
- uses: actions/setup-node@v6
name: Install Node
with:
node-version: 24.13.1
node-version: 24.14.1
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.13.1
node-version: 24.14.1
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
@@ -38,13 +38,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
name: Install pnpm
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.13.1
node-version: 24.14.1
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
@@ -58,7 +58,7 @@ jobs:
- name: Install Go
uses: actions/setup-go@v6
with:
go-version: "1.26.0"
go-version: "1.26.2"
check-latest: true
- name: Checkout code
uses: actions/checkout@v6
@@ -81,12 +81,12 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.26.0"
go-version: "1.26.2"
check-latest: true
- name: Generate dependencies
run: make fake_assets shared_key.pem shared_cert.pem
- name: Stactic checker
uses: dominikh/staticcheck-action@v1.4.0
uses: dominikh/staticcheck-action@v1.4.1
with:
install-go: false
int-test:
@@ -95,27 +95,27 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
name: Install pnpm
- uses: actions/setup-node@v6
name: Install Node
with:
node-version: 24.13.1
node-version: 24.14.1
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.13.1
node-version: 24.14.1
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Generate certs
run: make shared_key.pem shared_cert.pem
- name: Build
uses: docker/bake-action@v6
uses: docker/bake-action@v7
with:
source: .
load: true
@@ -124,7 +124,7 @@ jobs:
*.cache-to=type=gha,mode=max
- name: Run Playwright tests
run: docker compose up --exit-code-from playwright
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report
+2 -1
View File
@@ -15,4 +15,5 @@ coverage.out
*.pem
*.csr
tmp
.claude
.claude/settings.local.json
.claude/worktrees/
+32
View File
@@ -112,6 +112,15 @@ The Go backend is organized into these key packages:
- Uses Protocol Buffers (protos defined in `protos/`)
- Enables distributed log collection across Docker hosts
- **`internal/cloud/`** - Dozzle Cloud integration (tool execution engine)
- `client.go`: Bidirectional gRPC stream client with auto-reconnect and exponential backoff
- `tools.go`: Tool registration, dispatch (`executeTool`), and `ToolHostService` interface
- `tools_containers.go`: Container listing, finding, stats, and inspection tools
- `tools_logs.go`: Log fetching with level/query/regex filtering (max 100 lines)
- `tools_actions.go`: Container start/stop/restart actions (gated by `enableActions`)
- `tools_helpers.go`: Proto conversion utilities and host name resolution
- Uses `protos/cloud.proto` for service and message definitions
- **`internal/k8s/`** - Kubernetes client support
- Alternative to Docker client for k8s deployments
@@ -226,6 +235,9 @@ The frontend uses file-based routing with these conventions:
- `BarChart.vue`: Self-contained bar chart with responsive downsampling
- Downsampling algorithm: Averages data into buckets based on available screen width
- All stat history tracked in `Container.statsHistory` (max 300 items via rolling window)
- `chartData` is always a rolling window of max 300 items — array length stays constant
- Uses `ref` (not `computed`) for `downsampledBars` to enable in-place mutation of the last bar, avoiding full re-renders
- Component instance is reused when switching containers; parent must call exposed `recalculate()` to force refresh
### Backend
@@ -336,6 +348,16 @@ Implementation (DockerClient, K8sClient, AgentClient)
5. Streaming RPCs (logs, stats, events) use bidirectional channels
6. Responses converted back to domain models via `FromProto()` methods
### Cloud Tool Execution Flow
1. `cloud.Client.Run()` blocks until `Notify()` signals a cloud dispatcher is configured
2. `connect()` establishes bidirectional gRPC stream (`ToolStream` RPC) to cloud endpoint
3. Cloud sends `ToolRequest` (ListTools or CallTool), client dispatches via `executeTool()`
4. Tool calls run concurrently (max 5 via weighted semaphore), responses sent back on stream
5. On disconnect, exponential backoff (1s→30s with jitter) triggers reconnection
6. `PermissionDenied` errors stop retrying permanently (invalid API key / no pro plan)
7. Tool definitions cached via `sync.Once`; zero overhead for non-cloud users
### Log Parsing Pipeline
1. Docker API returns multiplexed stream (8-byte headers + payload)
@@ -412,6 +434,16 @@ Implementation (DockerClient, K8sClient, AgentClient)
3. Add UI form in `assets/components/Notification/DestinationForm.vue`
4. Add GraphQL schema fields if needed
### Adding a New Cloud Tool
1. Define the tool in `AvailableTools()` in `internal/cloud/tools.go` with name, description, and parameter schema
2. Add a response message type in `protos/cloud.proto` and add it to the `CallToolResponse.result` oneof
3. Run `make generate` to regenerate protobuf code
4. Add a case in the `executeTool()` switch in `internal/cloud/tools.go`
5. Implement the execution function in the appropriate `tools_*.go` file
6. Use `ToolHostService` interface methods to access container/host data
7. Add tests in `tools_test.go`
## Common Development Patterns
### Testing
+7 -12
View File
@@ -1,5 +1,5 @@
# Build assets
FROM --platform=$BUILDPLATFORM node:25.6.0-alpine AS node
FROM --platform=$BUILDPLATFORM node:25.9.0-alpine AS node
RUN npm install -g --force corepack && corepack enable
@@ -14,7 +14,7 @@ COPY package.json ./
RUN pnpm install --offline --ignore-scripts --no-optional
# Copy assets and translations to build
COPY .* *.config.ts *.config.js *.config.cjs ./
COPY vite.config.ts tsconfig.json .prettierrc.cjs .npmrc ./
COPY assets ./assets
COPY locales ./locales
COPY public ./public
@@ -24,20 +24,17 @@ RUN pnpm build
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
# install gRPC dependencies
RUN apk add --no-cache ca-certificates protoc protobuf-dev \
&& mkdir /dozzle \
&& go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \
&& go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
RUN apk add --no-cache ca-certificates && mkdir /dozzle
WORKDIR /dozzle
# Copy go mod files
COPY go.* ./
RUN go mod download
RUN --mount=type=cache,target=/go/pkg/mod go mod download
# Copy all other files
COPY internal ./internal
COPY proto ./proto
COPY types ./types
COPY main.go ./
COPY protos ./protos
@@ -50,11 +47,9 @@ COPY --from=node /build/dist ./dist
ARG TAG=dev
ARG TARGETOS TARGETARCH
# Generate protos and graphql
RUN go generate ./...
# Build binary
RUN GOEXPERIMENT=jsonv2 GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle
RUN mkdir /data
+84
View File
@@ -0,0 +1,84 @@
# Dozzle Major Version Highlights
A summary of major features introduced with each major version bump since 2020.
## v2.0.0 — 2020-06-27
- Theme overhaul using CSS variables
- Progress notification bar
- Toggle between new light and dark themes
## v3.0.0 — 2020-09-06
- Live container stats (CPU/memory) streamed in real time
- Pinned tabs and improved mobile/responsive layout
- Major UI overhaul
## v4.0.0 — 2022-08-17
- First-class JSON log support
- Jump-to-context, soft wraps, clear-logs action
- Auto color scheme, dark mode polish
- Container stats panel, total CPU/mem usage
- Healthcheck endpoint and "wait for docker" startup option
## v5.0.0 — 2023-09-23
- Multi-host support with parallel client connections
- New homepage dashboard with all containers, sortable table, pagination, bar charts
- Exponential moving average for stats
- Container pinning, keyboard shortcut overlay
- stdout/stderr stream separation
- i18n: Chinese, German added; locale infrastructure
- Refactored UI with faster components
## v6.0.0 — 2024-01-01
- **Forward-proxy authentication** (Authelia, etc.) and `users.yml` file-based auth
- Container actions: start/stop/restart from the UI
- Hot-reload of users.yml without restart
- Custom headers for forward-proxy auth
- Settings synced to disk for authenticated users
- Toast notifications, release list, redirect-to-new-container
- Removed legacy auth model (breaking)
## v7.0.0 — 2024-05-24
- **Docker Swarm mode** with stacks and services on remote hosts
- Host cards on dashboard with per-host stats
- Container grouping by stack/compose
- Background stats collection (up to 5 min) with idle deactivation
- LogFmt parser support
- Compact mode, draggable search, alt-click split panes
- Many new locales (French, Italian, Polish, Danish, Turkish, …)
- `generate` subcommand for users.yml
## v8.0.0 — 2024-07-05
- **Swarm mode rebuilt on gRPC agents** (breaking architecture change)
- Critical/severe log levels
- Stacks and services in fuzzy search
- Improved search with full match scrolling
- Container start events shown inline
## v9.0.0 — 2026-01-06
- **Kubernetes mode** with k8s-specific menu
- **User roles** and `dozzle_*` role mapping; logout URL for forward proxy
- Historical stats on homepage, hosts, and containers
- Permanent links to specific past log lines
- Grouping by `dev.dozzle.group` label, log message grouping
- Shell resize support, action toolbar in menu
- Settings page, collapsible side menu sections
- Parallel container fetching, gRPC compression
- CLEF (`@l`) log level extraction
- New locales: Korean, Indonesian, Dutch
## v10.0.0 — 2026-02-10
- **Dozzle Cloud** integration (bidirectional gRPC tool execution)
- **Notifications & alerts**: full notifications page, webhooks, dispatchers, Go template support, test connections
- Notifications work across agents
- Per-container network usage stats (with mobile view)
- Coolify label fallbacks for container name/group
- Alert creation shortcut, JSON syntax in templates
+4 -2
View File
@@ -26,7 +26,7 @@ build: dist generate
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=local"
.PHONY: docker
docker: shared_key.pem shared_cert.pem
docker: generate
@docker build --build-arg TAG=local -t amir20/dozzle:local .
.PHONY: generate
@@ -51,12 +51,14 @@ shared_cert.pem: shared_key.pem
.PHONY: push
push: docker
@docker tag amir20/dozzle:latest amir20/dozzle:local-test
@docker tag amir20/dozzle:local amir20/dozzle:local-test
@docker push amir20/dozzle:local-test
.PHONY: run
run: docker
docker run -it --rm -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock amir20/dozzle:local
.PHONY: preview
preview: build
pnpm preview
+21 -2
View File
@@ -29,11 +29,13 @@ declare global {
const createApp: typeof import('vue').createApp
const createContainerHints: typeof import('./composable/exprEditor').createContainerHints
const createDrawer: typeof import('./composable/drawer').createDrawer
const createEventHints: typeof import('./composable/exprEditor').createEventHints
const createEventHook: typeof import('@vueuse/core').createEventHook
const createExprEditor: typeof import('./composable/exprEditor').createExprEditor
const createGlobalState: typeof import('@vueuse/core').createGlobalState
const createInjectionState: typeof import('@vueuse/core').createInjectionState
const createLogHints: typeof import('./composable/exprEditor').createLogHints
const createMetricHints: typeof import('./composable/exprEditor').createMetricHints
const createPinia: typeof import('pinia').createPinia
const createReactiveFn: typeof import('@vueuse/core').createReactiveFn
const createRef: typeof import('@vueuse/core').createRef
@@ -56,6 +58,7 @@ declare global {
const flattenJSON: typeof import('./utils/index').flattenJSON
const flattenJSONToMap: typeof import('./utils/index').flattenJSONToMap
const formatBytes: typeof import('./utils/index').formatBytes
const formatDuration: typeof import('./utils/index').formatDuration
const getActivePinia: typeof import('pinia').getActivePinia
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
@@ -78,7 +81,7 @@ declare global {
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const lightTheme: typeof import('./stores/settings').lightTheme
const loadBetween: typeof import('./composable/eventStreams').loadBetween
const loadBetween: typeof import('./composable/loadBetween').loadBetween
const locale: typeof import('./stores/settings').locale
const loggingContextKey: typeof import('./composable/logContext').loggingContextKey
const makeDestructurable: typeof import('@vueuse/core').makeDestructurable
@@ -111,6 +114,7 @@ declare global {
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const parseMessage: typeof import('./composable/loadBetween').parseMessage
const pausableWatch: typeof import('@vueuse/core').pausableWatch
const persistentVisibleKeysForContainer: typeof import('./composable/storage').persistentVisibleKeysForContainer
const pinnedContainers: typeof import('./composable/storage').pinnedContainers
@@ -173,6 +177,7 @@ declare global {
const unrefElement: typeof import('@vueuse/core').unrefElement
const until: typeof import('@vueuse/core').until
const useActiveElement: typeof import('@vueuse/core').useActiveElement
const useAlertForm: typeof import('./composable/alertForm').useAlertForm
const useAnimate: typeof import('@vueuse/core').useAnimate
const useAnnouncements: typeof import('./stores/announcements').useAnnouncements
const useArrayDifference: typeof import('@vueuse/core').useArrayDifference
@@ -200,6 +205,7 @@ declare global {
const useClipboard: typeof import('@vueuse/core').useClipboard
const useClipboardItems: typeof import('@vueuse/core').useClipboardItems
const useCloned: typeof import('@vueuse/core').useCloned
const useCloudConfig: typeof import('./composable/cloudConfig').useCloudConfig
const useColorMode: typeof import('@vueuse/core').useColorMode
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
const useContainerActions: typeof import('./composable/containerActions').useContainerActions
@@ -238,6 +244,7 @@ declare global {
const useEventListener: typeof import('@vueuse/core').useEventListener
const useEventSource: typeof import('@vueuse/core').useEventSource
const useExponentialMovingAverage: typeof import('./utils/index').useExponentialMovingAverage
const useExprEditorField: typeof import('./composable/useExprEditorField').useExprEditorField
const useEyeDropper: typeof import('@vueuse/core').useEyeDropper
const useFavicon: typeof import('@vueuse/core').useFavicon
const useFetch: typeof import('@vueuse/core').useFetch
@@ -267,6 +274,7 @@ declare global {
const useLastChanged: typeof import('@vueuse/core').useLastChanged
const useLink: typeof import('vue-router/auto').useLink
const useLocalStorage: typeof import('@vueuse/core').useLocalStorage
const useLogLoader: typeof import('./composable/logLoader').useLogLoader
const useLoggingContext: typeof import('./composable/logContext').useLoggingContext
const useMagicKeys: typeof import('@vueuse/core').useMagicKeys
const useManualRefHistory: typeof import('@vueuse/core').useManualRefHistory
@@ -395,6 +403,9 @@ declare global {
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
// @ts-ignore
export type { AlertFormOptions, ContainerResult } from './composable/alertForm'
import('./composable/alertForm')
// @ts-ignore
export type { DrawerWidth } from './composable/drawer'
import('./composable/drawer')
// @ts-ignore
@@ -448,11 +459,13 @@ declare module 'vue' {
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly createContainerHints: UnwrapRef<typeof import('./composable/exprEditor')['createContainerHints']>
readonly createDrawer: UnwrapRef<typeof import('./composable/drawer')['createDrawer']>
readonly createEventHints: UnwrapRef<typeof import('./composable/exprEditor')['createEventHints']>
readonly createEventHook: UnwrapRef<typeof import('@vueuse/core')['createEventHook']>
readonly createExprEditor: UnwrapRef<typeof import('./composable/exprEditor')['createExprEditor']>
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
readonly createLogHints: UnwrapRef<typeof import('./composable/exprEditor')['createLogHints']>
readonly createMetricHints: UnwrapRef<typeof import('./composable/exprEditor')['createMetricHints']>
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
readonly createRef: UnwrapRef<typeof import('@vueuse/core')['createRef']>
@@ -475,6 +488,7 @@ declare module 'vue' {
readonly flattenJSON: UnwrapRef<typeof import('./utils/index')['flattenJSON']>
readonly flattenJSONToMap: UnwrapRef<typeof import('./utils/index')['flattenJSONToMap']>
readonly formatBytes: UnwrapRef<typeof import('./utils/index')['formatBytes']>
readonly formatDuration: UnwrapRef<typeof import('./utils/index')['formatDuration']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
@@ -497,7 +511,7 @@ declare module 'vue' {
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly isShallow: UnwrapRef<typeof import('vue')['isShallow']>
readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']>
readonly loadBetween: UnwrapRef<typeof import('./composable/eventStreams')['loadBetween']>
readonly loadBetween: UnwrapRef<typeof import('./composable/loadBetween')['loadBetween']>
readonly locale: UnwrapRef<typeof import('./stores/settings')['locale']>
readonly loggingContextKey: UnwrapRef<typeof import('./composable/logContext')['loggingContextKey']>
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
@@ -530,6 +544,7 @@ declare module 'vue' {
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
readonly parseMessage: UnwrapRef<typeof import('./composable/loadBetween')['parseMessage']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly persistentVisibleKeysForContainer: UnwrapRef<typeof import('./composable/storage')['persistentVisibleKeysForContainer']>
readonly pinnedContainers: UnwrapRef<typeof import('./composable/storage')['pinnedContainers']>
@@ -592,6 +607,7 @@ declare module 'vue' {
readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']>
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
readonly useAlertForm: UnwrapRef<typeof import('./composable/alertForm')['useAlertForm']>
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
readonly useAnnouncements: UnwrapRef<typeof import('./stores/announcements')['useAnnouncements']>
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
@@ -619,6 +635,7 @@ declare module 'vue' {
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
readonly useCloudConfig: UnwrapRef<typeof import('./composable/cloudConfig')['useCloudConfig']>
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
@@ -657,6 +674,7 @@ declare module 'vue' {
readonly useEventListener: UnwrapRef<typeof import('@vueuse/core')['useEventListener']>
readonly useEventSource: UnwrapRef<typeof import('@vueuse/core')['useEventSource']>
readonly useExponentialMovingAverage: UnwrapRef<typeof import('./utils/index')['useExponentialMovingAverage']>
readonly useExprEditorField: UnwrapRef<typeof import('./composable/useExprEditorField')['useExprEditorField']>
readonly useEyeDropper: UnwrapRef<typeof import('@vueuse/core')['useEyeDropper']>
readonly useFavicon: UnwrapRef<typeof import('@vueuse/core')['useFavicon']>
readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>
@@ -686,6 +704,7 @@ declare module 'vue' {
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
readonly useLink: UnwrapRef<typeof import('vue-router/auto')['useLink']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useLogLoader: UnwrapRef<typeof import('./composable/logLoader')['useLogLoader']>
readonly useLoggingContext: UnwrapRef<typeof import('./composable/logContext')['useLoggingContext']>
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
+23
View File
@@ -15,8 +15,10 @@ declare module 'vue' {
AlertForm: typeof import('./components/Notification/AlertForm.vue')['default']
Announcements: typeof import('./components/Announcements.vue')['default']
BarChart: typeof import('./components/BarChart.vue')['default']
'Carbon:add': typeof import('~icons/carbon/add')['default']
'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default']
'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default']
'Carbon:close': typeof import('~icons/carbon/close')['default']
'Carbon:information': typeof import('~icons/carbon/information')['default']
'Carbon:logoKubernetes': typeof import('~icons/carbon/logo-kubernetes')['default']
'Carbon:macShift': typeof import('~icons/carbon/mac-shift')['default']
@@ -25,6 +27,7 @@ declare module 'vue' {
'Carbon:star': typeof import('~icons/carbon/star')['default']
'Carbon:starFilled': typeof import('~icons/carbon/star-filled')['default']
'Carbon:stopFilledAlt': typeof import('~icons/carbon/stop-filled-alt')['default']
'Carbon:upgrade': typeof import('~icons/carbon/upgrade')['default']
'Carbon:warning': typeof import('~icons/carbon/warning')['default']
Carousel: typeof import('./components/common/Carousel.vue')['default']
CarouselItem: typeof import('./components/common/CarouselItem.vue')['default']
@@ -32,6 +35,9 @@ declare module 'vue' {
'Cil:circle': typeof import('~icons/cil/circle')['default']
'Cil:columns': typeof import('~icons/cil/columns')['default']
'Cil:xCircle': typeof import('~icons/cil/x-circle')['default']
CloudDestinationForm: typeof import('./components/Notification/CloudDestinationForm.vue')['default']
CloudPopover: typeof import('./components/CloudPopover.vue')['default']
CloudSettingsCard: typeof import('./components/CloudSettingsCard.vue')['default']
ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default']
ContainerActionsToolbar: typeof import('./components/ContainerViewer/ContainerActionsToolbar.vue')['default']
ContainerDropdown: typeof import('./components/ContainerDropdown.vue')['default']
@@ -42,11 +48,13 @@ declare module 'vue' {
ContainerStatCell: typeof import('./components/ContainerStatCell.vue')['default']
ContainerTable: typeof import('./components/ContainerTable.vue')['default']
ContainerTitle: typeof import('./components/ContainerViewer/ContainerTitle.vue')['default']
CooldownField: typeof import('./components/Notification/CooldownField.vue')['default']
DateTime: typeof import('./components/common/DateTime.vue')['default']
DestinationCard: typeof import('./components/Notification/DestinationCard.vue')['default']
DestinationForm: typeof import('./components/Notification/DestinationForm.vue')['default']
Dropdown: typeof import('./components/common/Dropdown.vue')['default']
DropdownMenu: typeof import('./components/common/DropdownMenu.vue')['default']
EventAlertFields: typeof import('./components/Notification/EventAlertFields.vue')['default']
EventSource: typeof import('./components/LogViewer/EventSource.vue')['default']
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.vue')['default']
@@ -67,6 +75,7 @@ declare module 'vue' {
Links: typeof import('./components/Links.vue')['default']
LoadMoreLogItem: typeof import('./components/LogViewer/LoadMoreLogItem.vue')['default']
LogActions: typeof import('./components/LogViewer/LogActions.vue')['default']
LogAlertFields: typeof import('./components/Notification/LogAlertFields.vue')['default']
LogAnalytics: typeof import('./components/LogViewer/LogAnalytics.vue')['default']
LogDate: typeof import('./components/LogViewer/LogDate.vue')['default']
LogDetails: typeof import('./components/LogViewer/LogDetails.vue')['default']
@@ -85,11 +94,15 @@ declare module 'vue' {
'MaterialSymbolsLight:collapseAll': typeof import('~icons/material-symbols-light/collapse-all')['default']
'Mdi:account': typeof import('~icons/mdi/account')['default']
'Mdi:alert': typeof import('~icons/mdi/alert')['default']
'Mdi:alertCircle': typeof import('~icons/mdi/alert-circle')['default']
'Mdi:alertOutline': typeof import('~icons/mdi/alert-outline')['default']
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
'Mdi:beer': typeof import('~icons/mdi/beer')['default']
'Mdi:bell': typeof import('~icons/mdi/bell')['default']
'Mdi:bellRingOutline': typeof import('~icons/mdi/bell-ring-outline')['default']
'Mdi:chartBar': typeof import('~icons/mdi/chart-bar')['default']
'Mdi:chartLine': typeof import('~icons/mdi/chart-line')['default']
'Mdi:check': typeof import('~icons/mdi/check')['default']
'Mdi:checkCircle': typeof import('~icons/mdi/check-circle')['default']
'Mdi:chevronDoubleDown': typeof import('~icons/mdi/chevron-double-down')['default']
@@ -98,8 +111,10 @@ declare module 'vue' {
'Mdi:chevronRight': typeof import('~icons/mdi/chevron-right')['default']
'Mdi:close': typeof import('~icons/mdi/close')['default']
'Mdi:cloud': typeof import('~icons/mdi/cloud')['default']
'Mdi:cloudOffOutline': typeof import('~icons/mdi/cloud-off-outline')['default']
'Mdi:cloudOutline': typeof import('~icons/mdi/cloud-outline')['default']
'Mdi:cog': typeof import('~icons/mdi/cog')['default']
'Mdi:contentCopy': typeof import('~icons/mdi/content-copy')['default']
'Mdi:docker': typeof import('~icons/mdi/docker')['default']
'Mdi:gauge': typeof import('~icons/mdi/gauge')['default']
'Mdi:github': typeof import('~icons/mdi/github')['default']
@@ -109,12 +124,17 @@ declare module 'vue' {
'Mdi:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default']
'Mdi:lightningBolt': typeof import('~icons/mdi/lightning-bolt')['default']
'Mdi:linkVariant': typeof import('~icons/mdi/link-variant')['default']
'Mdi:linkVariantOff': typeof import('~icons/mdi/link-variant-off')['default']
'Mdi:magnify': typeof import('~icons/mdi/magnify')['default']
'Mdi:pencilOutline': typeof import('~icons/mdi/pencil-outline')['default']
'Mdi:plus': typeof import('~icons/mdi/plus')['default']
'Mdi:poll': typeof import('~icons/mdi/poll')['default']
'Mdi:refresh': typeof import('~icons/mdi/refresh')['default']
'Mdi:satelliteVariant': typeof import('~icons/mdi/satellite-variant')['default']
'Mdi:textBoxOutline': typeof import('~icons/mdi/text-box-outline')['default']
'Mdi:trashCanOutline': typeof import('~icons/mdi/trash-can-outline')['default']
'Mdi:webhook': typeof import('~icons/mdi/webhook')['default']
MetricAlertFields: typeof import('./components/Notification/MetricAlertFields.vue')['default']
MetricCard: typeof import('./components/MetricCard.vue')['default']
MobileMenu: typeof import('./components/common/MobileMenu.vue')['default']
MultiContainerActionToolbar: typeof import('./components/LogViewer/MultiContainerActionToolbar.vue')['default']
@@ -158,6 +178,7 @@ declare module 'vue' {
SQLTable: typeof import('./components/LogViewer/SQLTable.vue')['default']
StackLog: typeof import('./components/StackViewer/StackLog.vue')['default']
StatMonitor: typeof import('./components/LogViewer/StatMonitor.vue')['default']
'SvgSpinners:ringResize': typeof import('~icons/svg-spinners/ring-resize')['default']
SwarmMenu: typeof import('./components/SwarmMenu.vue')['default']
Tag: typeof import('./components/common/Tag.vue')['default']
Terminal: typeof import('./components/Terminal.vue')['default']
@@ -165,6 +186,8 @@ declare module 'vue' {
ToastModal: typeof import('./components/common/ToastModal.vue')['default']
Toggle: typeof import('./components/common/Toggle.vue')['default']
ViewerWithSource: typeof import('./components/LogViewer/ViewerWithSource.vue')['default']
WebhookDestinationForm: typeof import('./components/Notification/WebhookDestinationForm.vue')['default']
WelcomeModal: typeof import('./components/WelcomeModal.vue')['default']
ZigZag: typeof import('./components/LogViewer/ZigZag.vue')['default']
}
}
+69 -42
View File
@@ -1,11 +1,11 @@
<template>
<div ref="chartContainer" class="flex items-end gap-[2px]" @mousemove="onContainerHover">
<div
v-for="(dataPoint, i) in downsampledData"
v-for="(bar, i) in downsampledBars"
:key="i"
class="bar min-h-px flex-1 rounded-t-sm"
:class="barClass"
:style="{ '--height': `${Math.min(dataPoint, 100)}%` }"
:style="{ '--height': `${maxValue > 0 ? (bar.percent / maxValue) * 100 : 0}%` }"
></div>
</div>
</template>
@@ -19,12 +19,17 @@
</style>
<script setup lang="ts">
export interface BarDataPoint {
percent: number;
value: number;
}
const { chartData, barClass = "" } = defineProps<{
chartData: number[];
chartData: BarDataPoint[];
barClass?: string;
}>();
const hoverIndex = defineEmit<[startIndex: number, endIndex: number]>();
const hoverValue = defineEmit<[value: number]>();
const chartContainer = ref<HTMLElement | null>(null);
const { width } = useElementSize(chartContainer);
@@ -35,74 +40,96 @@ const GAP = 2;
const availableBars = computed(() => Math.floor(width.value / (BAR_WIDTH + GAP)));
const bucketSize = computed(() => Math.ceil(chartData.length / availableBars.value));
const downsampledData = ref<number[]>([]);
const changeCounter = ref(-1);
const downsampledBars = ref<BarDataPoint[]>([]);
const maxValue = computed(() => {
const dataMax = Math.max(0, ...downsampledBars.value.map((b) => b.percent));
return Math.max(dataMax * 1.25, 1);
});
// Full recalculate when width/bucket size changes
watch([availableBars, bucketSize], () => {
recalculate();
changeCounter.value = 0;
});
// Watch chartData changes
// On data changes, only update the last bar unless a new bucket boundary is crossed
const changeCounter = ref(0);
let initialized = false;
watch(
() => chartData,
() => chartData.at(-1),
() => {
// If changeCounter is -1, it means this is the first time the data is loaded
if (changeCounter.value === -1) {
if (!initialized) {
initialized = true;
recalculate();
return;
}
changeCounter.value++;
if (changeCounter.value >= bucketSize.value) {
// Recalculate when counter reaches bucket size
recalculate();
changeCounter.value = 0;
} else {
updateLastBar();
}
},
);
// Recalculate when width changes
watch([availableBars, bucketSize], () => {
recalculate();
changeCounter.value = -1;
});
defineExpose({ recalculate });
function averageBucket(bucket: BarDataPoint[]): BarDataPoint {
const percent = bucket.reduce((sum, d) => sum + d.percent, 0) / bucket.length;
const value = bucket.reduce((sum, d) => sum + d.value, 0) / bucket.length;
return { percent, value };
}
function recalculate() {
if (chartData.length <= availableBars.value || availableBars.value === 0) {
downsampledData.value = [...chartData];
if (availableBars.value === 0) return;
if (chartData.length <= availableBars.value) {
downsampledBars.value = [...chartData];
return;
}
const size = bucketSize.value;
const result = [];
const result: BarDataPoint[] = [];
const numBuckets = Math.ceil(chartData.length / size);
// Create complete buckets
const numCompleteBuckets = Math.floor(chartData.length / size);
for (let i = 0; i < numCompleteBuckets; i++) {
for (let i = 0; i < numBuckets; i++) {
const start = i * size;
const end = start + size;
const bucket = chartData.slice(start, end);
const avg = bucket.reduce((sum, val) => sum + val, 0) / bucket.length;
result.push(avg);
const end = Math.min(start + size, chartData.length);
result.push(averageBucket(chartData.slice(start, end)));
}
// Show only the last N bars that fit on screen
downsampledData.value = result.slice(-availableBars.value);
downsampledBars.value = result.slice(-availableBars.value);
}
function updateLastBar() {
if (downsampledBars.value.length === 0) return;
const size = bucketSize.value;
const lastBucketStart = (Math.ceil(chartData.length / size) - 1) * size;
const bucket = chartData.slice(lastBucketStart);
downsampledBars.value[downsampledBars.value.length - 1] = averageBucket(bucket);
}
function onContainerHover(event: MouseEvent) {
if (!chartContainer.value) return;
const rect = chartContainer.value.getBoundingClientRect();
const x = event.clientX - rect.left;
const bars = chartContainer.value.children;
if (bars.length === 0) return;
// Calculate which bar the mouse is over based on position
const barWidth = width.value / downsampledData.value.length;
const index = Math.floor(x / barWidth);
const mouseX = event.clientX;
let index = 0;
// Ensure index is within bounds
if (index < 0 || index >= downsampledData.value.length) return;
// Find the bar whose column contains the mouse x position
for (let i = 0; i < bars.length; i++) {
const rect = bars[i].getBoundingClientRect();
if (mouseX >= rect.left) {
index = i;
} else {
break;
}
}
// Map downsampled index back to original data index range
const numCompleteBuckets = Math.floor(chartData.length / bucketSize.value);
const offset = Math.max(0, numCompleteBuckets - availableBars.value);
const startIndex = (offset + index) * bucketSize.value;
const endIndex = Math.min(startIndex + bucketSize.value - 1, chartData.length - 1);
hoverIndex(startIndex, endIndex);
hoverValue(downsampledBars.value[index].value);
}
</script>
+158
View File
@@ -0,0 +1,158 @@
<template>
<Dropdown class="dropdown-end" @click="onOpen">
<template #trigger>
<div class="relative">
<mdi:cloud
class="size-6"
:class="
!cloudConfig
? 'text-base-content/40'
: cloudConfig.linked && !cloudStatusError
? 'text-info'
: cloudStatusError === 'unavailable'
? 'text-warning'
: 'text-error'
"
/>
<span
v-if="cloudConfig?.linked"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full"
:class="
cloudStatusError === 'auth'
? 'bg-error'
: cloudStatusError === 'unavailable'
? 'bg-warning'
: cloudStatusError
? 'bg-error'
: 'bg-success'
"
></span>
</div>
</template>
<template #content>
<div class="w-80 space-y-3 p-1">
<!-- Not linked -->
<template v-if="!cloudConfig">
<div class="flex flex-col items-center gap-2 p-2 text-center">
<mdi:cloud class="text-base-content/40 text-4xl" />
<h3 class="text-base font-bold">{{ $t("cloud.title") }}</h3>
<p class="text-base-content/60 text-sm">{{ $t("cloud.description") }}</p>
<div class="mt-2 flex w-full gap-2">
<a :href="`${cloudUrl}`" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
{{ $t("cloud.learn-more") }}
</a>
<a :href="cloudLinkUrl" class="btn btn-primary btn-sm flex-1">
<mdi:link-variant class="text-base" />
{{ $t("cloud.link-instance") }}
</a>
</div>
</div>
</template>
<!-- Linked -->
<template v-else-if="cloudConfig.linked">
<!-- Error state -->
<div v-if="cloudStatusError" class="space-y-3">
<div class="alert" :class="cloudStatusError === 'auth' ? 'alert-error' : 'alert-warning'">
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
<mdi:cloud-off-outline v-else class="text-lg" />
<span class="text-sm">{{
cloudStatusError === "auth" ? $t("cloud.error") : $t("cloud.error-unavailable")
}}</span>
</div>
<a v-if="cloudStatusError === 'auth'" :href="cloudLinkUrl" class="btn btn-primary btn-sm w-full">
<mdi:link-variant class="text-base" />
{{ $t("cloud.relink-instance") }}
</a>
<button v-else class="btn btn-sm w-full" @click="fetchCloudStatus">
<mdi:refresh class="text-base" />
{{ $t("button.retry") }}
</button>
</div>
<!-- Loading -->
<div v-else-if="isLoadingCloudStatus" class="flex items-center justify-center gap-2 py-4">
<span class="loading loading-spinner loading-xs"></span>
</div>
<!-- Healthy -->
<div v-else-if="cloudStatus" class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="font-bold">{{ $t("cloud.title") }}</h3>
<div class="flex items-center gap-1">
<span class="badge badge-success badge-sm">{{ $t("cloud.connected") }}</span>
<span class="badge badge-primary badge-sm capitalize">{{ cloudStatus.plan.name }}</span>
</div>
</div>
<div>
<div class="mb-1 flex items-center justify-between text-sm">
<span class="text-base-content/60">{{ $t("cloud.usage") }}</span>
<span>
{{ cloudStatus.usage.events_used.toLocaleString() }} /
{{ cloudStatus.usage.events_limit.toLocaleString() }}
</span>
</div>
<progress
class="progress w-full"
:class="
usagePercent > 90 ? 'progress-error' : usagePercent > 70 ? 'progress-warning' : 'progress-primary'
"
:value="cloudStatus.usage.events_used"
:max="cloudStatus.usage.events_limit"
></progress>
</div>
<div class="flex gap-2">
<a :href="cloudUrl" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
{{ $t("cloud.dashboard") }}
</a>
<a :href="`${cloudUrl}/settings`" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
{{ $t("cloud.settings") }}
</a>
</div>
</div>
</template>
</div>
</template>
</Dropdown>
<WelcomeModal ref="welcomeModal" />
</template>
<script lang="ts" setup>
const cloudUrl = __CLOUD_URL__;
const callbackUrl = `${window.location.origin}${withBase("/")}`;
const cloudLinkUrl = `${cloudUrl}/link?appUrl=${encodeURIComponent(callbackUrl)}&from=cloud`;
const { cloudConfig, cloudStatus, cloudStatusError, isLoadingCloudStatus, fetchCloudConfig, fetchCloudStatus } =
useCloudConfig();
const welcomeModal = ref<{ open: () => void }>();
const cloudWelcomeShown = useProfileStorage("cloudWelcomeShown", false);
const usagePercent = computed(() => {
if (!cloudStatus.value) return 0;
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
});
function onOpen() {
if (cloudConfig.value?.linked && !cloudStatus.value && !isLoadingCloudStatus.value) {
fetchCloudStatus();
}
}
onMounted(async () => {
await fetchCloudConfig();
if (cloudConfig.value?.linked) {
fetchCloudStatus();
}
// Handle successful OAuth return — show welcome modal
if (window.location.hash === "#cloudLinked" && !cloudWelcomeShown.value) {
cloudWelcomeShown.value = true;
nextTick(() => welcomeModal.value?.open());
history.replaceState(history.state, "", window.location.pathname + window.location.search);
}
});
</script>
+158
View File
@@ -0,0 +1,158 @@
<template>
<div>
<!-- Not linked -->
<template v-if="!cloudConfig">
<div class="flex items-start gap-4">
<mdi:cloud class="text-base-content/40 mt-0.5 text-4xl" />
<div class="flex flex-col gap-1">
<p class="text-base-content/70 text-sm">{{ $t("cloud.description") }}</p>
<div class="mt-3 flex gap-2">
<a :href="`${cloudUrl}`" target="_blank" rel="noreferrer noopener" class="btn btn-sm">
{{ $t("cloud.learn-more") }}
</a>
<a :href="cloudLinkUrl" class="btn btn-primary btn-sm">
<mdi:link-variant class="text-base" />
{{ $t("cloud.link-instance") }}
</a>
</div>
</div>
</div>
</template>
<!-- Linked -->
<template v-else-if="cloudConfig.linked">
<!-- Error state -->
<div v-if="cloudStatusError" class="space-y-3">
<div class="alert" :class="cloudStatusError === 'auth' ? 'alert-error' : 'alert-warning'">
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
<mdi:cloud-off-outline v-else class="text-lg" />
<span class="text-sm">{{
cloudStatusError === "auth" ? $t("cloud.error") : $t("cloud.error-unavailable")
}}</span>
</div>
<div class="flex gap-2">
<a v-if="cloudStatusError === 'auth'" :href="cloudLinkUrl" class="btn btn-primary btn-sm">
<mdi:link-variant class="text-base" />
{{ $t("cloud.relink-instance") }}
</a>
<button v-else class="btn btn-sm" @click="fetchCloudStatus">
<mdi:refresh class="text-base" />
{{ $t("button.retry") }}
</button>
<button class="btn btn-sm btn-error" @click="confirmUnlink">
<mdi:link-variant-off class="text-base" />
{{ $t("cloud.unlink") }}
</button>
</div>
</div>
<!-- Loading -->
<div v-else-if="isLoadingCloudStatus" class="flex items-center gap-2 py-2">
<span class="loading loading-spinner loading-sm"></span>
</div>
<!-- Healthy -->
<div v-else-if="cloudStatus" class="space-y-4">
<div class="flex items-center gap-2">
<span class="badge badge-success">{{ $t("cloud.connected") }}</span>
<span class="badge badge-primary capitalize">{{ cloudStatus.plan.name }}</span>
<span class="text-base-content/50 text-sm">{{ cloudStatus.user.email }}</span>
</div>
<div>
<div class="mb-1 flex items-center justify-between text-sm">
<span class="text-base-content/60">{{ $t("cloud.usage") }}</span>
<span>
{{ cloudStatus.usage.events_used.toLocaleString() }} /
{{ cloudStatus.usage.events_limit.toLocaleString() }}
</span>
</div>
<progress
class="progress w-full max-w-xs"
:class="usagePercent > 90 ? 'progress-error' : usagePercent > 70 ? 'progress-warning' : 'progress-primary'"
:value="cloudStatus.usage.events_used"
:max="cloudStatus.usage.events_limit"
></progress>
</div>
<div class="flex gap-2">
<a :href="cloudUrl" target="_blank" rel="noreferrer noopener" class="btn btn-sm">
{{ $t("cloud.dashboard") }}
</a>
<button class="btn btn-sm btn-error" @click="confirmUnlink">
{{ $t("cloud.unlink") }}
</button>
</div>
</div>
</template>
<!-- Unlink confirmation modal -->
<dialog ref="unlinkModal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">{{ $t("cloud.unlink") }}</h3>
<p class="py-4 text-sm">{{ $t("cloud.unlink-confirm") }}</p>
<div class="modal-action">
<form method="dialog">
<button class="btn btn-sm">{{ $t("button.cancel") }}</button>
</form>
<button class="btn btn-error btn-sm" :disabled="isUnlinking" @click="doUnlink">
<span v-if="isUnlinking" class="loading loading-spinner loading-xs"></span>
{{ $t("cloud.unlink") }}
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button></button>
</form>
</dialog>
</div>
</template>
<script lang="ts" setup>
const cloudUrl = __CLOUD_URL__;
const callbackUrl = `${window.location.origin}${withBase("/")}`;
const cloudLinkUrl = `${cloudUrl}/link?appUrl=${encodeURIComponent(callbackUrl)}&from=cloud`;
const {
cloudConfig,
cloudStatus,
cloudStatusError,
isLoadingCloudStatus,
fetchCloudConfig,
fetchCloudStatus,
clearCloudState,
} = useCloudConfig();
const isUnlinking = ref(false);
const unlinkModal = ref<HTMLDialogElement | null>(null);
const usagePercent = computed(() => {
if (!cloudStatus.value) return 0;
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
});
function confirmUnlink() {
unlinkModal.value?.showModal();
}
async function doUnlink() {
isUnlinking.value = true;
try {
const res = await fetch(withBase("/api/cloud/config"), { method: "DELETE" });
if (!res.ok) {
cloudStatusError.value = "unavailable";
return;
}
clearCloudState();
unlinkModal.value?.close();
} finally {
isUnlinking.value = false;
}
}
onMounted(async () => {
await fetchCloudConfig();
if (cloudConfig.value?.linked) {
fetchCloudStatus();
}
});
</script>
+31 -6
View File
@@ -1,7 +1,12 @@
<template>
<div class="flex flex-row items-center gap-2">
<BarChart class="h-4 flex-1" :chart-data="chartData" :bar-class="barClass" />
<span class="w-fit text-right text-sm">{{ displayValue }}</span>
<template v-if="mode === 'chart'">
<BarChart class="h-4 flex-1" :chart-data="chartData" :bar-class="barClass" />
</template>
<template v-else>
<progress class="progress flex-1" :class="progressClass" :value="averageValue" max="100"></progress>
</template>
<span class="min-w-12 text-right text-sm tabular-nums">{{ displayValue }}</span>
</div>
</template>
@@ -9,15 +14,21 @@
import type { Container } from "@/models/Container";
import type { Host } from "@/stores/hosts";
const { container, type, host } = defineProps<{
const {
container,
type,
host,
mode = "chart",
} = defineProps<{
container: Container;
type: "cpu" | "mem";
host: Host;
mode?: "chart" | "progress";
}>();
function totalCores(): number {
if (container.cpuLimit && container.cpuLimit > 0) {
return 1;
return container.cpuLimit;
}
return host.nCPU ?? 1;
}
@@ -25,9 +36,15 @@ function totalCores(): number {
const chartData = computed(() => {
if (type === "cpu") {
const cores = totalCores();
return container.statsHistory.map((stat) => Math.min(stat.cpu / cores, 100));
return container.statsHistory.map((stat) => {
const percent = Math.min(stat.cpu / cores, 100);
return { percent, value: stat.cpu };
});
}
return container.statsHistory.map((stat) => Math.min(stat.memory, 100));
return container.statsHistory.map((stat) => {
const percent = Math.min(stat.memory, 100);
return { percent, value: stat.memoryUsage };
});
});
const averageValue = computed(() => {
@@ -52,4 +69,12 @@ const barClass = computed(() => {
if (value <= 90) return "bg-warning";
return "bg-error";
});
const progressClass = computed(() => {
const value = averageValue.value;
if (value <= 50) return "progress-success";
if (value <= 70) return "progress-secondary";
if (value <= 90) return "progress-warning";
return "progress-error";
});
</script>
+34 -10
View File
@@ -35,14 +35,32 @@
v-else
/>
</div>
<div class="flex-1 text-right" v-show="containers.length > pageSizes[0]">
{{ $t("label.per-page") }}
<div class="flex flex-1 items-center justify-end gap-2">
<div v-show="containers.length > pageSizes[0]">
{{ $t("label.per-page") }}
<DropdownMenu
class="dropdown-left btn-xs md:btn-sm"
v-model="perPage"
:options="pageSizes.map((i) => ({ label: i.toLocaleString(), value: i }))"
/>
<DropdownMenu
class="dropdown-left btn-xs md:btn-sm"
v-model="perPage"
:options="pageSizes.map((i) => ({ label: i.toLocaleString(), value: i }))"
/>
</div>
<div class="join max-md:hidden">
<button
class="btn join-item btn-xs md:btn-sm"
:class="statMode === 'chart' ? 'btn-active' : 'btn-ghost'"
@click="statMode = 'chart'"
>
<mdi:chart-bar />
</button>
<button
class="btn join-item btn-xs md:btn-sm"
:class="statMode === 'progress' ? 'btn-active' : 'btn-ghost'"
@click="statMode = 'progress'"
>
<mdi:poll class="scale-x-[-1] rotate-90" />
</button>
</div>
</div>
</div>
<div class="rounded-box border-base-content/10 overflow-x-auto border">
@@ -66,7 +84,12 @@
</tr>
</thead>
<tbody class="bg-base-300/30">
<tr v-for="container in paginated" :key="container.id" v-memo="[container.id]" class="hover:bg-base-100/80!">
<tr
v-for="container in paginated"
:key="container.id"
v-memo="[container.id, statMode]"
class="hover:bg-base-100/80!"
>
<td v-if="isVisible('name')" class="max-w-80 truncate">
<router-link :to="{ name: '/container/[id]', params: { id: container.id } }" :title="container.name">
{{ container.name }}
@@ -78,10 +101,10 @@
<RelativeTime :date="container.created" />
</td>
<td v-if="isVisible('cpu')">
<ContainerStatCell :container="container" type="cpu" :host="hosts[container.host]" />
<ContainerStatCell :container="container" type="cpu" :host="hosts[container.host]" :mode="statMode" />
</td>
<td v-if="isVisible('mem')">
<ContainerStatCell :container="container" type="mem" :host="hosts[container.host]" />
<ContainerStatCell :container="container" type="mem" :host="hosts[container.host]" :mode="statMode" />
</td>
</tr>
</tbody>
@@ -167,6 +190,7 @@ const { containers } = defineProps<{
}>();
type keys = keyof typeof fields;
const statMode = useStorage<"chart" | "progress">("DOZZLE_TABLE_STAT_MODE", "chart");
const perPage = useStorage("DOZZLE_TABLE_PAGE_SIZE", 15);
const pageSizes = [15, 30, 50, 100];
@@ -9,24 +9,18 @@
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 shadow-sm"
@click="hideMenu"
>
<li v-if="!historical">
<a @click="clear()">
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
</a>
</li>
<li v-if="enableDownload">
<a :href="downloadUrl" download>
<octicon:download-24 />
{{ isFiltered ? $t("toolbar.download-filtered") : $t("toolbar.download") }}
</a>
</li>
<li v-if="!historical">
<a @click="showSearch = true">
<mdi:magnify /> {{ $t("toolbar.search") }}
<KeyShortcut char="f" />
</a>
</li>
<li v-if="!historical">
<a @click="clear()">
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
</a>
</li>
<li v-if="hasComplexLogs">
<a @click="showDrawer(LogAnalytics, { container }, 'lg')">
<ph:file-sql /> SQL Analytics
@@ -109,6 +103,26 @@
</details>
</li>
<li class="line"></li>
<li v-if="enableDownload">
<a :href="downloadUrl" download>
<octicon:download-24 />
{{ isFiltered ? $t("toolbar.download-filtered") : $t("toolbar.download") }}
</a>
</li>
<li v-if="isSupported">
<a @click="copyLogs()">
<mdi:content-copy />
{{ isFiltered ? $t("toolbar.copy-filtered-logs") : $t("toolbar.copy-logs") }}
</a>
</li>
<li>
<a @click="copyPermalink()">
<material-symbols:link />
{{ $t("toolbar.copy-permalink") }}
</a>
</li>
<!-- Container Actions (Enabled via config) -->
<template v-if="enableActions && !historical">
<li class="line"></li>
@@ -140,6 +154,12 @@
{{ $t("toolbar.restart") }}
</button>
</li>
<li>
<button @click="update()" :disabled="actionStates.update">
<carbon:upgrade />
{{ container.isSwarm ? $t("toolbar.update-service") : $t("toolbar.update") }}
</button>
</li>
</template>
<template v-if="enableShell && !historical">
@@ -176,7 +196,108 @@ const showDrawer = useDrawer();
const { container, historical = false } = defineProps<{ container: Container; historical?: boolean }>();
const clear = defineEmit();
const { actionStates, start, stop, restart } = useContainerActions(toRef(() => container));
const { actionStates, start, stop, restart, update } = useContainerActions(toRef(() => container));
const router = useRouter();
const { copy, copied, isSupported } = useClipboard();
const { t } = useI18n();
const { showToast, removeToast } = useToast();
async function copyPermalink() {
const url = router.resolve({
name: "/show",
query: { name: container.name, host: container.host },
}).href;
const resolved = new URL(url, window.location.origin);
if (!isSupported.value) {
showToast(
{
title: t("error.copy-not-supported-hint"),
message: resolved.href,
type: "info",
},
{ expire: 10000 },
);
return;
}
await copy(resolved.href);
if (copied.value) {
showToast(
{
title: t("toasts.copied.title"),
message: t("toasts.copied.message"),
type: "info",
},
{ expire: 2000 },
);
}
}
async function copyLogs() {
const params = new URLSearchParams();
if (streamConfig.value.stdout) params.append("stdout", "1");
if (streamConfig.value.stderr) params.append("stderr", "1");
params.append("everything", "1");
const { debouncedSearchFilter } = useSearchFilter();
if (debouncedSearchFilter.value) {
params.append("filter", debouncedSearchFilter.value);
}
const selectedLevels = Array.from(levels.value);
if (selectedLevels.length > 0 && selectedLevels.length < allLevels.length) {
selectedLevels.forEach((level) => params.append("levels", level));
}
const url = withBase(`/api/hosts/${container.host}/containers/${container.id}/logs?${params.toString()}`);
const toastId = "copy-logs";
showToast(
{
id: toastId,
title: t("toolbar.copying-logs"),
message: "",
type: "info",
},
{ once: true },
);
const blobPromise = fetch(url, { headers: { Accept: "text/plain" } })
.then((response) => {
if (!response.ok) throw new Error(response.statusText);
return response.blob();
})
.then((blob) => {
removeToast(toastId);
showToast(
{
title: t("toasts.copied.title"),
message: t("toasts.copied.message"),
type: "info",
},
{ expire: 2000 },
);
return blob;
})
.catch((err) => {
removeToast(toastId);
showToast(
{
title: "Error",
message: err.message,
type: "error",
},
{ expire: 5000 },
);
throw err;
});
await navigator.clipboard.write([new ClipboardItem({ "text/plain": blobPromise })]);
}
onKeyStroke("f", (e) => {
if (hasComplexLogs.value) {
+3 -1
View File
@@ -144,7 +144,7 @@ const list = computed(() => {
return items;
});
const { results } = useFuse(query, list, {
const { results: fuseResults } = useFuse(query, list, {
fuseOptions: {
keys: ["name", "host"],
includeScore: true,
@@ -154,6 +154,8 @@ const { results } = useFuse(query, list, {
},
});
const results = computed(() => (query.value ? fuseResults.value : []));
const data = computed(() => {
return [...results.value].sort((a: FuseResult<Item>, b: FuseResult<Item>) => {
if (a.score === b.score) {
+1 -1
View File
@@ -80,7 +80,7 @@ const hostContainers = computed(() =>
function toContainerCores(container: Container): number {
if (container.cpuLimit && container.cpuLimit > 0) {
return 1;
return container.cpuLimit;
}
return props.host.nCPU ?? 1;
}
+21 -1
View File
@@ -79,7 +79,12 @@
</router-link>
</summary>
<ul>
<li v-for="item in containers" :class="item.state" :key="item.id">
<li
v-for="item in containers"
:class="[item.state, { 'highlight-new': item.isNew }]"
:key="item.id"
@animationend="item.isNew = false"
>
<Popup>
<router-link
:to="{ name: '/container/[id]', params: { id: item.id } }"
@@ -88,7 +93,9 @@
:title="item.name"
class="group auto-cols-[content_max_auto_max-content_max-content]"
>
<svg-spinners:ring-resize v-if="item.isNew" class="text-secondary w-2" />
<div
v-else
class="status data-[state=exited]:status-error data-[state=running]:status-success"
:data-state="item.state"
></div>
@@ -248,4 +255,17 @@ li.exited {
li.deleted {
@apply hidden;
}
li.highlight-new {
animation: highlight-fade 3s ease-out;
}
@keyframes highlight-fade {
from {
background-color: oklch(from var(--color-secondary) l c h / 0.25);
}
to {
background-color: transparent;
}
}
</style>
+2
View File
@@ -12,6 +12,8 @@
<mdi:bell class="size-6" />
</router-link>
<CloudPopover />
<router-link
:to="{ name: '/settings' }"
:aria-label="$t('title.settings')"
+1 -1
View File
@@ -36,7 +36,7 @@
</button>
</UseClipboard>
</div>
<div class="bg-base-200 max-h-48 overflow-scroll rounded-sm border border-white/20 p-2">
<div class="bg-base-200 max-h-125 overflow-scroll rounded-sm border border-white/20 p-2">
<pre v-html="syntaxHighlight(entry.rawMessage)"></pre>
</div>
</section>
+4 -2
View File
@@ -9,9 +9,11 @@
<RandomColorTag
v-if="showContainerName"
class="w-30 shrink-0 select-none group-[.compact]:flex-1 md:w-40"
:value="container.name"
:value="container.id"
truncateRight
/>
>
{{ container.name }}
</RandomColorTag>
<LogDate
v-if="showTimestamp"
:date="logEntry.date"
@@ -9,6 +9,7 @@
<span class="tabular-nums">{{ formatBytes(networkRate.rx, { short: true, decimals: 1 }) }}/s</span>
</div>
<StatMonitor
ref="cpuMonitorRef"
:data="cpuData"
:icon="PhCpu"
:stat-value="Math.max(0, totalStat.cpu).toFixed(2) + '%'"
@@ -19,6 +20,7 @@
:formatter="(value: number) => value.toFixed(2) + '%'"
/>
<StatMonitor
ref="memoryMonitorRef"
:data="memoryData"
:icon="PhMemory"
:stat-value="formatBytes(totalStat.memoryUsage)"
@@ -34,6 +36,7 @@
<script lang="ts" setup>
import { Stat } from "@/models/Container";
import { Container } from "@/models/Container";
import StatMonitor from "@/components/LogViewer/StatMonitor.vue";
// @ts-ignore
import PhCpu from "~icons/ph/cpu";
// @ts-ignore
@@ -43,6 +46,8 @@ const { containers } = defineProps<{
containers: Container[];
}>();
const cpuMonitorRef = ref<InstanceType<typeof StatMonitor> | null>(null);
const memoryMonitorRef = ref<InstanceType<typeof StatMonitor> | null>(null);
const totalStat = ref<Stat>({ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 });
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
const { hosts } = useHosts();
@@ -52,7 +57,7 @@ const roundCPU = (num: number) => (Number.isInteger(num) ? num.toFixed(0) : num.
function toContainerCores(container: Container): number {
if (container.cpuLimit && container.cpuLimit > 0) {
return 1;
return container.cpuLimit;
}
const hostInfo = hosts.value[container.host];
return hostInfo?.nCPU ?? 1;
@@ -84,6 +89,10 @@ watch(
}
totalStat.value = initial[0];
reset({ initial: initial.reverse() });
nextTick(() => {
cpuMonitorRef.value?.recalculate();
memoryMonitorRef.value?.recalculate();
});
},
{ immediate: true },
);
@@ -2,7 +2,7 @@
<div class="tag grid overflow-hidden rounded-sm text-center text-sm text-white">
<div class="random-color col-start-1 row-start-1 brightness-75"></div>
<div class="col-start-1 row-start-1 truncate px-2 brightness-100" :class="truncateRight ? '[direction:rtl]' : ''">
{{ value }}
<slot>{{ value }}</slot>
</div>
</div>
</template>
+25 -16
View File
@@ -1,11 +1,20 @@
<template>
<div class="relative" @mouseenter="mouseOver = true" @mouseleave="mouseOver = false" :class="textClass">
<div
class="relative"
@mouseenter="mouseOver = true"
@mouseleave="
mouseOver = false;
hoveredValue = null;
"
:class="textClass"
>
<div class="overflow-hidden rounded-xs border px-px pt-1 pb-px max-md:hidden" :class="containerClass">
<BarChart
ref="barChartRef"
:chart-data="chartData"
:bar-class="`${barClass} opacity-70 hover:opacity-100`"
class="h-8 w-44"
@hover-index="(startIndex: number, endIndex: number) => onHoverIndexChange(startIndex, endIndex)"
@hover-value="(value: number) => (hoveredValue = value)"
/>
</div>
<div class="bg-base-200 flex gap-1 rounded-sm p-px text-xs md:absolute md:-top-2 md:-left-0.5">
@@ -20,6 +29,7 @@
<script lang="ts" setup>
import type { Component } from "vue";
import BarChart from "@/components/BarChart.vue";
const {
data,
@@ -31,7 +41,7 @@ const {
barClass = "bg-primary",
formatter,
} = defineProps<{
data: Point<unknown>[];
data: Point<number>[];
icon: Component;
statValue: string | number;
limit?: string | number;
@@ -41,25 +51,24 @@ const {
formatter?: (value: number) => string;
}>();
const chartData = computed(() => data.map((point) => (point.y as number) ?? 0));
const chartData = computed(() =>
data.map((point) => ({
percent: point.y ?? 0,
value: point.value ?? point.y ?? 0,
})),
);
const barChartRef = ref<InstanceType<typeof BarChart> | null>(null);
const mouseOver = ref(false);
const hoveredRange = ref<{ start: number; end: number } | null>(null);
const hoveredValue = ref<number | null>(null);
function onHoverIndexChange(startIndex: number, endIndex: number) {
hoveredRange.value = { start: startIndex, end: endIndex };
}
defineExpose({ recalculate: () => barChartRef.value?.recalculate() });
const displayValue = computed(() => {
if (mouseOver.value && hoveredRange.value !== null) {
const { start, end } = hoveredRange.value;
const points = data.slice(start, end + 1);
const sum = points.reduce((acc, point) => acc + ((point.value as number) ?? (point.y as number) ?? 0), 0);
const avg = sum / points.length;
if (mouseOver.value && hoveredValue.value !== null) {
if (formatter) {
return formatter(avg);
return formatter(hoveredValue.value);
}
return avg.toFixed(2);
return hoveredValue.value.toFixed(2);
}
return statValue;
});
+3 -9
View File
@@ -8,17 +8,13 @@
<div class="text-base-content/60 mb-1 text-xs tabular-nums max-md:hidden">
avg {{ formatValue(average) }} pk {{ formatValue(peak) }}
</div>
<BarChart class="h-8" :chartData="percentData" :barClass="barClass" />
<BarChart class="h-8" :chart-data="chartData" :bar-class="barClass" />
</div>
</template>
<script setup lang="ts">
import type { Component } from "vue";
export interface MetricDataPoint {
percent: number; // value 0 - 100
value: number;
}
import type { BarDataPoint } from "@/components/BarChart.vue";
const {
label,
@@ -33,15 +29,13 @@ const {
label: string;
icon: Component;
value: string | number;
chartData: MetricDataPoint[];
chartData: BarDataPoint[];
containerClass?: string;
textClass?: string;
barClass?: string;
formatValue?: (value: number) => string;
}>();
const percentData = computed(() => chartData.map((d) => d.percent));
const peak = computed(() => (chartData.length > 0 ? Math.max(...chartData.map((d) => d.value)) : 0));
const average = computed(() => {
+71 -14
View File
@@ -5,18 +5,42 @@
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<h4 class="flex items-center gap-2 text-lg font-semibold">
<mdi:chart-line v-if="alert.metricExpression" class="text-info" />
<mdi:bell-ring-outline v-else-if="alert.eventExpression" class="text-info" />
<mdi:text-box-outline v-else class="text-info" />
<span>{{ alert.name }}</span> <span class="text-sm font-light"></span>
<span class="flex gap-1 text-xs font-light" :class="{ 'text-warning': !alert.dispatcher }">
<template v-if="alert.dispatcher">
<mdi:webhook v-if="alert.dispatcher.type === 'webhook'" />
<mdi:cloud v-else />
{{ alert.dispatcher.name }}
</template>
<template v-else>
<mdi:alert-outline />
{{ $t("notifications.alert.dispatcher-deleted") }}
</template>
</span>
<div class="group/dispatch dropdown dropdown-hover">
<div
tabindex="0"
role="button"
class="border-base-content/0 hover:border-base-content/20 flex cursor-pointer items-center gap-1 rounded border px-1.5 py-0.5 text-xs font-light transition-colors"
:class="{ 'text-warning': !alert.dispatcher }"
>
<template v-if="alert.dispatcher">
<mdi:webhook v-if="alert.dispatcher.type === 'webhook'" />
<mdi:cloud v-else />
{{ alert.dispatcher.name }}
</template>
<template v-else>
<mdi:alert-outline />
{{ $t("notifications.alert.dispatcher-deleted") }}
</template>
<mdi:chevron-down class="text-[0.6rem] opacity-0 transition-opacity group-hover/dispatch:opacity-100" />
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-50 w-48 p-2 shadow-lg">
<li v-for="dest in dispatchers" :key="dest.id">
<a
class="flex items-center gap-2"
:class="{ active: dest.id === alert.dispatcher?.id }"
@click="changeDispatcher(dest.id)"
>
<mdi:webhook v-if="dest.type === 'webhook'" />
<mdi:cloud v-else />
{{ dest.name }}
</a>
</li>
</ul>
</div>
</h4>
<span v-if="!alert.enabled" class="badge badge-warning badge-sm">{{ $t("notifications.alert.paused") }}</span>
</div>
@@ -27,8 +51,26 @@
<div class="text-base-content/80 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
<span>{{ $t("notifications.alert.containers") }}</span>
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.containerExpression }}</code>
<span>{{ $t("notifications.alert.log-filter") }}</span>
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.logExpression }}</code>
<template v-if="alert.metricExpression">
<span>{{ $t("notifications.alert.metric-filter") }}</span>
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.metricExpression }}</code>
<span>{{ $t("notifications.alert.sample-window") }}</span>
<span>{{ formatDuration(alert.sampleWindow || 15, locale || undefined) }}</span>
<span>{{ $t("notifications.alert.cooldown") }}</span>
<span>{{ formatDuration(alert.cooldown || 300, locale || undefined) }}</span>
</template>
<template v-else-if="alert.eventExpression">
<span>{{ $t("notifications.alert.event-filter") }}</span>
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.eventExpression }}</code>
<template v-if="alert.cooldown">
<span>{{ $t("notifications.alert.cooldown") }}</span>
<span>{{ formatDuration(alert.cooldown, locale || undefined) }}</span>
</template>
</template>
<template v-else>
<span>{{ $t("notifications.alert.log-filter") }}</span>
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.logExpression }}</code>
</template>
</div>
<!-- Footer -->
@@ -59,7 +101,7 @@
</template>
<script lang="ts" setup>
import type { NotificationRule } from "@/types/notifications";
import type { Dispatcher, NotificationRule } from "@/types/notifications";
import AlertForm from "./AlertForm.vue";
const { alert, onUpdated } = defineProps<{
@@ -69,6 +111,21 @@ const { alert, onUpdated } = defineProps<{
const showDrawer = useDrawer();
const isDeleting = ref(false);
const dispatchers = ref<Dispatcher[]>([]);
onMounted(async () => {
const res = await fetch(withBase("/api/notifications/dispatchers"));
dispatchers.value = await res.json();
});
async function changeDispatcher(id: number) {
await fetch(withBase(`/api/notifications/rules/${alert.id}`), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dispatcherId: id }),
});
onUpdated?.();
}
function formatTimeAgo(dateStr: string): string {
const date = new Date(dateStr);
+105 -215
View File
@@ -21,11 +21,42 @@
/>
</fieldset>
<!-- Alert Type Toggle -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.alert-type") }}</legend>
<div class="flex gap-2">
<button
class="btn btn-sm"
:class="alertType === 'log' ? 'btn-primary' : 'btn-outline'"
@click="alertType = 'log'"
>
<mdi:text-box-outline class="mr-1" />
{{ $t("notifications.alert-form.log-alert") }}
</button>
<button
class="btn btn-sm"
:class="alertType === 'metric' ? 'btn-primary' : 'btn-outline'"
@click="alertType = 'metric'"
>
<mdi:chart-line class="mr-1" />
{{ $t("notifications.alert-form.metric-alert") }}
</button>
<button
class="btn btn-sm"
:class="alertType === 'event' ? 'btn-primary' : 'btn-outline'"
@click="alertType = 'event'"
>
<mdi:bell-ring-outline class="mr-1" />
{{ $t("notifications.alert-form.event-alert") }}
</button>
</div>
</fieldset>
<!-- Container Filter -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.container-filter") }}</legend>
<div
class="input focus-within:input-primary w-full focus-within:z-50"
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
:class="
containerExpression.trim() && !containerResult?.error
? 'input-primary'
@@ -52,27 +83,36 @@
</div>
</fieldset>
<!-- Log Filter -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.log-filter") }}</legend>
<div
class="input focus-within:input-primary w-full focus-within:z-50"
:class="logExpression.trim() && !logError ? 'input-primary' : { 'input-error!': logError }"
>
<div ref="logEditorRef" class="w-full"></div>
</div>
<div v-if="logError || logExpression" class="fieldset-label">
<span v-if="logError" class="text-error">{{ logError }}</span>
<span v-else-if="logMessages.length" class="text-success">
<mdi:check class="inline" />
{{ $t("notifications.alert-form.logs-match", { count: logTotalCount }) }}
</span>
<span v-else-if="!isLoading" class="text-warning">
<mdi:alert class="inline" />
{{ $t("notifications.alert-form.no-logs-match") }}
</span>
</div>
</fieldset>
<!-- Type-specific fields -->
<KeepAlive>
<LogAlertFields
v-if="alertType === 'log'"
ref="fieldsRef"
:alert="alert"
:prefill="prefill"
:container-expression="containerExpression"
:is-loading="isLoading"
:validate-preview="validatePreview"
/>
<MetricAlertFields
v-else-if="alertType === 'metric'"
ref="fieldsRef"
:alert="alert"
:prefill="prefill"
:container-expression="containerExpression"
:is-loading="isLoading"
:validate-preview="validatePreview"
/>
<EventAlertFields
v-else
ref="fieldsRef"
:alert="alert"
:prefill="prefill"
:container-expression="containerExpression"
:is-loading="isLoading"
:validate-preview="validatePreview"
/>
</KeepAlive>
<!-- Destination -->
<fieldset class="fieldset">
@@ -113,16 +153,6 @@
</div>
</fieldset>
<!-- Log Preview -->
<div v-if="logMessages.length" class="mt-4">
<div class="mb-2 text-lg">{{ $t("notifications.alert-form.preview") }}</div>
<LogList
:messages="logMessages"
:last-selected-item="undefined"
class="border-base-content/50 h-64 overflow-hidden rounded-lg border"
/>
</div>
<!-- Error -->
<div v-if="saveError" class="alert alert-error">
<span>{{ saveError }}</span>
@@ -131,7 +161,7 @@
<!-- Actions -->
<div class="flex justify-end gap-2 pt-4">
<button class="btn" @click="close?.()">{{ $t("notifications.alert-form.cancel") }}</button>
<button class="btn btn-primary" :disabled="!canSave" @click="saveAlert">
<button class="btn btn-primary" :disabled="!canSave" @click="save">
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
{{ isEditing ? $t("notifications.alert-form.save") : $t("notifications.alert-form.create") }}
</button>
@@ -140,204 +170,64 @@
</template>
<script lang="ts" setup>
import { type LogEvent, type LogEntry, type LogMessage, asLogEntry } from "@/models/LogEntry";
import { Container } from "@/models/Container";
import type { ContainerJson } from "@/types/Container";
import { createExprEditor, createContainerHints, createLogHints } from "@/composable/exprEditor";
import { useAlertForm } from "@/composable/alertForm";
import LogAlertFields from "./LogAlertFields.vue";
import MetricAlertFields from "./MetricAlertFields.vue";
import EventAlertFields from "./EventAlertFields.vue";
import type { NotificationRule } from "@/types/notifications";
import type { Dispatcher, NotificationRule, PreviewResult } from "@/types/notifications";
const { close, onCreated, alert, prefill } = defineProps<{
const props = defineProps<{
close?: () => void;
onCreated?: () => void;
alert?: NotificationRule;
prefill?: { name?: string; containerExpression?: string; logExpression?: string };
prefill?: {
name?: string;
containerExpression?: string;
logExpression?: string;
metricExpression?: string;
eventExpression?: string;
dispatcherId?: number;
};
}>();
// Fetch dispatchers
const destinations = ref<Dispatcher[]>([]);
onMounted(async () => {
const res = await fetch(withBase("/api/notifications/dispatchers"));
destinations.value = await res.json();
});
// Container store for autocomplete hints
const containerStore = useContainerStore();
const { containers } = storeToRefs(containerStore);
const containerNames = computed(() => [
...new Set(containers.value.filter((c) => c.state === "running").map((c) => c.name)),
]);
const imageNames = computed(() => [...new Set(containers.value.map((c) => c.image))]);
const hostNames = computed(() => [...new Set(containers.value.map((c) => c.host))]);
const {
isEditing,
alertName,
containerExpression,
dispatcherId,
destinations,
selectedDestination,
containerResult,
isLoading,
isSaving,
saveError,
baseCanSave,
setupContainerEditor,
saveAlert,
validatePreview,
} = useAlertForm(props);
// Template refs
const alertNameInput = ref<HTMLInputElement>();
const containerEditorRef = ref<HTMLElement>();
const logEditorRef = ref<HTMLElement>();
const destinationDropdown = ref<HTMLDetailsElement>();
// Form state
const isEditing = computed(() => !!alert);
const alertName = ref(alert?.name ?? prefill?.name ?? "");
const containerExpression = ref(alert?.containerExpression ?? prefill?.containerExpression ?? "");
const logExpression = ref(alert?.logExpression ?? prefill?.logExpression ?? "");
const dispatcherId = ref(alert?.dispatcher?.id ?? 0);
const selectedDestination = computed(() => destinations.value.find((d) => d.id === dispatcherId.value));
const fieldsRef = ref<
InstanceType<typeof LogAlertFields> | InstanceType<typeof MetricAlertFields> | InstanceType<typeof EventAlertFields>
>();
useFocus(alertNameInput, { initialValue: true });
// Validation state
interface ContainerResult {
error?: string;
containers?: Container[];
}
const containerResult = ref<ContainerResult | null>(null);
const logError = ref<string | null>(null);
const logTotalCount = ref(0);
const logMessages = shallowRef<LogEntry<LogMessage>[]>([]);
const isLoading = ref(false);
const isSaving = ref(false);
const saveError = ref<string | null>(null);
const canSave = computed(
() =>
alertName.value.trim() &&
containerExpression.value.trim() &&
dispatcherId.value > 0 &&
!containerResult.value?.error &&
!logError.value &&
!isSaving.value,
// Alert type
const alertType = ref<"log" | "metric" | "event">(
props.alert?.metricExpression ? "metric" : props.alert?.eventExpression ? "event" : "log",
);
async function saveAlert() {
if (!canSave.value) return;
const canSave = computed(() => baseCanSave.value && (fieldsRef.value?.canSave ?? false));
isSaving.value = true;
saveError.value = null;
try {
const input = {
name: alertName.value.trim(),
containerExpression: containerExpression.value,
logExpression: logExpression.value,
dispatcherId: dispatcherId.value!,
enabled: true,
};
const url = isEditing.value
? withBase(`/api/notifications/rules/${alert!.id}`)
: withBase("/api/notifications/rules");
const res = await fetch(url, {
method: isEditing.value ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to save alert");
}
onCreated?.();
close?.();
} catch (e) {
saveError.value = e instanceof Error ? e.message : "Failed to save alert";
} finally {
isSaving.value = false;
}
async function save() {
if (!canSave.value || !fieldsRef.value) return;
await saveAlert(fieldsRef.value.typeFields);
}
async function validateExpressions() {
if (!containerExpression.value && !logExpression.value) {
containerResult.value = null;
logError.value = null;
logTotalCount.value = 0;
logMessages.value = [];
return;
}
isLoading.value = true;
try {
const res = await fetch(withBase("/api/notifications/preview"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
containerExpression: containerExpression.value,
logExpression: logExpression.value || undefined,
}),
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || "Preview failed");
}
const data: PreviewResult = await res.json();
// Update container result
containerResult.value = containerExpression.value
? {
error: data.containerError ?? undefined,
containers: data.matchedContainers?.map((c) => Container.fromJSON(c as ContainerJson)),
}
: null;
// Update log result
if (logExpression.value && !data.containerError) {
logError.value = data.logError ?? null;
logTotalCount.value = data.totalLogs;
logMessages.value = data.matchedLogs?.map((event) => asLogEntry(event as LogEvent)) ?? [];
} else {
logError.value = null;
logTotalCount.value = 0;
logMessages.value = [];
}
} catch (e) {
containerResult.value = { error: e instanceof Error ? e.message : "Unknown error" };
} finally {
isLoading.value = false;
}
}
const debouncedValidate = useDebounceFn(validateExpressions, 500);
watch(
[containerExpression, logExpression],
() => {
isLoading.value = true;
debouncedValidate();
},
{ immediate: true },
);
let containerEditorView: Awaited<ReturnType<typeof createExprEditor>> | undefined;
let logEditorView: Awaited<ReturnType<typeof createExprEditor>> | undefined;
onMounted(async () => {
if (containerEditorRef.value) {
containerEditorView = await createExprEditor({
parent: containerEditorRef.value,
placeholder: 'name contains "api"',
initialValue: alert?.containerExpression ?? prefill?.containerExpression ?? "",
getHints: () => createContainerHints(containerNames.value, imageNames.value, hostNames.value),
onChange: (v) => (containerExpression.value = v),
});
}
if (logEditorRef.value) {
logEditorView = await createExprEditor({
parent: logEditorRef.value,
placeholder: 'level == "error" && message contains "timeout"',
initialValue: alert?.logExpression ?? prefill?.logExpression ?? "",
getHints: createLogHints,
onChange: (v) => (logExpression.value = v),
});
}
});
onScopeDispose(() => {
containerEditorView?.destroy();
logEditorView?.destroy();
});
// Container editor
setupContainerEditor(containerEditorRef);
</script>
@@ -0,0 +1,128 @@
<template>
<div class="space-y-4">
<!-- Cloud linked (when editing with prefix) -->
<fieldset v-if="destination?.prefix" class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.api-key") }}</legend>
<div class="join w-full">
<input
type="text"
:value="destination.prefix + '**************************************'"
readonly
disabled
class="input join-item w-full font-mono"
:class="
cloudStatusError === 'auth'
? 'input-error'
: cloudStatusError === 'unavailable'
? 'input-warning'
: 'input-success'
"
/>
<span
class="join-item btn pointer-events-none"
:class="
cloudStatusError === 'auth'
? 'btn-error'
: cloudStatusError === 'unavailable'
? 'btn-warning'
: 'btn-success'
"
>
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
<mdi:cloud-off-outline v-else-if="cloudStatusError === 'unavailable'" class="text-lg" />
<mdi:check v-else class="text-lg" />
</span>
</div>
<!-- Cloud Status -->
<div v-if="isLoadingCloudStatus" class="mt-3 flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
<span class="text-base-content/60 text-sm">{{ $t("notifications.destination-form.cloud-checking") }}</span>
</div>
<div v-else-if="cloudStatusError" class="mt-3">
<div class="alert" :class="cloudStatusError === 'auth' ? 'alert-error' : 'alert-warning'">
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
<mdi:cloud-off-outline v-else class="text-lg" />
<span>{{
cloudStatusError === "auth"
? $t("notifications.destination-form.cloud-relink")
: $t("notifications.destination-form.cloud-unavailable")
}}</span>
</div>
</div>
<div v-else-if="cloudStatus" class="mt-3 space-y-3">
<div class="flex items-center justify-between text-sm">
<span class="text-base-content/60">{{ $t("notifications.destination-form.cloud-plan") }}</span>
<span class="badge badge-primary badge-sm capitalize">{{ cloudStatus.plan.name }}</span>
</div>
<div>
<div class="mb-1 flex items-center justify-between text-sm">
<span class="text-base-content/60">{{ $t("notifications.destination-form.cloud-usage") }}</span>
<span
>{{ cloudStatus.usage.events_used.toLocaleString() }} /
{{ cloudStatus.usage.events_limit.toLocaleString() }}</span
>
</div>
<progress
class="progress w-full"
:class="usagePercent > 90 ? 'progress-error' : usagePercent > 70 ? 'progress-warning' : 'progress-primary'"
:value="cloudStatus.usage.events_used"
:max="cloudStatus.usage.events_limit"
></progress>
</div>
</div>
<p class="text-base-content/60 mt-2 text-sm">
{{ $t("notifications.destination-form.cloud-settings-hint") }}
<a :href="cloudSettingsUrl" target="_blank" class="link link-primary">
{{ $t("notifications.destination-form.cloud-settings-link") }}
</a>
</p>
</fieldset>
<!-- Link Dozzle Cloud (when creating or not linked) -->
<div v-else class="card card-border border-primary/30 bg-primary/5">
<div class="card-body items-center text-center">
<mdi:cloud-outline class="text-primary text-4xl" />
<h3 class="card-title">{{ $t("notifications.destination-form.link-cloud") }}</h3>
<p class="text-base-content/60 text-sm">{{ $t("notifications.destination-form.cloud-description") }}</p>
<a :href="cloudLinkUrl" class="btn btn-primary btn-lg mt-2">
<mdi:link-variant class="text-lg" />
{{ $t("notifications.destination-form.link-cloud-button") }}
</a>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 pt-4">
<div class="flex-1"></div>
<button class="btn btn-primary" @click="close?.()">
{{ $t("notifications.destination-form.close") }}
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Dispatcher } from "@/types/notifications";
const { destination, close } = defineProps<{
destination?: Dispatcher;
close?: () => void;
}>();
const callbackUrl = `${window.location.origin}${withBase("/")}`;
const cloudLinkUrl = `${__CLOUD_URL__}/link?appUrl=${encodeURIComponent(callbackUrl)}&from=notifications`;
const cloudSettingsUrl = `${__CLOUD_URL__}/settings`;
const { cloudStatus, cloudStatusError, isLoadingCloudStatus, fetchCloudStatus } = useCloudConfig();
const usagePercent = computed(() => {
if (!cloudStatus.value) return 0;
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
});
if (destination?.prefix) {
fetchCloudStatus();
}
</script>
@@ -0,0 +1,16 @@
<template>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.cooldown-label") }}</legend>
<input v-model.number="model" type="range" min="0" max="3600" step="10" class="range range-primary" />
<p class="text-base-content/50 mt-1 text-xs">
<template v-if="model === 0">{{ $t("notifications.alert-form.no-cooldown") }}</template>
<template v-else>{{
$t("notifications.alert-form.cooldown-hint", { duration: formatDuration(model, locale || undefined) })
}}</template>
</p>
</fieldset>
</template>
<script lang="ts" setup>
const model = defineModel<number>({ required: true });
</script>
@@ -27,7 +27,7 @@
<li>
<a @click="editDestination">{{ $t("notifications.destination.edit") }}</a>
</li>
<li>
<li v-if="destination.type !== 'cloud'">
<a class="text-error" @click="deleteDestination">{{ $t("notifications.destination.delete") }}</a>
</li>
</ul>
@@ -2,30 +2,33 @@
<div class="space-y-4 p-4">
<div class="mb-6">
<h2 class="text-2xl font-bold">
{{
isEditing
? $t("notifications.destination-form.edit-title")
: $t("notifications.destination-form.create-title")
}}
<template v-if="type === 'cloud'">
{{ $t("notifications.destination-form.cloud-title") }}
</template>
<template v-else>
{{
isEditing
? $t("notifications.destination-form.edit-title")
: $t("notifications.destination-form.create-title")
}}
</template>
</h2>
<p class="text-base-content/60">{{ $t("notifications.destination-form.description") }}</p>
<p class="text-base-content/60">
<template v-if="type === 'cloud'">
{{ $t("notifications.destination-form.cloud-description") }}
</template>
<template v-else>
{{ $t("notifications.destination-form.description") }}
</template>
</p>
</div>
<!-- Link Success Alert -->
<div v-if="showLinkSuccess" class="alert alert-success">
<mdi:check-circle class="text-lg" />
<div>
<div class="font-semibold">{{ $t("notifications.cloud-link-success.title") }}</div>
<div class="text-sm">{{ $t("notifications.cloud-link-success.message") }}</div>
</div>
</div>
<!-- Type Selection -->
<fieldset class="fieldset">
<!-- Type Selection (only when creating) -->
<fieldset v-if="!isEditing" class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.type") }}</legend>
<div class="space-y-3">
<label
class="card card-border 20 cursor-pointer transition-colors"
class="card card-border cursor-pointer transition-colors"
:class="type === 'webhook' ? 'border-primary bg-primary/10' : ''"
>
<div class="card-body flex-row items-center gap-3 p-4">
@@ -39,26 +42,21 @@
</div>
</label>
<label
class="card card-border border-base-content/20 transition-colors"
class="card card-border cursor-pointer transition-colors"
:class="[
type === 'cloud' ? 'border-primary bg-primary/10' : '',
hasExistingCloudDestination && type !== 'cloud' ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
isCloudLinked ? 'cursor-not-allowed opacity-50' : '',
]"
>
<div class="card-body flex-row items-center gap-3 p-4">
<input
type="radio"
v-model="type"
value="cloud"
class="radio radio-primary"
:disabled="hasExistingCloudDestination && type !== 'cloud'"
/>
<input type="radio" v-model="type" value="cloud" class="radio radio-primary" :disabled="isCloudLinked" />
<div>
<div class="font-semibold">{{ $t("notifications.destination-form.cloud-title") }}</div>
<div class="text-base-content/60 text-sm">
{{ $t("notifications.destination-form.cloud-description") }}
</div>
<div v-if="hasExistingCloudDestination && type !== 'cloud'" class="text-warning mt-1 text-xs">
<div v-if="isCloudLinked" class="text-success mt-1 text-xs">
<mdi:check class="inline" />
{{ $t("notifications.destination-form.cloud-exists") }}
</div>
</div>
@@ -67,298 +65,34 @@
</div>
</fieldset>
<!-- Name (only for webhook type) -->
<fieldset v-if="type === 'webhook'" class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.name") }}</legend>
<input
ref="nameInput"
v-model="name"
type="text"
class="input focus:input-primary w-full text-base"
required
:class="{ 'input-primary': name.trim().length > 0 }"
:placeholder="$t('notifications.destination-form.name-placeholder')"
/>
</fieldset>
<!-- Cloud linked success (when editing cloud with prefix) -->
<fieldset v-if="type === 'cloud' && destination?.prefix" class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.api-key") }}</legend>
<div class="join w-full">
<input
type="text"
:value="destination.prefix + '**************************************'"
readonly
disabled
class="input join-item input-success w-full font-mono"
/>
<span class="join-item btn btn-success pointer-events-none">
<mdi:check class="text-lg" />
</span>
</div>
<p class="text-base-content/60 mt-2 text-sm">
{{ $t("notifications.destination-form.cloud-settings-hint") }}
<a :href="cloudSettingsUrl" target="_blank" class="link link-primary">
{{ $t("notifications.destination-form.cloud-settings-link") }}
</a>
</p>
</fieldset>
<!-- Link Dozzle Cloud (only for cloud type, when creating or not linked) -->
<div v-else-if="type === 'cloud'" class="card card-border border-primary/30 bg-primary/5">
<div class="card-body items-center text-center">
<mdi:cloud-outline class="text-primary text-4xl" />
<h3 class="card-title">{{ $t("notifications.destination-form.link-cloud") }}</h3>
<p class="text-base-content/60 text-sm">{{ $t("notifications.destination-form.cloud-description") }}</p>
<a :href="cloudLinkUrl" class="btn btn-primary btn-lg mt-2">
<mdi:link-variant class="text-lg" />
{{ $t("notifications.destination-form.link-cloud-button") }}
</a>
</div>
</div>
<!-- Webhook URL (only for webhook type) -->
<fieldset v-if="type === 'webhook'" class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.webhook-url") }}</legend>
<input
v-model="webhookUrl"
type="url"
class="input focus:input-primary w-full text-base"
:class="{ 'input-primary': isValidUrl, 'input-error': webhookUrl.trim() && !isValidUrl }"
:placeholder="$t('notifications.destination-form.webhook-url-placeholder')"
/>
</fieldset>
<!-- Payload Format (only for webhook type) -->
<fieldset v-if="type === 'webhook' && !isEditing" class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.payload-format") }}</legend>
<div class="flex flex-wrap gap-2">
<button
v-for="format in ['slack', 'discord', 'ntfy', 'custom'] as const"
:key="format"
type="button"
class="btn btn-sm"
:class="payloadFormat === format ? 'btn-primary' : 'btn-ghost'"
@click="selectPayloadFormat(format)"
>
{{ $t(`notifications.destination-form.format-${format}`) }}
</button>
</div>
</fieldset>
<!-- Template (only for webhook type) -->
<fieldset v-if="type === 'webhook'" class="fieldset">
<legend class="fieldset-legend text-lg">
{{ $t("notifications.destination-form.template") }}
<span class="text-base-content/60 ml-2 text-sm font-normal">{{
$t("notifications.destination-form.template-hint")
}}</span>
</legend>
<div
ref="templateEditorRef"
class="border-base-content/20 focus-within:border-primary min-h-48 w-full overflow-auto rounded-lg border"
></div>
</fieldset>
<!-- Error -->
<div v-if="error" class="alert alert-error">
<span>{{ error }}</span>
</div>
<!-- Test Result -->
<div v-if="testResult" class="alert" :class="testResult.success ? 'alert-success' : 'alert-error'">
<span v-if="testResult.success">
{{ $t("notifications.destination-form.test-success") }}
<span v-if="testResult.statusCode" class="opacity-70">({{ testResult.statusCode }})</span>
</span>
<span v-else>
{{ testResult.error }}
</span>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 pt-4">
<button
v-if="type === 'webhook'"
class="btn"
@click="testDestination"
:disabled="!canTest || !isValidUrl || isTesting"
>
<span v-if="isTesting" class="loading loading-spinner loading-sm"></span>
{{ $t("notifications.destination-form.test") }}
</button>
<div class="flex-1"></div>
<button class="btn" :class="{ 'btn-primary': type === 'cloud' }" @click="close?.()">
{{
type === "cloud" ? $t("notifications.destination-form.close") : $t("notifications.destination-form.cancel")
}}
</button>
<button v-if="type === 'webhook'" class="btn btn-primary" :disabled="!canSave" @click="saveDestination">
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
{{ isEditing ? $t("notifications.destination-form.save") : $t("notifications.destination-form.add") }}
</button>
</div>
<!-- Type-specific form -->
<WebhookDestinationForm
v-if="type === 'webhook'"
:destination="destination"
:close="close"
:on-created="onCreated"
:is-editing="isEditing"
/>
<CloudDestinationForm v-else :destination="destination" :close="close" />
</div>
</template>
<script lang="ts" setup>
import type { Dispatcher, TestWebhookResult } from "@/types/notifications";
import { createTemplateEditor } from "@/composable/templateEditor";
import { PAYLOAD_TEMPLATES, type PayloadFormat } from "./payloadTemplates";
import type { Dispatcher } from "@/types/notifications";
import WebhookDestinationForm from "./WebhookDestinationForm.vue";
import CloudDestinationForm from "./CloudDestinationForm.vue";
const {
close,
onCreated,
destination,
existingDispatchers = [],
showLinkSuccess = false,
} = defineProps<{
const { close, onCreated, destination } = defineProps<{
close?: () => void;
onCreated?: () => void;
destination?: Dispatcher;
existingDispatchers?: Dispatcher[];
showLinkSuccess?: boolean;
}>();
const hasExistingCloudDestination = computed(() => {
// When editing, exclude the current destination from the check
const others = isEditing ? existingDispatchers.filter((d) => d.id !== destination!.id) : existingDispatchers;
return others.some((d) => d.type === "cloud");
});
const isEditing = !!destination;
const nameInput = ref<HTMLInputElement>();
const templateEditorRef = ref<HTMLElement>();
const name = ref(destination?.name ?? "");
useFocus(nameInput, { initialValue: true });
const type = ref<"webhook" | "cloud">((destination?.type as "webhook" | "cloud") ?? "webhook");
const webhookUrl = ref(destination?.url ?? "");
const payloadFormat = ref<PayloadFormat>(isEditing ? "custom" : "slack");
const template = ref(isEditing ? (destination?.template ?? "") : PAYLOAD_TEMPLATES[payloadFormat.value]);
const isTesting = ref(false);
const isSaving = ref(false);
const error = ref<string | null>(null);
const testResult = ref<TestWebhookResult | null>(null);
const callbackUrl = `${window.location.origin}${withBase("/")}`;
const cloudLinkUrl = `${__CLOUD_URL__}/link?appUrl=${encodeURIComponent(callbackUrl)}`;
const cloudSettingsUrl = `${__CLOUD_URL__}/settings`;
const { cloudConfig, fetchCloudConfig } = useCloudConfig();
const isCloudLinked = computed(() => !!cloudConfig.value?.linked);
let templateEditorView: Awaited<ReturnType<typeof createTemplateEditor>> | undefined;
function selectPayloadFormat(format: PayloadFormat) {
payloadFormat.value = format;
template.value = PAYLOAD_TEMPLATES[format];
setEditorContent(template.value);
}
function setEditorContent(value: string) {
if (!templateEditorView) return;
templateEditorView.dispatch({
changes: { from: 0, to: templateEditorView.state.doc.length, insert: value },
});
}
onMounted(async () => {
if (!templateEditorRef.value) return;
templateEditorView = await createTemplateEditor({
parent: templateEditorRef.value,
initialValue: template.value,
onChange: (v) => (template.value = v),
});
});
onScopeDispose(() => {
templateEditorView?.destroy();
});
const canTest = computed(() => {
if (type.value === "webhook") {
return webhookUrl.value.trim().length > 0;
}
return false;
});
const isValidUrl = computed(() => {
try {
new URL(webhookUrl.value.trim());
return true;
} catch {
return false;
}
});
const canSave = computed(() => {
if (isSaving.value) return false;
if (type.value === "cloud") return false;
if (type.value === "webhook") {
if (!name.value.trim()) return false;
if (!isValidUrl.value) return false;
}
return true;
});
async function testDestination() {
if (!canTest.value) return;
isTesting.value = true;
testResult.value = null;
try {
const res = await fetch(withBase("/api/notifications/test-webhook"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: webhookUrl.value.trim(),
template: template.value.trim() || undefined,
}),
});
const data: TestWebhookResult = await res.json();
testResult.value = data;
} catch (e) {
testResult.value = { success: false, error: e instanceof Error ? e.message : "Test failed" };
} finally {
isTesting.value = false;
}
}
async function saveDestination() {
if (!canSave.value) return;
isSaving.value = true;
error.value = null;
try {
const input = {
name: name.value.trim(),
type: type.value,
url: webhookUrl.value.trim(),
template: template.value.trim() || undefined,
};
const url = isEditing
? withBase(`/api/notifications/dispatchers/${destination!.id}`)
: withBase("/api/notifications/dispatchers");
const res = await fetch(url, {
method: isEditing ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to save destination");
}
onCreated?.();
close?.();
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to save destination";
} finally {
isSaving.value = false;
}
}
onMounted(() => fetchCloudConfig());
</script>
@@ -0,0 +1,89 @@
<template>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.event-filter") }}</legend>
<div
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
:class="eventExpression.trim() && !eventError ? 'input-primary' : { 'input-error!': eventError }"
>
<div ref="editorRef" class="w-full"></div>
</div>
<div v-if="eventError || eventExpression" class="fieldset-label">
<span v-if="eventError" class="text-error">{{ eventError }}</span>
<span v-else class="text-success">
<mdi:check class="inline" />
{{ $t("notifications.alert-form.expression-valid") }}
</span>
</div>
<p class="text-base-content/50 mt-1 text-xs">
{{
$t("notifications.alert-form.event-fields-hint", {
fields: "name (start, stop, die, restart, health_status), attributes (exitCode, signal, etc.)",
})
}}
</p>
</fieldset>
<CooldownField v-model="cooldown" />
</template>
<script lang="ts" setup>
import { createEventHints } from "@/composable/exprEditor";
import type { NotificationRule, PreviewResult } from "@/types/notifications";
const props = defineProps<{
alert?: NotificationRule;
prefill?: { eventExpression?: string };
containerExpression: string;
isLoading: boolean;
validatePreview: (extra: Record<string, unknown>) => Promise<{ data: PreviewResult | null }>;
}>();
const eventExpression = ref(props.alert?.eventExpression ?? props.prefill?.eventExpression ?? "");
const eventError = ref<string | null>(null);
const cooldown = ref(props.alert?.cooldown ?? 10);
const canSave = computed(() => !!eventExpression.value.trim() && !eventError.value);
const typeFields = computed(() => ({
eventExpression: eventExpression.value,
logExpression: "",
metricExpression: "",
cooldown: cooldown.value,
sampleWindow: 0,
}));
defineExpose({ canSave, typeFields });
// Validation
async function validate() {
if (!props.containerExpression && !eventExpression.value) {
eventError.value = null;
return;
}
const { data } = await props.validatePreview({
eventExpression: eventExpression.value || undefined,
});
if (data) {
eventError.value = data.eventError ?? null;
}
}
const debouncedValidate = useDebounceFn(validate, 500);
watch(
[() => props.containerExpression, eventExpression],
() => {
debouncedValidate();
},
{ immediate: true },
);
// Editor
const editorRef = ref<HTMLElement>();
useExprEditorField(editorRef, {
placeholder: 'name == "die"',
initialValue: props.alert?.eventExpression ?? props.prefill?.eventExpression ?? "",
getHints: () => createEventHints(),
onChange: (v) => (eventExpression.value = v),
});
</script>
@@ -0,0 +1,103 @@
<template>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.log-filter") }}</legend>
<div
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
:class="logExpression.trim() && !logError ? 'input-primary' : { 'input-error!': logError }"
>
<div ref="editorRef" class="w-full"></div>
</div>
<div v-if="logError || logExpression" class="fieldset-label">
<span v-if="logError" class="text-error">{{ logError }}</span>
<span v-else-if="logMessages.length" class="text-success">
<mdi:check class="inline" />
{{ $t("notifications.alert-form.logs-match", { count: logTotalCount }) }}
</span>
<span v-else-if="!props.isLoading" class="text-warning">
<mdi:alert class="inline" />
{{ $t("notifications.alert-form.no-logs-match") }}
</span>
</div>
</fieldset>
<!-- Log Preview -->
<div v-if="logMessages.length" class="mt-4">
<div class="mb-2 text-lg">{{ $t("notifications.alert-form.preview") }}</div>
<LogList
:messages="logMessages"
:last-selected-item="undefined"
class="border-base-content/50 h-64 overflow-hidden rounded-lg border"
/>
</div>
</template>
<script lang="ts" setup>
import { type LogEvent, type LogEntry, type LogMessage, asLogEntry } from "@/models/LogEntry";
import { createLogHints } from "@/composable/exprEditor";
import type { NotificationRule, PreviewResult } from "@/types/notifications";
const props = defineProps<{
alert?: NotificationRule;
prefill?: { logExpression?: string };
containerExpression: string;
isLoading: boolean;
validatePreview: (extra: Record<string, unknown>) => Promise<{ data: PreviewResult | null }>;
}>();
const logExpression = ref(props.alert?.logExpression ?? props.prefill?.logExpression ?? "");
const logError = ref<string | null>(null);
const logTotalCount = ref(0);
const logMessages = shallowRef<LogEntry<LogMessage>[]>([]);
const messageKeys = ref<string[]>([]);
const canSave = computed(() => !logError.value);
const typeFields = computed(() => ({ logExpression: logExpression.value, metricExpression: "", cooldown: 0 }));
defineExpose({ canSave, typeFields });
// Validation
async function validate() {
if (!props.containerExpression && !logExpression.value) {
logError.value = null;
logTotalCount.value = 0;
logMessages.value = [];
messageKeys.value = [];
return;
}
const { data } = await props.validatePreview({
logExpression: logExpression.value || undefined,
});
if (data) {
messageKeys.value = data.messageKeys ?? [];
if (logExpression.value && !data.containerError) {
logError.value = data.logError ?? null;
logTotalCount.value = data.totalLogs;
logMessages.value = data.matchedLogs?.map((event) => asLogEntry(event as LogEvent)) ?? [];
} else {
logError.value = null;
logTotalCount.value = 0;
logMessages.value = [];
}
}
}
const debouncedValidate = useDebounceFn(validate, 500);
watch(
[() => props.containerExpression, logExpression],
() => {
debouncedValidate();
},
{ immediate: true },
);
// Editor
const editorRef = ref<HTMLElement>();
useExprEditorField(editorRef, {
placeholder: 'level == "error" && message contains "timeout"',
initialValue: props.alert?.logExpression ?? props.prefill?.logExpression ?? "",
getHints: () => createLogHints(messageKeys.value),
onChange: (v) => (logExpression.value = v),
});
</script>
@@ -0,0 +1,101 @@
<template>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.metric-filter") }}</legend>
<div
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
:class="metricExpression.trim() && !metricError ? 'input-primary' : { 'input-error!': metricError }"
>
<div ref="editorRef" class="w-full"></div>
</div>
<div v-if="metricError || metricExpression" class="fieldset-label">
<span v-if="metricError" class="text-error">{{ metricError }}</span>
<span v-else class="text-success">
<mdi:check class="inline" />
{{ $t("notifications.alert-form.expression-valid") }}
</span>
</div>
<p class="text-base-content/50 mt-1 text-xs">
{{
$t("notifications.alert-form.metric-fields-hint", {
fields: "cpu (CPU %), memory (memory %), memoryUsage (bytes)",
})
}}
</p>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.sample-window-label") }}</legend>
<input v-model.number="sampleWindow" type="range" min="15" max="300" step="15" class="range range-primary" />
<p class="text-base-content/50 mt-1 text-xs">
{{
$t("notifications.alert-form.sample-window-hint", {
duration: formatDuration(sampleWindow, locale || undefined),
})
}}
</p>
</fieldset>
<CooldownField v-model="cooldown" />
</template>
<script lang="ts" setup>
import { createMetricHints } from "@/composable/exprEditor";
import type { NotificationRule, PreviewResult } from "@/types/notifications";
const props = defineProps<{
alert?: NotificationRule;
prefill?: { metricExpression?: string };
containerExpression: string;
isLoading: boolean;
validatePreview: (extra: Record<string, unknown>) => Promise<{ data: PreviewResult | null }>;
}>();
const metricExpression = ref(props.alert?.metricExpression ?? props.prefill?.metricExpression ?? "");
const metricError = ref<string | null>(null);
const sampleWindow = ref(props.alert?.sampleWindow ?? 15);
const cooldown = ref(props.alert?.cooldown ?? 300);
const canSave = computed(() => !!metricExpression.value.trim() && !metricError.value);
const typeFields = computed(() => ({
metricExpression: metricExpression.value,
logExpression: "",
cooldown: cooldown.value,
sampleWindow: sampleWindow.value,
}));
defineExpose({ canSave, typeFields });
// Validation
async function validate() {
if (!props.containerExpression && !metricExpression.value) {
metricError.value = null;
return;
}
const { data } = await props.validatePreview({
metricExpression: metricExpression.value || undefined,
});
if (data) {
metricError.value = data.metricError ?? null;
}
}
const debouncedValidate = useDebounceFn(validate, 500);
watch(
[() => props.containerExpression, metricExpression],
() => {
debouncedValidate();
},
{ immediate: true },
);
// Editor
const editorRef = ref<HTMLElement>();
useExprEditorField(editorRef, {
placeholder: "cpu > 80 || memory > 90",
initialValue: props.alert?.metricExpression ?? props.prefill?.metricExpression ?? "",
getHints: () => createMetricHints(),
onChange: (v) => (metricExpression.value = v),
});
</script>
@@ -0,0 +1,277 @@
<template>
<div class="space-y-4">
<!-- Name -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.name") }}</legend>
<input
ref="nameInput"
v-model="name"
type="text"
class="input focus:input-primary w-full text-base"
required
:class="{ 'input-primary': name.trim().length > 0 }"
:placeholder="$t('notifications.destination-form.name-placeholder')"
/>
</fieldset>
<!-- Webhook URL -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.webhook-url") }}</legend>
<input
v-model="webhookUrl"
type="url"
class="input focus:input-primary w-full text-base"
:class="{ 'input-primary': isValidUrl, 'input-error': webhookUrl.trim() && !isValidUrl }"
:placeholder="$t('notifications.destination-form.webhook-url-placeholder')"
/>
</fieldset>
<!-- Payload Format (create mode only) -->
<fieldset v-if="!isEditing" class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.payload-format") }}</legend>
<div class="flex flex-wrap gap-2">
<button
v-for="format in ['slack', 'discord', 'ntfy', 'custom'] as const"
:key="format"
type="button"
class="btn btn-sm"
:class="payloadFormat === format ? 'btn-primary' : 'btn-ghost'"
@click="selectPayloadFormat(format)"
>
{{ $t(`notifications.destination-form.format-${format}`) }}
</button>
</div>
</fieldset>
<!-- Template -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">
{{ $t("notifications.destination-form.template") }}
<span class="text-base-content/60 ml-2 text-sm font-normal">{{
$t("notifications.destination-form.template-hint")
}}</span>
</legend>
<div
ref="templateEditorRef"
class="border-base-content/20 focus-within:border-primary min-h-48 w-full overflow-auto rounded-lg border"
></div>
</fieldset>
<!-- Custom Headers -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">
{{ $t("notifications.destination-form.headers") }}
<span class="text-base-content/60 ml-2 text-sm font-normal">{{
$t("notifications.destination-form.headers-hint")
}}</span>
</legend>
<div class="space-y-2">
<div v-for="(header, index) in headers" :key="header.key" class="flex items-center gap-2">
<input
v-model="header.name"
type="text"
class="input focus:input-primary flex-1 text-base"
:placeholder="$t('notifications.destination-form.header-name')"
/>
<input
v-model="header.value"
type="text"
class="input focus:input-primary flex-1 text-base"
:placeholder="$t('notifications.destination-form.header-value')"
/>
<button type="button" class="btn btn-ghost btn-sm btn-square" @click="headers.splice(index, 1)">
<carbon:close />
</button>
</div>
<button
type="button"
class="btn btn-ghost btn-sm"
@click="headers.push({ name: '', value: '', key: headerKeyCounter++ })"
>
<carbon:add />
{{ $t("notifications.destination-form.add-header") }}
</button>
</div>
</fieldset>
<!-- Error -->
<div v-if="error" class="alert alert-error">
<span>{{ error }}</span>
</div>
<!-- Test Result -->
<div v-if="testResult" class="alert" :class="testResult.success ? 'alert-success' : 'alert-error'">
<span v-if="testResult.success">
{{ $t("notifications.destination-form.test-success") }}
<span v-if="testResult.statusCode" class="opacity-70">({{ testResult.statusCode }})</span>
</span>
<span v-else>
{{ testResult.error }}
</span>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 pt-4">
<button class="btn" @click="testDestination" :disabled="!canTest || !isValidUrl || isTesting">
<span v-if="isTesting" class="loading loading-spinner loading-sm"></span>
{{ $t("notifications.destination-form.test") }}
</button>
<div class="flex-1"></div>
<button class="btn" @click="close?.()">
{{ $t("notifications.destination-form.cancel") }}
</button>
<button class="btn btn-primary" :disabled="!canSave" @click="saveDestination">
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
{{ isEditing ? $t("notifications.destination-form.save") : $t("notifications.destination-form.add") }}
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Dispatcher, TestWebhookResult } from "@/types/notifications";
import { createTemplateEditor } from "@/composable/templateEditor";
import { PAYLOAD_TEMPLATES, type PayloadFormat } from "./payloadTemplates";
const { close, onCreated, destination, isEditing } = defineProps<{
close?: () => void;
onCreated?: () => void;
destination?: Dispatcher;
isEditing: boolean;
}>();
const nameInput = ref<HTMLInputElement>();
const templateEditorRef = ref<HTMLElement>();
const name = ref(destination?.name ?? "");
useFocus(nameInput, { initialValue: true });
const webhookUrl = ref(destination?.url ?? "");
const payloadFormat = ref<PayloadFormat>(isEditing ? "custom" : "slack");
const template = ref(isEditing ? (destination?.template ?? "") : PAYLOAD_TEMPLATES[payloadFormat.value]);
let headerKeyCounter = 0;
const headers = ref<{ name: string; value: string; key: number }[]>(
destination?.headers
? Object.entries(destination.headers).map(([name, value]) => ({ name, value, key: headerKeyCounter++ }))
: [],
);
const isTesting = ref(false);
const isSaving = ref(false);
const error = ref<string | null>(null);
const testResult = ref<TestWebhookResult | null>(null);
let templateEditorView: Awaited<ReturnType<typeof createTemplateEditor>> | undefined;
function selectPayloadFormat(format: PayloadFormat) {
payloadFormat.value = format;
template.value = PAYLOAD_TEMPLATES[format];
setEditorContent(template.value);
}
function setEditorContent(value: string) {
if (!templateEditorView) return;
templateEditorView.dispatch({
changes: { from: 0, to: templateEditorView.state.doc.length, insert: value },
});
}
onMounted(async () => {
if (!templateEditorRef.value) return;
templateEditorView = await createTemplateEditor({
parent: templateEditorRef.value,
initialValue: template.value,
onChange: (v) => (template.value = v),
});
});
onScopeDispose(() => {
templateEditorView?.destroy();
});
function headersToRecord(): Record<string, string> | undefined {
const filtered = headers.value.filter((h) => h.name.trim() && h.value.trim());
if (filtered.length === 0) return undefined;
return Object.fromEntries(filtered.map((h) => [h.name.trim(), h.value.trim()]));
}
const canTest = computed(() => webhookUrl.value.trim().length > 0);
const isValidUrl = computed(() => {
try {
new URL(webhookUrl.value.trim());
return true;
} catch {
return false;
}
});
const canSave = computed(() => {
if (isSaving.value) return false;
if (!name.value.trim()) return false;
if (!isValidUrl.value) return false;
return true;
});
async function testDestination() {
if (!canTest.value) return;
isTesting.value = true;
testResult.value = null;
try {
const res = await fetch(withBase("/api/notifications/test-webhook"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: webhookUrl.value.trim(),
template: template.value.trim() || undefined,
headers: headersToRecord(),
}),
});
const data: TestWebhookResult = await res.json();
testResult.value = data;
} catch (e) {
testResult.value = { success: false, error: e instanceof Error ? e.message : "Test failed" };
} finally {
isTesting.value = false;
}
}
async function saveDestination() {
if (!canSave.value) return;
isSaving.value = true;
error.value = null;
try {
const input = {
name: name.value.trim(),
type: "webhook",
url: webhookUrl.value.trim(),
template: template.value.trim() || undefined,
headers: headersToRecord(),
};
const url = isEditing
? withBase(`/api/notifications/dispatchers/${destination!.id}`)
: withBase("/api/notifications/dispatchers");
const res = await fetch(url, {
method: isEditing ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to save destination");
}
onCreated?.();
close?.();
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to save destination";
} finally {
isSaving.value = false;
}
}
</script>
@@ -9,7 +9,7 @@ export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
type: "section",
text: {
type: "mrkdwn",
text: "*{{ .Container.Name }}*\n{{ .Log.Message }}",
text: "*{{ .Container.Name }}*\n{{ .Detail }}",
},
},
{
@@ -32,7 +32,7 @@ export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
embeds: [
{
title: "{{ .Container.Name }}",
description: "{{ .Log.Message }}",
description: "{{ .Detail }}",
fields: [
{ name: "Host", value: "{{ .Container.HostName }}", inline: true },
{ name: "Image", value: "{{ .Container.Image }}", inline: true },
@@ -47,7 +47,7 @@ export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
{
topic: "dozzle-{{ .Container.HostName }}",
title: "{{ .Container.Name }}",
message: "{{ .Log.Message }}",
message: "{{ .Detail }}",
},
null,
2,
@@ -55,8 +55,7 @@ export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
custom: JSON.stringify(
{
container: "{{ .Container.Name }}",
level: "{{ .Log.Level }}",
message: "{{ .Log.Message }}",
message: "{{ .Detail }}",
},
null,
2,
+1 -1
View File
@@ -6,7 +6,7 @@
>
<slot name="header"></slot>
</header>
<main :data-scrolling="scrollable ? true : undefined" class="snap-y overflow-auto">
<main :data-scrolling="scrollable ? true : undefined" class="min-h-[300px] snap-y overflow-auto">
<div class="invisible relative md:visible" v-show="scrollContext.paused">
<div class="absolute top-4 right-44">
<ScrollProgress
+203
View File
@@ -0,0 +1,203 @@
<template>
<dialog ref="modal" class="modal" @close="onClose">
<div class="modal-box max-w-md p-8">
<!-- Step 1: Feedback -->
<template v-if="step === 'step1'">
<div class="flex flex-col items-center gap-2 text-center">
<mdi:check-circle class="text-success text-4xl" />
<h3 class="text-xl font-bold">{{ $t("cloud.welcome.title") }}</h3>
<p class="text-base-content/60 text-sm">{{ $t("cloud.welcome.subtitle") }}</p>
</div>
<div class="divider"></div>
<p class="mb-3 text-sm font-medium">{{ $t("cloud.welcome.question") }}</p>
<textarea
v-model="intent"
class="textarea textarea-bordered w-full text-sm"
rows="3"
:placeholder="$t('cloud.welcome.placeholder')"
></textarea>
<p class="text-base-content/60 mt-3 mb-2 text-xs">{{ $t("cloud.welcome.or-pick") }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="option in chipOptions"
:key="option.value"
class="btn btn-sm"
:class="selectedOptions.has(option.value) ? 'btn-primary' : 'btn-outline'"
@click="toggleOption(option.value)"
>
{{ option.label }}
</button>
</div>
<button class="btn btn-primary btn-block mt-6" :disabled="submitting" @click="submitFeedback">
<span v-if="submitting" class="loading loading-spinner loading-xs"></span>
{{ $t("cloud.welcome.get-started") }}
</button>
<button class="btn btn-ghost btn-block btn-sm mt-1" :disabled="submitting" @click="skipFeedback">
{{ $t("cloud.welcome.skip") }}
</button>
</template>
<!-- Step 2: Onboarding Checklist -->
<template v-else-if="step === 'step2'">
<h3 class="text-xl font-bold">{{ $t("cloud.welcome.step2-title") }}</h3>
<p class="text-base-content/60 mt-1 text-sm">{{ $t("cloud.welcome.step2-subtitle") }}</p>
<div class="mt-6 space-y-4">
<div v-for="(item, index) in checklistItems" :key="index" class="flex gap-3">
<div
class="flex size-7 shrink-0 items-center justify-center rounded-full text-xs font-bold"
:class="index === 0 ? 'bg-primary text-primary-content' : 'bg-base-200 text-base-content/60'"
>
{{ index + 1 }}
</div>
<div>
<component
:is="item.href ? 'a' : 'p'"
class="text-sm font-semibold"
:class="item.href ? 'link link-hover' : ''"
:href="item.href"
:target="item.href ? '_blank' : undefined"
:rel="item.href ? 'noreferrer noopener' : undefined"
>
{{ item.title }}
</component>
<p class="text-base-content/60 text-xs">{{ item.description }}</p>
</div>
</div>
</div>
<button class="btn btn-primary btn-block mt-6" @click="createFirstAlert">
{{ $t("cloud.welcome.create-alert") }}
</button>
<button class="btn btn-ghost btn-block btn-sm mt-1" @click="close">
{{ $t("cloud.welcome.later") }}
</button>
</template>
</div>
<form method="dialog" class="modal-backdrop">
<button></button>
</form>
</dialog>
</template>
<script lang="ts" setup>
const cloudUrl = __CLOUD_URL__;
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const modal = ref<HTMLDialogElement>();
const step = ref<"step1" | "step2">("step1");
const intent = ref("");
const selectedOptions = ref(new Set<string>());
const submitting = ref(false);
let feedbackSent = false;
const chipOptions = [
{ value: "alerts", label: t("cloud.welcome.chip-alerts") },
{ value: "daily_summary", label: t("cloud.welcome.chip-summary") },
{ value: "multiple_hosts", label: t("cloud.welcome.chip-hosts") },
{ value: "something_else", label: t("cloud.welcome.chip-other") },
];
const checklistItems = computed(() => [
{
title: t("cloud.welcome.checklist-alert-title"),
description: t("cloud.welcome.checklist-alert-desc"),
},
{
title: t("cloud.welcome.checklist-notify-title"),
description: t("cloud.welcome.checklist-notify-desc"),
href: `${cloudUrl}/channels`,
},
{
title: t("cloud.welcome.checklist-agent-title"),
description: t("cloud.welcome.checklist-agent-desc"),
href: `${cloudUrl}/assistant`,
},
]);
function toggleOption(value: string) {
const next = new Set(selectedOptions.value);
if (next.has(value)) {
next.delete(value);
} else {
next.add(value);
}
selectedOptions.value = next;
}
async function postFeedback(skipped: boolean) {
try {
await fetch(withBase("/api/cloud/feedback"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source: "welcome_modal",
intent: skipped ? undefined : intent.value || undefined,
selectedOptions: skipped ? undefined : Array.from(selectedOptions.value),
skipped,
}),
});
} catch {
// Feedback failure should not block the user
}
}
const onNotificationsPage = computed(() => route.path === "/notifications");
async function submitFeedback() {
submitting.value = true;
feedbackSent = true;
await postFeedback(false);
submitting.value = false;
if (onNotificationsPage.value) {
createFirstAlert();
} else {
step.value = "step2";
}
}
async function skipFeedback() {
submitting.value = true;
feedbackSent = true;
await postFeedback(true);
submitting.value = false;
if (onNotificationsPage.value) {
createFirstAlert();
} else {
step.value = "step2";
}
}
function createFirstAlert() {
close();
router.push({ path: "/notifications", query: { action: "create-alert" } });
}
function open() {
step.value = "step1";
intent.value = "";
selectedOptions.value = new Set();
feedbackSent = false;
modal.value?.showModal();
}
function close() {
modal.value?.close();
}
function onClose() {
if (step.value === "step1" && !feedbackSent) {
feedbackSent = true;
postFeedback(true);
}
}
defineExpose({ open });
</script>
+9 -9
View File
@@ -1,7 +1,7 @@
<template>
<div class="toast toast-end whitespace-normal max-md:end-auto max-md:m-0 max-md:max-w-full">
<div class="toast toast-end max-md:toast-center max-md:toast-bottom whitespace-normal max-md:w-full max-md:px-2">
<div
class="alert max-w-xl shadow-sm max-md:rounded-none"
class="alert max-w-xl shadow-sm max-md:w-full max-md:rounded-lg"
v-for="{ toast, options: { timed } } in toasts"
:key="toast.id"
:class="{
@@ -10,14 +10,14 @@
'alert-warning': toast.type === 'warning',
}"
>
<carbon:information class="size-6 shrink-0 stroke-current" v-if="toast.type === 'info'" />
<carbon:warning class="size-6 shrink-0 stroke-current" v-else-if="toast.type === 'error'" />
<carbon:warning class="size-6 shrink-0 stroke-current" v-else-if="toast.type === 'warning'" />
<div>
<h3 class="text-lg font-bold" v-if="toast.title">{{ toast.title }}</h3>
<div v-html="toast.message" class="[&>a]:underline"></div>
<carbon:information class="size-5 shrink-0 stroke-current" v-if="toast.type === 'info'" />
<carbon:warning class="size-5 shrink-0 stroke-current" v-else-if="toast.type === 'error'" />
<carbon:warning class="size-5 shrink-0 stroke-current" v-else-if="toast.type === 'warning'" />
<div class="min-w-0">
<h3 class="text-lg font-bold max-md:text-base" v-if="toast.title">{{ toast.title }}</h3>
<div v-html="toast.message" class="max-md:text-sm [&>a]:underline"></div>
</div>
<div>
<div class="shrink-0">
<TimedButton
v-if="timed"
class="btn-primary btn-sm"
+156
View File
@@ -0,0 +1,156 @@
import { Container } from "@/models/Container";
import type { ContainerJson } from "@/types/Container";
import type { Dispatcher, NotificationRule, PreviewResult } from "@/types/notifications";
import { createContainerHints } from "@/composable/exprEditor";
export interface AlertFormOptions {
close?: () => void;
onCreated?: () => void;
alert?: NotificationRule;
prefill?: {
name?: string;
containerExpression?: string;
logExpression?: string;
metricExpression?: string;
eventExpression?: string;
dispatcherId?: number;
};
}
export interface ContainerResult {
error?: string;
containers?: Container[];
}
export function useAlertForm(options: AlertFormOptions) {
const isEditing = computed(() => !!options.alert);
const alertName = ref(options.alert?.name ?? options.prefill?.name ?? "");
const containerExpression = ref(options.alert?.containerExpression ?? options.prefill?.containerExpression ?? "");
const dispatcherId = ref(options.alert?.dispatcher?.id ?? options.prefill?.dispatcherId ?? -1);
const isSaving = ref(false);
const saveError = ref<string | null>(null);
// Destinations (cloud dispatcher with id=0 is included by the backend when configured)
const destinations = ref<Dispatcher[]>([]);
onMounted(async () => {
const res = await fetch(withBase("/api/notifications/dispatchers"));
destinations.value = await res.json();
});
const selectedDestination = computed(() => destinations.value.find((d) => d.id === dispatcherId.value));
// Container store for autocomplete
const containerStore = useContainerStore();
const { containers } = storeToRefs(containerStore);
const containerNames = computed(() => [
...new Set(containers.value.filter((c) => c.state === "running").map((c) => c.name)),
]);
const imageNames = computed(() => [...new Set(containers.value.map((c) => c.image))]);
const hostNames = computed(() => [...new Set(containers.value.map((c) => c.host))]);
// Container validation
const containerResult = ref<ContainerResult | null>(null);
const isLoading = ref(false);
const baseCanSave = computed(
() =>
alertName.value.trim() &&
containerExpression.value.trim() &&
dispatcherId.value >= 0 &&
!containerResult.value?.error &&
!isSaving.value,
);
function setupContainerEditor(editorRef: Ref<HTMLElement | undefined>) {
useExprEditorField(editorRef, {
placeholder: 'name contains "api"',
initialValue: options.alert?.containerExpression ?? options.prefill?.containerExpression ?? "",
getHints: () => createContainerHints(containerNames.value, imageNames.value, hostNames.value),
onChange: (v) => (containerExpression.value = v),
});
}
async function saveAlert(typeSpecificFields: Record<string, unknown>) {
isSaving.value = true;
saveError.value = null;
try {
const input = {
name: alertName.value.trim(),
containerExpression: containerExpression.value,
dispatcherId: dispatcherId.value,
enabled: true,
...typeSpecificFields,
};
const url = isEditing.value
? withBase(`/api/notifications/rules/${options.alert!.id}`)
: withBase("/api/notifications/rules");
const res = await fetch(url, {
method: isEditing.value ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to save alert");
}
options.onCreated?.();
options.close?.();
} catch (e) {
saveError.value = e instanceof Error ? e.message : "Failed to save alert";
} finally {
isSaving.value = false;
}
}
async function validatePreview(extraFields: Record<string, unknown> = {}) {
if (!containerExpression.value && !Object.values(extraFields).some(Boolean)) {
containerResult.value = null;
return { data: null };
}
isLoading.value = true;
try {
const res = await fetch(withBase("/api/notifications/preview"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
containerExpression: containerExpression.value,
...extraFields,
}),
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || "Preview failed");
}
const data: PreviewResult = await res.json();
containerResult.value = containerExpression.value
? {
error: data.containerError ?? undefined,
containers: data.matchedContainers?.map((c) => Container.fromJSON(c as ContainerJson)),
}
: null;
return { data };
} catch (e) {
containerResult.value = { error: e instanceof Error ? e.message : "Unknown error" };
return { data: null };
} finally {
isLoading.value = false;
}
}
return {
isEditing,
alertName,
containerExpression,
dispatcherId,
destinations,
selectedDestination,
containerResult,
isLoading,
isSaving,
saveError,
baseCanSave,
setupContainerEditor,
saveAlert,
validatePreview,
};
}
+56
View File
@@ -0,0 +1,56 @@
import type { CloudConfig, CloudStatus } from "@/types/notifications";
// Shared state across all component instances
const cloudConfig = ref<CloudConfig | null>(null);
const cloudStatus = ref<CloudStatus | null>(null);
const cloudStatusError = ref<"auth" | "unavailable" | false>(false);
const isLoadingCloudStatus = ref(false);
async function fetchCloudConfig() {
try {
const res = await fetch(withBase("/api/cloud/config"));
if (!res.ok) {
cloudConfig.value = null;
return;
}
cloudConfig.value = await res.json();
} catch {
cloudConfig.value = null;
}
}
async function fetchCloudStatus() {
if (!cloudConfig.value?.linked) return;
isLoadingCloudStatus.value = true;
cloudStatusError.value = false;
try {
const res = await fetch(withBase("/api/cloud/status"));
if (!res.ok) {
cloudStatusError.value = res.status === 401 || res.status === 403 ? "auth" : "unavailable";
return;
}
cloudStatus.value = await res.json();
} catch {
cloudStatusError.value = "unavailable";
} finally {
isLoadingCloudStatus.value = false;
}
}
function clearCloudState() {
cloudConfig.value = null;
cloudStatus.value = null;
cloudStatusError.value = false;
}
export function useCloudConfig() {
return {
cloudConfig,
cloudStatus,
cloudStatusError,
isLoadingCloudStatus,
fetchCloudConfig,
fetchCloudStatus,
clearCloudState,
};
}
+101 -6
View File
@@ -2,25 +2,27 @@ import { Container } from "@/models/Container";
type ContainerActions = "start" | "stop" | "restart";
export const useContainerActions = (container: Ref<Container>) => {
const { showToast } = useToast();
const { showToast, removeToast } = useToast();
const { t } = useI18n();
const actionStates = reactive({
stop: false,
restart: false,
start: false,
update: false,
});
async function actionHandler(action: ContainerActions) {
const actionUrl = `/api/hosts/${container.value.host}/containers/${container.value.id}/actions/${action}`;
const errors = {
404: "container not found",
500: "unable to complete action",
400: "invalid action",
404: t("error.container-not-found"),
500: t("error.unable-to-complete-action"),
400: t("error.invalid-action"),
} as Record<number, string>;
const defaultError = "something went wrong";
const toastTitle = "Action Failed";
const defaultError = t("error.something-went-wrong");
const toastTitle = t("error.action-failed");
actionStates[action] = true;
@@ -37,10 +39,103 @@ export const useContainerActions = (container: Ref<Container>) => {
actionStates[action] = false;
}
async function update() {
const updateUrl = `/api/hosts/${container.value.host}/containers/${container.value.id}/actions/update`;
const toastId = "container-update";
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
actionStates.update = true;
showToast(
{
id: toastId,
title: t("toolbar.update"),
message: t("toolbar.update-pulling"),
type: "info",
},
{ once: true },
);
try {
const response = await fetch(withBase(updateUrl), { method: "POST" });
if (!response.ok) {
removeToast(toastId);
showToast({ type: "error", message: t("error.unable-to-update"), title: t("error.update-failed") });
return;
}
reader = response.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop() ?? "";
for (const chunk of lines) {
const dataLine = chunk.split("\n").find((l) => l.startsWith("data: "));
if (!dataLine) continue;
const data = JSON.parse(dataLine.slice(6));
switch (data.status) {
case "pulling":
break;
case "recreating":
removeToast(toastId);
showToast(
{
id: toastId,
title: t("toolbar.update"),
message: t("toolbar.update-recreating"),
type: "info",
},
{ once: true },
);
break;
case "done":
case "up-to-date":
removeToast(toastId);
showToast(
{
title: t("toolbar.update"),
message: t(`toolbar.update-${data.status}`),
type: "info",
},
{ expire: 3000 },
);
break;
case "error":
removeToast(toastId);
showToast({
type: "error",
message: data.error || t("error.unknown-error"),
title: t("error.update-failed"),
});
break;
}
}
}
} catch (error) {
removeToast(toastId);
showToast({ type: "error", message: t("error.something-went-wrong"), title: t("error.update-failed") });
} finally {
reader?.cancel();
actionStates.update = false;
}
}
return {
actionStates,
start: () => actionHandler("start"),
stop: () => actionHandler("stop"),
restart: () => actionHandler("restart"),
update,
};
};
+18 -112
View File
@@ -14,14 +14,11 @@ import {
} from "@/models/LogEntry";
import { Service, Stack } from "@/models/Stack";
import { Container, GroupedContainers } from "@/models/Container";
import { parseMessage } from "@/composable/loadBetween";
import { useLogLoader } from "@/composable/logLoader";
const { isSearching, debouncedSearchFilter } = useSearchFilter();
function parseMessage(data: string): LogEntry<LogMessage> {
const e = JSON.parse(data) as LogEvent;
return asLogEntry(e);
}
export function useContainerStream(container: Ref<Container>): LogStreamSource {
const url = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs/stream`);
return useLogStream(url, container);
@@ -73,9 +70,23 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
const loading = ref(true);
const error = ref(false);
const { paused: scrollingPaused } = useScrollContext();
const { streamConfig, hasComplexLogs, levels, loadingMore } = useLoggingContext();
const { streamConfig, hasComplexLogs, levels, loadingMore, containers } = useLoggingContext();
let initial = true;
const params = computed(() => {
const params = new URLSearchParams();
if (streamConfig.value.stdout) params.append("stdout", "1");
if (streamConfig.value.stderr) params.append("stderr", "1");
if (isSearching.value) params.append("filter", debouncedSearchFilter.value);
for (const level of levels.value) {
params.append("levels", level);
}
return params;
});
const allContainers = computed(() => (container ? [container.value] : containers.value));
const { loadOlderLogs, loadSkippedLogs } = useLogLoader(messages, allContainers, params, loadingMore);
function flushNow() {
if (messages.value.length + buffer.value.length > config.maxLogs) {
if (scrollingPaused.value === true) {
@@ -105,7 +116,7 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
// sort the buffer the very first time because of multiple logs in parallel
buffer.value.sort((a, b) => a.date.getTime() - b.date.getTime());
if (container) {
if (container || containers.value.length > 0) {
const loadMoreItem = new LoadMoreLogEntry(new Date(), loadOlderLogs);
messages.value = [loadMoreItem];
}
@@ -131,17 +142,6 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
buffer.value = [];
}
const params = computed(() => {
const params = new URLSearchParams();
if (streamConfig.value.stdout) params.append("stdout", "1");
if (streamConfig.value.stderr) params.append("stderr", "1");
if (isSearching.value) params.append("filter", debouncedSearchFilter.value);
for (const level of levels.value) {
params.append("levels", level);
}
return params;
});
const urlWithParams = computed(() => withBase(`${url.value}?${params.value.toString()}`));
function connect({ clear } = { clear: true }) {
@@ -194,51 +194,6 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
watch(urlWithParams, () => connect(), { immediate: true });
async function loadOlderLogs(entry: LoadMoreLogEntry) {
if (!(messages.value[0] instanceof LoadMoreLogEntry)) throw new Error("No loadMoreLogEntry on first item");
if (!container) throw new Error("No container");
const [loader, ...existingLogs] = messages.value;
const to = existingLogs[0].date;
const lastSeenId = existingLogs[0].id;
const last = messages.value[Math.min(messages.value.length - 1, 300)].date;
const delta = to.getTime() - last.getTime();
const from = new Date(to.getTime() + delta);
try {
loadingMore.value = true;
const { logs: newLogs, signal } = await loadBetween(container, params, from, to, {
min: 100,
lastSeenId,
});
if (newLogs && signal.aborted === false) {
messages.value = [loader, ...newLogs, ...existingLogs];
}
} catch (error) {
console.error(error);
} finally {
loadingMore.value = false;
}
}
async function loadSkippedLogs(entry: SkippedLogsEntry) {
if (!container) throw new Error("No container");
const from = entry.firstSkipped.date;
const to = entry.lastSkippedLog.date;
const lastSeenId = entry.lastSkippedLog.id;
try {
loadingMore.value = true;
const { logs, signal } = await loadBetween(container, params, from, to, { lastSeenId });
if (logs && signal.aborted === false) {
messages.value = messages.value.slice(logs.length).flatMap((log) => (log === entry ? logs : [log]));
}
} catch (error) {
console.error(error);
} finally {
loadingMore.value = false;
}
}
onScopeDispose(() => close());
watch(messages, () => {
@@ -254,52 +209,3 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
loading,
};
}
export async function loadBetween(
container: Ref<Container>,
params: Ref<URLSearchParams>,
from: Date,
to: Date,
{
lastSeenId,
startId,
min,
maxStart,
}: { lastSeenId?: number; startId?: number; min?: number; maxStart?: number } = {},
) {
const url = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs`);
const abortController = new AbortController();
const signal = abortController.signal;
const urlWithMoreParams = computed(() => {
const loadMoreParams = new URLSearchParams(params.value);
loadMoreParams.append("from", from.toISOString());
loadMoreParams.append("to", to.toISOString());
if (min) {
loadMoreParams.append("min", String(min));
}
if (maxStart) {
loadMoreParams.append("maxStart", String(maxStart));
}
if (lastSeenId) {
loadMoreParams.append("lastSeenId", String(lastSeenId));
}
if (startId) {
loadMoreParams.append("startId", String(startId));
}
return withBase(`${url.value}?${loadMoreParams.toString()}`);
});
const stopWatcher = watchOnce(urlWithMoreParams, () => abortController.abort("stream changed"));
const logs = await (await fetch(urlWithMoreParams.value, { signal })).text();
stopWatcher();
if (!logs) return { logs: [], signal };
return {
logs: logs
.trim()
.split("\n")
.map((line) => parseMessage(line)),
signal,
};
}
+36 -1
View File
@@ -50,7 +50,7 @@ export function createContainerHints(
];
}
export function createLogHints(): Completion[] {
export function createLogHints(messageKeys?: string[]): Completion[] {
return [
{ label: "message", detail: "log message content", type: "property" },
{ label: "level", detail: "log level", type: "property" },
@@ -58,6 +58,9 @@ export function createLogHints(): Completion[] {
{ label: "type", detail: "log type", type: "property" },
{ label: "timestamp", detail: "unix timestamp", type: "property" },
{ label: "id", detail: "log entry ID", type: "property" },
...(messageKeys ?? []).map(
(key) => ({ label: `message.${key}`, detail: "message field", type: "property" }) as Completion,
),
...exprOperators,
{ label: '"error"', detail: "level value", type: "string" },
{ label: '"warn"', detail: "level value", type: "string" },
@@ -72,6 +75,38 @@ export function createLogHints(): Completion[] {
];
}
export function createMetricHints(): Completion[] {
return [
{ label: "cpu", detail: "CPU usage percent", type: "property" },
{ label: "memory", detail: "memory usage percent", type: "property" },
{ label: "memoryUsage", detail: "memory usage bytes", type: "property" },
...exprOperators,
{ label: ">", detail: "greater than", type: "operator" },
{ label: "<", detail: "less than", type: "operator" },
{ label: ">=", detail: "greater or equal", type: "operator" },
{ label: "<=", detail: "less or equal", type: "operator" },
{ label: "cpu > 80", detail: "CPU over 80%", type: "text", boost: 10 },
{ label: "memory > 90", detail: "memory over 90%", type: "text", boost: 10 },
{ label: "cpu > 80 || memory > 90", detail: "CPU or memory high", type: "text", boost: 10 },
];
}
export function createEventHints(): Completion[] {
return [
{ label: "name", detail: "event name", type: "property" },
{ label: "attributes", detail: "event attributes map", type: "property" },
...exprOperators,
{ label: '"start"', detail: "container started", type: "string" },
{ label: '"stop"', detail: "container stopped", type: "string" },
{ label: '"die"', detail: "container died", type: "string" },
{ label: '"restart"', detail: "container restarted", type: "string" },
{ label: '"health_status"', detail: "health check changed", type: "string" },
{ label: 'name == "die"', detail: "match container death", type: "text", boost: 10 },
{ label: 'name == "health_status"', detail: "match health changes", type: "text", boost: 10 },
{ label: 'name in ["stop", "die"]', detail: "match stop or death", type: "text", boost: 10 },
];
}
function createAutocomplete(getHints: () => Completion[]) {
return (context: any) => {
const word = context.matchBefore(/[\w"=!&|]+/);
+1 -1
View File
@@ -1,7 +1,7 @@
import { HistoricalContainer } from "@/models/Container";
import { LogMessage, LoadMoreLogEntry, LogEntry } from "@/models/LogEntry";
import { ShallowRef } from "vue";
import { loadBetween } from "@/composable/eventStreams";
import { loadBetween } from "@/composable/loadBetween";
export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalContainer>): LogStreamSource {
const messages: ShallowRef<LogEntry<LogMessage>[]> = shallowRef([]);
+60
View File
@@ -0,0 +1,60 @@
import { type Ref } from "vue";
import { type LogEvent, type LogMessage, LogEntry, asLogEntry } from "@/models/LogEntry";
import { Container } from "@/models/Container";
export function parseMessage(data: string): LogEntry<LogMessage> {
const e = JSON.parse(data) as LogEvent;
return asLogEntry(e);
}
export async function loadBetween(
container: Container | Ref<Container>,
params: Ref<URLSearchParams>,
from: Date,
to: Date,
{
lastSeenId,
startId,
min,
maxStart,
}: { lastSeenId?: number; startId?: number; min?: number; maxStart?: number } = {},
) {
const c = toValue(container);
const url = `/api/hosts/${c.host}/containers/${c.id}/logs`;
const abortController = new AbortController();
const signal = abortController.signal;
function buildUrl() {
const loadMoreParams = new URLSearchParams(params.value);
loadMoreParams.append("from", from.toISOString());
loadMoreParams.append("to", to.toISOString());
if (min) {
loadMoreParams.append("min", String(min));
}
if (maxStart) {
loadMoreParams.append("maxStart", String(maxStart));
}
if (lastSeenId) {
loadMoreParams.append("lastSeenId", String(lastSeenId));
}
if (startId) {
loadMoreParams.append("startId", String(startId));
}
return withBase(`${url}?${loadMoreParams.toString()}`);
}
const fullUrl = buildUrl();
const stopWatcher = watchOnce(params, () => abortController.abort("stream changed"));
const logs = await (await fetch(fullUrl, { signal })).text();
stopWatcher();
if (!logs) return { logs: [] as LogEntry<LogMessage>[], signal };
return {
logs: logs
.trim()
.split("\n")
.map((line) => parseMessage(line)),
signal,
};
}
+104
View File
@@ -0,0 +1,104 @@
import { ShallowRef, type Ref } from "vue";
import { type LogMessage, LogEntry, LoadMoreLogEntry, SkippedLogsEntry } from "@/models/LogEntry";
import { Container } from "@/models/Container";
import { loadBetween } from "@/composable/loadBetween";
// Matches the rolling window size used for stats history
const LOG_WINDOW_FOR_DELTA = 300;
export function useLogLoader(
messages: ShallowRef<LogEntry<LogMessage>[]>,
containers: Ref<Container[]>,
params: Ref<URLSearchParams>,
loadingMore: Ref<boolean>,
) {
async function loadOlderLogs(entry: LoadMoreLogEntry) {
if (!(messages.value[0] instanceof LoadMoreLogEntry)) throw new Error("No loadMoreLogEntry on first item");
if (containers.value.length === 0) return;
const [loader, ...existingLogs] = messages.value;
if (existingLogs.length === 0) return;
const containerIDs = new Set(containers.value.map((c) => c.id));
const earliestByContainer = new Map<string, LogEntry<LogMessage>>();
const countByContainer = new Map<string, number>();
const nthByContainer = new Map<string, LogEntry<LogMessage>>();
for (const log of existingLogs) {
const id = log.containerID;
if (!id || !containerIDs.has(id)) continue;
if (!earliestByContainer.has(id)) {
earliestByContainer.set(id, log);
}
const count = (countByContainer.get(id) ?? 0) + 1;
countByContainer.set(id, count);
if (count <= LOG_WINDOW_FOR_DELTA) {
nthByContainer.set(id, log);
}
}
try {
loadingMore.value = true;
const minPerContainer = Math.ceil(100 / containers.value.length);
const results = await Promise.all(
containers.value.map((c) => {
const earliest = earliestByContainer.get(c.id);
const to = earliest?.date ?? existingLogs[0].date;
const nth = nthByContainer.get(c.id);
const delta = to.getTime() - (nth?.date ?? to).getTime();
const from = new Date(to.getTime() + (delta !== 0 ? delta : -60_000));
return loadBetween(c, params, from, to, {
min: minPerContainer,
lastSeenId: earliest?.id,
});
}),
);
const allNewLogs = results
.filter(({ signal }) => !signal.aborted)
.flatMap(({ logs }) => logs)
.sort((a, b) => a.date.getTime() - b.date.getTime());
if (allNewLogs.length > 0) {
messages.value = [loader, ...allNewLogs, ...existingLogs];
}
} catch (err) {
console.error(err);
} finally {
loadingMore.value = false;
}
}
async function loadSkippedLogs(entry: SkippedLogsEntry) {
if (containers.value.length === 0) return;
const from = entry.firstSkipped.date;
const to = entry.lastSkippedLog.date;
const ownerContainerID = entry.lastSkippedLog.containerID;
try {
loadingMore.value = true;
const results = await Promise.all(
containers.value.map((c) => {
const lastSeenId = c.id === ownerContainerID ? entry.lastSkippedLog.id : undefined;
return loadBetween(c, params, from, to, { lastSeenId });
}),
);
const allLogs = results
.filter(({ signal }) => !signal.aborted)
.flatMap(({ logs }) => logs)
.sort((a, b) => a.date.getTime() - b.date.getTime());
if (allLogs.length > 0) {
const updated = messages.value.flatMap((log) => (log === entry ? allLogs : [log]));
messages.value = updated.length > config.maxLogs ? updated.slice(-config.maxLogs) : updated;
}
} catch (err) {
console.error(err);
} finally {
loadingMore.value = false;
}
}
return { loadOlderLogs, loadSkippedLogs };
}
+3 -1
View File
@@ -39,7 +39,7 @@ export function useProfileStorage<K extends keyof Profile>(
}
}
if (config.user) {
if (config.user || config.authProvider === "none") {
watch(
storage,
(value) => {
@@ -56,6 +56,8 @@ export function useProfileStorage<K extends keyof Profile>(
return value;
}
}),
}).catch((e) => {
console.error(`Failed to sync ${key} to profile`, e);
});
},
{ deep: true },
+23
View File
@@ -0,0 +1,23 @@
import { createExprEditor } from "@/composable/exprEditor";
type ExprEditorOptions = Parameters<typeof createExprEditor>[0];
export function useExprEditorField(
editorRef: Ref<HTMLElement | undefined>,
options: Omit<ExprEditorOptions, "parent">,
) {
let editorView: Awaited<ReturnType<typeof createExprEditor>> | undefined;
onMounted(async () => {
if (editorRef.value) {
editorView = await createExprEditor({
parent: editorRef.value,
...options,
});
}
});
onScopeDispose(() => {
editorView?.destroy();
});
}
+1 -1
View File
@@ -8,7 +8,7 @@
--color-red: oklch(64% 0.218 28.85);
--color-purple: oklch(51.49% 0.215 321.03);
--color-blue: oklch(65% 0.171 249.5);
--color-orange: oklch(70% 0.186 48.13);
--color-orange: oklch(85% 0.186 48.13);
}
@utility pt-safe {
+4
View File
@@ -7,3 +7,7 @@ Object.values(import.meta.glob<{ install: (app: VueApp) => void }>("./modules/*.
i.install?.(app),
);
app.mount("#app");
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register(withBase("/sw.js"));
}
+1
View File
@@ -49,6 +49,7 @@ export class Container {
stats: Stat[],
public readonly group?: string,
public health?: ContainerHealth,
public isNew: boolean = false,
) {
const defaultStat = { cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 } as Stat;
this._stat = ref(stats.at(-1) || defaultStat);
+72 -39
View File
@@ -12,16 +12,9 @@
<h3 class="text-base-content/60 mb-4 font-semibold tracking-wide uppercase">
{{ $t("notifications.destinations") }}
</h3>
<div class="flex flex-wrap gap-4">
<DestinationCard
v-for="dest in dispatchers"
:key="dest.id"
:destination="dest"
:on-updated="fetchAll"
:existing-dispatchers="dispatchers"
class="w-full md:w-72"
/>
<!-- Add Destination Card -->
<template v-if="dispatchers.length === 0">
<p class="text-base-content/60 mb-4 text-sm">{{ $t("notifications.empty-state.description") }}</p>
<button
class="card card-border border-base-content/30 hover:border-base-content/50 w-full cursor-pointer border-dashed transition-colors md:w-72"
@click="openAddDestination"
@@ -31,17 +24,36 @@
<span class="text-base-content/60 text-sm">{{ $t("notifications.add-destination") }}</span>
</div>
</button>
</div>
</template>
<template v-else>
<div class="flex flex-wrap gap-4">
<DestinationCard
v-for="dest in dispatchers"
:key="dest.id"
:destination="dest"
:on-updated="fetchAll"
:existing-dispatchers="dispatchers"
class="w-full md:w-72"
/>
<!-- Add Destination Card -->
<button
class="card card-border border-base-content/30 hover:border-base-content/50 w-full cursor-pointer border-dashed transition-colors md:w-72"
@click="openAddDestination"
>
<div class="card-body items-center justify-center gap-1 p-4">
<mdi:plus class="text-2xl" />
<span class="text-base-content/60 text-sm">{{ $t("notifications.add-destination") }}</span>
</div>
</button>
</div>
</template>
</div>
<!-- Alerts Section -->
<div>
<div class="mb-4 flex items-center justify-between">
<div class="mb-4">
<h3 class="text-base-content/60 font-semibold tracking-wide uppercase">{{ $t("notifications.alerts") }}</h3>
<button class="btn btn-primary btn-sm" @click="openCreateAlert">
<mdi:plus />
{{ $t("notifications.add") }}
</button>
</div>
<!-- Filter Tabs -->
@@ -58,11 +70,17 @@
</div>
<!-- Alerts List -->
<div v-if="!alerts.length" class="text-base-content/60 py-4">
{{ $t("notifications.no-alerts") }}
</div>
<div v-else class="space-y-4">
<div class="space-y-4">
<AlertCard v-for="alert in filteredAlerts" :key="alert.id" :alert="alert" :on-updated="fetchAlerts" />
<button
class="card card-border border-base-content/30 hover:border-base-content/50 w-full cursor-pointer border-dashed transition-colors"
@click="openCreateAlert"
>
<div class="card-body items-center justify-center gap-1 p-4">
<mdi:plus class="text-2xl" />
<span class="text-base-content/60 text-sm">{{ $t("notifications.add-alert") }}</span>
</div>
</button>
</div>
</div>
</section>
@@ -73,10 +91,11 @@
import type { NotificationRule, Dispatcher } from "@/types/notifications";
import AlertForm from "@/components/Notification/AlertForm.vue";
import DestinationForm from "@/components/Notification/DestinationForm.vue";
import DestinationCard from "@/components/Notification/DestinationCard.vue";
const { t } = useI18n();
const showDrawer = useDrawer();
const router = useRouter();
const route = useRoute();
// State
const alerts = ref<NotificationRule[]>([]);
@@ -96,30 +115,29 @@ async function fetchAll() {
await Promise.all([fetchAlerts(), fetchDispatchers()]);
}
// Handle cloudLinkSuccess hash param
onMounted(async () => {
await fetchAll();
const hash = window.location.hash;
if (hash.startsWith("#cloudLinkSuccess=")) {
const id = Number(hash.replace("#cloudLinkSuccess=", ""));
if (!isNaN(id)) {
const destination = dispatchers.value.find((d) => d.id === id);
if (destination) {
showDrawer(
DestinationForm,
{
destination,
existingDispatchers: dispatchers.value,
showLinkSuccess: true,
},
"md",
);
}
}
if (hash === "#cloudLinked") {
router.replace({ hash: "" });
}
if (route.query.action === "create-alert") {
router.replace({ query: {} });
openCreateAlertPrefilled();
}
});
watch(
() => route.query.action,
(action) => {
if (action === "create-alert") {
router.replace({ query: {} });
openCreateAlertPrefilled();
}
},
);
// Local state
const filter = ref<"all" | "enabled" | "paused">("all");
@@ -136,12 +154,27 @@ function openCreateAlert() {
showDrawer(AlertForm, { onCreated: fetchAlerts }, "lg");
}
function openCreateAlertPrefilled() {
const cloudDispatcher = dispatchers.value.find((d) => d.type === "cloud");
showDrawer(
AlertForm,
{
onCreated: fetchAlerts,
prefill: {
name: t("notifications.prefill-name"),
logExpression: t("notifications.prefill-expression"),
...(cloudDispatcher ? { dispatcherId: cloudDispatcher.id } : {}),
},
},
"lg",
);
}
function openAddDestination() {
showDrawer(
DestinationForm,
{
onCreated: fetchDispatchers,
existingDispatchers: dispatchers.value,
},
"md",
);
+7
View File
@@ -37,6 +37,13 @@
</div>
</section>
<section>
<div class="has-underline">
<h2>{{ $t("cloud.title") }}</h2>
</div>
<CloudSettingsCard />
</section>
<section class="@container flex flex-col">
<div class="has-underline">
<h2>{{ $t("settings.display") }}</h2>
+10 -6
View File
@@ -3,16 +3,20 @@ const router = useRouter();
const route = useRoute();
const store = useContainerStore();
const { visibleContainers } = storeToRefs(store);
const { containers } = storeToRefs(store);
watch(visibleContainers, (newValue) => {
watch(containers, (newValue) => {
if (newValue) {
if (route.query.name) {
const [container, _] = visibleContainers.value.filter((c) => c.name == route.query.name);
if (container) {
router.push({ name: "/container/[id]", params: { id: container.id } });
const name = route.query.name as string;
const host = route.query.host as string | undefined;
const matches = containers.value
.filter((c) => c.name == name && (!host || c.host == host))
.sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime());
if (matches.length > 0) {
router.push({ name: "/container/[id]", params: { id: matches[0].id } });
} else {
console.error(`No containers found matching name=${route.query.name}. Redirecting to /`);
console.error(`No containers found matching name=${name}${host ? ` host=${host}` : ""}. Redirecting to /`);
router.push({ name: "/" });
}
} else {
+1
View File
@@ -31,6 +31,7 @@ export interface Profile {
visibleKeys?: Map<string, Map<string[], boolean>>;
releaseSeen?: string;
collapsedGroups?: Set<string>;
cloudWelcomeShown?: boolean;
}
const pageConfig = JSON.parse(text);
+12 -1
View File
@@ -135,12 +135,23 @@ export const useContainerStore = defineStore("container", () => {
existingContainers.forEach((c) => {
const existing = allContainersById.value[c.id];
if (ready.value && existing.state !== "running" && c.state === "running") {
existing.isNew = true;
}
existing.state = c.state;
existing.health = c.health;
existing.name = c.name;
});
containers.value = [...containers.value, ...newContainers.map(Container.fromJSON)];
const mapped = newContainers.map((c) => {
const container = Container.fromJSON(c);
if (ready.value) {
container.isNew = true;
}
return container;
});
containers.value = [...containers.value, ...mapped];
};
const currentContainer = (id: Ref<string>) => computed(() => allContainersById.value[id.value]);
+15
View File
@@ -0,0 +1,15 @@
// Node v25+ ships a built-in localStorage that lacks the Web Storage API
// (getItem/setItem/removeItem). Replace it with a spec-compliant shim so
// libraries like @vue/devtools-kit work correctly in tests.
const store = new Map<string, string>();
globalThis.localStorage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, String(value)),
removeItem: (key: string) => store.delete(key),
clear: () => store.clear(),
get length() {
return store.size;
},
key: (index: number) => [...store.keys()][index] ?? null,
} as Storage;
+2 -1
View File
@@ -17,7 +17,8 @@ import type {
declare module 'vue-router' {
interface TypesConfig {
ParamParsers: never
ParamParsers:
| never
}
}
+24
View File
@@ -4,6 +4,10 @@ export interface NotificationRule {
enabled: boolean;
containerExpression: string;
logExpression: string;
metricExpression?: string;
eventExpression?: string;
cooldown?: number;
sampleWindow?: number;
triggerCount: number;
triggeredContainers: number;
lastTriggeredAt: string | null;
@@ -16,6 +20,7 @@ export interface Dispatcher {
type: string;
url?: string;
template?: string;
headers?: Record<string, string>;
prefix?: string;
expiresAt?: string;
}
@@ -26,11 +31,17 @@ export interface NotificationRuleInput {
dispatcherId: number;
logExpression: string;
containerExpression: string;
metricExpression?: string;
eventExpression?: string;
cooldown?: number;
sampleWindow?: number;
}
export interface PreviewResult {
containerError?: string;
logError?: string;
metricError?: string;
eventError?: string;
matchedContainers: {
id: string;
name: string;
@@ -47,6 +58,7 @@ export interface PreviewResult {
s: string;
}[];
totalLogs: number;
messageKeys?: string[];
}
export interface TestWebhookResult {
@@ -54,3 +66,15 @@ export interface TestWebhookResult {
statusCode?: number;
error?: string;
}
export interface CloudConfig {
prefix: string;
expiresAt?: string;
linked: boolean;
}
export interface CloudStatus {
user: { email: string; name: string };
plan: { name: string; events_per_month: number; retention_days: number };
usage: { events_used: number; events_limit: number; period: string };
}
+38
View File
@@ -0,0 +1,38 @@
export function formatDuration(seconds: number, locale: string | undefined): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
const duration = { hours, minutes, seconds: secs };
if (typeof Intl !== "undefined" && "DurationFormat" in Intl) {
return new Intl.DurationFormat(locale, { style: "narrow" }).format(duration);
}
if (hours > 0) return `${hours}h ${minutes ? `${minutes}m` : ""}`.trim();
if (minutes > 0) return `${minutes}m ${secs ? `${secs}s` : ""}`.trim();
return `${secs}s`;
}
const units: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 31536000],
["month", 2592000],
["week", 604800],
["day", 86400],
["hour", 3600],
["minute", 60],
["second", 1],
];
export function toRelativeTime(date: Date, locale: string | undefined): string {
const diffInSeconds = (date.getTime() - new Date().getTime()) / 1000;
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
for (const [unit, seconds] of units) {
const value = Math.round(diffInSeconds / seconds);
if (Math.abs(value) >= 1) {
return rtf.format(value, unit);
}
}
return rtf.format(0, "second");
}
+31
View File
@@ -0,0 +1,31 @@
export function formatBytes(
bytes: number,
{ decimals = 2, short = false }: { decimals?: number; short?: boolean } = { decimals: 2, short: false },
) {
if (bytes === 0) return short ? "0B" : "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
if (short) {
return value + sizes[i].charAt(0);
} else {
return value + " " + sizes[i];
}
}
export function stripVersion(label: string) {
const [name, _] = label.split(":");
return name;
}
export function hashCode(str: string) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return hash;
}
+4 -135
View File
@@ -1,135 +1,4 @@
export function formatBytes(
bytes: number,
{ decimals = 2, short = false }: { decimals?: number; short?: boolean } = { decimals: 2, short: false },
) {
if (bytes === 0) return short ? "0B" : "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
if (short) {
return value + sizes[i].charAt(0);
} else {
return value + " " + sizes[i];
}
}
export function getDeep(obj: Record<string, any>, path: string[]) {
return path.reduce((acc, key) => acc?.[key], obj);
}
export function isObject(value: any): value is Record<string, any> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function flattenJSON(obj: Record<string, any>, path: string[] = []) {
const map = flattenJSONToMap(obj);
const result = {} as Record<string, any>;
for (const [key, value] of map) {
result[key.join(".")] = value;
}
return result;
}
export function flattenJSONToMap(obj: Record<string, any>, path: string[] = []): Map<string[], any> {
const result = new Map<string[], any>();
for (const key of Object.keys(obj)) {
const value = obj[key];
const newPath = path.concat(key);
if (isObject(value)) {
for (const [k, v] of flattenJSONToMap(value, newPath)) {
result.set(k, v);
}
} else {
result.set(newPath, value);
}
}
return result;
}
export function arrayEquals(a: string[], b: string[]): boolean {
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((val, index) => val === b[index]);
}
export function stripVersion(label: string) {
const [name, _] = label.split(":");
return name;
}
export function useExponentialMovingAverage<T extends Record<string, number>>(source: Ref<T>, alpha: number = 0.2) {
const ema = ref<T>(source.value) as Ref<T>;
watch(source, (value) => {
const newValue = {} as Record<string, number>;
for (const key in value) {
newValue[key] = alpha * value[key] + (1 - alpha) * ema.value[key];
}
ema.value = newValue as T;
});
return { movingAverage: ema, reset: (value: T) => (ema.value = value) };
}
interface UseSimpleRefHistoryOptions<T> {
capacity: number;
deep?: boolean;
initial?: T[];
}
export function useSimpleRefHistory<T>(source: Ref<T>, options: UseSimpleRefHistoryOptions<T>) {
const { capacity, deep = true, initial = [] as T[] } = options;
const history = ref<T[]>(initial) as Ref<T[]>;
watch(
source,
(value) => {
history.value.push(value);
if (history.value.length > capacity) {
history.value.shift();
}
},
{ deep },
);
const reset = ({ initial = [] }: Pick<UseSimpleRefHistoryOptions<T>, "initial">) => {
history.value = initial;
};
return { history, reset };
}
export function hashCode(str: string) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return hash;
}
const units: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 31536000],
["month", 2592000],
["week", 604800],
["day", 86400],
["hour", 3600],
["minute", 60],
["second", 1],
];
export function toRelativeTime(date: Date, locale: string | undefined): string {
const diffInSeconds = (date.getTime() - new Date().getTime()) / 1000;
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
for (const [unit, seconds] of units) {
const value = Math.round(diffInSeconds / seconds);
if (Math.abs(value) >= 1) {
return rtf.format(value, unit);
}
}
return rtf.format(0, "second");
}
export { formatDuration, toRelativeTime } from "./date";
export { getDeep, isObject, flattenJSON, flattenJSONToMap, arrayEquals } from "./object";
export { useExponentialMovingAverage, useSimpleRefHistory } from "./reactive";
export { formatBytes, stripVersion, hashCode } from "./format";
+37
View File
@@ -0,0 +1,37 @@
export function getDeep(obj: Record<string, any>, path: string[]) {
return path.reduce((acc, key) => acc?.[key], obj);
}
export function isObject(value: any): value is Record<string, any> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function flattenJSON(obj: Record<string, any>, path: string[] = []) {
const map = flattenJSONToMap(obj);
const result = {} as Record<string, any>;
for (const [key, value] of map) {
result[key.join(".")] = value;
}
return result;
}
export function flattenJSONToMap(obj: Record<string, any>, path: string[] = []): Map<string[], any> {
const result = new Map<string[], any>();
for (const key of Object.keys(obj)) {
const value = obj[key];
const newPath = path.concat(key);
if (isObject(value)) {
for (const [k, v] of flattenJSONToMap(value, newPath)) {
result.set(k, v);
}
} else {
result.set(newPath, value);
}
}
return result;
}
export function arrayEquals(a: string[], b: string[]): boolean {
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((val, index) => val === b[index]);
}
+41
View File
@@ -0,0 +1,41 @@
export function useExponentialMovingAverage<T extends Record<string, number>>(source: Ref<T>, alpha: number = 0.2) {
const ema = ref<T>(source.value) as Ref<T>;
watch(source, (value) => {
const newValue = {} as Record<string, number>;
for (const key in value) {
newValue[key] = alpha * value[key] + (1 - alpha) * ema.value[key];
}
ema.value = newValue as T;
});
return { movingAverage: ema, reset: (value: T) => (ema.value = value) };
}
interface UseSimpleRefHistoryOptions<T> {
capacity: number;
deep?: boolean;
initial?: T[];
}
export function useSimpleRefHistory<T>(source: Ref<T>, options: UseSimpleRefHistoryOptions<T>) {
const { capacity, deep = true, initial = [] as T[] } = options;
const history = ref<T[]>(initial) as Ref<T[]>;
watch(
source,
(value) => {
history.value.push(value);
if (history.value.length > capacity) {
history.value.shift();
}
},
{ deep },
);
const reset = ({ initial = [] }: Pick<UseSimpleRefHistoryOptions<T>, "initial">) => {
history.value = initial;
};
return { history, reset };
}
+2 -1
View File
@@ -75,6 +75,7 @@ services:
start_interval: 5s
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./e2e/data/notifications.yml:/data/notifications.yml:ro
build:
context: .
proxy:
@@ -95,7 +96,7 @@ services:
playwright:
container_name: playwright
image: mcr.microsoft.com/playwright:v1.58.2-jammy
image: mcr.microsoft.com/playwright:v1.59.1-jammy
working_dir: /app
volumes:
- .:/app
+1
View File
@@ -8,3 +8,4 @@ node_modules
.idea/
*.log
!.vscode
superpowers
+6 -3
View File
@@ -35,7 +35,7 @@ services:
:::
> [!NOTE] Docker Socket Proxy users
> If you are using a remote agent you **CANNOT** add a socket proxy on top of the agent. Dozzle agents **REPLACE** using a proxy, see [Remote Hosts](/guide/remote-hosts.md) for more info and how to use a socket proxy instead of an agent.
> If you are using a remote agent you **CANNOT** add a socket proxy on top of the agent. Dozzle agents **REPLACE** using a proxy, see [Remote Hosts](/guide/remote-hosts) for more info and how to use a socket proxy instead of an agent.
The agent will start and listen on port `7007`. You can connect to the agent using the Dozzle UI by providing the agent's IP address and port. The agent will only show the containers that are available on the host where the agent is running.
@@ -49,7 +49,7 @@ To connect to an agent, you need to provide the agent's IP address and port. Her
::: code-group
```sh
docker run -p 8080:8080 amir20/dozzle:latest --remote-agent agent-ip:7007
docker run -p 8080:8080 amir20/dozzle:latest --remote-agent agent:7007
```
```yaml [docker-compose.yml]
@@ -64,7 +64,10 @@ services:
:::
Note that when connecting remotely, you don't need to mount local Docker socket. The UI will only show the containers that are available on the agent.
Note that it is not necessary to mount the local Docker socket when connecting to agents, in which case the UI will only show the containers that are available on the agents.
> [!TIP]
> If you want to include the host containers in the UI as well, mount the `docker.sock` socket as shown in the [getting started](/guide/getting-started) example.
> [!TIP]
> You can connect to multiple agents by providing multiple `DOZZLE_REMOTE_AGENT` environment variables. For example, `DOZZLE_REMOTE_AGENT=agent1:7007,agent2:7007`.
+29 -11
View File
@@ -56,17 +56,21 @@ You can also write your own payload template using Go's `text/template` syntax.
<div v-pre>
| Variable | Description |
| ------------------------- | --------------------------- |
| `{{.Container.Name}}` | Container name |
| `{{.Container.Image}}` | Container image |
| `{{.Container.HostName}}` | Docker host name |
| `{{.Container.State}}` | Container state |
| `{{.Log.Message}}` | Log message content |
| `{{.Log.Level}}` | Log level |
| `{{.Log.Timestamp}}` | Log timestamp |
| `{{.Log.Stream}}` | Stream type (stdout/stderr) |
| `{{.Subscription.Name}}` | Alert rule name |
| Variable | Description |
| ------------------------- | -------------------------------------- |
| `{{.Detail}}` | Summary (log message or metric values) |
| `{{.Container.Name}}` | Container name |
| `{{.Container.Image}}` | Container image |
| `{{.Container.HostName}}` | Docker host name |
| `{{.Container.State}}` | Container state |
| `{{.Log.Message}}` | Log message content |
| `{{.Log.Level}}` | Log level |
| `{{.Log.Timestamp}}` | Log timestamp |
| `{{.Log.Stream}}` | Stream type (stdout/stderr) |
| `{{.Stat.CPUPercent}}` | CPU usage percentage |
| `{{.Stat.MemoryPercent}}` | Memory usage percentage |
| `{{.Stat.MemoryUsage}}` | Memory usage in bytes |
| `{{.Subscription.Name}}` | Alert rule name |
</div>
@@ -142,6 +146,20 @@ Container: image startsWith "myapp/"
Log: stream == "stderr"
```
**Alert on slow API responses from production:**
```
Container: name contains "api" && labels["env"] == "production"
Log: message.duration > 5000 && message.path contains "/api"
```
**Alert on authentication failures using regex:**
```
Container: name contains "auth" || name contains "gateway"
Log: message matches "(?i)(unauthorized|forbidden|invalid token)"
```
> [!NOTE]
> The alert editor includes autocomplete and real-time validation. You can preview matched containers and logs before saving.
+1 -1
View File
@@ -25,7 +25,7 @@ users:
admin:
email: me@email.net
name: Admin
# Generate with docker run -it --rm amir20/dozzle generate --name Admin --email me@email.net --password secret admin
# Generate with docker run -it --rm amir20/dozzle generate admin --password password --email me@email.net --name "Admin"
password: $2a$11$9ho4vY2LdJ/WBopFcsAS0uORC0x2vuFHQgT/yBqZyzclhHsoaIkzK
filter:
roles:
+1 -1
View File
@@ -32,7 +32,7 @@ services:
If you're using [Coolify](https://coolify.io/), Dozzle automatically recognizes Coolify's labels as fallbacks:
- `coolify.resourceName` → Used as container name if `dev.dozzle.name` is not set
- `coolify.serviceName` → Used as container name if `dev.dozzle.name` is not set
- `coolify.projectName` → Used for grouping if `dev.dozzle.group` is not set
No additional configuration is needed for Coolify deployments.
+32 -17
View File
@@ -10,55 +10,70 @@ title: Dozzle Cloud
## Why Dozzle Cloud?
Container logs are noisy. Dozzle Cloud helps you cut through the noise with intelligent summarization and multi-instance aggregation — without requiring additional agents or complex setup.
Container logs are noisy. You could wire up custom webhooks and build your own alerting pipeline — but then you're maintaining alert logic, deduplication, summarization, and delivery infrastructure yourself.
Dozzle Cloud handles all of that out of the box, and adds capabilities that webhooks simply can't provide — like AI-powered summaries, two-way chat with your containers, and proactive daily digests.
## Key Features
### Log Summaries
Dozzle Cloud automatically batches related container events into concise summaries. Each summary includes severity levels, source container information, and direct links to the full logs in your Dozzle instance.
Raw log lines aren't useful at 2am. Dozzle Cloud automatically batches related container events into concise, AI-powered summaries. Each summary includes severity levels, source container information, and direct links to the full logs in your Dozzle instance.
### Pattern Clustering
Instead of showing duplicate errors, Dozzle Cloud groups similar errors together and displays frequency counts. This makes it easy to identify recurring issues across multiple containers.
Instead of showing the same error 50 times, Dozzle Cloud groups similar errors together and displays frequency counts. This makes it easy to identify recurring issues across multiple containers without the noise.
### AI Agent
Talk to your containers. The AI agent lets you ask questions about container health, search historical logs, and get context on what went wrong — all from a chat interface in Telegram or Discord.
On Pro and Team plans, the agent can also take action: restart, start, or stop containers directly from the conversation. Get an alert, ask what happened, and fix it — without leaving the chat or SSH-ing into a server.
### Daily Digests
Webhooks are reactive — they only fire when something breaks. Daily digests give you a proactive summary of what's happening across your infrastructure: top error patterns, event counts, and overall health. Delivered to your inbox at a time you choose, in your timezone.
### Smart Alert Distribution
Receive notifications through multiple channels:
Receive notifications through multiple channels — and unlike one-way webhooks, some channels support full two-way interaction:
- **Email**
- **Telegram** — alerts plus two-way AI agent chat
- **Discord** — alerts plus two-way AI agent chat
- **Email** — alerts and daily digests
- **Slack**
- **ntfy**
- **Webhooks**
- **Browser push notifications**
You can enable or disable channels independently with unlimited configurations.
You can enable or disable channels independently, scope them to specific Dozzle instances, and set up as many as you need.
### Notification Muting
Sometimes you know things are broken and you're working on it. Mute notifications for an hour, eight hours, until tomorrow, or until next week — so you can focus on fixing without the noise.
### Multi-Instance Dashboard
Monitor all your Dozzle servers from a single dashboard. Connecting is simple — just link your instance with an API key. No additional agents are required.
Monitor all your Dozzle servers from a single dashboard. Each instance connects with an API key — no additional agents required. See which instances are online, browse containers, and stream logs in real time.
### Searchable Event History
Search across all logged events with full-text search. Filter by container, severity (error, warning, info), and keywords. Retention is configurable from 24 hours to 30 days.
Search across all logged events with full-text search. Filter by container, severity, or keyword. Retention is configurable from 24 hours to 30 days depending on your plan.
### Security
- API keys are hashed with expiration support
- GitHub OAuth authentication
- API keys are hashed with BLAKE2b and support expiration
- GitHub and Google OAuth authentication
- Your logs remain your own — Dozzle Cloud is committed to data privacy
## Connecting to Dozzle Cloud
To link your Dozzle instance to Dozzle Cloud:
1. Navigate to the **Notifications** page in Dozzle
2. Click **Add Destination** and select **Dozzle Cloud**
3. Click **Link Dozzle Cloud** — you'll be redirected to authenticate
4. Once linked, your API key is automatically configured
You can then select Dozzle Cloud as a destination when creating [alerts](/guide/alerts-and-webhooks).
1. Open your local Dozzle and click the **cloud** icon in the top bar
2. Click **Link instance** — you'll be redirected to authenticate and confirm the connection
3. Once linked, configure alert subscriptions in Dozzle to start receiving notifications
## Pricing
Dozzle Cloud offers a free tier with 500 events per month — no credit card required. Visit [cloud.dozzle.dev](https://cloud.dozzle.dev) for more details.
Dozzle Cloud offers a free tier with 500 events per month — no credit card required. Visit [cloud.dozzle.dev](https://cloud.dozzle.dev) for plan details and pricing.
+15 -1
View File
@@ -104,7 +104,7 @@ To resolve the error take following steps:
The engine-id file should now have a UUID inside.
An example setup for Ansible can be found in [Podman Infos](podman.md)
An example setup for Ansible can be found in [Podman](/guide/podman)
It might be necessary to clean up your existing Dozzle deployment under Podman, stop the container and remove the associated data (container/volumes). After that you can redeploy the Dozzle container and your logs should now show up.
@@ -116,6 +116,20 @@ By default, Dozzle only shows running containers. To see stopped containers, you
In single-user mode, Dozzle stores the settings in the browser's local storage. This means that the settings are only available on the browser where they were set. For Dozzle to enable syncing settings across multiple instances, it needs to know who the user is. In multi-user mode, Dozzle uses the user's username to store the settings on disk and sync them across multiple instances. This information is stored in `/data` directory. If you want to sync settings across multiple instances, you need to [enable](/guide/authentication) multi-user mode and provide a username.
## Why doesn't Dozzle support notifications for Slack, Discord, Telegram, email, etc. directly?
By design, Dozzle is unopinionated about where your alerts go. Instead of bundling integrations for specific notification platforms, Dozzle provides **webhooks** with customizable payload templates. This means you can send alerts to _any_ service that accepts HTTP requests — Slack, Discord, Telegram, ntfy, PagerDuty, Opsgenie, or your own internal tools — without waiting for Dozzle to add explicit support.
There are a few reasons for this approach:
- **Universality.** Webhooks work with virtually every notification platform. Adding provider-specific integrations would cover only a fraction of what users need, while webhooks cover all of them.
- **Maintenance.** Each provider integration brings its own API quirks, authentication flows, rate limits, and breaking changes. Supporting them means Dozzle maintainers become responsible for debugging issues with third-party services — which is outside the scope of a log viewer.
- **Simplicity.** Dozzle is a lightweight, focused tool for viewing Docker logs. Keeping the notification layer generic keeps the codebase small and the project sustainable.
If you need a more opinionated experience with richer provider integrations (e.g., web push notifications, ntfy action buttons), [Dozzle Cloud](/guide/dozzle-cloud) is designed for that.
For setting up webhooks with your preferred service, see the [Alerts & Webhooks](/guide/alerts-and-webhooks) guide — it includes built-in payload templates for Slack, Discord, and ntfy that you can use as-is or customize.
## My Dozzle instances are timing out in Swarm Mode or I'm not seeing all my Swarm nodes when behind a load balancer. How do I fix it?
In Swarm Mode, Dozzle instances may require their own overlay network. If you see inconsistent behavior when connecting to different Dozzle nodes, consider adding a separate overlay network which only contains the Dozzle instances, as shown below:
+9 -74
View File
@@ -16,7 +16,7 @@ The easiest way to set up Dozzle is to use the CLI and mount `docker.sock` file.
::: code-group
```sh
docker run -d -v /var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle:latest
docker run -d -v /var/run/docker.sock:/var/run/docker.sock -v dozzle_data:/data -p 8080:8080 amir20/dozzle:latest
```
```yaml [docker-compose.yml]
@@ -26,6 +26,7 @@ services:
image: amir20/dozzle:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- dozzle_data:/data
ports:
- 8080:8080
environment:
@@ -37,6 +38,8 @@ services:
#
# Uncomment to enable authentication. See https://dozzle.dev/guide/authentication
# - DOZZLE_AUTH_PROVIDER=simple
volumes:
dozzle_data:
```
:::
@@ -45,6 +48,9 @@ services:
> Dozzle supports actions, such as stopping, starting, and restarting containers, or attaching to container shells. But they are disabled by default for security reasons. To enable them, uncomment the corresponding environment variables.
> Dozzle also supports connecting to remote agents to monitor multiple Docker hosts. See [agent](/guide/agent) to learn more.
> [!IMPORTANT]
> Dozzle stores notification settings and other data in `/data` inside the container. To persist these settings across container restarts, you need to mount a volume to `/data`. Without this mount, notification settings will be lost when the container is recreated. See the Docker Compose example above for the recommended volume configuration.
## Docker Swarm
Dozzle supports running in Swarm mode by deploying it on every node. To run Dozzle in Swarm mode, you can use the following configuration:
@@ -79,77 +85,6 @@ See [swarm mode](/guide/swarm-mode) for more information.
## K8s <Badge type="tip" text="New" />
Dozzle supports running in Kubernetes. It only needs to be deployed on one node within the cluster.
Dozzle supports running in Kubernetes. It only needs to be deployed on one node within the cluster. You'll need to set `DOZZLE_MODE=k8s` and configure RBAC for pod log access.
<details>
<summary>Kubernetes Configuration</summary>
```yaml [k8s-dozzle.yml]
# rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: pod-viewer
---
# clusterrole.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: pod-viewer-role
rules:
- apiGroups: [""]
resources: ["pods", "pods/log", "nodes"]
verbs: ["get", "list", "watch"]
- apiGroups: ["metrics.k8s.io"]
resources: ["pods"]
verbs: ["get", "list"]
---
# clusterrolebinding.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: pod-viewer-binding
subjects:
- kind: ServiceAccount
name: pod-viewer
namespace: default
roleRef:
kind: ClusterRole
name: pod-viewer-role
apiGroup: rbac.authorization.k8s.io
---
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: dozzle
spec:
replicas: 1
selector:
matchLabels:
app: dozzle
template:
metadata:
labels:
app: dozzle
spec:
serviceAccountName: pod-viewer
containers:
- name: dozzle
image: amir20/dozzle:latest
ports:
- containerPort: 8080
env:
- name: DOZZLE_MODE
value: "k8s"
```
</details>
Apply the configuration using the following command:
```sh
kubectl apply -f k8s-dozzle.yml
```
See [Kubernetes mode](/guide/k8s) for more information.
See [Kubernetes mode](/guide/k8s) for the full setup configuration including RBAC, deployment, and service manifests.
+2
View File
@@ -84,6 +84,8 @@ spec:
```
This configuration creates a service account, a cluster role, and a cluster role binding to allow Dozzle to access the necessary Kubernetes resources. It also creates a deployment for Dozzle and exposes it via a service.
> [!WARNING]
> When deploying this with any GitOps tool (like Flux CD or Argo CD) in a specific namespace apart from `default`, make sure to change the **namespace** in the **ClusterRoleBinding Subject**
All other features are supported as well, including authentication, filtering, and more. You can use the same environment variables as you would in Docker to configure Dozzle in Kubernetes.
+2 -2
View File
@@ -19,7 +19,7 @@ Options for generating UUIDs:
### uuidgen
:warning: Adjust folder/file permissions if necessary. There isn't any critical info but depending on your existing setup you might want to take additional steps.
> [!WARNING] Adjust folder/file permissions if necessary. There isn't any critical info but depending on your existing setup you might want to take additional steps.
1. Install uuidgen
2. Create the folders: `mkdir -p /var/lib/docker`
@@ -28,7 +28,7 @@ Options for generating UUIDs:
### Ansible
:warning: Depending on your setup you might have to make adjustments for file/folder permissions. The following task snippets would run as the become_user/remote_user of the playbook running these tasks.
> [!WARNING] Depending on your setup you might have to make adjustments for file/folder permissions. The following task snippets would run as the become_user/remote_user of the playbook running these tasks.
If you wish to adjust the user, you have to set individual become/become_user parameters for these tasks.
+22 -1
View File
@@ -12,7 +12,7 @@ This feature is currently in beta and is available to all users. If you have any
To get started with the SQL Engine, you will need to have a dataset that you can query. Only JSON logs can be queried using SQL. Dozzle leverages the power of WebAssembly to run SQL queries in the browser, which means that your data never leaves your machine.
To start using the SQL Engine, make sure you have JSON logs and navigate to the dropdown and choose `SQL Analytics`. There is also a keyboard shortcut `^+⇧+f` or `⌘+⇧+f` to quickly open the SQL Engine.
To start using the SQL Engine, make sure you have JSON logs and navigate to the dropdown and choose `SQL Analytics`. There is also a keyboard shortcut `Ctrl+Shift+F` (or `Cmd+Shift+F` on macOS) to quickly open the SQL Engine.
## How Does It Work?
@@ -48,6 +48,27 @@ SELECT * FROM logs WHERE level = 'error'
SELECT level, COUNT(*) FROM logs GROUP BY level
```
### Query nested JSON fields
```sql
SELECT message.path, message.status, message.duration
FROM logs
WHERE message.status >= 400
ORDER BY message.duration DESC
```
### Aggregate by time window
```sql
SELECT
date_trunc('minute', timestamp) AS minute,
COUNT(*) AS error_count
FROM logs
WHERE level = 'error'
GROUP BY minute
ORDER BY minute DESC
```
## Limitations
WebAssembly has some limitations that you should be aware of when using the SQL Engine:
-3
View File
@@ -4,9 +4,6 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
esbuild@<=0.24.2: '>=0.25.0'
importers:
.:
+12
View File
@@ -0,0 +1,12 @@
subscriptions:
- id: 1
name: test-alert
enabled: true
dispatcherId: 1
logExpression: 'message contains "error"'
containerExpression: 'name contains "dozzle"'
dispatchers:
- id: 1
name: test-webhook
type: webhook
url: http://localhost:9999/webhook
+78 -15
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env fish
# docker network create --driver bridge swarm-net
echo "🧹 Removing existing containers and network"
docker rm -f manager worker-1 worker-2 2>/dev/null
docker network rm swarm-net 2>/dev/null
echo "Removing existing containers"
docker rm -f manager worker-1 worker-2
echo "🌐 Creating bridge network"
docker network create --driver bridge swarm-net
echo "Creating manager"
echo "🖥️ Creating manager"
docker run -d --name manager \
--privileged \
--network swarm-net \
@@ -13,16 +15,25 @@ docker run -d --name manager \
-p 2377:2377 \
-p 7946:7946 \
-p 4789:4789 \
-p 8000-9000:8000-9000 \
-p 8080:8080 \
-v ./examples:/examples \
docker
# Store join command in a variable
sleep 2
echo "Initializing swarm"
set JOIN_COMMAND (docker exec manager docker swarm init | grep "swarm join --token")
# Wait for dockerd to be ready inside manager
echo "⏳ Waiting for manager dockerd to start..."
while not docker exec manager docker info >/dev/null 2>&1
sleep 1
end
echo "Creating workers"
echo "🐝 Initializing swarm"
set MANAGER_IP (docker exec manager hostname -i | string trim)
set JOIN_TOKEN (docker exec manager docker swarm init --advertise-addr $MANAGER_IP 2>&1 | string match -r 'SWMTKN-\S+')
if test -z "$JOIN_TOKEN"
echo "❌ Failed to get swarm join token"
exit 1
end
echo "👷 Creating workers"
for i in 1 2
docker run -d --name worker-$i \
--privileged \
@@ -31,17 +42,69 @@ for i in 1 2
docker
end
sleep 2
# Wait for worker dockerd to be ready
for i in 1 2
echo "Joining worker-$i to swarm"
docker exec worker-$i sh -c "$JOIN_COMMAND"
echo "⏳ Waiting for worker-$i dockerd to start..."
while not docker exec worker-$i docker info >/dev/null 2>&1
sleep 1
end
end
function cleanup
for i in 1 2
echo "🔗 Joining worker-$i to swarm"
docker exec worker-$i docker swarm join --token $JOIN_TOKEN $MANAGER_IP:2377
end
echo "✅ Swarm is ready. Deploying Dozzle..."
# Create the stack file inside the manager
docker exec manager sh -c 'cat > /dozzle-stack.yml << "EOF"
services:
dozzle:
image: amir20/dozzle:local
environment:
- DOZZLE_MODE=swarm
volumes:
- /var/run/docker.sock:/var/run/docker.sock
ports:
- 8080:8080
networks:
- dozzle
deploy:
mode: global
networks:
dozzle:
driver: overlay
EOF'
docker exec manager docker stack deploy -c /dozzle-stack.yml dozzle
echo "🚀 Dozzle deployed! Access at http://localhost:8080"
function swarm-cleanup
docker exec manager docker stack rm dozzle 2>/dev/null
docker rm -f manager worker-1 worker-2
docker network rm swarm-net 2>/dev/null
functions -e swarm-cleanup
functions -e swarm
functions -e swarm-deploy
end
function swarm
docker exec manager docker $argv
end
function swarm-deploy
echo "🔨 Building local image..."
make -C (git rev-parse --show-toplevel) docker
echo "📦 Loading image into swarm nodes..."
for node in manager worker-1 worker-2
docker save amir20/dozzle:local | docker exec -i $node docker load
end
echo "🚀 Redeploying stack..."
docker exec manager docker stack rm dozzle 2>/dev/null
sleep 2
docker exec manager docker stack deploy -c /dozzle-stack.yml dozzle
echo "✅ Deployed local changes!"
end
echo "💡 Functions 'swarm', 'swarm-deploy', and 'swarm-cleanup' are available in this session."
+31 -31
View File
@@ -11,50 +11,51 @@ require (
github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/opencontainers/image-spec v1.1.1
github.com/sergi/go-diff v1.3.1 // indirect
github.com/spf13/afero v1.15.0
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.43.0 // indirect
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/PuerkitoBio/goquery v1.11.0
github.com/andybalholm/brotli v1.2.0
github.com/expr-lang/expr v1.17.7
github.com/PuerkitoBio/goquery v1.12.0
github.com/andybalholm/brotli v1.2.1
github.com/expr-lang/expr v1.17.8
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/jwtauth/v5 v5.3.3
github.com/go-chi/jwtauth/v5 v5.4.0
github.com/go-faker/faker/v4 v4.7.0
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/puzpuzpuz/xsync/v4 v4.4.0
github.com/rs/zerolog v1.34.0
github.com/samber/lo v1.52.0
github.com/rs/zerolog v1.35.0
github.com/samber/lo v1.53.0
github.com/wk8/go-ordered-map/v2 v2.1.8
github.com/yuin/goldmark v1.7.16
github.com/yuin/goldmark v1.8.2
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/crypto v0.48.0
golang.org/x/sync v0.19.0
google.golang.org/grpc v1.78.0
golang.org/x/crypto v0.50.0
golang.org/x/sync v0.20.0
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
k8s.io/api v0.35.1
k8s.io/apimachinery v0.35.1
k8s.io/client-go v0.35.1
k8s.io/metrics v0.35.1
k8s.io/api v0.35.3
k8s.io/apimachinery v0.35.3
k8s.io/client-go v0.35.3
k8s.io/metrics v0.35.3
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/air-verse/air v1.64.5 // indirect
github.com/air-verse/air v1.65.1 // indirect
github.com/alexflint/go-scalar v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bep/godartsass/v2 v2.5.0 // indirect
github.com/bep/golibsass v1.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
@@ -79,11 +80,10 @@ require (
github.com/joho/godotenv v1.5.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx/v2 v2.1.3 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 // indirect
github.com/lestrrat-go/jwx/v3 v3.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
@@ -106,16 +106,16 @@ require (
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
@@ -129,7 +129,7 @@ require (
sigs.k8s.io/yaml v1.6.0 // indirect
)
go 1.26.0
go 1.26.2
tool (
github.com/air-verse/air
+80 -99
View File
@@ -8,19 +8,20 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/air-verse/air v1.64.4 h1:P0alz5Jia5NucZew1HYZy69lzPWZHFjLTCKo0VhxME8=
github.com/air-verse/air v1.64.4/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/air-verse/air v1.64.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4=
github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/air-verse/air v1.65.0/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/air-verse/air v1.65.1/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/alexflint/go-arg v1.6.1 h1:uZogJ6VDBjcuosydKgvYYRhh9sRCusjOvoOLZopBlnA=
github.com/alexflint/go-arg v1.6.1/go.mod h1:nQ0LFYftLJ6njcaee0sU+G0iS2+2XJQfA8I062D0LGc=
github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
@@ -61,8 +62,8 @@ github.com/bep/overlayfs v0.10.0 h1:wS3eQ6bRsLX+4AAmwGjvoFSAQoeheamxofFiJ2SthSE=
github.com/bep/overlayfs v0.10.0/go.mod h1:ouu4nu6fFJaL0sPzNICzxYsBeWwrjiTdFZdK4lI3tro=
github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI=
github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -75,7 +76,6 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -101,8 +101,8 @@ github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanw/esbuild v0.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs=
github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -118,12 +118,10 @@ github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo=
github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
github.com/go-faker/faker/v4 v4.7.0 h1:VboC02cXHl/NuQh5lM2W8b87yp4iFXIu59x4w0RZi4E=
github.com/go-faker/faker/v4 v4.7.0/go.mod h1:u1dIRP5neLB6kTzgyVjdBOV5R1uP7BdxkcWk7tiKQXk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -145,7 +143,6 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e h1:QArsSubW7eDh8APMXkByjQWvuljwPGAGQpJEFn0F0wY=
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e/go.mod h1:3Ltoo9Banwq0gOtcOwxuHG6omk+AwsQPADyw2vQYOJQ=
github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
@@ -204,16 +201,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.1.3 h1:Ud4lb2QuxRClYAmRleF50KrbKIoM1TddXgBrneT5/Jo=
github.com/lestrrat-go/jwx/v2 v2.1.3/go.mod h1:q6uFgbgZfEmQrfJfrCo90QcQOcXFMfbI/fO0NqRtvZo=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 h1:SDxjGoH7qj0nBXVrcrxX8eD94wEnjR+EEuqqmeqQYlY=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2/go.mod h1:Nwo81sMxE0DcvTB+rJyynNhv/DUu2yZErV7sscw9pHE=
github.com/lestrrat-go/jwx/v3 v3.0.2 h1:N+XLjTJEzDZRP3S0SezclXFAfopwL+o5vaL+qg6rX1I=
github.com/lestrrat-go/jwx/v3 v3.0.2/go.mod h1:qO9w1qkQH77a0r9OXNM33YQPnV/evetKYRg58h1rBNE=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
@@ -222,11 +217,8 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1r
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
@@ -281,6 +273,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
@@ -301,11 +295,10 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
@@ -346,28 +339,28 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.22.0 h1:9M3+rhx7kZCIQQhQRYaZCdNu1V73tm4TvXs2ntl98C4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.22.0/go.mod h1:noq80iT8rrHP1SfybmPiRGc9dc5M8RPmGvtwo7Oo7tc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0 h1:FyjCyI9jVEfqhUh2MoSkmolPjfh5fp2hnV0b0irxH4Q=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0/go.mod h1:hYwym2nDEeZfG/motx0p7L7J1N1vyzIThemQsb4g2qY=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
@@ -380,10 +373,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
@@ -393,8 +386,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -405,12 +398,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -418,15 +409,14 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -434,10 +424,10 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -447,10 +437,10 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -460,10 +450,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -473,21 +463,20 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.0 h1:6Al3kEFFP9VJhRz3DID6quisgPnTeZVr4lep9kkxdPA=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.0/go.mod h1:QLvsjh0OIR0TYBeiu2bkWGTJBUNQ64st52iWj/yA93I=
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM=
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1 h1:/WILD1UcXj/ujCxgoL/DvRgt2CP3txG8+FwkUbb9110=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1/go.mod h1:YNKnb2OAApgYn2oYY47Rn7alMr1zWjb2U8Q0aoGWiNc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
@@ -508,26 +497,18 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=
k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q=
k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM=
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU=
k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM=
k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA=
k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ=
k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4=
k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8=
k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg=
k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
k8s.io/metrics v0.35.0 h1:xVFoqtAGm2dMNJAcB5TFZJPCen0uEqqNt52wW7ABbX8=
k8s.io/metrics v0.35.0/go.mod h1:g2Up4dcBygZi2kQSEQVDByFs+VUwepJMzzQLJJLpq4M=
k8s.io/metrics v0.35.1 h1:MUcrUcWlq81XiripkydzCGsY9zQawDXfP9IICNNcVVw=
k8s.io/metrics v0.35.1/go.mod h1:9x7xWOAOiWzHA0vaqLgSE4PXF3vyT5ts5XIbx8OSjiI=
k8s.io/metrics v0.35.3 h1:WonA18pEwrtb7a6XfhFg1ZY1Le0RFkcEw7CFApMTZos=
k8s.io/metrics v0.35.3/go.mod h1:/O8UBb5QVyAekR2QvL/WWxskpdV1wVSEl4MSLAy4Ql4=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
+94 -10
View File
@@ -22,6 +22,7 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/encoding/gzip"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -51,6 +52,11 @@ func NewClient(endpoint string, certificates tls.Certificate, opts ...grpc.DialO
opts = append(opts,
grpc.WithTransportCredentials(creds),
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(10*1024*1024), grpc.UseCompressor(gzip.Name)),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second,
Timeout: 10 * time.Second,
PermitWithoutStream: true,
}),
)
conn, err := grpc.NewClient(endpoint, opts...)
if err != nil {
@@ -231,10 +237,12 @@ func (c *Client) StreamStats(ctx context.Context, stats chan<- container.Contain
}
stats <- container.ContainerStat{
CPUPercent: resp.Stat.CpuPercent,
MemoryPercent: resp.Stat.MemoryPercent,
MemoryUsage: resp.Stat.MemoryUsage,
ID: resp.Stat.Id,
CPUPercent: resp.Stat.CpuPercent,
MemoryPercent: resp.Stat.MemoryPercent,
MemoryUsage: resp.Stat.MemoryUsage,
ID: resp.Stat.Id,
NetworkRxTotal: resp.Stat.NetworkRxTotal,
NetworkTxTotal: resp.Stat.NetworkTxTotal,
}
}
}
@@ -252,10 +260,11 @@ func (c *Client) StreamEvents(ctx context.Context, events chan<- container.Conta
}
events <- container.ContainerEvent{
ActorID: resp.Event.ActorId,
Name: resp.Event.Name,
Host: resp.Event.Host,
Time: resp.Event.Timestamp.AsTime(),
ActorID: resp.Event.ActorId,
Name: resp.Event.Name,
Host: resp.Event.Host,
Time: resp.Event.Timestamp.AsTime(),
ActorAttributes: resp.Event.ActorAttributes,
}
}
}
@@ -358,6 +367,38 @@ func (c *Client) ContainerAction(ctx context.Context, containerId string, action
return err
}
func (c *Client) UpdateContainer(ctx context.Context, containerID string, progressCh chan<- container.UpdateProgress) (bool, error) {
defer close(progressCh)
stream, err := c.client.UpdateContainer(ctx, &pb.UpdateContainerRequest{ContainerId: containerID})
if err != nil {
return false, err
}
updated := false
for {
progress, err := stream.Recv()
if err == io.EOF {
return updated, nil
}
if err != nil {
return false, err
}
if progress.Status == "done" {
updated = true
}
progressCh <- container.UpdateProgress{
Status: progress.Status,
Layer: progress.Layer,
Current: progress.Current,
Total: progress.Total,
Error: progress.Error,
}
}
}
func (c *Client) ContainerAttach(ctx context.Context, containerId string) (*container.ExecSession, error) {
stream, err := c.client.ContainerAttach(ctx)
if err != nil {
@@ -497,7 +538,6 @@ func (c *Client) Close() error {
}
func (c *Client) UpdateNotificationConfig(ctx context.Context, subscriptions []types.SubscriptionConfig, dispatchers []types.DispatcherConfig) error {
// Convert to proto
pbSubs := make([]*pb.NotificationSubscription, len(subscriptions))
for i, sub := range subscriptions {
pbSubs[i] = &pb.NotificationSubscription{
@@ -507,6 +547,10 @@ func (c *Client) UpdateNotificationConfig(ctx context.Context, subscriptions []t
DispatcherId: int32(sub.DispatcherID),
LogExpression: sub.LogExpression,
ContainerExpression: sub.ContainerExpression,
MetricExpression: sub.MetricExpression,
Cooldown: int32(sub.Cooldown),
SampleWindow: int32(sub.SampleWindow),
EventExpression: sub.EventExpression,
}
}
@@ -518,6 +562,7 @@ func (c *Client) UpdateNotificationConfig(ctx context.Context, subscriptions []t
Type: d.Type,
Url: d.URL,
Template: d.Template,
Headers: d.Headers,
}
}
@@ -525,10 +570,49 @@ func (c *Client) UpdateNotificationConfig(ctx context.Context, subscriptions []t
Subscriptions: pbSubs,
Dispatchers: pbDispatchers,
})
return err
}
func (c *Client) UpdateCloudConfig(ctx context.Context, cloudConfig *types.CloudConfig) error {
req := &pb.UpdateCloudConfigRequest{}
if cloudConfig != nil {
req.CloudConfig = &pb.NotificationCloudConfig{
ApiKey: cloudConfig.APIKey,
Prefix: cloudConfig.Prefix,
}
if cloudConfig.ExpiresAt != nil {
req.CloudConfig.ExpiresAt = timestamppb.New(*cloudConfig.ExpiresAt)
}
}
_, err := c.client.UpdateCloudConfig(ctx, req)
return err
}
func (c *Client) GetNotificationStats(ctx context.Context) ([]types.SubscriptionStats, error) {
resp, err := c.client.GetNotificationStats(ctx, &pb.GetNotificationStatsRequest{})
if err != nil {
return nil, err
}
stats := make([]types.SubscriptionStats, len(resp.Stats))
for i, s := range resp.Stats {
var lastTriggered *time.Time
if s.LastTriggeredAt != nil {
t := s.LastTriggeredAt.AsTime()
if !t.IsZero() {
lastTriggered = &t
}
}
stats[i] = types.SubscriptionStats{
SubscriptionID: int(s.SubscriptionId),
TriggerCount: s.TriggerCount,
LastTriggeredAt: lastTriggered,
TriggeredContainerIDs: s.TriggeredContainerIds,
}
}
return stats, nil
}
func jsonBytesToOrderedMap(b []byte) *orderedmap.OrderedMap[string, any] {
var data *orderedmap.OrderedMap[string, any]
reader := bytes.NewReader(b)
+21 -1
View File
@@ -12,7 +12,9 @@ import (
"time"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/internal/notification/dispatcher"
"github.com/amir20/dozzle/internal/utils"
"github.com/amir20/dozzle/types"
"github.com/go-faker/faker/v4"
"github.com/go-faker/faker/v4/pkg/options"
"github.com/stretchr/testify/assert"
@@ -28,6 +30,19 @@ var lis *bufconn.Listener
var certs tls.Certificate
var mockService *MockedClientService
type mockNotificationHandler struct{}
func (m *mockNotificationHandler) HandleNotificationConfig(subscriptions []types.SubscriptionConfig, dispatchers []types.DispatcherConfig) error {
return nil
}
func (m *mockNotificationHandler) SetCloudDispatcher(d dispatcher.Dispatcher) {}
func (m *mockNotificationHandler) ClearCloudDispatcher() {}
func (m *mockNotificationHandler) GetNotificationStats() []types.SubscriptionStats {
return nil
}
type MockedClientService struct {
mock.Mock
}
@@ -89,6 +104,11 @@ func (m *MockedClientService) Exec(ctx context.Context, c container.Container, c
return args.Error(0)
}
func (m *MockedClientService) UpdateContainer(ctx context.Context, c container.Container, progressCh chan<- container.UpdateProgress) (bool, error) {
args := m.Called(ctx, c, progressCh)
return args.Bool(0), args.Error(1)
}
var wantedContainer = container.Container{}
func init() {
@@ -134,7 +154,7 @@ func init() {
mockService.On("Client").Return(nil)
server, _ := NewServer(mockService, certs, "test", nil)
server, _ := NewServer(mockService, certs, "test", &mockNotificationHandler{})
go server.Serve(lis)
}
+413 -100
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 v7.34.1
// source: rpc.proto
package pb
@@ -942,6 +942,126 @@ func (*ContainerActionResponse) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{19}
}
type UpdateContainerRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ContainerId string `protobuf:"bytes,1,opt,name=containerId,proto3" json:"containerId,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateContainerRequest) Reset() {
*x = UpdateContainerRequest{}
mi := &file_rpc_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpdateContainerRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateContainerRequest) ProtoMessage() {}
func (x *UpdateContainerRequest) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[20]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use UpdateContainerRequest.ProtoReflect.Descriptor instead.
func (*UpdateContainerRequest) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{20}
}
func (x *UpdateContainerRequest) GetContainerId() string {
if x != nil {
return x.ContainerId
}
return ""
}
type UpdateContainerProgress struct {
state protoimpl.MessageState `protogen:"open.v1"`
Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"`
Layer string `protobuf:"bytes,2,opt,name=layer,proto3" json:"layer,omitempty"`
Current int64 `protobuf:"varint,3,opt,name=current,proto3" json:"current,omitempty"`
Total int64 `protobuf:"varint,4,opt,name=total,proto3" json:"total,omitempty"`
Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateContainerProgress) Reset() {
*x = UpdateContainerProgress{}
mi := &file_rpc_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpdateContainerProgress) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateContainerProgress) ProtoMessage() {}
func (x *UpdateContainerProgress) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[21]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use UpdateContainerProgress.ProtoReflect.Descriptor instead.
func (*UpdateContainerProgress) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{21}
}
func (x *UpdateContainerProgress) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
func (x *UpdateContainerProgress) GetLayer() string {
if x != nil {
return x.Layer
}
return ""
}
func (x *UpdateContainerProgress) GetCurrent() int64 {
if x != nil {
return x.Current
}
return 0
}
func (x *UpdateContainerProgress) GetTotal() int64 {
if x != nil {
return x.Total
}
return 0
}
func (x *UpdateContainerProgress) GetError() string {
if x != nil {
return x.Error
}
return ""
}
type ContainerExecRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ContainerId string `protobuf:"bytes,1,opt,name=containerId,proto3" json:"containerId,omitempty"`
@@ -957,7 +1077,7 @@ type ContainerExecRequest struct {
func (x *ContainerExecRequest) Reset() {
*x = ContainerExecRequest{}
mi := &file_rpc_proto_msgTypes[20]
mi := &file_rpc_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -969,7 +1089,7 @@ func (x *ContainerExecRequest) String() string {
func (*ContainerExecRequest) ProtoMessage() {}
func (x *ContainerExecRequest) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[20]
mi := &file_rpc_proto_msgTypes[22]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -982,7 +1102,7 @@ func (x *ContainerExecRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ContainerExecRequest.ProtoReflect.Descriptor instead.
func (*ContainerExecRequest) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{20}
return file_rpc_proto_rawDescGZIP(), []int{22}
}
func (x *ContainerExecRequest) GetContainerId() string {
@@ -1050,7 +1170,7 @@ type ResizePayload struct {
func (x *ResizePayload) Reset() {
*x = ResizePayload{}
mi := &file_rpc_proto_msgTypes[21]
mi := &file_rpc_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1062,7 +1182,7 @@ func (x *ResizePayload) String() string {
func (*ResizePayload) ProtoMessage() {}
func (x *ResizePayload) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[21]
mi := &file_rpc_proto_msgTypes[23]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1075,7 +1195,7 @@ func (x *ResizePayload) ProtoReflect() protoreflect.Message {
// Deprecated: Use ResizePayload.ProtoReflect.Descriptor instead.
func (*ResizePayload) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{21}
return file_rpc_proto_rawDescGZIP(), []int{23}
}
func (x *ResizePayload) GetWidth() uint32 {
@@ -1101,7 +1221,7 @@ type ContainerExecResponse struct {
func (x *ContainerExecResponse) Reset() {
*x = ContainerExecResponse{}
mi := &file_rpc_proto_msgTypes[22]
mi := &file_rpc_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1113,7 +1233,7 @@ func (x *ContainerExecResponse) String() string {
func (*ContainerExecResponse) ProtoMessage() {}
func (x *ContainerExecResponse) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[22]
mi := &file_rpc_proto_msgTypes[24]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1126,7 +1246,7 @@ func (x *ContainerExecResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ContainerExecResponse.ProtoReflect.Descriptor instead.
func (*ContainerExecResponse) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{22}
return file_rpc_proto_rawDescGZIP(), []int{24}
}
func (x *ContainerExecResponse) GetStdout() []byte {
@@ -1150,7 +1270,7 @@ type ContainerAttachRequest struct {
func (x *ContainerAttachRequest) Reset() {
*x = ContainerAttachRequest{}
mi := &file_rpc_proto_msgTypes[23]
mi := &file_rpc_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1162,7 +1282,7 @@ func (x *ContainerAttachRequest) String() string {
func (*ContainerAttachRequest) ProtoMessage() {}
func (x *ContainerAttachRequest) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[23]
mi := &file_rpc_proto_msgTypes[25]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1175,7 +1295,7 @@ func (x *ContainerAttachRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ContainerAttachRequest.ProtoReflect.Descriptor instead.
func (*ContainerAttachRequest) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{23}
return file_rpc_proto_rawDescGZIP(), []int{25}
}
func (x *ContainerAttachRequest) GetContainerId() string {
@@ -1235,7 +1355,7 @@ type ContainerAttachResponse struct {
func (x *ContainerAttachResponse) Reset() {
*x = ContainerAttachResponse{}
mi := &file_rpc_proto_msgTypes[24]
mi := &file_rpc_proto_msgTypes[26]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1247,7 +1367,7 @@ func (x *ContainerAttachResponse) String() string {
func (*ContainerAttachResponse) ProtoMessage() {}
func (x *ContainerAttachResponse) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[24]
mi := &file_rpc_proto_msgTypes[26]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1260,7 +1380,7 @@ func (x *ContainerAttachResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ContainerAttachResponse.ProtoReflect.Descriptor instead.
func (*ContainerAttachResponse) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{24}
return file_rpc_proto_rawDescGZIP(), []int{26}
}
func (x *ContainerAttachResponse) GetStdout() []byte {
@@ -1280,7 +1400,7 @@ type UpdateNotificationConfigRequest struct {
func (x *UpdateNotificationConfigRequest) Reset() {
*x = UpdateNotificationConfigRequest{}
mi := &file_rpc_proto_msgTypes[25]
mi := &file_rpc_proto_msgTypes[27]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1292,7 +1412,7 @@ func (x *UpdateNotificationConfigRequest) String() string {
func (*UpdateNotificationConfigRequest) ProtoMessage() {}
func (x *UpdateNotificationConfigRequest) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[25]
mi := &file_rpc_proto_msgTypes[27]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1305,7 +1425,7 @@ func (x *UpdateNotificationConfigRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use UpdateNotificationConfigRequest.ProtoReflect.Descriptor instead.
func (*UpdateNotificationConfigRequest) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{25}
return file_rpc_proto_rawDescGZIP(), []int{27}
}
func (x *UpdateNotificationConfigRequest) GetSubscriptions() []*NotificationSubscription {
@@ -1330,7 +1450,7 @@ type UpdateNotificationConfigResponse struct {
func (x *UpdateNotificationConfigResponse) Reset() {
*x = UpdateNotificationConfigResponse{}
mi := &file_rpc_proto_msgTypes[26]
mi := &file_rpc_proto_msgTypes[28]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1342,7 +1462,7 @@ func (x *UpdateNotificationConfigResponse) String() string {
func (*UpdateNotificationConfigResponse) ProtoMessage() {}
func (x *UpdateNotificationConfigResponse) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[26]
mi := &file_rpc_proto_msgTypes[28]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1355,7 +1475,167 @@ func (x *UpdateNotificationConfigResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use UpdateNotificationConfigResponse.ProtoReflect.Descriptor instead.
func (*UpdateNotificationConfigResponse) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{26}
return file_rpc_proto_rawDescGZIP(), []int{28}
}
type UpdateCloudConfigRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
CloudConfig *NotificationCloudConfig `protobuf:"bytes,1,opt,name=cloudConfig,proto3" json:"cloudConfig,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateCloudConfigRequest) Reset() {
*x = UpdateCloudConfigRequest{}
mi := &file_rpc_proto_msgTypes[29]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpdateCloudConfigRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateCloudConfigRequest) ProtoMessage() {}
func (x *UpdateCloudConfigRequest) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[29]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use UpdateCloudConfigRequest.ProtoReflect.Descriptor instead.
func (*UpdateCloudConfigRequest) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{29}
}
func (x *UpdateCloudConfigRequest) GetCloudConfig() *NotificationCloudConfig {
if x != nil {
return x.CloudConfig
}
return nil
}
type UpdateCloudConfigResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateCloudConfigResponse) Reset() {
*x = UpdateCloudConfigResponse{}
mi := &file_rpc_proto_msgTypes[30]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpdateCloudConfigResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateCloudConfigResponse) ProtoMessage() {}
func (x *UpdateCloudConfigResponse) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[30]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use UpdateCloudConfigResponse.ProtoReflect.Descriptor instead.
func (*UpdateCloudConfigResponse) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{30}
}
type GetNotificationStatsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetNotificationStatsRequest) Reset() {
*x = GetNotificationStatsRequest{}
mi := &file_rpc_proto_msgTypes[31]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetNotificationStatsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetNotificationStatsRequest) ProtoMessage() {}
func (x *GetNotificationStatsRequest) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[31]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetNotificationStatsRequest.ProtoReflect.Descriptor instead.
func (*GetNotificationStatsRequest) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{31}
}
type GetNotificationStatsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Stats []*NotificationSubscriptionStats `protobuf:"bytes,1,rep,name=stats,proto3" json:"stats,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetNotificationStatsResponse) Reset() {
*x = GetNotificationStatsResponse{}
mi := &file_rpc_proto_msgTypes[32]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetNotificationStatsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetNotificationStatsResponse) ProtoMessage() {}
func (x *GetNotificationStatsResponse) ProtoReflect() protoreflect.Message {
mi := &file_rpc_proto_msgTypes[32]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetNotificationStatsResponse.ProtoReflect.Descriptor instead.
func (*GetNotificationStatsResponse) Descriptor() ([]byte, []int) {
return file_rpc_proto_rawDescGZIP(), []int{32}
}
func (x *GetNotificationStatsResponse) GetStats() []*NotificationSubscriptionStats {
if x != nil {
return x.Stats
}
return nil
}
var File_rpc_proto protoreflect.FileDescriptor
@@ -1415,7 +1695,15 @@ const file_rpc_proto_rawDesc = "" +
"\x16ContainerActionRequest\x12 \n" +
"\vcontainerId\x18\x01 \x01(\tR\vcontainerId\x121\n" +
"\x06action\x18\x02 \x01(\x0e2\x19.protobuf.ContainerActionR\x06action\"\x19\n" +
"\x17ContainerActionResponse\"\xa8\x01\n" +
"\x17ContainerActionResponse\":\n" +
"\x16UpdateContainerRequest\x12 \n" +
"\vcontainerId\x18\x01 \x01(\tR\vcontainerId\"\x8d\x01\n" +
"\x17UpdateContainerProgress\x12\x16\n" +
"\x06status\x18\x01 \x01(\tR\x06status\x12\x14\n" +
"\x05layer\x18\x02 \x01(\tR\x05layer\x12\x18\n" +
"\acurrent\x18\x03 \x01(\x03R\acurrent\x12\x14\n" +
"\x05total\x18\x04 \x01(\x03R\x05total\x12\x14\n" +
"\x05error\x18\x05 \x01(\tR\x05error\"\xa8\x01\n" +
"\x14ContainerExecRequest\x12 \n" +
"\vcontainerId\x18\x01 \x01(\tR\vcontainerId\x12\x18\n" +
"\acommand\x18\x02 \x03(\tR\acommand\x12\x16\n" +
@@ -1437,7 +1725,13 @@ const file_rpc_proto_rawDesc = "" +
"\x1fUpdateNotificationConfigRequest\x12H\n" +
"\rsubscriptions\x18\x01 \x03(\v2\".protobuf.NotificationSubscriptionR\rsubscriptions\x12B\n" +
"\vdispatchers\x18\x02 \x03(\v2 .protobuf.NotificationDispatcherR\vdispatchers\"\"\n" +
" UpdateNotificationConfigResponse2\x96\t\n" +
" UpdateNotificationConfigResponse\"_\n" +
"\x18UpdateCloudConfigRequest\x12C\n" +
"\vcloudConfig\x18\x01 \x01(\v2!.protobuf.NotificationCloudConfigR\vcloudConfig\"\x1b\n" +
"\x19UpdateCloudConfigResponse\"\x1d\n" +
"\x1bGetNotificationStatsRequest\"]\n" +
"\x1cGetNotificationStatsResponse\x12=\n" +
"\x05stats\x18\x01 \x03(\v2'.protobuf.NotificationSubscriptionStatsR\x05stats2\xbb\v\n" +
"\fAgentService\x12U\n" +
"\x0eListContainers\x12\x1f.protobuf.ListContainersRequest\x1a .protobuf.ListContainersResponse\"\x00\x12R\n" +
"\rFindContainer\x12\x1e.protobuf.FindContainerRequest\x1a\x1f.protobuf.FindContainerResponse\"\x00\x12K\n" +
@@ -1449,10 +1743,13 @@ const file_rpc_proto_rawDesc = "" +
"\vStreamStats\x12\x1c.protobuf.StreamStatsRequest\x1a\x1d.protobuf.StreamStatsResponse\"\x000\x01\x12o\n" +
"\x16StreamContainerStarted\x12'.protobuf.StreamContainerStartedRequest\x1a(.protobuf.StreamContainerStartedResponse\"\x000\x01\x12C\n" +
"\bHostInfo\x12\x19.protobuf.HostInfoRequest\x1a\x1a.protobuf.HostInfoResponse\"\x00\x12X\n" +
"\x0fContainerAction\x12 .protobuf.ContainerActionRequest\x1a!.protobuf.ContainerActionResponse\"\x00\x12V\n" +
"\x0fContainerAction\x12 .protobuf.ContainerActionRequest\x1a!.protobuf.ContainerActionResponse\"\x00\x12Z\n" +
"\x0fUpdateContainer\x12 .protobuf.UpdateContainerRequest\x1a!.protobuf.UpdateContainerProgress\"\x000\x01\x12V\n" +
"\rContainerExec\x12\x1e.protobuf.ContainerExecRequest\x1a\x1f.protobuf.ContainerExecResponse\"\x00(\x010\x01\x12\\\n" +
"\x0fContainerAttach\x12 .protobuf.ContainerAttachRequest\x1a!.protobuf.ContainerAttachResponse\"\x00(\x010\x01\x12s\n" +
"\x18UpdateNotificationConfig\x12).protobuf.UpdateNotificationConfigRequest\x1a*.protobuf.UpdateNotificationConfigResponse\"\x00B\x13Z\x11internal/agent/pbb\x06proto3"
"\x18UpdateNotificationConfig\x12).protobuf.UpdateNotificationConfigRequest\x1a*.protobuf.UpdateNotificationConfigResponse\"\x00\x12^\n" +
"\x11UpdateCloudConfig\x12\".protobuf.UpdateCloudConfigRequest\x1a#.protobuf.UpdateCloudConfigResponse\"\x00\x12g\n" +
"\x14GetNotificationStats\x12%.protobuf.GetNotificationStatsRequest\x1a&.protobuf.GetNotificationStatsResponse\"\x00B\x13Z\x11internal/agent/pbb\x06proto3"
var (
file_rpc_proto_rawDescOnce sync.Once
@@ -1466,7 +1763,7 @@ func file_rpc_proto_rawDescGZIP() []byte {
return file_rpc_proto_rawDescData
}
var file_rpc_proto_msgTypes = make([]protoimpl.MessageInfo, 29)
var file_rpc_proto_msgTypes = make([]protoimpl.MessageInfo, 35)
var file_rpc_proto_goTypes = []any{
(*ListContainersRequest)(nil), // 0: protobuf.ListContainersRequest
(*RepeatedString)(nil), // 1: protobuf.RepeatedString
@@ -1488,78 +1785,94 @@ var file_rpc_proto_goTypes = []any{
(*StreamContainerStartedResponse)(nil), // 17: protobuf.StreamContainerStartedResponse
(*ContainerActionRequest)(nil), // 18: protobuf.ContainerActionRequest
(*ContainerActionResponse)(nil), // 19: protobuf.ContainerActionResponse
(*ContainerExecRequest)(nil), // 20: protobuf.ContainerExecRequest
(*ResizePayload)(nil), // 21: protobuf.ResizePayload
(*ContainerExecResponse)(nil), // 22: protobuf.ContainerExecResponse
(*ContainerAttachRequest)(nil), // 23: protobuf.ContainerAttachRequest
(*ContainerAttachResponse)(nil), // 24: protobuf.ContainerAttachResponse
(*UpdateNotificationConfigRequest)(nil), // 25: protobuf.UpdateNotificationConfigRequest
(*UpdateNotificationConfigResponse)(nil), // 26: protobuf.UpdateNotificationConfigResponse
nil, // 27: protobuf.ListContainersRequest.FilterEntry
nil, // 28: protobuf.FindContainerRequest.FilterEntry
(*Container)(nil), // 29: protobuf.Container
(*timestamppb.Timestamp)(nil), // 30: google.protobuf.Timestamp
(*LogEvent)(nil), // 31: protobuf.LogEvent
(*ContainerEvent)(nil), // 32: protobuf.ContainerEvent
(*ContainerStat)(nil), // 33: protobuf.ContainerStat
(*Host)(nil), // 34: protobuf.Host
(ContainerAction)(0), // 35: protobuf.ContainerAction
(*NotificationSubscription)(nil), // 36: protobuf.NotificationSubscription
(*NotificationDispatcher)(nil), // 37: protobuf.NotificationDispatcher
(*UpdateContainerRequest)(nil), // 20: protobuf.UpdateContainerRequest
(*UpdateContainerProgress)(nil), // 21: protobuf.UpdateContainerProgress
(*ContainerExecRequest)(nil), // 22: protobuf.ContainerExecRequest
(*ResizePayload)(nil), // 23: protobuf.ResizePayload
(*ContainerExecResponse)(nil), // 24: protobuf.ContainerExecResponse
(*ContainerAttachRequest)(nil), // 25: protobuf.ContainerAttachRequest
(*ContainerAttachResponse)(nil), // 26: protobuf.ContainerAttachResponse
(*UpdateNotificationConfigRequest)(nil), // 27: protobuf.UpdateNotificationConfigRequest
(*UpdateNotificationConfigResponse)(nil), // 28: protobuf.UpdateNotificationConfigResponse
(*UpdateCloudConfigRequest)(nil), // 29: protobuf.UpdateCloudConfigRequest
(*UpdateCloudConfigResponse)(nil), // 30: protobuf.UpdateCloudConfigResponse
(*GetNotificationStatsRequest)(nil), // 31: protobuf.GetNotificationStatsRequest
(*GetNotificationStatsResponse)(nil), // 32: protobuf.GetNotificationStatsResponse
nil, // 33: protobuf.ListContainersRequest.FilterEntry
nil, // 34: protobuf.FindContainerRequest.FilterEntry
(*Container)(nil), // 35: protobuf.Container
(*timestamppb.Timestamp)(nil), // 36: google.protobuf.Timestamp
(*LogEvent)(nil), // 37: protobuf.LogEvent
(*ContainerEvent)(nil), // 38: protobuf.ContainerEvent
(*ContainerStat)(nil), // 39: protobuf.ContainerStat
(*Host)(nil), // 40: protobuf.Host
(ContainerAction)(0), // 41: protobuf.ContainerAction
(*NotificationSubscription)(nil), // 42: protobuf.NotificationSubscription
(*NotificationDispatcher)(nil), // 43: protobuf.NotificationDispatcher
(*NotificationCloudConfig)(nil), // 44: protobuf.NotificationCloudConfig
(*NotificationSubscriptionStats)(nil), // 45: protobuf.NotificationSubscriptionStats
}
var file_rpc_proto_depIdxs = []int32{
27, // 0: protobuf.ListContainersRequest.filter:type_name -> protobuf.ListContainersRequest.FilterEntry
29, // 1: protobuf.ListContainersResponse.containers:type_name -> protobuf.Container
28, // 2: protobuf.FindContainerRequest.filter:type_name -> protobuf.FindContainerRequest.FilterEntry
29, // 3: protobuf.FindContainerResponse.container:type_name -> protobuf.Container
30, // 4: protobuf.StreamLogsRequest.since:type_name -> google.protobuf.Timestamp
31, // 5: protobuf.StreamLogsResponse.event:type_name -> protobuf.LogEvent
30, // 6: protobuf.LogsBetweenDatesRequest.since:type_name -> google.protobuf.Timestamp
30, // 7: protobuf.LogsBetweenDatesRequest.until:type_name -> google.protobuf.Timestamp
30, // 8: protobuf.StreamRawBytesRequest.since:type_name -> google.protobuf.Timestamp
30, // 9: protobuf.StreamRawBytesRequest.until:type_name -> google.protobuf.Timestamp
32, // 10: protobuf.StreamEventsResponse.event:type_name -> protobuf.ContainerEvent
33, // 11: protobuf.StreamStatsResponse.stat:type_name -> protobuf.ContainerStat
34, // 12: protobuf.HostInfoResponse.host:type_name -> protobuf.Host
29, // 13: protobuf.StreamContainerStartedResponse.container:type_name -> protobuf.Container
35, // 14: protobuf.ContainerActionRequest.action:type_name -> protobuf.ContainerAction
21, // 15: protobuf.ContainerExecRequest.resize:type_name -> protobuf.ResizePayload
21, // 16: protobuf.ContainerAttachRequest.resize:type_name -> protobuf.ResizePayload
36, // 17: protobuf.UpdateNotificationConfigRequest.subscriptions:type_name -> protobuf.NotificationSubscription
37, // 18: protobuf.UpdateNotificationConfigRequest.dispatchers:type_name -> protobuf.NotificationDispatcher
1, // 19: protobuf.ListContainersRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
1, // 20: protobuf.FindContainerRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
0, // 21: protobuf.AgentService.ListContainers:input_type -> protobuf.ListContainersRequest
3, // 22: protobuf.AgentService.FindContainer:input_type -> protobuf.FindContainerRequest
5, // 23: protobuf.AgentService.StreamLogs:input_type -> protobuf.StreamLogsRequest
7, // 24: protobuf.AgentService.LogsBetweenDates:input_type -> protobuf.LogsBetweenDatesRequest
8, // 25: protobuf.AgentService.StreamRawBytes:input_type -> protobuf.StreamRawBytesRequest
10, // 26: protobuf.AgentService.StreamEvents:input_type -> protobuf.StreamEventsRequest
12, // 27: protobuf.AgentService.StreamStats:input_type -> protobuf.StreamStatsRequest
16, // 28: protobuf.AgentService.StreamContainerStarted:input_type -> protobuf.StreamContainerStartedRequest
14, // 29: protobuf.AgentService.HostInfo:input_type -> protobuf.HostInfoRequest
18, // 30: protobuf.AgentService.ContainerAction:input_type -> protobuf.ContainerActionRequest
20, // 31: protobuf.AgentService.ContainerExec:input_type -> protobuf.ContainerExecRequest
23, // 32: protobuf.AgentService.ContainerAttach:input_type -> protobuf.ContainerAttachRequest
25, // 33: protobuf.AgentService.UpdateNotificationConfig:input_type -> protobuf.UpdateNotificationConfigRequest
2, // 34: protobuf.AgentService.ListContainers:output_type -> protobuf.ListContainersResponse
4, // 35: protobuf.AgentService.FindContainer:output_type -> protobuf.FindContainerResponse
6, // 36: protobuf.AgentService.StreamLogs:output_type -> protobuf.StreamLogsResponse
6, // 37: protobuf.AgentService.LogsBetweenDates:output_type -> protobuf.StreamLogsResponse
9, // 38: protobuf.AgentService.StreamRawBytes:output_type -> protobuf.StreamRawBytesResponse
11, // 39: protobuf.AgentService.StreamEvents:output_type -> protobuf.StreamEventsResponse
13, // 40: protobuf.AgentService.StreamStats:output_type -> protobuf.StreamStatsResponse
17, // 41: protobuf.AgentService.StreamContainerStarted:output_type -> protobuf.StreamContainerStartedResponse
15, // 42: protobuf.AgentService.HostInfo:output_type -> protobuf.HostInfoResponse
19, // 43: protobuf.AgentService.ContainerAction:output_type -> protobuf.ContainerActionResponse
22, // 44: protobuf.AgentService.ContainerExec:output_type -> protobuf.ContainerExecResponse
24, // 45: protobuf.AgentService.ContainerAttach:output_type -> protobuf.ContainerAttachResponse
26, // 46: protobuf.AgentService.UpdateNotificationConfig:output_type -> protobuf.UpdateNotificationConfigResponse
34, // [34:47] is the sub-list for method output_type
21, // [21:34] is the sub-list for method input_type
21, // [21:21] is the sub-list for extension type_name
21, // [21:21] is the sub-list for extension extendee
0, // [0:21] is the sub-list for field type_name
33, // 0: protobuf.ListContainersRequest.filter:type_name -> protobuf.ListContainersRequest.FilterEntry
35, // 1: protobuf.ListContainersResponse.containers:type_name -> protobuf.Container
34, // 2: protobuf.FindContainerRequest.filter:type_name -> protobuf.FindContainerRequest.FilterEntry
35, // 3: protobuf.FindContainerResponse.container:type_name -> protobuf.Container
36, // 4: protobuf.StreamLogsRequest.since:type_name -> google.protobuf.Timestamp
37, // 5: protobuf.StreamLogsResponse.event:type_name -> protobuf.LogEvent
36, // 6: protobuf.LogsBetweenDatesRequest.since:type_name -> google.protobuf.Timestamp
36, // 7: protobuf.LogsBetweenDatesRequest.until:type_name -> google.protobuf.Timestamp
36, // 8: protobuf.StreamRawBytesRequest.since:type_name -> google.protobuf.Timestamp
36, // 9: protobuf.StreamRawBytesRequest.until:type_name -> google.protobuf.Timestamp
38, // 10: protobuf.StreamEventsResponse.event:type_name -> protobuf.ContainerEvent
39, // 11: protobuf.StreamStatsResponse.stat:type_name -> protobuf.ContainerStat
40, // 12: protobuf.HostInfoResponse.host:type_name -> protobuf.Host
35, // 13: protobuf.StreamContainerStartedResponse.container:type_name -> protobuf.Container
41, // 14: protobuf.ContainerActionRequest.action:type_name -> protobuf.ContainerAction
23, // 15: protobuf.ContainerExecRequest.resize:type_name -> protobuf.ResizePayload
23, // 16: protobuf.ContainerAttachRequest.resize:type_name -> protobuf.ResizePayload
42, // 17: protobuf.UpdateNotificationConfigRequest.subscriptions:type_name -> protobuf.NotificationSubscription
43, // 18: protobuf.UpdateNotificationConfigRequest.dispatchers:type_name -> protobuf.NotificationDispatcher
44, // 19: protobuf.UpdateCloudConfigRequest.cloudConfig:type_name -> protobuf.NotificationCloudConfig
45, // 20: protobuf.GetNotificationStatsResponse.stats:type_name -> protobuf.NotificationSubscriptionStats
1, // 21: protobuf.ListContainersRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
1, // 22: protobuf.FindContainerRequest.FilterEntry.value:type_name -> protobuf.RepeatedString
0, // 23: protobuf.AgentService.ListContainers:input_type -> protobuf.ListContainersRequest
3, // 24: protobuf.AgentService.FindContainer:input_type -> protobuf.FindContainerRequest
5, // 25: protobuf.AgentService.StreamLogs:input_type -> protobuf.StreamLogsRequest
7, // 26: protobuf.AgentService.LogsBetweenDates:input_type -> protobuf.LogsBetweenDatesRequest
8, // 27: protobuf.AgentService.StreamRawBytes:input_type -> protobuf.StreamRawBytesRequest
10, // 28: protobuf.AgentService.StreamEvents:input_type -> protobuf.StreamEventsRequest
12, // 29: protobuf.AgentService.StreamStats:input_type -> protobuf.StreamStatsRequest
16, // 30: protobuf.AgentService.StreamContainerStarted:input_type -> protobuf.StreamContainerStartedRequest
14, // 31: protobuf.AgentService.HostInfo:input_type -> protobuf.HostInfoRequest
18, // 32: protobuf.AgentService.ContainerAction:input_type -> protobuf.ContainerActionRequest
20, // 33: protobuf.AgentService.UpdateContainer:input_type -> protobuf.UpdateContainerRequest
22, // 34: protobuf.AgentService.ContainerExec:input_type -> protobuf.ContainerExecRequest
25, // 35: protobuf.AgentService.ContainerAttach:input_type -> protobuf.ContainerAttachRequest
27, // 36: protobuf.AgentService.UpdateNotificationConfig:input_type -> protobuf.UpdateNotificationConfigRequest
29, // 37: protobuf.AgentService.UpdateCloudConfig:input_type -> protobuf.UpdateCloudConfigRequest
31, // 38: protobuf.AgentService.GetNotificationStats:input_type -> protobuf.GetNotificationStatsRequest
2, // 39: protobuf.AgentService.ListContainers:output_type -> protobuf.ListContainersResponse
4, // 40: protobuf.AgentService.FindContainer:output_type -> protobuf.FindContainerResponse
6, // 41: protobuf.AgentService.StreamLogs:output_type -> protobuf.StreamLogsResponse
6, // 42: protobuf.AgentService.LogsBetweenDates:output_type -> protobuf.StreamLogsResponse
9, // 43: protobuf.AgentService.StreamRawBytes:output_type -> protobuf.StreamRawBytesResponse
11, // 44: protobuf.AgentService.StreamEvents:output_type -> protobuf.StreamEventsResponse
13, // 45: protobuf.AgentService.StreamStats:output_type -> protobuf.StreamStatsResponse
17, // 46: protobuf.AgentService.StreamContainerStarted:output_type -> protobuf.StreamContainerStartedResponse
15, // 47: protobuf.AgentService.HostInfo:output_type -> protobuf.HostInfoResponse
19, // 48: protobuf.AgentService.ContainerAction:output_type -> protobuf.ContainerActionResponse
21, // 49: protobuf.AgentService.UpdateContainer:output_type -> protobuf.UpdateContainerProgress
24, // 50: protobuf.AgentService.ContainerExec:output_type -> protobuf.ContainerExecResponse
26, // 51: protobuf.AgentService.ContainerAttach:output_type -> protobuf.ContainerAttachResponse
28, // 52: protobuf.AgentService.UpdateNotificationConfig:output_type -> protobuf.UpdateNotificationConfigResponse
30, // 53: protobuf.AgentService.UpdateCloudConfig:output_type -> protobuf.UpdateCloudConfigResponse
32, // 54: protobuf.AgentService.GetNotificationStats:output_type -> protobuf.GetNotificationStatsResponse
39, // [39:55] is the sub-list for method output_type
23, // [23:39] is the sub-list for method input_type
23, // [23:23] is the sub-list for extension type_name
23, // [23:23] is the sub-list for extension extendee
0, // [0:23] is the sub-list for field type_name
}
func init() { file_rpc_proto_init() }
@@ -1568,11 +1881,11 @@ func file_rpc_proto_init() {
return
}
file_types_proto_init()
file_rpc_proto_msgTypes[20].OneofWrappers = []any{
file_rpc_proto_msgTypes[22].OneofWrappers = []any{
(*ContainerExecRequest_Stdin)(nil),
(*ContainerExecRequest_Resize)(nil),
}
file_rpc_proto_msgTypes[23].OneofWrappers = []any{
file_rpc_proto_msgTypes[25].OneofWrappers = []any{
(*ContainerAttachRequest_Stdin)(nil),
(*ContainerAttachRequest_Resize)(nil),
}
@@ -1582,7 +1895,7 @@ func file_rpc_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_rpc_proto_rawDesc), len(file_rpc_proto_rawDesc)),
NumEnums: 0,
NumMessages: 29,
NumMessages: 35,
NumExtensions: 0,
NumServices: 1,
},
+120 -3
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 v7.34.1
// source: rpc.proto
package pb
@@ -29,9 +29,12 @@ const (
AgentService_StreamContainerStarted_FullMethodName = "/protobuf.AgentService/StreamContainerStarted"
AgentService_HostInfo_FullMethodName = "/protobuf.AgentService/HostInfo"
AgentService_ContainerAction_FullMethodName = "/protobuf.AgentService/ContainerAction"
AgentService_UpdateContainer_FullMethodName = "/protobuf.AgentService/UpdateContainer"
AgentService_ContainerExec_FullMethodName = "/protobuf.AgentService/ContainerExec"
AgentService_ContainerAttach_FullMethodName = "/protobuf.AgentService/ContainerAttach"
AgentService_UpdateNotificationConfig_FullMethodName = "/protobuf.AgentService/UpdateNotificationConfig"
AgentService_UpdateCloudConfig_FullMethodName = "/protobuf.AgentService/UpdateCloudConfig"
AgentService_GetNotificationStats_FullMethodName = "/protobuf.AgentService/GetNotificationStats"
)
// AgentServiceClient is the client API for AgentService service.
@@ -48,9 +51,12 @@ type AgentServiceClient interface {
StreamContainerStarted(ctx context.Context, in *StreamContainerStartedRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StreamContainerStartedResponse], error)
HostInfo(ctx context.Context, in *HostInfoRequest, opts ...grpc.CallOption) (*HostInfoResponse, error)
ContainerAction(ctx context.Context, in *ContainerActionRequest, opts ...grpc.CallOption) (*ContainerActionResponse, error)
UpdateContainer(ctx context.Context, in *UpdateContainerRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[UpdateContainerProgress], error)
ContainerExec(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ContainerExecRequest, ContainerExecResponse], error)
ContainerAttach(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ContainerAttachRequest, ContainerAttachResponse], error)
UpdateNotificationConfig(ctx context.Context, in *UpdateNotificationConfigRequest, opts ...grpc.CallOption) (*UpdateNotificationConfigResponse, error)
UpdateCloudConfig(ctx context.Context, in *UpdateCloudConfigRequest, opts ...grpc.CallOption) (*UpdateCloudConfigResponse, error)
GetNotificationStats(ctx context.Context, in *GetNotificationStatsRequest, opts ...grpc.CallOption) (*GetNotificationStatsResponse, error)
}
type agentServiceClient struct {
@@ -215,9 +221,28 @@ func (c *agentServiceClient) ContainerAction(ctx context.Context, in *ContainerA
return out, nil
}
func (c *agentServiceClient) UpdateContainer(ctx context.Context, in *UpdateContainerRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[UpdateContainerProgress], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[6], AgentService_UpdateContainer_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[UpdateContainerRequest, UpdateContainerProgress]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type AgentService_UpdateContainerClient = grpc.ServerStreamingClient[UpdateContainerProgress]
func (c *agentServiceClient) ContainerExec(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ContainerExecRequest, ContainerExecResponse], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[6], AgentService_ContainerExec_FullMethodName, cOpts...)
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[7], AgentService_ContainerExec_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
@@ -230,7 +255,7 @@ type AgentService_ContainerExecClient = grpc.BidiStreamingClient[ContainerExecRe
func (c *agentServiceClient) ContainerAttach(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ContainerAttachRequest, ContainerAttachResponse], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[7], AgentService_ContainerAttach_FullMethodName, cOpts...)
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[8], AgentService_ContainerAttach_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
@@ -251,6 +276,26 @@ func (c *agentServiceClient) UpdateNotificationConfig(ctx context.Context, in *U
return out, nil
}
func (c *agentServiceClient) UpdateCloudConfig(ctx context.Context, in *UpdateCloudConfigRequest, opts ...grpc.CallOption) (*UpdateCloudConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateCloudConfigResponse)
err := c.cc.Invoke(ctx, AgentService_UpdateCloudConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *agentServiceClient) GetNotificationStats(ctx context.Context, in *GetNotificationStatsRequest, opts ...grpc.CallOption) (*GetNotificationStatsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetNotificationStatsResponse)
err := c.cc.Invoke(ctx, AgentService_GetNotificationStats_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AgentServiceServer is the server API for AgentService service.
// All implementations must embed UnimplementedAgentServiceServer
// for forward compatibility.
@@ -265,9 +310,12 @@ type AgentServiceServer interface {
StreamContainerStarted(*StreamContainerStartedRequest, grpc.ServerStreamingServer[StreamContainerStartedResponse]) error
HostInfo(context.Context, *HostInfoRequest) (*HostInfoResponse, error)
ContainerAction(context.Context, *ContainerActionRequest) (*ContainerActionResponse, error)
UpdateContainer(*UpdateContainerRequest, grpc.ServerStreamingServer[UpdateContainerProgress]) error
ContainerExec(grpc.BidiStreamingServer[ContainerExecRequest, ContainerExecResponse]) error
ContainerAttach(grpc.BidiStreamingServer[ContainerAttachRequest, ContainerAttachResponse]) error
UpdateNotificationConfig(context.Context, *UpdateNotificationConfigRequest) (*UpdateNotificationConfigResponse, error)
UpdateCloudConfig(context.Context, *UpdateCloudConfigRequest) (*UpdateCloudConfigResponse, error)
GetNotificationStats(context.Context, *GetNotificationStatsRequest) (*GetNotificationStatsResponse, error)
mustEmbedUnimplementedAgentServiceServer()
}
@@ -308,6 +356,9 @@ func (UnimplementedAgentServiceServer) HostInfo(context.Context, *HostInfoReques
func (UnimplementedAgentServiceServer) ContainerAction(context.Context, *ContainerActionRequest) (*ContainerActionResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ContainerAction not implemented")
}
func (UnimplementedAgentServiceServer) UpdateContainer(*UpdateContainerRequest, grpc.ServerStreamingServer[UpdateContainerProgress]) error {
return status.Error(codes.Unimplemented, "method UpdateContainer not implemented")
}
func (UnimplementedAgentServiceServer) ContainerExec(grpc.BidiStreamingServer[ContainerExecRequest, ContainerExecResponse]) error {
return status.Error(codes.Unimplemented, "method ContainerExec not implemented")
}
@@ -317,6 +368,12 @@ func (UnimplementedAgentServiceServer) ContainerAttach(grpc.BidiStreamingServer[
func (UnimplementedAgentServiceServer) UpdateNotificationConfig(context.Context, *UpdateNotificationConfigRequest) (*UpdateNotificationConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateNotificationConfig not implemented")
}
func (UnimplementedAgentServiceServer) UpdateCloudConfig(context.Context, *UpdateCloudConfigRequest) (*UpdateCloudConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateCloudConfig not implemented")
}
func (UnimplementedAgentServiceServer) GetNotificationStats(context.Context, *GetNotificationStatsRequest) (*GetNotificationStatsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetNotificationStats not implemented")
}
func (UnimplementedAgentServiceServer) mustEmbedUnimplementedAgentServiceServer() {}
func (UnimplementedAgentServiceServer) testEmbeddedByValue() {}
@@ -476,6 +533,17 @@ func _AgentService_ContainerAction_Handler(srv interface{}, ctx context.Context,
return interceptor(ctx, in, info, handler)
}
func _AgentService_UpdateContainer_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(UpdateContainerRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(AgentServiceServer).UpdateContainer(m, &grpc.GenericServerStream[UpdateContainerRequest, UpdateContainerProgress]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type AgentService_UpdateContainerServer = grpc.ServerStreamingServer[UpdateContainerProgress]
func _AgentService_ContainerExec_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(AgentServiceServer).ContainerExec(&grpc.GenericServerStream[ContainerExecRequest, ContainerExecResponse]{ServerStream: stream})
}
@@ -508,6 +576,42 @@ func _AgentService_UpdateNotificationConfig_Handler(srv interface{}, ctx context
return interceptor(ctx, in, info, handler)
}
func _AgentService_UpdateCloudConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateCloudConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServiceServer).UpdateCloudConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AgentService_UpdateCloudConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServiceServer).UpdateCloudConfig(ctx, req.(*UpdateCloudConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AgentService_GetNotificationStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetNotificationStatsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServiceServer).GetNotificationStats(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AgentService_GetNotificationStats_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServiceServer).GetNotificationStats(ctx, req.(*GetNotificationStatsRequest))
}
return interceptor(ctx, in, info, handler)
}
// AgentService_ServiceDesc is the grpc.ServiceDesc for AgentService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -535,6 +639,14 @@ var AgentService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UpdateNotificationConfig",
Handler: _AgentService_UpdateNotificationConfig_Handler,
},
{
MethodName: "UpdateCloudConfig",
Handler: _AgentService_UpdateCloudConfig_Handler,
},
{
MethodName: "GetNotificationStats",
Handler: _AgentService_GetNotificationStats_Handler,
},
},
Streams: []grpc.StreamDesc{
{
@@ -567,6 +679,11 @@ var AgentService_ServiceDesc = grpc.ServiceDesc{
Handler: _AgentService_StreamContainerStarted_Handler,
ServerStreams: true,
},
{
StreamName: "UpdateContainer",
Handler: _AgentService_UpdateContainer_Handler,
ServerStreams: true,
},
{
StreamName: "ContainerExec",
Handler: _AgentService_ContainerExec_Handler,
+298 -46
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 v7.34.1
// source: types.proto
package pb
@@ -93,6 +93,11 @@ type Container struct {
MemoryLimit uint64 `protobuf:"varint,17,opt,name=memoryLimit,proto3" json:"memoryLimit,omitempty"`
CpuLimit float64 `protobuf:"fixed64,18,opt,name=cpuLimit,proto3" json:"cpuLimit,omitempty"`
FullyLoaded bool `protobuf:"varint,19,opt,name=fullyLoaded,proto3" json:"fullyLoaded,omitempty"`
Env []string `protobuf:"bytes,20,rep,name=env,proto3" json:"env,omitempty"`
Ports []string `protobuf:"bytes,21,rep,name=ports,proto3" json:"ports,omitempty"`
Mounts []string `protobuf:"bytes,22,rep,name=mounts,proto3" json:"mounts,omitempty"`
RestartPolicy string `protobuf:"bytes,23,opt,name=restartPolicy,proto3" json:"restartPolicy,omitempty"`
NetworkMode string `protobuf:"bytes,24,opt,name=networkMode,proto3" json:"networkMode,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -260,6 +265,41 @@ func (x *Container) GetFullyLoaded() bool {
return false
}
func (x *Container) GetEnv() []string {
if x != nil {
return x.Env
}
return nil
}
func (x *Container) GetPorts() []string {
if x != nil {
return x.Ports
}
return nil
}
func (x *Container) GetMounts() []string {
if x != nil {
return x.Mounts
}
return nil
}
func (x *Container) GetRestartPolicy() string {
if x != nil {
return x.RestartPolicy
}
return ""
}
func (x *Container) GetNetworkMode() string {
if x != nil {
return x.NetworkMode
}
return ""
}
type ContainerStat struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
@@ -621,13 +661,14 @@ func (x *ComplexMessage) GetData() []byte {
}
type ContainerEvent struct {
state protoimpl.MessageState `protogen:"open.v1"`
ActorId string `protobuf:"bytes,1,opt,name=actorId,proto3" json:"actorId,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
Host string `protobuf:"bytes,3,opt,name=host,proto3" json:"host,omitempty"`
Timestamp *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
ActorId string `protobuf:"bytes,1,opt,name=actorId,proto3" json:"actorId,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
Host string `protobuf:"bytes,3,opt,name=host,proto3" json:"host,omitempty"`
Timestamp *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
ActorAttributes map[string]string `protobuf:"bytes,5,rep,name=actorAttributes,proto3" json:"actorAttributes,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ContainerEvent) Reset() {
@@ -688,6 +729,13 @@ func (x *ContainerEvent) GetTimestamp() *timestamppb.Timestamp {
return nil
}
func (x *ContainerEvent) GetActorAttributes() map[string]string {
if x != nil {
return x.ActorAttributes
}
return nil
}
type Host struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
@@ -828,6 +876,10 @@ type NotificationSubscription struct {
DispatcherId int32 `protobuf:"varint,4,opt,name=dispatcherId,proto3" json:"dispatcherId,omitempty"`
LogExpression string `protobuf:"bytes,5,opt,name=logExpression,proto3" json:"logExpression,omitempty"`
ContainerExpression string `protobuf:"bytes,6,opt,name=containerExpression,proto3" json:"containerExpression,omitempty"`
MetricExpression string `protobuf:"bytes,7,opt,name=metricExpression,proto3" json:"metricExpression,omitempty"`
Cooldown int32 `protobuf:"varint,8,opt,name=cooldown,proto3" json:"cooldown,omitempty"`
SampleWindow int32 `protobuf:"varint,9,opt,name=sampleWindow,proto3" json:"sampleWindow,omitempty"`
EventExpression string `protobuf:"bytes,10,opt,name=eventExpression,proto3" json:"eventExpression,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -904,6 +956,34 @@ func (x *NotificationSubscription) GetContainerExpression() string {
return ""
}
func (x *NotificationSubscription) GetMetricExpression() string {
if x != nil {
return x.MetricExpression
}
return ""
}
func (x *NotificationSubscription) GetCooldown() int32 {
if x != nil {
return x.Cooldown
}
return 0
}
func (x *NotificationSubscription) GetSampleWindow() int32 {
if x != nil {
return x.SampleWindow
}
return 0
}
func (x *NotificationSubscription) GetEventExpression() string {
if x != nil {
return x.EventExpression
}
return ""
}
type NotificationDispatcher struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
@@ -911,6 +991,7 @@ type NotificationDispatcher struct {
Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"`
Url string `protobuf:"bytes,4,opt,name=url,proto3" json:"url,omitempty"`
Template string `protobuf:"bytes,5,opt,name=template,proto3" json:"template,omitempty"`
Headers map[string]string `protobuf:"bytes,6,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -980,11 +1061,146 @@ func (x *NotificationDispatcher) GetTemplate() string {
return ""
}
func (x *NotificationDispatcher) GetHeaders() map[string]string {
if x != nil {
return x.Headers
}
return nil
}
type NotificationCloudConfig struct {
state protoimpl.MessageState `protogen:"open.v1"`
ApiKey string `protobuf:"bytes,1,opt,name=apiKey,proto3" json:"apiKey,omitempty"`
Prefix string `protobuf:"bytes,2,opt,name=prefix,proto3" json:"prefix,omitempty"`
ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=expiresAt,proto3" json:"expiresAt,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NotificationCloudConfig) Reset() {
*x = NotificationCloudConfig{}
mi := &file_types_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NotificationCloudConfig) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NotificationCloudConfig) ProtoMessage() {}
func (x *NotificationCloudConfig) ProtoReflect() protoreflect.Message {
mi := &file_types_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NotificationCloudConfig.ProtoReflect.Descriptor instead.
func (*NotificationCloudConfig) Descriptor() ([]byte, []int) {
return file_types_proto_rawDescGZIP(), []int{11}
}
func (x *NotificationCloudConfig) GetApiKey() string {
if x != nil {
return x.ApiKey
}
return ""
}
func (x *NotificationCloudConfig) GetPrefix() string {
if x != nil {
return x.Prefix
}
return ""
}
func (x *NotificationCloudConfig) GetExpiresAt() *timestamppb.Timestamp {
if x != nil {
return x.ExpiresAt
}
return nil
}
type NotificationSubscriptionStats struct {
state protoimpl.MessageState `protogen:"open.v1"`
SubscriptionId int32 `protobuf:"varint,1,opt,name=subscriptionId,proto3" json:"subscriptionId,omitempty"`
TriggerCount int64 `protobuf:"varint,2,opt,name=triggerCount,proto3" json:"triggerCount,omitempty"`
LastTriggeredAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=lastTriggeredAt,proto3" json:"lastTriggeredAt,omitempty"`
TriggeredContainerIds []string `protobuf:"bytes,4,rep,name=triggeredContainerIds,proto3" json:"triggeredContainerIds,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NotificationSubscriptionStats) Reset() {
*x = NotificationSubscriptionStats{}
mi := &file_types_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NotificationSubscriptionStats) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NotificationSubscriptionStats) ProtoMessage() {}
func (x *NotificationSubscriptionStats) ProtoReflect() protoreflect.Message {
mi := &file_types_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NotificationSubscriptionStats.ProtoReflect.Descriptor instead.
func (*NotificationSubscriptionStats) Descriptor() ([]byte, []int) {
return file_types_proto_rawDescGZIP(), []int{12}
}
func (x *NotificationSubscriptionStats) GetSubscriptionId() int32 {
if x != nil {
return x.SubscriptionId
}
return 0
}
func (x *NotificationSubscriptionStats) GetTriggerCount() int64 {
if x != nil {
return x.TriggerCount
}
return 0
}
func (x *NotificationSubscriptionStats) GetLastTriggeredAt() *timestamppb.Timestamp {
if x != nil {
return x.LastTriggeredAt
}
return nil
}
func (x *NotificationSubscriptionStats) GetTriggeredContainerIds() []string {
if x != nil {
return x.TriggeredContainerIds
}
return nil
}
var File_types_proto protoreflect.FileDescriptor
const file_types_proto_rawDesc = "" +
"\n" +
"\vtypes.proto\x12\bprotobuf\x1a\x19google/protobuf/any.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa2\x05\n" +
"\vtypes.proto\x12\bprotobuf\x1a\x19google/protobuf/any.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xaa\x06\n" +
"\tContainer\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" +
@@ -1005,7 +1221,12 @@ const file_types_proto_rawDesc = "" +
"\bfinished\x18\x10 \x01(\v2\x1a.google.protobuf.TimestampR\bfinished\x12 \n" +
"\vmemoryLimit\x18\x11 \x01(\x04R\vmemoryLimit\x12\x1a\n" +
"\bcpuLimit\x18\x12 \x01(\x01R\bcpuLimit\x12 \n" +
"\vfullyLoaded\x18\x13 \x01(\bR\vfullyLoaded\x1a9\n" +
"\vfullyLoaded\x18\x13 \x01(\bR\vfullyLoaded\x12\x10\n" +
"\x03env\x18\x14 \x03(\tR\x03env\x12\x14\n" +
"\x05ports\x18\x15 \x03(\tR\x05ports\x12\x16\n" +
"\x06mounts\x18\x16 \x03(\tR\x06mounts\x12$\n" +
"\rrestartPolicy\x18\x17 \x01(\tR\rrestartPolicy\x12 \n" +
"\vnetworkMode\x18\x18 \x01(\tR\vnetworkMode\x1a9\n" +
"\vLabelsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xd7\x01\n" +
@@ -1036,12 +1257,16 @@ const file_types_proto_rawDesc = "" +
"\fGroupMessage\x123\n" +
"\tfragments\x18\x01 \x03(\v2\x15.protobuf.LogFragmentR\tfragments\"$\n" +
"\x0eComplexMessage\x12\x12\n" +
"\x04data\x18\x01 \x01(\fR\x04data\"\x8c\x01\n" +
"\x04data\x18\x01 \x01(\fR\x04data\"\xa9\x02\n" +
"\x0eContainerEvent\x12\x18\n" +
"\aactorId\x18\x01 \x01(\tR\aactorId\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" +
"\x04host\x18\x03 \x01(\tR\x04host\x128\n" +
"\ttimestamp\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\"\xaf\x03\n" +
"\ttimestamp\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12W\n" +
"\x0factorAttributes\x18\x05 \x03(\v2-.protobuf.ContainerEvent.ActorAttributesEntryR\x0factorAttributes\x1aB\n" +
"\x14ActorAttributesEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xaf\x03\n" +
"\x04Host\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12 \n" +
@@ -1058,20 +1283,39 @@ const file_types_proto_rawDesc = "" +
"\rdockerVersion\x18\f \x01(\tR\rdockerVersion\x1a9\n" +
"\vLabelsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xd4\x01\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xea\x02\n" +
"\x18NotificationSubscription\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x05R\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x18\n" +
"\aenabled\x18\x03 \x01(\bR\aenabled\x12\"\n" +
"\fdispatcherId\x18\x04 \x01(\x05R\fdispatcherId\x12$\n" +
"\rlogExpression\x18\x05 \x01(\tR\rlogExpression\x120\n" +
"\x13containerExpression\x18\x06 \x01(\tR\x13containerExpression\"~\n" +
"\x13containerExpression\x18\x06 \x01(\tR\x13containerExpression\x12*\n" +
"\x10metricExpression\x18\a \x01(\tR\x10metricExpression\x12\x1a\n" +
"\bcooldown\x18\b \x01(\x05R\bcooldown\x12\"\n" +
"\fsampleWindow\x18\t \x01(\x05R\fsampleWindow\x12(\n" +
"\x0feventExpression\x18\n" +
" \x01(\tR\x0feventExpression\"\x95\x02\n" +
"\x16NotificationDispatcher\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x05R\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" +
"\x04type\x18\x03 \x01(\tR\x04type\x12\x10\n" +
"\x03url\x18\x04 \x01(\tR\x03url\x12\x1a\n" +
"\btemplate\x18\x05 \x01(\tR\btemplate*3\n" +
"\btemplate\x18\x05 \x01(\tR\btemplate\x12G\n" +
"\aheaders\x18\x06 \x03(\v2-.protobuf.NotificationDispatcher.HeadersEntryR\aheaders\x1a:\n" +
"\fHeadersEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\a\x10\bJ\x04\b\b\x10\tJ\x04\b\t\x10\n" +
"\"\x83\x01\n" +
"\x17NotificationCloudConfig\x12\x16\n" +
"\x06apiKey\x18\x01 \x01(\tR\x06apiKey\x12\x16\n" +
"\x06prefix\x18\x02 \x01(\tR\x06prefix\x128\n" +
"\texpiresAt\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"\xe7\x01\n" +
"\x1dNotificationSubscriptionStats\x12&\n" +
"\x0esubscriptionId\x18\x01 \x01(\x05R\x0esubscriptionId\x12\"\n" +
"\ftriggerCount\x18\x02 \x01(\x03R\ftriggerCount\x12D\n" +
"\x0flastTriggeredAt\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x0flastTriggeredAt\x124\n" +
"\x15triggeredContainerIds\x18\x04 \x03(\tR\x15triggeredContainerIds*3\n" +
"\x0fContainerAction\x12\t\n" +
"\x05Start\x10\x00\x12\b\n" +
"\x04Stop\x10\x01\x12\v\n" +
@@ -1090,41 +1334,49 @@ func file_types_proto_rawDescGZIP() []byte {
}
var file_types_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_types_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
var file_types_proto_msgTypes = make([]protoimpl.MessageInfo, 17)
var file_types_proto_goTypes = []any{
(ContainerAction)(0), // 0: protobuf.ContainerAction
(*Container)(nil), // 1: protobuf.Container
(*ContainerStat)(nil), // 2: protobuf.ContainerStat
(*LogFragment)(nil), // 3: protobuf.LogFragment
(*LogEvent)(nil), // 4: protobuf.LogEvent
(*SingleMessage)(nil), // 5: protobuf.SingleMessage
(*GroupMessage)(nil), // 6: protobuf.GroupMessage
(*ComplexMessage)(nil), // 7: protobuf.ComplexMessage
(*ContainerEvent)(nil), // 8: protobuf.ContainerEvent
(*Host)(nil), // 9: protobuf.Host
(*NotificationSubscription)(nil), // 10: protobuf.NotificationSubscription
(*NotificationDispatcher)(nil), // 11: protobuf.NotificationDispatcher
nil, // 12: protobuf.Container.LabelsEntry
nil, // 13: protobuf.Host.LabelsEntry
(*timestamppb.Timestamp)(nil), // 14: google.protobuf.Timestamp
(*anypb.Any)(nil), // 15: google.protobuf.Any
(ContainerAction)(0), // 0: protobuf.ContainerAction
(*Container)(nil), // 1: protobuf.Container
(*ContainerStat)(nil), // 2: protobuf.ContainerStat
(*LogFragment)(nil), // 3: protobuf.LogFragment
(*LogEvent)(nil), // 4: protobuf.LogEvent
(*SingleMessage)(nil), // 5: protobuf.SingleMessage
(*GroupMessage)(nil), // 6: protobuf.GroupMessage
(*ComplexMessage)(nil), // 7: protobuf.ComplexMessage
(*ContainerEvent)(nil), // 8: protobuf.ContainerEvent
(*Host)(nil), // 9: protobuf.Host
(*NotificationSubscription)(nil), // 10: protobuf.NotificationSubscription
(*NotificationDispatcher)(nil), // 11: protobuf.NotificationDispatcher
(*NotificationCloudConfig)(nil), // 12: protobuf.NotificationCloudConfig
(*NotificationSubscriptionStats)(nil), // 13: protobuf.NotificationSubscriptionStats
nil, // 14: protobuf.Container.LabelsEntry
nil, // 15: protobuf.ContainerEvent.ActorAttributesEntry
nil, // 16: protobuf.Host.LabelsEntry
nil, // 17: protobuf.NotificationDispatcher.HeadersEntry
(*timestamppb.Timestamp)(nil), // 18: google.protobuf.Timestamp
(*anypb.Any)(nil), // 19: google.protobuf.Any
}
var file_types_proto_depIdxs = []int32{
14, // 0: protobuf.Container.created:type_name -> google.protobuf.Timestamp
14, // 1: protobuf.Container.started:type_name -> google.protobuf.Timestamp
12, // 2: protobuf.Container.labels:type_name -> protobuf.Container.LabelsEntry
18, // 0: protobuf.Container.created:type_name -> google.protobuf.Timestamp
18, // 1: protobuf.Container.started:type_name -> google.protobuf.Timestamp
14, // 2: protobuf.Container.labels:type_name -> protobuf.Container.LabelsEntry
2, // 3: protobuf.Container.stats:type_name -> protobuf.ContainerStat
14, // 4: protobuf.Container.finished:type_name -> google.protobuf.Timestamp
15, // 5: protobuf.LogEvent.message:type_name -> google.protobuf.Any
14, // 6: protobuf.LogEvent.timestamp:type_name -> google.protobuf.Timestamp
18, // 4: protobuf.Container.finished:type_name -> google.protobuf.Timestamp
19, // 5: protobuf.LogEvent.message:type_name -> google.protobuf.Any
18, // 6: protobuf.LogEvent.timestamp:type_name -> google.protobuf.Timestamp
3, // 7: protobuf.GroupMessage.fragments:type_name -> protobuf.LogFragment
14, // 8: protobuf.ContainerEvent.timestamp:type_name -> google.protobuf.Timestamp
13, // 9: protobuf.Host.labels:type_name -> protobuf.Host.LabelsEntry
10, // [10:10] is the sub-list for method output_type
10, // [10:10] is the sub-list for method input_type
10, // [10:10] is the sub-list for extension type_name
10, // [10:10] is the sub-list for extension extendee
0, // [0:10] is the sub-list for field type_name
18, // 8: protobuf.ContainerEvent.timestamp:type_name -> google.protobuf.Timestamp
15, // 9: protobuf.ContainerEvent.actorAttributes:type_name -> protobuf.ContainerEvent.ActorAttributesEntry
16, // 10: protobuf.Host.labels:type_name -> protobuf.Host.LabelsEntry
17, // 11: protobuf.NotificationDispatcher.headers:type_name -> protobuf.NotificationDispatcher.HeadersEntry
18, // 12: protobuf.NotificationCloudConfig.expiresAt:type_name -> google.protobuf.Timestamp
18, // 13: protobuf.NotificationSubscriptionStats.lastTriggeredAt:type_name -> google.protobuf.Timestamp
14, // [14:14] is the sub-list for method output_type
14, // [14:14] is the sub-list for method input_type
14, // [14:14] is the sub-list for extension type_name
14, // [14:14] is the sub-list for extension extendee
0, // [0:14] is the sub-list for field type_name
}
func init() { file_types_proto_init() }
@@ -1138,7 +1390,7 @@ func file_types_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_types_proto_rawDesc), len(file_types_proto_rawDesc)),
NumEnums: 1,
NumMessages: 13,
NumMessages: 17,
NumExtensions: 0,
NumServices: 0,
},
+96 -10
View File
@@ -14,12 +14,14 @@ import (
"github.com/amir20/dozzle/internal/agent/pb"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/internal/notification/dispatcher"
"github.com/amir20/dozzle/types"
"github.com/rs/zerolog/log"
orderedmap "github.com/wk8/go-ordered-map/v2"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/keepalive"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -29,6 +31,9 @@ import (
// NotificationConfigHandler handles notification config updates received from the main server
type NotificationConfigHandler interface {
HandleNotificationConfig(subscriptions []types.SubscriptionConfig, dispatchers []types.DispatcherConfig) error
SetCloudDispatcher(d dispatcher.Dispatcher)
ClearCloudDispatcher()
GetNotificationStats() []types.SubscriptionStats
}
// ClientService is the interface for container operations used by the agent server
@@ -37,6 +42,7 @@ type ClientService interface {
ListContainers(ctx context.Context, filter container.ContainerLabels) ([]container.Container, error)
Host(ctx context.Context) (container.Host, error)
ContainerAction(ctx context.Context, container container.Container, action container.ContainerAction) error
UpdateContainer(ctx context.Context, container container.Container, progressCh chan<- container.UpdateProgress) (bool, error)
LogsBetweenDates(ctx context.Context, container container.Container, from time.Time, to time.Time, stdTypes container.StdType) (<-chan *container.LogEvent, error)
RawLogs(ctx context.Context, container container.Container, from time.Time, to time.Time, stdTypes container.StdType) (io.ReadCloser, error)
SubscribeStats(context.Context, chan<- container.ContainerStat)
@@ -56,6 +62,10 @@ type server struct {
}
func newServer(service ClientService, dozzleVersion string, notificationHandler NotificationConfigHandler) pb.AgentServiceServer {
if notificationHandler == nil {
log.Fatal().Msg("No notification config handler registered")
}
return &server{
service: service,
version: dozzleVersion,
@@ -164,10 +174,11 @@ func (s *server) StreamEvents(in *pb.StreamEventsRequest, out pb.AgentService_St
case event := <-events:
out.Send(&pb.StreamEventsResponse{
Event: &pb.ContainerEvent{
ActorId: event.ActorID,
Name: event.Name,
Host: event.Host,
Timestamp: timestamppb.New(event.Time),
ActorId: event.ActorID,
Name: event.Name,
Host: event.Host,
Timestamp: timestamppb.New(event.Time),
ActorAttributes: event.ActorAttributes,
},
})
case <-out.Context().Done():
@@ -306,6 +317,35 @@ func (s *server) ContainerAction(ctx context.Context, in *pb.ContainerActionRequ
return &pb.ContainerActionResponse{}, nil
}
func (s *server) UpdateContainer(req *pb.UpdateContainerRequest, out pb.AgentService_UpdateContainerServer) error {
c, err := s.service.FindContainer(out.Context(), req.ContainerId, container.ContainerLabels{})
if err != nil {
return status.Error(codes.NotFound, err.Error())
}
progressCh := make(chan container.UpdateProgress)
errCh := make(chan error, 1)
go func() {
_, err := s.service.UpdateContainer(out.Context(), c, progressCh)
errCh <- err
}()
for progress := range progressCh {
if err := out.Send(&pb.UpdateContainerProgress{
Status: progress.Status,
Layer: progress.Layer,
Current: progress.Current,
Total: progress.Total,
Error: progress.Error,
}); err != nil {
return err
}
}
return <-errCh
}
// terminalMessage represents a message from a terminal gRPC stream (exec or attach)
type terminalMessage interface {
GetStdin() []byte
@@ -388,11 +428,6 @@ func (s *server) ContainerAttach(stream pb.AgentService_ContainerAttachServer) e
}
func (s *server) UpdateNotificationConfig(ctx context.Context, req *pb.UpdateNotificationConfigRequest) (*pb.UpdateNotificationConfigResponse, error) {
if s.notificationConfigHandler == nil {
log.Warn().Msg("No notification config handler registered, ignoring config update")
return &pb.UpdateNotificationConfigResponse{}, nil
}
// Validate request sizes to prevent memory exhaustion
const maxSubscriptions = 1000
const maxDispatchers = 100
@@ -413,6 +448,10 @@ func (s *server) UpdateNotificationConfig(ctx context.Context, req *pb.UpdateNot
DispatcherID: int(sub.DispatcherId),
LogExpression: sub.LogExpression,
ContainerExpression: sub.ContainerExpression,
MetricExpression: sub.MetricExpression,
Cooldown: int(sub.Cooldown),
SampleWindow: int(sub.SampleWindow),
EventExpression: sub.EventExpression,
}
}
@@ -425,6 +464,7 @@ func (s *server) UpdateNotificationConfig(ctx context.Context, req *pb.UpdateNot
Type: d.Type,
URL: d.Url,
Template: d.Template,
Headers: d.Headers,
}
}
@@ -438,6 +478,46 @@ func (s *server) UpdateNotificationConfig(ctx context.Context, req *pb.UpdateNot
return &pb.UpdateNotificationConfigResponse{}, nil
}
func (s *server) UpdateCloudConfig(ctx context.Context, req *pb.UpdateCloudConfigRequest) (*pb.UpdateCloudConfigResponse, error) {
if cc := req.CloudConfig; cc != nil && cc.ApiKey != "" {
var expiresAt *time.Time
if cc.ExpiresAt != nil {
t := cc.ExpiresAt.AsTime()
expiresAt = &t
}
d, err := dispatcher.NewCloudDispatcher("Dozzle Cloud", cc.ApiKey, cc.Prefix, expiresAt)
if err != nil {
log.Error().Err(err).Msg("Failed to create cloud dispatcher from broadcast config")
return nil, status.Error(codes.Internal, err.Error())
}
s.notificationConfigHandler.SetCloudDispatcher(d)
log.Info().Msg("Updated cloud config from main server")
} else {
s.notificationConfigHandler.ClearCloudDispatcher()
log.Info().Msg("Cleared cloud config from main server")
}
return &pb.UpdateCloudConfigResponse{}, nil
}
func (s *server) GetNotificationStats(ctx context.Context, req *pb.GetNotificationStatsRequest) (*pb.GetNotificationStatsResponse, error) {
stats := s.notificationConfigHandler.GetNotificationStats()
pbStats := make([]*pb.NotificationSubscriptionStats, len(stats))
for i, s := range stats {
pbStat := &pb.NotificationSubscriptionStats{
SubscriptionId: int32(s.SubscriptionID),
TriggerCount: s.TriggerCount,
TriggeredContainerIds: s.TriggeredContainerIDs,
}
if s.LastTriggeredAt != nil {
pbStat.LastTriggeredAt = timestamppb.New(*s.LastTriggeredAt)
}
pbStats[i] = pbStat
}
return &pb.GetNotificationStatsResponse{Stats: pbStats}, nil
}
func NewServer(service ClientService, certificates tls.Certificate, dozzleVersion string, notificationHandler NotificationConfigHandler) (*grpc.Server, error) {
caCertPool := x509.NewCertPool()
c, err := x509.ParseCertificate(certificates.Certificate[0])
@@ -456,7 +536,13 @@ func NewServer(service ClientService, certificates tls.Certificate, dozzleVersio
// Create the gRPC server with the credentials
creds := credentials.NewTLS(tlsConfig)
grpcServer := grpc.NewServer(grpc.Creds(creds))
grpcServer := grpc.NewServer(
grpc.Creds(creds),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 15 * time.Second,
PermitWithoutStream: true,
}),
)
pb.RegisterAgentServiceServer(grpcServer, newServer(service, dozzleVersion, notificationHandler))
return grpcServer, nil
+390
View File
@@ -0,0 +1,390 @@
package cloud
import (
"context"
"errors"
"fmt"
"math/rand/v2"
"os"
"strings"
"sync"
"time"
"github.com/amir20/dozzle/internal/container"
"github.com/amir20/dozzle/internal/notification/dispatcher"
"github.com/amir20/dozzle/internal/support/cli"
pb "github.com/amir20/dozzle/proto/cloud"
"github.com/rs/zerolog/log"
"golang.org/x/sync/semaphore"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
const (
initialBackoff = 1 * time.Second
maxBackoff = 30 * time.Second
backoffFactor = 2
jitterFraction = 0.1
maxConcurrent = 5
maxConcurrentStreams = 10
unauthenticatedPause = 1 * time.Hour
)
// Client manages the gRPC connection to Dozzle Cloud
type Client struct {
enableActions bool
labels container.ContainerLabels
hostService ToolHostService
apiKeyFunc func() string
target string
plaintext bool
toolSem *semaphore.Weighted
streamSem *semaphore.Weighted
cachedTools []*pb.ToolDefinition
toolsOnce sync.Once
startCh chan struct{}
activeStreams sync.Map // requestID -> context.CancelFunc
}
// NewClient creates a new cloud gRPC client.
// apiKeyFunc is called to get the current cloud API key — it may return ""
// if no cloud dispatcher is configured yet, in which case the client waits.
func NewClient(enableActions bool, labels container.ContainerLabels, hostService ToolHostService, apiKeyFunc func() string) *Client {
cloudURL := os.Getenv("AGENT_URL")
if cloudURL == "" {
cloudURL = "https://agent.doligence.dozzle.dev"
}
// Support plaintext for local dev (AGENT_URL=http://localhost:7008)
plaintext := strings.HasPrefix(cloudURL, "http://")
target := cloudURL
target = strings.TrimPrefix(target, "https://")
target = strings.TrimPrefix(target, "http://")
if !strings.Contains(target, ":") {
if plaintext {
target = target + ":80"
} else {
target = target + ":443"
}
}
return &Client{
enableActions: enableActions,
labels: labels,
hostService: hostService,
apiKeyFunc: apiKeyFunc,
target: target,
plaintext: plaintext,
toolSem: semaphore.NewWeighted(maxConcurrent),
streamSem: semaphore.NewWeighted(maxConcurrentStreams),
startCh: make(chan struct{}, 1),
}
}
// Notify signals the client to attempt a connection. Safe to call multiple times.
// Use this when a cloud dispatcher is added or when the status page is viewed.
func (c *Client) Notify() {
select {
case c.startCh <- struct{}{}:
default:
}
}
// Run blocks until signaled via Notify(), then connects to the cloud gRPC endpoint
// and processes tool requests. Reconnects automatically on failure.
// Does nothing until Notify() is called — zero overhead for non-cloud users.
// Blocks until ctx is cancelled.
func (c *Client) Run(ctx context.Context) {
// Wait for signal to start
select {
case <-ctx.Done():
return
case <-c.startCh:
}
backoff := initialBackoff
backoffTimer := time.NewTimer(0)
backoffTimer.Stop()
defer backoffTimer.Stop()
for {
apiKey := c.apiKeyFunc()
if apiKey == "" {
// Cloud dispatcher was removed — go back to waiting for signal
select {
case <-ctx.Done():
return
case <-c.startCh:
}
continue
}
wasConnected, err := c.connect(ctx, apiKey)
if ctx.Err() != nil {
return
}
if wasConnected {
backoff = initialBackoff
}
if err != nil {
if isPermissionDenied(err) {
log.Debug().Msg("cloud account does not have pro plan, waiting for upgrade")
select {
case <-ctx.Done():
return
case <-c.startCh:
}
backoff = initialBackoff
continue
}
if isUnauthenticated(err) {
log.Warn().Err(err).Dur("pause", unauthenticatedPause).Msg("invalid API key, pausing before retry")
backoffTimer.Reset(unauthenticatedPause)
select {
case <-ctx.Done():
return
case <-backoffTimer.C:
}
backoff = initialBackoff
continue
}
log.Warn().Err(err).Dur("backoff", backoff).Msg("cloud connection failed, reconnecting")
}
jitter := time.Duration(float64(backoff) * jitterFraction * rand.Float64())
backoffTimer.Reset(backoff + jitter)
select {
case <-ctx.Done():
return
case <-backoffTimer.C:
}
backoff = min(backoff*backoffFactor, maxBackoff)
}
}
// connect establishes a gRPC stream to the cloud and processes requests.
// Returns wasConnected=true if the stream was successfully established and
// at least one message was received before disconnecting.
func (c *Client) connect(ctx context.Context, apiKey string) (wasConnected bool, err error) {
var creds grpc.DialOption
if c.plaintext {
creds = grpc.WithTransportCredentials(insecure.NewCredentials())
} else {
creds = grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, ""))
}
conn, err := grpc.NewClient(c.target, creds, grpc.WithUserAgent(dispatcher.UserAgent),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second,
Timeout: 10 * time.Second,
PermitWithoutStream: true,
}),
)
if err != nil {
return false, fmt.Errorf("failed to dial cloud: %w", err)
}
defer conn.Close()
client := pb.NewCloudToolServiceClient(conn)
md := metadata.Pairs("x-api-key", apiKey)
streamCtx := metadata.NewOutgoingContext(ctx, md)
stream, err := client.ToolStream(streamCtx)
if err != nil {
return false, fmt.Errorf("failed to open tool stream: %w", err)
}
log.Debug().Str("target", c.target).Msg("connected to cloud tool service")
streamLifetime, streamCancel := context.WithCancel(stream.Context())
var sendMu sync.Mutex
var wg sync.WaitGroup
sendResp := func(resp *pb.ToolResponse) error {
sendMu.Lock()
defer sendMu.Unlock()
return stream.Send(resp)
}
defer func() {
// Cancel all active log streams before shutting down
c.activeStreams.Range(func(key, value any) bool {
if cancel, ok := value.(context.CancelFunc); ok {
cancel()
}
c.activeStreams.Delete(key)
return true
})
streamCancel()
wg.Wait()
}()
for {
req, err := stream.Recv()
if err != nil {
return wasConnected, fmt.Errorf("stream recv error: %w", err)
}
wasConnected = true
// Handle cancel stream request
if cancelReq, ok := req.Type.(*pb.ToolRequest_CancelStream); ok {
streamReqID := cancelReq.CancelStream.StreamRequestId
if cancel, loaded := c.activeStreams.LoadAndDelete(streamReqID); loaded {
if cancelFn, ok := cancel.(context.CancelFunc); ok {
log.Debug().Str("request_id", streamReqID).Msg("cancelling active stream")
cancelFn()
}
} else {
log.Warn().Str("request_id", streamReqID).Msg("cancel_stream: no active stream found")
}
continue
}
// Handle stream_logs specially — long-lived, separate semaphore from tool calls
if callReq, ok := req.Type.(*pb.ToolRequest_CallTool); ok && callReq.CallTool.Name == "stream_logs" {
if !c.streamSem.TryAcquire(1) {
resp := &pb.ToolResponse{
RequestId: req.RequestId,
Type: &pb.ToolResponse_CallTool{
CallTool: &pb.CallToolResponse{
Success: false,
Error: "too many concurrent log streams",
},
},
}
if err := sendResp(resp); err != nil {
return wasConnected, fmt.Errorf("stream send error: %w", err)
}
continue
}
logStreamCtx, logStreamCancel := context.WithCancel(streamLifetime)
c.activeStreams.Store(req.RequestId, logStreamCancel)
reqID := req.RequestId
argsJSON := callReq.CallTool.ArgumentsJson
wg.Go(func() {
defer func() {
c.streamSem.Release(1)
c.activeStreams.Delete(reqID)
logStreamCancel()
}()
log.Debug().Str("request_id", reqID).Msg("starting stream_logs")
if err := executeStreamLogs(logStreamCtx, reqID, argsJSON, c.hostService, c.labels, sendResp); err != nil {
if streamLifetime.Err() != nil {
return
}
log.Debug().Err(err).Str("request_id", reqID).Msg("stream_logs ended")
}
})
continue
}
// List tools is fast — handle inline. Tool calls run concurrently with a semaphore.
if _, ok := req.Type.(*pb.ToolRequest_CallTool); ok {
if !c.toolSem.TryAcquire(1) {
resp := &pb.ToolResponse{
RequestId: req.RequestId,
Type: &pb.ToolResponse_CallTool{
CallTool: &pb.CallToolResponse{
Success: false,
Error: "too many concurrent tool calls",
},
},
}
if err := sendResp(resp); err != nil {
return wasConnected, fmt.Errorf("stream send error: %w", err)
}
continue
}
wg.Go(func() {
defer c.toolSem.Release(1)
resp := c.handleRequest(streamLifetime, req)
if streamLifetime.Err() != nil {
return
}
if err := sendResp(resp); err != nil {
log.Debug().Err(err).Msg("failed to send tool response")
}
})
} else {
resp := c.handleRequest(streamLifetime, req)
if err := sendResp(resp); err != nil {
return wasConnected, fmt.Errorf("stream send error: %w", err)
}
}
}
}
func (c *Client) handleRequest(ctx context.Context, req *pb.ToolRequest) *pb.ToolResponse {
resp := &pb.ToolResponse{
RequestId: req.RequestId,
}
switch t := req.Type.(type) {
case *pb.ToolRequest_ListTools:
log.Debug().Str("request_id", req.RequestId).Msg("cloud requested tool list")
resp.Type = &pb.ToolResponse_ListTools{
ListTools: &pb.ListToolsResponse{
Tools: c.tools(),
Version: cli.Version,
},
}
case *pb.ToolRequest_CallTool:
log.Debug().Str("request_id", req.RequestId).Str("tool", t.CallTool.Name).Str("args", t.CallTool.ArgumentsJson).Msg("cloud tool call received")
callResp := ExecuteTool(ctx, t.CallTool.Name, t.CallTool.ArgumentsJson, c.enableActions, c.hostService, c.labels)
if !callResp.Success {
log.Debug().Str("error", callResp.Error).Str("request_id", req.RequestId).Str("tool", t.CallTool.Name).Msg("cloud tool call failed")
} else {
log.Debug().Str("request_id", req.RequestId).Str("tool", t.CallTool.Name).Msg("cloud tool call completed")
}
resp.Type = &pb.ToolResponse_CallTool{
CallTool: callResp,
}
default:
log.Warn().Msg("received unknown tool request type")
resp.Type = &pb.ToolResponse_CallTool{
CallTool: &pb.CallToolResponse{
Success: false,
Error: "unknown request type",
},
}
}
return resp
}
func (c *Client) tools() []*pb.ToolDefinition {
c.toolsOnce.Do(func() {
c.cachedTools = AvailableTools(c.enableActions)
})
return c.cachedTools
}
func isPermissionDenied(err error) bool {
return hasGRPCCode(err, codes.PermissionDenied)
}
func isUnauthenticated(err error) bool {
return hasGRPCCode(err, codes.Unauthenticated)
}
func hasGRPCCode(err error, code codes.Code) bool {
for e := err; e != nil; e = errors.Unwrap(e) {
if s, ok := status.FromError(e); ok && s.Code() == code {
return true
}
}
return false
}
+156
View File
@@ -0,0 +1,156 @@
package cloud
import (
"context"
"testing"
"github.com/amir20/dozzle/internal/container"
container_support "github.com/amir20/dozzle/internal/support/container"
pb "github.com/amir20/dozzle/proto/cloud"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestNewClient_DefaultURL(t *testing.T) {
t.Setenv("AGENT_URL", "")
client := NewClient(true, nil, nil, func() string { return "test-key" })
assert.Equal(t, "agent.doligence.dozzle.dev:443", client.target)
}
func TestNewClient_CustomURL(t *testing.T) {
t.Setenv("AGENT_URL", "https://custom.cloud.dev")
client := NewClient(true, nil, nil, func() string { return "test-key" })
assert.Equal(t, "custom.cloud.dev:443", client.target)
assert.False(t, client.plaintext)
}
func TestNewClient_PlaintextURL(t *testing.T) {
t.Setenv("AGENT_URL", "http://localhost:7008")
client := NewClient(true, nil, nil, func() string { return "test-key" })
assert.Equal(t, "localhost:7008", client.target)
assert.True(t, client.plaintext)
}
func TestHandleRequest_ListTools(t *testing.T) {
client := &Client{
enableActions: true,
}
req := &pb.ToolRequest{
RequestId: "req-1",
Type: &pb.ToolRequest_ListTools{
ListTools: &pb.ListToolsRequest{},
},
}
resp := client.handleRequest(context.Background(), req)
assert.Equal(t, "req-1", resp.RequestId)
listResp := resp.GetListTools()
assert.NotNil(t, listResp)
assert.Len(t, listResp.Tools, 12) // list_hosts + find_containers + list_running/all + get_stats + fetch_logs + stream_logs + inspect_container + 3 actions + update
}
func TestHandleRequest_ListTools_ActionsDisabled(t *testing.T) {
client := &Client{
enableActions: false,
}
req := &pb.ToolRequest{
RequestId: "req-2",
Type: &pb.ToolRequest_ListTools{
ListTools: &pb.ListToolsRequest{},
},
}
resp := client.handleRequest(context.Background(), req)
listResp := resp.GetListTools()
assert.Len(t, listResp.Tools, 8) // list_hosts + find_containers + list_running/all + get_stats + fetch_logs + stream_logs + inspect_container
}
func TestHandleRequest_CallTool_ListContainers(t *testing.T) {
mockHost := &MockHostService{}
mockHost.On("ListAllContainers", container.ContainerLabels(nil)).Return([]container.Container{
{ID: "abc", Name: "nginx", Image: "nginx:latest", State: "running", Host: "local"},
}, nil)
mockHost.On("Hosts").Return([]container.Host{{ID: "local", Name: "my-server"}})
client := &Client{
hostService: mockHost,
}
req := &pb.ToolRequest{
RequestId: "req-3",
Type: &pb.ToolRequest_CallTool{
CallTool: &pb.CallToolRequest{
Name: "list_running_containers",
ArgumentsJson: "",
},
},
}
resp := client.handleRequest(context.Background(), req)
callResp := resp.GetCallTool()
assert.True(t, callResp.Success)
result := callResp.GetListContainers()
assert.NotNil(t, result)
assert.Len(t, result.Containers, 1)
assert.Equal(t, "nginx", result.Containers[0].Name)
}
func TestHandleRequest_CallTool_UnknownTool(t *testing.T) {
mockHost := &MockHostService{}
client := &Client{
hostService: mockHost,
}
req := &pb.ToolRequest{
RequestId: "req-4",
Type: &pb.ToolRequest_CallTool{
CallTool: &pb.CallToolRequest{
Name: "nonexistent",
ArgumentsJson: "",
},
},
}
resp := client.handleRequest(context.Background(), req)
callResp := resp.GetCallTool()
assert.False(t, callResp.Success)
assert.Contains(t, callResp.Error, "unknown tool")
}
func TestHandleRequest_CallTool_RestartContainer(t *testing.T) {
mockClient := &MockClientService{}
mockClient.On("ContainerAction", mock.Anything, mock.Anything, container.Restart).Return(nil)
cs := container_support.NewContainerService(mockClient, container.Container{ID: "abc123"})
mockHost := &MockHostService{}
mockHost.On("FindContainer", "local", "abc123", container.ContainerLabels(nil)).Return(cs, nil)
client := &Client{
hostService: mockHost,
enableActions: true,
}
req := &pb.ToolRequest{
RequestId: "req-5",
Type: &pb.ToolRequest_CallTool{
CallTool: &pb.CallToolRequest{
Name: "restart_container",
ArgumentsJson: `{"container_id": "abc123", "host_id": "local"}`,
},
},
}
resp := client.handleRequest(context.Background(), req)
callResp := resp.GetCallTool()
assert.True(t, callResp.Success)
mockClient.AssertCalled(t, "ContainerAction", mock.Anything, mock.Anything, container.Restart)
}

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