Compare commits

..

209 Commits

Author SHA1 Message Date
Amir Raminfar 956e08c44e fix: remove host_id from cloud tool parameters to prevent LLM hallucination
The LLM was fabricating Swarm-style host IDs instead of using actual host IDs,
causing tool calls to fail. Since container IDs are unique across hosts, we now
resolve the host automatically via ListAllContainers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:36:48 -07: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
Amir Raminfar 161b8d91d7 chore: release v10.0.1 2026-02-12 16:20:13 -08:00
Amir Raminfar 585771b804 fix!: Removes the sha256 support. Dozzle now does a fatal exit (#4429) 2026-02-11 16:23:32 -08:00
Amir Raminfar 78362b35fd chore: fixes workflows 2026-02-11 14:54:47 -08:00
Amir Raminfar fa8f6e4aeb feat: implements more dynamic filenames when downloading 📦 (#4428) 2026-02-11 14:15:30 -08:00
Amir Raminfar c605bbc1bb fix: removes ansi colors when sending to webhook (#4427) 2026-02-11 13:29:58 -08:00
Amir Raminfar f1d4e682db chore: adds dozzle cloud 2026-02-11 07:12:19 -08:00
renovate[bot] fb65dc9b7d fix(deps): update all non-major dependencies (#4424)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-11 14:19:32 +00:00
Amir Raminfar 2324e511b2 docs: adds v10 release (#4417) 2026-02-10 16:14:46 -08:00
Amir Raminfar cc82f9f993 chore: release v10.0.0 2026-02-10 16:14:21 -08:00
renovate[bot] e48aa3139c fix(deps): update all non-major dependencies (#4423)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 23:03:25 +00:00
Amir Raminfar aef834ba06 chore: ignore dozzle 2026-02-09 13:08:31 -08:00
renovate[bot] 3678332ab2 chore(deps): update pnpm to v10.29.2 (#4419)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 02:02:38 +00:00
renovate[bot] 30614269c7 fix(deps): update dependency @codemirror/view to ^6.39.13 (#4418)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-08 18:17:33 +00:00
Amir Raminfar 6b2af1cf3a chore: fixes bugs on destination (#4416) 2026-02-08 16:00:52 +00:00
Amir Raminfar 80220f2388 chore: clean up 2026-02-08 07:33:22 -08:00
renovate[bot] 98ab88aa71 chore(deps): update all non-major dependencies (#4415)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-08 05:31:41 +00:00
Amir Raminfar c5af5a6f5e feat: adds json sytanx to template (#4414) 2026-02-07 21:26:19 -08:00
Jordy b8f224c13d feat: Expand trace level aliases in level_guesser.go (#4413) 2026-02-07 13:40:44 -08:00
Amir Raminfar 40f335265f feat: adds shortcut for creating alerts (#4411) 2026-02-07 08:16:12 -08:00
renovate[bot] 9ac5fcc2c4 chore(deps): update all non-major dependencies (#4410)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-07 03:18:48 +00:00
renovate[bot] 8d38034c86 chore(deps): update dependency jsdom to v28 (#4409)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-06 15:10:28 +00:00
Amir Raminfar 8c231d7ba7 chore: implements circuit break when too many requests recevied (#4406) 2026-02-06 07:05:19 -08:00
renovate[bot] 0a1d854168 fix(deps): update all non-major dependencies (#4408)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-06 15:03:24 +00:00
Amir Raminfar 425d9e4fdd perf: improves performance by removing watcher on containers (#4394) 2026-02-02 08:47:14 -08:00
renovate[bot] 21676d9917 fix(deps): update all non-major dependencies (#4404)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-02 00:39:31 +00:00
Amir Raminfar c923980ae6 chore: adds host name (#4403) 2026-02-01 12:31:59 -08:00
Amir Raminfar 87ea90966e perf: implements a semaphore when too many requests happen in notificaiton manager (#4402) 2026-02-01 08:53:54 -08:00
Amir Raminfar cd15f91447 fix: fixes broken UI on smaller screens (#4400) 2026-01-31 13:03:11 -08:00
Amir Raminfar 8800bc8c37 chore: fixes typecheck (#4401) 2026-01-31 12:53:45 -08:00
Amir Raminfar ecdcd5fb26 fix: fixes alert edit not loading destination correctly (#4399) 2026-01-31 15:18:43 +00:00
Amir Raminfar ff5867eaca fix: fixes scrolling down in historical logs (#4398) 2026-01-31 07:00:35 -08:00
Amir Raminfar e5422513f7 chore: fixes typecheck 2026-01-30 11:36:53 -08:00
Amir Raminfar cd164e62c5 chore: clean up 2026-01-30 11:11:41 -08:00
Amir Raminfar 03d6c6a41a chore: fixes preview 2026-01-29 16:58:20 -08:00
Amir Raminfar 7131a6bdf8 chore: clean up 2026-01-29 09:20:02 -08:00
Amir Raminfar 4c523d5185 chore: improves README. Fixes #4395 2026-01-29 07:15:48 -08:00
Amir Raminfar 69b4d8b651 chore: makes the experience better for cloud linking (#4393) 2026-01-29 07:05:16 -08:00
renovate[bot] dc41e018ca chore(deps): update docker/login-action action to v3.7.0 (#4392)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-28 15:51:45 +00:00
renovate[bot] ca3cd93e9a chore(deps): update all non-major dependencies (#4391)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 21:02:32 +00:00
Amir Raminfar 9224a4d1f9 chore: removes graphql (#4389) 2026-01-26 17:34:06 +00:00
Amir Raminfar c826564e3f Merge remote-tracking branch 'origin/v9.x' 2026-01-26 09:18:29 -08:00
Amir Raminfar 8b4f4d1b00 feat: supports dozzle cloud (#4381) 2026-01-26 07:57:35 -08:00
Amir Raminfar 463fea4634 chore: release v9.0.3 2026-01-26 07:54:41 -08:00
Javier Pérez 4db74360e6 feat: add Coolify labels as fallbacks for container name and group (#4384) 2026-01-26 07:54:14 -08:00
MirzaSamadAhmedBaig 58caca2d68 fix: Avoid fatal on invalid container filters in auth (#4379)
Signed-off-by: Mirza-Samad-Ahmed-Baig <Mirzasamadahmedbaig@gmail.com>
2026-01-26 07:50:37 -08:00
Amir Raminfar 98a4901256 fix: fixes incorrect user scope for agent.find 2026-01-26 07:49:17 -08:00
renovate[bot] 4a6461ce2a fix(deps): update module github.com/puzpuzpuz/xsync/v4 to v4.4.0 (#4388)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-26 00:40:55 +00:00
Amir Raminfar 620e59aa24 fix: fixes incorrect user scope for agent.find 2026-01-25 15:03:38 -08:00
Amir Raminfar c13aca6d2c chore: fixes race issue (#4385) 2026-01-25 07:16:22 -08:00
Robert Breker f3afbf843c fix: use io.Copy for exec TTY output instead of stdcopy (#4382) 2026-01-24 16:56:48 -08:00
Amir Raminfar 10e4dfd17f chore: adds notification to types 2026-01-24 09:17:09 -08:00
MirzaSamadAhmedBaig efcadfb98a fix: Avoid fatal on invalid container filters in auth (#4379)
Signed-off-by: Mirza-Samad-Ahmed-Baig <Mirzasamadahmedbaig@gmail.com>
2026-01-23 11:39:18 -08:00
renovate[bot] f831dc6ecd fix(deps): update all non-major dependencies (#4378)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-23 19:25:58 +00:00
Amir Raminfar 0ff0d486b1 feat: supports notifications for agents (#4372) 2026-01-22 16:54:53 -08:00
Amir Raminfar a7405ac2d6 chore: updates node (#4374) 2026-01-22 20:02:00 +00:00
Amir Raminfar 3f4baea664 chore: uses go tools instead (#4373) 2026-01-22 19:33:57 +00:00
Amir Raminfar 0d71261358 fix: fixes json objects not working for expr (#4371) 2026-01-22 01:16:00 +00:00
Amir Raminfar 1411c70c9b feat: adds test connection for destination (#4369) 2026-01-21 20:39:11 +00:00
Amir Raminfar 6c2c58345c chore: cleans up types for notifications (#4368) 2026-01-21 20:28:30 +00:00
Amir Raminfar af0407157b chore: cleans up types for notificaitons (#4367) 2026-01-21 10:18:48 -08:00
Amir Raminfar 9f6d24523c chore: css clean up 2026-01-20 14:41:06 -08:00
Amir Raminfar abeb86d285 feat: supports go templates for webhooks (#4366) 2026-01-20 14:21:46 -08:00
Amir Raminfar 4954a020f9 chore: updates info to debug 2026-01-19 19:33:20 -08:00
Amir Raminfar 5b96267f1f chore: updates info to debug 2026-01-19 19:30:52 -08:00
renovate[bot] e2109257e4 fix(deps): update all non-major dependencies (#4362)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 03:26:18 +00:00
Amir Raminfar 9ac4762f1d chore: migrates releases to gql (#4361) 2026-01-20 03:18:27 +00:00
Amir Raminfar 321bf43f1c feat: implements web hooks and dispatchers with a complete new notificaiton page (#4349) 2026-01-19 17:30:36 -08:00
Amir Raminfar bca061fa7b chore: release v9.0.2 2026-01-19 10:06:06 -08:00
Amir Raminfar 8599b602dc chore: release v9.0.2 2026-01-19 10:05:41 -08:00
renovate[bot] d60a119f9e fix(deps): update all non-major dependencies to ^1.2.52 (#4359)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-19 00:37:28 +00:00
Amir Raminfar 4b3e44fcdc fix: fixes cpu limit not working correctly (#4356) 2026-01-18 06:12:40 -08:00
Amir Raminfar 51fce48137 feat: shows net stats on mobile (#4357) 2026-01-17 16:17:23 +00:00
Amir Raminfar c1432c33a2 chore: updates deps 2026-01-16 06:53:21 -08:00
renovate[bot] 22d659f509 fix(deps): update dependency unplugin-auto-import to v21 (#4352)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-16 06:52:26 -08:00
renovate[bot] 9c529af633 fix(deps): update all non-major dependencies (#4351)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-16 14:48:07 +00:00
207 changed files with 19233 additions and 4159 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/
@@ -0,0 +1,30 @@
name: "\u2601\uFE0F Dozzle Cloud Bug Report"
labels: ["bug", "cloud"]
description: |
Use this template for issues related to Dozzle Cloud.
body:
- type: checkboxes
attributes:
label: Check for existing issues
description: Check the backlog of issues to reduce the chances of creating duplicates; if an issue already exists, place a `+1` on it.
options:
- label: Completed
required: true
- type: input
attributes:
label: Dozzle version
description: The version of Dozzle connected to Dozzle Cloud.
validations:
required: true
- type: textarea
attributes:
label: Describe the bug / provide steps to reproduce it
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
attributes:
label: Screenshots or logs
description: Drag screenshots or paste logs into the text input below.
validations:
required: false
+25 -24
View File
@@ -3,25 +3,25 @@ on:
tags:
- "v*"
name: Test and Release
permissions:
contents: read
jobs:
npm-test:
name: JavaScript Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
name: Install pnpm
- uses: actions/setup-node@v6
name: Install Node
with:
node-version: 24.12.0
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.12.0
node-version: 24.14.1
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- run: corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline
- name: Run Tests
@@ -33,7 +33,7 @@ jobs:
- name: Install Go
uses: actions/setup-go@v6
with:
go-version: 1.25.5
go-version: 1.26.1
check-latest: true
- name: Checkout code
uses: actions/checkout@v6
@@ -51,29 +51,25 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
name: Install pnpm
- uses: actions/setup-node@v6
name: Install Node
with:
node-version: 24.12.0
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.12.0
node-version: 24.14.1
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- run: corepack enable
- name: Install dependencies
run: pnpm install
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Writing certs to file
run: |
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
- name: Build
uses: docker/bake-action@v6
uses: docker/bake-action@v7
with:
source: .
load: true
@@ -82,7 +78,7 @@ jobs:
*.cache-to=type=gha,mode=max
- name: Run Playwright tests
run: docker compose up --exit-code-from playwright
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report
@@ -92,16 +88,19 @@ jobs:
needs: [go-test, npm-test, int-test]
name: Release
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to DockerHub
uses: docker/login-action@v3.6.0
uses: docker/login-action@v4.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the Container registry
uses: docker/login-action@v3.6.0
uses: docker/login-action@v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -110,7 +109,7 @@ jobs:
uses: actions/checkout@v6
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
amir20/dozzle
@@ -125,7 +124,7 @@ jobs:
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
- name: Build and push
uses: docker/build-push-action@v6.18.0
uses: docker/build-push-action@v7.0.0
with:
push: true
context: .
@@ -139,12 +138,14 @@ jobs:
needs: [buildx]
name: Github Release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
name: Install pnpm
- name: Install Node
uses: actions/setup-node@v6
+11 -6
View File
@@ -6,6 +6,11 @@ on:
branches:
- master
name: Push container
permissions:
contents: read
packages: write
jobs:
buildx:
name: Push branches and PRs
@@ -13,14 +18,14 @@ jobs:
if: ${{ !github.event.repository.fork && !github.event.pull_request.head.repo.fork && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'amir20/dozzle') }}
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to DockerHub
uses: docker/login-action@v3.6.0
uses: docker/login-action@v4.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the Container registry
uses: docker/login-action@v3.6.0
uses: docker/login-action@v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -29,7 +34,7 @@ jobs:
uses: actions/checkout@v6
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
amir20/dozzle
@@ -39,11 +44,11 @@ jobs:
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
- name: Build and push
uses: docker/build-push-action@v6.18.0
uses: docker/build-push-action@v7.0.0
with:
context: .
push: true
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
platforms: linux/amd64,linux/arm64/v8
tags: ${{ steps.meta.outputs.tags }}
build-args: TAG=${{ steps.meta.outputs.version }}
labels: ${{ steps.meta.outputs.labels }}
+3 -3
View File
@@ -31,10 +31,10 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24.12.0
node-version: 24.14.1
cache: pnpm # or pnpm / yarn
- name: Setup Pages
uses: actions/configure-pages@v5
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
+18 -14
View File
@@ -6,23 +6,27 @@ on:
branches:
- master
name: Test
permissions:
contents: read
jobs:
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
name: Install pnpm
- uses: actions/setup-node@v6
name: Install Node
with:
node-version: 24.12.0
node-version: 24.14.1
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.12.0
node-version: 24.14.1
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
@@ -34,13 +38,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
name: Install pnpm
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.12.0
node-version: 24.14.1
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
@@ -54,7 +58,7 @@ jobs:
- name: Install Go
uses: actions/setup-go@v6
with:
go-version: "1.25.5"
go-version: "1.26.1"
check-latest: true
- name: Checkout code
uses: actions/checkout@v6
@@ -77,12 +81,12 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.25.5"
go-version: "1.26.1"
check-latest: true
- name: Generate dependencies
run: make fake_assets shared_key.pem shared_cert.pem
- name: Stactic checker
uses: dominikh/staticcheck-action@v1.4.0
uses: dominikh/staticcheck-action@v1.4.1
with:
install-go: false
int-test:
@@ -91,27 +95,27 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
name: Install pnpm
- uses: actions/setup-node@v6
name: Install Node
with:
node-version: 24.12.0
node-version: 24.14.1
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
with:
node-version: 24.12.0
node-version: 24.14.1
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Generate certs
run: make shared_key.pem shared_cert.pem
- name: Build
uses: docker/bake-action@v6
uses: docker/bake-action@v7
with:
source: .
load: true
@@ -120,7 +124,7 @@ jobs:
*.cache-to=type=gha,mode=max
- name: Run Playwright tests
run: docker compose up --exit-code-from playwright
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report
+2 -1
View File
@@ -15,4 +15,5 @@ coverage.out
*.pem
*.csr
tmp
.claude
.claude/settings.local.json
.claude/worktrees/
+1
View File
@@ -4,3 +4,4 @@ typed-router.d.ts
docs/.vitepress/cache
docs/.vitepress/dist
dist
assets/types/graphql.ts
+227 -11
View File
@@ -31,9 +31,6 @@ The application supports multiple deployment modes: standalone server, Docker Sw
# Install dependencies
pnpm install
# Install Go tools (protobuf, air hot-reloader)
make tools
# Generate certificates and protobuf files
make generate
```
@@ -115,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
@@ -130,8 +136,18 @@ The Go backend is organized into these key packages:
- Role-based authorization (`roles.go`)
- **`internal/container/`** - Container domain models and interfaces
- `event_generator.go`: Log parsing and grouping logic (multi-line, JSON detection)
- **`main.go`** - Application entry point with mode switching (server/swarm/k8s)
- **`internal/notification/`** - Alert and notification system
- `manager.go`: Notification rule evaluation and dispatching
- `log_listener.go`: Log pattern matching for alerts
- `dispatcher/`: Notification channel implementations (email, webhook, etc.)
- **`graph/`** - GraphQL API layer
- `schema.graphqls`: GraphQL schema definitions
- `*.resolvers.go`: GraphQL resolver implementations
- **`main.go`** - Application entry point with mode switching (server/swarm/k8s/agent)
### Frontend (Vue 3)
@@ -161,17 +177,23 @@ The frontend uses file-based routing with these conventions:
- `ContainerTable.vue`: Container table with historical stat visualization
- **`assets/stores/`** - Pinia stores (auto-imported)
- `config.ts`: App configuration and feature flags
- `container.ts`: Container state management
- `config.ts`: App configuration and feature flags (injected from backend HTML, frozen immutable)
- `container.ts`: Container state management with EventSource streaming (`/api/events/stream`)
- `hosts.ts`: Multi-host state
- `settings.ts`: User preferences
- `settings.ts`: User preferences (localStorage-backed via profileStorage)
- `pinned.ts`: Pinned container logs for side-by-side viewing
- `swarm.ts`, `k8s.ts`: Deployment mode-specific state
- `announcements.ts`: Feature announcements
- **`assets/composable/`** - Vue composables (auto-imported)
- `eventStreams.ts`: SSE connection management
- `eventStreams.ts`: SSE connection management with buffer-based flushing (250ms debounce)
- `historicalLogs.ts`: Historical log fetching
- `logContext.ts`: Log filtering and search context
- `storage.ts`: LocalStorage abstractions
- `logContext.ts`: Log filtering and search context (provide/inject pattern)
- `scrollContext.ts`: Scroll state management (paused, progress, currentDate)
- `storage.ts`: LocalStorage abstractions with reactivity
- `visible.ts`: Log filtering by visible keys for complex logs
- `containerActions.ts`: Container control operations
- `duckdb.ts`: DuckDB WASM for SQL queries on logs
- **`assets/modules/`** - Vue plugins
- `router.ts`: Vue Router configuration
@@ -185,6 +207,7 @@ The frontend uses file-based routing with these conventions:
3. **Stats**: Real-time CPU/memory stats streamed via SSE alongside events
4. **Actions**: POST to `/api/hosts/{host}/containers/{id}/actions/{action}` (start/stop/restart)
5. **Terminal**: WebSocket connections for container attach/exec at `/api/hosts/{host}/containers/{id}/attach`
6. **GraphQL**: POST to `/api/graphql` for queries and mutations (container metadata, historical logs, notifications)
### Build System
@@ -206,10 +229,15 @@ The frontend uses file-based routing with these conventions:
- `ComplexLogEntry`: Structured JSON logs (`JSONObject`)
- `GroupedLogEntry`: Multi-line grouped logs (`string[]`)
- **Type consistency**: Use `LogMessage` type alias instead of `string | string[] | JSONObject` for log entry messages
- **Log Entry Factory Pattern**: Use `LogEntry.create(logEvent)` to instantiate the correct entry type based on `logEvent.t` field
- **EventSource Buffering**: Log streams use buffer-based flushing (250ms debounce, 1000ms max) to batch UI updates
- **Charts/Visualizations**: Custom lightweight implementations (no D3.js)
- `BarChart.vue`: Self-contained bar chart with responsive downsampling
- Downsampling algorithm: Averages data into buckets based on available screen width
- All stat history tracked in `Container.statsHistory` (max 300 items via rolling window)
- `chartData` is always a rolling window of max 300 items — array length stays constant
- Uses `ref` (not `computed`) for `downsampledBars` to enable in-place mutation of the last bar, avoiding full re-renders
- Component instance is reused when switching containers; parent must call exposed `recalculate()` to force refresh
### Backend
@@ -217,6 +245,13 @@ The frontend uses file-based routing with these conventions:
- Certificate generation is required (`make generate` creates shared_key.pem and shared_cert.pem)
- Protocol buffer generation happens via `go generate` directive in `main.go`
- Docker client uses API version negotiation for compatibility
- **GraphQL API**: Uses gqlgen with schema in `graph/schema.graphqls`, generated code in `graph/generated.go`
- Run `pnpm codegen` to regenerate GraphQL types
- Resolvers follow-schema layout in `graph/*.resolvers.go`
- **Service Layer Architecture**:
- `ClientService` interface abstracts Docker/K8s/Agent backends
- `MultiHostService` orchestrates multi-host operations
- `ClientManager` implementations: `RetriableClientManager` (server mode), `SwarmClientManager` (swarm mode)
### Authentication
@@ -247,7 +282,188 @@ The frontend uses file-based routing with these conventions:
### Deployment Modes
- **Server mode**: Single or multi-host Docker monitoring
- **Server mode** (default): Single or multi-host Docker monitoring
- Uses `RetriableClientManager` with local + remote agent clients
- **Swarm mode**: Automatic discovery of Swarm nodes via Docker API
- Creates gRPC agent server on each node (port 7007)
- Uses `SwarmClientManager` for node discovery
- **K8s mode**: Pod log monitoring in Kubernetes cluster
- Implements `container.Client` interface via Kubernetes API
- **Agent mode**: Lightweight gRPC agent for remote log collection
- Run with `dozzle agent` or `pnpm run agent:dev`
- Listens on port 7007 with TLS certificate authentication
## Key Architectural Patterns
### Backend Abstraction Layers
The backend follows a clean layered architecture:
```
HTTP Handlers (internal/web)
HostService Interface (MultiHostService)
ClientService Interface (per host)
container.Client Interface
Implementation (DockerClient, K8sClient, AgentClient)
```
**When adding new container operations:**
1. Define method in `container.Client` interface (`internal/container/client.go`)
2. Implement in `internal/docker/client.go` (and `internal/k8s/client.go` if applicable)
3. Add wrapper method in `ClientService` interface (`internal/support/container/service.go`)
4. Add HTTP handler in `internal/web/` with appropriate route
### Frontend Data Flow
**Real-time Log Viewing:**
1. User navigates to `/container/{id}` route
2. Page component calls `useContainerStream(container)` composable
3. Composable creates EventSource connection to `/api/hosts/{host}/containers/{id}/logs/stream`
4. Backend streams `LogEvent` objects via SSE
5. Frontend buffers events (250ms debounce, max 1000ms)
6. Batched buffer flushes update reactive `messages` array
7. `LogViewer.vue` renders using appropriate component (`SimpleLogItem`, `ComplexLogItem`, `GroupedLogItem`)
8. When messages exceed `maxLogs` (400), oldest entries replaced or marked as `SkippedLogsEntry`
**Stats Streaming:**
1. `container.ts` store connects to `/api/events/stream` on app init
2. Backend multiplexes container events and stats into single SSE stream
3. `container-stat` events update `Container._stat` and append to `_statsHistory`
4. EMA calculation provides smoothed `movingAverageStat` (alpha=0.2)
5. `ContainerTable.vue` displays mini bar charts using `statsHistory` with downsampling
### Protocol Buffer Flow (Agent Mode)
1. Main server creates `agent.NewClient(endpoint, certs)` for each remote host
2. AgentClient implements `container.Client` interface
3. Method calls translate to gRPC requests defined in `protos/rpc.proto`
4. Remote agent receives gRPC call, delegates to local `DockerClient`
5. Streaming RPCs (logs, stats, events) use bidirectional channels
6. Responses converted back to domain models via `FromProto()` methods
### 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)
2. `log_reader.go` parses headers, extracts stdout/stderr type
3. `event_generator.go` receives raw log lines
4. Detection logic identifies:
- JSON structure → `ComplexLogEntry`
- Multi-line patterns (stack traces) → `GroupedLogEntry`
- Single lines → `SimpleLogEntry`
5. Log level extraction via regex patterns
6. `LogEvent` serialized to JSON and sent via SSE
7. Frontend deserializes and renders with appropriate component
## Adding New Features
### Adding a New HTTP Route
1. Define route in `internal/web/routes.go` using chi router:
```go
r.Get("/api/custom-endpoint", h.customHandler)
```
2. Implement handler method in appropriate file (e.g., `actions.go`, `logs.go`)
3. Use `hostService` to find container/host via `FindContainer()` or `FindHost()`
4. Return JSON response or establish SSE/WebSocket stream
### Adding a New Log View Type
1. Create route file in `assets/pages/` (e.g., `custom/[id].vue`)
2. Create composable in `assets/composable/eventStreams.ts` (e.g., `useCustomStream()`)
3. Composable should:
- Build API URL with appropriate filters
- Create EventSource connection
- Handle buffering and message batching
- Return reactive `messages` array and control methods
4. Use `LogViewer.vue` component to render messages
5. Add backend API endpoint if needed (see above)
### Adding a New GraphQL Query/Mutation
1. Define in `graph/schema.graphqls`
2. Run `pnpm codegen` to regenerate types
3. Implement resolver in `graph/schema.resolvers.go`
4. Use `hostService` from resolver context to access backend services
5. Frontend calls via urql client (auto-imported via `@urql/vue`)
### Adding Container Stats/Metrics
1. Add field to `Stat` type in `internal/container/types.go`
2. Update `stats_collector.go` to extract metric from Docker API response
3. Add calculation logic in `docker/calculation.go` if needed
4. Ensure protobuf definition includes field in `protos/rpc.proto`
5. Frontend automatically receives updates via existing SSE stream
6. Update `Container` model in `assets/models/Container.ts` if UI needs access
### Working with Notifications/Alerts
**Backend** (`internal/notification/`):
- `manager.go`: Rule evaluation engine, manages alert state
- `log_listener.go`: Subscribes to container log streams, evaluates rules against incoming logs
- `types.go`: Alert rule definitions (log pattern matching, thresholds)
- `dispatcher/`: Notification channel implementations
**Frontend** (`assets/pages/notifications.vue`, `assets/components/Notification/`):
- `AlertForm.vue`, `DestinationForm.vue`: UI for creating rules
- Rules stored via GraphQL mutations
- Alert state displayed in notification cards
**Adding a new notification channel:**
1. Implement dispatcher interface in `internal/notification/dispatcher/`
2. Register in `manager.go` dispatcher factory
3. Add UI form in `assets/components/Notification/DestinationForm.vue`
4. Add GraphQL schema fields if needed
### 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
- Always run Go tests with race detector: `go test -race`
- Frontend tests require `TZ=UTC` for timestamp consistency
- Integration tests use Playwright with `make int` (runs docker-compose setup)
- Use `testify/assert` for Go test assertions
### Hot Reload Development
- `make dev` runs both backend (air) and frontend (vite) with hot reload
- `DEV=true` disables embedded asset serving
- `LIVE_FS=true` serves assets from filesystem instead of embedded
- Backend changes trigger air restart automatically
- Frontend changes trigger vite HMR
### Debugging
- Backend logs: Set `--level debug` flag or `DOZZLE_LEVEL=debug` env var
- Frontend: Vue DevTools browser extension
- GraphQL: Use GraphQL Playground at `/api/graphql` (when enabled)
- SSE streams: Browser DevTools Network tab shows EventSource connections
+9 -14
View File
@@ -1,7 +1,7 @@
# Build assets
FROM --platform=$BUILDPLATFORM node:24.12.0-alpine AS node
FROM --platform=$BUILDPLATFORM node:25.9.0-alpine AS node
RUN corepack enable
RUN npm install -g --force corepack && corepack enable
WORKDIR /build
@@ -14,7 +14,7 @@ COPY package.json ./
RUN pnpm install --offline --ignore-scripts --no-optional
# Copy assets and translations to build
COPY .* *.config.ts *.config.js *.config.cjs ./
COPY vite.config.ts tsconfig.json .prettierrc.cjs .npmrc ./
COPY assets ./assets
COPY locales ./locales
COPY public ./public
@@ -22,22 +22,19 @@ COPY public ./public
# Build assets
RUN pnpm build
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
# install gRPC dependencies
RUN apk add --no-cache ca-certificates protoc protobuf-dev\
&& mkdir /dozzle \
&& go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \
&& go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
RUN apk add --no-cache ca-certificates && mkdir /dozzle
WORKDIR /dozzle
# Copy go mod files
COPY go.* ./
RUN go mod download
RUN --mount=type=cache,target=/go/pkg/mod go mod download
# Copy all other files
COPY internal ./internal
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
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
+20 -16
View File
@@ -1,7 +1,4 @@
PROTO_DIR := protos
GEN_DIR := internal/agent/pb
PROTO_FILES := $(wildcard $(PROTO_DIR)/*.proto)
GEN_FILES := $(patsubst $(PROTO_DIR)/%.proto,$(GEN_DIR)/%.pb.go,$(PROTO_FILES))
.PHONY: clean
clean:
@@ -29,10 +26,12 @@ build: dist generate
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=local"
.PHONY: docker
docker: shared_key.pem shared_cert.pem
@docker build --build-arg TAG=local -t amir20/dozzle .
docker: generate
@docker build --build-arg TAG=local -t amir20/dozzle:local .
generate: shared_key.pem shared_cert.pem $(GEN_FILES)
.PHONY: generate
generate: shared_key.pem shared_cert.pem
@go generate ./...
.PHONY: dev
dev: generate fake_assets
@@ -50,21 +49,26 @@ shared_cert.pem: shared_key.pem
@openssl x509 -req -in shared_request.csr -signkey shared_key.pem -out shared_cert.pem -days 1825
@rm shared_request.csr
$(GEN_DIR)/%.pb.go: $(PROTO_DIR)/%.proto
@go generate
.PHONY: push
push: docker
@docker tag amir20/dozzle:latest amir20/dozzle:local-test
@docker tag amir20/dozzle:local amir20/dozzle:local-test
@docker push amir20/dozzle:local-test
tools:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
go install github.com/air-verse/air@latest
.PHONY: run
run: docker
docker run -it --rm -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock amir20/dozzle:latest
docker run -it --rm -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock amir20/dozzle:local
.PHONY: preview
preview: build
pnpm preview
.PHONY: agent-reload
agent-reload: docker
@VM_NAME=$${VM_NAME:-dozzle-agent}; \
echo "📦 Loading image into VM $$VM_NAME..."; \
docker save amir20/dozzle:local | orb exec -m $$VM_NAME docker load; \
echo "🔄 Recreating agent..."; \
orb exec -m $$VM_NAME docker stop dozzle-agent || true; \
orb exec -m $$VM_NAME docker rm dozzle-agent || true; \
orb exec -m $$VM_NAME docker run -d --name dozzle-agent -p 7007:7007 -v /var/run/docker.sock:/var/run/docker.sock -v ~/dozzle-certs:/certs -v ~/dozzle-data:/data -e DOZZLE_LEVEL=debug amir20/dozzle:local agent --cert /certs/shared_cert.pem --key /certs/shared_key.pem; \
echo "✅ Agent reloaded"
+39 -38
View File
@@ -4,7 +4,7 @@
# Dozzle - [dozzle.dev](https://dozzle.dev/)
Dozzle is a small lightweight application with a web based interface to monitor Docker logs. It doesnt store any log files. It is for live monitoring of your container logs only.
Dozzle is a lightweight, web-based application for monitoring Docker logs in real time. It doesn't store any log files—it's designed purely for live log viewing.
https://github.com/user-attachments/assets/66a7b4b2-d6c9-4fca-ab04-aef6cd7c0c31
@@ -14,22 +14,22 @@ https://github.com/user-attachments/assets/66a7b4b2-d6c9-4fca-ab04-aef6cd7c0c31
![Test](https://github.com/amir20/dozzle/workflows/Test/badge.svg)
> [!NOTE]
> If you like Dozzle, check out [`dtop`](https://github.com/amir20/dtop) which is a top like application for monitoring Docker containers. It integrates with Dozzle to allow for linking directly to container logs.
> If you like Dozzle, check out [`dtop`](https://github.com/amir20/dtop), a top-like application for monitoring Docker containers. It integrates with Dozzle to link directly to container logs.
## Features
- Intelligent fuzzy search for container names 🤖
- Search logs using regex 🔦
- Search logs using [SQL queries](https://dozzle.dev/guide/sql-engine) 📊
- Small memory footprint 🏎
- Intelligent fuzzy search for container names
- Search logs using regex
- Search logs using [SQL queries](https://dozzle.dev/guide/sql-engine)
- Small memory footprint
- Split screen for viewing multiple logs
- Live stats with memory and CPU usage
- Multi-user [authentication](https://dozzle.dev/guide/authentication) with support for proxy forward authorization 🚨
- [Swarm](https://dozzle.dev/guide/swarm-mode) mode support 🐳
- [Agent](https://dozzle.dev/guide/agent) mode for monitoring multiple Docker hosts 🕵️‍♂️
- Dark mode 🌙
- Multi-user [authentication](https://dozzle.dev/guide/authentication) with support for forward proxy authorization
- [Swarm mode](https://dozzle.dev/guide/swarm-mode) support
- [Agent mode](https://dozzle.dev/guide/agent) for monitoring multiple Docker hosts
- Dark mode
Dozzle has been tested with hundreds of containers. However, it doesn't support offline searching. Products like [Loggly](https://www.loggly.com), [Papertrail](https://papertrailapp.com) or [Kibana](https://www.elastic.co/products/kibana) are more suited for full search capabilities.
Dozzle has been tested with hundreds of containers. However, it doesn't support offline searching. Products like [Loggly](https://www.loggly.com), [Papertrail](https://papertrailapp.com), or [Kibana](https://www.elastic.co/products/kibana) are better suited for full search capabilities.
## Getting Started
@@ -39,13 +39,13 @@ Dozzle is a small container (7 MB compressed). Pull the latest release with:
### Running Dozzle
The simplest way to use dozzle is to run the docker container. Also, mount the Docker Unix socket with `--volume` to `/var/run/docker.sock`:
The simplest way to use Dozzle is to run the Docker container. Mount the Docker Unix socket with `--volume` to `/var/run/docker.sock`:
$ docker run --name dozzle -d --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle:latest
Dozzle will be available at [http://localhost:8080/](http://localhost:8080/).
Here is the Docker Compose file:
Here is a Docker Compose example:
services:
dozzle:
@@ -56,11 +56,11 @@ Here is the Docker Compose file:
ports:
- 8080:8080
For advanced options like [authentication](https://dozzle.dev/guide/authentication), [remote hosts](https://dozzle.dev/guide/remote-hosts) or common [questions](https://dozzle.dev/guide/faq) see documentation at [dozzle.dev](https://dozzle.dev/guide/getting-started).
For advanced options like [authentication](https://dozzle.dev/guide/authentication), [remote hosts](https://dozzle.dev/guide/remote-hosts), or common [questions](https://dozzle.dev/guide/faq), see the documentation at [dozzle.dev](https://dozzle.dev/guide/getting-started).
## Swarm Mode
Dozzle works with Docker Swarm mode. You can run Dozzle as a global service with:
Dozzle works with Docker Swarm. You can run Dozzle as a global service:
$ docker service create --name dozzle --env DOZZLE_MODE=swarm --mode global --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock -p 8080:8080 amir20/dozzle:latest
@@ -68,7 +68,7 @@ See the [Swarm Mode](https://dozzle.dev/guide/swarm-mode) documentation for more
## Agent Mode
Dozzle can be used to monitor multiple Docker hosts. You can run Dozzle in agent mode with:
Dozzle can monitor multiple Docker hosts. Run Dozzle in agent mode with:
$ docker run -v /var/run/docker.sock:/var/run/docker.sock -p 7007:7007 amir20/dozzle:latest agent
@@ -76,19 +76,19 @@ See the [Agent Mode](https://dozzle.dev/guide/agent) documentation for more deta
## Technical Details
Dozzle users automatic API negotiation which works with most Docker configurations. Dozzle also works with [Colima](https://github.com/abiosoft/colima) and [Podman](https://podman.io/).
Dozzle uses automatic API negotiation, which works with most Docker configurations. Dozzle also works with [Colima](https://github.com/abiosoft/colima) and [Podman](https://podman.io/).
### Installation on podman
### Installation on Podman
By default Podman doesn't have a background process but you can enable this for Dozzle to work.
By default, Podman doesn't have a background process, but you can enable the remote socket for Dozzle to work.
Verify first if your podman installation has enabled remote socket:
First, verify if your Podman installation has the remote socket enabled:
```
podman info
```
When you get under the key remote socket output like this, its already enabled:
If you see output like this under the remote socket key, it's already enabled:
```
remoteSocket:
@@ -96,40 +96,40 @@ When you get under the key remote socket output like this, its already enabled:
path: /run/user/1000/podman/podman.sock
```
If it's not enabled please follow [this tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/socket_activation.md) to enable it.
If it's not enabled, follow [this tutorial](https://github.com/containers/podman/blob/main/docs/tutorials/socket_activation.md) to enable it.
Once you have the podman remote socket you can run Dozzle on podman.
Once the Podman remote socket is enabled, you can run Dozzle:
```
podman run --volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock -d -p 8080:8080 docker.io/amir20/dozzle:latest
```
Additionally you have to create a fake engine-id to prevent `host not found` errors. Podman doesn't generate an engine-id like Docker by itself due to its daemonless architecture.
Additionally, you need to create a fake engine-id to prevent `host not found` errors. Podman doesn't generate an engine-id like Docker does, due to its daemonless architecture.
Under `/var/lib/docker` create a file named `engine-id`. On a system with Podman you will have to create the folder path as well. Inside the file place the UUID, for instance using `uuidgen > engine-id`. After that the file should have an identifier that looks like this: `b9f1d7fc-b459-4b6e-9f7a-e3d1cd2e14a9`.
Create a file named `engine-id` under `/var/lib/docker`. On a system with Podman, you'll need to create the folder path as well. Place a UUID inside the file, for example using `uuidgen > engine-id`. The file should contain an identifier like: `b9f1d7fc-b459-4b6e-9f7a-e3d1cd2e14a9`.
For more details check [Podman Infos](docs/guide/podman.md) or the [FAQ](docs/guide/faq.md#i-am-seeing-host-not-found-error-in-the-logs-how-do-i-fix-it)
For more details, see [Podman Info](docs/guide/podman.md) or the [FAQ](docs/guide/faq.md#i-am-seeing-host-not-found-error-in-the-logs-how-do-i-fix-it).
## Security
Dozzle supports file based authentication and forward proxy like [Authelia](https://www.authelia.com/). These are documented at https://dozzle.dev/guide/authentication.
Dozzle supports file-based authentication and forward proxy authentication with tools like [Authelia](https://www.authelia.com/). See the documentation at https://dozzle.dev/guide/authentication.
## Analytics collected
## Analytics
Dozzle collects anonymous user configurations using Google Analytics. Why? Dozzle is an open source project with no funding. As a result, there is no time to do user studies of Dozzle. Analytics is collected to prioritize features and fixes based on how people use Dozzle. This data is completely public and can be viewed live using [ Data Studio dashboard](https://datastudio.google.com/s/naeIu0MiWsY).
Dozzle collects anonymous user configurations using Google Analytics. Why? Dozzle is an open source project with no funding, so there's no time for formal user studies. Analytics help prioritize features and fixes based on how people use Dozzle. This data is completely public and can be viewed live on the [Data Studio dashboard](https://datastudio.google.com/s/naeIu0MiWsY).
If you do not want to be tracked at all, see the `--no-analytics` flag below.
To disable analytics, use the `--no-analytics` flag.
## Environment variables and configuration
## Environment Variables and Configuration
Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can use the CLI flags or environment variables. See documentation at [https://dozzle.dev/guide/supported-env-vars](https://dozzle.dev/guide/supported-env-vars) for more details.
Dozzle follows the [12-factor](https://12factor.net/) model. Configuration can be done via CLI flags or environment variables. See the documentation at [dozzle.dev/guide/supported-env-vars](https://dozzle.dev/guide/supported-env-vars) for more details.
## Support
There are many ways you can support Dozzle:
There are many ways to support Dozzle:
- Use it! Write about it! Star it! If you love Dozzle, drop me a line and tell me what you love.
- Blog about Dozzle to spread the word. If you are good at writing send PRs to improve the documentation at [dozzle.dev](https://dozzle.dev/)
- Blog about Dozzle to spread the word. If you're good at writing, send PRs to improve the documentation at [dozzle.dev](https://dozzle.dev/).
- Sponsor my work at https://www.buymeacoffee.com/amirraminfar
<a href="https://www.buymeacoffee.com/amirraminfar" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
@@ -142,8 +142,9 @@ There are many ways you can support Dozzle:
To build and test locally:
1. Install [NodeJs](https://nodejs.org/en/download/) and [pnpm](https://pnpm.io/installation).
1. Install [Node.js](https://nodejs.org/en/download/) and [pnpm](https://pnpm.io/installation).
2. Install [Go](https://go.dev/doc/install).
3. Install tools with `make tools`.
4. Install node modules `pnpm install`.
5. Run `make dev` to start a development server with hot reload.
3. Install [protoc](https://grpc.io/docs/protoc-installation/).
4. Install Go tools with `go install tool`.
5. Install Node modules with `pnpm install`.
6. Run `make dev` to start a development server with hot reload.
+41 -8
View File
@@ -27,15 +27,21 @@ declare global {
const controlledComputed: typeof import('@vueuse/core').controlledComputed
const controlledRef: typeof import('@vueuse/core').controlledRef
const createApp: typeof import('vue').createApp
const createContainerHints: typeof import('./composable/exprEditor').createContainerHints
const createDrawer: typeof import('./composable/drawer').createDrawer
const createEventHints: typeof import('./composable/exprEditor').createEventHints
const createEventHook: typeof import('@vueuse/core').createEventHook
const createExprEditor: typeof import('./composable/exprEditor').createExprEditor
const createGlobalState: typeof import('@vueuse/core').createGlobalState
const createInjectionState: typeof import('@vueuse/core').createInjectionState
const createLogHints: typeof import('./composable/exprEditor').createLogHints
const createMetricHints: typeof import('./composable/exprEditor').createMetricHints
const createPinia: typeof import('pinia').createPinia
const createReactiveFn: typeof import('@vueuse/core').createReactiveFn
const createRef: typeof import('@vueuse/core').createRef
const createReusableTemplate: typeof import('@vueuse/core').createReusableTemplate
const createSharedComposable: typeof import('@vueuse/core').createSharedComposable
const createTemplateEditor: typeof import('./composable/templateEditor').createTemplateEditor
const createTemplatePromise: typeof import('@vueuse/core').createTemplatePromise
const createUnrefFn: typeof import('@vueuse/core').createUnrefFn
const customRef: typeof import('vue').customRef
@@ -52,6 +58,7 @@ declare global {
const flattenJSON: typeof import('./utils/index').flattenJSON
const flattenJSONToMap: typeof import('./utils/index').flattenJSONToMap
const formatBytes: typeof import('./utils/index').formatBytes
const formatDuration: typeof import('./utils/index').formatDuration
const getActivePinia: typeof import('pinia').getActivePinia
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
@@ -74,11 +81,10 @@ 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
const manualResetRef: typeof import('@vueuse/core')['manualResetRef']
const mapActions: typeof import('pinia').mapActions
const mapGetters: typeof import('pinia').mapGetters
const mapState: typeof import('pinia').mapState
@@ -108,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
@@ -131,7 +138,6 @@ declare global {
const refWithControl: typeof import('@vueuse/core').refWithControl
const resolveComponent: typeof import('vue').resolveComponent
const resolveRef: typeof import('@vueuse/core').resolveRef
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const scrollContextKey: typeof import('./composable/scrollContext').scrollContextKey
const search: typeof import('./stores/settings').search
const sessionHost: typeof import('./composable/storage').sessionHost
@@ -171,6 +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
@@ -206,6 +213,7 @@ declare global {
const useCountdown: typeof import('@vueuse/core').useCountdown
const useCounter: typeof import('@vueuse/core').useCounter
const useCssModule: typeof import('vue').useCssModule
const useCssSupports: typeof import('@vueuse/core').useCssSupports
const useCssVar: typeof import('@vueuse/core').useCssVar
const useCssVars: typeof import('vue').useCssVars
const useCurrentElement: typeof import('@vueuse/core').useCurrentElement
@@ -235,6 +243,7 @@ declare global {
const useEventListener: typeof import('@vueuse/core').useEventListener
const useEventSource: typeof import('@vueuse/core').useEventSource
const useExponentialMovingAverage: typeof import('./utils/index').useExponentialMovingAverage
const useExprEditorField: typeof import('./composable/useExprEditorField').useExprEditorField
const useEyeDropper: typeof import('@vueuse/core').useEyeDropper
const useFavicon: typeof import('@vueuse/core').useFavicon
const useFetch: typeof import('@vueuse/core').useFetch
@@ -262,7 +271,9 @@ declare global {
const useK8sStore: typeof import('./stores/k8s').useK8sStore
const useKeyModifier: typeof import('@vueuse/core').useKeyModifier
const useLastChanged: typeof import('@vueuse/core').useLastChanged
const useLink: typeof import('vue-router/auto').useLink
const useLocalStorage: typeof import('@vueuse/core').useLocalStorage
const 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
@@ -305,8 +316,8 @@ declare global {
const useRafFn: typeof import('@vueuse/core').useRafFn
const useRefHistory: typeof import('@vueuse/core').useRefHistory
const useResizeObserver: typeof import('@vueuse/core').useResizeObserver
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useRoute: typeof import('vue-router/auto').useRoute
const useRouter: typeof import('vue-router/auto').useRouter
const useSSRWidth: typeof import('@vueuse/core').useSSRWidth
const useScreenOrientation: typeof import('@vueuse/core').useScreenOrientation
const useScreenSafeArea: typeof import('@vueuse/core').useScreenSafeArea
@@ -391,12 +402,21 @@ declare global {
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
// @ts-ignore
export type { AlertFormOptions, ContainerResult } from './composable/alertForm'
import('./composable/alertForm')
// @ts-ignore
export type { DrawerWidth } from './composable/drawer'
import('./composable/drawer')
// @ts-ignore
export type { LogStreamSource } from './composable/eventStreams'
import('./composable/eventStreams')
// @ts-ignore
export type { ExprEditorOptions } from './composable/exprEditor'
import('./composable/exprEditor')
// @ts-ignore
export type { TemplateEditorOptions } from './composable/templateEditor'
import('./composable/templateEditor')
// @ts-ignore
export type { Config, Profile } from './stores/config'
import('./stores/config')
// @ts-ignore
@@ -436,15 +456,21 @@ declare module 'vue' {
readonly controlledComputed: UnwrapRef<typeof import('@vueuse/core')['controlledComputed']>
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly createContainerHints: UnwrapRef<typeof import('./composable/exprEditor')['createContainerHints']>
readonly createDrawer: UnwrapRef<typeof import('./composable/drawer')['createDrawer']>
readonly createEventHints: UnwrapRef<typeof import('./composable/exprEditor')['createEventHints']>
readonly createEventHook: UnwrapRef<typeof import('@vueuse/core')['createEventHook']>
readonly createExprEditor: UnwrapRef<typeof import('./composable/exprEditor')['createExprEditor']>
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
readonly createLogHints: UnwrapRef<typeof import('./composable/exprEditor')['createLogHints']>
readonly createMetricHints: UnwrapRef<typeof import('./composable/exprEditor')['createMetricHints']>
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
readonly createRef: UnwrapRef<typeof import('@vueuse/core')['createRef']>
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
readonly createTemplateEditor: UnwrapRef<typeof import('./composable/templateEditor')['createTemplateEditor']>
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
@@ -461,6 +487,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']>
@@ -483,7 +510,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']>
@@ -516,6 +543,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']>
@@ -578,6 +606,7 @@ declare module 'vue' {
readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']>
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
readonly useAlertForm: UnwrapRef<typeof import('./composable/alertForm')['useAlertForm']>
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
readonly useAnnouncements: UnwrapRef<typeof import('./stores/announcements')['useAnnouncements']>
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
@@ -613,6 +642,7 @@ declare module 'vue' {
readonly useCountdown: UnwrapRef<typeof import('@vueuse/core')['useCountdown']>
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssSupports: UnwrapRef<typeof import('@vueuse/core')['useCssSupports']>
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useCurrentElement: UnwrapRef<typeof import('@vueuse/core')['useCurrentElement']>
@@ -642,6 +672,7 @@ declare module 'vue' {
readonly useEventListener: UnwrapRef<typeof import('@vueuse/core')['useEventListener']>
readonly useEventSource: UnwrapRef<typeof import('@vueuse/core')['useEventSource']>
readonly useExponentialMovingAverage: UnwrapRef<typeof import('./utils/index')['useExponentialMovingAverage']>
readonly useExprEditorField: UnwrapRef<typeof import('./composable/useExprEditorField')['useExprEditorField']>
readonly useEyeDropper: UnwrapRef<typeof import('@vueuse/core')['useEyeDropper']>
readonly useFavicon: UnwrapRef<typeof import('@vueuse/core')['useFavicon']>
readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>
@@ -669,7 +700,9 @@ declare module 'vue' {
readonly useK8sStore: UnwrapRef<typeof import('./stores/k8s')['useK8sStore']>
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
readonly useLink: UnwrapRef<typeof import('vue-router/auto')['useLink']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly 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']>
@@ -712,8 +745,8 @@ declare module 'vue' {
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useRoute: UnwrapRef<typeof import('vue-router/auto')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router/auto')['useRouter']>
readonly useSSRWidth: UnwrapRef<typeof import('@vueuse/core')['useSSRWidth']>
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
+32
View File
@@ -11,10 +11,14 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AlertCard: typeof import('./components/Notification/AlertCard.vue')['default']
AlertForm: typeof import('./components/Notification/AlertForm.vue')['default']
Announcements: typeof import('./components/Announcements.vue')['default']
BarChart: typeof import('./components/BarChart.vue')['default']
'Carbon:add': typeof import('~icons/carbon/add')['default']
'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default']
'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default']
'Carbon:close': typeof import('~icons/carbon/close')['default']
'Carbon:information': typeof import('~icons/carbon/information')['default']
'Carbon:logoKubernetes': typeof import('~icons/carbon/logo-kubernetes')['default']
'Carbon:macShift': typeof import('~icons/carbon/mac-shift')['default']
@@ -23,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']
@@ -30,6 +35,7 @@ declare module 'vue' {
'Cil:circle': typeof import('~icons/cil/circle')['default']
'Cil:columns': typeof import('~icons/cil/columns')['default']
'Cil:xCircle': typeof import('~icons/cil/x-circle')['default']
CloudDestinationForm: typeof import('./components/Notification/CloudDestinationForm.vue')['default']
ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default']
ContainerActionsToolbar: typeof import('./components/ContainerViewer/ContainerActionsToolbar.vue')['default']
ContainerDropdown: typeof import('./components/ContainerDropdown.vue')['default']
@@ -40,9 +46,13 @@ declare module 'vue' {
ContainerStatCell: typeof import('./components/ContainerStatCell.vue')['default']
ContainerTable: typeof import('./components/ContainerTable.vue')['default']
ContainerTitle: typeof import('./components/ContainerViewer/ContainerTitle.vue')['default']
CooldownField: typeof import('./components/Notification/CooldownField.vue')['default']
DateTime: typeof import('./components/common/DateTime.vue')['default']
DestinationCard: typeof import('./components/Notification/DestinationCard.vue')['default']
DestinationForm: typeof import('./components/Notification/DestinationForm.vue')['default']
Dropdown: typeof import('./components/common/Dropdown.vue')['default']
DropdownMenu: typeof import('./components/common/DropdownMenu.vue')['default']
EventAlertFields: typeof import('./components/Notification/EventAlertFields.vue')['default']
EventSource: typeof import('./components/LogViewer/EventSource.vue')['default']
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.vue')['default']
@@ -63,6 +73,7 @@ declare module 'vue' {
Links: typeof import('./components/Links.vue')['default']
LoadMoreLogItem: typeof import('./components/LogViewer/LoadMoreLogItem.vue')['default']
LogActions: typeof import('./components/LogViewer/LogActions.vue')['default']
LogAlertFields: typeof import('./components/Notification/LogAlertFields.vue')['default']
LogAnalytics: typeof import('./components/LogViewer/LogAnalytics.vue')['default']
LogDate: typeof import('./components/LogViewer/LogDate.vue')['default']
LogDetails: typeof import('./components/LogViewer/LogDetails.vue')['default']
@@ -80,16 +91,27 @@ declare module 'vue' {
'MaterialSymbols:terminal': typeof import('~icons/material-symbols/terminal')['default']
'MaterialSymbolsLight:collapseAll': typeof import('~icons/material-symbols-light/collapse-all')['default']
'Mdi:account': typeof import('~icons/mdi/account')['default']
'Mdi:alert': typeof import('~icons/mdi/alert')['default']
'Mdi:alertCircle': typeof import('~icons/mdi/alert-circle')['default']
'Mdi:alertOutline': typeof import('~icons/mdi/alert-outline')['default']
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
'Mdi:beer': typeof import('~icons/mdi/beer')['default']
'Mdi:bell': typeof import('~icons/mdi/bell')['default']
'Mdi:bellRingOutline': typeof import('~icons/mdi/bell-ring-outline')['default']
'Mdi:chartBar': typeof import('~icons/mdi/chart-bar')['default']
'Mdi:chartLine': typeof import('~icons/mdi/chart-line')['default']
'Mdi:check': typeof import('~icons/mdi/check')['default']
'Mdi:checkCircle': typeof import('~icons/mdi/check-circle')['default']
'Mdi:chevronDoubleDown': typeof import('~icons/mdi/chevron-double-down')['default']
'Mdi:chevronDown': typeof import('~icons/mdi/chevron-down')['default']
'Mdi:chevronLeft': typeof import('~icons/mdi/chevron-left')['default']
'Mdi:chevronRight': typeof import('~icons/mdi/chevron-right')['default']
'Mdi:close': typeof import('~icons/mdi/close')['default']
'Mdi:cloud': typeof import('~icons/mdi/cloud')['default']
'Mdi:cloudOutline': typeof import('~icons/mdi/cloud-outline')['default']
'Mdi:cog': typeof import('~icons/mdi/cog')['default']
'Mdi:contentCopy': typeof import('~icons/mdi/content-copy')['default']
'Mdi:docker': typeof import('~icons/mdi/docker')['default']
'Mdi:gauge': typeof import('~icons/mdi/gauge')['default']
'Mdi:github': typeof import('~icons/mdi/github')['default']
@@ -98,8 +120,16 @@ declare module 'vue' {
'Mdi:key': typeof import('~icons/mdi/key')['default']
'Mdi:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default']
'Mdi:lightningBolt': typeof import('~icons/mdi/lightning-bolt')['default']
'Mdi:linkVariant': typeof import('~icons/mdi/link-variant')['default']
'Mdi:magnify': typeof import('~icons/mdi/magnify')['default']
'Mdi:pencilOutline': typeof import('~icons/mdi/pencil-outline')['default']
'Mdi:plus': typeof import('~icons/mdi/plus')['default']
'Mdi:poll': typeof import('~icons/mdi/poll')['default']
'Mdi:satelliteVariant': typeof import('~icons/mdi/satellite-variant')['default']
'Mdi:textBoxOutline': typeof import('~icons/mdi/text-box-outline')['default']
'Mdi:trashCanOutline': typeof import('~icons/mdi/trash-can-outline')['default']
'Mdi:webhook': typeof import('~icons/mdi/webhook')['default']
MetricAlertFields: typeof import('./components/Notification/MetricAlertFields.vue')['default']
MetricCard: typeof import('./components/MetricCard.vue')['default']
MobileMenu: typeof import('./components/common/MobileMenu.vue')['default']
MultiContainerActionToolbar: typeof import('./components/LogViewer/MultiContainerActionToolbar.vue')['default']
@@ -143,6 +173,7 @@ declare module 'vue' {
SQLTable: typeof import('./components/LogViewer/SQLTable.vue')['default']
StackLog: typeof import('./components/StackViewer/StackLog.vue')['default']
StatMonitor: typeof import('./components/LogViewer/StatMonitor.vue')['default']
'SvgSpinners:ringResize': typeof import('~icons/svg-spinners/ring-resize')['default']
SwarmMenu: typeof import('./components/SwarmMenu.vue')['default']
Tag: typeof import('./components/common/Tag.vue')['default']
Terminal: typeof import('./components/Terminal.vue')['default']
@@ -150,6 +181,7 @@ declare module 'vue' {
ToastModal: typeof import('./components/common/ToastModal.vue')['default']
Toggle: typeof import('./components/common/Toggle.vue')['default']
ViewerWithSource: typeof import('./components/LogViewer/ViewerWithSource.vue')['default']
WebhookDestinationForm: typeof import('./components/Notification/WebhookDestinationForm.vue')['default']
ZigZag: typeof import('./components/LogViewer/ZigZag.vue')['default']
}
}
+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>
+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,17 @@
{{ $t("toolbar.restart") }}
</button>
</li>
<li>
<button @click="update()" :disabled="actionStates.update">
<carbon:upgrade
:class="{
'animate-spin': actionStates.update,
'text-secondary': actionStates.update,
}"
/>
{{ container.isSwarm ? $t("toolbar.update-service") : $t("toolbar.update") }}
</button>
</li>
</template>
<template v-if="enableShell && !historical">
@@ -176,7 +201,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) {
@@ -203,7 +329,12 @@ if (enableShell) {
}
const containerRef = computed(() => [container]);
const { downloadUrl, isFiltered } = useDownloadUrl(containerRef, streamConfig, levels);
const { downloadUrl, isFiltered } = useDownloadUrl(
containerRef,
streamConfig,
levels,
toRef(() => container.name),
);
const disableRestart = computed(() => actionStates.stop || actionStates.start || actionStates.restart);
@@ -1,12 +1,12 @@
<template>
<div class="@container flex flex-1 items-center gap-1.5 md:gap-2">
<div class="@container flex min-w-0 flex-1 items-center gap-1.5 md:gap-2">
<label class="swap swap-rotate size-4">
<input type="checkbox" v-model="pinned" />
<carbon:star-filled class="swap-on text-secondary" />
<carbon:star class="swap-off" />
</label>
<div class="inline-flex items-center text-sm">
<div class="breadcrumbs overflow-x-visible p-0 font-mono">
<div class="inline-flex min-w-0 items-center text-sm">
<div class="breadcrumbs min-w-0 overflow-x-visible p-0 font-mono">
<ul>
<li v-if="config.hosts.length > 1" class="font-thin max-md:hidden">
{{ container.hostLabel }}
+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) {
@@ -6,7 +6,7 @@
{{ $t("label.container", group.containers.length) }}
</ContainerDropdown>
<MultiContainerStat class="ml-auto" :containers="group.containers" />
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
<MultiContainerActionToolbar class="max-md:hidden" :name="group.name" @clear="viewer?.clear()" />
</div>
</template>
<template #default>
+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>
+1 -1
View File
@@ -12,7 +12,7 @@
</Tag>
</div>
<MultiContainerStat class="ml-auto" :containers="containers" />
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
<MultiContainerActionToolbar class="max-md:hidden" :name="host.name" @clear="viewer?.clear()" />
</div>
</template>
<template #default>
+1 -1
View File
@@ -10,7 +10,7 @@
</ContainerDropdown>
</div>
<MultiContainerStat class="ml-auto" :containers="namespace.containers" />
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
<MultiContainerActionToolbar class="max-md:hidden" :name="namespace.name" @clear="viewer?.clear()" />
</div>
</template>
<template #default>
+1 -1
View File
@@ -10,7 +10,7 @@
</ContainerDropdown>
</div>
<MultiContainerStat class="ml-auto" :containers="owner.containers" />
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
<MultiContainerActionToolbar class="max-md:hidden" :name="owner.name" @clear="viewer?.clear()" />
</div>
</template>
<template #default>
+9
View File
@@ -3,6 +3,15 @@
<slot name="more-items"></slot>
<Announcements />
<router-link
:to="{ name: '/notifications' }"
:aria-label="$t('title.notifications')"
data-testid="notifications"
class="btn btn-circle btn-sm"
>
<mdi:bell class="size-6" />
</router-link>
<router-link
:to="{ name: '/settings' }"
:aria-label="$t('title.settings')"
@@ -73,6 +73,12 @@
{{ $t("action.show-details") }}
</a>
</li>
<li>
<a @click="createAlert()">
<mdi:bell />
{{ $t("action.create-alert") }}
</a>
</li>
</ul>
</div>
</template>
@@ -82,6 +88,7 @@ import stripAnsi from "strip-ansi";
import { Container } from "@/models/Container";
import { LogEntry, SimpleLogEntry, ComplexLogEntry, GroupedLogEntry, JSONObject } from "@/models/LogEntry";
import LogDetails from "./LogDetails.vue";
import AlertForm from "@/components/Notification/AlertForm.vue";
const { logEntry, container } = defineProps<{
logEntry: LogEntry<string | JSONObject>;
@@ -147,6 +154,22 @@ async function copyPermalink() {
}
}
function createAlert() {
const containerExpr = `name contains "${container.name}"`;
let logExpr = "";
if (logEntry.level && logEntry.level !== "unknown") {
logExpr = `level == "${logEntry.level}"`;
}
const nameParts = [container.name];
if (logEntry.level && logEntry.level !== "unknown") {
nameParts.push(logEntry.level);
}
const name = nameParts.join(" ");
showDrawer(AlertForm, { prefill: { name, containerExpression: containerExpr, logExpression: logExpr } }, "lg");
}
function hideMenu(e: MouseEvent) {
if (e.target instanceof HTMLAnchorElement) {
setTimeout(() => {
+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"
+2 -2
View File
@@ -14,12 +14,12 @@
</template>
<script lang="ts" setup>
import { type JSONObject, LogEntry } from "@/models/LogEntry";
import type { LogEntry, LogMessage } from "@/models/LogEntry";
const { progress, currentDate } = useScrollContext();
const { messages } = defineProps<{
messages: LogEntry<string | string[] | JSONObject>[];
messages: LogEntry<LogMessage>[];
}>();
const { containers } = useLoggingContext();
@@ -94,9 +94,11 @@ const { showSearch } = useSearchFilter();
const { enableDownload } = config;
const clear = defineEmit();
const { name } = defineProps<{ name?: string }>();
const { streamConfig, showHostname, showContainerName, containers, levels } = useLoggingContext();
const { downloadUrl, isFiltered } = useDownloadUrl(containers, streamConfig, levels);
const { downloadUrl, isFiltered } = useDownloadUrl(containers, streamConfig, levels, name);
const hideMenu = (e: MouseEvent) => {
if (e.target instanceof HTMLAnchorElement) {
@@ -1,12 +1,15 @@
<template>
<div class="flex gap-1 md:gap-4">
<div class="grid min-w-15 grid-cols-[auto_1fr] items-center gap-0.5 text-xs leading-none max-md:hidden">
<div
class="grid hidden min-w-15 grid-cols-[auto_1fr_auto_1fr] items-center gap-0.5 text-xs leading-none sm:grid md:grid-cols-[auto_1fr]"
>
<PhArrowUp class="text-primary" />
<span class="tabular-nums">{{ formatBytes(networkRate.tx, { short: true, decimals: 1 }) }}/s</span>
<PhArrowDown class="text-secondary" />
<span class="tabular-nums">{{ formatBytes(networkRate.rx, { short: true, decimals: 1 }) }}/s</span>
</div>
<StatMonitor
ref="cpuMonitorRef"
:data="cpuData"
:icon="PhCpu"
:stat-value="Math.max(0, totalStat.cpu).toFixed(2) + '%'"
@@ -17,6 +20,7 @@
:formatter="(value: number) => value.toFixed(2) + '%'"
/>
<StatMonitor
ref="memoryMonitorRef"
:data="memoryData"
:icon="PhMemory"
:stat-value="formatBytes(totalStat.memoryUsage)"
@@ -32,6 +36,7 @@
<script lang="ts" setup>
import { Stat } from "@/models/Container";
import { Container } from "@/models/Container";
import StatMonitor from "@/components/LogViewer/StatMonitor.vue";
// @ts-ignore
import PhCpu from "~icons/ph/cpu";
// @ts-ignore
@@ -41,6 +46,8 @@ const { containers } = defineProps<{
containers: Container[];
}>();
const cpuMonitorRef = ref<InstanceType<typeof StatMonitor> | null>(null);
const memoryMonitorRef = ref<InstanceType<typeof StatMonitor> | null>(null);
const totalStat = ref<Stat>({ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 });
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
const { hosts } = useHosts();
@@ -50,7 +57,7 @@ const roundCPU = (num: number) => (Number.isInteger(num) ? num.toFixed(0) : num.
function toContainerCores(container: Container): number {
if (container.cpuLimit && container.cpuLimit > 0) {
return 1;
return container.cpuLimit;
}
const hostInfo = hosts.value[container.host];
return hostInfo?.nCPU ?? 1;
@@ -82,6 +89,10 @@ watch(
}
totalStat.value = initial[0];
reset({ initial: initial.reverse() });
nextTick(() => {
cpuMonitorRef.value?.recalculate();
memoryMonitorRef.value?.recalculate();
});
},
{ immediate: true },
);
@@ -115,7 +126,7 @@ const limits = computed(() => {
totalCpu += hostTotalCpu;
} else {
// All containers have limits, sum them up (capped at host total)
const sumCpu = hostContainers.reduce((sum, c) => sum + 1, 0);
const sumCpu = hostContainers.reduce((sum, c) => sum + (c.cpuLimit || 0), 0);
totalCpu += Math.min(sumCpu, hostTotalCpu);
}
@@ -2,7 +2,7 @@
<div class="tag grid overflow-hidden rounded-sm text-center text-sm text-white">
<div class="random-color col-start-1 row-start-1 brightness-75"></div>
<div class="col-start-1 row-start-1 truncate px-2 brightness-100" :class="truncateRight ? '[direction:rtl]' : ''">
{{ value }}
<slot>{{ value }}</slot>
</div>
</div>
</template>
+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;
});
@@ -21,6 +21,9 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
</svg> action.copy-link</a></li>
<!--v-if-->
<li><a><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<path fill="currentColor" d="M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"></path>
</svg> action.create-alert</a></li>
</ul>
</div>
<!--v-if-->
@@ -59,6 +62,9 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
</svg> action.copy-link</a></li>
<!--v-if-->
<li><a><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<path fill="currentColor" d="M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"></path>
</svg> action.create-alert</a></li>
</ul>
</div>
<!--v-if-->
@@ -97,6 +103,9 @@ exports[`<ContainerEventSource /> > render html correctly > should render messag
<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"></path>
</svg> action.copy-link</a></li>
<!--v-if-->
<li><a><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<path fill="currentColor" d="M21 19v1H3v-1l2-2v-6c0-3.1 2.03-5.83 5-6.71V4a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.29c2.97.88 5 3.61 5 6.71v6zm-7 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"></path>
</svg> action.create-alert</a></li>
</ul>
</div>
<!--v-if-->
+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(() => {
@@ -0,0 +1,158 @@
<template>
<div class="card bg-base-100 shadow-sm" :class="{ 'opacity-60': !alert.enabled }">
<div class="card-body gap-4 p-5">
<!-- Header -->
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<h4 class="flex items-center gap-2 text-lg font-semibold">
<mdi:chart-line v-if="alert.metricExpression" class="text-info" />
<mdi:bell-ring-outline v-else-if="alert.eventExpression" class="text-info" />
<mdi:text-box-outline v-else class="text-info" />
<span>{{ alert.name }}</span> <span class="text-sm font-light"></span>
<div class="group/dispatch dropdown dropdown-hover">
<div
tabindex="0"
role="button"
class="border-base-content/0 hover:border-base-content/20 flex cursor-pointer items-center gap-1 rounded border px-1.5 py-0.5 text-xs font-light transition-colors"
:class="{ 'text-warning': !alert.dispatcher }"
>
<template v-if="alert.dispatcher">
<mdi:webhook v-if="alert.dispatcher.type === 'webhook'" />
<mdi:cloud v-else />
{{ alert.dispatcher.name }}
</template>
<template v-else>
<mdi:alert-outline />
{{ $t("notifications.alert.dispatcher-deleted") }}
</template>
<mdi:chevron-down class="text-[0.6rem] opacity-0 transition-opacity group-hover/dispatch:opacity-100" />
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-50 w-48 p-2 shadow-lg">
<li v-for="dest in dispatchers" :key="dest.id">
<a
class="flex items-center gap-2"
:class="{ active: dest.id === alert.dispatcher?.id }"
@click="changeDispatcher(dest.id)"
>
<mdi:webhook v-if="dest.type === 'webhook'" />
<mdi:cloud v-else />
{{ dest.name }}
</a>
</li>
</ul>
</div>
</h4>
<span v-if="!alert.enabled" class="badge badge-warning badge-sm">{{ $t("notifications.alert.paused") }}</span>
</div>
<input type="checkbox" class="toggle toggle-primary" :checked="alert.enabled" @change="toggleEnabled" />
</div>
<!-- Expressions -->
<div class="text-base-content/80 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
<span>{{ $t("notifications.alert.containers") }}</span>
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.containerExpression }}</code>
<template v-if="alert.metricExpression">
<span>{{ $t("notifications.alert.metric-filter") }}</span>
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.metricExpression }}</code>
<span>{{ $t("notifications.alert.sample-window") }}</span>
<span>{{ formatDuration(alert.sampleWindow || 15, locale || undefined) }}</span>
<span>{{ $t("notifications.alert.cooldown") }}</span>
<span>{{ formatDuration(alert.cooldown || 300, locale || undefined) }}</span>
</template>
<template v-else-if="alert.eventExpression">
<span>{{ $t("notifications.alert.event-filter") }}</span>
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.eventExpression }}</code>
<template v-if="alert.cooldown">
<span>{{ $t("notifications.alert.cooldown") }}</span>
<span>{{ formatDuration(alert.cooldown, locale || undefined) }}</span>
</template>
</template>
<template v-else>
<span>{{ $t("notifications.alert.log-filter") }}</span>
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.logExpression }}</code>
</template>
</div>
<!-- Footer -->
<div class="border-base-content/10 text-base-content/80 flex items-center justify-between border-t pt-3 text-xs">
<div class="flex items-center gap-4">
<span>
{{ $t("notifications.alert.containers-count", { count: alert.triggeredContainers }) }}
</span>
<span>
{{ $t("notifications.alert.triggered-count", { count: alert.triggerCount }) }}
</span>
<span v-if="alert.lastTriggeredAt">
{{ $t("notifications.alert.last-triggered", { time: formatTimeAgo(alert.lastTriggeredAt) }) }}
</span>
</div>
<div class="flex items-center gap-1">
<button class="btn btn-ghost btn-square" @click="editAlert">
<mdi:pencil-outline />
</button>
<button class="btn btn-ghost btn-square" @click="deleteAlert" :disabled="isDeleting">
<span v-if="isDeleting" class="loading loading-spinner loading-xs"></span>
<mdi:trash-can-outline v-else />
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Dispatcher, NotificationRule } from "@/types/notifications";
import AlertForm from "./AlertForm.vue";
const { alert, onUpdated } = defineProps<{
alert: NotificationRule;
onUpdated?: () => void;
}>();
const showDrawer = useDrawer();
const isDeleting = ref(false);
const dispatchers = ref<Dispatcher[]>([]);
onMounted(async () => {
const res = await fetch(withBase("/api/notifications/dispatchers"));
dispatchers.value = await res.json();
});
async function changeDispatcher(id: number) {
await fetch(withBase(`/api/notifications/rules/${alert.id}`), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dispatcherId: id }),
});
onUpdated?.();
}
function formatTimeAgo(dateStr: string): string {
const date = new Date(dateStr);
if (date.getFullYear() === 0) return "-";
return toRelativeTime(date, undefined);
}
async function toggleEnabled() {
await fetch(withBase(`/api/notifications/rules/${alert.id}`), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !alert.enabled }),
});
onUpdated?.();
}
function editAlert() {
showDrawer(AlertForm, { alert, onCreated: onUpdated }, "lg");
}
async function deleteAlert() {
isDeleting.value = true;
try {
await fetch(withBase(`/api/notifications/rules/${alert.id}`), { method: "DELETE" });
onUpdated?.();
} finally {
isDeleting.value = false;
}
}
</script>
@@ -0,0 +1,232 @@
<template>
<div class="space-y-4 p-4">
<div class="mb-6">
<h2 class="text-2xl font-bold">
{{ isEditing ? $t("notifications.alert-form.edit-title") : $t("notifications.alert-form.create-title") }}
</h2>
<p class="text-base-content/60">{{ $t("notifications.alert-form.description") }}</p>
</div>
<!-- Alert Name -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.alert-name") }}</legend>
<input
ref="alertNameInput"
v-model="alertName"
type="text"
class="input focus:input-primary w-full text-base"
:class="alertName.trim() ? 'input-primary' : ''"
required
:placeholder="$t('notifications.alert-form.alert-name-placeholder')"
/>
</fieldset>
<!-- Alert Type Toggle -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.alert-type") }}</legend>
<div class="flex gap-2">
<button
class="btn btn-sm"
:class="alertType === 'log' ? 'btn-primary' : 'btn-outline'"
@click="alertType = 'log'"
>
<mdi:text-box-outline class="mr-1" />
{{ $t("notifications.alert-form.log-alert") }}
</button>
<button
class="btn btn-sm"
:class="alertType === 'metric' ? 'btn-primary' : 'btn-outline'"
@click="alertType = 'metric'"
>
<mdi:chart-line class="mr-1" />
{{ $t("notifications.alert-form.metric-alert") }}
</button>
<button
class="btn btn-sm"
:class="alertType === 'event' ? 'btn-primary' : 'btn-outline'"
@click="alertType = 'event'"
>
<mdi:bell-ring-outline class="mr-1" />
{{ $t("notifications.alert-form.event-alert") }}
</button>
</div>
</fieldset>
<!-- Container Filter -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.container-filter") }}</legend>
<div
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
:class="
containerExpression.trim() && !containerResult?.error
? 'input-primary'
: { 'input-error!': containerResult?.error }
"
>
<div ref="containerEditorRef" class="w-full"></div>
</div>
<div v-if="containerResult" class="fieldset-label">
<span v-if="containerResult.error" class="text-error">{{ containerResult.error }}</span>
<span v-else-if="containerResult.containers?.length" class="text-success">
<mdi:check class="inline" />
{{
$t("notifications.alert-form.containers-match", {
count: containerResult.containers.length,
names: containerResult.containers.map((c) => c.name).join(", "),
})
}}
</span>
<span v-else class="text-warning">
<mdi:alert class="inline" />
{{ $t("notifications.alert-form.no-containers-match") }}
</span>
</div>
</fieldset>
<!-- Type-specific fields -->
<KeepAlive>
<LogAlertFields
v-if="alertType === 'log'"
ref="fieldsRef"
:alert="alert"
:prefill="prefill"
:container-expression="containerExpression"
:is-loading="isLoading"
:validate-preview="validatePreview"
/>
<MetricAlertFields
v-else-if="alertType === 'metric'"
ref="fieldsRef"
:alert="alert"
:prefill="prefill"
:container-expression="containerExpression"
:is-loading="isLoading"
:validate-preview="validatePreview"
/>
<EventAlertFields
v-else
ref="fieldsRef"
:alert="alert"
:prefill="prefill"
:container-expression="containerExpression"
:is-loading="isLoading"
:validate-preview="validatePreview"
/>
</KeepAlive>
<!-- Destination -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.destination") }}</legend>
<details class="dropdown w-full" ref="destinationDropdown">
<summary class="btn btn-outline w-full justify-between" :class="{ 'btn-primary': selectedDestination }">
<span class="flex items-center gap-2">
<template v-if="selectedDestination">
<mdi:webhook v-if="selectedDestination.type === 'webhook'" />
<mdi:cloud v-else />
{{ selectedDestination.name }}
</template>
<span v-else class="text-base-content/60">{{ $t("notifications.alert-form.select-destination") }}</span>
</span>
<carbon:caret-down />
</summary>
<ul class="dropdown-content menu bg-base-200 rounded-box z-50 mt-1 w-full border p-2 shadow-sm">
<li v-for="dest in destinations" :key="dest.id">
<a
@click="
dispatcherId = dest.id;
destinationDropdown?.removeAttribute('open');
"
:class="{ active: dispatcherId === dest.id }"
>
<mdi:webhook v-if="dest.type === 'webhook'" />
<mdi:cloud v-else />
{{ dest.name }}
</a>
</li>
</ul>
</details>
<div v-if="!destinations.length" class="fieldset-label">
<span class="text-warning">
<mdi:alert class="inline" />
{{ $t("notifications.alert-form.no-destinations") }}
</span>
</div>
</fieldset>
<!-- Error -->
<div v-if="saveError" class="alert alert-error">
<span>{{ saveError }}</span>
</div>
<!-- Actions -->
<div class="flex justify-end gap-2 pt-4">
<button class="btn" @click="close?.()">{{ $t("notifications.alert-form.cancel") }}</button>
<button class="btn btn-primary" :disabled="!canSave" @click="save">
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
{{ isEditing ? $t("notifications.alert-form.save") : $t("notifications.alert-form.create") }}
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { useAlertForm } from "@/composable/alertForm";
import LogAlertFields from "./LogAlertFields.vue";
import MetricAlertFields from "./MetricAlertFields.vue";
import EventAlertFields from "./EventAlertFields.vue";
import type { NotificationRule } from "@/types/notifications";
const props = defineProps<{
close?: () => void;
onCreated?: () => void;
alert?: NotificationRule;
prefill?: {
name?: string;
containerExpression?: string;
logExpression?: string;
metricExpression?: string;
eventExpression?: string;
};
}>();
const {
isEditing,
alertName,
containerExpression,
dispatcherId,
destinations,
selectedDestination,
containerResult,
isLoading,
isSaving,
saveError,
baseCanSave,
setupContainerEditor,
saveAlert,
validatePreview,
} = useAlertForm(props);
// Template refs
const alertNameInput = ref<HTMLInputElement>();
const containerEditorRef = ref<HTMLElement>();
const destinationDropdown = ref<HTMLDetailsElement>();
const fieldsRef = ref<
InstanceType<typeof LogAlertFields> | InstanceType<typeof MetricAlertFields> | InstanceType<typeof EventAlertFields>
>();
useFocus(alertNameInput, { initialValue: true });
// Alert type
const alertType = ref<"log" | "metric" | "event">(
props.alert?.metricExpression ? "metric" : props.alert?.eventExpression ? "event" : "log",
);
const canSave = computed(() => baseCanSave.value && (fieldsRef.value?.canSave ?? false));
async function save() {
if (!canSave.value || !fieldsRef.value) return;
await saveAlert(fieldsRef.value.typeFields);
}
// Container editor
setupContainerEditor(containerEditorRef);
</script>
@@ -0,0 +1,133 @@
<template>
<div class="space-y-4">
<!-- Cloud linked (when editing with prefix) -->
<fieldset v-if="destination?.prefix" class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.api-key") }}</legend>
<div class="join w-full">
<input
type="text"
:value="destination.prefix + '**************************************'"
readonly
disabled
class="input join-item w-full font-mono"
:class="cloudStatusError ? 'input-error' : 'input-success'"
/>
<span class="join-item btn pointer-events-none" :class="cloudStatusError ? 'btn-error' : 'btn-success'">
<mdi:alert-circle v-if="cloudStatusError" class="text-lg" />
<mdi:check v-else class="text-lg" />
</span>
</div>
<!-- Cloud Status -->
<div v-if="isLoadingCloudStatus" class="mt-3 flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
<span class="text-base-content/60 text-sm">{{ $t("notifications.destination-form.cloud-checking") }}</span>
</div>
<div v-else-if="cloudStatusError" class="mt-3">
<div class="alert alert-error">
<mdi:alert-circle class="text-lg" />
<span>{{ $t("notifications.destination-form.cloud-relink") }}</span>
</div>
</div>
<div v-else-if="cloudStatus" class="mt-3 space-y-3">
<div class="flex items-center justify-between text-sm">
<span class="text-base-content/60">{{ $t("notifications.destination-form.cloud-plan") }}</span>
<span class="badge badge-primary badge-sm capitalize">{{ cloudStatus.plan.name }}</span>
</div>
<div>
<div class="mb-1 flex items-center justify-between text-sm">
<span class="text-base-content/60">{{ $t("notifications.destination-form.cloud-usage") }}</span>
<span
>{{ cloudStatus.usage.events_used.toLocaleString() }} /
{{ cloudStatus.usage.events_limit.toLocaleString() }}</span
>
</div>
<progress
class="progress w-full"
:class="usagePercent > 90 ? 'progress-error' : usagePercent > 70 ? 'progress-warning' : 'progress-primary'"
:value="cloudStatus.usage.events_used"
:max="cloudStatus.usage.events_limit"
></progress>
</div>
</div>
<p class="text-base-content/60 mt-2 text-sm">
{{ $t("notifications.destination-form.cloud-settings-hint") }}
<a :href="cloudSettingsUrl" target="_blank" class="link link-primary">
{{ $t("notifications.destination-form.cloud-settings-link") }}
</a>
</p>
</fieldset>
<!-- Link Dozzle Cloud (when creating or not linked) -->
<div v-else class="card card-border border-primary/30 bg-primary/5">
<div class="card-body items-center text-center">
<mdi:cloud-outline class="text-primary text-4xl" />
<h3 class="card-title">{{ $t("notifications.destination-form.link-cloud") }}</h3>
<p class="text-base-content/60 text-sm">{{ $t("notifications.destination-form.cloud-description") }}</p>
<a :href="cloudLinkUrl" class="btn btn-primary btn-lg mt-2">
<mdi:link-variant class="text-lg" />
{{ $t("notifications.destination-form.link-cloud-button") }}
</a>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 pt-4">
<div class="flex-1"></div>
<button class="btn btn-primary" @click="close?.()">
{{ $t("notifications.destination-form.close") }}
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Dispatcher } from "@/types/notifications";
const { destination, close } = defineProps<{
destination?: Dispatcher;
close?: () => void;
}>();
const callbackUrl = `${window.location.origin}${withBase("/")}`;
const cloudLinkUrl = `${__CLOUD_URL__}/link?appUrl=${encodeURIComponent(callbackUrl)}`;
const cloudSettingsUrl = `${__CLOUD_URL__}/settings`;
// Cloud status
interface CloudStatus {
user: { email: string; name: string };
plan: { name: string; events_per_month: number; retention_days: number };
usage: { events_used: number; events_limit: number; period: string };
}
const cloudStatus = ref<CloudStatus | null>(null);
const cloudStatusError = ref(false);
const isLoadingCloudStatus = ref(false);
const usagePercent = computed(() => {
if (!cloudStatus.value) return 0;
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
});
async function fetchCloudStatus() {
isLoadingCloudStatus.value = true;
cloudStatusError.value = false;
try {
const res = await fetch(withBase("/api/cloud/status"));
if (!res.ok) {
cloudStatusError.value = true;
return;
}
cloudStatus.value = await res.json();
} catch {
cloudStatusError.value = true;
} finally {
isLoadingCloudStatus.value = false;
}
}
if (destination?.prefix) {
fetchCloudStatus();
}
</script>
@@ -0,0 +1,16 @@
<template>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.cooldown-label") }}</legend>
<input v-model.number="model" type="range" min="0" max="3600" step="10" class="range range-primary" />
<p class="text-base-content/50 mt-1 text-xs">
<template v-if="model === 0">{{ $t("notifications.alert-form.no-cooldown") }}</template>
<template v-else>{{
$t("notifications.alert-form.cooldown-hint", { duration: formatDuration(model, locale || undefined) })
}}</template>
</p>
</fieldset>
</template>
<script lang="ts" setup>
const model = defineModel<number>({ required: true });
</script>
@@ -0,0 +1,68 @@
<template>
<div class="card bg-base-100 hover:border-primary cursor-pointer border border-transparent" @click="editDestination">
<div class="card-body gap-2 p-4">
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg">
<mdi:webhook v-if="destination.type === 'webhook'" class="text-lg" />
<mdi:cloud v-else class="text-primary-content text-lg" />
</div>
<div class="flex-1">
<h4 class="font-semibold">{{ destination.name }}</h4>
<p class="text-base-content/60 text-sm">
{{
destination.type === "webhook"
? $t("notifications.destination.http-webhook")
: $t("notifications.destination.dozzle-cloud")
}}
</p>
</div>
<div class="dropdown dropdown-end" @click.stop>
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
<ion:ellipsis-vertical />
</label>
<ul
tabindex="0"
class="menu dropdown-content rounded-box bg-base-100 border-base-content/20 z-50 w-40 border p-1 shadow-sm"
>
<li>
<a @click="editDestination">{{ $t("notifications.destination.edit") }}</a>
</li>
<li>
<a class="text-error" @click="deleteDestination">{{ $t("notifications.destination.delete") }}</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Dispatcher } from "@/types/notifications";
import DestinationForm from "./DestinationForm.vue";
const { destination, onUpdated, existingDispatchers } = defineProps<{
destination: Dispatcher;
onUpdated?: () => void;
existingDispatchers: Dispatcher[];
}>();
const showDrawer = useDrawer();
function editDestination() {
showDrawer(
DestinationForm,
{
destination,
onCreated: onUpdated,
existingDispatchers,
},
"md",
);
}
async function deleteDestination() {
await fetch(withBase(`/api/notifications/dispatchers/${destination.id}`), { method: "DELETE" });
onUpdated?.();
}
</script>
@@ -0,0 +1,108 @@
<template>
<div class="space-y-4 p-4">
<div class="mb-6">
<h2 class="text-2xl font-bold">
{{
isEditing
? $t("notifications.destination-form.edit-title")
: $t("notifications.destination-form.create-title")
}}
</h2>
<p class="text-base-content/60">{{ $t("notifications.destination-form.description") }}</p>
</div>
<!-- Link Success Alert -->
<div v-if="showLinkSuccess" class="alert alert-success">
<mdi:check-circle class="text-lg" />
<div>
<div class="font-semibold">{{ $t("notifications.cloud-link-success.title") }}</div>
<div class="text-sm">{{ $t("notifications.cloud-link-success.message") }}</div>
</div>
</div>
<!-- Type Selection -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.type") }}</legend>
<div class="space-y-3">
<label
class="card card-border 20 cursor-pointer transition-colors"
:class="type === 'webhook' ? 'border-primary bg-primary/10' : ''"
>
<div class="card-body flex-row items-center gap-3 p-4">
<input type="radio" v-model="type" value="webhook" class="radio radio-primary" />
<div>
<div class="font-semibold">{{ $t("notifications.destination-form.webhook-title") }}</div>
<div class="text-base-content/60 text-sm">
{{ $t("notifications.destination-form.webhook-description") }}
</div>
</div>
</div>
</label>
<label
class="card card-border border-base-content/20 transition-colors"
:class="[
type === 'cloud' ? 'border-primary bg-primary/10' : '',
hasExistingCloudDestination && type !== 'cloud' ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
]"
>
<div class="card-body flex-row items-center gap-3 p-4">
<input
type="radio"
v-model="type"
value="cloud"
class="radio radio-primary"
:disabled="hasExistingCloudDestination && type !== 'cloud'"
/>
<div>
<div class="font-semibold">{{ $t("notifications.destination-form.cloud-title") }}</div>
<div class="text-base-content/60 text-sm">
{{ $t("notifications.destination-form.cloud-description") }}
</div>
<div v-if="hasExistingCloudDestination && type !== 'cloud'" class="text-warning mt-1 text-xs">
{{ $t("notifications.destination-form.cloud-exists") }}
</div>
</div>
</div>
</label>
</div>
</fieldset>
<!-- Type-specific form -->
<WebhookDestinationForm
v-if="type === 'webhook'"
:destination="destination"
:close="close"
:on-created="onCreated"
:is-editing="isEditing"
/>
<CloudDestinationForm v-else :destination="destination" :close="close" />
</div>
</template>
<script lang="ts" setup>
import type { Dispatcher } from "@/types/notifications";
import WebhookDestinationForm from "./WebhookDestinationForm.vue";
import CloudDestinationForm from "./CloudDestinationForm.vue";
const {
close,
onCreated,
destination,
existingDispatchers = [],
showLinkSuccess = false,
} = defineProps<{
close?: () => void;
onCreated?: () => void;
destination?: Dispatcher;
existingDispatchers?: Dispatcher[];
showLinkSuccess?: boolean;
}>();
const isEditing = !!destination;
const type = ref<"webhook" | "cloud">((destination?.type as "webhook" | "cloud") ?? "webhook");
const hasExistingCloudDestination = computed(() => {
const others = isEditing ? existingDispatchers.filter((d) => d.id !== destination!.id) : existingDispatchers;
return others.some((d) => d.type === "cloud");
});
</script>
@@ -0,0 +1,89 @@
<template>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.event-filter") }}</legend>
<div
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
:class="eventExpression.trim() && !eventError ? 'input-primary' : { 'input-error!': eventError }"
>
<div ref="editorRef" class="w-full"></div>
</div>
<div v-if="eventError || eventExpression" class="fieldset-label">
<span v-if="eventError" class="text-error">{{ eventError }}</span>
<span v-else class="text-success">
<mdi:check class="inline" />
{{ $t("notifications.alert-form.expression-valid") }}
</span>
</div>
<p class="text-base-content/50 mt-1 text-xs">
{{
$t("notifications.alert-form.event-fields-hint", {
fields: "name (start, stop, die, restart, health_status), attributes (exitCode, signal, etc.)",
})
}}
</p>
</fieldset>
<CooldownField v-model="cooldown" />
</template>
<script lang="ts" setup>
import { createEventHints } from "@/composable/exprEditor";
import type { NotificationRule, PreviewResult } from "@/types/notifications";
const props = defineProps<{
alert?: NotificationRule;
prefill?: { eventExpression?: string };
containerExpression: string;
isLoading: boolean;
validatePreview: (extra: Record<string, unknown>) => Promise<{ data: PreviewResult | null }>;
}>();
const eventExpression = ref(props.alert?.eventExpression ?? props.prefill?.eventExpression ?? "");
const eventError = ref<string | null>(null);
const cooldown = ref(props.alert?.cooldown ?? 10);
const canSave = computed(() => !!eventExpression.value.trim() && !eventError.value);
const typeFields = computed(() => ({
eventExpression: eventExpression.value,
logExpression: "",
metricExpression: "",
cooldown: cooldown.value,
sampleWindow: 0,
}));
defineExpose({ canSave, typeFields });
// Validation
async function validate() {
if (!props.containerExpression && !eventExpression.value) {
eventError.value = null;
return;
}
const { data } = await props.validatePreview({
eventExpression: eventExpression.value || undefined,
});
if (data) {
eventError.value = data.eventError ?? null;
}
}
const debouncedValidate = useDebounceFn(validate, 500);
watch(
[() => props.containerExpression, eventExpression],
() => {
debouncedValidate();
},
{ immediate: true },
);
// Editor
const editorRef = ref<HTMLElement>();
useExprEditorField(editorRef, {
placeholder: 'name == "die"',
initialValue: props.alert?.eventExpression ?? props.prefill?.eventExpression ?? "",
getHints: () => createEventHints(),
onChange: (v) => (eventExpression.value = v),
});
</script>
@@ -0,0 +1,103 @@
<template>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.log-filter") }}</legend>
<div
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
:class="logExpression.trim() && !logError ? 'input-primary' : { 'input-error!': logError }"
>
<div ref="editorRef" class="w-full"></div>
</div>
<div v-if="logError || logExpression" class="fieldset-label">
<span v-if="logError" class="text-error">{{ logError }}</span>
<span v-else-if="logMessages.length" class="text-success">
<mdi:check class="inline" />
{{ $t("notifications.alert-form.logs-match", { count: logTotalCount }) }}
</span>
<span v-else-if="!props.isLoading" class="text-warning">
<mdi:alert class="inline" />
{{ $t("notifications.alert-form.no-logs-match") }}
</span>
</div>
</fieldset>
<!-- Log Preview -->
<div v-if="logMessages.length" class="mt-4">
<div class="mb-2 text-lg">{{ $t("notifications.alert-form.preview") }}</div>
<LogList
:messages="logMessages"
:last-selected-item="undefined"
class="border-base-content/50 h-64 overflow-hidden rounded-lg border"
/>
</div>
</template>
<script lang="ts" setup>
import { type LogEvent, type LogEntry, type LogMessage, asLogEntry } from "@/models/LogEntry";
import { createLogHints } from "@/composable/exprEditor";
import type { NotificationRule, PreviewResult } from "@/types/notifications";
const props = defineProps<{
alert?: NotificationRule;
prefill?: { logExpression?: string };
containerExpression: string;
isLoading: boolean;
validatePreview: (extra: Record<string, unknown>) => Promise<{ data: PreviewResult | null }>;
}>();
const logExpression = ref(props.alert?.logExpression ?? props.prefill?.logExpression ?? "");
const logError = ref<string | null>(null);
const logTotalCount = ref(0);
const logMessages = shallowRef<LogEntry<LogMessage>[]>([]);
const messageKeys = ref<string[]>([]);
const canSave = computed(() => !logError.value);
const typeFields = computed(() => ({ logExpression: logExpression.value, metricExpression: "", cooldown: 0 }));
defineExpose({ canSave, typeFields });
// Validation
async function validate() {
if (!props.containerExpression && !logExpression.value) {
logError.value = null;
logTotalCount.value = 0;
logMessages.value = [];
messageKeys.value = [];
return;
}
const { data } = await props.validatePreview({
logExpression: logExpression.value || undefined,
});
if (data) {
messageKeys.value = data.messageKeys ?? [];
if (logExpression.value && !data.containerError) {
logError.value = data.logError ?? null;
logTotalCount.value = data.totalLogs;
logMessages.value = data.matchedLogs?.map((event) => asLogEntry(event as LogEvent)) ?? [];
} else {
logError.value = null;
logTotalCount.value = 0;
logMessages.value = [];
}
}
}
const debouncedValidate = useDebounceFn(validate, 500);
watch(
[() => props.containerExpression, logExpression],
() => {
debouncedValidate();
},
{ immediate: true },
);
// Editor
const editorRef = ref<HTMLElement>();
useExprEditorField(editorRef, {
placeholder: 'level == "error" && message contains "timeout"',
initialValue: props.alert?.logExpression ?? props.prefill?.logExpression ?? "",
getHints: () => createLogHints(messageKeys.value),
onChange: (v) => (logExpression.value = v),
});
</script>
@@ -0,0 +1,101 @@
<template>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.metric-filter") }}</legend>
<div
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
:class="metricExpression.trim() && !metricError ? 'input-primary' : { 'input-error!': metricError }"
>
<div ref="editorRef" class="w-full"></div>
</div>
<div v-if="metricError || metricExpression" class="fieldset-label">
<span v-if="metricError" class="text-error">{{ metricError }}</span>
<span v-else class="text-success">
<mdi:check class="inline" />
{{ $t("notifications.alert-form.expression-valid") }}
</span>
</div>
<p class="text-base-content/50 mt-1 text-xs">
{{
$t("notifications.alert-form.metric-fields-hint", {
fields: "cpu (CPU %), memory (memory %), memoryUsage (bytes)",
})
}}
</p>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.sample-window-label") }}</legend>
<input v-model.number="sampleWindow" type="range" min="15" max="300" step="15" class="range range-primary" />
<p class="text-base-content/50 mt-1 text-xs">
{{
$t("notifications.alert-form.sample-window-hint", {
duration: formatDuration(sampleWindow, locale || undefined),
})
}}
</p>
</fieldset>
<CooldownField v-model="cooldown" />
</template>
<script lang="ts" setup>
import { createMetricHints } from "@/composable/exprEditor";
import type { NotificationRule, PreviewResult } from "@/types/notifications";
const props = defineProps<{
alert?: NotificationRule;
prefill?: { metricExpression?: string };
containerExpression: string;
isLoading: boolean;
validatePreview: (extra: Record<string, unknown>) => Promise<{ data: PreviewResult | null }>;
}>();
const metricExpression = ref(props.alert?.metricExpression ?? props.prefill?.metricExpression ?? "");
const metricError = ref<string | null>(null);
const sampleWindow = ref(props.alert?.sampleWindow ?? 15);
const cooldown = ref(props.alert?.cooldown ?? 300);
const canSave = computed(() => !!metricExpression.value.trim() && !metricError.value);
const typeFields = computed(() => ({
metricExpression: metricExpression.value,
logExpression: "",
cooldown: cooldown.value,
sampleWindow: sampleWindow.value,
}));
defineExpose({ canSave, typeFields });
// Validation
async function validate() {
if (!props.containerExpression && !metricExpression.value) {
metricError.value = null;
return;
}
const { data } = await props.validatePreview({
metricExpression: metricExpression.value || undefined,
});
if (data) {
metricError.value = data.metricError ?? null;
}
}
const debouncedValidate = useDebounceFn(validate, 500);
watch(
[() => props.containerExpression, metricExpression],
() => {
debouncedValidate();
},
{ immediate: true },
);
// Editor
const editorRef = ref<HTMLElement>();
useExprEditorField(editorRef, {
placeholder: "cpu > 80 || memory > 90",
initialValue: props.alert?.metricExpression ?? props.prefill?.metricExpression ?? "",
getHints: () => createMetricHints(),
onChange: (v) => (metricExpression.value = v),
});
</script>
@@ -0,0 +1,277 @@
<template>
<div class="space-y-4">
<!-- Name -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.name") }}</legend>
<input
ref="nameInput"
v-model="name"
type="text"
class="input focus:input-primary w-full text-base"
required
:class="{ 'input-primary': name.trim().length > 0 }"
:placeholder="$t('notifications.destination-form.name-placeholder')"
/>
</fieldset>
<!-- Webhook URL -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.webhook-url") }}</legend>
<input
v-model="webhookUrl"
type="url"
class="input focus:input-primary w-full text-base"
:class="{ 'input-primary': isValidUrl, 'input-error': webhookUrl.trim() && !isValidUrl }"
:placeholder="$t('notifications.destination-form.webhook-url-placeholder')"
/>
</fieldset>
<!-- Payload Format (create mode only) -->
<fieldset v-if="!isEditing" class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.payload-format") }}</legend>
<div class="flex flex-wrap gap-2">
<button
v-for="format in ['slack', 'discord', 'ntfy', 'custom'] as const"
:key="format"
type="button"
class="btn btn-sm"
:class="payloadFormat === format ? 'btn-primary' : 'btn-ghost'"
@click="selectPayloadFormat(format)"
>
{{ $t(`notifications.destination-form.format-${format}`) }}
</button>
</div>
</fieldset>
<!-- Template -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">
{{ $t("notifications.destination-form.template") }}
<span class="text-base-content/60 ml-2 text-sm font-normal">{{
$t("notifications.destination-form.template-hint")
}}</span>
</legend>
<div
ref="templateEditorRef"
class="border-base-content/20 focus-within:border-primary min-h-48 w-full overflow-auto rounded-lg border"
></div>
</fieldset>
<!-- Custom Headers -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">
{{ $t("notifications.destination-form.headers") }}
<span class="text-base-content/60 ml-2 text-sm font-normal">{{
$t("notifications.destination-form.headers-hint")
}}</span>
</legend>
<div class="space-y-2">
<div v-for="(header, index) in headers" :key="header.key" class="flex items-center gap-2">
<input
v-model="header.name"
type="text"
class="input focus:input-primary flex-1 text-base"
:placeholder="$t('notifications.destination-form.header-name')"
/>
<input
v-model="header.value"
type="text"
class="input focus:input-primary flex-1 text-base"
:placeholder="$t('notifications.destination-form.header-value')"
/>
<button type="button" class="btn btn-ghost btn-sm btn-square" @click="headers.splice(index, 1)">
<carbon:close />
</button>
</div>
<button
type="button"
class="btn btn-ghost btn-sm"
@click="headers.push({ name: '', value: '', key: headerKeyCounter++ })"
>
<carbon:add />
{{ $t("notifications.destination-form.add-header") }}
</button>
</div>
</fieldset>
<!-- Error -->
<div v-if="error" class="alert alert-error">
<span>{{ error }}</span>
</div>
<!-- Test Result -->
<div v-if="testResult" class="alert" :class="testResult.success ? 'alert-success' : 'alert-error'">
<span v-if="testResult.success">
{{ $t("notifications.destination-form.test-success") }}
<span v-if="testResult.statusCode" class="opacity-70">({{ testResult.statusCode }})</span>
</span>
<span v-else>
{{ testResult.error }}
</span>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 pt-4">
<button class="btn" @click="testDestination" :disabled="!canTest || !isValidUrl || isTesting">
<span v-if="isTesting" class="loading loading-spinner loading-sm"></span>
{{ $t("notifications.destination-form.test") }}
</button>
<div class="flex-1"></div>
<button class="btn" @click="close?.()">
{{ $t("notifications.destination-form.cancel") }}
</button>
<button class="btn btn-primary" :disabled="!canSave" @click="saveDestination">
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
{{ isEditing ? $t("notifications.destination-form.save") : $t("notifications.destination-form.add") }}
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Dispatcher, TestWebhookResult } from "@/types/notifications";
import { createTemplateEditor } from "@/composable/templateEditor";
import { PAYLOAD_TEMPLATES, type PayloadFormat } from "./payloadTemplates";
const { close, onCreated, destination, isEditing } = defineProps<{
close?: () => void;
onCreated?: () => void;
destination?: Dispatcher;
isEditing: boolean;
}>();
const nameInput = ref<HTMLInputElement>();
const templateEditorRef = ref<HTMLElement>();
const name = ref(destination?.name ?? "");
useFocus(nameInput, { initialValue: true });
const webhookUrl = ref(destination?.url ?? "");
const payloadFormat = ref<PayloadFormat>(isEditing ? "custom" : "slack");
const template = ref(isEditing ? (destination?.template ?? "") : PAYLOAD_TEMPLATES[payloadFormat.value]);
let headerKeyCounter = 0;
const headers = ref<{ name: string; value: string; key: number }[]>(
destination?.headers
? Object.entries(destination.headers).map(([name, value]) => ({ name, value, key: headerKeyCounter++ }))
: [],
);
const isTesting = ref(false);
const isSaving = ref(false);
const error = ref<string | null>(null);
const testResult = ref<TestWebhookResult | null>(null);
let templateEditorView: Awaited<ReturnType<typeof createTemplateEditor>> | undefined;
function selectPayloadFormat(format: PayloadFormat) {
payloadFormat.value = format;
template.value = PAYLOAD_TEMPLATES[format];
setEditorContent(template.value);
}
function setEditorContent(value: string) {
if (!templateEditorView) return;
templateEditorView.dispatch({
changes: { from: 0, to: templateEditorView.state.doc.length, insert: value },
});
}
onMounted(async () => {
if (!templateEditorRef.value) return;
templateEditorView = await createTemplateEditor({
parent: templateEditorRef.value,
initialValue: template.value,
onChange: (v) => (template.value = v),
});
});
onScopeDispose(() => {
templateEditorView?.destroy();
});
function headersToRecord(): Record<string, string> | undefined {
const filtered = headers.value.filter((h) => h.name.trim() && h.value.trim());
if (filtered.length === 0) return undefined;
return Object.fromEntries(filtered.map((h) => [h.name.trim(), h.value.trim()]));
}
const canTest = computed(() => webhookUrl.value.trim().length > 0);
const isValidUrl = computed(() => {
try {
new URL(webhookUrl.value.trim());
return true;
} catch {
return false;
}
});
const canSave = computed(() => {
if (isSaving.value) return false;
if (!name.value.trim()) return false;
if (!isValidUrl.value) return false;
return true;
});
async function testDestination() {
if (!canTest.value) return;
isTesting.value = true;
testResult.value = null;
try {
const res = await fetch(withBase("/api/notifications/test-webhook"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: webhookUrl.value.trim(),
template: template.value.trim() || undefined,
headers: headersToRecord(),
}),
});
const data: TestWebhookResult = await res.json();
testResult.value = data;
} catch (e) {
testResult.value = { success: false, error: e instanceof Error ? e.message : "Test failed" };
} finally {
isTesting.value = false;
}
}
async function saveDestination() {
if (!canSave.value) return;
isSaving.value = true;
error.value = null;
try {
const input = {
name: name.value.trim(),
type: "webhook",
url: webhookUrl.value.trim(),
template: template.value.trim() || undefined,
headers: headersToRecord(),
};
const url = isEditing
? withBase(`/api/notifications/dispatchers/${destination!.id}`)
: withBase("/api/notifications/dispatchers");
const res = await fetch(url, {
method: isEditing ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to save destination");
}
onCreated?.();
close?.();
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to save destination";
} finally {
isSaving.value = false;
}
}
</script>
@@ -0,0 +1,63 @@
export type PayloadFormat = "slack" | "discord" | "ntfy" | "custom";
export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
slack: JSON.stringify(
{
text: "{{ .Container.Name }}",
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: "*{{ .Container.Name }}*\n{{ .Detail }}",
},
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: "Host: {{ .Container.HostName }} | Image: {{ .Container.Image }}",
},
],
},
],
},
null,
2,
),
discord: JSON.stringify(
{
content: "{{ .Container.Name }}",
embeds: [
{
title: "{{ .Container.Name }}",
description: "{{ .Detail }}",
fields: [
{ name: "Host", value: "{{ .Container.HostName }}", inline: true },
{ name: "Image", value: "{{ .Container.Image }}", inline: true },
],
},
],
},
null,
2,
),
ntfy: JSON.stringify(
{
topic: "dozzle-{{ .Container.HostName }}",
title: "{{ .Container.Name }}",
message: "{{ .Detail }}",
},
null,
2,
),
custom: JSON.stringify(
{
container: "{{ .Container.Name }}",
message: "{{ .Detail }}",
},
null,
2,
),
};
+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
@@ -5,7 +5,7 @@
<ph:stack-simple />
<ContainerDropdown :containers="service.containers">{{ service.name }}</ContainerDropdown>
<MultiContainerStat class="ml-auto" :containers="service.containers" />
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
<MultiContainerActionToolbar class="max-md:hidden" :name="service.name" @clear="viewer?.clear()" />
</div>
</template>
<template #default>
+1 -1
View File
@@ -10,7 +10,7 @@
</ContainerDropdown>
</div>
<MultiContainerStat class="ml-auto" :containers="stack.containers" />
<MultiContainerActionToolbar class="max-md:hidden" @clear="viewer?.clear()" />
<MultiContainerActionToolbar class="max-md:hidden" :name="stack.name" @clear="viewer?.clear()" />
</div>
</template>
<template #default>
+6 -1
View File
@@ -11,7 +11,7 @@
<mdi:close class="swap-on" />
</button>
</form>
<slot v-if="open"></slot>
<slot v-if="open" :close="close"></slot>
</div>
</div>
<form method="dialog" class="modal-backdrop">
@@ -28,11 +28,16 @@ const { width } = defineProps<{
width: DrawerWidth;
}>();
function close() {
panel.value?.close();
}
defineExpose({
open: () => {
open.value = true;
panel.value?.showModal();
},
close,
});
useEventListener(panel, "close", () => (open.value = false));
+155
View File
@@ -0,0 +1,155 @@
import { Container } from "@/models/Container";
import type { ContainerJson } from "@/types/Container";
import type { Dispatcher, NotificationRule, PreviewResult } from "@/types/notifications";
import { createContainerHints } from "@/composable/exprEditor";
export interface AlertFormOptions {
close?: () => void;
onCreated?: () => void;
alert?: NotificationRule;
prefill?: {
name?: string;
containerExpression?: string;
logExpression?: string;
metricExpression?: string;
eventExpression?: string;
};
}
export interface ContainerResult {
error?: string;
containers?: Container[];
}
export function useAlertForm(options: AlertFormOptions) {
const isEditing = computed(() => !!options.alert);
const alertName = ref(options.alert?.name ?? options.prefill?.name ?? "");
const containerExpression = ref(options.alert?.containerExpression ?? options.prefill?.containerExpression ?? "");
const dispatcherId = ref(options.alert?.dispatcher?.id ?? 0);
const isSaving = ref(false);
const saveError = ref<string | null>(null);
// Destinations
const destinations = ref<Dispatcher[]>([]);
onMounted(async () => {
const res = await fetch(withBase("/api/notifications/dispatchers"));
destinations.value = await res.json();
});
const selectedDestination = computed(() => destinations.value.find((d) => d.id === dispatcherId.value));
// Container store for autocomplete
const containerStore = useContainerStore();
const { containers } = storeToRefs(containerStore);
const containerNames = computed(() => [
...new Set(containers.value.filter((c) => c.state === "running").map((c) => c.name)),
]);
const imageNames = computed(() => [...new Set(containers.value.map((c) => c.image))]);
const hostNames = computed(() => [...new Set(containers.value.map((c) => c.host))]);
// Container validation
const containerResult = ref<ContainerResult | null>(null);
const isLoading = ref(false);
const baseCanSave = computed(
() =>
alertName.value.trim() &&
containerExpression.value.trim() &&
dispatcherId.value > 0 &&
!containerResult.value?.error &&
!isSaving.value,
);
function setupContainerEditor(editorRef: Ref<HTMLElement | undefined>) {
useExprEditorField(editorRef, {
placeholder: 'name contains "api"',
initialValue: options.alert?.containerExpression ?? options.prefill?.containerExpression ?? "",
getHints: () => createContainerHints(containerNames.value, imageNames.value, hostNames.value),
onChange: (v) => (containerExpression.value = v),
});
}
async function saveAlert(typeSpecificFields: Record<string, unknown>) {
isSaving.value = true;
saveError.value = null;
try {
const input = {
name: alertName.value.trim(),
containerExpression: containerExpression.value,
dispatcherId: dispatcherId.value,
enabled: true,
...typeSpecificFields,
};
const url = isEditing.value
? withBase(`/api/notifications/rules/${options.alert!.id}`)
: withBase("/api/notifications/rules");
const res = await fetch(url, {
method: isEditing.value ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to save alert");
}
options.onCreated?.();
options.close?.();
} catch (e) {
saveError.value = e instanceof Error ? e.message : "Failed to save alert";
} finally {
isSaving.value = false;
}
}
async function validatePreview(extraFields: Record<string, unknown> = {}) {
if (!containerExpression.value && !Object.values(extraFields).some(Boolean)) {
containerResult.value = null;
return { data: null };
}
isLoading.value = true;
try {
const res = await fetch(withBase("/api/notifications/preview"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
containerExpression: containerExpression.value,
...extraFields,
}),
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || "Preview failed");
}
const data: PreviewResult = await res.json();
containerResult.value = containerExpression.value
? {
error: data.containerError ?? undefined,
containers: data.matchedContainers?.map((c) => Container.fromJSON(c as ContainerJson)),
}
: null;
return { data };
} catch (e) {
containerResult.value = { error: e instanceof Error ? e.message : "Unknown error" };
return { data: null };
} finally {
isLoading.value = false;
}
}
return {
isEditing,
alertName,
containerExpression,
dispatcherId,
destinations,
selectedDestination,
containerResult,
isLoading,
isSaving,
saveError,
baseCanSave,
setupContainerEditor,
saveAlert,
validatePreview,
};
}
+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,
};
};
+6
View File
@@ -5,6 +5,7 @@ export function useDownloadUrl(
containers: Ref<Container[]> | ComputedRef<Container[]>,
streamConfig: { stdout: boolean; stderr: boolean } | Ref<{ stdout: boolean; stderr: boolean }>,
levels: Ref<Set<string>>,
name?: Ref<string> | ComputedRef<string> | string,
) {
const { debouncedSearchFilter } = useSearchFilter();
@@ -27,6 +28,11 @@ export function useDownloadUrl(
selectedLevels.forEach((level) => params.append("levels", level));
}
const nameValue = toValue(name);
if (nameValue) {
params.append("name", nameValue);
}
const containerIds = toValue(containers)
.map((c) => c.host + "~" + c.id)
.join(",");
+18 -104
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,44 +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, min, maxStart }: { lastSeenId?: number; min?: number; maxStart?: number } = {},
) {
const url = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs`);
const abortController = new AbortController();
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));
}
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,
};
}
+201
View File
@@ -0,0 +1,201 @@
import type { Completion } from "@codemirror/autocomplete";
export interface ExprEditorOptions {
parent: HTMLElement;
placeholder: string;
initialValue: string;
getHints: () => Completion[];
onChange?: (value: string) => void;
}
// Common operators for expr language
const exprOperators: Completion[] = [
{ label: "==", detail: "equals", type: "operator" },
{ label: "!=", detail: "not equals", type: "operator" },
{ label: "contains", detail: "string contains", type: "keyword" },
{ label: "startsWith", detail: "string starts with", type: "keyword" },
{ label: "endsWith", detail: "string ends with", type: "keyword" },
{ label: "matches", detail: "regex match", type: "keyword" },
{ label: "&&", detail: "logical AND", type: "operator" },
{ label: "||", detail: "logical OR", type: "operator" },
{ label: "!", detail: "logical NOT", type: "operator" },
{ label: "in", detail: "membership test", type: "keyword" },
{ label: "not in", detail: "negative membership", type: "keyword" },
];
export function createContainerHints(
containerNames: string[],
imageNames: string[],
hostNames: string[],
): Completion[] {
return [
{ label: "name", detail: "container name", type: "property" },
{ label: "id", detail: "container ID", type: "property" },
{ label: "image", detail: "container image", type: "property" },
{ label: "state", detail: "running, exited, etc.", type: "property" },
{ label: "health", detail: "healthy, unhealthy, none", type: "property" },
{ label: "host", detail: "docker host", type: "property" },
{ label: "labels", detail: "container labels map", type: "property" },
...exprOperators,
{ label: '"running"', detail: "state value", type: "string" },
{ label: '"exited"', detail: "state value", type: "string" },
{ label: '"created"', detail: "state value", type: "string" },
{ label: '"paused"', detail: "state value", type: "string" },
{ label: '"healthy"', detail: "health value", type: "string" },
{ label: '"unhealthy"', detail: "health value", type: "string" },
{ label: '"none"', detail: "health value", type: "string" },
...containerNames.map((name) => ({ label: `"${name}"`, detail: "container name", type: "string" }) as Completion),
...imageNames.map((image) => ({ label: `"${image}"`, detail: "image name", type: "string" }) as Completion),
...hostNames.map((host) => ({ label: `"${host}"`, detail: "host name", type: "string" }) as Completion),
];
}
export function createLogHints(messageKeys?: string[]): Completion[] {
return [
{ label: "message", detail: "log message content", type: "property" },
{ label: "level", detail: "log level", type: "property" },
{ label: "stream", detail: "stdout or stderr", type: "property" },
{ label: "type", detail: "log type", type: "property" },
{ label: "timestamp", detail: "unix timestamp", type: "property" },
{ label: "id", detail: "log entry ID", type: "property" },
...(messageKeys ?? []).map(
(key) => ({ label: `message.${key}`, detail: "message field", type: "property" }) as Completion,
),
...exprOperators,
{ label: '"error"', detail: "level value", type: "string" },
{ label: '"warn"', detail: "level value", type: "string" },
{ label: '"info"', detail: "level value", type: "string" },
{ label: '"debug"', detail: "level value", type: "string" },
{ label: '"trace"', detail: "level value", type: "string" },
{ label: '"stdout"', detail: "stream value", type: "string" },
{ label: '"stderr"', detail: "stream value", type: "string" },
{ label: 'level == "error"', detail: "match error logs", type: "text", boost: 10 },
{ label: 'message contains ""', detail: "search in message", type: "text", boost: 10 },
{ label: 'stream == "stderr"', detail: "match stderr", type: "text", boost: 10 },
];
}
export function createMetricHints(): Completion[] {
return [
{ label: "cpu", detail: "CPU usage percent", type: "property" },
{ label: "memory", detail: "memory usage percent", type: "property" },
{ label: "memoryUsage", detail: "memory usage bytes", type: "property" },
...exprOperators,
{ label: ">", detail: "greater than", type: "operator" },
{ label: "<", detail: "less than", type: "operator" },
{ label: ">=", detail: "greater or equal", type: "operator" },
{ label: "<=", detail: "less or equal", type: "operator" },
{ label: "cpu > 80", detail: "CPU over 80%", type: "text", boost: 10 },
{ label: "memory > 90", detail: "memory over 90%", type: "text", boost: 10 },
{ label: "cpu > 80 || memory > 90", detail: "CPU or memory high", type: "text", boost: 10 },
];
}
export function createEventHints(): Completion[] {
return [
{ label: "name", detail: "event name", type: "property" },
{ label: "attributes", detail: "event attributes map", type: "property" },
...exprOperators,
{ label: '"start"', detail: "container started", type: "string" },
{ label: '"stop"', detail: "container stopped", type: "string" },
{ label: '"die"', detail: "container died", type: "string" },
{ label: '"restart"', detail: "container restarted", type: "string" },
{ label: '"health_status"', detail: "health check changed", type: "string" },
{ label: 'name == "die"', detail: "match container death", type: "text", boost: 10 },
{ label: 'name == "health_status"', detail: "match health changes", type: "text", boost: 10 },
{ label: 'name in ["stop", "die"]', detail: "match stop or death", type: "text", boost: 10 },
];
}
function createAutocomplete(getHints: () => Completion[]) {
return (context: any) => {
const word = context.matchBefore(/[\w"=!&|]+/);
if (!word && !context.explicit) return null;
const currentWord = word ? word.text.toLowerCase() : "";
const hints = getHints();
const filtered = currentWord ? hints.filter((h) => h.label.toLowerCase().includes(currentWord)) : hints;
return { from: word ? word.from : context.pos, options: filtered };
};
}
export async function createExprEditor(options: ExprEditorOptions) {
const [
{ EditorView, keymap, placeholder },
{ EditorState },
{ autocompletion, completionKeymap },
{ HighlightStyle, syntaxHighlighting },
{ tags },
] = await Promise.all([
import("@codemirror/view"),
import("@codemirror/state"),
import("@codemirror/autocomplete"),
import("@codemirror/language"),
import("@lezer/highlight"),
]);
const editorTheme = EditorView.theme({
"&": {
backgroundColor: "var(--color-base-100)",
color: "var(--color-base-content)",
},
".cm-content": {
caretColor: "var(--color-primary)",
},
".cm-cursor": {
borderLeftColor: "var(--color-primary)",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
backgroundColor: "var(--color-base-300)",
},
".cm-activeLine": {
backgroundColor: "color-mix(in oklch, var(--color-base-200) 50%, transparent)",
},
".cm-gutters": {
backgroundColor: "var(--color-base-200)",
color: "color-mix(in oklch, var(--color-base-content) 50%, transparent)",
border: "none",
},
".cm-activeLineGutter": {
backgroundColor: "var(--color-base-300)",
},
});
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: "var(--color-primary)" },
{ tag: tags.operator, color: "var(--color-secondary)" },
{ tag: tags.string, color: "var(--color-success)" },
{ tag: tags.number, color: "var(--color-warning)" },
{ tag: tags.bool, color: "var(--color-warning)" },
{ tag: tags.propertyName, color: "var(--color-info)" },
{ tag: tags.variableName, color: "var(--color-base-content)" },
{
tag: tags.comment,
color: "color-mix(in oklch, var(--color-base-content) 50%, transparent)",
fontStyle: "italic",
},
]);
const state = EditorState.create({
doc: options.initialValue,
extensions: [
EditorView.lineWrapping,
placeholder(options.placeholder),
autocompletion({
override: [createAutocomplete(options.getHints)],
activateOnTyping: true,
}),
keymap.of(completionKeymap),
editorTheme,
syntaxHighlighting(highlightStyle),
EditorView.updateListener.of((update) => {
if (update.docChanged && options.onChange) {
options.onChange(update.view.state.doc.toString());
}
}),
],
});
return new EditorView({ state, parent: options.parent });
}
+2 -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([]);
@@ -96,6 +96,7 @@ export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalCon
const item = messages.value.at(-2)!;
const { logs, signal } = await loadBetween(container, params, item.date, new Date(), {
maxStart: 100,
startId: item.id,
});
if (signal.aborted) {
+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 },
+71
View File
@@ -0,0 +1,71 @@
export interface TemplateEditorOptions {
parent: HTMLElement;
initialValue: string;
onChange?: (value: string) => void;
}
export async function createTemplateEditor(options: TemplateEditorOptions) {
const [{ EditorView }, { EditorState }, { json }, { HighlightStyle, syntaxHighlighting }, { tags }] =
await Promise.all([
import("@codemirror/view"),
import("@codemirror/state"),
import("@codemirror/lang-json"),
import("@codemirror/language"),
import("@lezer/highlight"),
]);
const editorTheme = EditorView.theme({
"&": {
backgroundColor: "var(--color-base-100)",
color: "var(--color-base-content)",
fontSize: "0.875rem",
},
".cm-content": {
caretColor: "var(--color-primary)",
fontFamily: "ui-monospace, monospace",
},
".cm-cursor": {
borderLeftColor: "var(--color-primary)",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
backgroundColor: "var(--color-base-300)",
},
".cm-activeLine": {
backgroundColor: "color-mix(in oklch, var(--color-base-200) 50%, transparent)",
},
".cm-gutters": {
backgroundColor: "var(--color-base-200)",
color: "color-mix(in oklch, var(--color-base-content) 50%, transparent)",
border: "none",
},
".cm-activeLineGutter": {
backgroundColor: "var(--color-base-300)",
},
});
const highlightStyle = HighlightStyle.define([
{ tag: tags.propertyName, color: "var(--color-info)" },
{ tag: tags.string, color: "var(--color-success)" },
{ tag: tags.number, color: "var(--color-warning)" },
{ tag: tags.bool, color: "var(--color-warning)" },
{ tag: tags.null, color: "var(--color-secondary)" },
{ tag: tags.punctuation, color: "var(--color-base-content)" },
]);
const state = EditorState.create({
doc: options.initialValue,
extensions: [
EditorView.lineWrapping,
json(),
editorTheme,
syntaxHighlighting(highlightStyle),
EditorView.updateListener.of((update) => {
if (update.docChanged && options.onChange) {
options.onChange(update.view.state.doc.toString());
}
}),
],
});
return new EditorView({ state, parent: options.parent });
}
+1 -1
View File
@@ -3,7 +3,7 @@ type Toast = {
createdAt: Date;
title?: string;
message: string;
type: "success" | "error" | "warning" | "info";
type: "error" | "warning" | "info";
action?: {
label: string;
handler: () => void;
+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();
});
}
+3 -3
View File
@@ -42,10 +42,10 @@
<button>close</button>
</form>
</dialog>
<SideDrawer ref="drawer" :width="drawerWidth">
<SideDrawer ref="drawer" :width="drawerWidth" v-slot="{ close }">
<Suspense :timeout="0">
<component :is="drawerComponent" v-bind="drawerProperties" />
<template #fallback> Loading dependencies... </template>
<component :is="drawerComponent" v-bind="drawerProperties" :close="close" />
<template #fallback> <span class="loading loading-spinner loading-sm"></span></template>
</Suspense>
</SideDrawer>
<ToastModal />
+42 -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 {
@@ -151,3 +151,44 @@ body {
.splitpanes--vertical .splitpanes__pane {
transition: none !important;
}
/* CodeMirror autocomplete tooltip styles */
.cm-tooltip {
@apply bg-base-200! border-base-content/40! min-w-96 rounded-sm border shadow-md;
}
.cm-tooltip-autocomplete ul {
@apply font-sans;
}
.cm-tooltip-autocomplete ul li {
@apply my-1 px-2;
}
.cm-tooltip-autocomplete ul li[aria-selected] {
@apply bg-primary/20 text-base-content!;
}
.cm-completionLabel {
@apply text-base-content!;
}
.cm-completionDetail {
@apply text-base-content/60! ml-2 italic;
}
.cm-completionMatchedText {
@apply text-primary! font-bold no-underline;
}
.cm-completionIcon {
@apply mr-2 opacity-70;
}
.cm-editor.cm-focused {
outline: none;
}
.cm-scroller {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}
+56 -11
View File
@@ -1,5 +1,4 @@
import type { ContainerHealth, ContainerStat, ContainerState } from "@/types/Container";
import { useExponentialMovingAverage, useSimpleRefHistory } from "@/utils";
import type { ContainerHealth, ContainerJson, ContainerStat, ContainerState } from "@/types/Container";
import { Ref } from "vue";
export type Stat = Omit<ContainerStat, "id">;
@@ -50,14 +49,14 @@ export class Container {
stats: Stat[],
public readonly group?: string,
public health?: ContainerHealth,
public isNew: boolean = false,
) {
this._stat = ref(
stats.at(-1) || ({ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 } as Stat),
);
const { history } = useSimpleRefHistory(this._stat, { capacity: 300, deep: true, initial: stats });
this._statsHistory = history;
const { movingAverage } = useExponentialMovingAverage(this._stat, 0.2);
this.movingAverageStat = movingAverage;
const defaultStat = { cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 } as Stat;
this._stat = ref(stats.at(-1) || defaultStat);
const recentStats = stats.slice(-300);
const padding = Array(300 - recentStats.length).fill(defaultStat);
this._statsHistory = ref([...padding, ...recentStats]);
this.movingAverageStat = ref(stats.at(-1) || defaultStat);
this._name = name;
}
@@ -85,6 +84,7 @@ export class Container {
get namespace() {
return (
this.labels["dev.dozzle.group"] ||
this.labels["coolify.projectName"] ||
this.labels["com.docker.stack.namespace"] ||
this.labels["com.docker.compose.project"]
);
@@ -115,11 +115,56 @@ export class Container {
}
public updateStat(stat: Stat) {
// When Container is inside a reactive array, refs get unwrapped
if (isRef(this._stat)) {
this._stat.value = stat;
} else {
// @ts-ignore
this._stat = stat;
(this._stat as unknown as Stat) = stat;
}
// Update history directly (no watcher needed)
const history = isRef(this._statsHistory) ? this._statsHistory.value : (this._statsHistory as unknown as Stat[]);
history.push(stat);
if (history.length > 300) {
history.shift();
}
// Calculate EMA directly (no watcher needed)
const alpha = 0.2;
const prev = isRef(this.movingAverageStat)
? this.movingAverageStat.value
: (this.movingAverageStat as unknown as Stat);
const newEma = {
cpu: alpha * stat.cpu + (1 - alpha) * prev.cpu,
memory: alpha * stat.memory + (1 - alpha) * prev.memory,
memoryUsage: alpha * stat.memoryUsage + (1 - alpha) * prev.memoryUsage,
networkRxTotal: stat.networkRxTotal,
networkTxTotal: stat.networkTxTotal,
};
if (isRef(this.movingAverageStat)) {
this.movingAverageStat.value = newEma;
} else {
(this.movingAverageStat as unknown as Stat) = newEma;
}
}
static fromJSON(c: ContainerJson): Container {
return new Container(
c.id,
new Date(c.created),
new Date(c.startedAt),
new Date(c.finishedAt),
c.image,
c.name,
c.command,
c.host,
c.labels,
c.state,
c.cpuLimit,
c.memoryLimit,
c.stats ?? [],
c.group,
c.health,
);
}
}
+150
View File
@@ -0,0 +1,150 @@
<template>
<PageWithLinks>
<section>
<!-- Header -->
<div class="mb-8">
<h2 class="text-2xl font-bold">{{ $t("notifications.title") }}</h2>
<p class="text-base-content/60">{{ $t("notifications.description") }}</p>
</div>
<!-- Destinations Section -->
<div class="mb-8">
<h3 class="text-base-content/60 mb-4 font-semibold tracking-wide uppercase">
{{ $t("notifications.destinations") }}
</h3>
<div class="flex flex-wrap gap-4">
<DestinationCard
v-for="dest in dispatchers"
:key="dest.id"
:destination="dest"
:on-updated="fetchAll"
:existing-dispatchers="dispatchers"
class="w-full md:w-72"
/>
<!-- Add Destination Card -->
<button
class="card card-border border-base-content/30 hover:border-base-content/50 w-full cursor-pointer border-dashed transition-colors md:w-72"
@click="openAddDestination"
>
<div class="card-body items-center justify-center gap-1 p-4">
<mdi:plus class="text-2xl" />
<span class="text-base-content/60 text-sm">{{ $t("notifications.add-destination") }}</span>
</div>
</button>
</div>
</div>
<!-- Alerts Section -->
<div>
<div class="mb-4">
<h3 class="text-base-content/60 font-semibold tracking-wide uppercase">{{ $t("notifications.alerts") }}</h3>
</div>
<!-- Filter Tabs -->
<div class="tabs tabs-box mb-6">
<button class="tab" :class="{ 'tab-active': filter === 'all' }" @click="filter = 'all'">
{{ $t("notifications.filter.all", { count: alerts.length }) }}
</button>
<button class="tab" :class="{ 'tab-active': filter === 'enabled' }" @click="filter = 'enabled'">
{{ $t("notifications.filter.enabled", { count: enabledCount }) }}
</button>
<button class="tab" :class="{ 'tab-active': filter === 'paused' }" @click="filter = 'paused'">
{{ $t("notifications.filter.paused", { count: pausedCount }) }}
</button>
</div>
<!-- Alerts List -->
<div class="space-y-4">
<AlertCard v-for="alert in filteredAlerts" :key="alert.id" :alert="alert" :on-updated="fetchAlerts" />
<button
class="card card-border border-base-content/30 hover:border-base-content/50 w-full cursor-pointer border-dashed transition-colors"
@click="openCreateAlert"
>
<div class="card-body items-center justify-center gap-1 p-4">
<mdi:plus class="text-2xl" />
<span class="text-base-content/60 text-sm">{{ $t("notifications.add-alert") }}</span>
</div>
</button>
</div>
</div>
</section>
</PageWithLinks>
</template>
<script lang="ts" setup>
import type { NotificationRule, Dispatcher } from "@/types/notifications";
import AlertForm from "@/components/Notification/AlertForm.vue";
import DestinationForm from "@/components/Notification/DestinationForm.vue";
const showDrawer = useDrawer();
const router = useRouter();
// State
const alerts = ref<NotificationRule[]>([]);
const dispatchers = ref<Dispatcher[]>([]);
async function fetchAlerts() {
const res = await fetch(withBase("/api/notifications/rules"));
alerts.value = await res.json();
}
async function fetchDispatchers() {
const res = await fetch(withBase("/api/notifications/dispatchers"));
dispatchers.value = await res.json();
}
async function fetchAll() {
await Promise.all([fetchAlerts(), fetchDispatchers()]);
}
// Handle cloudLinkSuccess hash param
onMounted(async () => {
await fetchAll();
const hash = window.location.hash;
if (hash.startsWith("#cloudLinkSuccess=")) {
const id = Number(hash.replace("#cloudLinkSuccess=", ""));
if (!isNaN(id)) {
const destination = dispatchers.value.find((d) => d.id === id);
if (destination) {
showDrawer(
DestinationForm,
{
destination,
existingDispatchers: dispatchers.value,
showLinkSuccess: true,
},
"md",
);
}
}
router.replace({ hash: "" });
}
});
// Local state
const filter = ref<"all" | "enabled" | "paused">("all");
const enabledCount = computed(() => alerts.value.filter((a) => a.enabled).length);
const pausedCount = computed(() => alerts.value.filter((a) => !a.enabled).length);
const filteredAlerts = computed(() => {
if (filter.value === "enabled") return alerts.value.filter((a) => a.enabled);
if (filter.value === "paused") return alerts.value.filter((a) => !a.enabled);
return alerts.value;
});
function openCreateAlert() {
showDrawer(AlertForm, { onCreated: fetchAlerts }, "lg");
}
function openAddDestination() {
showDrawer(
DestinationForm,
{
onCreated: fetchDispatchers,
existingDispatchers: dispatchers.value,
},
"md",
);
}
</script>
+10 -6
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 {
+2
View File
@@ -4,3 +4,5 @@ declare module "*.vue" {
const component: DefineComponent<{}, {}, any>;
export default component;
}
declare const __CLOUD_URL__: string;
+18 -11
View File
@@ -1,15 +1,19 @@
type Announcement = {
interface Release {
name: string;
announcement: boolean;
createdAt: Date;
body: string;
mentionsCount: number;
tag: string;
body: string;
createdAt: string;
htmlUrl: string;
latest: boolean;
mentionsCount: number;
features: number;
bugFixes: number;
breaking: number;
}
type Announcement = Omit<Release, "createdAt"> & {
announcement: boolean;
createdAt: Date;
};
const releases = ref<Announcement[]>([]);
@@ -20,8 +24,14 @@ async function fetchReleases() {
fetched = true;
try {
const { data } = await useFetch(withBase("/api/releases")).get().json<Announcement[]>();
releases.value = data.value || [];
const res = await fetch(withBase("/api/releases"));
const data: Release[] = await res.json();
releases.value =
data?.map((r) => ({
...r,
createdAt: new Date(r.createdAt),
announcement: false,
})) || [];
} catch (error) {
console.error("Error while fetching releases:\n", error);
fetched = false;
@@ -35,10 +45,7 @@ if (config.releaseCheckMode === "automatic") {
const otherAnnouncements = [] as Announcement[];
const announcements = computed(() => {
const newReleases =
releases.value?.map((release) => ({ ...release, createdAt: new Date(release.createdAt), announcement: false })) ??
[];
return [...newReleases, ...otherAnnouncements].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return [...releases.value, ...otherAnnouncements].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
});
const mostRecent = computed(() => announcements.value?.[0]);
+12 -22
View File
@@ -135,33 +135,23 @@ export const useContainerStore = defineStore("container", () => {
existingContainers.forEach((c) => {
const existing = allContainersById.value[c.id];
if (ready.value && existing.state !== "running" && c.state === "running") {
existing.isNew = true;
}
existing.state = c.state;
existing.health = c.health;
existing.name = c.name;
});
containers.value = [
...containers.value,
...newContainers.map((c) => {
return new Container(
c.id,
new Date(c.created),
new Date(c.startedAt),
new Date(c.finishedAt),
c.image,
c.name,
c.command,
c.host,
c.labels,
c.state,
c.cpuLimit,
c.memoryLimit,
c.stats,
c.group,
c.health,
);
}),
];
const mapped = newContainers.map((c) => {
const container = Container.fromJSON(c);
if (ready.value) {
container.isNew = true;
}
return container;
});
containers.value = [...containers.value, ...mapped];
};
const currentContainer = (id: Ref<string>) => computed(() => allContainersById.value[id.value]);
+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;
+32 -13
View File
@@ -1,26 +1,30 @@
/* eslint-disable */
/* prettier-ignore */
// oxfmt-ignore
// @ts-nocheck
// noinspection ES6UnusedImports
// Generated by unplugin-vue-router. !! DO NOT MODIFY THIS FILE !!
// Generated by vue-router. !! DO NOT MODIFY THIS FILE !!
// It's recommended to commit this file.
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
declare module 'vue-router/auto-resolver' {
export type ParamParserCustom = never
import type {
RouteRecordInfo,
ParamValue,
ParamValueOneOrMore,
ParamValueZeroOrMore,
ParamValueZeroOrOne,
} from 'vue-router'
declare module 'vue-router' {
interface TypesConfig {
ParamParsers:
| never
}
}
declare module 'vue-router/auto-routes' {
import type {
RouteRecordInfo,
ParamValue,
ParamValueOneOrMore,
ParamValueZeroOrMore,
ParamValueZeroOrOne,
} from 'vue-router'
/**
* Route name map generated by unplugin-vue-router
* Route name map generated by vue-router
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<
@@ -86,6 +90,13 @@ declare module 'vue-router/auto-routes' {
{ name: ParamValue<false> },
| never
>,
'/notifications': RouteRecordInfo<
'/notifications',
'/notifications',
Record<never, never>,
Record<never, never>,
| never
>,
'/owner/[name]': RouteRecordInfo<
'/owner/[name]',
'/owner/:name',
@@ -124,7 +135,7 @@ declare module 'vue-router/auto-routes' {
}
/**
* Route file to route info map by unplugin-vue-router.
* Route file to route info map by vue-router.
* Used by the \`sfc-typed-router\` Volar plugin to automatically type \`useRoute()\`.
*
* Each key is a file path relative to the project root with 2 properties:
@@ -188,6 +199,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'assets/pages/notifications.vue': {
routes:
| '/notifications'
views:
| never
}
'assets/pages/owner/[name].vue': {
routes:
| '/owner/[name]'
@@ -231,3 +248,5 @@ declare module 'vue-router/auto-routes' {
? Info['routes']
: keyof RouteNamedMap
}
export {}
+68
View File
@@ -0,0 +1,68 @@
export interface NotificationRule {
id: number;
name: string;
enabled: boolean;
containerExpression: string;
logExpression: string;
metricExpression?: string;
eventExpression?: string;
cooldown?: number;
sampleWindow?: number;
triggerCount: number;
triggeredContainers: number;
lastTriggeredAt: string | null;
dispatcher: Dispatcher | null;
}
export interface Dispatcher {
id: number;
name: string;
type: string;
url?: string;
template?: string;
headers?: Record<string, string>;
prefix?: string;
expiresAt?: string;
}
export interface NotificationRuleInput {
name: string;
enabled: boolean;
dispatcherId: number;
logExpression: string;
containerExpression: string;
metricExpression?: string;
eventExpression?: string;
cooldown?: number;
sampleWindow?: number;
}
export interface PreviewResult {
containerError?: string;
logError?: string;
metricError?: string;
eventError?: string;
matchedContainers: {
id: string;
name: string;
image: string;
host: string;
}[];
matchedLogs: {
id: number;
t: string;
m: unknown;
rm: string;
ts: number;
l: string;
s: string;
}[];
totalLogs: number;
messageKeys?: string[];
}
export interface TestWebhookResult {
success: boolean;
statusCode?: number;
error?: string;
}
+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.57.0-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
+8
View File
@@ -37,6 +37,7 @@ export default defineConfig({
nav: [
{ text: "Home", link: "/" },
{ text: "Guide", link: "/guide/what-is-dozzle", activeMatch: "/guide/" },
{ text: "Dozzle Cloud", link: "https://cloud.dozzle.dev" },
{
text: `v${pkg.version}`,
items: [
@@ -68,6 +69,13 @@ export default defineConfig({
{ text: "Podman", link: "/guide/podman" },
],
},
{
text: "Notifications",
items: [
{ text: "Alerts & Webhooks", link: "/guide/alerts-and-webhooks" },
{ text: "Dozzle Cloud", link: "/guide/dozzle-cloud" },
],
},
{
text: "Advanced Configuration",
items: [
+2 -9
View File
@@ -3,15 +3,8 @@
class="banner fixed top-0 right-0 left-0 z-(--vp-z-index-layout-top) flex items-center overflow-hidden bg-[oklch(74%_0.16_232.661)] p-4 font-bold text-gray-800 dark:bg-[oklch(60%_0.126_221.723)] dark:text-white"
>
<div class="mx-auto flex items-center gap-2 lg:gap-4">
<span class="animate-bounce">🚀</span>K8s users, I need your help! I have added K8s support to Dozzle and I need
your feedback! 🙏
<a
href="https://github.com/amir20/dozzle/discussions/3614"
target="_blank"
class="btn btn-sm btn-primary text-white!"
>
See the discussion
</a>
<span class="animate-bounce">🎉</span>Dozzle v10 is here! Alerts, webhooks, and Dozzle Cloud are now available.
<a href="/guide/alerts-and-webhooks" class="btn btn-sm btn-primary text-white!"> Learn more </a>
</div>
<button class="btn btn-circle btn-ghost btn-xs ml-auto" @click="dismiss">
<svg class="swap-on fill-current" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512">
+1 -1
View File
@@ -17,7 +17,7 @@ export default {
"home-hero-image": () => h(HeroVideo),
"sidebar-nav-after": () => h(BuyMeCoffee),
"home-hero-actions-after": () => h(Stats),
// "layout-top": () => h(Banner),
"layout-top": () => h(Banner),
"home-hero-after": () => h(Supported),
});
},
+5 -2
View File
@@ -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`.
+159
View File
@@ -0,0 +1,159 @@
---
title: Alerts & Webhooks
---
# Alerts & Webhooks
<Badge type="tip" text="New in v10" />
Dozzle v10 introduces a powerful alerting system that lets you monitor container logs and receive notifications when specific conditions are met. Alerts use customizable expressions to filter containers and log messages, and can send notifications to webhooks, Slack, Discord, ntfy, or [Dozzle Cloud](/guide/dozzle-cloud).
## How It Works
Alerts are configured with two expressions:
1. **Container filter** — selects which containers to monitor
2. **Log filter** — defines which log messages trigger the alert
When a log entry matches both filters, Dozzle sends a notification to the configured destination.
> [!IMPORTANT]
> Alert and destination configurations are stored in the `/data` directory. You must mount this directory as a volume to persist your notification settings across container restarts.
::: code-group
```sh
docker run -v /var/run/docker.sock:/var/run/docker.sock -v /path/to/data:/data -p 8080:8080 amir20/dozzle:latest
```
```yaml [docker-compose.yml]
services:
dozzle:
image: amir20/dozzle:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /path/to/data:/data
ports:
- 8080:8080
```
:::
## Setting Up a Destination
Before creating alerts, you need to configure at least one notification destination. Navigate to the **Notifications** page in Dozzle and click **Add Destination**.
### Webhook
Webhooks send an HTTP POST request to a URL of your choice. Dozzle includes built-in payload templates for popular services:
- **Slack** — formatted with blocks and markdown
- **Discord** — formatted for Discord webhook API
- **ntfy** — formatted for [ntfy.sh](https://ntfy.sh) push notifications
- **Custom** — generic JSON payload you can customize
You can also write your own payload template using Go's `text/template` syntax. The following variables are available:
<div v-pre>
| Variable | Description |
| ------------------------- | -------------------------------------- |
| `{{.Detail}}` | Summary (log message or metric values) |
| `{{.Container.Name}}` | Container name |
| `{{.Container.Image}}` | Container image |
| `{{.Container.HostName}}` | Docker host name |
| `{{.Container.State}}` | Container state |
| `{{.Log.Message}}` | Log message content |
| `{{.Log.Level}}` | Log level |
| `{{.Log.Timestamp}}` | Log timestamp |
| `{{.Log.Stream}}` | Stream type (stdout/stderr) |
| `{{.Stat.CPUPercent}}` | CPU usage percentage |
| `{{.Stat.MemoryPercent}}` | Memory usage percentage |
| `{{.Stat.MemoryUsage}}` | Memory usage in bytes |
| `{{.Subscription.Name}}` | Alert rule name |
</div>
> [!TIP]
> Use the **Test** button to verify your webhook is working before saving.
### Dozzle Cloud
You can also send alerts to [Dozzle Cloud](/guide/dozzle-cloud) for centralized monitoring across multiple Dozzle instances. See the [Dozzle Cloud guide](/guide/dozzle-cloud) for more details.
## Creating an Alert
Navigate to the **Notifications** page and click **Add Alert**. You'll need to configure:
### Container Expression
The container expression selects which containers to monitor. Available properties:
| Property | Type | Example |
| ---------- | ------ | ------------------------------- |
| `name` | string | `name contains "api"` |
| `image` | string | `image == "nginx:latest"` |
| `state` | string | `state == "running"` |
| `health` | string | `health == "unhealthy"` |
| `hostName` | string | `hostName == "prod-host"` |
| `labels` | map | `labels["env"] == "production"` |
You can combine conditions with `&&` (AND), `||` (OR), and `!` (NOT):
```
name contains "api" && labels["env"] == "production"
```
### Log Expression
The log expression filters which log messages trigger the alert. Available properties:
| Property | Type | Example |
| --------- | ---------- | -------------------------- |
| `message` | string/map | `message contains "error"` |
| `level` | string | `level == "error"` |
| `stream` | string | `stream == "stderr"` |
| `type` | string | `type == "complex"` |
For JSON logs, you can access nested fields using dot notation:
```
message.status >= 500 && message.path contains "/api"
```
Supported string operators include `contains`, `startsWith`, `endsWith`, and `matches` (regex).
### Examples
**Alert on all errors from production containers:**
```
Container: labels["env"] == "production"
Log: level == "error"
```
**Alert on HTTP 5xx errors from API containers:**
```
Container: name contains "api"
Log: message.status >= 500
```
**Alert on any stderr output from a specific image:**
```
Container: image startsWith "myapp/"
Log: stream == "stderr"
```
> [!NOTE]
> The alert editor includes autocomplete and real-time validation. You can preview matched containers and logs before saving.
## Managing Alerts
From the Notifications page, you can:
- **Enable/disable** alerts without deleting them
- **Edit** alert expressions and destinations
- **View statistics** including trigger count, matched containers, and last triggered time
- **Delete** alerts that are no longer needed
+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:
+9
View File
@@ -27,3 +27,12 @@ services:
```
:::
## Coolify Integration
If you're using [Coolify](https://coolify.io/), Dozzle automatically recognizes Coolify's labels as fallbacks:
- `coolify.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.
+64
View File
@@ -0,0 +1,64 @@
---
title: Dozzle Cloud
---
# Dozzle Cloud
<Badge type="tip" text="New in v10" />
[Dozzle Cloud](https://cloud.dozzle.dev) is a companion service that extends your self-hosted Dozzle instances with centralized monitoring, smart alerting, and log intelligence. While Dozzle remains fully open source and self-hosted, Dozzle Cloud adds a managed layer on top for teams that need more visibility across their infrastructure.
## Why Dozzle Cloud?
Container logs are noisy. Dozzle Cloud helps you cut through the noise with intelligent summarization and multi-instance aggregation — without requiring additional agents or complex setup.
## Key Features
### Log Summaries
Dozzle Cloud automatically batches related container events into concise summaries. Each summary includes severity levels, source container information, and direct links to the full logs in your Dozzle instance.
### Pattern Clustering
Instead of showing duplicate errors, Dozzle Cloud groups similar errors together and displays frequency counts. This makes it easy to identify recurring issues across multiple containers.
### Smart Alert Distribution
Receive notifications through multiple channels:
- **Email**
- **Slack**
- **ntfy**
- **Webhooks**
- **Browser push notifications**
You can enable or disable channels independently with unlimited configurations.
### Multi-Instance Dashboard
Monitor all your Dozzle servers from a single dashboard. Connecting is simple — just link your instance with an API key. No additional agents are required.
### Searchable Event History
Search across all logged events with full-text search. Filter by container, severity (error, warning, info), and keywords. Retention is configurable from 24 hours to 30 days.
### Security
- API keys are hashed with expiration support
- GitHub OAuth authentication
- Your logs remain your own — Dozzle Cloud is committed to data privacy
## Connecting to Dozzle Cloud
To link your Dozzle instance to Dozzle Cloud:
1. Navigate to the **Notifications** page in Dozzle
2. Click **Add Destination** and select **Dozzle Cloud**
3. Click **Link Dozzle Cloud** — you'll be redirected to authenticate
4. Once linked, your API key is automatically configured
You can then select Dozzle Cloud as a destination when creating [alerts](/guide/alerts-and-webhooks).
## Pricing
Dozzle Cloud offers a free tier with 500 events per month — no credit card required. Visit [cloud.dozzle.dev](https://cloud.dozzle.dev) for more details.
+14
View File
@@ -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:
+7 -1
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:
+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.

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