Compare commits

...

411 Commits

Author SHA1 Message Date
renovate[bot] 9f2eb5ec47 fix(deps): update all non-major dependencies (#4894)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-07 01:19:09 +00:00
Amir Raminfar 8358b98111 chore: use import.meta.dirname in vite configs (#4891)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 23:20:53 +00:00
renovate[bot] af3aefe830 chore(deps): update dependency @playwright/test to ^1.62.0 (#4866)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Amir Raminfar <findamir@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:37:33 +00:00
renovate[bot] ebc54a4481 fix(deps): update all non-major dependencies (#4888)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-04 20:55:53 +00:00
Amir Raminfar 8cf7ccd5ee fix(security): block IPv6 transition addresses in webhook SSRF guard (#4887) 2026-08-04 12:33:51 -07:00
TowyTowy a9395e9f73 fix: derive jwt signing key from users in a stable order (#4886)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-04 11:46:28 -07:00
renovate[bot] b9df31356f fix(deps): update all non-major dependencies (#4885)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-03 03:07:44 +00:00
renovate[bot] d2abd0e279 chore(deps): update dependency typescript to v6.0.3-bridge.8.tsgo.7.0.2 (#4884)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-02 14:07:31 +00:00
renovate[bot] 8a6ce3b21d fix(deps): update all non-major dependencies (#4883)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-01 15:10:14 +00:00
renovate[bot] 239850c52d fix(deps): update dependency daisyui to v5.7.9 (#4882)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-31 22:50:23 +00:00
renovate[bot] 6b84105e79 fix(deps): update all non-major dependencies (#4881)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-31 20:05:46 +00:00
Amir Raminfar c159db5f0b chore: release v10.6.14 2026-07-30 10:36:35 -07:00
Amir Raminfar a18edf03ec chore: release v10.6.13 2026-07-30 10:36:13 -07:00
Amir Raminfar 18acf6722b test(e2e): assert static assets resolve under a custom base path (#4880)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 07:20:19 -07:00
Loi Nguyen 5e0a4c43e6 fix: respect custom base paths for built assets (#4879) 2026-07-30 07:10:53 -07:00
Amir Raminfar 48f04a0a7d docs: refresh homepage with icons, comparison, and closing CTA (#4877)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:25:43 -07:00
Kobi Hikri ae1753bcee ci: attach an SBOM attestation to the published image (#4872) 2026-07-29 08:24:40 -07:00
Kobi Hikri 3ae53dd719 ci: attach an SBOM attestation to the published image (#4872) 2026-07-29 08:24:26 -07:00
renovate[bot] 3e0ed70362 chore(deps): update dependency jsdom to v30 (#4875)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-29 08:08:23 -07:00
renovate[bot] c6c64fdd86 fix(deps): update all non-major dependencies (#4874)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-29 15:05:56 +00:00
Amir Raminfar 616dd91483 docs: correct docker.sock read-only mount security claim (#4873) 2026-07-29 03:33:25 +00:00
Amir Raminfar f952961eea docs: rework homepage (#4869)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:34:49 -07:00
Amir Raminfar 3ca08f781b feat(analytics): maximize drawer and export SQL results (#4871)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:33:19 -07:00
Amir Raminfar 03011eea97 fix: revert sidebar collapse animation from #4862 (#4867)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 10:00:57 -07:00
Amir Raminfar 3783125b6a test(e2e): fix dead specs and cover log streaming + auth negatives (#4868)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 16:45:31 +00:00
Amir Raminfar fe6549e5f5 test: add critical int tests for mobile container stats
Covers the dashboard's CPU/mem stat badges and the invariant behind
the "mobile status bar" fix from #4862: the sticky stats header must
sit flush under the fixed mobile nav, with no gap or overlap. Adds a
data-testid to ScrollableView's header so the test doesn't rely on
CSS selectors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 19:07:39 -07:00
renovate[bot] cedb384dc4 fix(deps): update all non-major dependencies (#4865)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-26 16:55:29 +00:00
Amir Raminfar 163452a398 chore: release v10.6.12 2026-07-26 09:47:54 -07:00
Amir Raminfar f348b57721 feat: Command palette in Cmd+K search (#4861)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:45:26 -07:00
Amir Raminfar df5a60479c fix(security): scope MCP read tools to the requesting user's filter (GHSA-p66q-2gfp-8v55) (#4864)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:43:21 -07:00
Amir Raminfar 9c665d42a9 fix(security): require auth for /cloud/callback (GHSA-p66q-2gfp-8v55) (#4863)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:42:29 -07:00
Baiyi Liao 49f625a1db feat(ui): small improvements and polishments (#4862) 2026-07-24 07:11:13 -07:00
Amir Raminfar af38e3a0c0 chore: type-check on tsgo via typescript-native-bridge (#4860)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:47:13 -07:00
renovate[bot] 104749063d chore(deps): update all non-major dependencies (#4859)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-23 16:33:07 +00:00
renovate[bot] d7b896e3d8 chore(deps): update dependency bumpp to v12 (#4855)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-22 14:00:00 -07:00
renovate[bot] 5693bcc4e4 chore(deps): update all non-major dependencies (#4854)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-22 16:37:27 +00:00
renovate[bot] de70cbe389 fix(deps): update all non-major dependencies (#4850)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Amir Raminfar <findamir@gmail.com>
2026-07-22 00:14:19 +00:00
Amir Raminfar 73099aa67a fix: prompt for generate password on stdin when --password omitted (#4852)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:09:20 -07:00
renovate[bot] 9266f89776 chore(deps): update dependency c8 to v12 (#4851)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-20 10:29:31 -07:00
Amir Raminfar f88d7a1066 chore: release v10.6.11 2026-07-19 18:40:23 -07:00
TowyTowy f3e61606bd fix: deliver last log line when it has no trailing newline (#4846)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-19 18:38:46 -07:00
Amir Raminfar 95794779d6 feat: Add theme-aware ANSI color palette for log viewer (#4849)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-19 18:38:36 -07:00
TowyTowy e3f4566f6f fix: unescape quoted logfmt values (#4844)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-16 17:20:56 -07:00
Amir Raminfar b86831d2ba chore: updates pinia (#4843) 2026-07-16 08:24:38 -07:00
renovate[bot] 4917984385 chore(deps): update actions/setup-go action to v7 (#4842)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 15:24:03 +00:00
renovate[bot] c3b1c76151 fix(deps): update all non-major dependencies (#4841)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 15:23:21 +00:00
renovate[bot] 7897a1555e chore(deps): update dependency @pinia/testing to v2 (#4838)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 08:17:28 -07:00
TowyTowy 523ac9f35e fix(utils): prevent panic when pushing to a zero-size ring buffer (#4840)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-15 13:04:37 -07:00
Amir Raminfar 6e65b33161 chore: release v10.6.10 2026-07-15 08:03:04 -07:00
renovate[bot] be2a9e0047 chore(deps): update actions/setup-node action to v7 (#4837)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-15 15:00:25 +00:00
renovate[bot] fad2ea7763 fix(deps): update all non-major dependencies (#4836)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-15 14:59:55 +00:00
Amir Raminfar 4e7b6eea7b refactor(mcp): dedupe log fetch/collect between log tools (#4835)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:56:42 +00:00
morcth a0704d5c7b feat: add search_container_logs MCP tool (#4832) 2026-07-15 07:41:23 -07:00
TowyTowy 63fa7c9e04 fix(level-guesser): recognize "Information" as info level (#4833) 2026-07-15 06:01:05 -07:00
Amir Raminfar 237e916390 ci: install pnpm via corepack, update pnpm to 11.12.0 (#4830)
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.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:31:02 -07:00
renovate[bot] 6a16e35eb1 fix(deps): update all non-major dependencies (#4828)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Amir Raminfar <findamir@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:21:17 +00:00
Amir Raminfar 15b6abf843 feat(web): "see in context" for level-filtered logs (#4825)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-07-13 02:18:30 +00:00
TowyTowy 540ace4e84 fix(container): keep custom name label on docker rename (#4827) 2026-07-12 14:10:21 -07:00
Amir Raminfar c0a38587e2 docs: complete missing translation keys across all locales (#4826)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:16:16 +00:00
Amir Raminfar 2b1d9e889e chore: release v10.6.9
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-07-10 11:18:45 -07:00
renovate[bot] 5cd63429a9 fix(deps): update all non-major dependencies (#4820)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Amir Raminfar <findamir@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 18:12:24 +00:00
TowyTowy 820831b03d fix(web): strip highlight markers from copied and downloaded logs (#4823)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:05:09 -07:00
moduvoice 8cde0e7e48 docs: complete missing Korean (ko) translation keys (#4818)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-07-09 17:02:41 -07:00
Amir Raminfar bd4651fb0b chore: apply go fix modernizations (#4822)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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.8 <noreply@anthropic.com>
2026-07-09 10:51:26 -07:00
renovate[bot] 8d810b7a0b chore(deps): update mcr.microsoft.com/playwright docker tag to v1.61.1 (#4819)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-07-08 23:50:47 +00:00
Amir Raminfar c193e601f3 chore: release v10.6.8
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-07-08 08:22:36 -07:00
Amir Raminfar 78ff45fdaf docs: explain elevated idle CPU from stats streaming
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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.8 <noreply@anthropic.com>
2026-07-07 17:47:28 -07:00
Amir Raminfar ac42ce868a fix(k8s): trim owner labels, bound owner cache, refresh discovery (#4812)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:56:02 -07:00
renovate[bot] 84ac9e51ac fix(deps): update all non-major dependencies (#4816)
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-07-06 01:51:18 +00:00
Hiram Chirino 3062410b42 feat(k8s): group containers by owner chain (#4811)
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
Signed-off-by: Hiram Chirino <hiram@hiramchirino.com>
2026-07-01 09:07:51 -07:00
Amir Raminfar beddc79892 chore: release v10.6.7
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-06-30 08:58:18 -07:00
renovate[bot] f7557ccbb5 fix(deps): update all non-major dependencies (#4810)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-06-29 23:45:02 +00:00
renovate[bot] 6391853f3a fix(deps): update all non-major dependencies (#4809)
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-06-29 03:52:33 +00:00
renovate[bot] 619a844feb fix(deps): update all non-major dependencies (#4808)
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-06-25 17:13:54 +00:00
renovate[bot] e989caed33 chore(deps): update dependency @playwright/test to ^1.61.1 (#4807)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-25 17:12:39 +00:00
jordanvalnet ad2b124a9e fix(coolify): include PR id in preview container display names (#4806) 2026-06-25 10:07:41 -07:00
Amir Raminfar 19c01e0fb4 fix(web): apply per-user label filter to events stream stats/lifecycle channels (#4803)
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.8 <noreply@anthropic.com>
2026-06-22 10:20:06 -07:00
renovate[bot] b38117bfae fix(deps): update all non-major dependencies (#4801)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-06-22 01:57:26 +00:00
renovate[bot] 134f812336 chore(deps): update all non-major dependencies to v24.17.0 (#4799)
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-06-19 16:15:16 +00:00
renovate[bot] 55ab21b1bf chore(deps): update actions/checkout action to v7 (#4795)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 16:07:37 +00:00
Amir Raminfar 853f46deb4 ci(renovate): group playwright npm and docker image together (#4794)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:06:28 +00:00
renovate[bot] ff0f855cf6 chore(deps): update all non-major dependencies (#4793)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Amir Raminfar <findamir@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:03:12 +00:00
Amir Raminfar 38b8511042 chore: release v10.6.6
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-06-16 06:33:25 -07:00
Amir Raminfar 3c68345938 fix(notifications): match health_status event alerts (#4790) 2026-06-16 05:39:34 -07:00
Patrick Richardson acaec204b6 fix(ui): highlighted URLs staying clickable (#4791)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-06-15 17:26:54 -07:00
renovate[bot] 9b9c04498c fix(deps): update all non-major dependencies (#4789)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-06-15 01:12:45 +00:00
renovate[bot] f13e169d10 fix(deps): update all non-major dependencies (#4786)
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>
Co-authored-by: Amir Raminfar <findamir@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:59:24 +00:00
renovate[bot] 2b23684dbd fix(deps): update all non-major dependencies (#4784)
Deploy VitePress site to Pages / build (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Push container / Push branches and PRs (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-06-08 02:20:35 +00:00
renovate[bot] 69b7ede7c4 fix(deps): update all non-major dependencies (#4781)
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-06-04 17:01:29 +00:00
Amir Raminfar eb5c8fac1b fix(ui): null in JSON array crashes ComplexLogItem (#4780)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:37:59 +00:00
Amir Raminfar de368a1d97 refactor(ui): tighten TypeScript inference, drop redundant casts (#4779)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:25:31 +00:00
Amir Raminfar e283a3e361 chore: release v10.6.5 2026-06-04 06:53:06 -07:00
Richard Hull df11cc4740 fix: add font-src to CSP headers (#4778)
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
Adds `font-src 'self' data:;` to the CSP so fonts loaded from data: URIs are no longer blocked.

Closes #4777
2026-06-03 16:16:25 -07:00
Amir Raminfar 791310a8b8 chore: release v10.6.4
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-06-02 09:37:59 -07:00
Amir Raminfar d9c66f3786 chore: removes plan 2026-06-02 09:37:00 -07:00
Amir Raminfar 2abcf3480e feat: search progress and completion indicator (#4769) (#4775)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:36:28 -07:00
Amir Raminfar fd0a4850f0 fix: live log view stalls on busy containers with rotated logs (#4776)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-06-02 08:05:37 -07:00
Amir Raminfar 87bc48338d fix: rank log level guesses by confidence (#4772)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:56:00 -07:00
Amir Raminfar 8811dc82bd fix(cloud): resolve read-only container tools in one shot (no extra LLM round-trip) (#4767)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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.8 (1M context) <noreply@anthropic.com>
2026-06-01 07:59:19 -07:00
Amir Raminfar 51e13d5f97 fix: expand grouped log lines when copying to clipboard (#4771)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 07:30:34 -07:00
Amir Raminfar b37de0a16d chore: updates d files
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-31 16:35:06 -07:00
renovate[bot] 743981b6e0 fix(deps): update all non-major dependencies (#4766)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-31 22:30:45 +00:00
renovate[bot] c6a261c35d chore(deps): update pnpm to v11.5.0 (#4764)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-31 00:06:00 +00:00
Amir Raminfar 55a10273da chore: release v10.6.3 2026-05-30 17:00:55 -07:00
renovate[bot] 38636d507f fix(deps): update dependency vue-router to ^5.1.0 (#4763)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-30 12:58:17 +00:00
Amir Raminfar 9ceb850d00 fix: strip control bytes when copying logs to clipboard (#4762)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 05:51:15 -07:00
Amir Raminfar c1d09ddcf1 fix(ci): grant write permissions for claude workflows
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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
claude-code-review.yml only had pull-requests: read, but the prompt
runs `gh pr comment`, which needs pull-requests: write (was failing to
post). Also enable use_sticky_comment so repeated pushes update one
comment instead of spamming.

claude.yml was read-only, so a tagged @claude couldn't respond, commit,
or open a PR. Bump contents/pull-requests/issues to write per the
recommended template.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 07:11:13 -07:00
renovate[bot] 62b5ef80ec chore(deps): update dependency concurrently to v10 (#4759)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-29 06:45:57 -07:00
renovate[bot] e54e14ba27 fix(deps): update all non-major dependencies to ^1.2.75 (#4758)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-29 13:43:14 +00:00
Amir Raminfar 424abad60e fix(notifications): prevent alert card header overflow on mobile (#4756) (#4757)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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.8 (1M context) <noreply@anthropic.com>
2026-05-28 17:58:36 -07:00
Amir Raminfar 6cdf81e4bb fix: normalize CPU by core count in metric alerts (#4754)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:55:29 -07:00
renovate[bot] 7bbddd90cb fix(deps): update all non-major dependencies (#4755)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-28 14:19:22 +00:00
Amir Raminfar c823617ff4 fix(cloud): default exit alert ignores graceful shutdowns (143/137) (#4752)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:10:58 +00:00
renovate[bot] 90ce6210ae fix(deps): update all non-major dependencies (#4751)
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-05-27 05:05:32 +00:00
renovate[bot] 21d6cd9922 chore(deps): update all non-major dependencies (#4750)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-27 00:25:52 +00:00
Amir Raminfar d80128684c chore: release v10.6.2
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-26 09:55:23 -07:00
Amir Raminfar 7b453854ac chore: clean up icons 2026-05-26 09:55:07 -07:00
Amir Raminfar a51c7c1d86 fix: Improve SQL analytics panel UX (#4749)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 09:54:23 -07:00
Amir Raminfar 9a99e9305f fix: prevent DuckDB MAP inference from breaking log analytics (#4746) 2026-05-26 07:46:03 -07:00
Amir Raminfar 5bef729c52 fix: bump cloud search timeout to 3s and map gRPC deadline to 504 (#4748)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:17:49 +00:00
Amir Raminfar be352eec66 fix: back off cloud notification dispatcher on invalid API key (#4747)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:03:05 +00:00
Amir Raminfar 6a014d1e76 feat(cloud): accept container name or id in container-scoped tools (#4743)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:15:20 -07:00
Amir Raminfar a0744e836b fix: show CPU/Mem as compact pills in container table on mobile (#4744)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:15:43 +00:00
Amir Raminfar dd2eba59b2 fix: set Secure flag on jwt cookie when request is HTTPS (#4740)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:52:44 +00:00
Amir Raminfar b7d9d1346a test: add unit tests for core UI logic (utils, Container, LogEntry, search) (#4739)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-24 11:52:35 -07:00
Amir Raminfar ab6c93a679 fix: stats charts stale for a few seconds after switching containers (#4738)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 09:59:27 -07:00
Amir Raminfar 1db40bc4cd chore: release v10.6.1 2026-05-24 07:38:54 -07:00
Amir Raminfar d237fa9524 chore: updates modules 2026-05-24 07:36:59 -07:00
Amir Raminfar 630abbe8a6 feat(cloud): per-container log filtering via dev.dozzle.cloud.min_level label (#4729)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:15:13 -07:00
renovate[bot] b1d8971a74 fix(deps): update all non-major dependencies (#4736)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-24 03:42:06 +00:00
Amir Raminfar 345102f498 test: apply Go modernizations across test files (#4735)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:07:21 +00:00
Amir Raminfar 58f76ee19d chore: refresh logo svg and tighten sidebar lockup (#4734)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-23 18:04:42 +00:00
Amir Raminfar 12ad969d0e fix mangled quick-reference table in update-int-snapshots skill 2026-05-23 11:00:36 -07:00
Amir Raminfar fe6444f2d7 fix update-int-snapshots skill to use compose up 2026-05-23 11:00:16 -07:00
Amir Raminfar 65b515e533 add update-int-snapshots claude skill 2026-05-23 10:50:38 -07:00
Amir Raminfar 4bf94b0486 fix(level-guesser): recognize Zigbee2MQTT-style log levels (#4733)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:24:18 -07:00
renovate[bot] 450d5dc9de fix(deps): update all non-major dependencies (#4732)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 15:05:48 +00:00
Amir Raminfar c165120666 feat: distinguish paused container state in UI (#4731)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 07:59:36 -07:00
dependabot[bot] 6339f9750a chore(deps): bump js-cookie from 3.0.5 to 3.0.7 (#4728)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 12:49:26 -07:00
Amir Raminfar cd27fe90e4 docs: note pr-XXX docker tag convention for testing unreleased PRs
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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
CI builds an image per PR. When replying to issues with an open fix,
point reporters at amir20/dozzle:pr-XXX so they can verify without
waiting for the next release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 06:04:41 -07:00
renovate[bot] c5907129f2 chore(deps): update pnpm to v11.2.1 (#4721)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 12:51:20 +00:00
Amir Raminfar fb4c5c7410 chore: set minimumReleaseAge to 24 hours for pnpm and renovate
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 05:45:15 -07:00
Amir Raminfar d3861b8e04 chore: disable pnpm minimumReleaseAge check (#4726)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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.7 (1M context) <noreply@anthropic.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 13:28:51 +00:00
Amir Raminfar 1f4f21b98e docs: drop v10 promo banner, add icons to podman page
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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
The v10 launch banner has run its course. Remove it.

Also bring podman.md in line with the rest of the guide pages (agent, k8s, swarm-mode) by adding inline mdi icons to the section headings.
2026-05-20 10:14:55 -07:00
Amir Raminfar 99d1ae93e9 docs: fix vitepress build break in podman.md
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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
VitePress's NOTE container doesn't escape inline-code angle brackets before handing the block to Vue's template compiler, so <container> was being parsed as an unclosed HTML element and broke the docs build.
2026-05-20 08:44:19 -07:00
Amir Raminfar 47233170e1 docs: fix incorrect podman.md examples (#4723) 2026-05-20 08:38:19 -07:00
C.Lee Taylor 7b5ec0c79a docs: update podman.md guide with quadlet info (#4722) 2026-05-20 08:13:37 -07:00
Amir Raminfar e197963251 chore: release v10.6.0 2026-05-20 06:18:33 -07:00
Amir Raminfar 262bc93310 chore: bump deps (#4720)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:12:56 +00:00
Amir Raminfar feece87c09 chore: adds icons 2026-05-20 06:05:42 -07:00
Amir Raminfar f364ce9398 fix: keep search toolbar button working when ⌘F shortcut is disabled (#4719)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 06:04:57 -07:00
Amir Raminfar 727b33c8cc feat: detect podman vs docker and show runtime icon (#4717)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 05:47:08 -07:00
Amir Raminfar 118a7deeab chore: cleanups across mounts + multi-container stats (#4716)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:13:28 +00:00
Amir Raminfar 0201f813ba feat: disk-fill default rule + mountUsedPercent metric expression (#4713)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:46:54 +00:00
renovate[bot] 1c7e804138 fix(deps): update module github.com/go-git/go-git/v5 to v5.19.1 [security] (#4715)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 15:43:07 +00:00
Amir Raminfar b840174542 feat: warn when a container volume is running out of space (#4711)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:30:06 +00:00
Amir Raminfar 3895d87337 feat: disk I/O stats and volume free-space tracking (#4708)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:18:44 +00:00
Amir Raminfar e51a5d6392 feat(welcome): reframe cloud setup as triage with selectable default signals (#4710)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-18 23:31:59 +00:00
Amir Raminfar 425c4f0cb5 feat: allow oom and kill events through notification listener (#4709) 2026-05-18 15:55:19 -07:00
Amir Raminfar 29086778ce docs: add GitHub tone guidance to CLAUDE.md
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:01:37 -07:00
renovate[bot] 9ef395d1a4 fix(deps): update all non-major dependencies (#4705)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-18 01:59:36 +00:00
Amir Raminfar 525b848d90 chore: updates d files
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-17 11:45:09 -07:00
renovate[bot] 809ce73463 fix(deps): update all non-major dependencies (#4703)
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-05-16 14:10:05 +00:00
Amir Raminfar 37a135804b docs: remove stale GraphQL references and fix paths
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:03:38 -07:00
Amir Raminfar 2a69d5fb80 docs: make README Building section friendlier for newcomers
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:10:30 -07:00
renovate[bot] 7699171345 fix(deps): update all non-major dependencies (#4699)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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>
Co-authored-by: Amir Raminfar <findamir@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:51:52 +00:00
dever23b 8d0682d0f5 fix: update key binds (#4698)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-13 11:49:25 -07:00
renovate[bot] 04e546e8b5 chore(deps): update pnpm to v11.1.1 (#4697)
Push container / Push branches and PRs (push) Failing after 3m10s
Deploy VitePress site to Pages / build (push) Failing after 6m41s
Deploy VitePress site to Pages / Deploy (push) Has been skipped
Test / Typecheck (push) Successful in 9m41s
Test / JavaScript Tests (push) Successful in 9m41s
Test / Go Tests (push) Failing after 50s
Test / Go Staticcheck (push) Successful in 12m33s
Test / Integration Tests (push) Failing after 10m6s
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 18:04:33 +00:00
Amir Raminfar fa5d3e4a41 fix: clearer error when Docker daemon API < 1.40 (#4696)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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.7 (1M context) <noreply@anthropic.com>
2026-05-12 06:09:49 -07:00
renovate[bot] 622e54c4c6 chore(deps): update all non-major dependencies (#4692)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-11 21:59:56 +00:00
Amir Raminfar 694582fe64 chore: release v10.5.3
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-11 08:38:02 -07:00
Aaron Powell 0942b220d0 feat: integrate MCP server via Streamable HTTP on existing web server (#4684) 2026-05-11 15:26:33 +00:00
renovate[bot] e8bc72f27e fix(deps): update dependency vite to v8.0.12 (#4691)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 14:36:09 +00:00
Amir Raminfar f17b758fdf feat: add inverse/exclude filter to log search (#4690)
Co-authored-by: lingfish <jason@lucid.net.au>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:31:03 +00:00
renovate[bot] 7ee71ccafa chore(deps): update dependency lint-staged to ^17.0.4 (#4688)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-11 01:00:23 +00:00
Amir Raminfar d1b3507709 fix: debounce SSE connection error toast (#4687)
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.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:50:46 +00:00
renovate[bot] 1e215db588 fix(deps): update all non-major dependencies (#4644)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-09 14:44:27 +00:00
Amir Raminfar 5c81fdbb84 chore(deps): update all non-major dependencies (#4686)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:39:03 +00:00
dependabot[bot] 0431118366 chore(deps): bump github.com/gohugoio/hugo from 0.149.1 to 0.161.0 (#4681)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Amir Raminfar <findamir@gmail.com>
2026-05-08 10:11:53 -07:00
Amir Raminfar 5bc90019f8 chore: cleans up modules
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-07 09:36:19 -07:00
Amir Raminfar a5d192df6d chore: updates all go modules 2026-05-07 09:13:09 -07:00
Amir Raminfar 5a24db9f12 chore: updates all other modules 2026-05-07 09:10:15 -07:00
Amir Raminfar bec7d1ed2b chore(deps): update pnpm to v11 and lint-staged to v17 (#4683)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 09:02:52 -07:00
Amir Raminfar 5dccbf5a6e Revert "chore(deps): update pnpm to v11 and lint-staged to v17"
This reverts commit 3e95d2c71b.
2026-05-07 08:34:56 -07:00
Amir Raminfar 3e95d2c71b chore(deps): update pnpm to v11 and lint-staged to v17
- Bump packageManager to pnpm 11.0.8
- Bump lint-staged to ^17.0.2
- Move build-script allowlist to pnpm-workspace.yaml (pnpm v11 format)
- Fix Dockerfile to work with pnpm 11: set CI=true, use --ignore-workspace
  on offline install so workspace package manifests are not required

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:32:01 -07:00
Amir Raminfar bfcb14fe91 Revert "chore(deps): update all dependencies to latest"
This reverts commit cb3cd8e81a.
2026-05-07 08:29:40 -07:00
Amir Raminfar cb3cd8e81a chore(deps): update all dependencies to latest
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:27:14 -07:00
Amir Raminfar 51e74db127 Revert "fix(docker): set CI=true to allow pnpm install in non-TTY build"
This reverts commit 6a2cb3eaaa.
2026-05-07 08:26:47 -07:00
Amir Raminfar cf110f2325 Revert "fix(docker): use confirm-modules-purge flag instead of CI=true"
This reverts commit 73dc6ed039.
2026-05-07 08:26:47 -07:00
Amir Raminfar 73dc6ed039 fix(docker): use confirm-modules-purge flag instead of CI=true
Setting CI=true caused pnpm to switch to a stricter resolver mode
that surfaced ERR_PNPM_NO_OFFLINE_TARBALL on the offline install.
Use the targeted --config.confirm-modules-purge=false flag instead
to disable the TTY prompt without altering other install behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:18:35 -07:00
Amir Raminfar 6a2cb3eaaa fix(docker): set CI=true to allow pnpm install in non-TTY build
pnpm aborts with ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY when
purging node_modules without a TTY, breaking integration test image
builds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 07:15:43 -07:00
Dominik Gašparić 5e780eb767 refactor: Switch from docker go modules to moby (#4676)
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: codedoga <codedoga@users.noreply.github.com>
2026-05-06 05:27:02 -07:00
Amir Raminfar f69a4674a5 chore(cloud): stop exposing compose deploy tools (#4675)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:26:00 -07:00
Amir Raminfar c034a675c9 Merge branch 'destination_duplicate' 2026-05-06 05:23:20 -07:00
Amir Raminfar 75d8bfb3e5 i18n: add duplicate destination translation to all locales
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 05:23:16 -07:00
dshiri abb510f3c9 feat(notifications): add duplicate destination action 2026-05-06 13:48:20 +02:00
Amir Raminfar eca7008e1f chore: release v10.5.2
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-05 16:15:09 -07:00
Amir Raminfar 1893f48493 chore: clean up icons 2026-05-05 16:14:17 -07:00
Amir Raminfar 8dac197f60 feat(cloud-proto): add SearchLogs unary RPC (#4672)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 16:11:32 -07:00
Srujan rai 883f5464e3 fix(security): block 0.0.0.0/8 and broadcast in SSRF webhook guard (#4674)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-05 06:12:14 -07:00
Amir Raminfar 023ff1242d chore: adds missing icons
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-04 17:21:37 -07:00
Amir Raminfar f3c17ee493 chore: updates homepage 2026-05-04 17:21:20 -07:00
Srujan rai 79184bbfd2 fix(docker): use cancellable context in ContainerEvents and close event channel (#4673)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-04 06:26:58 -07:00
Amir Raminfar fa7479e1ec fix(notifications): harden webhook dispatcher against SSRF (#4670)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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.7 (1M context) <noreply@anthropic.com>
2026-05-03 06:49:48 -07:00
Amir Raminfar 543f96198f fix(web): reject cross-origin WebSocket upgrades on attach/exec (#4671)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 06:49:41 -07:00
Amir Raminfar 581bab3a43 docs: adds icons to user manual
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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-05-02 14:26:39 -07:00
Amir Raminfar 3dc5e3afab docs: updates cloud 2026-05-02 11:03:29 -07:00
Amir Raminfar b3e5f2d2b0 docs: add default profile guide
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.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:23:14 -07:00
Amir Raminfar 4de47308a6 chore: release v10.5.1 2026-04-30 08:20:12 -07:00
Amir Raminfar a9c773a15a fix(cloud): reconnect on unlink/relink so new key takes effect (#4667)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:59:11 +00:00
Amir Raminfar c5c301ffb2 feat(cloud): per-replica cloud connections in swarm mode (#4665)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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.7 (1M context) <noreply@anthropic.com>
2026-04-29 06:21:41 -07:00
Amir Raminfar 980f52be6f chore: polish settings and dashboard pages (#4664)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (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.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:51:42 +00:00
Amir Raminfar e1bfb88dad chore: fixes d files
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-04-27 13:34:21 -07:00
Amir Raminfar 84c4d1144f feat: host grouping with bug fixes and hardening (#4662)
Co-authored-by: Mikhail Gorbachev <31391617+nomah4@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:57:48 +00:00
Amir Raminfar 2478cd16d6 docs: add /data volume mount to README install examples (#4660)
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.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:42:56 +00:00
Amir Raminfar 0dc3720795 chore: release v10.5.0
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-04-26 14:04:20 -07:00
Amir Raminfar 57fa1ba77a chore: clean up settings page dropdown alignment and version tag
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:42:10 -07:00
Amir Raminfar 551ecce726 feat: Redesign settings page (#4657)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:08:12 +00:00
Amir Raminfar 74fd80229c feat(cloud): stream container logs to Dozzle Cloud over gRPC (#4652)
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.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:32:14 +00:00
dependabot[bot] e0e061f47c chore(deps): bump go.opentelemetry.io/otel from 1.39.0 to 1.41.0 (#4653)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 07:22:27 -07:00
Amir Raminfar e3d3c789df perf: parallelize agent init and fix retry race (#4656)
Co-authored-by: Mikhail Gorbachev <31391617+nomah4@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:06:28 +00:00
Amir Raminfar 7d3164dede fix(deps): update non-major deps (excluding tailwindcss) (#4651)
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.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:18:07 +00:00
Amir Raminfar 4466001c78 chore: drop socket-proxy recommendation from security notes
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.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:18:38 -07:00
Amir Raminfar e6aa6adce6 chore: fold reverse proxy, security, upgrade docs into existing pages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:13:27 -07:00
Amir Raminfar 116006dbbf chore: expand and tighten guide docs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:42:49 -07:00
Amir Raminfar 6ce3750062 chore: bump vue-router to ^5.0.6 to fix typed-router generator
The 5.0.5 generator emitted an unterminated string literal in
typed-router.d.ts, breaking typecheck and build. Fixed upstream in 5.0.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:30:00 -07:00
Amir Raminfar ef59688748 chore: updates .d files
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-04-23 06:50:59 -07:00
renovate[bot] 8d020d618c chore(deps): update pnpm/action-setup action to v6 (#4647)
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>
Co-authored-by: Amir Raminfar <findamir@gmail.com>
2026-04-22 10:39:58 -07:00
Amir Raminfar 9288249aca fix(deps): update non-major deps (tailwindcss pinned at 4.2.2) (#4646)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:04:00 +00:00
Amir Raminfar 321ae607f6 chore: hide deployments page from sidebar
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.7 <noreply@anthropic.com>
2026-04-21 18:16:28 -07:00
Amir Raminfar 416db140c8 chore: updates websites 2026-04-21 17:45:36 -07:00
Amir Raminfar 6ba57afe0b feat(cloud): add network stats to get_running_container_stats (#4645)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:00:05 -07:00
renovate[bot] 59994ea431 fix(deps): update dependency vite to v8.0.9 (#4642)
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-04-20 16:21:37 +00:00
renovate[bot] cebe484da5 fix(deps): update all non-major dependencies (#4641)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-19 17:05:24 +00:00
Amir Raminfar 837b994c19 fix: avoid _grpc_config DNS lookups from agent healthcheck (#4640)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-04-18 09:18:12 -07:00
Amir Raminfar c48dcc5c4b fix: data race in ContainerStore in-place mutations (#4639)
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.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:22:14 +00:00
Amir Raminfar 6a6b34b35d chore: release v10.4.1 2026-04-17 14:35:08 -07:00
Amir Raminfar 0346c1e09c chore: Auto-create default alert on welcome modal CTA (#4638)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-04-17 10:43:54 -07:00
renovate[bot] 9d008da270 fix(deps): update all non-major dependencies (#4637)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-17 13:46:42 +00:00
Amir Raminfar 797faf079c Merge branch 'feat/k8s-mode-notifications' 2026-04-17 06:38:54 -07:00
Amir Raminfar 92ae58c058 minor clean up 2026-04-17 06:38:48 -07:00
Amir Raminfar 816dccc8c1 fix: cloud remove_container on agents and queue burst tool calls (#4634)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 06:22:48 -07:00
Parag ac2ca7c280 feat: implement notification manager support for k8s cluster service 2026-04-17 09:25:21 +05:30
Amir Raminfar 8c5338dea8 chore: release v10.4.0
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-04-16 17:05:20 -07:00
Amir Raminfar d09b2fe04e feat: add native compose file deployment (#4618)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:21:18 -07:00
Amir Raminfar 2994ccf239 chore: release v10.3.3
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-04-14 14:18:39 -07:00
Amir Raminfar f8e6704c34 chore: update welcome modal chips to better represent Cloud capabilities
Replace "Understand failures" with "Ask Assistant about my logs" and add
"Get daily log digests" chip. Broaden "Get error alerts" to "Get notified
about issues" to cover all notification channels.

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:16:21 -08:00
Amir Raminfar a3fc0ea992 chore: adds keep-alive flags (#4474) 2026-02-23 15:07:28 -08:00
Amir Raminfar 54469dc163 fix: normalize CPU % against container cpuLimit and add soft ceiling to BarChart (#4464)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:04:03 -08:00
Amir Raminfar 5b412456cd test: add notification config to agent integration test (#4473)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 14:56:36 +00:00
Amir Raminfar 475cd916f2 chore: updates gitignore 2026-02-23 06:37:03 -08:00
Amir Raminfar 9982e6e2a6 chore: updates settings 2026-02-23 06:36:05 -08:00
renovate[bot] 798981b334 fix(deps): update all non-major dependencies (#4471)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-23 01:02:41 +00:00
Richard Coker 76e94a7ad4 docs: update commented users.yaml generation command (#4470) 2026-02-22 12:20:19 -08:00
Amir Raminfar 4351493914 chore: ignore .claude/settings.local.json
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:14:45 -08:00
Amir Raminfar a074aa9511 chore: release v10.0.4 2026-02-21 12:11:11 -08:00
Amir Raminfar 78114eb2d2 fix: call Start() before LoadConfig() in agent mode to prevent nil pointer panic (#4468)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-21 12:10:51 -08:00
Javier Pérez 5ff6d100d4 fix: use coolify.serviceName for unique container names in multi-container apps (#4466) 2026-02-21 06:34:38 -08:00
Amir Raminfar 94cfa6da1c chore: updates to settings 2026-02-20 16:24:58 -08:00
renovate[bot] ffa38bcb2f fix(deps): update all non-major dependencies (#4463)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-20 20:32:46 +00:00
Amir Raminfar 8ab06d9a24 chore: release v10.0.3 2026-02-20 12:14:19 -08:00
Amir Raminfar c3a4583ba7 fix: fixes multiple bugs with alerting 🔔 (#4459) 2026-02-20 17:37:58 +00:00
Amir Raminfar bf16c1042d fix: fixes context menu not showing with little rows. see #4332 (#4462) 2026-02-20 17:01:32 +00:00
Amir Raminfar 1174c1d58e chore: moves container events to trace 2026-02-20 07:10:40 -08:00
renovate[bot] f3c4e806ae fix(deps): update all non-major dependencies (#4460)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 21:46:40 +00:00
Amir Raminfar 374865589a fix: fixes new containers not subsrcribing when first starting (#4458) 2026-02-19 10:13:32 -08:00
Richard Hull ec7d15c6bc style: brighten orange theme color (#4457) 2026-02-18 19:56:36 -08:00
Amir Raminfar 9e128d8de8 chore: makes dispatcher configurable 2026-02-17 13:57:27 -08:00
Amir Raminfar 94ea7b39e9 chore: adds type to notifications 2026-02-17 06:25:47 -08:00
Amir Raminfar bcc8adc81b chore: fix translation 2026-02-16 16:21:34 -08:00
Amir Raminfar a79ffdaf50 feat: add metric-based alerts for container CPU/memory thresholds (#4454)
Co-authored-by: Dhaval Patel <dhavu262@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:55:35 +00:00
Amir Raminfar 51a92ba448 chore: fixes javascript tests 2026-02-16 11:47:14 -08:00
382 changed files with 36118 additions and 7049 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:*)"
]
}
}
@@ -0,0 +1,66 @@
---
name: update-int-snapshots
description: Use when Playwright visual snapshots in e2e/visual.spec.ts-snapshots/ need to be regenerated after a UI change (logo, theme, layout, anything that affects rendered pixels) and the integration suite needs to be rerun via make int
---
# Update Integration Snapshots
## When to Use
- A visual change landed (logo, color, spacing, fonts) and `make int` will fail on `visual.spec.ts` until snapshots are regenerated.
- A snapshot test reports diffs you've confirmed are intentional.
Do NOT use when diffs are unintentional regressions — investigate first.
## Procedure
1. **Delete the stale snapshots** so Playwright writes fresh ones (don't try to update in place, the old PNGs can confuse the diff):
```bash
rm e2e/visual.spec.ts-snapshots/*.png
```
2. **Check for port 8080 conflicts.** The `custom_base` test container binds host port 8080. If another container is already on it (common: `doligence-api-1`), the run fails with `Bind for 0.0.0.0:8080 failed: port is already allocated`.
```bash
docker ps --format '{{.Names}}\t{{.Ports}}' | grep ':8080->'
```
If anything other than test containers is bound, stop it first (ask the user before stopping containers from other projects).
3. **Run with `--update-snapshots` via `compose up`.** You must use `compose up` (not `compose run`) because the navigation sidebar snapshot includes the live container list, and the two modes produce different sibling containers. The cleanest way is to patch the playwright command in `docker-compose.yml` temporarily:
```bash
sed -i.bak 's|command: npx --yes playwright test|command: npx --yes playwright test --update-snapshots|' docker-compose.yml
make int
mv docker-compose.yml.bak docker-compose.yml
```
Snapshots must be generated in Linux/Chromium (the compose image), not macOS, because filenames include the platform suffix (e.g. `-chromium-linux.png`).
4. **Verify** by rerunning the normal suite:
```bash
make int
```
Should now pass cleanly.
5. **Commit the regenerated PNGs** alongside the UI change so CI stays green.
## Common Mistakes
- **Running `npx playwright test --update-snapshots` locally on macOS.** Generates `-darwin` filenames CI doesn't use. Always go through the docker compose image.
- **Forgetting to delete first.** `--update-snapshots` does overwrite, but if the test layout changed (new test, renamed snapshot), stale PNGs are left behind. Wipe-and-regenerate is safest.
- **Stopping unrelated containers without asking.** The port conflict is annoying but the user's other dev containers may be holding important state.
## Quick Reference
```bash
rm e2e/visual.spec.ts-snapshots/*.png
docker ps | grep ':8080->' # confirm port free
sed -i.bak 's|command: npx --yes playwright test|& --update-snapshots|' docker-compose.yml
make int
mv docker-compose.yml.bak docker-compose.yml
make int # verify pass
```
-1
View File
@@ -6,4 +6,3 @@ dist
.git
e2e
docs
internal/agent/pb/
+8 -1
View File
@@ -2,5 +2,12 @@
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", "schedule:weekly", "group:allNonMajor", ":automergeMinor"],
"rangeStrategy": "bump",
"ignoreDeps": []
"minimumReleaseAge": "24 hours",
"ignoreDeps": [],
"packageRules": [
{
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"],
"groupName": "playwright"
}
]
}
+3 -2
View File
@@ -21,13 +21,13 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
pull-requests: write
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 1
@@ -36,6 +36,7 @@ jobs:
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
use_sticky_comment: true
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
+4 -4
View File
@@ -23,14 +23,14 @@ jobs:
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
contents: write
pull-requests: write
issues: write
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 1
+27 -30
View File
@@ -12,18 +12,17 @@ jobs:
name: JavaScript Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
name: Install pnpm
- uses: actions/setup-node@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
name: Install Node
with:
node-version: 24.13.1
node-version: latest
- run: npm install --global corepack@latest
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 24.13.1
node-version: latest
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
@@ -35,12 +34,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Install Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: 1.26.0
go-version: 1.26.5
check-latest: true
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install Protoc
uses: arduino/setup-protoc@v3
- name: Install gRPC and Go
@@ -54,30 +53,29 @@ jobs:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
name: Install pnpm
- uses: actions/setup-node@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
name: Install Node
with:
node-version: 24.13.1
node-version: latest
- run: npm install --global corepack@latest
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 24.13.1
node-version: latest
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: Writing certs to file
run: |
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
- name: Build
uses: docker/bake-action@v6
uses: docker/bake-action@v7
with:
source: .
load: true
@@ -86,7 +84,7 @@ jobs:
*.cache-to=type=gha,mode=max
- name: Run Playwright tests
run: docker compose up --exit-code-from playwright
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report
@@ -101,23 +99,23 @@ jobs:
packages: write
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to DockerHub
uses: docker/login-action@v3.7.0
uses: docker/login-action@v4.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the Container registry
uses: docker/login-action@v3.7.0
uses: docker/login-action@v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
amir20/dozzle
@@ -132,9 +130,10 @@ jobs:
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
- name: Build and push
uses: docker/build-push-action@v6.19.2
uses: docker/build-push-action@v7.3.0
with:
push: true
sbom: true
context: .
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
tags: ${{ steps.meta.outputs.tags }}
@@ -150,13 +149,11 @@ jobs:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
name: Install pnpm
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
- name: Release to Github
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+11 -8
View File
@@ -18,39 +18,42 @@ 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@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to DockerHub
uses: docker/login-action@v3.7.0
uses: docker/login-action@v4.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the Container registry
uses: docker/login-action@v3.7.0
uses: docker/login-action@v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
amir20/dozzle
ghcr.io/amir20/dozzle
- name: Short SHA
id: sha
run: echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Writing certs to file
run: |
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
- name: Build and push
uses: docker/build-push-action@v6.19.2
uses: docker/build-push-action@v7.3.0
with:
context: .
push: true
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
platforms: linux/amd64,linux/arm64/v8
tags: ${{ steps.meta.outputs.tags }}
build-args: TAG=${{ steps.meta.outputs.version }}
build-args: TAG=${{ steps.meta.outputs.version }}-${{ steps.sha.outputs.short }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+14 -8
View File
@@ -24,17 +24,23 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0 # Not needed if lastUpdated is not enabled
- uses: pnpm/action-setup@v2
- name: Setup Node
uses: actions/setup-node@v6
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: 24.13.1
node-version: latest
- run: npm install --global corepack@latest
- run: corepack enable
- run: pnpm --version
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: latest
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
@@ -42,7 +48,7 @@ jobs:
pnpm docs:build
touch docs/.vitepress/dist/.nojekyll
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
uses: actions/upload-pages-artifact@v5
with:
path: docs/.vitepress/dist
@@ -57,4 +63,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
+30 -29
View File
@@ -15,18 +15,17 @@ jobs:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
name: Install pnpm
- uses: actions/setup-node@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
name: Install Node
with:
node-version: 24.13.1
node-version: latest
- run: npm install --global corepack@latest
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 24.13.1
node-version: latest
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
@@ -37,14 +36,17 @@ jobs:
name: JavaScript Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
name: Install pnpm
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
name: Install Node
with:
node-version: latest
- run: npm install --global corepack@latest
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 24.13.1
node-version: latest
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
@@ -56,12 +58,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Install Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: "1.26.0"
go-version: "1.26.5"
check-latest: true
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install Protoc
uses: arduino/setup-protoc@v3
with:
@@ -77,16 +79,16 @@ jobs:
name: Go Staticcheck
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: "1.26.0"
go-version: "1.26.5"
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:
@@ -94,28 +96,27 @@ jobs:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
name: Install pnpm
- uses: actions/setup-node@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
name: Install Node
with:
node-version: 24.13.1
node-version: latest
- run: npm install --global corepack@latest
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 24.13.1
node-version: latest
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Generate certs
run: make shared_key.pem shared_cert.pem
- name: Build
uses: docker/bake-action@v6
uses: docker/bake-action@v7
with:
source: .
load: true
@@ -124,7 +125,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/
+53 -22
View File
@@ -14,6 +14,23 @@ Format:
- Include file:line references when relevant
- Maximum ~10-15 lines per response
## Testing Unreleased PRs
When replying to a GitHub issue or discussion where the fix lives in an open PR, ask the reporter to test the pre-built image: `amir20/dozzle:pr-XXX` (XXX = PR number). CI builds a tagged image per PR, so reporters can verify without waiting for the next release.
## GitHub Tone (issues, PRs, comments, discussions)
When posting anything to GitHub, write like a human maintainer, not an AI assistant. Avoid telltale LLM patterns:
- No em dashes or en dashes. Use commas, periods, or parentheses instead.
- No "Not X, but Y" rhetorical contrasts.
- No throat-clearing openers ("Great point", "Makes sense", "Thanks for the detailed write-up").
- No closing summaries or recap sentences.
- No bolded inline labels mid-paragraph ("**Why:**", "**Note:**").
- Drop hedges ("essentially", "basically", "essentially just"). Say it plain.
- Lowercase casual tone is fine. Contractions are fine. Short sentences are fine.
- Don't over-explain tradeoffs. State the decision, give one reason, stop.
## Project Overview
Dozzle is a lightweight, web-based Docker log viewer with real-time monitoring capabilities. It's a hybrid application with:
@@ -112,13 +129,22 @@ 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
- **`internal/support/`** - Support utilities
- `cli/`: Command-line argument parsing and validation
- `docker/`: Multi-host Docker management and Swarm support
- `container/`: Container service abstractions
- `docker/`: Multi-host Docker management and Swarm support (`docker_service.go`, client managers)
- `k8s/`: Kubernetes service abstractions
- `web/`: Web service utilities
- **`internal/auth/`** - Authentication providers
@@ -134,10 +160,6 @@ The Go backend is organized into these key packages:
- `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)
@@ -198,7 +220,6 @@ The frontend uses file-based routing with these conventions:
3. **Stats**: Real-time CPU/memory stats streamed via SSE alongside events
4. **Actions**: POST to `/api/hosts/{host}/containers/{id}/actions/{action}` (start/stop/restart)
5. **Terminal**: WebSocket connections for container attach/exec at `/api/hosts/{host}/containers/{id}/attach`
6. **GraphQL**: POST to `/api/graphql` for queries and mutations (container metadata, historical logs, notifications)
### Build System
@@ -226,6 +247,9 @@ The frontend uses file-based routing with these conventions:
- `BarChart.vue`: Self-contained bar chart with responsive downsampling
- Downsampling algorithm: Averages data into buckets based on available screen width
- All stat history tracked in `Container.statsHistory` (max 300 items via rolling window)
- `chartData` is always a rolling window of max 300 items — array length stays constant
- Uses `ref` (not `computed`) for `downsampledBars` to enable in-place mutation of the last bar, avoiding full re-renders
- Component instance is reused when switching containers; after init the chart only patches the last bar per tick, so on a wholesale `chartData` replacement (container switch) the parent must call the exposed `recalculate()`. `MultiContainerStat` holds refs to its `BarChart`s and calls it in the `containers` watch. (Note: `Container` carries Vue `ref`s, so VueTestUtils `setProps` cannot retrigger such a watch — tests must swap the container via a parent `ref` re-render.)
### Backend
@@ -233,9 +257,6 @@ The frontend uses file-based routing with these conventions:
- Certificate generation is required (`make generate` creates shared_key.pem and shared_cert.pem)
- Protocol buffer generation happens via `go generate` directive in `main.go`
- Docker client uses API version negotiation for compatibility
- **GraphQL API**: Uses gqlgen with schema in `graph/schema.graphqls`, generated code in `graph/generated.go`
- Run `pnpm codegen` to regenerate GraphQL types
- Resolvers follow-schema layout in `graph/*.resolvers.go`
- **Service Layer Architecture**:
- `ClientService` interface abstracts Docker/K8s/Agent backends
- `MultiHostService` orchestrates multi-host operations
@@ -303,7 +324,7 @@ Implementation (DockerClient, K8sClient, AgentClient)
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`)
3. Add wrapper method in `ClientService` interface (`internal/support/docker/docker_service.go`)
4. Add HTTP handler in `internal/web/` with appropriate route
### Frontend Data Flow
@@ -336,6 +357,16 @@ Implementation (DockerClient, K8sClient, AgentClient)
5. Streaming RPCs (logs, stats, events) use bidirectional channels
6. Responses converted back to domain models via `FromProto()` methods
### Cloud Tool Execution Flow
1. `cloud.Client.Run()` blocks until `Notify()` signals a cloud dispatcher is configured
2. `connect()` establishes bidirectional gRPC stream (`ToolStream` RPC) to cloud endpoint
3. Cloud sends `ToolRequest` (ListTools or CallTool), client dispatches via `executeTool()`
4. Tool calls run concurrently (max 5 via weighted semaphore), responses sent back on stream
5. On disconnect, exponential backoff (1s→30s with jitter) triggers reconnection
6. `PermissionDenied` errors stop retrying permanently (invalid API key / no pro plan)
7. Tool definitions cached via `sync.Once`; zero overhead for non-cloud users
### Log Parsing Pipeline
1. Docker API returns multiplexed stream (8-byte headers + payload)
@@ -373,14 +404,6 @@ Implementation (DockerClient, K8sClient, AgentClient)
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`
@@ -402,7 +425,7 @@ Implementation (DockerClient, K8sClient, AgentClient)
**Frontend** (`assets/pages/notifications.vue`, `assets/components/Notification/`):
- `AlertForm.vue`, `DestinationForm.vue`: UI for creating rules
- Rules stored via GraphQL mutations
- Rules persisted to `./data/notifications.yml` via `internal/notification/persist.go`
- Alert state displayed in notification cards
**Adding a new notification channel:**
@@ -410,7 +433,16 @@ Implementation (DockerClient, K8sClient, AgentClient)
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
@@ -433,5 +465,4 @@ Implementation (DockerClient, K8sClient, AgentClient)
- 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
+15 -15
View File
@@ -1,43 +1,45 @@
# Build assets
FROM --platform=$BUILDPLATFORM node:25.6.1-alpine AS node
FROM --platform=$BUILDPLATFORM node:25.9.0-alpine AS node
RUN npm install -g --force corepack && corepack enable
ENV CI=true
WORKDIR /build
# Install dependencies from lock file
COPY pnpm-*.yaml ./
RUN pnpm fetch --ignore-scripts --no-optional
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm fetch --ignore-scripts
# Copy package.json and install dependencies
COPY package.json ./
RUN pnpm install --offline --ignore-scripts --no-optional
RUN pnpm install --offline --ignore-scripts
# 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
ARG CLOUD_URL
ENV CLOUD_URL=$CLOUD_URL
# Build assets
RUN pnpm build
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
# install gRPC dependencies
RUN apk add --no-cache ca-certificates protoc protobuf-dev \
&& mkdir /dozzle \
&& go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \
&& go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
RUN apk add --no-cache ca-certificates && mkdir /dozzle
WORKDIR /dozzle
# Copy go mod files
COPY go.* ./
RUN go mod download
RUN --mount=type=cache,target=/go/pkg/mod go mod download
# Copy all other files
COPY internal ./internal
COPY proto ./proto
COPY types ./types
COPY main.go ./
COPY protos ./protos
@@ -50,11 +52,9 @@ COPY --from=node /build/dist ./dist
ARG TAG=dev
ARG TARGETOS TARGETARCH
# Generate protos and graphql
RUN go generate ./...
# Build binary
RUN GOEXPERIMENT=jsonv2 GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/amir20/dozzle/internal/support/cli.Version=$TAG" -o dozzle
RUN mkdir /data
+84
View File
@@ -0,0 +1,84 @@
# Dozzle Major Version Highlights
A summary of major features introduced with each major version bump since 2020.
## v2.0.0 — 2020-06-27
- Theme overhaul using CSS variables
- Progress notification bar
- Toggle between new light and dark themes
## v3.0.0 — 2020-09-06
- Live container stats (CPU/memory) streamed in real time
- Pinned tabs and improved mobile/responsive layout
- Major UI overhaul
## v4.0.0 — 2022-08-17
- First-class JSON log support
- Jump-to-context, soft wraps, clear-logs action
- Auto color scheme, dark mode polish
- Container stats panel, total CPU/mem usage
- Healthcheck endpoint and "wait for docker" startup option
## v5.0.0 — 2023-09-23
- Multi-host support with parallel client connections
- New homepage dashboard with all containers, sortable table, pagination, bar charts
- Exponential moving average for stats
- Container pinning, keyboard shortcut overlay
- stdout/stderr stream separation
- i18n: Chinese, German added; locale infrastructure
- Refactored UI with faster components
## v6.0.0 — 2024-01-01
- **Forward-proxy authentication** (Authelia, etc.) and `users.yml` file-based auth
- Container actions: start/stop/restart from the UI
- Hot-reload of users.yml without restart
- Custom headers for forward-proxy auth
- Settings synced to disk for authenticated users
- Toast notifications, release list, redirect-to-new-container
- Removed legacy auth model (breaking)
## v7.0.0 — 2024-05-24
- **Docker Swarm mode** with stacks and services on remote hosts
- Host cards on dashboard with per-host stats
- Container grouping by stack/compose
- Background stats collection (up to 5 min) with idle deactivation
- LogFmt parser support
- Compact mode, draggable search, alt-click split panes
- Many new locales (French, Italian, Polish, Danish, Turkish, …)
- `generate` subcommand for users.yml
## v8.0.0 — 2024-07-05
- **Swarm mode rebuilt on gRPC agents** (breaking architecture change)
- Critical/severe log levels
- Stacks and services in fuzzy search
- Improved search with full match scrolling
- Container start events shown inline
## v9.0.0 — 2026-01-06
- **Kubernetes mode** with k8s-specific menu
- **User roles** and `dozzle_*` role mapping; logout URL for forward proxy
- Historical stats on homepage, hosts, and containers
- Permanent links to specific past log lines
- Grouping by `dev.dozzle.group` label, log message grouping
- Shell resize support, action toolbar in menu
- Settings page, collapsible side menu sections
- Parallel container fetching, gRPC compression
- CLEF (`@l`) log level extraction
- New locales: Korean, Indonesian, Dutch
## v10.0.0 — 2026-02-10
- **Dozzle Cloud** integration (bidirectional gRPC tool execution)
- **Notifications & alerts**: full notifications page, webhooks, dispatchers, Go template support, test connections
- Notifications work across agents
- Per-container network usage stats (with mobile view)
- Coolify label fallbacks for container name/group
- Alert creation shortcut, JSON syntax in templates
+8 -7
View File
@@ -21,13 +21,17 @@ fake_assets:
test: fake_assets generate
go test -cover -race -count 1 -timeout 40s ./...
.PHONY: test-update
test-update: fake_assets generate
go test -cover -race -count 1 -timeout 5s ./... -- -- -u
.PHONY: build
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:local .
docker: generate
@docker build --build-arg TAG=local --build-arg CLOUD_URL=$(CLOUD_URL) -t amir20/dozzle:local .
.PHONY: generate
generate: shared_key.pem shared_cert.pem
@@ -49,14 +53,11 @@ 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
.PHONY: push
push: docker
@docker tag amir20/dozzle:latest amir20/dozzle:local-test
@docker push amir20/dozzle:local-test
.PHONY: run
run: docker
docker run -it --rm -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock amir20/dozzle:local
.PHONY: preview
preview: build
pnpm preview
+53 -8
View File
@@ -41,7 +41,7 @@ Dozzle is a small container (7 MB compressed). Pull the latest release with:
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
$ docker run --name dozzle -d --volume=/var/run/docker.sock:/var/run/docker.sock -v dozzle_data:/data -p 8080:8080 amir20/dozzle:latest
Dozzle will be available at [http://localhost:8080/](http://localhost:8080/).
@@ -53,8 +53,11 @@ Here is a Docker Compose example:
image: amir20/dozzle:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- dozzle_data:/data
ports:
- 8080:8080
volumes:
dozzle_data:
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).
@@ -78,6 +81,8 @@ See the [Agent Mode](https://dozzle.dev/guide/agent) documentation for more deta
Dozzle uses automatic API negotiation, which works with most Docker configurations. Dozzle also works with [Colima](https://github.com/abiosoft/colima) and [Podman](https://podman.io/).
Dozzle requires Docker Engine 19.03 or newer (API version 1.40+). Older daemons are not supported by the underlying Docker SDK.
### Installation on Podman
By default, Podman doesn't have a background process, but you can enable the remote socket for Dozzle to work.
@@ -140,11 +145,51 @@ There are many ways to support Dozzle:
## Building
To build and test locally:
Want to contribute? Great! Dozzle has two parts: a **Go backend** that talks to Docker, and a **Vue frontend** that runs in the browser. You don't need to know both — pick the side that matches what you want to change. For documentation fixes, no setup is needed at all; just edit the file on GitHub.
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 [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.
### 1. Install the prerequisites
You'll need [Go](https://go.dev/doc/install) (1.25+), [Node.js](https://nodejs.org/en/download/) (with [pnpm](https://pnpm.io/installation)), and [protoc](https://grpc.io/docs/protoc-installation/).
On macOS, you can install everything in one go:
```bash
brew install go node pnpm protobuf
```
On Linux (Debian/Ubuntu):
```bash
sudo apt install golang nodejs protobuf-compiler
npm install -g pnpm
```
On Windows, we recommend using [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) and following the Linux instructions.
### 2. Clone and set up
```bash
git clone https://github.com/amir20/dozzle.git
cd dozzle
pnpm install # installs frontend dependencies
go install tool # installs Go build tools listed in go.mod (air, protoc-gen-go, etc.)
make generate # generates TLS certificates and protobuf code (only needed once)
```
### 3. Start the dev server
```bash
make dev
```
Open [http://localhost:3100](http://localhost:3100) — you should see the Dozzle UI connected to your local Docker. Both the frontend and backend reload automatically when you save a file.
### Making your first change
Try editing `assets/pages/index.vue` and saving — the browser updates instantly. For backend changes, edit any `.go` file and the server will restart on its own.
### Troubleshooting
- **Nothing shows up at localhost:3100** — make sure Docker is running and the socket is accessible at `/var/run/docker.sock`.
- **`make generate` fails** — confirm `protoc` is on your PATH (`protoc --version`).
- **Still stuck?** Open a question in [GitHub Discussions](https://github.com/amir20/dozzle/discussions) — we're happy to help.
+53 -5
View File
@@ -6,10 +6,12 @@
// biome-ignore lint: disable
export {}
declare global {
const DEFAULT_MENU_WIDTH: typeof import('./stores/settings').DEFAULT_MENU_WIDTH
const DEFAULT_SETTINGS: typeof import('./stores/settings').DEFAULT_SETTINGS
const EffectScope: typeof import('vue').EffectScope
const K8sNamespace: typeof import('./stores/k8s').K8sNamespace
const K8sOwner: typeof import('./stores/k8s').K8sOwner
const MIN_MENU_WIDTH: typeof import('./stores/settings').MIN_MENU_WIDTH
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const allLevels: typeof import('./composable/logContext').allLevels
const arrayEquals: typeof import('./utils/index').arrayEquals
@@ -17,6 +19,7 @@ declare global {
const autoResetRef: typeof import('@vueuse/core').autoResetRef
const automaticRedirect: typeof import('./stores/settings').automaticRedirect
const collapseNav: typeof import('./stores/settings').collapseNav
const colorize: typeof import('./utils/index').colorize
const compact: typeof import('./stores/settings').compact
const computed: typeof import('vue').computed
const computedAsync: typeof import('@vueuse/core').computedAsync
@@ -28,12 +31,15 @@ declare global {
const controlledRef: typeof import('@vueuse/core').controlledRef
const createApp: typeof import('vue').createApp
const createContainerHints: typeof import('./composable/exprEditor').createContainerHints
const createDisposableDirective: typeof import('@vueuse/core').createDisposableDirective
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
@@ -52,19 +58,24 @@ declare global {
const drawerContext: typeof import('./composable/drawer').drawerContext
const eagerComputed: typeof import('@vueuse/core').eagerComputed
const effectScope: typeof import('vue').effectScope
const escapeHtml: typeof import('./utils/index').escapeHtml
const extendRef: typeof import('@vueuse/core').extendRef
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
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const getDeep: typeof import('./utils/index').getDeep
const getK8sOwnerRefs: typeof import('./stores/k8s').getK8sOwnerRefs
const globalShowPopup: typeof import('./composable/popup').globalShowPopup
const groupContainers: typeof import('./stores/settings').groupContainers
const groupK8sOwners: typeof import('./stores/k8s').groupK8sOwners
const h: typeof import('vue').h
const hashCode: typeof import('./utils/index').hashCode
const highlightSubstringInHtml: typeof import('./utils/index').highlightSubstringInHtml
const hourStyle: typeof import('./stores/settings').hourStyle
const ignorableWatch: typeof import('@vueuse/core').ignorableWatch
const inject: typeof import('vue').inject
@@ -78,7 +89,7 @@ declare global {
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const lightTheme: typeof import('./stores/settings').lightTheme
const loadBetween: typeof import('./composable/eventStreams').loadBetween
const loadBetween: typeof import('./composable/loadBetween').loadBetween
const locale: typeof import('./stores/settings').locale
const loggingContextKey: typeof import('./composable/logContext').loggingContextKey
const makeDestructurable: typeof import('@vueuse/core').makeDestructurable
@@ -111,6 +122,8 @@ declare global {
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const ownerMembershipLabel: typeof import('./stores/k8s').ownerMembershipLabel
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
@@ -154,6 +167,7 @@ declare global {
const stripVersion: typeof import('./utils/index').stripVersion
const syncRef: typeof import('@vueuse/core').syncRef
const syncRefs: typeof import('@vueuse/core').syncRefs
const syntaxHighlightJson: typeof import('./utils/index').syntaxHighlightJson
const templateRef: typeof import('@vueuse/core').templateRef
const throttledRef: typeof import('@vueuse/core').throttledRef
const throttledWatch: typeof import('@vueuse/core').throttledWatch
@@ -164,6 +178,7 @@ declare global {
const toRelativeTime: typeof import('./utils/index').toRelativeTime
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const tryFormatJson: typeof import('./utils/index').tryFormatJson
const tryOnBeforeMount: typeof import('@vueuse/core').tryOnBeforeMount
const tryOnBeforeUnmount: typeof import('@vueuse/core').tryOnBeforeUnmount
const tryOnMounted: typeof import('@vueuse/core').tryOnMounted
@@ -173,6 +188,7 @@ declare global {
const unrefElement: typeof import('@vueuse/core').unrefElement
const until: typeof import('@vueuse/core').until
const useActiveElement: typeof import('@vueuse/core').useActiveElement
const useAlertForm: typeof import('./composable/alertForm').useAlertForm
const useAnimate: typeof import('@vueuse/core').useAnimate
const useAnnouncements: typeof import('./stores/announcements').useAnnouncements
const useArrayDifference: typeof import('@vueuse/core').useArrayDifference
@@ -200,7 +216,10 @@ declare global {
const useClipboard: typeof import('@vueuse/core').useClipboard
const useClipboardItems: typeof import('@vueuse/core').useClipboardItems
const useCloned: typeof import('@vueuse/core').useCloned
const useCloudConfig: typeof import('./composable/cloudConfig').useCloudConfig
const useCloudLogSearch: typeof import('./composable/cloudLogSearch').useCloudLogSearch
const useColorMode: typeof import('@vueuse/core').useColorMode
const useCommands: typeof import('./composable/commands').useCommands
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
const useContainerActions: typeof import('./composable/containerActions').useContainerActions
const useContainerStore: typeof import('./stores/container').useContainerStore
@@ -238,6 +257,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
@@ -247,11 +267,13 @@ declare global {
const useFocusWithin: typeof import('@vueuse/core').useFocusWithin
const useFps: typeof import('@vueuse/core').useFps
const useFullscreen: typeof import('@vueuse/core').useFullscreen
const useFuzzySearch: typeof import('./composable/fuzzySearch').useFuzzySearch
const useGamepad: typeof import('@vueuse/core').useGamepad
const useGeolocation: typeof import('@vueuse/core').useGeolocation
const useGroupedStream: typeof import('./composable/eventStreams').useGroupedStream
const useHead: typeof import('@vueuse/head').useHead
const useHistoricalContainerLog: typeof import('./composable/historicalLogs').useHistoricalContainerLog
const useHostGroupStream: typeof import('./composable/eventStreams').useHostGroupStream
const useHostStream: typeof import('./composable/eventStreams').useHostStream
const useHosts: typeof import('./stores/hosts').useHosts
const useI18n: typeof import('vue-i18n').useI18n
@@ -267,6 +289,7 @@ declare global {
const useLastChanged: typeof import('@vueuse/core').useLastChanged
const useLink: typeof import('vue-router/auto').useLink
const useLocalStorage: typeof import('@vueuse/core').useLocalStorage
const useLogLoader: typeof import('./composable/logLoader').useLogLoader
const useLoggingContext: typeof import('./composable/logContext').useLoggingContext
const useMagicKeys: typeof import('@vueuse/core').useMagicKeys
const useManualRefHistory: typeof import('@vueuse/core').useManualRefHistory
@@ -395,10 +418,19 @@ 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 { CloudLogHit } from './composable/cloudLogSearch'
import('./composable/cloudLogSearch')
// @ts-ignore
export type { CommandSection, Command } from './composable/commands'
import('./composable/commands')
// @ts-ignore
export type { DrawerWidth } from './composable/drawer'
import('./composable/drawer')
// @ts-ignore
export type { LogStreamSource } from './composable/eventStreams'
export type { SearchStatus, LogStreamSource } from './composable/eventStreams'
import('./composable/eventStreams')
// @ts-ignore
export type { ExprEditorOptions } from './composable/exprEditor'
@@ -413,7 +445,7 @@ declare global {
export type { Host } from './stores/hosts'
import('./stores/hosts')
// @ts-ignore
export type { K8sNamespace, K8sOwner } from './stores/k8s'
export type { K8sNamespace, K8sOwner, K8sOwnerRef } from './stores/k8s'
import('./stores/k8s')
// @ts-ignore
export type { Settings } from './stores/settings'
@@ -436,6 +468,7 @@ declare module 'vue' {
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly automaticRedirect: UnwrapRef<typeof import('./stores/settings')['automaticRedirect']>
readonly collapseNav: UnwrapRef<typeof import('./stores/settings')['collapseNav']>
readonly colorize: UnwrapRef<typeof import('./utils/index')['colorize']>
readonly compact: UnwrapRef<typeof import('./stores/settings')['compact']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
@@ -447,12 +480,15 @@ declare module 'vue' {
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly createContainerHints: UnwrapRef<typeof import('./composable/exprEditor')['createContainerHints']>
readonly createDisposableDirective: UnwrapRef<typeof import('@vueuse/core')['createDisposableDirective']>
readonly createDrawer: UnwrapRef<typeof import('./composable/drawer')['createDrawer']>
readonly createEventHints: UnwrapRef<typeof import('./composable/exprEditor')['createEventHints']>
readonly createEventHook: UnwrapRef<typeof import('@vueuse/core')['createEventHook']>
readonly createExprEditor: UnwrapRef<typeof import('./composable/exprEditor')['createExprEditor']>
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
readonly createLogHints: UnwrapRef<typeof import('./composable/exprEditor')['createLogHints']>
readonly createMetricHints: UnwrapRef<typeof import('./composable/exprEditor')['createMetricHints']>
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
readonly createRef: UnwrapRef<typeof import('@vueuse/core')['createRef']>
@@ -475,13 +511,16 @@ 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']>
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
readonly getDeep: UnwrapRef<typeof import('./utils/index')['getDeep']>
readonly getK8sOwnerRefs: UnwrapRef<typeof import('./stores/k8s')['getK8sOwnerRefs']>
readonly globalShowPopup: UnwrapRef<typeof import('./composable/popup')['globalShowPopup']>
readonly groupContainers: UnwrapRef<typeof import('./stores/settings')['groupContainers']>
readonly groupK8sOwners: UnwrapRef<typeof import('./stores/k8s')['groupK8sOwners']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly hashCode: UnwrapRef<typeof import('./utils/index')['hashCode']>
readonly hourStyle: UnwrapRef<typeof import('./stores/settings')['hourStyle']>
@@ -497,7 +536,7 @@ declare module 'vue' {
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly isShallow: UnwrapRef<typeof import('vue')['isShallow']>
readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']>
readonly loadBetween: UnwrapRef<typeof import('./composable/eventStreams')['loadBetween']>
readonly loadBetween: UnwrapRef<typeof import('./composable/loadBetween')['loadBetween']>
readonly locale: UnwrapRef<typeof import('./stores/settings')['locale']>
readonly loggingContextKey: UnwrapRef<typeof import('./composable/logContext')['loggingContextKey']>
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
@@ -530,6 +569,8 @@ 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 ownerMembershipLabel: UnwrapRef<typeof import('./stores/k8s')['ownerMembershipLabel']>
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']>
@@ -552,7 +593,6 @@ declare module 'vue' {
readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
readonly scrollContextKey: UnwrapRef<typeof import('./composable/scrollContext')['scrollContextKey']>
readonly search: UnwrapRef<typeof import('./stores/settings')['search']>
readonly sessionHost: UnwrapRef<typeof import('./composable/storage')['sessionHost']>
@@ -592,6 +632,7 @@ declare module 'vue' {
readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']>
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
readonly useAlertForm: UnwrapRef<typeof import('./composable/alertForm')['useAlertForm']>
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
readonly useAnnouncements: UnwrapRef<typeof import('./stores/announcements')['useAnnouncements']>
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
@@ -619,7 +660,10 @@ declare module 'vue' {
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
readonly useCloudConfig: UnwrapRef<typeof import('./composable/cloudConfig')['useCloudConfig']>
readonly useCloudLogSearch: UnwrapRef<typeof import('./composable/cloudLogSearch')['useCloudLogSearch']>
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
readonly useCommands: UnwrapRef<typeof import('./composable/commands')['useCommands']>
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']>
@@ -657,6 +701,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']>
@@ -666,11 +711,13 @@ declare module 'vue' {
readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>
readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']>
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useFuzzySearch: UnwrapRef<typeof import('./composable/fuzzySearch')['useFuzzySearch']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useGroupedStream: UnwrapRef<typeof import('./composable/eventStreams')['useGroupedStream']>
readonly useHead: UnwrapRef<typeof import('@vueuse/head')['useHead']>
readonly useHistoricalContainerLog: UnwrapRef<typeof import('./composable/historicalLogs')['useHistoricalContainerLog']>
readonly useHostGroupStream: UnwrapRef<typeof import('./composable/eventStreams')['useHostGroupStream']>
readonly useHostStream: UnwrapRef<typeof import('./composable/eventStreams')['useHostStream']>
readonly useHosts: UnwrapRef<typeof import('./stores/hosts')['useHosts']>
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
@@ -686,6 +733,7 @@ declare module 'vue' {
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
readonly useLink: UnwrapRef<typeof import('vue-router/auto')['useLink']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useLogLoader: UnwrapRef<typeof import('./composable/logLoader')['useLogLoader']>
readonly useLoggingContext: UnwrapRef<typeof import('./composable/logContext')['useLoggingContext']>
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
+24
View File
@@ -0,0 +1,24 @@
import { build } from "vite";
import { describe, expect, it } from "vitest";
describe("production build", () => {
it("emits font URLs relative to the generated stylesheet", async () => {
const result = await build({
configFile: "vite.config.ts",
logLevel: "silent",
build: { write: false },
});
const outputs = Array.isArray(result) ? result : [result];
const stylesheet = outputs
.flatMap((output) => ("output" in output ? output.output : []))
.find((item) => item.type === "asset" && item.fileName.endsWith(".css"));
expect(stylesheet).toBeDefined();
if (!stylesheet || stylesheet.type !== "asset") {
throw new Error("Production build did not emit a stylesheet");
}
const css = String(stylesheet.source);
expect(css).toMatch(/url\(\.\/jetbrains-mono/);
expect(css).not.toMatch(/url\(\/assets\/jetbrains-mono/);
}, 15_000);
});
+42 -3
View File
@@ -15,8 +15,10 @@ declare module 'vue' {
AlertForm: typeof import('./components/Notification/AlertForm.vue')['default']
Announcements: typeof import('./components/Announcements.vue')['default']
BarChart: typeof import('./components/BarChart.vue')['default']
'Carbon:add': typeof import('~icons/carbon/add')['default']
'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default']
'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default']
'Carbon:close': typeof import('~icons/carbon/close')['default']
'Carbon:information': typeof import('~icons/carbon/information')['default']
'Carbon:logoKubernetes': typeof import('~icons/carbon/logo-kubernetes')['default']
'Carbon:macShift': typeof import('~icons/carbon/mac-shift')['default']
@@ -25,6 +27,7 @@ declare module 'vue' {
'Carbon:star': typeof import('~icons/carbon/star')['default']
'Carbon:starFilled': typeof import('~icons/carbon/star-filled')['default']
'Carbon:stopFilledAlt': typeof import('~icons/carbon/stop-filled-alt')['default']
'Carbon:upgrade': typeof import('~icons/carbon/upgrade')['default']
'Carbon:warning': typeof import('~icons/carbon/warning')['default']
Carousel: typeof import('./components/common/Carousel.vue')['default']
CarouselItem: typeof import('./components/common/CarouselItem.vue')['default']
@@ -33,6 +36,9 @@ declare module 'vue' {
'Cil:columns': typeof import('~icons/cil/columns')['default']
'Cil:xCircle': typeof import('~icons/cil/x-circle')['default']
CloudDestinationForm: typeof import('./components/Notification/CloudDestinationForm.vue')['default']
CloudPopover: typeof import('./components/CloudPopover.vue')['default']
CloudSearchInline: typeof import('./components/CloudSearchInline.vue')['default']
CloudSettingsCard: typeof import('./components/CloudSettingsCard.vue')['default']
ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default']
ContainerActionsToolbar: typeof import('./components/ContainerViewer/ContainerActionsToolbar.vue')['default']
ContainerDropdown: typeof import('./components/ContainerDropdown.vue')['default']
@@ -43,11 +49,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']
@@ -55,19 +63,25 @@ declare module 'vue' {
GroupMenu: typeof import('./components/GroupMenu.vue')['default']
HistoricalContainerLog: typeof import('./components/ContainerViewer/HistoricalContainerLog.vue')['default']
HostCard: typeof import('./components/HostCard.vue')['default']
HostGroupLog: typeof import('./components/HostViewer/HostGroupLog.vue')['default']
HostIcon: typeof import('./components/common/HostIcon.vue')['default']
HostList: typeof import('./components/HostList.vue')['default']
HostLog: typeof import('./components/HostViewer/HostLog.vue')['default']
HostMenu: typeof import('./components/HostMenu.vue')['default']
'Ic:sharpKeyboardReturn': typeof import('~icons/ic/sharp-keyboard-return')['default']
IndeterminateBar: typeof import('./components/common/IndeterminateBar.vue')['default']
IOCard: typeof import('./components/LogViewer/IOCard.vue')['default']
'Ion:ellipsisVertical': typeof import('~icons/ion/ellipsis-vertical')['default']
JsonFormatted: typeof import('./components/common/JsonFormatted.vue')['default']
JsonText: typeof import('./components/common/JsonText.vue')['default']
JsonValue: typeof import('./components/common/JsonValue.vue')['default']
K8sMenu: typeof import('./components/K8sMenu.vue')['default']
KeyShortcut: typeof import('./components/common/KeyShortcut.vue')['default']
LabeledInput: typeof import('./components/common/LabeledInput.vue')['default']
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']
@@ -84,14 +98,21 @@ declare module 'vue' {
'MaterialSymbols:person': typeof import('~icons/material-symbols/person')['default']
'MaterialSymbols:terminal': typeof import('~icons/material-symbols/terminal')['default']
'MaterialSymbolsLight:collapseAll': typeof import('~icons/material-symbols-light/collapse-all')['default']
'MaterialSymbolsLight:expandAll': typeof import('~icons/material-symbols-light/expand-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:alertCircleOutline': typeof import('~icons/mdi/alert-circle-outline')['default']
'Mdi:alertOutline': typeof import('~icons/mdi/alert-outline')['default']
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
'Mdi:arrowCollapse': typeof import('~icons/mdi/arrow-collapse')['default']
'Mdi:arrowExpand': typeof import('~icons/mdi/arrow-expand')['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']
@@ -100,24 +121,36 @@ declare module 'vue' {
'Mdi:chevronRight': typeof import('~icons/mdi/chevron-right')['default']
'Mdi:close': typeof import('~icons/mdi/close')['default']
'Mdi:cloud': typeof import('~icons/mdi/cloud')['default']
'Mdi:cloudCheckOutline': typeof import('~icons/mdi/cloud-check-outline')['default']
'Mdi:cloudOffOutline': typeof import('~icons/mdi/cloud-off-outline')['default']
'Mdi:cloudOutline': typeof import('~icons/mdi/cloud-outline')['default']
'Mdi:cloudSearchOutline': typeof import('~icons/mdi/cloud-search-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:filterOffOutline': typeof import('~icons/mdi/filter-off-outline')['default']
'Mdi:filterOutline': typeof import('~icons/mdi/filter-outline')['default']
'Mdi:flash': typeof import('~icons/mdi/flash')['default']
'Mdi:gauge': typeof import('~icons/mdi/gauge')['default']
'Mdi:github': typeof import('~icons/mdi/github')['default']
'Mdi:hamburgerMenu': typeof import('~icons/mdi/hamburger-menu')['default']
'Mdi:heart': typeof import('~icons/mdi/heart')['default']
'Mdi:hexagonMultiple': typeof import('~icons/mdi/hexagon-multiple')['default']
'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:linkVariantOff': typeof import('~icons/mdi/link-variant-off')['default']
'Mdi:magnify': typeof import('~icons/mdi/magnify')['default']
'Mdi:pencilOutline': typeof import('~icons/mdi/pencil-outline')['default']
'Mdi:plus': typeof import('~icons/mdi/plus')['default']
'Mdi:poll': typeof import('~icons/mdi/poll')['default']
'Mdi:refresh': typeof import('~icons/mdi/refresh')['default']
'Mdi:satelliteVariant': typeof import('~icons/mdi/satellite-variant')['default']
'Mdi:textBoxOutline': typeof import('~icons/mdi/text-box-outline')['default']
'Mdi:trashCanOutline': typeof import('~icons/mdi/trash-can-outline')['default']
'Mdi:webhook': typeof import('~icons/mdi/webhook')['default']
MetricAlertFields: typeof import('./components/Notification/MetricAlertFields.vue')['default']
MetricCard: typeof import('./components/MetricCard.vue')['default']
MobileMenu: typeof import('./components/common/MobileMenu.vue')['default']
MultiContainerActionToolbar: typeof import('./components/LogViewer/MultiContainerActionToolbar.vue')['default']
@@ -131,17 +164,18 @@ declare module 'vue' {
PageWithLinks: typeof import('./components/PageWithLinks.vue')['default']
'Ph:arrowsMerge': typeof import('~icons/ph/arrows-merge')['default']
'Ph:boundingBoxFill': typeof import('~icons/ph/bounding-box-fill')['default']
'Ph:caretRight': typeof import('~icons/ph/caret-right')['default']
'Ph:circlesFour': typeof import('~icons/ph/circles-four')['default']
'Ph:command': typeof import('~icons/ph/command')['default']
'Ph:computerTower': typeof import('~icons/ph/computer-tower')['default']
'Ph:controlBold': typeof import('~icons/ph/control-bold')['default']
'Ph:database': typeof import('~icons/ph/database')['default']
'Ph:dotsThreeVerticalBold': typeof import('~icons/ph/dots-three-vertical-bold')['default']
'Ph:downloadSimple': typeof import('~icons/ph/download-simple')['default']
'Ph:fileSql': typeof import('~icons/ph/file-sql')['default']
'Ph:globeSimple': typeof import('~icons/ph/globe-simple')['default']
'Ph:stack': typeof import('~icons/ph/stack')['default']
'Ph:stackSimple': typeof import('~icons/ph/stack-simple')['default']
PhArrowDown: typeof import('~icons/ph/arrow-down')['default']
PhArrowUp: typeof import('~icons/ph/arrow-up')['default']
Popup: typeof import('./components/Popup.vue')['default']
RandomColorTag: typeof import('./components/LogViewer/RandomColorTag.vue')['default']
RelativeTime: typeof import('./components/common/RelativeTime.vue')['default']
@@ -151,16 +185,19 @@ declare module 'vue' {
ScrollableView: typeof import('./components/ScrollableView.vue')['default']
ScrollProgress: typeof import('./components/ScrollProgress.vue')['default']
Search: typeof import('./components/Search.vue')['default']
SearchStatus: typeof import('./components/LogViewer/SearchStatus.vue')['default']
ServiceLog: typeof import('./components/ServiceViewer/ServiceLog.vue')['default']
SideDrawer: typeof import('./components/common/SideDrawer.vue')['default']
SideMenu: typeof import('./components/SideMenu.vue')['default']
SidePanel: typeof import('./components/SidePanel.vue')['default']
'SimpleIcons:podman': typeof import('~icons/simple-icons/podman')['default']
SimpleLogItem: typeof import('./components/LogViewer/SimpleLogItem.vue')['default']
SkippedEntriesLogItem: typeof import('./components/LogViewer/SkippedEntriesLogItem.vue')['default']
SlideTransition: typeof import('./components/common/SlideTransition.vue')['default']
SQLTable: typeof import('./components/LogViewer/SQLTable.vue')['default']
StackLog: typeof import('./components/StackViewer/StackLog.vue')['default']
StatMonitor: typeof import('./components/LogViewer/StatMonitor.vue')['default']
StatCard: typeof import('./components/LogViewer/StatCard.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']
@@ -168,7 +205,9 @@ 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']
VolumeWarning: typeof import('./components/ContainerViewer/VolumeWarning.vue')['default']
WebhookDestinationForm: typeof import('./components/Notification/WebhookDestinationForm.vue')['default']
WelcomeModal: typeof import('./components/WelcomeModal.vue')['default']
ZigZag: typeof import('./components/LogViewer/ZigZag.vue')['default']
}
}
+75
View File
@@ -0,0 +1,75 @@
/**
* @vitest-environment jsdom
*/
import { flushPromises, mount } from "@vue/test-utils";
import { describe, expect, test, vi } from "vitest";
import { nextTick } from "vue";
import BarChart, { type BarDataPoint } from "./BarChart.vue";
// useElementSize relies on ResizeObserver which jsdom lacks, so the width stays
// 0 and the chart never renders. Mock it with a controllable width ref that we
// flip to a real value after mount to mimic the ResizeObserver firing.
const holder = vi.hoisted(() => ({ width: null as ReturnType<typeof import("vue").ref<number>> | null }));
vi.mock("@vueuse/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@vueuse/core")>();
const { ref: vueRef } = await import("vue");
holder.width = vueRef(0);
return { ...actual, useElementSize: () => ({ width: holder.width, height: vueRef(0) }) };
});
function ramp(start = 0, n = 300): BarDataPoint[] {
return Array.from({ length: n }, (_, i) => ({ percent: start + i, value: start + i }));
}
function constant(percent: number, n = 300): BarDataPoint[] {
return Array.from({ length: n }, () => ({ percent, value: percent }));
}
function heightOf(wrapper: ReturnType<typeof mount>, index: number): number {
const style = wrapper.findAll(".bar")[index]?.attributes("style") ?? "";
const match = style.match(/--height:\s*([\d.]+)%/);
return match ? parseFloat(match[1]) : 0;
}
async function mountAndRender(chartData: BarDataPoint[]) {
// Mount with an unmeasured element, then simulate ResizeObserver reporting a
// real width -> triggers the initial recalculate, like the live component.
holder.width!.value = 0;
const wrapper = mount(BarChart, { props: { chartData } });
await nextTick();
holder.width!.value = 300;
await nextTick();
await flushPromises();
return wrapper;
}
describe("<BarChart />", () => {
test("exposed recalculate() rebuilds all bars after a wholesale data swap", async () => {
// First container: a ramp where the oldest bars are near zero.
const wrapper = await mountAndRender(ramp());
expect(heightOf(wrapper, 0)).toBeLessThan(20); // oldest ramp bar is tiny
// A stat tick arrives: the rolling window shifts by one, marking the chart
// initialized so further changes only patch the last bar.
await wrapper.setProps({ chartData: ramp(1) });
await nextTick();
// Switch containers: the whole series is replaced with a flat high value.
// The chart caches bars and only patches the last one, so without help the
// older bars stay stale.
await wrapper.setProps({ chartData: constant(1000) });
await nextTick();
expect(heightOf(wrapper, 0)).toBeLessThan(20); // still stale
// The parent owns container switches and calls recalculate() to refresh.
(wrapper.vm as unknown as { recalculate: () => void }).recalculate();
await nextTick();
expect(heightOf(wrapper, 0)).toBeGreaterThan(50); // flat series -> uniform height
});
test("renders downsampled bars once width is known", async () => {
const wrapper = await mountAndRender(constant(1000));
expect(wrapper.findAll(".bar").length).toBeGreaterThan(0);
expect(heightOf(wrapper, 0)).toBeGreaterThan(50);
});
});
+71 -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,98 @@ 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.
// A wholesale replacement of the series (e.g. switching containers) is not detected
// here; the parent owns that and must call the exposed recalculate() on switch.
const changeCounter = ref(0);
let initialized = false;
watch(
() => chartData,
() => chartData.at(-1),
() => {
// If changeCounter is -1, it means this is the first time the data is loaded
if (changeCounter.value === -1) {
if (!initialized) {
initialized = true;
recalculate();
return;
}
changeCounter.value++;
if (changeCounter.value >= bucketSize.value) {
// Recalculate when counter reaches bucket size
recalculate();
changeCounter.value = 0;
} else {
updateLastBar();
}
},
);
// Recalculate when width changes
watch([availableBars, bucketSize], () => {
recalculate();
changeCounter.value = -1;
});
defineExpose({ recalculate });
function averageBucket(bucket: BarDataPoint[]): BarDataPoint {
const percent = bucket.reduce((sum, d) => sum + d.percent, 0) / bucket.length;
const value = bucket.reduce((sum, d) => sum + d.value, 0) / bucket.length;
return { percent, value };
}
function recalculate() {
if (chartData.length <= availableBars.value || availableBars.value === 0) {
downsampledData.value = [...chartData];
if (availableBars.value === 0) return;
if (chartData.length <= availableBars.value) {
downsampledBars.value = [...chartData];
return;
}
const size = bucketSize.value;
const result = [];
const result: BarDataPoint[] = [];
const numBuckets = Math.ceil(chartData.length / size);
// Create complete buckets
const numCompleteBuckets = Math.floor(chartData.length / size);
for (let i = 0; i < numCompleteBuckets; i++) {
for (let i = 0; i < numBuckets; i++) {
const start = i * size;
const end = start + size;
const bucket = chartData.slice(start, end);
const avg = bucket.reduce((sum, val) => sum + val, 0) / bucket.length;
result.push(avg);
const end = Math.min(start + size, chartData.length);
result.push(averageBucket(chartData.slice(start, end)));
}
// Show only the last N bars that fit on screen
downsampledData.value = result.slice(-availableBars.value);
downsampledBars.value = result.slice(-availableBars.value);
}
function updateLastBar() {
if (downsampledBars.value.length === 0) return;
const size = bucketSize.value;
const lastBucketStart = (Math.ceil(chartData.length / size) - 1) * size;
const bucket = chartData.slice(lastBucketStart);
downsampledBars.value[downsampledBars.value.length - 1] = averageBucket(bucket);
}
function onContainerHover(event: MouseEvent) {
if (!chartContainer.value) return;
const rect = chartContainer.value.getBoundingClientRect();
const x = event.clientX - rect.left;
const bars = chartContainer.value.children;
if (bars.length === 0) return;
// Calculate which bar the mouse is over based on position
const barWidth = width.value / downsampledData.value.length;
const index = Math.floor(x / barWidth);
const mouseX = event.clientX;
let index = 0;
// Ensure index is within bounds
if (index < 0 || index >= downsampledData.value.length) return;
// Find the bar whose column contains the mouse x position
for (let i = 0; i < bars.length; i++) {
const rect = bars[i].getBoundingClientRect();
if (mouseX >= rect.left) {
index = i;
} else {
break;
}
}
// Map downsampled index back to original data index range
const numCompleteBuckets = Math.floor(chartData.length / bucketSize.value);
const offset = Math.max(0, numCompleteBuckets - availableBars.value);
const startIndex = (offset + index) * bucketSize.value;
const endIndex = Math.min(startIndex + bucketSize.value - 1, chartData.length - 1);
hoverIndex(startIndex, endIndex);
hoverValue(downsampledBars.value[index].value);
}
</script>
+158
View File
@@ -0,0 +1,158 @@
<template>
<Dropdown class="dropdown-end" @click="onOpen">
<template #trigger>
<div class="relative">
<mdi:cloud
class="size-6"
:class="
!cloudConfig
? 'text-base-content/40'
: cloudConfig.linked && !cloudStatusError
? 'text-info'
: cloudStatusError === 'unavailable'
? 'text-warning'
: 'text-error'
"
/>
<span
v-if="cloudConfig?.linked"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full"
:class="
cloudStatusError === 'auth'
? 'bg-error'
: cloudStatusError === 'unavailable'
? 'bg-warning'
: cloudStatusError
? 'bg-error'
: 'bg-success'
"
></span>
</div>
</template>
<template #content>
<div class="w-80 space-y-3 p-1">
<!-- Not linked -->
<template v-if="!cloudConfig">
<div class="flex flex-col items-center gap-2 p-2 text-center">
<mdi:cloud class="text-base-content/40 text-4xl" />
<h3 class="text-base font-bold">{{ $t("cloud.title") }}</h3>
<p class="text-base-content/60 text-sm">{{ $t("cloud.description") }}</p>
<div class="mt-2 flex w-full gap-2">
<a :href="`${cloudUrl}`" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
{{ $t("cloud.learn-more") }}
</a>
<a :href="cloudLinkUrl" class="btn btn-primary btn-sm flex-1">
<mdi:link-variant class="text-base" />
{{ $t("cloud.link-instance") }}
</a>
</div>
</div>
</template>
<!-- Linked -->
<template v-else-if="cloudConfig.linked">
<!-- Error state -->
<div v-if="cloudStatusError" class="space-y-3">
<div class="alert" :class="cloudStatusError === 'auth' ? 'alert-error' : 'alert-warning'">
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
<mdi:cloud-off-outline v-else class="text-lg" />
<span class="text-sm">{{
cloudStatusError === "auth" ? $t("cloud.error") : $t("cloud.error-unavailable")
}}</span>
</div>
<a v-if="cloudStatusError === 'auth'" :href="cloudLinkUrl" class="btn btn-primary btn-sm w-full">
<mdi:link-variant class="text-base" />
{{ $t("cloud.relink-instance") }}
</a>
<button v-else class="btn btn-sm w-full" @click="fetchCloudStatus">
<mdi:refresh class="text-base" />
{{ $t("button.retry") }}
</button>
</div>
<!-- Loading -->
<div v-else-if="isLoadingCloudStatus" class="flex items-center justify-center gap-2 py-4">
<span class="loading loading-spinner loading-xs"></span>
</div>
<!-- Healthy -->
<div v-else-if="cloudStatus" class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="font-bold">{{ $t("cloud.title") }}</h3>
<div class="flex items-center gap-1">
<span class="badge badge-success badge-sm">{{ $t("cloud.connected") }}</span>
<span class="badge badge-primary badge-sm capitalize">{{ cloudStatus.plan.name }}</span>
</div>
</div>
<div>
<div class="mb-1 flex items-center justify-between text-sm">
<span class="text-base-content/60">{{ $t("cloud.usage") }}</span>
<span>
{{ cloudStatus.usage.events_used.toLocaleString() }} /
{{ cloudStatus.usage.events_limit.toLocaleString() }}
</span>
</div>
<progress
class="progress w-full"
:class="
usagePercent > 90 ? 'progress-error' : usagePercent > 70 ? 'progress-warning' : 'progress-primary'
"
:value="cloudStatus.usage.events_used"
:max="cloudStatus.usage.events_limit"
></progress>
</div>
<div class="flex gap-2">
<a :href="cloudUrl" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
{{ $t("cloud.dashboard") }}
</a>
<a :href="`${cloudUrl}/settings`" target="_blank" rel="noreferrer noopener" class="btn btn-sm flex-1">
{{ $t("cloud.settings") }}
</a>
</div>
</div>
</template>
</div>
</template>
</Dropdown>
<WelcomeModal ref="welcomeModal" />
</template>
<script lang="ts" setup>
const cloudUrl = __CLOUD_URL__;
const callbackUrl = `${window.location.origin}${withBase("/")}`;
const cloudLinkUrl = `${cloudUrl}/link?appUrl=${encodeURIComponent(callbackUrl)}&from=cloud`;
const { cloudConfig, cloudStatus, cloudStatusError, isLoadingCloudStatus, fetchCloudConfig, fetchCloudStatus } =
useCloudConfig();
const welcomeModal = ref<{ open: () => void }>();
const cloudWelcomeShown = useProfileStorage("cloudWelcomeShown", false);
const usagePercent = computed(() => {
if (!cloudStatus.value) return 0;
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
});
function onOpen() {
if (cloudConfig.value?.linked && !cloudStatus.value && !isLoadingCloudStatus.value) {
fetchCloudStatus();
}
}
onMounted(async () => {
await fetchCloudConfig();
if (cloudConfig.value?.linked) {
fetchCloudStatus();
}
// Handle successful OAuth return — show welcome modal
if (window.location.hash === "#cloudLinked" && !cloudWelcomeShown.value) {
cloudWelcomeShown.value = true;
nextTick(() => welcomeModal.value?.open());
history.replaceState(history.state, "", window.location.pathname + window.location.search);
}
});
</script>
+35
View File
@@ -0,0 +1,35 @@
<template>
<button
type="button"
data-testid="search"
class="bg-base-200 border-base-content/15 hover:border-primary/50 hover:bg-base-200/80 flex h-9 w-full items-center gap-2 rounded-md border px-3 text-left transition-colors"
@click="openSearch"
>
<mdi:magnify class="size-4 shrink-0" :class="cloudReady ? 'text-primary' : 'text-base-content/60'" />
<!-- Show the active query when we're on the cloud search page so the
topbar reflects what the user is looking at. -->
<span v-if="activeQuery" class="text-base-content truncate font-mono text-sm">{{ activeQuery }}</span>
<span v-else class="text-base-content/60 truncate text-sm">
<template v-if="cloudReady">{{ $t("cloud-search.hero-title-cloud") }}</template>
<template v-else>{{ $t("cloud-search.hero-title-plain") }}</template>
</span>
<span class="ml-auto flex items-center gap-1">
<kbd class="kbd kbd-xs"></kbd>
<kbd class="kbd kbd-xs">K</kbd>
</span>
</button>
</template>
<script lang="ts" setup>
import { useFuzzySearch } from "@/composable/fuzzySearch";
import { useCloudConfig } from "@/composable/cloudConfig";
const { openSearch } = useFuzzySearch();
const { cloudConfig } = useCloudConfig();
const cloudReady = computed(() => !!cloudConfig.value?.linked && !!cloudConfig.value?.streamLogs);
const route = useRoute();
const activeQuery = computed(() =>
route?.path === "/cloud/search" && typeof route.query?.q === "string" ? route.query.q : "",
);
</script>
+207
View File
@@ -0,0 +1,207 @@
<template>
<div class="border-base-content/15 bg-base-200/40 divide-base-content/10 divide-y rounded-lg border">
<!-- Not linked -->
<template v-if="!cloudConfig">
<div class="flex items-start gap-4 p-4">
<mdi:cloud class="text-base-content/40 mt-0.5 text-4xl" />
<div class="flex flex-col gap-1">
<p class="text-base-content/70 text-sm">{{ $t("cloud.description") }}</p>
<div class="mt-3 flex gap-2">
<a :href="`${cloudUrl}`" target="_blank" rel="noreferrer noopener" class="btn btn-sm">
{{ $t("cloud.learn-more") }}
</a>
<a :href="cloudLinkUrl" class="btn btn-primary btn-sm">
<mdi:link-variant class="text-base" />
{{ $t("cloud.link-instance") }}
</a>
</div>
</div>
</div>
</template>
<!-- Linked -->
<template v-else-if="cloudConfig.linked">
<!-- Error state -->
<div v-if="cloudStatusError" class="space-y-3 p-4">
<div class="alert" :class="cloudStatusError === 'auth' ? 'alert-error' : 'alert-warning'">
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
<mdi:cloud-off-outline v-else class="text-lg" />
<span class="text-sm">{{
cloudStatusError === "auth" ? $t("cloud.error") : $t("cloud.error-unavailable")
}}</span>
</div>
<div class="flex gap-2">
<a v-if="cloudStatusError === 'auth'" :href="cloudLinkUrl" class="btn btn-primary btn-sm">
<mdi:link-variant class="text-base" />
{{ $t("cloud.relink-instance") }}
</a>
<button v-else class="btn btn-sm" @click="fetchCloudStatus">
<mdi:refresh class="text-base" />
{{ $t("button.retry") }}
</button>
<button class="btn btn-sm btn-error" @click="confirmUnlink">
<mdi:link-variant-off class="text-base" />
{{ $t("cloud.unlink") }}
</button>
</div>
</div>
<!-- Loading -->
<div v-else-if="isLoadingCloudStatus" class="flex items-center gap-2 p-4">
<span class="loading loading-spinner loading-sm"></span>
</div>
<!-- Healthy -->
<template v-else-if="cloudStatus">
<div class="flex flex-wrap items-center gap-2 p-4">
<span class="status-pill status-pill-success">
<span class="size-1.5 rounded-full bg-current"></span>
{{ $t("cloud.connected") }}
</span>
<span class="status-pill status-pill-primary">{{ cloudStatus.plan.name }}</span>
<span class="text-base-content/50 text-sm">{{ cloudStatus.user.email }}</span>
</div>
<div class="flex flex-col gap-2 p-4">
<div class="flex items-baseline justify-between">
<span class="text-base-content/60 text-sm font-medium">{{ $t("cloud.usage") }}</span>
<span class="font-mono text-sm">
<span class="font-semibold">{{ cloudStatus.usage.events_used.toLocaleString() }}</span>
<span class="text-base-content/40"> / {{ cloudStatus.usage.events_limit.toLocaleString() }}</span>
</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 class="text-base-content/40 flex justify-between font-mono text-xs">
<span v-if="cloudStatus.usage.period">{{ cloudStatus.usage.period }}</span>
<span v-else></span>
<span>{{ usagePercent.toFixed(2) }}% used</span>
</div>
</div>
<label class="flex min-h-13 cursor-pointer items-center justify-between gap-4 p-4">
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium">{{ $t("cloud.stream-logs") }}</span>
<span class="text-base-content/60 text-xs">{{ $t("cloud.stream-logs-help") }}</span>
</div>
<input
type="checkbox"
class="toggle toggle-primary toggle-sm shrink-0"
:checked="streamLogs"
:disabled="isSavingStreamLogs"
@change="onStreamLogsChange(($event.target as HTMLInputElement).checked)"
/>
</label>
<div class="flex gap-2 p-4">
<a :href="cloudUrl" target="_blank" rel="noreferrer noopener" class="btn btn-sm">
{{ $t("cloud.dashboard") }}
</a>
<button class="btn btn-sm btn-error" @click="confirmUnlink">
{{ $t("cloud.unlink") }}
</button>
</div>
</template>
</template>
<!-- Unlink confirmation modal -->
<dialog ref="unlinkModal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">{{ $t("cloud.unlink") }}</h3>
<p class="py-4 text-sm">{{ $t("cloud.unlink-confirm") }}</p>
<div class="modal-action">
<form method="dialog">
<button class="btn btn-sm">{{ $t("button.cancel") }}</button>
</form>
<button class="btn btn-error btn-sm" :disabled="isUnlinking" @click="doUnlink">
<span v-if="isUnlinking" class="loading loading-spinner loading-xs"></span>
{{ $t("cloud.unlink") }}
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button></button>
</form>
</dialog>
</div>
</template>
<script lang="ts" setup>
const cloudUrl = __CLOUD_URL__;
const callbackUrl = `${window.location.origin}${withBase("/")}`;
const cloudLinkUrl = `${cloudUrl}/link?appUrl=${encodeURIComponent(callbackUrl)}&from=cloud`;
const {
cloudConfig,
cloudStatus,
cloudStatusError,
isLoadingCloudStatus,
initialLoad,
fetchCloudStatus,
clearCloudState,
} = useCloudConfig();
const isUnlinking = ref(false);
const unlinkModal = ref<HTMLDialogElement | null>(null);
const streamLogs = ref(true);
const isSavingStreamLogs = ref(false);
watchEffect(() => {
if (cloudConfig.value) streamLogs.value = cloudConfig.value.streamLogs;
});
async function onStreamLogsChange(value: boolean | undefined) {
if (!cloudConfig.value || value === undefined) return;
isSavingStreamLogs.value = true;
try {
const res = await fetch(withBase("/api/cloud/config"), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ streamLogs: value }),
});
if (!res.ok) {
streamLogs.value = !value;
return;
}
cloudConfig.value.streamLogs = value;
} catch {
streamLogs.value = !value;
} finally {
isSavingStreamLogs.value = false;
}
}
const usagePercent = computed(() => {
if (!cloudStatus.value) return 0;
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
});
function confirmUnlink() {
unlinkModal.value?.showModal();
}
async function doUnlink() {
isUnlinking.value = true;
try {
const res = await fetch(withBase("/api/cloud/config"), { method: "DELETE" });
if (!res.ok) {
cloudStatusError.value = "unavailable";
return;
}
clearCloudState();
unlinkModal.value?.close();
} finally {
isUnlinking.value = false;
}
}
onMounted(async () => {
await initialLoad;
if (cloudConfig.value?.linked) {
fetchCloudStatus();
}
});
</script>
+2 -1
View File
@@ -5,11 +5,12 @@
<li v-for="other in containers">
<router-link :to="{ name: '/container/[id]', params: { id: other.id } }" class="text-nowrap">
<div
class="status data-[state=exited]:status-error data-[state=running]:status-success"
class="status data-[state=exited]:status-error data-[state=running]:status-success data-[state=paused]:status-warning"
:data-state="other.state"
></div>
{{ other.name }}
<div v-if="other.state === 'running'">running</div>
<div v-else-if="other.state === 'paused'">paused</div>
<RelativeTime :date="other.finishedAt" class="text-base-content/70 text-xs" v-else />
</router-link>
</li>
+112
View File
@@ -0,0 +1,112 @@
/**
* @vitest-environment jsdom
*/
import { mount } from "@vue/test-utils";
import { describe, expect, test, vi } from "vitest";
import ContainerStatCell from "./ContainerStatCell.vue";
import { Container, emptyStat, type Stat } from "@/models/Container";
import type { Host } from "@/stores/hosts";
vi.mock("@/stores/config", () => ({
__esModule: true,
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
withBase: (path: string) => path,
}));
function makeStat(partial: Partial<Stat> = {}): Stat {
return { ...emptyStat(), ...partial };
}
function makeContainer(lastStat: Partial<Stat>, cpuLimit = 0): Container {
return new Container(
"id-1",
new Date(),
new Date(),
new Date(),
"image",
"name",
"command",
"localhost",
{},
"running",
cpuLimit,
0,
[makeStat(lastStat)],
);
}
function host(nCPU?: number): Host {
return { id: "localhost", name: "localhost", nCPU } as unknown as Host;
}
function mountCell(props: { container: Container; type: "cpu" | "mem"; host: Host; mode?: "chart" | "progress" }) {
return mount(ContainerStatCell, {
props: { mode: "progress", ...props },
global: { stubs: { BarChart: true } },
});
}
describe("<ContainerStatCell /> cpu", () => {
test("normalizes by cpuLimit when set", () => {
const wrapper = mountCell({ container: makeContainer({ cpu: 100 }, 2), type: "cpu", host: host(8) });
expect(wrapper.find(".tabular-nums").text()).toBe("50%");
expect(wrapper.find("progress").attributes("value")).toBe("50");
expect(wrapper.find("progress").classes()).toContain("progress-success");
});
test("falls back to host nCPU when no cpuLimit", () => {
const wrapper = mountCell({ container: makeContainer({ cpu: 360 }, 0), type: "cpu", host: host(4) });
expect(wrapper.find(".tabular-nums").text()).toBe("90%");
expect(wrapper.find("progress").classes()).toContain("progress-warning");
});
test("falls back to a single core and clamps at 100%", () => {
const wrapper = mountCell({ container: makeContainer({ cpu: 200 }, 0), type: "cpu", host: host(undefined) });
expect(wrapper.find(".tabular-nums").text()).toBe("100%");
expect(wrapper.find("progress").classes()).toContain("progress-error");
});
});
describe("<ContainerStatCell /> memory", () => {
test("shows absolute usage and percentage-based color", () => {
const wrapper = mountCell({
container: makeContainer({ memory: 40, memoryUsage: 1500 }),
type: "mem",
host: host(4),
});
expect(wrapper.find(".tabular-nums").text()).toBe("1.46 KB");
expect(wrapper.find("progress").attributes("value")).toBe("40");
expect(wrapper.find("progress").classes()).toContain("progress-success");
});
});
describe("<ContainerStatCell /> color thresholds", () => {
test.each([
[50, "bg-success"],
[70, "bg-secondary"],
[90, "bg-warning"],
[95, "bg-error"],
])("memory %i%% -> %s", (memory, expected) => {
const wrapper = mountCell({ container: makeContainer({ memory }), type: "mem", host: host(4) });
expect((wrapper.vm as unknown as { barClass: string }).barClass).toBe(expected);
});
});
describe("<ContainerStatCell /> chart data", () => {
test("cpu series is normalized by cores", () => {
const wrapper = mountCell({ container: makeContainer({ cpu: 100 }, 4), type: "cpu", host: host(8) });
const chartData = (wrapper.vm as unknown as { chartData: { percent: number; value: number }[] }).chartData;
expect(chartData).toHaveLength(300);
expect(chartData.at(-1)).toEqual({ percent: 25, value: 100 });
});
test("memory series uses percent and absolute usage", () => {
const wrapper = mountCell({
container: makeContainer({ memory: 30, memoryUsage: 2048 }),
type: "mem",
host: host(4),
});
const chartData = (wrapper.vm as unknown as { chartData: { percent: number; value: number }[] }).chartData;
expect(chartData.at(-1)).toEqual({ percent: 30, value: 2048 });
});
});
+42 -7
View File
@@ -1,23 +1,44 @@
<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>
<div
v-if="isMobile"
class="flex w-fit items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium tabular-nums"
:class="type === 'cpu' ? 'bg-primary/10 text-primary' : 'bg-secondary/10 text-secondary'"
>
<component :is="type === 'cpu' ? PhCpu : PhMemory" class="size-3.5 shrink-0" />
<span>{{ displayValue }}</span>
</div>
<div v-else class="flex flex-row items-center gap-2">
<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>
<script setup lang="ts">
import type { Container } from "@/models/Container";
import type { Host } from "@/stores/hosts";
import PhCpu from "~icons/ph/cpu";
import PhMemory from "~icons/ph/memory";
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 +46,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 +79,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>
+45 -17
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">
@@ -57,7 +75,7 @@
v-show="isVisible(key)"
>
<a class="inline-flex cursor-pointer gap-2 text-sm uppercase">
<span>{{ $t(value.label) }}</span>
<span>{{ $t(isMobile && value.mobileLabel ? value.mobileLabel : value.label) }}</span>
<span class="h-4" data-icon>
<mdi:arrow-up />
</span>
@@ -66,8 +84,13 @@
</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!">
<td v-if="isVisible('name')" class="max-w-80 truncate">
<tr
v-for="container in paginated"
:key="container.id"
v-memo="[container.id, statMode, isMobile]"
class="hover:bg-base-100/80!"
>
<td v-if="isVisible('name')" class="max-w-80 truncate max-md:max-w-32">
<router-link :to="{ name: '/container/[id]', params: { id: container.id } }" :title="container.name">
{{ container.name }}
</router-link>
@@ -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>
@@ -119,6 +142,7 @@ const fields: Record<
string,
{
label: string;
mobileLabel?: string;
sortFunc: (a: Container, b: Container) => number;
mobileVisible: boolean;
customClass?: string;
@@ -126,6 +150,7 @@ const fields: Record<
> = {
name: {
label: "label.container-name",
mobileLabel: "label.name",
sortFunc: (a: Container, b: Container) => a.name.localeCompare(b.name) * direction.value,
mobileVisible: true,
},
@@ -144,21 +169,23 @@ const fields: Record<
created: {
label: "label.created",
sortFunc: (a: Container, b: Container) => (a.created.getTime() - b.created.getTime()) * direction.value,
mobileVisible: true,
mobileVisible: false,
customClass: "w-1",
},
cpu: {
label: "label.avg-cpu",
mobileLabel: "label.cpu",
sortFunc: (a: Container, b: Container) => (a.movingAverage.cpu - b.movingAverage.cpu) * direction.value,
mobileVisible: false,
customClass: "min-w-48",
mobileVisible: true,
customClass: "min-w-48 max-md:min-w-0",
},
mem: {
label: "label.avg-mem",
mobileLabel: "label.mem",
sortFunc: (a: Container, b: Container) =>
(a.movingAverage.memoryUsage - b.movingAverage.memoryUsage) * direction.value,
mobileVisible: false,
customClass: "min-w-48",
mobileVisible: true,
customClass: "min-w-48 max-md:min-w-0",
},
};
@@ -167,6 +194,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];
@@ -18,7 +18,7 @@
<li v-if="!historical">
<a @click="clear()">
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
<KeyShortcut char="l" :modifiers="['shift', 'meta']" />
</a>
</li>
<li v-if="hasComplexLogs">
@@ -154,6 +154,12 @@
{{ $t("toolbar.restart") }}
</button>
</li>
<li>
<button @click="update()" :disabled="actionStates.update">
<carbon:upgrade />
{{ container.isSwarm ? $t("toolbar.update-service") : $t("toolbar.update") }}
</button>
</li>
</template>
<template v-if="enableShell && !historical">
@@ -190,10 +196,10 @@ 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 { copy, copied, isSupported } = useClipboard({ legacy: true });
const { t } = useI18n();
const { showToast, removeToast } = useToast();
@@ -293,7 +299,7 @@ async function copyLogs() {
await navigator.clipboard.write([new ClipboardItem({ "text/plain": blobPromise })]);
}
onKeyStroke("f", (e) => {
onKeyStroke(["f", "F"], (e) => {
if (hasComplexLogs.value) {
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
showDrawer(LogAnalytics, { container }, "lg");
@@ -302,14 +308,14 @@ onKeyStroke("f", (e) => {
}
});
if (enableShell) {
onKeyStroke("a", (e) => {
onKeyStroke(["a", "A"], (e) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
showDrawer(Terminal, { container, action: "attach" }, "lg");
e.preventDefault();
}
});
onKeyStroke("e", (e) => {
onKeyStroke(["e", "E"], (e) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
showDrawer(Terminal, { container, action: "exec" }, "lg");
e.preventDefault();
@@ -360,10 +366,25 @@ a {
@apply whitespace-nowrap;
}
/* daisyUI's .menu is width: fit-content, so nested submenus (Streams, Levels)
* shrink to their content and the hover highlight stops short. Stretch them to
* fill the dropdown so the row highlight spans the full width. */
.menu li ul {
margin-inline-start: 0;
width: 100%;
&:before {
display: none;
}
}
/* Keep the solid level colors, but use white labels in the light theme so the
* text reads against the saturated chip backgrounds. warn is a light orange,
* where dark text has better contrast than white, so it keeps the default. */
[data-theme="light"] .badge[data-level="info"],
[data-theme="light"] .badge[data-level="debug"],
[data-theme="light"] .badge[data-level="trace"],
[data-theme="light"] .badge[data-level="error"],
[data-theme="light"] .badge[data-level="fatal"] {
color: oklch(100% 0 0) !important;
}
</style>
@@ -11,8 +11,10 @@
<li v-if="config.hosts.length > 1" class="font-thin max-md:hidden">
{{ container.hostLabel }}
</li>
<li>
<template v-if="otherContainers.length === 0">{{ container.name }}</template>
<li class="min-w-0">
<template v-if="otherContainers.length === 0"
><span class="block truncate">{{ container.name }}</span></template
>
<div v-else>
<div class="dropdown">
<button tabindex="0" role="button" class="btn btn-xs md:btn-sm">
@@ -25,12 +27,13 @@
<li v-for="other in otherContainers">
<router-link :to="{ name: '/container/[id]', params: { id: other.id } }">
<div
class="status data-[state=exited]:status-error data-[state=running]:status-success"
class="status data-[state=exited]:status-error data-[state=running]:status-success data-[state=paused]:status-warning"
:data-state="other.state"
></div>
<div v-if="other.isSwarm">{{ other.swarmId }}</div>
<div v-else>{{ other.name }}</div>
<div v-if="other.state === 'running'">running</div>
<div v-else-if="other.state === 'paused'">paused</div>
<RelativeTime :date="other.finishedAt" class="text-base-content/70 text-xs" v-else />
</router-link>
</li>
@@ -42,8 +45,21 @@
</div>
</div>
<ContainerHealth :health="container.health" v-if="container.health" />
<Tag class="hidden! font-mono @xl:block!" size="small">
{{ container.image.replace(/@sha.*/, "") }}
<VolumeWarning :container="container" />
<Tag
class="group hidden! cursor-pointer items-center gap-1.5 pr-1! font-mono @md:inline-flex!"
size="small"
role="button"
:title="$t('toolbar.copy-image')"
:aria-label="$t('toolbar.copy-image')"
@click="copyImage"
>
<span class="truncate">{{ imageTag }}</span>
<span
class="bg-base-content/10 text-base-content/40 group-hover:text-base-content/70 flex size-4 shrink-0 items-center justify-center rounded-sm transition-colors"
>
<mdi:content-copy class="size-3" />
</span>
</Tag>
</div>
</template>
@@ -52,6 +68,21 @@
import { Container } from "@/models/Container";
const { container } = defineProps<{ container: Container }>();
const { t } = useI18n();
const { copy, copied, isSupported } = useClipboard({ legacy: true });
const { showToast } = useToast();
const imageTag = computed(() => container.image.replace(/@sha.*/, ""));
async function copyImage() {
if (!isSupported.value) return;
await copy(imageTag.value);
if (copied.value) {
showToast({ title: t("toasts.copied.title"), message: t("toasts.copied.message"), type: "info" }, { expire: 2000 });
}
}
const pinned = computed({
get: () => pinnedContainers.value.has(container.name),
set: (value) => {
@@ -0,0 +1,113 @@
<template>
<div v-if="worst" class="dropdown dropdown-end" :class="{ 'dropdown-bottom': !openUp }">
<button
tabindex="0"
role="button"
class="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium tabular-nums"
:class="badgeClass"
:title="title"
>
<PhWarning class="size-3.5" />
<span class="max-md:hidden">{{ worst.destination }}</span>
<span>{{ formatPct(worst.pct) }}</span>
</button>
<div
tabindex="0"
class="dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 mt-1 w-72 border p-2 text-xs shadow-sm"
>
<div class="text-base-content/60 mb-1.5 px-1 text-[11px] tracking-wide uppercase">{{ t("tooltip.volumes") }}</div>
<ul class="space-y-1.5">
<li
v-for="m in sortedMounts"
:key="m.destination"
class="flex flex-col gap-1 rounded p-1.5"
:class="rowClass(m.pct, m.available)"
>
<div class="flex items-baseline justify-between gap-2">
<span class="truncate font-mono text-[11.5px]" :title="m.destination">{{ m.destination }}</span>
<span v-if="m.available" class="tabular-nums">{{ formatPct(m.pct) }}</span>
<span v-else class="text-base-content/50">n/a</span>
</div>
<div v-if="m.available" class="bg-base-content/10 h-1 w-full overflow-hidden rounded">
<div class="h-full" :class="barClass(m.pct)" :style="{ width: Math.min(100, m.pct * 100) + '%' }"></div>
</div>
<div class="text-base-content/60 flex justify-between tabular-nums">
<span v-if="m.available">{{ formatBytes(m.used) }} / {{ formatBytes(m.total) }}</span>
<span v-else>{{ t("tooltip.volume-unreachable") }}</span>
<RelativeTime v-if="m.lastChecked" :date="m.lastChecked" class="text-[10.5px]" />
</div>
</li>
</ul>
</div>
</div>
</template>
<script lang="ts" setup>
import { Container } from "@/models/Container";
import PhWarning from "~icons/ph/warning-fill";
const WARN = 0.85;
const CRITICAL = 0.95;
const { container } = defineProps<{ container: Container; openUp?: boolean }>();
const { t } = useI18n();
interface DerivedMount {
destination: string;
total: number;
used: number;
free: number;
available: boolean;
pct: number;
lastChecked?: Date;
}
const mounts = computed<DerivedMount[]>(() => {
const raw = container.mountStats ?? {};
return Object.values(raw).map((m) => ({
destination: m.destination,
total: m.total,
used: m.used,
free: m.free,
available: m.available && m.total > 0,
pct: m.available && m.total > 0 ? m.used / m.total : 0,
lastChecked: m.lastChecked ? new Date(m.lastChecked) : undefined,
}));
});
const sortedMounts = computed(() => [...mounts.value].sort((a, b) => b.pct - a.pct));
const worst = computed(() => {
const candidate = sortedMounts.value.find((m) => m.available && m.pct >= WARN);
return candidate ?? null;
});
const badgeClass = computed(() => {
if (!worst.value) return "";
if (worst.value.pct >= CRITICAL) return "bg-error/15 text-error hover:bg-error/25";
return "bg-warning/15 text-warning hover:bg-warning/25";
});
function rowClass(pct: number, available: boolean) {
if (!available) return "bg-base-content/[0.04]";
if (pct >= CRITICAL) return "bg-error/10";
if (pct >= WARN) return "bg-warning/10";
return "bg-base-content/[0.04]";
}
function barClass(pct: number) {
if (pct >= CRITICAL) return "bg-error";
if (pct >= WARN) return "bg-warning";
return "bg-success";
}
function formatPct(pct: number) {
return `${Math.round(pct * 100)}%`;
}
const title = computed(() => {
if (!worst.value) return "";
return t("tooltip.volume-full", { destination: worst.value.destination, pct: formatPct(worst.value.pct) });
});
</script>
+27 -2
View File
@@ -4,6 +4,7 @@ import { mount } from "@vue/test-utils";
import FuzzySearchModal from "./FuzzySearchModal.vue";
import { Container } from "@/models/Container";
import { lightTheme } from "@/stores/settings";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createI18n } from "vue-i18n";
import { useRouter } from "vue-router";
@@ -16,7 +17,7 @@ vi.mock("vue-router");
vi.mock("@/stores/config", () => ({
__esModule: true,
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }], enableActions: true },
withBase: (path: string) => path,
}));
@@ -109,7 +110,7 @@ describe("<FuzzySearchModal />", () => {
await wrapper.find("input").setValue("foo");
expect(wrapper.findAll("li").length).toBe(1);
expect(wrapper.find("ul [data-name]").html()).toMatchInlineSnapshot(
`"<span data-v-dc2e8c61="" data-name=""><mark>foo</mark> bar</span>"`,
`"<span data-v-dc2e8c61="" class="text-base-content" data-name=""><mark>foo</mark> bar</span>"`,
);
});
@@ -119,4 +120,28 @@ describe("<FuzzySearchModal />", () => {
await wrapper.find("input").trigger("keydown.enter");
expect(useRouter().push).toHaveBeenCalledWith({ name: "/container/[id]", params: { id: "567" } });
});
test("matches commands by keyword", async () => {
const wrapper = createFuzzySearchModal();
await wrapper.find("input").setValue("theme");
const items = wrapper.findAll("li").map((li) => li.text());
expect(items).toContain("command-palette.theme-dark");
});
test("theme commands set the theme explicitly", async () => {
lightTheme.value = "auto";
const wrapper = createFuzzySearchModal();
await wrapper.find("input").setValue("dark theme");
await wrapper.find("input").trigger("keydown.enter");
expect(lightTheme.value).toBe("dark");
await wrapper.find("input").setValue("light theme");
await wrapper.find("input").trigger("keydown.enter");
expect(lightTheme.value).toBe("light");
await wrapper.find("input").setValue("system theme");
await wrapper.find("input").trigger("keydown.enter");
expect(lightTheme.value).toBe("auto");
});
});
+273 -60
View File
@@ -1,70 +1,188 @@
<template>
<div class="dropdown dropdown-open w-full shadow-md">
<div class="input input-xl input-primary flex w-full items-center">
<mdi:magnify class="flex size-8" />
<!-- Single bordered card containing the input, results, and footer in one
frame to match the design mock. No daisyUI input/dropdown chrome. -->
<div class="bg-base-200 border-base-content/15 w-full overflow-hidden rounded-xl border shadow-2xl">
<!-- Input row -->
<div class="flex items-center gap-3 px-4 py-3.5">
<mdi:magnify class="text-base-content/60 size-5 shrink-0" />
<input
tabindex="0"
class="input-ghost flex-1 px-1"
class="text-base-content placeholder:text-base-content/40 flex-1 bg-transparent text-base outline-none"
ref="input"
@keydown.down="selectedIndex = Math.min(selectedIndex + 1, data.length - 1)"
@keydown.down="selectedIndex = Math.min(selectedIndex + 1, totalCount - 1)"
@keydown.up="selectedIndex = Math.max(selectedIndex - 1, 0)"
@keydown.enter.exact="selected(data[selectedIndex].item)"
@keydown.alt.enter="addColumn(data[selectedIndex].item)"
@keydown.enter.exact="onEnter"
@keydown.shift.enter.exact.prevent="runLogSearch"
@keydown.alt.enter.exact.prevent="onPin"
v-model="query"
:placeholder="$t('placeholder.search-containers')"
:placeholder="placeholderCopy"
/>
<form method="dialog" class="flex">
<button v-if="isMobile">
<mdi:close />
<button v-if="isMobile" class="text-base-content/50 hover:text-base-content">
<mdi:close class="size-5" />
</button>
<button v-else class="swap hover:swap-active outline-hidden">
<mdi:keyboard-esc class="swap-off" />
<mdi:close class="swap-on" />
<button v-else>
<kbd class="kbd kbd-xs">esc</kbd>
</button>
</form>
</div>
<div
class="dropdown-content bg-base-100 relative! mt-2 max-h-[calc(100dvh-20rem)] w-full overflow-y-scroll rounded-md border-y-8 border-transparent px-2"
tabindex="0"
v-if="results.length"
>
<ul class="menu w-auto">
<li v-for="(result, index) in data" ref="listItems">
<a
class="grid auto-cols-max grid-cols-[min-content_auto] gap-2 py-4"
@click.prevent="selected(result.item)"
:class="{ 'menu-focus': index === selectedIndex }"
>
<div :class="{ 'text-primary': result.item.state === 'running' }">
<template v-if="result.item.type === 'container'">
<octicon:container-24 />
</template>
<template v-else-if="result.item.type === 'service'">
<ph:stack-simple />
</template>
<template v-else-if="result.item.type === 'stack'">
<ph:stack />
</template>
</div>
<div class="truncate">
<template v-if="config.hosts.length > 1 && result.item.host">
<span class="font-light">{{ result.item.host }}</span> /
</template>
<span data-name v-html="matchedName(result)"></span>
</div>
<RelativeTime :date="result.item.created" class="text-xs font-light" />
<span
@click.stop.prevent="addColumn(result.item)"
:title="$t('tooltip.pin-column')"
class="hover:text-secondary"
<!-- Body: results + log search CTA. Only renders when there is something
to show keeps the empty modal compact. -->
<div v-if="totalCount || logSearchVisible" class="border-base-content/10 border-t">
<!-- Scroll container spans both sections so the flat Commands + Containers
list scrolls as one, matching the unified selection index. -->
<div class="max-h-[50vh] overflow-y-auto overscroll-contain">
<!-- Commands section -->
<template v-if="commandEntries.length">
<div class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase">
{{ $t("command-palette.section-commands") }} · {{ commandEntries.length }}
</div>
<ul class="pb-1">
<li v-for="(command, index) in commandEntries" :ref="(el) => setItemRef(el, index)">
<a
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
:class="{ 'bg-base-content/10': index === selectedIndex }"
@click.prevent="runCommand(command)"
>
<component :is="command.icon" class="text-base-content/60 size-4 shrink-0" />
<span class="min-w-0 flex-1 truncate text-sm">{{ command.title }}</span>
<ic:sharp-keyboard-return v-if="index === selectedIndex" class="text-base-content/40 size-4" />
</a>
</li>
</ul>
</template>
<!-- Containers section -->
<template v-if="containerEntries.length">
<div
class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase"
:class="{ 'border-base-content/10 mt-1 border-t': commandEntries.length }"
>
{{ $t("cloud-search.containers-section") }} · {{ containerEntries.length }}
</div>
<ul class="pb-1">
<li
v-for="(result, index) in containerEntries"
:ref="(el) => setItemRef(el, commandEntries.length + index)"
>
<ic:sharp-keyboard-return v-if="index === selectedIndex" />
<cil:columns v-else-if="result.item.type === 'container'" />
<a
class="hover:bg-base-content/5 flex cursor-pointer items-center gap-3 px-4 py-2"
:class="{ 'bg-base-content/10': commandEntries.length + index === selectedIndex }"
@click.prevent="selected(result.item)"
>
<div :class="result.item.state === 'running' ? 'text-primary' : 'text-base-content/50'">
<template v-if="result.item.type === 'container'">
<octicon:container-24 class="size-4" />
</template>
<template v-else-if="result.item.type === 'service'">
<ph:stack-simple class="size-4" />
</template>
<template v-else-if="result.item.type === 'stack'">
<ph:stack class="size-4" />
</template>
</div>
<div class="min-w-0 flex-1 truncate text-sm">
<template v-if="config.hosts.length > 1 && result.item.host">
<span class="text-base-content/50 font-light">{{ result.item.host }}</span>
<span class="text-base-content/30"> / </span>
</template>
<span class="text-base-content" data-name v-html="matchedName(result)"></span>
</div>
<RelativeTime :date="result.item.created" class="text-base-content/40 text-xs" />
<span
@click.stop.prevent="addColumn(result.item)"
:title="$t('tooltip.pin-column')"
class="text-base-content/40 hover:text-secondary"
>
<ic:sharp-keyboard-return v-if="commandEntries.length + index === selectedIndex" class="size-4" />
<cil:columns v-else-if="result.item.type === 'container'" class="size-4" />
</span>
</a>
</li>
</ul>
</template>
</div>
<!-- Log search CTA -->
<div
v-if="logSearchVisible"
class="border-base-content/10 border-t"
:class="{ 'cursor-pointer': cloudSearch.available.value, 'opacity-70': !cloudSearch.available.value }"
@click="cloudSearch.available.value && runLogSearch()"
>
<div
class="flex items-center gap-3 px-4 py-3"
:class="cloudSearch.available.value ? 'bg-primary/[0.07] hover:bg-primary/10' : ''"
>
<mdi:cloud-search-outline
class="size-5 shrink-0"
:class="cloudSearch.available.value ? 'text-primary' : 'text-base-content/40'"
/>
<div class="flex min-w-0 flex-1 flex-col">
<span
class="truncate text-sm font-semibold"
:class="cloudSearch.available.value ? 'text-primary' : 'text-base-content/60'"
>
<i18n-t keypath="cloud-search.search-logs-for">
<template #query>
<span class="font-mono">{{ query }}</span>
</template>
</i18n-t>
</span>
</a>
</li>
</ul>
<span class="text-base-content/50 mt-0.5 flex items-center gap-1 text-xs">
<template v-if="cloudSearch.available.value">
<mdi:flash class="text-primary size-3" />
{{ $t("cloud-search.across-containers") }}
</template>
<template v-else-if="cloudConfig?.linked && !cloudConfig.streamLogs">
<mdi:cloud-off-outline class="size-3" />
<RouterLink to="/settings/cloud" class="link link-hover" @click.stop>
{{ $t("cloud-search.enable-streaming-to-search") }}
</RouterLink>
</template>
<template v-else>
<mdi:cloud-off-outline class="size-3" />
<RouterLink to="/settings/cloud" class="link link-hover" @click.stop>
{{ $t("cloud-search.connect-to-enable") }}
</RouterLink>
</template>
</span>
</div>
<kbd class="kbd kbd-xs"></kbd>
<kbd class="kbd kbd-xs"></kbd>
</div>
</div>
</div>
<!-- Footer: kbd hints + cloud status. Always present while the modal is
open so users know log search is available before they type. -->
<div
class="bg-base-300/40 border-base-content/10 text-base-content/50 flex items-center gap-4 border-t px-4 py-2 text-xs"
>
<span v-if="totalCount" class="flex items-center gap-1.5">
<kbd class="kbd kbd-xs"></kbd> {{ $t("cloud-search.open-container") }}
</span>
<span v-if="cloudSearch.available.value && logSearchVisible" class="flex items-center gap-1">
<kbd class="kbd kbd-xs"></kbd><kbd class="kbd kbd-xs"></kbd>
<span class="ml-0.5">{{ $t("cloud-search.search-logs-shortcut") }}</span>
</span>
<span v-if="cloudSearch.available.value" class="ml-auto flex items-center gap-1.5">
<mdi:cloud-check-outline class="text-primary size-3.5" />
{{ $t("cloud-search.cloud-connected") }}
</span>
<span v-else-if="cloudConfig?.linked" class="ml-auto flex items-center gap-1.5">
<mdi:cloud-off-outline class="size-3.5" />
<RouterLink to="/settings/cloud" class="link link-hover" @click.stop>
{{ $t("cloud-search.enable-streaming-to-search") }}
</RouterLink>
</span>
<span v-else class="ml-auto flex items-center gap-1.5">
<mdi:cloud-off-outline class="size-3.5" />
<RouterLink to="/settings/cloud" class="link link-hover" @click.stop>
{{ $t("cloud-search.connect-to-enable") }}
</RouterLink>
</span>
</div>
</div>
</template>
@@ -73,15 +191,30 @@
import { ContainerState } from "@/types/Container";
import { useFuse } from "@vueuse/integrations/useFuse";
import { type FuseResult } from "fuse.js";
import { useCloudConfig } from "@/composable/cloudConfig";
import { useCloudLogSearch } from "@/composable/cloudLogSearch";
import { useCommands, type Command } from "@/composable/commands";
const close = defineEmit();
const query = ref("");
const router = useRouter();
const route = useRoute();
// Prefill with the current /cloud/search query so the user can refine
// without retyping. Empty everywhere else. Null-safe for unit tests
// that mount the component without a router context.
const initialQuery = route?.path === "/cloud/search" && typeof route.query?.q === "string" ? route.query.q : "";
const query = ref(initialQuery);
const input = ref<HTMLInputElement>();
const listItems = ref<HTMLInputElement[]>();
const listItems = ref<(Element | null)[]>([]);
const selectedIndex = ref(0);
const router = useRouter();
// Function ref into a single flat array so Commands and Containers share one
// selection index for arrow-key navigation and scroll-into-view.
function setItemRef(el: any, index: number) {
listItems.value[index] = (el?.$el ?? el) as Element | null;
}
const containerStore = useContainerStore();
const pinnedStore = usePinnedLogsStore();
const { visibleContainers } = storeToRefs(containerStore);
@@ -89,12 +222,27 @@ const { visibleContainers } = storeToRefs(containerStore);
const swarmStore = useSwarmStore();
const { stacks, services } = storeToRefs(swarmStore);
const { cloudConfig } = useCloudConfig();
// Mounted only so the "Search logs for X" CTA can read `available`. We
// don't render the hits inside the popup. The composable's debounced
// watch short-circuits on empty query, so opening the modal alone does
// not fire a request.
const cloudSearch = useCloudLogSearch(query);
const logSearchVisible = computed(() => query.value.trim().length > 0);
const { t } = useI18n();
const placeholderCopy = computed(() =>
cloudSearch.available.value ? t("cloud-search.modal-placeholder-cloud") : t("cloud-search.modal-placeholder-plain"),
);
onMounted(async () => {
const dialog = input.value?.closest("dialog");
if (dialog) {
const animations = dialog.getAnimations();
await Promise.all(animations.map((animation) => animation.finished));
input.value?.focus();
if (initialQuery) input.value?.select();
}
});
@@ -144,7 +292,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 +302,22 @@ const { results } = useFuse(query, list, {
},
});
const results = computed(() => (query.value ? fuseResults.value : []));
// Commands palette. Fuzzy-matched against title/keywords while typing; the
// context commands (container actions) show up front on an empty query.
const { commands, contextCommands } = useCommands();
const { results: commandFuseResults } = useFuse(query, commands, {
fuseOptions: {
keys: ["title", "keywords"],
useExtendedSearch: true,
threshold: 0.3,
},
});
const commandEntries = computed<Command[]>(() =>
query.value ? commandFuseResults.value.map((r) => r.item) : contextCommands.value,
);
const data = computed(() => {
return [...results.value].sort((a: FuseResult<Item>, b: FuseResult<Item>) => {
if (a.score === b.score) {
@@ -170,14 +334,26 @@ const data = computed(() => {
});
});
watch(query, (data) => {
if (data.length > 0) {
selectedIndex.value = 0;
// Container hits, mirrors the previously named `data` list for the template.
const containerEntries = computed(() => data.value);
const totalCount = computed(() => commandEntries.value.length + containerEntries.value.length);
// Reset to the top only when the user types. Live SSE container add/remove
// changes totalCount too, and resetting on that would snap the selection back
// to 0 while the palette is open.
watch(query, () => {
selectedIndex.value = 0;
});
// Keep the selection in bounds when the result count shrinks underneath it.
watch(totalCount, (count) => {
if (selectedIndex.value > count - 1) {
selectedIndex.value = Math.max(count - 1, 0);
}
});
watch(selectedIndex, () => {
listItems.value?.[selectedIndex.value].scrollIntoView({ block: "end" });
listItems.value?.[selectedIndex.value]?.scrollIntoView({ block: "nearest" });
});
function selected(item: Item) {
@@ -191,6 +367,43 @@ function selected(item: Item) {
close();
}
async function runCommand(command: Command) {
close();
await command.perform();
}
function onEnter() {
// Commands come first in the flat list, then containers. With nothing
// selectable (cloud-only query like "OOM"), fall back to log search so the
// user isn't stuck on a popup that does nothing.
const commandCount = commandEntries.value.length;
if (selectedIndex.value < commandCount) {
runCommand(commandEntries.value[selectedIndex.value]);
} else if (containerEntries.value.length > 0) {
selected(containerEntries.value[selectedIndex.value - commandCount].item);
} else if (cloudSearch.available.value && logSearchVisible.value) {
runLogSearch();
}
}
function onPin() {
// Alt+Enter pins a container column. Only meaningful when a container row is
// selected, not a command.
const commandCount = commandEntries.value.length;
if (selectedIndex.value >= commandCount) {
const entry = containerEntries.value[selectedIndex.value - commandCount];
if (entry?.item.type === "container") addColumn(entry.item);
}
}
function runLogSearch() {
if (!cloudSearch.available.value) return;
const q = query.value.trim();
if (!q) return;
router.push({ path: "/cloud/search", query: { q } });
close();
}
function addColumn(container: { id: string }) {
pinnedStore.pinContainer(container);
close();
+23 -18
View File
@@ -1,19 +1,17 @@
<template>
<div class="card bg-base-100">
<div class="card-body flex gap-2 max-md:p-4">
<div class="flex flex-row gap-2 overflow-hidden">
<div class="flex items-center gap-1 truncate text-xl font-semibold">
<HostIcon :type="host.type" class="flex-none" />
<div class="truncate">
{{ host.name }}
</div>
<div class="card-body flex gap-3 max-md:p-4">
<div class="flex flex-row items-center gap-3 overflow-hidden">
<div class="flex min-w-0 items-center gap-2 text-lg font-semibold tracking-tight md:text-xl">
<HostIcon :type="host.type" class="text-base-content/80 flex-none" />
<div class="truncate">{{ host.name }}</div>
<span class="badge badge-error badge-xs gap-2 p-2" v-if="!host.available">
<span class="badge badge-error badge-xs gap-1 p-2 font-normal" v-if="!host.available">
<carbon:warning />
offline
</span>
<span
class="badge badge-success badge-xs gap-2 p-2"
class="badge badge-success badge-xs gap-1 p-2 font-normal"
:class="{ 'badge-warning': config.version != host.agentVersion }"
v-else-if="host.type == 'agent'"
title="Dozzle Agent"
@@ -21,12 +19,19 @@
{{ host.agentVersion }}
</span>
</div>
<ul class="ml-auto flex flex-row flex-wrap gap-x-2 text-sm max-md:text-xs md:gap-3">
<li class="flex items-center gap-1">
<octicon:container-24 class="inline-block" />
<ul
class="text-base-content/60 ml-auto flex shrink-0 flex-row flex-wrap items-center gap-x-3 text-xs tabular-nums md:text-sm"
>
<li class="flex items-center gap-1.5">
<octicon:container-24 class="size-3.5" />
{{ $t("label.container", hostContainers.length) }}
</li>
<li class="flex items-center gap-1"><mdi:docker class="inline-block" /> {{ host.dockerVersion }}</li>
<li class="flex items-center gap-1.5" :title="runtimeLabel">
<simple-icons:podman v-if="host.runtime === 'podman'" class="size-3.5" />
<mdi:docker v-else class="size-3.5" />
{{ host.dockerVersion }}
</li>
</ul>
</div>
@@ -35,7 +40,7 @@
:icon="PhCpu"
:value="stats.weighted.movingAverage.totalCPU"
:chartData="cpuHistory"
container-class="border-primary/40 bg-primary/20"
container-class="bg-primary/10"
text-class="text-primary"
bar-class="bg-primary"
:formatValue="(value) => `${value.toFixed(1)}%`"
@@ -46,7 +51,7 @@
:icon="PhMemory"
:value="stats.weighted.movingAverage.totalMemUsage"
:chartData="memHistory"
container-class="border-secondary/40 bg-secondary/20"
container-class="bg-secondary/10"
text-class="text-secondary"
bar-class="bg-secondary"
:formatValue="(value) => formatBytes(value, { decimals: 1 })"
@@ -60,9 +65,7 @@
<script setup lang="ts">
import type { Host } from "@/stores/hosts";
import { Container } from "@/models/Container";
// @ts-ignore
import PhCpu from "~icons/ph/cpu";
// @ts-ignore
import PhMemory from "~icons/ph/memory";
const props = defineProps<{
@@ -78,9 +81,11 @@ const hostContainers = computed(() =>
containers.value.filter((container) => container.host === props.host.id && container.state === "running"),
);
const runtimeLabel = computed(() => (props.host.runtime === "podman" ? "Podman" : "Docker"));
function toContainerCores(container: Container): number {
if (container.cpuLimit && container.cpuLimit > 0) {
return 1;
return container.cpuLimit;
}
return props.host.nCPU ?? 1;
}
+185 -26
View File
@@ -36,9 +36,10 @@
<div v-else class="w-4"></div>
{{ $t("label.show-all-containers") }}
</a>
<a class="text-sm capitalize" @click="collapseAll()">
<material-symbols-light:collapse-all class="w-4" />
{{ $t("label.collapse-all") }}
<a v-if="hasCollapsible" class="text-sm capitalize" @click="collapseAll()">
<material-symbols-light:expand-all class="w-4" v-if="allCollapsed" />
<material-symbols-light:collapse-all class="w-4" v-else />
{{ allCollapsed ? $t("label.expand-all") : $t("label.collapse-all") }}
</a>
</li>
</ul>
@@ -49,13 +50,71 @@
<SlideTransition :slide-right="!!sessionHost">
<template #left>
<ul class="menu p-0">
<li v-for="host in hosts" :key="host.id">
<a @click.prevent="setHost(host.id)" :class="{ 'text-base-content/50 pointer-events-none': !host.available }">
<HostIcon :type="host.type" />
{{ host.name }}
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
</a>
</li>
<template v-if="!hasHostGroups">
<li v-for="host in hosts" :key="host.id">
<a
@click.prevent="setHost(host.id)"
class="auto-cols-[max-content_minmax(0,1fr)_max-content]"
:class="{ 'text-base-content/50 pointer-events-none': !host.available }"
>
<HostIcon :type="host.type" />
<span class="truncate">{{ host.name }}</span>
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
</a>
</li>
</template>
<template v-else v-for="[groupName, groupHosts] in groupedHostEntries" :key="groupName || '__ungrouped__'">
<li v-if="groupName" class="host-group">
<details :open="!collapsedHostGroups.has(groupName)" @toggle="updateCollapsedHostGroups($event, groupName)">
<summary class="host-group-summary">
<span class="truncate">{{ groupName }}</span>
<router-link
:to="{ name: '/host-group/[name]', params: { name: groupName } }"
class="btn btn-square btn-outline btn-primary btn-xs"
:title="$t('tooltip.merge-all')"
@click.stop
>
<ph:arrows-merge />
</router-link>
<button
v-if="!collapsedHostGroups.has(groupName)"
type="button"
class="btn btn-square btn-outline btn-primary btn-xs"
:title="$t('label.collapse-group')"
@click.stop.prevent="collapseHostGroup(groupName)"
>
<material-symbols-light:collapse-all />
</button>
</summary>
<ul>
<li v-for="host in groupHosts" :key="host.id">
<a
@click.prevent="setHost(host.id)"
class="auto-cols-[max-content_minmax(0,1fr)_max-content]"
:class="{ 'text-base-content/50 pointer-events-none': !host.available }"
>
<HostIcon :type="host.type" />
<span class="truncate">{{ host.name }}</span>
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
</a>
</li>
</ul>
</details>
</li>
<template v-else>
<li v-for="host in groupHosts" :key="host.id">
<a
@click.prevent="setHost(host.id)"
class="auto-cols-[max-content_minmax(0,1fr)_max-content]"
:class="{ 'text-base-content/50 pointer-events-none': !host.available }"
>
<HostIcon :type="host.type" />
<span class="truncate">{{ host.name }}</span>
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
</a>
</li>
</template>
</template>
</ul>
</template>
<template #right>
@@ -79,17 +138,24 @@
</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 } }"
active-class="menu-active"
@click.alt.stop.prevent="pinnedStore.pinContainer(item)"
:title="item.name"
class="group auto-cols-[content_max_auto_max-content_max-content]"
class="group auto-cols-[max-content_minmax(0,1fr)_max-content_max-content]"
>
<svg-spinners:ring-resize v-if="item.isNew" class="text-secondary w-2" />
<div
class="status data-[state=exited]:status-error data-[state=running]:status-success"
v-else
class="status data-[state=exited]:status-error data-[state=running]:status-success data-[state=paused]:status-warning"
:data-state="item.state"
></div>
<div class="truncate">
@@ -123,11 +189,8 @@ import { Container } from "@/models/Container";
import { sessionHost } from "@/composable/storage";
import { showAllContainers, groupContainers } from "@/stores/settings";
// @ts-ignore
import Pin from "~icons/ph/map-pin-simple";
// @ts-ignore
import Stack from "~icons/ph/stack";
// @ts-ignore
import Containers from "~icons/octicon/container-24";
const containerStore = useContainerStore();
@@ -139,7 +202,30 @@ const { hosts } = useHosts();
const setHost = (host: string | null) => (sessionHost.value = host);
const hasHostGroups = computed(() => Object.values(hosts.value).some((h) => h.group));
const groupedHostEntries = computed(() => {
const groups: Record<string, (typeof hosts.value)[string][]> = {};
const ungrouped: (typeof hosts.value)[string][] = [];
for (const host of Object.values(hosts.value)) {
if (host.group) {
groups[host.group] ||= [];
groups[host.group].push(host);
} else {
ungrouped.push(host);
}
}
const entries = Object.entries(groups).sort(([a], [b]) => a.localeCompare(b)) as [string, typeof ungrouped][];
if (ungrouped.length > 0) {
entries.push(["", ungrouped]);
}
return entries;
});
const collapsedGroups = useProfileStorage("collapsedGroups", new Set<string>());
const collapsedHostGroups = useProfileStorage("collapsedHostGroups", new Set<string>());
const updateCollapsedGroups = (event: Event, label: string) => {
const details = event.target as HTMLDetailsElement;
if (details.open) {
@@ -152,10 +238,51 @@ const updateCollapsedGroups = (event: Event, label: string) => {
}
};
const updateCollapsedHostGroups = (event: Event, groupName: string) => {
const details = event.target as HTMLDetailsElement;
if (details.open) {
collapsedHostGroups.value.delete(groupName);
} else {
collapsedHostGroups.value.add(groupName);
}
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
};
const collapseHostGroup = (groupName: string) => {
collapsedHostGroups.value.add(groupName);
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
};
const hasCollapsible = computed(
() => menuItems.value.length > 0 || groupedHostEntries.value.some(([groupName]) => groupName),
);
const allCollapsed = computed(() => {
const containerGroups = menuItems.value;
const hostGroups = groupedHostEntries.value.filter(([groupName]) => groupName);
if (containerGroups.length === 0 && hostGroups.length === 0) return false;
return (
containerGroups.every(({ label }) => collapsedGroups.value.has(label)) &&
hostGroups.every(([groupName]) => collapsedHostGroups.value.has(groupName))
);
});
const collapseAll = () => {
menuItems.value.forEach(({ label }) => {
collapsedGroups.value.add(label);
});
if (allCollapsed.value) {
menuItems.value.forEach(({ label }) => collapsedGroups.value.delete(label));
groupedHostEntries.value.forEach(([groupName]) => {
if (groupName) collapsedHostGroups.value.delete(groupName);
});
} else {
menuItems.value.forEach(({ label }) => collapsedGroups.value.add(label));
groupedHostEntries.value.forEach(([groupName]) => {
if (groupName) collapsedHostGroups.value.add(groupName);
});
}
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
@@ -225,14 +352,18 @@ const menuItems = computed(() => {
const route = useRoute("/container/[id]");
watchEffect(() => {
if (route.name === "/container/[id]") {
const container = containerStore.findContainerById(route.params.id);
if (container) {
setHost(container.host);
watch(
[() => route.name, () => route.params.id],
([name, id]) => {
if (name === "/container/[id]") {
const container = containerStore.findContainerById(id as string);
if (container) {
setHost(container.host);
}
}
}
});
},
{ immediate: true },
);
const toggleShowAllContainers = () => (showAllContainers.value = !showAllContainers.value);
</script>
@@ -241,6 +372,21 @@ const toggleShowAllContainers = () => (showAllContainers.value = !showAllContain
@apply text-[0.95rem];
}
.host-group-summary {
display: grid;
grid-template-columns: minmax(0, auto) max-content max-content max-content;
align-items: center;
justify-content: start;
gap: 0.5rem;
padding-left: 0;
padding-right: 0.25rem;
color: color-mix(in oklch, var(--color-base-content) 50%, transparent);
}
.host-group-summary::after {
margin-left: 0.25rem;
}
li.exited {
@apply opacity-75;
}
@@ -248,4 +394,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>
@@ -0,0 +1,61 @@
<template>
<div v-if="groupHosts.length === 0" class="hero min-h-[50vh]">
<div class="hero-content text-center">
<p class="text-base-content/70 text-lg">{{ $t("error.host-group-not-found", { name }) }}</p>
</div>
</div>
<ScrollableView :scrollable="scrollable" v-else>
<template #header>
<div class="mx-2 flex items-center gap-2 md:ml-4">
<div class="flex flex-1 items-center gap-1.5 truncate md:gap-2">
<ph:computer-tower />
<div class="inline-flex font-mono text-sm">
<div class="font-semibold">{{ name }}</div>
</div>
<Tag class="font-mono max-md:hidden" size="small">
{{ $t("label.host-count", groupHosts.length) }}
</Tag>
<Tag class="font-mono max-md:hidden" size="small">
{{ $t("label.container", containers.length) }}
</Tag>
</div>
<MultiContainerStat class="ml-auto" :containers="containers" />
<MultiContainerActionToolbar class="max-md:hidden" :name="name" @clear="viewer?.clear()" />
</div>
</template>
<template #default>
<ViewerWithSource
ref="viewer"
:stream-source="useHostGroupStream"
:entity="groupRef"
:visible-keys="visibleKeys"
/>
</template>
</ScrollableView>
</template>
<script lang="ts" setup>
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
import { useHostGroupStream } from "@/composable/eventStreams";
import { ComponentExposed } from "vue-component-type-helpers";
const { name, scrollable = false } = defineProps<{
name: string;
scrollable?: boolean;
}>();
const { hosts } = useHosts();
const store = useContainerStore();
const { containersByHost } = storeToRefs(store);
const groupHosts = computed(() => Object.values(hosts.value).filter((h) => h.group === name));
const groupRef = computed(() => ({ name }));
const visibleKeys = new Map<string[], boolean>();
const containers = computed(() =>
groupHosts.value.flatMap((h) => containersByHost.value?.[h.id]?.filter((c) => c.state === "running") ?? []),
);
const viewer = useTemplateRef<ComponentExposed<typeof ViewerWithSource>>("viewer");
provideLoggingContext(containers, { showContainerName: true, showHostname: true });
</script>
+4 -4
View File
@@ -78,8 +78,8 @@
</router-link>
</summary>
<ul>
<li v-for="owner in owners" :key="`${owner.kind}-${owner.name}`">
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active">
<li v-for="owner in owners" :key="owner.key">
<router-link :to="{ name: '/owner/[name]', params: { name: owner.key } }" active-class="menu-active">
<ph:stack-simple />
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
</router-link>
@@ -95,8 +95,8 @@
{{ $t("label.owners") }} ({{ ownersWithoutNamespace.length }})
</summary>
<ul>
<li v-for="owner in ownersWithoutNamespace" :key="`${owner.kind}-${owner.name}`">
<router-link :to="{ name: '/owner/[name]', params: { name: owner.name } }" active-class="menu-active">
<li v-for="owner in ownersWithoutNamespace" :key="owner.key">
<router-link :to="{ name: '/owner/[name]', params: { name: owner.key } }" active-class="menu-active">
<ph:stack-simple />
<div class="truncate">{{ owner.kind }}/{{ owner.name }}</div>
</router-link>
+3 -1
View File
@@ -12,6 +12,8 @@
<mdi:bell class="size-6" />
</router-link>
<CloudPopover />
<router-link
:to="{ name: '/settings' }"
:aria-label="$t('title.settings')"
@@ -21,7 +23,7 @@
<mdi:cog class="size-6" />
</router-link>
<dropdown class="dropdown-end" v-if="config.user">
<dropdown class="dropdown-end" data-testid="user-menu" v-if="config.user">
<template #trigger>
<template v-if="config.disableAvatars || !config.user.email">
<material-symbols:person class="size-6" />
@@ -0,0 +1,66 @@
/**
* @vitest-environment jsdom
*/
import { mount } from "@vue/test-utils";
import { describe, expect, test, vi } from "vitest";
import { ref } from "vue";
import ComplexLogItem from "./ComplexLogItem.vue";
import { ComplexLogEntry, type JSONObject } from "@/models/LogEntry";
vi.mock("@/stores/config", () => ({
__esModule: true,
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
withBase: (path: string) => path,
}));
function mountItem(message: JSONObject, visibleKeys?: ReturnType<typeof ref<Map<string[], boolean>>>) {
const entry = new ComplexLogEntry(message, "c1", 1, new Date(), "info", "stdout", "raw", visibleKeys as any);
return mount(ComplexLogItem, {
props: { logEntry: entry },
global: {
// LogItem pulls in stores/hosts; we only care about the payload rendering.
stubs: { LogItem: { template: "<div><slot /></div>" }, LogLevel: true },
},
});
}
describe("<ComplexLogItem />", () => {
test("renders key=value pairs", () => {
const wrapper = mountItem({ foo: "bar", n: 1 });
expect(wrapper.findAll("li")).toHaveLength(2);
expect(wrapper.text()).toContain("foo=");
expect(wrapper.text()).toContain("bar");
expect(wrapper.text()).toContain("n=");
expect(wrapper.text()).toContain("1");
});
test("renders null values as <null>", () => {
const wrapper = mountItem({ x: null } as any);
expect(wrapper.text()).toContain("<null>");
});
test("filters out undefined values", () => {
const wrapper = mountItem({ a: undefined as any, b: 2 });
expect(wrapper.findAll("li")).toHaveLength(1);
expect(wrapper.text()).toContain("b=");
expect(wrapper.text()).not.toContain("a=");
});
test("renders array values as a list", () => {
const wrapper = mountItem({ tags: ["a", "b"] });
expect(wrapper.find(".array").exists()).toBe(true);
expect(wrapper.find(".array").text()).toContain("a");
expect(wrapper.find(".array").text()).toContain("b");
});
test("renders null inside an array without throwing", () => {
const wrapper = mountItem({ tags: ["a", null, "b"] } as any);
expect(wrapper.find(".array").text()).toContain("null");
});
test("shows a placeholder when every value is hidden", () => {
const visibleKeys = ref(new Map<string[], boolean>([[["a"], false]]));
const wrapper = mountItem({ a: 1 }, visibleKeys);
expect(wrapper.text()).toContain("all values are hidden");
});
});
@@ -5,7 +5,7 @@
<span class="key">{{ name }}=</span>
<span class="value" v-if="value === null">&lt;null&gt;</span>
<ReuseTemplate :data="value" v-else-if="isObject(value) || Array.isArray(value)" />
<span v-else class="value" :class="typeof value" v-html="stripAnsi(value.toString())"></span>
<span v-else class="value" :class="typeof value" v-html="stripAnsi(String(value))"></span>
</li>
<li v-else-if="Array.isArray(data)">
<ul class="array inline-flex flex-wrap space-x-1">
@@ -15,7 +15,7 @@
class="after:text-base-content/70 not-last:after:content-[',']"
>
<ReuseTemplate :data="item" v-if="isObject(item) || Array.isArray(item)" />
<span v-else class="value" :class="typeof item" v-html="stripAnsi(item.toString())"></span>
<span v-else class="value" :class="typeof item" v-html="stripAnsi(String(item))"></span>
</li>
</ul>
</li>
@@ -9,6 +9,7 @@ import { computed, nextTick } from "vue";
import { createI18n } from "vue-i18n";
import { createRouter, createWebHistory } from "vue-router";
import { default as Component } from "./EventSource.vue";
import SearchStatus from "./SearchStatus.vue";
import LogViewer from "@/components/LogViewer/LogViewer.vue";
import { Container } from "@/models/Container";
import { Level } from "@/models/LogEntry";
@@ -172,6 +173,32 @@ describe("<ContainerEventSource />", () => {
expect(message).toMatchSnapshot();
});
describe("search status", () => {
test("shows no-logs when not searching and the stream is empty", async () => {
const wrapper = createLogEventSource();
sources[sourceUrl].emitOpen();
await vi.advanceTimersByTimeAsync(3500);
await nextTick();
expect(wrapper.find('[data-testid="no-logs"]').exists()).toBe(true);
});
test("suppresses no-logs while a search is still running", async () => {
const wrapper = createLogEventSource();
sources[sourceUrl].emitOpen();
sources[sourceUrl].emit("search-status", {
data: JSON.stringify({ scannedTo: "2026-06-01T14:31:00Z", matches: 0, done: false }),
});
vi.advanceTimersByTime(3000);
await nextTick();
expect(wrapper.find('[data-testid="no-logs"]').exists()).toBe(false);
expect(wrapper.findComponent(SearchStatus).exists()).toBe(true);
});
});
describe("render html correctly", () => {
test("should render messages", async () => {
const wrapper = createLogEventSource();
+11 -5
View File
@@ -1,12 +1,13 @@
<template>
<ul class="flex animate-pulse flex-col gap-4 p-4" v-if="loading || (noLogs && waitingForMoreLog)">
<SearchStatus :status="searchStatus" class="sticky top-0 z-10" />
<ul class="flex animate-pulse flex-col gap-4 p-4" v-if="loading || (noLogs && waitingForMoreLog && !inSearch)">
<div class="flex flex-row gap-2" v-for="size in sizes">
<div class="bg-base-content/50 h-3 w-40 shrink-0 rounded-full opacity-50"></div>
<div class="bg-base-content/50 h-3 rounded-full opacity-50" :class="size"></div>
</div>
<span class="sr-only">Loading...</span>
</ul>
<div v-else-if="noLogs && !waitingForMoreLog" class="p-4">
<div v-else-if="noLogs && !waitingForMoreLog && !inSearch" class="p-4" data-testid="no-logs">
{{ $t("label.no-logs") }}
</div>
<slot :messages="messages" v-else></slot>
@@ -24,7 +25,11 @@ const { entity, streamSource } = $defineProps<{
const { historical } = useLoggingContext();
const { messages, opened, loading, error } = streamSource(toRef(() => entity));
const { messages, opened, loading, error, searchStatus } = streamSource(toRef(() => entity));
// While a search is running (or just finished), SearchStatus owns the empty
// messaging, so suppress the generic "no logs" state to avoid the false signal.
const inSearch = computed(() => searchStatus.value.active || searchStatus.value.done);
const color = computed(() => {
if (error.value) return "error";
@@ -41,10 +46,11 @@ defineExpose({
clear: () => (messages.value = []),
});
if (historical.value && route.query.logId) {
if (historical.value && typeof route.query.logId === "string") {
const targetId = route.query.logId;
watchOnce(messages, async () => {
await nextTick();
document.getElementById(route.query.logId as string)?.scrollIntoView({ behavior: "instant", block: "center" });
document.getElementById(targetId)?.scrollIntoView({ behavior: "instant", block: "center" });
});
}
@@ -14,13 +14,6 @@
</template>
<script lang="ts" setup>
import { GroupedLogEntry, type Position } from "@/models/LogEntry";
import AnsiConvertor from "ansi-to-html";
const ansiConvertor = new AnsiConvertor({
escapeXML: false,
fg: "var(--color-base-content)",
bg: "var(--color-base-100)",
});
const { logEntry } = defineProps<{
logEntry: GroupedLogEntry;
@@ -32,6 +25,4 @@ const getPosition = (index: number): Position => {
if (index === len - 1) return "end";
return "middle";
};
const colorize = (value: string) => ansiConvertor.toHtml(value);
</script>
+40
View File
@@ -0,0 +1,40 @@
<template>
<div
class="bg-base-content/[0.06] grid min-w-0 grid-cols-[auto_auto_3.5rem_auto_3.5rem] items-center gap-x-1.5 gap-y-1 rounded-md px-2.5 py-1.5 text-[12.5px] leading-none tabular-nums max-md:hidden @max-5xl:hidden"
:title="tooltip"
>
<PhNetwork class="text-base-content/60 size-3.5" />
<PhArrowUp class="text-primary text-[10px]" />
<span class="text-right">{{ formatBytes(networkTx, { short: true, decimals: 1 }) }}/s</span>
<PhArrowDown class="text-secondary text-[10px]" />
<span class="text-right">{{ formatBytes(networkRx, { short: true, decimals: 1 }) }}/s</span>
<PhHardDrives class="text-base-content/60 size-3.5" />
<PhArrowUp class="text-primary text-[10px]" />
<span class="text-right">{{ formatBytes(diskWrite, { short: true, decimals: 1 }) }}/s</span>
<PhArrowDown class="text-secondary text-[10px]" />
<span class="text-right">{{ formatBytes(diskRead, { short: true, decimals: 1 }) }}/s</span>
</div>
</template>
<script lang="ts" setup>
import PhNetwork from "~icons/ph/network";
import PhHardDrives from "~icons/ph/hard-drives";
import PhArrowUp from "~icons/ph/arrow-up";
import PhArrowDown from "~icons/ph/arrow-down";
const { networkRx, networkTx, diskRead, diskWrite } = defineProps<{
networkRx: number;
networkTx: number;
diskRead: number;
diskWrite: number;
}>();
const { t } = useI18n();
const tooltip = computed(
() =>
t("tooltip.network-io", { tx: formatBytes(networkTx), rx: formatBytes(networkRx) }) +
"\n" +
t("tooltip.disk-io", { write: formatBytes(diskWrite), read: formatBytes(diskRead) }),
);
</script>
+8 -3
View File
@@ -7,7 +7,7 @@
@mouseenter="checkDropdownPosition"
>
<router-link
v-if="isSearching"
v-if="isFiltered"
@click="resetSearch()"
tabindex="0"
class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90"
@@ -31,7 +31,7 @@
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm"
@click="hideMenu"
>
<li v-if="isSearching">
<li v-if="isFiltered">
<router-link
@click="resetSearch()"
:to="{
@@ -99,8 +99,13 @@ const { showToast } = useToast();
const showDrawer = useDrawer();
const router = useRouter();
const { isSearching, resetSearch } = useSearchFilter();
const { levels } = useLoggingContext();
const { copy, isSupported, copied } = useClipboard();
// Show "see in context" whenever the stream is narrowed, either by a text search
// or by a log-level filter, so the entry can be inspected in the full log stream.
const isFiltered = computed(() => isSearching.value || allLevels.some((level) => !levels.value.has(level)));
const { copy, isSupported, copied } = useClipboard({ legacy: true });
const { t } = useI18n();
async function copyLogMessage() {
+210 -33
View File
@@ -1,37 +1,111 @@
<template>
<aside>
<header class="flex items-center gap-4">
<h1 class="text-2xl max-md:hidden">{{ container.name }}</h1>
<h2 class="text-sm"><RelativeTime :date="container.created" /></h2>
<aside class="flex flex-col gap-5 pb-8">
<header class="flex items-center gap-3 pr-8">
<ph:file-sql class="text-primary size-7 shrink-0" />
<div class="flex min-w-0 flex-col">
<h1 class="text-xl leading-tight font-semibold">{{ $t("analytics.title") }}</h1>
<p class="text-base-content/60 flex items-center gap-1.5 text-sm">
<span class="truncate">{{ container.name }}</span>
<span class="opacity-40">·</span>
<RelativeTime :date="container.created" />
</p>
</div>
</header>
<div class="mt-8 flex flex-col gap-2">
<section>
<label class="form-control">
<textarea
v-model="query"
class="textarea textarea-primary w-full font-mono text-lg"
:class="{ 'textarea-error!': error }"
:disabled="state === 'downloading'"
></textarea>
<div class="mt-2">
<span class="text-error" v-if="error">{{ error }}</span>
<span v-else-if="state === 'initializing'">{{ $t("analytics.creating_table") }}</span>
<span v-else-if="state === 'downloading'">{{
$t("analytics.downloading", { size: formatBytes(bytes, { decimals: 1 }) })
}}</span>
<span v-else-if="evaluating">{{ $t("analytics.evaluating_query") }}</span>
<span v-else>
{{ $t("analytics.total_records", { count: results.numRows.toLocaleString() }) }}
<template v-if="results.numRows > pageLimit">{{
$t("analytics.showing_first", { count: page.numRows.toLocaleString() })
}}</template>
</span>
<section class="flex flex-col gap-2">
<textarea
ref="queryEl"
v-model="query"
class="textarea textarea-primary w-full resize-y font-mono text-sm leading-relaxed"
:class="{ 'textarea-error!': error }"
:disabled="state !== 'ready'"
rows="3"
spellcheck="false"
autocapitalize="off"
autocomplete="off"
:aria-label="$t('analytics.title')"
@keydown.meta.enter.prevent="run"
@keydown.ctrl.enter.prevent="run"
></textarea>
<div class="flex min-h-6 items-center text-sm">
<div class="min-w-0 flex-1 truncate">
<span class="text-error" v-if="error">{{ error }}</span>
<span class="text-base-content/60 inline-flex items-center gap-2" v-else-if="state === 'initializing'">
<span class="loading loading-spinner loading-xs"></span>{{ $t("analytics.creating_table") }}
</span>
<span class="text-base-content/60 inline-flex items-center gap-2" v-else-if="state === 'downloading'">
<span class="loading loading-spinner loading-xs"></span
>{{ $t("analytics.downloading", { size: formatBytes(bytes, { decimals: 1 }) }) }}
</span>
<span class="text-base-content/60 inline-flex items-center gap-2" v-else-if="evaluating">
<span class="loading loading-spinner loading-xs"></span>{{ $t("analytics.evaluating_query") }}
</span>
<span class="text-base-content/60" v-else>
{{ $t("analytics.total_records", { count: results.numRows.toLocaleString() }) }}
<template v-if="results.numRows > pageLimit">{{
$t("analytics.showing_first", { count: page.numRows.toLocaleString() })
}}</template>
</span>
</div>
<div class="dropdown dropdown-end shrink-0" v-if="canExport">
<div tabindex="0" role="button" class="btn btn-xs btn-ghost cursor-pointer gap-1">
<ph:download-simple class="size-4" />
{{ $t("analytics.export") }}
</div>
</label>
</section>
<SQLTable :table="page" :loading="evaluating || state !== 'ready'" />
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-30 w-44 p-2 shadow-sm">
<li>
<a class="cursor-pointer whitespace-nowrap" @click="exportResults('csv')">{{
$t("analytics.export_csv")
}}</a>
</li>
<li>
<a class="cursor-pointer whitespace-nowrap" @click="exportResults('json')">{{
$t("analytics.export_json")
}}</a>
</li>
</ul>
</div>
</div>
</section>
<section v-if="state === 'ready' && columns.length" class="flex flex-col gap-2 text-xs">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="text-base-content/50 font-medium">{{ $t("analytics.examples") }}</span>
<button
v-for="ex in examples"
:key="ex.key"
class="badge badge-sm badge-outline hover:border-primary hover:text-primary cursor-pointer"
@click="applyExample(ex.sql)"
>
{{ $t(ex.key, ex.params ?? {}) }}
</button>
</div>
<details class="group">
<summary
class="text-base-content/50 hover:text-base-content/80 flex w-fit cursor-pointer items-center gap-1 font-medium select-none"
>
<ph:caret-right class="size-3 transition-transform group-open:rotate-90" />
{{ $t("analytics.columns") }}
<span class="opacity-60">{{ columns.length }}</span>
</summary>
<div class="mt-2 flex max-h-40 flex-wrap gap-1.5 overflow-y-auto">
<button
v-for="col in columns"
:key="col.name"
class="badge badge-sm badge-ghost hover:border-primary hover:text-primary cursor-pointer font-mono"
:title="col.type"
@click="insertColumn(col.name)"
>
{{ col.name }}
</button>
</div>
</details>
</section>
<SQLTable :table="page" :loading="evaluating || state !== 'ready'" />
</aside>
</template>
@@ -42,11 +116,15 @@ import { type Table } from "@apache-arrow/esnext-esm";
const { container } = defineProps<{ container: Container }>();
const query = ref("SELECT * FROM logs LIMIT 100");
const error = ref<string | null>(null);
const debouncedQuery = debouncedRef(query, 500);
const evaluating = ref(false);
const pageLimit = 1000;
const state = ref<"downloading" | "ready" | "initializing">("downloading");
const bytes = ref(0);
const columns = ref<{ name: string; type: string }[]>([]);
const queryEl = useTemplateRef<HTMLTextAreaElement>("queryEl");
const runQuery = ref(query.value);
watchDebounced(query, (v) => (runQuery.value = v), { debounce: 500 });
const url = withBase(
`/api/hosts/${container.host}/containers/${container.id}/logs?stdout=1&stderr=1&everything&jsonOnly`,
@@ -90,9 +168,15 @@ onMounted(async () => {
state.value = "initializing";
await conn.query(
`CREATE TABLE logs AS SELECT unnest(m) FROM read_json('logs.json', ignore_errors = true, format = 'newline_delimited')`,
`CREATE TABLE logs AS SELECT unnest(m) FROM read_json('logs.json', ignore_errors = true, format = 'newline_delimited', map_inference_threshold = -1)`,
);
const described = await conn.query<{ column_name: any; column_type: any }>(`DESCRIBE logs`);
columns.value = described.toArray().map((row) => ({
name: String(row.column_name),
type: String(row.column_type),
}));
state.value = "ready";
} catch (e) {
console.error(e);
@@ -102,10 +186,54 @@ onMounted(async () => {
}
});
const examples = computed(() => {
const names = columns.value.map((c) => c.name);
const pick = ["level", "severity", "lvl", "status"].find((c) => names.includes(c)) ?? names[0];
const list: { key: string; sql: string; params?: Record<string, string> }[] = [
{ key: "analytics.example_all", sql: "SELECT * FROM logs LIMIT 100" },
{ key: "analytics.example_count", sql: "SELECT count(*) AS total FROM logs" },
];
if (pick) {
list.push({
key: "analytics.example_group",
params: { column: pick },
sql: `SELECT "${pick}", count(*) AS count FROM logs GROUP BY "${pick}" ORDER BY count DESC`,
});
}
return list;
});
function run() {
if (state.value !== "ready") return;
runQuery.value = query.value;
}
function applyExample(sql: string) {
query.value = sql;
nextTick(run);
}
function insertColumn(name: string) {
const text = `"${name}"`;
const el = queryEl.value;
if (!el) {
query.value += text;
return;
}
const start = el.selectionStart ?? query.value.length;
const end = el.selectionEnd ?? start;
query.value = query.value.slice(0, start) + text + query.value.slice(end);
nextTick(() => {
el.focus();
const pos = start + text.length;
el.setSelectionRange(pos, pos);
});
}
const results = computedAsync(
async () => {
if (state.value === "ready") {
return await conn.query<Record<string, any>>(debouncedQuery.value);
return await conn.query<Record<string, any>>(runQuery.value);
} else {
return empty;
}
@@ -129,5 +257,54 @@ whenever(evaluating, () => {
const page = computed(() =>
results.value.numRows > pageLimit ? results.value.slice(0, pageLimit) : results.value,
) as unknown as ComputedRef<Table<Record<string, any>>>;
const canExport = computed(() => state.value === "ready" && !evaluating.value && results.value.numRows > 0);
function stringify(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "bigint") return value.toString();
if (typeof value === "object") return JSON.stringify(value, (_, v) => (typeof v === "bigint" ? v.toString() : v));
return String(value);
}
function toCSV(table: Table<Record<string, any>>, columns: string[]): string {
const escape = (value: string) => (/[",\n\r]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value);
const lines = [columns.map(escape).join(",")];
for (const row of table) {
lines.push(columns.map((column) => escape(stringify((row as Record<string, any>)[column]))).join(","));
}
return lines.join("\n");
}
function toJSON(table: Table<Record<string, any>>, columns: string[]): string {
const rows = [];
for (const row of table) {
const record: Record<string, unknown> = {};
for (const column of columns) {
const value = (row as Record<string, any>)[column];
record[column] = typeof value === "bigint" ? value.toString() : value;
}
rows.push(record);
}
return JSON.stringify(rows, null, 2);
}
function exportResults(format: "csv" | "json") {
const table = results.value as unknown as Table<Record<string, any>>;
if (table.numRows === 0) return;
const columns = Object.keys(table.get(0) as Record<string, any>);
const content = format === "csv" ? toCSV(table, columns) : toJSON(table, columns);
const type = format === "csv" ? "text/csv;charset=utf-8" : "application/json";
const name = container.name.replace(/[^\w.-]+/g, "-");
const url = URL.createObjectURL(new Blob([content], { type }));
const link = document.createElement("a");
link.href = url;
link.download = `${name}-query.${format}`;
link.click();
URL.revokeObjectURL(url);
(document.activeElement as HTMLElement | null)?.blur();
}
</script>
<style scoped></style>
+2 -42
View File
@@ -36,8 +36,8 @@
</button>
</UseClipboard>
</div>
<div class="bg-base-200 max-h-48 overflow-scroll rounded-sm border border-white/20 p-2">
<pre v-html="syntaxHighlight(entry.rawMessage)"></pre>
<div class="bg-base-200 max-h-125 overflow-scroll rounded-sm border border-white/20 p-2">
<JsonFormatted :value="entry.rawMessage" class="text-sm" />
</div>
</section>
<table class="table-pin-rows table table-fixed" v-if="entry instanceof ComplexLogEntry">
@@ -142,28 +142,6 @@ const toggleAllFields = computed({
},
});
function syntaxHighlight(json: string) {
json = JSON.stringify(JSON.parse(json.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")), null, 2);
return json.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|\b\d+\b)/g,
function (match: string) {
var cls = "json-number";
if (match.startsWith('"')) {
if (match.endsWith(":")) {
cls = "json-key";
} else {
cls = "json-string";
}
} else if (/true|false/.test(match)) {
cls = "json-boolean";
} else if (/null/.test(match)) {
cls = "json-null";
}
return `<span class="${cls}">${match}</span>`;
},
);
}
useSortable(list, fields);
</script>
<style scoped>
@@ -179,22 +157,4 @@ useSortable(list, fields);
Menlo,
monospace;
}
pre {
& :deep(.json-key) {
@apply text-blue;
}
& :deep(.json-string) {
@apply text-green;
}
& :deep(.json-number) {
@apply text-orange;
}
& :deep(.json-boolean) {
@apply text-purple;
}
& :deep(.json-null) {
@apply text-red;
}
}
</style>
+5 -10
View File
@@ -9,15 +9,12 @@
<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
/>
<LogDate
v-if="showTimestamp"
:date="logEntry.date"
class="shrink-0 select-none"
:class="{ 'bg-secondary': route.query.logId === logEntry.id.toString() }"
/>
>
{{ container.name }}
</RandomColorTag>
<LogDate v-if="showTimestamp" :date="logEntry.date" class="shrink-0 select-none" />
</div>
<slot />
</div>
@@ -35,6 +32,4 @@ const { hosts } = useHosts();
const container = currentContainer(toRef(() => logEntry.containerID));
const host = computed(() => hosts.value[container.value.host]);
const route = useRoute();
</script>
+19
View File
@@ -7,6 +7,7 @@
:id="item.id.toString()"
:data-time="item.date.getTime()"
class="group/entry"
:class="{ 'log-permalink-target': permalinkLogId === item.id.toString() }"
>
<component :is="item.getComponent()" :log-entry="item" />
</li>
@@ -24,6 +25,9 @@ const { messages } = defineProps<{
const { containers } = useLoggingContext();
const route = useRoute();
const permalinkLogId = computed(() => (typeof route.query.logId === "string" ? route.query.logId : ""));
const list = ref<HTMLElement[]>([]);
let previousDate = new Date();
@@ -71,6 +75,11 @@ ul {
&:last-child {
scroll-margin-block-end: 5rem;
}
&.log-permalink-target {
@apply bg-secondary/15 border-secondary -ml-1 border-l-4 pl-3;
animation: log-permalink-pulse 1.4s ease-out;
}
}
&.small {
@@ -113,4 +122,14 @@ ul {
transform: scale(1.05);
}
}
@keyframes log-permalink-pulse {
0% {
background-color: var(--color-secondary);
}
100% {
/* Settle to the resting bg-secondary/15 declared on the .li above. */
background-color: color-mix(in oklab, var(--color-secondary) 15%, transparent);
}
}
</style>
@@ -12,7 +12,7 @@
<li>
<a @click="clear()">
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
<KeyShortcut char="l" :modifiers="['shift', 'meta']" />
</a>
</li>
<li v-if="enableDownload">
@@ -0,0 +1,72 @@
/**
* @vitest-environment jsdom
*/
import { flushPromises, mount } from "@vue/test-utils";
import { describe, expect, test, vi } from "vitest";
import { createI18n } from "vue-i18n";
import { defineComponent, h, nextTick, ref } from "vue";
import { Container, Stat } from "@/models/Container";
import MultiContainerStat from "./MultiContainerStat.vue";
vi.mock("@/stores/config", () => ({
__esModule: true,
default: { hosts: [], base: "" },
withBase: (path: string) => path,
}));
// Capture recalculate() across both chart instances.
const recalculate = vi.fn();
const BarChartStub = defineComponent({
name: "BarChart",
props: ["chartData", "barClass"],
setup(_, { expose }) {
expose({ recalculate });
return () => h("div", { class: "bar-chart-stub" });
},
});
const i18n = createI18n({ legacy: false, locale: "en", missingWarn: false, fallbackWarn: false, messages: { en: {} } });
function stat(cpu: number): Stat {
return {
cpu,
memory: cpu,
memoryUsage: cpu,
networkRxTotal: 0,
networkTxTotal: 0,
diskReadTotal: 0,
diskWriteTotal: 0,
};
}
function makeContainer(id: string, cpu: number): Container {
const stats = Array.from({ length: 10 }, () => stat(cpu));
const now = new Date();
return new Container(id, now, now, now, "img", id, "cmd", "host1", {}, "running", 0, 0, stats);
}
describe("<MultiContainerStat />", () => {
test("recalculates the charts when the container changes", async () => {
// Mirror production: a parent holding a ref re-renders with a fresh
// [container] array on switch. (VueTestUtils setProps cannot trigger this
// because Container instances carry refs, which defeats prop change
// detection on a direct prop assignment.)
const current = ref<Container>(makeContainer("a", 10));
const Parent = defineComponent({
setup: () => () => h(MultiContainerStat as any, { containers: [current.value] }),
});
mount(Parent, {
global: { plugins: [i18n], stubs: { BarChart: BarChartStub, IOCard: true } },
});
await flushPromises();
recalculate.mockClear();
// Switching containers replaces the whole stats series; the parent must
// force the cached charts to fully recompute.
current.value = makeContainer("b", 90);
await nextTick();
await flushPromises();
expect(recalculate).toHaveBeenCalled();
});
});
@@ -1,58 +1,93 @@
<template>
<div class="flex gap-1 md:gap-4">
<div
class="grid hidden min-w-15 grid-cols-[auto_1fr_auto_1fr] items-center gap-0.5 text-xs leading-none sm:grid md:grid-cols-[auto_1fr]"
>
<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
:data="cpuData"
<div class="flex items-stretch gap-2.5">
<IOCard
:network-rx="networkRate.rx"
:network-tx="networkRate.tx"
:disk-read="diskRate.read"
:disk-write="diskRate.write"
/>
<StatCard
:icon="PhCpu"
:stat-value="Math.max(0, totalStat.cpu).toFixed(2) + '%'"
:limit="roundCPU(limits.cpu) + ' CPU'"
container-class="border-primary/40 bg-primary/20"
text-class="hover:text-primary"
bar-class="bg-primary"
:formatter="(value: number) => value.toFixed(2) + '%'"
/>
<StatMonitor
:data="memoryData"
card-class="bg-primary/10 md:min-w-56"
icon-class="text-primary"
:title="t('tooltip.cpu-usage', { cpu: totalStat.cpu.toFixed(2), cores: roundCPU(limits.cpu) })"
>
<template #value="{ hoveredValue }">
<span class="tabular-nums">
<span class="font-semibold"> {{ Math.max(0, hoveredValue ?? totalStat.cpu).toFixed(1) }}% </span>
<span class="text-base-content/60 max-md:hidden"> / {{ roundCPU(limits.cpu) }} CPU</span>
</span>
</template>
<template #chart="{ onHoverValue }">
<BarChart
ref="cpuChart"
:chart-data="cpuData"
bar-class="bg-primary opacity-80 hover:opacity-100"
class="h-5 w-full max-md:hidden"
@hover-value="onHoverValue"
/>
</template>
</StatCard>
<StatCard
:icon="PhMemory"
:stat-value="formatBytes(totalStat.memoryUsage)"
:limit="formatBytes(limits.memory, { short: true, decimals: 1 })"
container-class="border-secondary/40 bg-secondary/20"
text-class="hover:text-secondary"
bar-class="bg-secondary"
:formatter="(value: number) => formatBytes(value)"
/>
card-class="bg-secondary/10 md:min-w-56"
icon-class="text-secondary"
:title="
t('tooltip.memory-usage', { used: formatBytes(totalStat.memoryUsage), total: formatBytes(limits.memory) })
"
>
<template #value="{ hoveredValue }">
<span class="tabular-nums">
<span class="font-semibold">{{
formatBytes(hoveredValue ?? totalStat.memoryUsage, { short: true, decimals: 1 })
}}</span>
<span class="text-base-content/60 max-md:hidden">
/ {{ formatBytes(limits.memory, { short: true, decimals: 1 }) }}</span
>
</span>
</template>
<template #chart="{ onHoverValue }">
<BarChart
ref="memoryChart"
:chart-data="memoryData"
bar-class="bg-secondary opacity-80 hover:opacity-100"
class="h-5 w-full max-md:hidden"
@hover-value="onHoverValue"
/>
</template>
</StatCard>
</div>
</template>
<script lang="ts" setup>
import { Stat } from "@/models/Container";
import { Container } from "@/models/Container";
// @ts-ignore
import { Container, Stat, emptyStat } from "@/models/Container";
import StatCard from "@/components/LogViewer/StatCard.vue";
import IOCard from "@/components/LogViewer/IOCard.vue";
import BarChart from "@/components/BarChart.vue";
import PhCpu from "~icons/ph/cpu";
// @ts-ignore
import PhMemory from "~icons/ph/memory";
const { containers } = defineProps<{
containers: Container[];
}>();
const totalStat = ref<Stat>({ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 });
const { t } = useI18n();
const totalStat = ref<Stat>(emptyStat());
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
const { hosts } = useHosts();
const cpuChart = useTemplateRef("cpuChart");
const memoryChart = useTemplateRef("memoryChart");
const networkRate = ref({ rx: 0, tx: 0 });
const diskRate = ref({ read: 0, write: 0 });
const roundCPU = (num: number) => (Number.isInteger(num) ? num.toFixed(0) : num.toFixed(1));
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;
@@ -63,33 +98,37 @@ watch(
() => {
const initial: Stat[] = [];
for (let i = 1; i <= 300; i++) {
const stat = containers.reduce(
(acc, container) => {
const item = container.statsHistory.at(-i);
if (!item) {
return acc;
}
const cores = toContainerCores(container);
return {
cpu: acc.cpu + item.cpu / cores,
memory: acc.memory + item.memory,
memoryUsage: acc.memoryUsage + item.memoryUsage,
networkRxTotal: acc.networkRxTotal + item.networkRxTotal,
networkTxTotal: acc.networkTxTotal + item.networkTxTotal,
};
},
{ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 },
);
const stat = containers.reduce((acc, container) => {
const item = container.statsHistory.at(-i);
if (!item) {
return acc;
}
const cores = toContainerCores(container);
return {
cpu: acc.cpu + item.cpu / cores,
memory: acc.memory + item.memory,
memoryUsage: acc.memoryUsage + item.memoryUsage,
networkRxTotal: acc.networkRxTotal + item.networkRxTotal,
networkTxTotal: acc.networkTxTotal + item.networkTxTotal,
diskReadTotal: acc.diskReadTotal + item.diskReadTotal,
diskWriteTotal: acc.diskWriteTotal + item.diskWriteTotal,
};
}, emptyStat());
initial.push(stat);
}
totalStat.value = initial[0];
reset({ initial: initial.reverse() });
// Charts cache their downsampled bars and only patch the last bar per tick;
// a container switch replaces the whole series, so force a full recalculate.
nextTick(() => {
cpuChart.value?.recalculate();
memoryChart.value?.recalculate();
});
},
{ immediate: true },
);
const limits = computed(() => {
// Group containers by host
const containersByHost = new Map<string, Container[]>();
containers.forEach((container) => {
if (!containersByHost.has(container.host)) {
@@ -101,77 +140,67 @@ const limits = computed(() => {
let totalCpu = 0;
let totalMemory = 0;
// Process each host independently
containersByHost.forEach((hostContainers, hostId) => {
const hostInfo = hosts.value[hostId];
const hostTotalMemory = hostInfo?.memTotal || 0;
const hostTotalCpu = hostInfo?.nCPU || 0;
// Check if any container lacks limits
const hasUnlimitedCpu = hostContainers.some((c) => !c.cpuLimit || c.cpuLimit <= 0);
const hasUnlimitedMemory = hostContainers.some((c) => !c.memoryLimit);
// Calculate CPU for this host
if (hasUnlimitedCpu) {
// At least one container has no limit, use host total
totalCpu += hostTotalCpu;
} else {
// All containers have limits, sum them up (capped at host total)
const sumCpu = hostContainers.reduce((sum, c) => sum + (c.cpuLimit || 0), 0);
totalCpu += Math.min(sumCpu, hostTotalCpu);
}
// Calculate Memory for this host
if (hasUnlimitedMemory) {
// At least one container has no limit, use host total
totalMemory += hostTotalMemory;
} else {
// All containers have limits, sum them up (capped at host total)
const sumMemory = hostContainers.reduce((sum, c) => sum + (c.memoryLimit || 0), 0);
totalMemory += Math.min(sumMemory, hostTotalMemory);
}
});
return {
cpu: totalCpu,
memory: totalMemory,
};
return { cpu: totalCpu, memory: totalMemory };
});
useIntervalFn(() => {
const previousStat = totalStat.value;
totalStat.value = containers.reduce(
(acc, container) => {
const cores = toContainerCores(container);
return {
cpu: acc.cpu + container.stat.cpu / cores,
memory: acc.memory + container.stat.memory,
memoryUsage: acc.memoryUsage + container.stat.memoryUsage,
networkRxTotal: acc.networkRxTotal + container.stat.networkRxTotal,
networkTxTotal: acc.networkTxTotal + container.stat.networkTxTotal,
};
},
{ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 },
);
totalStat.value = containers.reduce((acc, container) => {
const cores = toContainerCores(container);
return {
cpu: acc.cpu + container.stat.cpu / cores,
memory: acc.memory + container.stat.memory,
memoryUsage: acc.memoryUsage + container.stat.memoryUsage,
networkRxTotal: acc.networkRxTotal + container.stat.networkRxTotal,
networkTxTotal: acc.networkTxTotal + container.stat.networkTxTotal,
diskReadTotal: acc.diskReadTotal + container.stat.diskReadTotal,
diskWriteTotal: acc.diskWriteTotal + container.stat.diskWriteTotal,
};
}, emptyStat());
networkRate.value = {
rx: Math.max(0, totalStat.value.networkRxTotal - previousStat.networkRxTotal),
tx: Math.max(0, totalStat.value.networkTxTotal - previousStat.networkTxTotal),
};
diskRate.value = {
read: Math.max(0, totalStat.value.diskReadTotal - previousStat.diskReadTotal),
write: Math.max(0, totalStat.value.diskWriteTotal - previousStat.diskWriteTotal),
};
}, 1000);
const cpuData = computed(() =>
history.value.map((stat, i) => ({
x: i,
y: Math.max(0, stat.cpu),
history.value.map((stat) => ({
percent: Math.max(0, stat.cpu),
value: Math.max(0, stat.cpu),
})),
);
const memoryData = computed(() =>
history.value.map((stat, i) => ({
x: i,
y: stat.memory,
history.value.map((stat) => ({
percent: stat.memory,
value: stat.memoryUsage,
})),
);
@@ -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>
+41 -17
View File
@@ -1,28 +1,39 @@
<template>
<table class="table-zebra table-pin-rows table-md table" v-if="!loading">
<div class="w-full overflow-x-auto" v-if="!loading">
<table class="table-zebra table-pin-rows table-md table" v-if="columns.length">
<thead>
<tr>
<th v-for="column in columns" :key="column" class="font-mono">{{ column }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in table" :key="index">
<td v-for="column in columns" :key="column" class="max-w-md align-top">
<span v-if="format(row[column]) === null" class="text-base-content/30 italic">NULL</span>
<span v-else class="block truncate font-mono" :title="format(row[column]) ?? undefined">{{
format(row[column])
}}</span>
</td>
</tr>
</tbody>
</table>
<div v-else class="text-base-content/50 flex flex-col items-center gap-2 py-16">
<ph:database class="size-8 opacity-40" />
<span>{{ $t("analytics.no_results") }}</span>
</div>
</div>
<table class="table-md table" v-else>
<thead>
<tr>
<th v-for="column in columns" :key="column">{{ column }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in table" :key="row">
<td v-for="column in columns" :key="column">{{ row[column] }}</td>
</tr>
</tbody>
</table>
<table class="table-md table animate-pulse" v-else>
<thead>
<tr>
<th v-for="_ in 3">
<th v-for="i in 3" :key="i">
<div class="bg-base-content/50 h-4 w-20 animate-pulse opacity-50"></div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="_ in 9">
<td v-for="_ in 3">
<div class="bg-base-content/50 h-4 w-20 opacity-20"></div>
<tr v-for="i in 9" :key="i">
<td v-for="j in 3" :key="j">
<div class="bg-base-content/50 h-4 w-20 animate-pulse opacity-20"></div>
</td>
</tr>
</tbody>
@@ -37,4 +48,17 @@ const { loading, table } = defineProps<{
}>();
const columns = computed(() => (table.numRows > 0 ? Object.keys(table.get(0) as Record<string, any>) : []));
function format(value: unknown): string | null {
if (value === null || value === undefined) return null;
if (typeof value === "bigint") return value.toString();
if (typeof value === "object") {
try {
return JSON.stringify(value, (_, v) => (typeof v === "bigint" ? v.toString() : v));
} catch {
return String(value);
}
}
return String(value);
}
</script>
@@ -0,0 +1,108 @@
import { mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { nextTick } from "vue";
import { createI18n } from "vue-i18n";
import SearchStatus from "./SearchStatus.vue";
import IndeterminateBar from "@/components/common/IndeterminateBar.vue";
/**
* @vitest-environment jsdom
*/
const i18n = createI18n({
legacy: false,
locale: "en",
messages: {
en: {
label: {
"search-status": {
searching: "Searching older logs…",
"searching-to": "Searching older logs… back to {time}",
capped: "{count} matches · searched back to {time}",
exhausted: "Searched all logs · {count} matches",
empty: "No matches · searched all logs",
},
},
},
},
});
function createStatus(overrides: Record<string, unknown> = {}) {
return mount(SearchStatus, {
global: { plugins: [i18n] },
props: {
status: { active: false, done: false, matches: 0, scannedTo: undefined, reason: undefined, ...overrides },
},
});
}
describe("<SearchStatus />", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("stays hidden while a search is active but still fast (flash avoidance)", async () => {
const wrapper = createStatus({ active: true });
vi.advanceTimersByTime(100);
await nextTick();
expect(wrapper.find("[data-state]").exists()).toBe(false);
});
test("shows searching state once a search runs past the reveal delay", async () => {
const wrapper = createStatus({ active: true });
vi.advanceTimersByTime(400);
await nextTick();
expect(wrapper.find('[data-state="searching"]').exists()).toBe(true);
expect(wrapper.findComponent(IndeterminateBar).exists()).toBe(true);
});
test("reveals the searching bar even when progress events arrive faster than the delay", async () => {
const wrapper = createStatus({ active: true });
// a slow search emits a progress event each window; the reveal delay must
// measure from when the search started, not restart on every event
for (let i = 0; i < 5; i++) {
vi.advanceTimersByTime(100);
await wrapper.setProps({ status: { active: true, done: false, matches: i, scannedTo: `t${i}` } });
await nextTick();
}
expect(wrapper.find('[data-state="searching"]').exists()).toBe(true);
});
test("shows the empty state when a search finishes with no matches", async () => {
const wrapper = createStatus({ active: false, done: true, matches: 0, reason: "exhausted" });
await nextTick();
expect(wrapper.find('[data-state="empty"]').exists()).toBe(true);
});
test("shows a completion summary for a slow exhausted search", async () => {
const wrapper = createStatus({ active: true });
vi.advanceTimersByTime(400);
await nextTick();
await wrapper.setProps({ status: { active: false, done: true, matches: 3, reason: "exhausted" } });
await nextTick();
expect(wrapper.find('[data-state="exhausted"]').exists()).toBe(true);
expect(wrapper.text()).toContain("3");
});
test("shows a capped summary for a slow capped search", async () => {
const wrapper = createStatus({ active: true });
vi.advanceTimersByTime(400);
await nextTick();
await wrapper.setProps({
status: { active: false, done: true, matches: 50, reason: "capped", scannedTo: "2026-06-01T13:10:00Z" },
});
await nextTick();
expect(wrapper.find('[data-state="capped"]').exists()).toBe(true);
});
test("stays quiet for a fast search that returned matches", async () => {
const wrapper = createStatus({ active: true });
vi.advanceTimersByTime(100);
await nextTick();
await wrapper.setProps({ status: { active: false, done: true, matches: 5, reason: "capped" } });
await nextTick();
expect(wrapper.find("[data-state]").exists()).toBe(false);
});
});
@@ -0,0 +1,69 @@
<template>
<div
v-if="state"
:data-state="state"
class="bg-base-200/80 text-base-content/70 flex items-center gap-2 px-4 py-1.5 text-xs backdrop-blur"
>
<template v-if="state === 'searching'">
<span>{{
status.scannedTo ? $t("label.search-status.searching-to", { time }) : $t("label.search-status.searching")
}}</span>
<IndeterminateBar color="primary" class="ml-auto" />
</template>
<span v-else-if="state === 'empty'">{{ $t("label.search-status.empty") }}</span>
<span v-else-if="state === 'capped'" class="tabular-nums">
{{ $t("label.search-status.capped", { count: status.matches, time }) }}
</span>
<span v-else-if="state === 'exhausted'" class="tabular-nums">
{{ $t("label.search-status.exhausted", { count: status.matches }) }}
</span>
</div>
</template>
<script lang="ts" setup>
import { type SearchStatus } from "@/composable/eventStreams";
const props = defineProps<{ status: SearchStatus }>();
// Reveal the in-progress bar only after a short delay so fast searches (the
// common case, which return almost instantly) never flash it. Slow searches
// — sparse matches over a large log — are the only ones that surface it.
const showSearching = ref(false);
// Remember whether this search ever ran slow, so the completion summary only
// shows for searches that actually made the user wait.
const wasSlow = ref(false);
// Watch the boolean, not the whole status object: a slow search replaces the
// status object on every progress event, and re-arming the timer each time
// would keep the bar from ever appearing. The delay must measure from when the
// search started.
const active = computed(() => props.status.active);
let timer: ReturnType<typeof setTimeout> | undefined;
watch(
active,
(isActive) => {
clearTimeout(timer);
if (isActive) {
timer = setTimeout(() => {
showSearching.value = true;
wasSlow.value = true;
}, 400);
} else {
showSearching.value = false;
}
},
{ immediate: true },
);
onScopeDispose(() => clearTimeout(timer));
const time = computed(() => (props.status.scannedTo ? new Date(props.status.scannedTo).toLocaleString() : ""));
const state = computed<"searching" | "empty" | "capped" | "exhausted" | null>(() => {
if (showSearching.value) return "searching";
if (!props.status.active && props.status.done) {
if (props.status.matches === 0) return "empty";
if (wasSlow.value) return props.status.reason === "capped" ? "capped" : "exhausted";
}
return null;
});
</script>
@@ -9,17 +9,8 @@
</template>
<script lang="ts" setup>
import { SimpleLogEntry } from "@/models/LogEntry";
import AnsiConvertor from "ansi-to-html";
const ansiConvertor = new AnsiConvertor({
escapeXML: false,
fg: "var(--color-base-content)",
bg: "var(--color-base-100)",
});
defineProps<{
logEntry: SimpleLogEntry;
}>();
const colorize = (value: string) => ansiConvertor.toHtml(value);
</script>
+27
View File
@@ -0,0 +1,27 @@
<template>
<div
class="flex min-w-0 flex-col gap-px rounded-md px-2.5 py-1"
:class="cardClass"
:title="title"
@mouseleave="hoveredValue = null"
>
<div class="flex h-[18px] items-center gap-2 text-[12.5px] font-medium">
<component :is="icon" class="size-3.5 shrink-0" :class="iconClass" />
<slot name="value" :hoveredValue="hoveredValue" />
</div>
<slot name="chart" :onHoverValue="(v: number) => (hoveredValue = v)" />
</div>
</template>
<script lang="ts" setup>
import type { Component } from "vue";
defineProps<{
icon: Component;
cardClass?: string;
iconClass?: string;
title?: string;
}>();
const hoveredValue = ref<number | null>(null);
</script>
@@ -1,66 +0,0 @@
<template>
<div class="relative" @mouseenter="mouseOver = true" @mouseleave="mouseOver = false" :class="textClass">
<div class="overflow-hidden rounded-xs border px-px pt-1 pb-px max-md:hidden" :class="containerClass">
<BarChart
: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)"
/>
</div>
<div class="bg-base-200 flex gap-1 rounded-sm p-px text-xs md:absolute md:-top-2 md:-left-0.5">
<component :is="icon" class="text-sm" />
<div class="font-bold tabular-nums select-none">
{{ displayValue }}
<span v-if="limit !== -1 && !mouseOver" class="max-md:hidden"> / {{ limit }} </span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Component } from "vue";
const {
data,
icon,
statValue,
limit = -1,
containerClass = "border-primary",
textClass = "",
barClass = "bg-primary",
formatter,
} = defineProps<{
data: Point<unknown>[];
icon: Component;
statValue: string | number;
limit?: string | number;
containerClass?: string;
textClass?: string;
barClass?: string;
formatter?: (value: number) => string;
}>();
const chartData = computed(() => data.map((point) => (point.y as number) ?? 0));
const mouseOver = ref(false);
const hoveredRange = ref<{ start: number; end: number } | null>(null);
function onHoverIndexChange(startIndex: number, endIndex: number) {
hoveredRange.value = { start: startIndex, end: endIndex };
}
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 (formatter) {
return formatter(avg);
}
return avg.toFixed(2);
}
return statValue;
});
</script>
@@ -21,7 +21,7 @@ defineExpose({
clear: () => source.value?.clear(),
});
onKeyStroke("k", (e) => {
onKeyStroke(["l", "L"], (e) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey) {
source.value?.clear();
e.preventDefault();
@@ -7,17 +7,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
</li>
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
</svg></button>
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90" tabindex="0"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
</svg></a>
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
<!--v-if-->
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<li><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
</svg> action.see-in-context</a></li>
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
</svg> action.copy-log</a></li>
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<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-->
@@ -48,17 +48,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
</li>
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
</svg></button>
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90" tabindex="0"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
</svg></a>
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
<!--v-if-->
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<li><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
</svg> action.see-in-context</a></li>
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
</svg> action.copy-log</a></li>
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<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-->
@@ -89,17 +89,17 @@ exports[`<ContainerEventSource /> > render html correctly > should render messag
</li>
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><button tabindex="0" class="btn btn-square btn-xs border-base-content/20 bg-base-100 border opacity-0 shadow-sm group-hover/entry:opacity-90"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
</svg></button>
<div class="dropdown dropdown-hover absolute -left-2 z-10 font-sans dropdown-right dropdown-end"><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class="btn btn-square btn-xs border-base-content/20 bg-base-100 pointer-events-auto! opacity-0 shadow-sm group-hover/entry:opacity-90" tabindex="0"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
</svg></a>
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 text-sm shadow-sm">
<!--v-if-->
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<li><a href="/container/abc/time/2019-06-12T10:55:42.459Z?logId=1" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<path fill="currentColor" d="M6 23H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm-6-4.5q-3 0-5.437-1.775T3 12q1.125-2.95 3.563-4.725T12 5.5t5.438 1.775T21 12q-1.125 2.95-3.562 4.725T12 18.5m0-3q1.45 0 2.475-1.025T15.5 12t-1.025-2.475T12 8.5T9.525 9.525T8.5 12t1.025 2.475T12 15.5m0-2q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm20 0V3h-3V1h3q.825 0 1.413.588T23 3v3z"></path>
</svg> action.see-in-context</a></li>
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"></path>
</svg> action.copy-log</a></li>
<li><a disabled="true" title="error.copy-not-supported" class="cursor-not-allowed opacity-50"><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<li><a disabled="false" title="" class=""><svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
<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-->
+4 -10
View File
@@ -1,5 +1,5 @@
<template>
<div class="rounded-lg border p-2 md:p-3" :class="containerClass">
<div class="rounded-lg p-2 md:p-3" :class="containerClass">
<div class="mb-2 flex items-center gap-1.5 text-sm font-medium" :class="textClass">
<component :is="icon" class="text-lg" />
<span>{{ label }}</span>
@@ -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(() => {
+116 -24
View File
@@ -1,39 +1,92 @@
<template>
<div class="card bg-base-100 shadow-sm" :class="{ 'opacity-60': !alert.enabled }">
<div
class="card bg-base-100 shadow-sm"
:class="{ 'opacity-60': !alert.enabled, 'highlight-new': isHighlighted }"
@animationend="isHighlighted = false"
>
<div class="card-body gap-4 p-5">
<!-- Header -->
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<h4 class="flex items-center gap-2 text-lg font-semibold">
<span>{{ alert.name }}</span> <span class="text-sm font-light"></span>
<span class="flex gap-1 text-xs font-light" :class="{ 'text-warning': !alert.dispatcher }">
<template v-if="alert.dispatcher">
<mdi:webhook v-if="alert.dispatcher.type === 'webhook'" />
<mdi:cloud v-else />
{{ alert.dispatcher.name }}
</template>
<template v-else>
<mdi:alert-outline />
{{ $t("notifications.alert.dispatcher-deleted") }}
</template>
</span>
<div class="flex items-start justify-between gap-2">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<h4 class="flex min-w-0 flex-wrap items-center gap-2 text-lg font-semibold">
<mdi:chart-line v-if="alert.metricExpression" class="text-info shrink-0" />
<mdi:bell-ring-outline v-else-if="alert.eventExpression" class="text-info shrink-0" />
<mdi:text-box-outline v-else class="text-info shrink-0" />
<span class="break-all">{{ 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" />
<input
type="checkbox"
class="toggle toggle-primary shrink-0"
:checked="alert.enabled"
@change="toggleEnabled"
/>
</div>
<!-- Expressions -->
<div class="text-base-content/80 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
<span>{{ $t("notifications.alert.containers") }}</span>
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.containerExpression }}</code>
<span>{{ $t("notifications.alert.log-filter") }}</span>
<code class="bg-base-200 text-base-content rounded px-2 py-0.5 font-mono">{{ alert.logExpression }}</code>
<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">
<div
class="border-base-content/10 text-base-content/80 flex items-center justify-between gap-2 border-t pt-3 text-xs"
>
<div class="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1">
<span>
{{ $t("notifications.alert.containers-count", { count: alert.triggeredContainers }) }}
</span>
@@ -44,7 +97,7 @@
{{ $t("notifications.alert.last-triggered", { time: formatTimeAgo(alert.lastTriggeredAt) }) }}
</span>
</div>
<div class="flex items-center gap-1">
<div class="flex shrink-0 items-center gap-1">
<button class="btn btn-ghost btn-square" @click="editAlert">
<mdi:pencil-outline />
</button>
@@ -59,16 +112,40 @@
</template>
<script lang="ts" setup>
import type { NotificationRule } from "@/types/notifications";
import type { Dispatcher, NotificationRule } from "@/types/notifications";
import AlertForm from "./AlertForm.vue";
const { alert, onUpdated } = defineProps<{
const { alert, onUpdated, highlight } = defineProps<{
alert: NotificationRule;
onUpdated?: () => void;
highlight?: boolean;
}>();
const isHighlighted = ref(highlight ?? false);
watch(
() => highlight,
(v) => {
if (v) isHighlighted.value = true;
},
);
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);
@@ -99,3 +176,18 @@ async function deleteAlert() {
}
}
</script>
<style scoped>
.card.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>
+105 -220
View File
@@ -21,11 +21,42 @@
/>
</fieldset>
<!-- Alert Type Toggle -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.alert-type") }}</legend>
<div class="flex gap-2">
<button
class="btn btn-sm"
:class="alertType === 'log' ? 'btn-primary' : 'btn-outline'"
@click="alertType = 'log'"
>
<mdi:text-box-outline class="mr-1" />
{{ $t("notifications.alert-form.log-alert") }}
</button>
<button
class="btn btn-sm"
:class="alertType === 'metric' ? 'btn-primary' : 'btn-outline'"
@click="alertType = 'metric'"
>
<mdi:chart-line class="mr-1" />
{{ $t("notifications.alert-form.metric-alert") }}
</button>
<button
class="btn btn-sm"
:class="alertType === 'event' ? 'btn-primary' : 'btn-outline'"
@click="alertType = 'event'"
>
<mdi:bell-ring-outline class="mr-1" />
{{ $t("notifications.alert-form.event-alert") }}
</button>
</div>
</fieldset>
<!-- Container Filter -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.container-filter") }}</legend>
<div
class="input focus-within:input-primary w-full focus-within:z-50"
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
:class="
containerExpression.trim() && !containerResult?.error
? 'input-primary'
@@ -52,27 +83,36 @@
</div>
</fieldset>
<!-- Log Filter -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.log-filter") }}</legend>
<div
class="input focus-within:input-primary w-full focus-within:z-50"
:class="logExpression.trim() && !logError ? 'input-primary' : { 'input-error!': logError }"
>
<div ref="logEditorRef" class="w-full"></div>
</div>
<div v-if="logError || logExpression" class="fieldset-label">
<span v-if="logError" class="text-error">{{ logError }}</span>
<span v-else-if="logMessages.length" class="text-success">
<mdi:check class="inline" />
{{ $t("notifications.alert-form.logs-match", { count: logTotalCount }) }}
</span>
<span v-else-if="!isLoading" class="text-warning">
<mdi:alert class="inline" />
{{ $t("notifications.alert-form.no-logs-match") }}
</span>
</div>
</fieldset>
<!-- Type-specific fields -->
<KeepAlive>
<LogAlertFields
v-if="alertType === 'log'"
ref="fieldsRef"
:alert="alert"
:prefill="prefill"
:container-expression="containerExpression"
:is-loading="isLoading"
:validate-preview="validatePreview"
/>
<MetricAlertFields
v-else-if="alertType === 'metric'"
ref="fieldsRef"
:alert="alert"
:prefill="prefill"
:container-expression="containerExpression"
:is-loading="isLoading"
:validate-preview="validatePreview"
/>
<EventAlertFields
v-else
ref="fieldsRef"
:alert="alert"
:prefill="prefill"
:container-expression="containerExpression"
:is-loading="isLoading"
:validate-preview="validatePreview"
/>
</KeepAlive>
<!-- Destination -->
<fieldset class="fieldset">
@@ -113,16 +153,6 @@
</div>
</fieldset>
<!-- Log Preview -->
<div v-if="logMessages.length" class="mt-4">
<div class="mb-2 text-lg">{{ $t("notifications.alert-form.preview") }}</div>
<LogList
:messages="logMessages"
:last-selected-item="undefined"
class="border-base-content/50 h-64 overflow-hidden rounded-lg border"
/>
</div>
<!-- Error -->
<div v-if="saveError" class="alert alert-error">
<span>{{ saveError }}</span>
@@ -131,7 +161,7 @@
<!-- Actions -->
<div class="flex justify-end gap-2 pt-4">
<button class="btn" @click="close?.()">{{ $t("notifications.alert-form.cancel") }}</button>
<button class="btn btn-primary" :disabled="!canSave" @click="saveAlert">
<button class="btn btn-primary" :disabled="!canSave" @click="save">
<span v-if="isSaving" class="loading loading-spinner loading-sm"></span>
{{ isEditing ? $t("notifications.alert-form.save") : $t("notifications.alert-form.create") }}
</button>
@@ -140,209 +170,64 @@
</template>
<script lang="ts" setup>
import { type LogEvent, type LogEntry, type LogMessage, asLogEntry } from "@/models/LogEntry";
import { Container } from "@/models/Container";
import type { ContainerJson } from "@/types/Container";
import { createExprEditor, createContainerHints, createLogHints } from "@/composable/exprEditor";
import { useAlertForm } from "@/composable/alertForm";
import LogAlertFields from "./LogAlertFields.vue";
import MetricAlertFields from "./MetricAlertFields.vue";
import EventAlertFields from "./EventAlertFields.vue";
import type { NotificationRule } from "@/types/notifications";
import type { Dispatcher, NotificationRule, PreviewResult } from "@/types/notifications";
const { close, onCreated, alert, prefill } = defineProps<{
const props = defineProps<{
close?: () => void;
onCreated?: () => void;
alert?: NotificationRule;
prefill?: { name?: string; containerExpression?: string; logExpression?: string };
prefill?: {
name?: string;
containerExpression?: string;
logExpression?: string;
metricExpression?: string;
eventExpression?: string;
dispatcherId?: number;
};
}>();
// Fetch dispatchers
const destinations = ref<Dispatcher[]>([]);
onMounted(async () => {
const res = await fetch(withBase("/api/notifications/dispatchers"));
destinations.value = await res.json();
});
// Container store for autocomplete hints
const containerStore = useContainerStore();
const { containers } = storeToRefs(containerStore);
const containerNames = computed(() => [
...new Set(containers.value.filter((c) => c.state === "running").map((c) => c.name)),
]);
const imageNames = computed(() => [...new Set(containers.value.map((c) => c.image))]);
const hostNames = computed(() => [...new Set(containers.value.map((c) => c.host))]);
const {
isEditing,
alertName,
containerExpression,
dispatcherId,
destinations,
selectedDestination,
containerResult,
isLoading,
isSaving,
saveError,
baseCanSave,
setupContainerEditor,
saveAlert,
validatePreview,
} = useAlertForm(props);
// Template refs
const alertNameInput = ref<HTMLInputElement>();
const containerEditorRef = ref<HTMLElement>();
const logEditorRef = ref<HTMLElement>();
const destinationDropdown = ref<HTMLDetailsElement>();
// Form state
const isEditing = computed(() => !!alert);
const alertName = ref(alert?.name ?? prefill?.name ?? "");
const containerExpression = ref(alert?.containerExpression ?? prefill?.containerExpression ?? "");
const logExpression = ref(alert?.logExpression ?? prefill?.logExpression ?? "");
const dispatcherId = ref(alert?.dispatcher?.id ?? 0);
const selectedDestination = computed(() => destinations.value.find((d) => d.id === dispatcherId.value));
const fieldsRef = ref<
InstanceType<typeof LogAlertFields> | InstanceType<typeof MetricAlertFields> | InstanceType<typeof EventAlertFields>
>();
useFocus(alertNameInput, { initialValue: true });
// Validation state
interface ContainerResult {
error?: string;
containers?: Container[];
}
const containerResult = ref<ContainerResult | null>(null);
const logError = ref<string | null>(null);
const logTotalCount = ref(0);
const logMessages = shallowRef<LogEntry<LogMessage>[]>([]);
const messageKeys = ref<string[]>([]);
const isLoading = ref(false);
const isSaving = ref(false);
const saveError = ref<string | null>(null);
const canSave = computed(
() =>
alertName.value.trim() &&
containerExpression.value.trim() &&
dispatcherId.value > 0 &&
!containerResult.value?.error &&
!logError.value &&
!isSaving.value,
// Alert type
const alertType = ref<"log" | "metric" | "event">(
props.alert?.metricExpression ? "metric" : props.alert?.eventExpression ? "event" : "log",
);
async function saveAlert() {
if (!canSave.value) return;
const canSave = computed(() => baseCanSave.value && (fieldsRef.value?.canSave ?? false));
isSaving.value = true;
saveError.value = null;
try {
const input = {
name: alertName.value.trim(),
containerExpression: containerExpression.value,
logExpression: logExpression.value,
dispatcherId: dispatcherId.value!,
enabled: true,
};
const url = isEditing.value
? withBase(`/api/notifications/rules/${alert!.id}`)
: withBase("/api/notifications/rules");
const res = await fetch(url, {
method: isEditing.value ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to save alert");
}
onCreated?.();
close?.();
} catch (e) {
saveError.value = e instanceof Error ? e.message : "Failed to save alert";
} finally {
isSaving.value = false;
}
async function save() {
if (!canSave.value || !fieldsRef.value) return;
await saveAlert(fieldsRef.value.typeFields);
}
async function validateExpressions() {
if (!containerExpression.value && !logExpression.value) {
containerResult.value = null;
logError.value = null;
logTotalCount.value = 0;
logMessages.value = [];
messageKeys.value = [];
return;
}
isLoading.value = true;
try {
const res = await fetch(withBase("/api/notifications/preview"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
containerExpression: containerExpression.value,
logExpression: logExpression.value || undefined,
}),
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || "Preview failed");
}
const data: PreviewResult = await res.json();
// Update container result
containerResult.value = containerExpression.value
? {
error: data.containerError ?? undefined,
containers: data.matchedContainers?.map((c) => Container.fromJSON(c as ContainerJson)),
}
: null;
// Update message keys for autocomplete
messageKeys.value = data.messageKeys ?? [];
// Update log result
if (logExpression.value && !data.containerError) {
logError.value = data.logError ?? null;
logTotalCount.value = data.totalLogs;
logMessages.value = data.matchedLogs?.map((event) => asLogEntry(event as LogEvent)) ?? [];
} else {
logError.value = null;
logTotalCount.value = 0;
logMessages.value = [];
}
} catch (e) {
containerResult.value = { error: e instanceof Error ? e.message : "Unknown error" };
} finally {
isLoading.value = false;
}
}
const debouncedValidate = useDebounceFn(validateExpressions, 500);
watch(
[containerExpression, logExpression],
() => {
isLoading.value = true;
debouncedValidate();
},
{ immediate: true },
);
let containerEditorView: Awaited<ReturnType<typeof createExprEditor>> | undefined;
let logEditorView: Awaited<ReturnType<typeof createExprEditor>> | undefined;
onMounted(async () => {
if (containerEditorRef.value) {
containerEditorView = await createExprEditor({
parent: containerEditorRef.value,
placeholder: 'name contains "api"',
initialValue: alert?.containerExpression ?? prefill?.containerExpression ?? "",
getHints: () => createContainerHints(containerNames.value, imageNames.value, hostNames.value),
onChange: (v) => (containerExpression.value = v),
});
}
if (logEditorRef.value) {
logEditorView = await createExprEditor({
parent: logEditorRef.value,
placeholder: 'level == "error" && message contains "timeout"',
initialValue: alert?.logExpression ?? prefill?.logExpression ?? "",
getHints: () => createLogHints(messageKeys.value),
onChange: (v) => (logExpression.value = v),
});
}
});
onScopeDispose(() => {
containerEditorView?.destroy();
logEditorView?.destroy();
});
// Container editor
setupContainerEditor(containerEditorRef);
</script>
@@ -10,10 +10,26 @@
readonly
disabled
class="input join-item w-full font-mono"
:class="cloudStatusError ? 'input-error' : 'input-success'"
:class="
cloudStatusError === 'auth'
? 'input-error'
: cloudStatusError === 'unavailable'
? 'input-warning'
: '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" />
<span
class="join-item btn pointer-events-none"
:class="
cloudStatusError === 'auth'
? 'btn-error'
: cloudStatusError === 'unavailable'
? 'btn-warning'
: 'btn-success'
"
>
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
<mdi:cloud-off-outline v-else-if="cloudStatusError === 'unavailable'" class="text-lg" />
<mdi:check v-else class="text-lg" />
</span>
</div>
@@ -24,9 +40,14 @@
<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 class="alert" :class="cloudStatusError === 'auth' ? 'alert-error' : 'alert-warning'">
<mdi:alert-circle v-if="cloudStatusError === 'auth'" class="text-lg" />
<mdi:cloud-off-outline v-else class="text-lg" />
<span>{{
cloudStatusError === "auth"
? $t("notifications.destination-form.cloud-relink")
: $t("notifications.destination-form.cloud-unavailable")
}}</span>
</div>
</div>
<div v-else-if="cloudStatus" class="mt-3 space-y-3">
@@ -91,42 +112,16 @@ const { destination, close } = defineProps<{
}>();
const callbackUrl = `${window.location.origin}${withBase("/")}`;
const cloudLinkUrl = `${__CLOUD_URL__}/link?appUrl=${encodeURIComponent(callbackUrl)}`;
const cloudLinkUrl = `${__CLOUD_URL__}/link?appUrl=${encodeURIComponent(callbackUrl)}&from=notifications`;
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 { cloudStatus, cloudStatusError, isLoadingCloudStatus, fetchCloudStatus } = useCloudConfig();
const usagePercent = computed(() => {
if (!cloudStatus.value) return 0;
return (cloudStatus.value.usage.events_used / cloudStatus.value.usage.events_limit) * 100;
});
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();
}
@@ -0,0 +1,16 @@
<template>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.cooldown-label") }}</legend>
<input v-model.number="model" type="range" min="0" max="3600" step="10" class="range range-primary" />
<p class="text-base-content/50 mt-1 text-xs">
<template v-if="model === 0">{{ $t("notifications.alert-form.no-cooldown") }}</template>
<template v-else>{{
$t("notifications.alert-form.cooldown-hint", { duration: formatDuration(model, locale || undefined) })
}}</template>
</p>
</fieldset>
</template>
<script lang="ts" setup>
const model = defineModel<number>({ required: true });
</script>
@@ -27,7 +27,10 @@
<li>
<a @click="editDestination">{{ $t("notifications.destination.edit") }}</a>
</li>
<li>
<li v-if="destination.type !== 'cloud'">
<a @click="duplicateDestination">{{ $t("notifications.destination.duplicate") }}</a>
</li>
<li v-if="destination.type !== 'cloud'">
<a class="text-error" @click="deleteDestination">{{ $t("notifications.destination.delete") }}</a>
</li>
</ul>
@@ -61,6 +64,21 @@ function editDestination() {
);
}
async function duplicateDestination() {
await fetch(withBase("/api/notifications/dispatchers"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: `Copy of ${destination.name}`,
type: destination.type,
url: destination.url,
template: destination.template,
headers: destination.headers,
}),
});
onUpdated?.();
}
async function deleteDestination() {
await fetch(withBase(`/api/notifications/dispatchers/${destination.id}`), { method: "DELETE" });
onUpdated?.();
@@ -2,30 +2,33 @@
<div class="space-y-4 p-4">
<div class="mb-6">
<h2 class="text-2xl font-bold">
{{
isEditing
? $t("notifications.destination-form.edit-title")
: $t("notifications.destination-form.create-title")
}}
<template v-if="type === 'cloud'">
{{ $t("notifications.destination-form.cloud-title") }}
</template>
<template v-else>
{{
isEditing
? $t("notifications.destination-form.edit-title")
: $t("notifications.destination-form.create-title")
}}
</template>
</h2>
<p class="text-base-content/60">{{ $t("notifications.destination-form.description") }}</p>
<p class="text-base-content/60">
<template v-if="type === 'cloud'">
{{ $t("notifications.destination-form.cloud-description") }}
</template>
<template v-else>
{{ $t("notifications.destination-form.description") }}
</template>
</p>
</div>
<!-- Link Success Alert -->
<div v-if="showLinkSuccess" class="alert alert-success">
<mdi:check-circle class="text-lg" />
<div>
<div class="font-semibold">{{ $t("notifications.cloud-link-success.title") }}</div>
<div class="text-sm">{{ $t("notifications.cloud-link-success.message") }}</div>
</div>
</div>
<!-- Type Selection -->
<fieldset class="fieldset">
<!-- Type Selection (only when creating) -->
<fieldset v-if="!isEditing" class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.destination-form.type") }}</legend>
<div class="space-y-3">
<label
class="card card-border 20 cursor-pointer transition-colors"
class="card card-border cursor-pointer transition-colors"
:class="type === 'webhook' ? 'border-primary bg-primary/10' : ''"
>
<div class="card-body flex-row items-center gap-3 p-4">
@@ -39,26 +42,21 @@
</div>
</label>
<label
class="card card-border border-base-content/20 transition-colors"
class="card card-border cursor-pointer transition-colors"
:class="[
type === 'cloud' ? 'border-primary bg-primary/10' : '',
hasExistingCloudDestination && type !== 'cloud' ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
isCloudLinked ? 'cursor-not-allowed opacity-50' : '',
]"
>
<div class="card-body flex-row items-center gap-3 p-4">
<input
type="radio"
v-model="type"
value="cloud"
class="radio radio-primary"
:disabled="hasExistingCloudDestination && type !== 'cloud'"
/>
<input type="radio" v-model="type" value="cloud" class="radio radio-primary" :disabled="isCloudLinked" />
<div>
<div class="font-semibold">{{ $t("notifications.destination-form.cloud-title") }}</div>
<div class="text-base-content/60 text-sm">
{{ $t("notifications.destination-form.cloud-description") }}
</div>
<div v-if="hasExistingCloudDestination && type !== 'cloud'" class="text-warning mt-1 text-xs">
<div v-if="isCloudLinked" class="text-success mt-1 text-xs">
<mdi:check class="inline" />
{{ $t("notifications.destination-form.cloud-exists") }}
</div>
</div>
@@ -84,25 +82,17 @@ import type { Dispatcher } from "@/types/notifications";
import WebhookDestinationForm from "./WebhookDestinationForm.vue";
import CloudDestinationForm from "./CloudDestinationForm.vue";
const {
close,
onCreated,
destination,
existingDispatchers = [],
showLinkSuccess = false,
} = defineProps<{
const { close, onCreated, destination } = defineProps<{
close?: () => void;
onCreated?: () => void;
destination?: Dispatcher;
existingDispatchers?: Dispatcher[];
showLinkSuccess?: boolean;
}>();
const 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");
});
const { cloudConfig, fetchCloudConfig } = useCloudConfig();
const isCloudLinked = computed(() => !!cloudConfig.value?.linked);
onMounted(() => fetchCloudConfig());
</script>
@@ -0,0 +1,89 @@
<template>
<fieldset class="fieldset">
<legend class="fieldset-legend text-lg">{{ $t("notifications.alert-form.event-filter") }}</legend>
<div
class="input focus-within:input-primary h-auto w-full focus-within:z-50"
:class="eventExpression.trim() && !eventError ? 'input-primary' : { 'input-error!': eventError }"
>
<div ref="editorRef" class="w-full"></div>
</div>
<div v-if="eventError || eventExpression" class="fieldset-label">
<span v-if="eventError" class="text-error">{{ eventError }}</span>
<span v-else class="text-success">
<mdi:check class="inline" />
{{ $t("notifications.alert-form.expression-valid") }}
</span>
</div>
<p class="text-base-content/50 mt-1 text-xs">
{{
$t("notifications.alert-form.event-fields-hint", {
fields: "name (start, stop, die, restart, health_status), attributes (healthStatus, 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>
@@ -57,6 +57,43 @@
></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>
@@ -110,6 +147,12 @@ 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);
@@ -143,6 +186,12 @@ 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(() => {
@@ -174,6 +223,7 @@ async function testDestination() {
body: JSON.stringify({
url: webhookUrl.value.trim(),
template: template.value.trim() || undefined,
headers: headersToRecord(),
}),
});
@@ -198,6 +248,7 @@ async function saveDestination() {
type: "webhook",
url: webhookUrl.value.trim(),
template: template.value.trim() || undefined,
headers: headersToRecord(),
};
const url = isEditing
@@ -9,7 +9,7 @@ export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
type: "section",
text: {
type: "mrkdwn",
text: "*{{ .Container.Name }}*\n{{ .Log.Message }}",
text: "*{{ .Container.Name }}*\n{{ .Detail }}",
},
},
{
@@ -32,7 +32,7 @@ export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
embeds: [
{
title: "{{ .Container.Name }}",
description: "{{ .Log.Message }}",
description: "{{ .Detail }}",
fields: [
{ name: "Host", value: "{{ .Container.HostName }}", inline: true },
{ name: "Image", value: "{{ .Container.Image }}", inline: true },
@@ -47,7 +47,7 @@ export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
{
topic: "dozzle-{{ .Container.HostName }}",
title: "{{ .Container.Name }}",
message: "{{ .Log.Message }}",
message: "{{ .Detail }}",
},
null,
2,
@@ -55,8 +55,7 @@ export const PAYLOAD_TEMPLATES: Record<PayloadFormat, string> = {
custom: JSON.stringify(
{
container: "{{ .Container.Name }}",
level: "{{ .Log.Level }}",
message: "{{ .Log.Message }}",
message: "{{ .Detail }}",
},
null,
2,
+4 -3
View File
@@ -1,9 +1,10 @@
<template>
<div class="flex flex-col gap-5 px-4 py-4 md:px-8">
<section>
<Links>
<section class="flex items-center gap-4">
<CloudSearchInline class="hidden max-w-sm flex-1 md:flex" />
<Links class="ml-auto">
<template #more-items>
<Tag>{{ config.version }}</Tag>
<Tag class="font-mono">{{ config.version }}</Tag>
</template>
</Links>
</section>
+3 -2
View File
@@ -2,11 +2,12 @@
<section :class="{ 'h-screen min-h-0': scrollable }" class="flex flex-col">
<header
v-if="$slots.header"
class="border-base-content/10 bg-base-200 sticky top-[calc(55px+env(safe-area-inset-top))] z-20 border-b py-0.5 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0 md:py-2"
data-testid="scrollable-header"
class="border-base-content/10 bg-base-200 sticky top-[var(--mobile-nav-height)] z-20 border-b py-0.5 shadow-[1px_1px_2px_0_rgb(0,0,0,0.05)] md:top-0 md:py-2"
>
<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
+55
View File
@@ -0,0 +1,55 @@
/**
* @vitest-environment jsdom
*/
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, test } from "vitest";
import { createI18n } from "vue-i18n";
import Search from "./Search.vue";
import { useSearchFilter } from "@/composable/search";
const search = useSearchFilter();
function mountSearch() {
search.showSearch.value = true;
return mount(Search, {
global: { plugins: [createI18n({})] },
});
}
describe("<Search />", () => {
beforeEach(() => {
search.resetSearch();
});
test("flags an invalid regex with a warning style", async () => {
const wrapper = mountSearch();
search.searchQueryFilter.value = "valid";
await nextTick();
expect(wrapper.find(".input").classes()).not.toContain("input-warning");
search.searchQueryFilter.value = "[";
await nextTick();
expect(wrapper.find(".input").classes()).toContain("input-warning");
});
test("binds the input to the shared search filter", async () => {
const wrapper = mountSearch();
await wrapper.find("input").setValue("abc");
expect(search.searchQueryFilter.value).toBe("abc");
});
test("toggles the inverse filter", async () => {
const wrapper = mountSearch();
expect(search.inverseFilter.value).toBe(false);
await wrapper.find("button").trigger("click");
expect(search.inverseFilter.value).toBe(true);
});
test("escape resets the search", async () => {
const wrapper = mountSearch();
search.searchQueryFilter.value = "abc";
await wrapper.find("input").trigger("keyup.esc");
expect(search.searchQueryFilter.value).toBe("");
expect(search.showSearch.value).toBe(false);
});
});
+12 -8
View File
@@ -1,12 +1,6 @@
<template>
<transition name="slide">
<div
class="fixed z-50 flex w-full justify-end p-2"
v-show="showSearch"
v-if="search"
ref="container"
:style="style"
>
<div class="fixed z-50 flex w-full justify-end p-2" v-show="showSearch" ref="container" :style="style">
<div class="input input-primary flex items-center shadow-lg" :class="!isValidQuery ? 'input-warning' : ''">
<mdi:magnify />
<input
@@ -17,6 +11,15 @@
v-model="searchQueryFilter"
@keyup.esc="resetSearch()"
/>
<button
class="btn btn-circle btn-xs"
:class="inverseFilter ? 'btn-error' : 'btn-ghost'"
@click="toggleInverse()"
:title="inverseFilter ? $t('toolbar.inverse-on') : $t('toolbar.inverse-off')"
>
<mdi:filter-off-outline v-if="inverseFilter" />
<mdi:filter-outline v-else />
</button>
<a class="btn btn-circle btn-xs" @click="resetSearch()"> <mdi:close /></a>
</div>
</div>
@@ -26,11 +29,12 @@
<script lang="ts" setup>
const input = ref<HTMLInputElement>();
const container = ref<HTMLDivElement>();
const { searchQueryFilter, showSearch, resetSearch, isValidQuery } = useSearchFilter();
const { searchQueryFilter, showSearch, resetSearch, isValidQuery, inverseFilter, toggleInverse } = useSearchFilter();
const { style } = useDraggable(container);
onKeyStroke("f", (e) => {
if (!search.value) return;
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
showSearch.value = true;
nextTick(() => input.value?.focus() || input.value?.select());
+2 -13
View File
@@ -1,25 +1,14 @@
<template>
<aside class="fixed flex h-screen w-[inherit] flex-col gap-4 p-3" data-testid="navigation">
<h1>
<router-link :to="{ name: '/' }" class="flex w-full items-center gap-4 overflow-hidden text-4xl font-thin">
<Logo class="h-14 w-14 shrink-0" />
<router-link :to="{ name: '/' }" class="flex w-full items-center gap-2.5 overflow-hidden text-4xl font-thin">
<Logo class="h-11 w-11 shrink-0" />
Dozzle
</router-link>
<small class="mt-4 block text-sm font-light" v-if="hostname">{{ hostname }}</small>
</h1>
<button
class="input input-sm hover:border-primary mt-2 inline-flex w-auto cursor-pointer items-center gap-2 self-start font-light"
@click="$emit('search')"
:title="$t('tooltip.search')"
data-testid="search"
>
<mdi:magnify />
{{ $t("placeholder.search") }}
<key-shortcut char="k" class="text-base-content/70"></key-shortcut>
</button>
<SideMenu class="flex-1" />
</aside>
</template>
+163
View File
@@ -0,0 +1,163 @@
/**
* @vitest-environment jsdom
*/
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import WelcomeModal from "./WelcomeModal.vue";
vi.mock("vue-router");
vi.mock("@/stores/config", () => ({
__esModule: true,
default: { base: "" },
withBase: (path: string) => path,
}));
const i18n = createI18n({
legacy: false,
locale: "en",
fallbackLocale: "en",
missingWarn: false,
fallbackWarn: false,
messages: {
en: {
cloud: {
welcome: {
"create-alerts": "Turn on selected signals",
signals: {
exited: "Container exited with an error",
"exited-desc": "Fires when a container stops with a non-zero exit code.",
unhealthy: "Container became unhealthy",
"unhealthy-desc": "Fires when a container's healthcheck transitions to unhealthy.",
oom: "Container was killed by the kernel (OOM)",
"oom-desc": "Fires when Docker reports an out-of-memory kill.",
restart: "Container restarted",
"restart-desc": "Off by default — noisy on its own; Cloud also uses this for loop detection.",
disk: "Disk space running low on any volume",
"disk-desc": "Fires when any mounted volume is over 85% full.",
},
},
},
},
},
});
function mountModal() {
return mount(WelcomeModal, {
global: {
plugins: [i18n],
},
});
}
describe("<WelcomeModal /> Create First Alert", () => {
const pushSpy = vi.fn();
beforeEach(() => {
// jsdom's HTMLDialogElement lacks .close()/.showModal() — stub them so WelcomeModal's close() works.
if (!HTMLDialogElement.prototype.close) {
HTMLDialogElement.prototype.close = function () {};
}
if (!HTMLDialogElement.prototype.showModal) {
HTMLDialogElement.prototype.showModal = function () {};
}
vi.mocked(useRouter).mockReturnValue({
push: pushSpy,
} as unknown as ReturnType<typeof useRouter>);
pushSpy.mockReset();
vi.restoreAllMocks();
});
async function openAndAdvance(wrapper: ReturnType<typeof mountModal>) {
// open() seeds defaultOn signals
(wrapper.vm as unknown as { open: () => void }).open();
const vm = wrapper.vm as unknown as { step: "step1" | "step2" };
vm.step = "step2";
await wrapper.vm.$nextTick();
}
test("POSTs one rule per checked default signal and routes to /notifications", async () => {
const fetchMock = vi.fn(async (url: RequestInfo | URL, _init?: RequestInit) => {
const u = String(url);
if (u.includes("/api/notifications/dispatchers")) {
return new Response(JSON.stringify([{ id: 7, type: "cloud", name: "Dozzle Cloud" }]), { status: 200 });
}
if (u.includes("/api/notifications/rules")) {
return new Response(JSON.stringify({ id: 42 }), { status: 200 });
}
return new Response("{}", { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
const wrapper = mountModal();
await openAndAdvance(wrapper);
const cta = wrapper.findAll("button").find((b) => b.text().toLowerCase().includes("turn on"));
expect(cta).toBeDefined();
await cta!.trigger("click");
await flushPromises();
const ruleCalls = fetchMock.mock.calls.filter((c) => String(c[0]).includes("/api/notifications/rules"));
expect(ruleCalls).toHaveLength(4); // exited + unhealthy + oom + disk on by default; restart off
const bodies = ruleCalls.map((c) => JSON.parse((c[1] as RequestInit).body as string));
const eventExpressions = bodies.map((b) => b.eventExpression).filter(Boolean);
expect(eventExpressions).toContain('name == "die" && !(attributes["exitCode"] in ["0", "130", "143", "137"])');
expect(eventExpressions).toContain('name == "health_status" && attributes["healthStatus"] == "unhealthy"');
expect(eventExpressions).toContain('name == "oom"');
expect(eventExpressions).not.toContain('name == "restart"');
const metricExpressions = bodies.map((b) => b.metricExpression).filter(Boolean);
expect(metricExpressions).toContain("any(mounts, .usedPercent >= 85)");
// disk rule should carry its own cooldown/sampleWindow; event rules should remain at 0
const diskBody = bodies.find((b) => b.metricExpression === "any(mounts, .usedPercent >= 85)");
expect(diskBody).toMatchObject({
enabled: true,
dispatcherId: 7,
cooldown: 3600,
sampleWindow: 60,
containerExpression: "true",
eventExpression: "",
});
// event-based POSTs use cloud dispatcher id with no cooldown
for (const b of bodies.filter((x) => x.eventExpression)) {
expect(b).toMatchObject({
enabled: true,
dispatcherId: 7,
cooldown: 0,
sampleWindow: 0,
containerExpression: "true",
metricExpression: "",
});
}
expect(pushSpy).toHaveBeenCalledWith({ path: "/notifications" });
});
test("falls back to ?action=create-alert when POST fails", async () => {
const fetchMock = vi.fn(async (url: RequestInfo | URL, _init?: RequestInit) => {
const u = String(url);
if (u.includes("/api/notifications/dispatchers")) {
return new Response(JSON.stringify([{ id: 7, type: "cloud", name: "Dozzle Cloud" }]), { status: 200 });
}
if (u.includes("/api/notifications/rules")) {
return new Response("{}", { status: 500 });
}
return new Response("{}", { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
const wrapper = mountModal();
await openAndAdvance(wrapper);
const cta = wrapper.findAll("button").find((b) => b.text().toLowerCase().includes("turn on"));
await cta!.trigger("click");
await flushPromises();
expect(pushSpy).toHaveBeenCalledWith({ path: "/notifications", query: { action: "create-alert" } });
});
});
+324
View File
@@ -0,0 +1,324 @@
<template>
<dialog ref="modal" class="modal" @close="onClose">
<div class="modal-box max-w-md p-8">
<!-- Step 1: Feedback -->
<template v-if="step === 'step1'">
<div class="flex flex-col items-center gap-2 text-center">
<mdi:check-circle class="text-success text-4xl" />
<h3 class="text-xl font-bold">{{ $t("cloud.welcome.title") }}</h3>
<p class="text-base-content/60 text-sm">{{ $t("cloud.welcome.subtitle") }}</p>
</div>
<div class="divider"></div>
<p class="mb-3 text-sm font-medium">{{ $t("cloud.welcome.question") }}</p>
<textarea
v-model="intent"
class="textarea textarea-bordered w-full text-sm"
rows="3"
:placeholder="$t('cloud.welcome.placeholder')"
></textarea>
<p class="text-base-content/60 mt-3 mb-2 text-xs">{{ $t("cloud.welcome.or-pick") }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="option in chipOptions"
:key="option.value"
class="btn btn-sm"
:class="selectedOptions.has(option.value) ? 'btn-primary' : 'btn-outline'"
@click="toggleOption(option.value)"
>
{{ option.label }}
</button>
</div>
<button class="btn btn-primary btn-block mt-6" :disabled="submitting" @click="submitFeedback">
<span v-if="submitting" class="loading loading-spinner loading-xs"></span>
{{ $t("cloud.welcome.get-started") }}
</button>
<button class="btn btn-ghost btn-block btn-sm mt-1" :disabled="submitting" @click="skipFeedback">
{{ $t("cloud.welcome.skip") }}
</button>
</template>
<!-- Step 2: Triage signal checklist -->
<template v-else-if="step === 'step2'">
<h3 class="text-xl font-bold">{{ $t("cloud.welcome.step2-title") }}</h3>
<p class="text-base-content/60 mt-2 text-sm">{{ $t("cloud.welcome.step2-body") }}</p>
<div class="mt-5 space-y-3">
<label
v-for="signal in signals"
:key="signal.key"
class="border-base-300 hover:border-primary/40 flex cursor-pointer gap-3 rounded-lg border p-3"
>
<input
v-model="selectedSignals"
type="checkbox"
:value="signal.key"
class="checkbox checkbox-primary checkbox-sm mt-0.5"
/>
<div class="flex-1">
<p class="text-sm font-semibold">{{ signal.label }}</p>
<p class="text-base-content/60 text-xs">{{ signal.description }}</p>
</div>
</label>
</div>
<p class="text-base-content/60 mt-4 text-xs">{{ $t("cloud.welcome.footer") }}</p>
<button
class="btn btn-primary btn-block mt-5"
:disabled="creating || selectedSignals.length === 0"
@click="createDefaultAlerts"
>
<span v-if="creating" class="loading loading-spinner loading-xs"></span>
{{ $t("cloud.welcome.create-alerts") }}
</button>
<button class="btn btn-ghost btn-block btn-sm mt-1" :disabled="creating" @click="close">
{{ $t("cloud.welcome.later") }}
</button>
</template>
</div>
<form method="dialog" class="modal-backdrop">
<button></button>
</form>
</dialog>
</template>
<script lang="ts" setup>
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const { showToast } = useToast();
const modal = ref<HTMLDialogElement>();
const step = ref<"step1" | "step2">("step1");
const intent = ref("");
const selectedOptions = ref(new Set<string>());
const submitting = ref(false);
const creating = ref(false);
let feedbackSent = false;
const chipOptions = [
{ value: "error_alerts", label: t("cloud.welcome.chip-alerts") },
{ value: "ai_assistant", label: t("cloud.welcome.chip-assistant") },
{ value: "search_logs", label: t("cloud.welcome.chip-search-logs") },
{ value: "remote_access", label: t("cloud.welcome.chip-remote-access") },
{ value: "log_digests", label: t("cloud.welcome.chip-digests") },
{ value: "something_else", label: t("cloud.welcome.chip-other") },
];
type SignalKey = "exited" | "unhealthy" | "oom" | "restart" | "disk";
type SignalKind = "event" | "metric";
interface SignalDef {
key: SignalKey;
kind: SignalKind;
label: string;
description: string;
// ruleName is intentionally English/stable so the rule stays recognizable
// if the user later switches locale.
ruleName: string;
expression: string;
defaultOn: boolean;
}
const signals = computed<SignalDef[]>(() => [
{
key: "exited",
kind: "event",
label: t("cloud.welcome.signals.exited"),
description: t("cloud.welcome.signals.exited-desc"),
ruleName: "Container exited with an error",
// Ignore clean/graceful shutdowns: 0 (success), 130 (SIGINT), 143 (SIGTERM), 137 (SIGKILL).
// These commonly fire on `docker stop`, Ctrl+C, and Watchtower update cycles, which are
// not errors. Still alerts on genuine error exits (1, 2, 125, ...) and crash-loops.
expression: 'name == "die" && !(attributes["exitCode"] in ["0", "130", "143", "137"])',
defaultOn: true,
},
{
key: "unhealthy",
kind: "event",
label: t("cloud.welcome.signals.unhealthy"),
description: t("cloud.welcome.signals.unhealthy-desc"),
ruleName: "Container became unhealthy",
expression: 'name == "health_status" && attributes["healthStatus"] == "unhealthy"',
defaultOn: true,
},
{
key: "oom",
kind: "event",
label: t("cloud.welcome.signals.oom"),
description: t("cloud.welcome.signals.oom-desc"),
ruleName: "Container killed (OOM)",
expression: 'name == "oom"',
defaultOn: true,
},
{
key: "restart",
kind: "event",
label: t("cloud.welcome.signals.restart"),
description: t("cloud.welcome.signals.restart-desc"),
ruleName: "Container restarted",
expression: 'name == "restart"',
defaultOn: false,
},
{
key: "disk",
kind: "metric",
label: t("cloud.welcome.signals.disk"),
description: t("cloud.welcome.signals.disk-desc"),
ruleName: "Volume running out of space",
expression: "any(mounts, .usedPercent >= 85)",
defaultOn: true,
},
]);
const selectedSignals = ref<SignalKey[]>([]);
function toggleOption(value: string) {
const next = new Set(selectedOptions.value);
if (next.has(value)) {
next.delete(value);
} else {
next.add(value);
}
selectedOptions.value = next;
}
async function postFeedback(skipped: boolean) {
try {
await fetch(withBase("/api/cloud/feedback"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source: "welcome_modal",
intent: skipped ? undefined : intent.value || undefined,
selectedOptions: skipped ? undefined : Array.from(selectedOptions.value),
skipped,
}),
});
} catch {
// Feedback failure should not block the user
}
}
const onNotificationsPage = computed(() => route.path === "/notifications");
async function submitFeedback() {
submitting.value = true;
feedbackSent = true;
await postFeedback(false);
submitting.value = false;
if (onNotificationsPage.value) {
await createDefaultAlerts();
} else {
step.value = "step2";
}
}
async function skipFeedback() {
submitting.value = true;
feedbackSent = true;
await postFeedback(true);
submitting.value = false;
if (onNotificationsPage.value) {
// User explicitly skipped — don't silently create defaults on their behalf.
// They're already on the notifications page; just dismiss.
close();
} else {
step.value = "step2";
}
}
let abortController: AbortController | null = null;
async function createDefaultAlerts() {
if (creating.value) return;
creating.value = true;
abortController?.abort();
abortController = new AbortController();
const signal = abortController.signal;
const chosen = signals.value.filter((s) => selectedSignals.value.includes(s.key));
try {
const dispatchersRes = await fetch(withBase("/api/notifications/dispatchers"), { signal });
if (!dispatchersRes.ok) throw new Error("dispatchers fetch failed");
const dispatchers: Array<{ id: number; type: string }> = await dispatchersRes.json();
const cloud = dispatchers.find((d) => d.type === "cloud");
if (!cloud) throw new Error("cloud dispatcher missing");
// Fire rule POSTs in parallel. Partial failure is not cleaned up — if one
// rejects, the earlier ones are already saved and the user lands on the
// fallback toast path. Acceptable for a welcome modal; the user can edit
// or delete rules from /notifications.
await Promise.all(
chosen.map((s) =>
fetch(withBase("/api/notifications/rules"), {
method: "POST",
headers: { "Content-Type": "application/json" },
signal,
body: JSON.stringify({
name: s.ruleName,
enabled: true,
dispatcherId: cloud.id,
logExpression: "",
containerExpression: "true",
eventExpression: s.kind === "event" ? s.expression : "",
metricExpression: s.kind === "metric" ? s.expression : "",
// Metric alerts: don't re-fire more than once an hour per container,
// and require the threshold to hold for the default sample window.
cooldown: s.kind === "metric" ? 3600 : 0,
sampleWindow: s.kind === "metric" ? 60 : 0,
}),
}).then((res) => {
if (!res.ok) throw new Error("rule POST failed");
}),
),
);
close();
router.push({ path: "/notifications" });
} catch (err) {
if ((err as Error)?.name === "AbortError") return;
close();
showToast(
{
type: "warning",
message: t("notifications.default-alert-failed"),
},
{ expire: 6000 },
);
router.push({ path: "/notifications", query: { action: "create-alert" } });
} finally {
creating.value = false;
}
}
function open() {
step.value = "step1";
intent.value = "";
selectedOptions.value = new Set();
selectedSignals.value = signals.value.filter((s) => s.defaultOn).map((s) => s.key);
feedbackSent = false;
modal.value?.showModal();
}
function close() {
modal.value?.close();
}
onBeforeUnmount(() => {
abortController?.abort();
});
function onClose() {
if (step.value === "step1" && !feedbackSent) {
feedbackSent = true;
postFeedback(true);
}
}
defineExpose({ open });
</script>
@@ -0,0 +1,37 @@
<template>
<span class="json" :class="{ 'json-block': block }">
<JsonValue :value="parsed" :indent="block ? 0 : -1" :highlight="highlight" />
</span>
</template>
<script lang="ts" setup>
import JsonValue from "./JsonValue.vue";
const {
value,
highlight,
block = true,
} = defineProps<{
value: unknown;
highlight?: string;
block?: boolean;
}>();
const parsed = computed(() => {
if (typeof value !== "string") return value;
const trimmed = value.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value;
try {
return JSON.parse(trimmed);
} catch {
return value;
}
});
</script>
<style scoped>
@reference "@/main.css";
.json-block {
@apply block font-mono break-all whitespace-pre-wrap;
}
</style>
+44
View File
@@ -0,0 +1,44 @@
/**
* @vitest-environment jsdom
*/
import { mount } from "@vue/test-utils";
import { describe, expect, test } from "vitest";
import JsonText from "./JsonText.vue";
function mountText(text: string, highlight?: string) {
return mount(JsonText, { props: { text, highlight } });
}
describe("<JsonText />", () => {
test("renders plain text without a highlight", () => {
const wrapper = mountText("hello");
expect(wrapper.text()).toBe("hello");
expect(wrapper.find("mark").exists()).toBe(false);
});
test("marks the matching substring", () => {
const wrapper = mountText("hello", "ell");
const marks = wrapper.findAll("mark");
expect(marks).toHaveLength(1);
expect(marks[0].text()).toBe("ell");
expect(wrapper.text()).toBe("hello");
});
test("matches case-insensitively", () => {
const wrapper = mountText("Hello", "ELL");
expect(wrapper.find("mark").text()).toBe("ell");
});
test("treats regex metacharacters literally", () => {
// Without escaping, "." would match every character; escaped it matches only dots.
const wrapper = mountText("a.b.c", ".");
const marks = wrapper.findAll("mark");
expect(marks).toHaveLength(2);
expect(marks.every((m) => m.text() === ".")).toBe(true);
});
test("marks every occurrence", () => {
const wrapper = mountText("xax", "x");
expect(wrapper.findAll("mark")).toHaveLength(2);
});
});
+31
View File
@@ -0,0 +1,31 @@
<template>
<template v-if="!highlight">{{ text }}</template>
<template v-else>
<template v-for="(part, i) in parts" :key="i">
<mark v-if="part.match" class="bg-warning text-warning-content rounded px-0.5">{{ part.text }}</mark>
<template v-else>{{ part.text }}</template>
</template>
</template>
</template>
<script lang="ts" setup>
const { text, highlight } = defineProps<{
text: string;
highlight?: string;
}>();
const parts = computed(() => {
if (!highlight) return [{ text, match: false }];
const pattern = highlight.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
const re = new RegExp(pattern, "gi");
const result: { text: string; match: boolean }[] = [];
let last = 0;
for (const m of text.matchAll(re)) {
if (m.index! > last) result.push({ text: text.slice(last, m.index), match: false });
result.push({ text: m[0], match: true });
last = m.index! + m[0].length;
}
if (last < text.length) result.push({ text: text.slice(last), match: false });
return result;
});
</script>
@@ -0,0 +1,73 @@
/**
* @vitest-environment jsdom
*/
import { mount } from "@vue/test-utils";
import { describe, expect, test } from "vitest";
import JsonValue from "./JsonValue.vue";
// indent < 0 forces the compact inline rendering, which is deterministic to assert.
function inline(value: unknown, highlight?: string) {
return mount(JsonValue, { props: { value, indent: -1, highlight } });
}
// wrapper.text() drops inter-node whitespace, so compare structure without spaces.
function compact(wrapper: ReturnType<typeof inline>) {
return wrapper.text().replace(/\s+/g, "");
}
describe("<JsonValue /> primitives", () => {
test("null", () => {
const wrapper = inline(null);
expect(wrapper.text()).toBe("null");
expect(wrapper.find(".json-null").exists()).toBe(true);
});
test("boolean", () => {
expect(inline(true).text()).toBe("true");
expect(inline(false).find(".json-boolean").exists()).toBe(true);
});
test("number", () => {
const wrapper = inline(42);
expect(wrapper.text()).toBe("42");
expect(wrapper.find(".json-number").exists()).toBe(true);
});
test("string is quoted", () => {
const wrapper = inline("hi");
expect(wrapper.text()).toBe('"hi"');
expect(wrapper.find(".json-string").exists()).toBe(true);
});
});
describe("<JsonValue /> structures", () => {
test("empty array and object", () => {
expect(inline([]).text()).toBe("[]");
expect(inline({}).text()).toBe("{}");
});
test("flat array", () => {
expect(compact(inline([1, 2]))).toBe("[1,2]");
});
test("flat object", () => {
expect(compact(inline({ a: 1 }))).toBe('{"a":1}');
});
test("nested object", () => {
expect(compact(inline({ a: { b: 1 } }))).toBe('{"a":{"b":1}}');
});
test("indent mode emits newline spans, inline mode does not", () => {
const indented = mount(JsonValue, { props: { value: { a: 1 }, indent: 0 } });
expect(indented.findAll(".json-newline").length).toBeGreaterThan(0);
expect(inline({ a: 1 }).findAll(".json-newline")).toHaveLength(0);
});
});
describe("<JsonValue /> highlight", () => {
test("passes highlight down to string values", () => {
const wrapper = inline("hello", "ell");
expect(wrapper.find("mark").text()).toBe("ell");
});
});
+106
View File
@@ -0,0 +1,106 @@
<template>
<template v-if="value === null">
<span class="json-null">null</span>
</template>
<template v-else-if="typeof value === 'boolean'">
<span class="json-boolean">{{ String(value) }}</span>
</template>
<template v-else-if="typeof value === 'number'">
<span class="json-number">{{ value }}</span>
</template>
<template v-else-if="typeof value === 'string'">
<span class="json-string">"<JsonText :text="value" :highlight="highlight" />"</span>
</template>
<template v-else-if="Array.isArray(value)">
<template v-if="value.length === 0">
<span>[]</span>
</template>
<template v-else-if="indent < 0">
<span>[</span>
<template v-for="(item, i) in value" :key="i">
<JsonValue :value="item" :indent="indent" :highlight="highlight" />
<span v-if="i < value.length - 1">, </span>
</template>
<span>]</span>
</template>
<template v-else>
<span>[</span>
<template v-for="(item, i) in value" :key="i">
<span class="json-newline">{{ "\n" + pad(indent + 1) }}</span>
<JsonValue :value="item" :indent="indent + 1" :highlight="highlight" />
<span v-if="i < value.length - 1">,</span>
</template>
<span class="json-newline">{{ "\n" + pad(indent) }}</span>
<span>]</span>
</template>
</template>
<template v-else-if="typeof value === 'object'">
<template v-if="entries.length === 0">
<span>{}</span>
</template>
<template v-else-if="indent < 0">
<span>{</span>
<template v-for="([k, v], i) in entries" :key="k">
<span class="json-key">"<JsonText :text="k" :highlight="highlight" />"</span><span>: </span>
<JsonValue :value="v" :indent="indent" :highlight="highlight" />
<span v-if="i < entries.length - 1">, </span>
</template>
<span>}</span>
</template>
<template v-else>
<span>{</span>
<template v-for="([k, v], i) in entries" :key="k">
<span class="json-newline">{{ "\n" + pad(indent + 1) }}</span>
<span class="json-key">"<JsonText :text="k" :highlight="highlight" />"</span><span>: </span>
<JsonValue :value="v" :indent="indent + 1" :highlight="highlight" />
<span v-if="i < entries.length - 1">,</span>
</template>
<span class="json-newline">{{ "\n" + pad(indent) }}</span>
<span>}</span>
</template>
</template>
<template v-else>
<span>{{ String(value) }}</span>
</template>
</template>
<script lang="ts" setup>
import JsonText from "./JsonText.vue";
const {
value,
indent = 0,
highlight,
} = defineProps<{
value: unknown;
indent?: number;
highlight?: string;
}>();
const entries = computed(() =>
value && typeof value === "object" && !Array.isArray(value) ? Object.entries(value as Record<string, unknown>) : [],
);
function pad(level: number): string {
return " ".repeat(Math.max(level, 0));
}
</script>
<style scoped>
@reference "@/main.css";
.json-key {
@apply text-blue;
}
.json-string {
@apply text-green;
}
.json-number {
@apply text-orange;
}
.json-boolean {
@apply text-purple;
}
.json-null {
@apply text-red;
}
</style>
+1 -1
View File
@@ -7,7 +7,7 @@
<ph:command v-if="isMac" class="size-4" />
<ph:control-bold v-else class="size-4" />
</template>
<kbd class="uppercase">{{ char }}</kbd>
<kbd class="kbd kbd-xs ml-0.5 uppercase">{{ char }}</kbd>
</div>
</template>
+2 -2
View File
@@ -1,6 +1,6 @@
<template>
<nav class="border-base-content/20 bg-base-200 pt-safe fixed top-0 z-10 w-full border-b" data-testid="navigation">
<div class="p-2">
<nav class="border-base-content/20 bg-base-200 pt-safe fixed top-0 z-30 w-full border-b" data-testid="navigation">
<div class="px-4 py-2">
<div class="flex items-center">
<router-link :to="{ name: '/' }">
<Logo class="h-10" />
+28 -9
View File
@@ -1,16 +1,29 @@
<template>
<dialog ref="panel" class="modal-right modal items-start outline-hidden backdrop:bg-none">
<div class="modal-box" :width="width">
<div class="modal-box" :width="maximized ? 'full' : width">
<div class="pt-safe relative">
<form method="dialog" class="absolute right-0">
<button v-if="isMobile">
<mdi:close />
<div class="absolute right-0 flex items-center gap-3">
<button
v-if="!isMobile"
class="hover:text-base-content/60 cursor-pointer outline-hidden"
type="button"
:title="maximized ? $t('drawer.restore') : $t('drawer.maximize')"
:aria-label="maximized ? $t('drawer.restore') : $t('drawer.maximize')"
@click="maximized = !maximized"
>
<mdi:arrow-collapse v-if="maximized" />
<mdi:arrow-expand v-else />
</button>
<button v-else class="swap hover:swap-active outline-hidden">
<mdi:keyboard-esc class="swap-off" />
<mdi:close class="swap-on" />
</button>
</form>
<form method="dialog">
<button v-if="isMobile" class="cursor-pointer">
<mdi:close />
</button>
<button v-else class="swap hover:swap-active cursor-pointer outline-hidden">
<mdi:keyboard-esc class="swap-off" />
<mdi:close class="swap-on" />
</button>
</form>
</div>
<slot v-if="open" :close="close"></slot>
</div>
</div>
@@ -24,6 +37,7 @@ import { type DrawerWidth } from "@/composable/drawer";
const panel = useTemplateRef<HTMLDialogElement>("panel");
const open = ref(false);
const maximized = ref(false);
const { width } = defineProps<{
width: DrawerWidth;
}>();
@@ -35,6 +49,7 @@ function close() {
defineExpose({
open: () => {
open.value = true;
maximized.value = false;
panel.value?.showModal();
},
close,
@@ -55,6 +70,10 @@ useEventListener(panel, "close", () => (open.value = false));
&[width="lg"] {
@apply max-w-5xl;
}
&[width="full"] {
@apply w-full max-w-full;
}
}
.modal-right[open] .modal-box {
+9 -9
View File
@@ -1,7 +1,7 @@
<template>
<div class="toast toast-end whitespace-normal max-md:end-auto max-md:m-0 max-md:max-w-full">
<div class="toast toast-end max-md:toast-center max-md:toast-bottom whitespace-normal max-md:w-full max-md:px-2">
<div
class="alert max-w-xl shadow-sm max-md:rounded-none"
class="alert max-w-xl shadow-sm max-md:w-full max-md:rounded-lg"
v-for="{ toast, options: { timed } } in toasts"
:key="toast.id"
:class="{
@@ -10,14 +10,14 @@
'alert-warning': toast.type === 'warning',
}"
>
<carbon:information class="size-6 shrink-0 stroke-current" v-if="toast.type === 'info'" />
<carbon:warning class="size-6 shrink-0 stroke-current" v-else-if="toast.type === 'error'" />
<carbon:warning class="size-6 shrink-0 stroke-current" v-else-if="toast.type === 'warning'" />
<div>
<h3 class="text-lg font-bold" v-if="toast.title">{{ toast.title }}</h3>
<div v-html="toast.message" class="[&>a]:underline"></div>
<carbon:information class="size-5 shrink-0 stroke-current" v-if="toast.type === 'info'" />
<carbon:warning class="size-5 shrink-0 stroke-current" v-else-if="toast.type === 'error'" />
<carbon:warning class="size-5 shrink-0 stroke-current" v-else-if="toast.type === 'warning'" />
<div class="min-w-0">
<h3 class="text-lg font-bold max-md:text-base" v-if="toast.title">{{ toast.title }}</h3>
<div v-html="toast.message" class="max-md:text-sm [&>a]:underline"></div>
</div>
<div>
<div class="shrink-0">
<TimedButton
v-if="timed"
class="btn-primary btn-sm"
+156
View File
@@ -0,0 +1,156 @@
import { Container } from "@/models/Container";
import type { ContainerJson } from "@/types/Container";
import type { Dispatcher, NotificationRule, PreviewResult } from "@/types/notifications";
import { createContainerHints } from "@/composable/exprEditor";
export interface AlertFormOptions {
close?: () => void;
onCreated?: () => void;
alert?: NotificationRule;
prefill?: {
name?: string;
containerExpression?: string;
logExpression?: string;
metricExpression?: string;
eventExpression?: string;
dispatcherId?: number;
};
}
export interface ContainerResult {
error?: string;
containers?: Container[];
}
export function useAlertForm(options: AlertFormOptions) {
const isEditing = computed(() => !!options.alert);
const alertName = ref(options.alert?.name ?? options.prefill?.name ?? "");
const containerExpression = ref(options.alert?.containerExpression ?? options.prefill?.containerExpression ?? "");
const dispatcherId = ref(options.alert?.dispatcher?.id ?? options.prefill?.dispatcherId ?? -1);
const isSaving = ref(false);
const saveError = ref<string | null>(null);
// Destinations (cloud dispatcher with id=0 is included by the backend when configured)
const destinations = ref<Dispatcher[]>([]);
onMounted(async () => {
const res = await fetch(withBase("/api/notifications/dispatchers"));
destinations.value = await res.json();
});
const selectedDestination = computed(() => destinations.value.find((d) => d.id === dispatcherId.value));
// Container store for autocomplete
const containerStore = useContainerStore();
const { containers } = storeToRefs(containerStore);
const containerNames = computed(() => [
...new Set(containers.value.filter((c) => c.state === "running").map((c) => c.name)),
]);
const imageNames = computed(() => [...new Set(containers.value.map((c) => c.image))]);
const hostNames = computed(() => [...new Set(containers.value.map((c) => c.host))]);
// Container validation
const containerResult = ref<ContainerResult | null>(null);
const isLoading = ref(false);
const baseCanSave = computed(
() =>
alertName.value.trim() &&
containerExpression.value.trim() &&
dispatcherId.value >= 0 &&
!containerResult.value?.error &&
!isSaving.value,
);
function setupContainerEditor(editorRef: Ref<HTMLElement | undefined>) {
useExprEditorField(editorRef, {
placeholder: 'name contains "api"',
initialValue: options.alert?.containerExpression ?? options.prefill?.containerExpression ?? "",
getHints: () => createContainerHints(containerNames.value, imageNames.value, hostNames.value),
onChange: (v) => (containerExpression.value = v),
});
}
async function saveAlert(typeSpecificFields: Record<string, unknown>) {
isSaving.value = true;
saveError.value = null;
try {
const input = {
name: alertName.value.trim(),
containerExpression: containerExpression.value,
dispatcherId: dispatcherId.value,
enabled: true,
...typeSpecificFields,
};
const url = isEditing.value
? withBase(`/api/notifications/rules/${options.alert!.id}`)
: withBase("/api/notifications/rules");
const res = await fetch(url, {
method: isEditing.value ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to save alert");
}
options.onCreated?.();
options.close?.();
} catch (e) {
saveError.value = e instanceof Error ? e.message : "Failed to save alert";
} finally {
isSaving.value = false;
}
}
async function validatePreview(extraFields: Record<string, unknown> = {}) {
if (!containerExpression.value && !Object.values(extraFields).some(Boolean)) {
containerResult.value = null;
return { data: null };
}
isLoading.value = true;
try {
const res = await fetch(withBase("/api/notifications/preview"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
containerExpression: containerExpression.value,
...extraFields,
}),
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || "Preview failed");
}
const data: PreviewResult = await res.json();
containerResult.value = containerExpression.value
? {
error: data.containerError ?? undefined,
containers: data.matchedContainers?.map((c) => Container.fromJSON(c as ContainerJson)),
}
: null;
return { data };
} catch (e) {
containerResult.value = { error: e instanceof Error ? e.message : "Unknown error" };
return { data: null };
} finally {
isLoading.value = false;
}
}
return {
isEditing,
alertName,
containerExpression,
dispatcherId,
destinations,
selectedDestination,
containerResult,
isLoading,
isSaving,
saveError,
baseCanSave,
setupContainerEditor,
saveAlert,
validatePreview,
};
}
+61
View File
@@ -0,0 +1,61 @@
import type { CloudConfig, CloudStatus } from "@/types/notifications";
// Shared state across all component instances
const cloudConfig = ref<CloudConfig | null>(null);
const cloudStatus = ref<CloudStatus | null>(null);
const cloudStatusError = ref<"auth" | "unavailable" | false>(false);
const isLoadingCloudStatus = ref(false);
async function fetchCloudConfig() {
try {
const res = await fetch(withBase("/api/cloud/config"));
if (!res.ok) {
cloudConfig.value = null;
return;
}
cloudConfig.value = await res.json();
} catch {
cloudConfig.value = null;
}
}
// Loaded once at module import (i.e. app boot). Every consumer reads the
// shared `cloudConfig` ref — no per-component fetch.
const initialLoad = fetchCloudConfig();
async function fetchCloudStatus() {
if (!cloudConfig.value?.linked) return;
isLoadingCloudStatus.value = true;
cloudStatusError.value = false;
try {
const res = await fetch(withBase("/api/cloud/status"));
if (!res.ok) {
cloudStatusError.value = res.status === 401 || res.status === 403 ? "auth" : "unavailable";
return;
}
cloudStatus.value = await res.json();
} catch {
cloudStatusError.value = "unavailable";
} finally {
isLoadingCloudStatus.value = false;
}
}
function clearCloudState() {
cloudConfig.value = null;
cloudStatus.value = null;
cloudStatusError.value = false;
}
export function useCloudConfig() {
return {
cloudConfig,
cloudStatus,
cloudStatusError,
isLoadingCloudStatus,
initialLoad,
fetchCloudConfig,
fetchCloudStatus,
clearCloudState,
};
}
+155
View File
@@ -0,0 +1,155 @@
import { useCloudConfig } from "@/composable/cloudConfig";
export interface CloudLogHit {
ts: number;
hostId: string;
containerId: string;
containerName: string;
message: string;
stream: string;
level: string;
// Dozzle's deterministic FNV-32a id for the raw log line — used to deep-link
// to the exact line in the local log viewer. Optional: pre-indexing logs
// (or older Dozzle clients) won't have it.
logId?: number;
}
interface CloudLogSearchResponse {
hits: CloudLogHit[];
hasMore: boolean;
// Cursor for the next older page. Pass back as `before=` in the URL.
// Omitted when there's nothing more to load.
nextBefore?: number;
}
const debounceMs = 250;
/**
* useCloudLogSearch performs Cloud-side log search via the Dozzle backend's
* /api/cloud/search/logs endpoint. Identity is derived server-side from the
* authenticated gRPC connection; this composable passes only the query.
*
* Behavior:
* - debounced 250ms; whitespace-only short-circuits to []
* - aborts any in-flight request on each new keystroke (AbortController)
* - `available` is computed: cloud linked AND streamLogs enabled
* - when `available` is false, results stay [] regardless of query
*
* Status mapping:
* 200 -> hits populated (may be empty)
* 204 -> streaming disabled server-side (defense-in-depth)
* 503 -> cloud not configured
* 504 -> timeout (500ms upstream)
* any other 4xx/5xx -> error set, results cleared
*/
export function useCloudLogSearch(query: Ref<string>) {
const { cloudConfig } = useCloudConfig();
const results = ref<CloudLogHit[]>([]);
const loading = ref(false);
const loadingMore = ref(false);
const error = ref<Error | null>(null);
const hasMore = ref(false);
// Cursor (timestamp_ns) of the last hit on the current page; 0 = at the
// newest page. Cleared on every new query.
const nextBefore = ref<number>(0);
const available = computed(() => !!cloudConfig.value?.linked && !!cloudConfig.value?.streamLogs);
// Two parallel fetch lifecycles — keystroke search (cancels on next
// keystroke / unmount) and pagination loadMore (cancels on unmount or
// when a new query lands and supersedes pagination state). Tracking
// them separately avoids the keystroke aborter cancelling an in-flight
// pagination request and vice versa.
let abortController: AbortController | null = null;
let loadMoreAborter: AbortController | null = null;
function clearResults() {
results.value = [];
error.value = null;
loading.value = false;
loadingMore.value = false;
hasMore.value = false;
nextBefore.value = 0;
}
async function fetchPage(q: string, before: number, signal: AbortSignal): Promise<CloudLogSearchResponse | null> {
let url = withBase(`/api/cloud/search/logs?q=${encodeURIComponent(q)}&limit=20`);
if (before > 0) url += `&before=${before}`;
const res = await fetch(url, { signal });
if (res.status === 204) return { hits: [], hasMore: false };
if (!res.ok) throw new Error(`cloud search failed: ${res.status}`);
return (await res.json()) as CloudLogSearchResponse;
}
async function runSearch(q: string) {
if (abortController) abortController.abort();
// A fresh query supersedes any in-flight pagination — that page is
// for the previous query and would be appended onto the wrong result
// set if it landed late.
loadMoreAborter?.abort();
abortController = new AbortController();
loading.value = true;
error.value = null;
nextBefore.value = 0;
try {
const body = await fetchPage(q, 0, abortController.signal);
if (!body) return;
results.value = body.hits ?? [];
hasMore.value = !!body.hasMore;
nextBefore.value = body.nextBefore ?? 0;
} catch (e) {
if ((e as DOMException)?.name !== "AbortError") {
error.value = e as Error;
results.value = [];
hasMore.value = false;
}
} finally {
loading.value = false;
}
}
// loadMore appends the next older page. Safe to call repeatedly — guarded
// by hasMore + a separate loading flag so the input-debounced search and
// the user-triggered pagination don't trip each other.
async function loadMore() {
if (loadingMore.value || !hasMore.value || nextBefore.value <= 0) return;
const q = query.value.trim();
if (!q) return;
loadingMore.value = true;
loadMoreAborter?.abort();
loadMoreAborter = new AbortController();
try {
const body = await fetchPage(q, nextBefore.value, loadMoreAborter.signal);
if (!body) return;
results.value = [...results.value, ...(body.hits ?? [])];
hasMore.value = !!body.hasMore;
nextBefore.value = body.nextBefore ?? 0;
} catch (e) {
if ((e as DOMException)?.name !== "AbortError") error.value = e as Error;
} finally {
loadingMore.value = false;
}
}
watchDebounced(
[query, available],
([q, isAvailable]) => {
const trimmed = q.trim();
if (!isAvailable || trimmed === "") {
clearResults();
return;
}
runSearch(trimmed);
},
{ debounce: debounceMs, immediate: true },
);
onScopeDispose(() => {
abortController?.abort();
loadMoreAborter?.abort();
});
return { results, loading, loadingMore, error, available, hasMore, loadMore };
}
+201
View File
@@ -0,0 +1,201 @@
import type { Component } from "vue";
import { Container } from "@/models/Container";
import { useContainerActions } from "@/composable/containerActions";
import config from "@/stores/config";
import {
lightTheme,
compact,
showTimestamp,
softWrap,
showAllContainers,
showStd,
smallerScrollbars,
} from "@/stores/settings";
import mdiThemeLightDark from "~icons/mdi/theme-light-dark";
import mdiWhiteBalanceSunny from "~icons/mdi/white-balance-sunny";
import mdiWeatherNight from "~icons/mdi/weather-night";
import mdiFormatLineSpacing from "~icons/mdi/format-line-spacing";
import mdiClockOutline from "~icons/mdi/clock-outline";
import mdiWrap from "~icons/mdi/wrap";
import mdiEyeOutline from "~icons/mdi/eye-outline";
import mdiFormatListBulleted from "~icons/mdi/format-list-bulleted";
import mdiUnfoldMoreHorizontal from "~icons/mdi/unfold-more-horizontal";
import mdiCogOutline from "~icons/mdi/cog-outline";
import carbonRestart from "~icons/carbon/restart";
import mdiStop from "~icons/mdi/stop";
import mdiPlay from "~icons/mdi/play";
import mdiDownload from "~icons/mdi/download";
export type CommandSection = "container" | "settings" | "navigation";
export type Command = {
id: string;
title: string;
section: CommandSection;
icon: Component;
keywords?: string;
perform: () => unknown;
};
// Central registry for the Cmd+K command palette. Commands are recomputed on
// every access so context-sensitive entries (container actions, current
// toggle labels) stay in sync with the route and settings.
export function useCommands() {
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const containerStore = useContainerStore();
const currentId = computed(() =>
route?.name === "/container/[id]" && typeof route.params.id === "string" ? route.params.id : "",
);
// Null-safe: containerStore.currentContainer is a stubbed action under
// @pinia/testing, so guard against it being absent.
const currentContainerRef = containerStore.currentContainer?.(currentId);
const currentContainer = computed(() => currentContainerRef?.value as Container | undefined);
// Bound to the current container. The cast is safe because the action
// handlers only read container.value when invoked, and container commands are
// only pushed into the list when currentContainer is truthy — so the handlers
// never run against an undefined container.
const { start, stop, restart, update } = useContainerActions(currentContainer as Ref<Container>);
const commands = computed<Command[]>(() => {
const list: Command[] = [];
const container = currentContainer.value;
if (container && config.enableActions) {
const name = container.name;
list.push({
id: "container.restart",
section: "container",
icon: carbonRestart,
title: t("command-palette.restart-container", { name }),
keywords: "restart reboot",
perform: restart,
});
if (container.state === "running") {
list.push({
id: "container.stop",
section: "container",
icon: mdiStop,
title: t("command-palette.stop-container", { name }),
keywords: "stop kill halt",
perform: stop,
});
} else {
list.push({
id: "container.start",
section: "container",
icon: mdiPlay,
title: t("command-palette.start-container", { name }),
keywords: "start run",
perform: start,
});
}
list.push({
id: "container.update",
section: "container",
icon: mdiDownload,
title: t("command-palette.update-container", { name }),
keywords: "update pull recreate upgrade",
perform: update,
});
}
list.push(
// lightTheme is tri-state, so expose each value as its own command rather
// than a single toggle — that keeps "auto" (follow OS) reachable and makes
// the target theme explicit instead of depending on the current state.
{
id: "settings.theme-auto",
section: "settings",
icon: mdiThemeLightDark,
title: t("command-palette.theme-auto"),
keywords: "theme auto system color mode appearance",
perform: () => (lightTheme.value = "auto"),
},
{
id: "settings.theme-light",
section: "settings",
icon: mdiWhiteBalanceSunny,
title: t("command-palette.theme-light"),
keywords: "theme light color mode appearance",
perform: () => (lightTheme.value = "light"),
},
{
id: "settings.theme-dark",
section: "settings",
icon: mdiWeatherNight,
title: t("command-palette.theme-dark"),
keywords: "theme dark color mode appearance",
perform: () => (lightTheme.value = "dark"),
},
{
id: "settings.toggle-compact",
section: "settings",
icon: mdiFormatLineSpacing,
title: t("command-palette.toggle-compact"),
keywords: "compact density spacing",
perform: () => (compact.value = !compact.value),
},
{
id: "settings.toggle-timestamps",
section: "settings",
icon: mdiClockOutline,
title: t("command-palette.toggle-timestamps"),
keywords: "timestamp time date",
perform: () => (showTimestamp.value = !showTimestamp.value),
},
{
id: "settings.toggle-soft-wrap",
section: "settings",
icon: mdiWrap,
title: t("command-palette.toggle-soft-wrap"),
keywords: "wrap soft line",
perform: () => (softWrap.value = !softWrap.value),
},
{
id: "settings.toggle-stopped",
section: "settings",
icon: mdiEyeOutline,
title: t("command-palette.toggle-stopped"),
keywords: "stopped hidden all containers exited",
perform: () => (showAllContainers.value = !showAllContainers.value),
},
{
id: "settings.toggle-std",
section: "settings",
icon: mdiFormatListBulleted,
title: t("command-palette.toggle-std"),
keywords: "stdout stderr std labels stream",
perform: () => (showStd.value = !showStd.value),
},
{
id: "settings.toggle-scrollbars",
section: "settings",
icon: mdiUnfoldMoreHorizontal,
title: t("command-palette.toggle-scrollbars"),
keywords: "scrollbar smaller thin",
perform: () => (smallerScrollbars.value = !smallerScrollbars.value),
},
{
id: "navigation.settings",
section: "navigation",
icon: mdiCogOutline,
title: t("command-palette.open-settings"),
keywords: "settings preferences options config",
perform: () => router.push("/settings"),
},
);
return list;
});
// Commands shown before the user types anything: the context-sensitive
// container actions so e.g. Restart is one keystroke away on a container page.
const contextCommands = computed(() => commands.value.filter((c) => c.section === "container"));
return { commands, contextCommands };
}
+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,
};
};

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