Compare commits

...

170 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
249 changed files with 12372 additions and 3316 deletions
@@ -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
```
+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
+29 -24
View File
@@ -12,16 +12,19 @@ jobs:
name: JavaScript Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
name: Install pnpm
- uses: actions/setup-node@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
name: Install Node
with:
node-version: 24.15.0
node-version: latest
- run: npm install --global corepack@latest
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v7
with:
node-version: latest
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- run: corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline
- name: Run Tests
@@ -31,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.3
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
@@ -50,16 +53,19 @@ jobs:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
name: Install pnpm
- uses: actions/setup-node@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
name: Install Node
with:
node-version: 24.15.0
node-version: latest
- run: npm install --global corepack@latest
- run: corepack enable
- run: pnpm --version
- uses: actions/setup-node@v7
with:
node-version: latest
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- run: corepack enable
- name: Install dependencies
run: pnpm install
- name: Set up Docker Buildx
@@ -93,20 +99,20 @@ jobs:
packages: write
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to DockerHub
uses: docker/login-action@v4.1.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@v4.1.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@v6
@@ -124,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@v7.1.0
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 }}
@@ -142,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@v6
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 }}
+5 -5
View File
@@ -18,20 +18,20 @@ 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@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to DockerHub
uses: docker/login-action@v4.1.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@v4.1.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@v6
@@ -47,7 +47,7 @@ jobs:
echo "${{ secrets.TTL_KEY }}" > shared_key.pem
echo "${{ secrets.TTL_CERT }}" > shared_cert.pem
- name: Build and push
uses: docker/build-push-action@v7.1.0
uses: docker/build-push-action@v7.3.0
with:
context: .
push: true
+11 -5
View File
@@ -24,14 +24,20 @@ 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@v6
- name: Setup Node
uses: actions/setup-node@v6
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: 24.15.0
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@v6
+26 -25
View File
@@ -15,18 +15,17 @@ jobs:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
name: Install pnpm
- uses: actions/setup-node@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
name: Install Node
with:
node-version: 24.15.0
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.15.0
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@v6
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.15.0
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.3"
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,11 +79,11 @@ 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.3"
go-version: "1.26.5"
check-latest: true
- name: Generate dependencies
run: make fake_assets shared_key.pem shared_cert.pem
@@ -94,18 +96,17 @@ jobs:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
name: Install pnpm
- uses: actions/setup-node@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
name: Install Node
with:
node-version: 24.15.0
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.15.0
node-version: latest
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
+22 -23
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:
@@ -126,8 +143,8 @@ The Go backend is organized into these key packages:
- **`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
@@ -143,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)
@@ -207,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
@@ -237,7 +249,7 @@ The frontend uses file-based routing with these conventions:
- All stat history tracked in `Container.statsHistory` (max 300 items via rolling window)
- `chartData` is always a rolling window of max 300 items — array length stays constant
- Uses `ref` (not `computed`) for `downsampledBars` to enable in-place mutation of the last bar, avoiding full re-renders
- Component instance is reused when switching containers; parent must call exposed `recalculate()` to force refresh
- 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
@@ -245,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
@@ -315,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
@@ -395,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`
@@ -424,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:**
@@ -432,7 +433,6 @@ 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
@@ -465,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
+4
View File
@@ -21,6 +21,10 @@ 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"
+49 -7
View File
@@ -81,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.
@@ -143,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.
+17 -2
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
@@ -66,8 +69,10 @@ declare global {
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
@@ -117,6 +122,7 @@ 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
@@ -213,6 +219,7 @@ declare global {
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
@@ -417,10 +424,13 @@ declare global {
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'
@@ -435,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'
@@ -458,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']>
@@ -506,8 +517,10 @@ declare module 'vue' {
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']>
@@ -556,6 +569,7 @@ declare module 'vue' {
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
readonly 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']>
@@ -649,6 +663,7 @@ declare module 'vue' {
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']>
+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);
});
+10 -3
View File
@@ -70,6 +70,7 @@ declare module 'vue' {
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']
@@ -104,6 +105,8 @@ declare module 'vue' {
'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']
@@ -161,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']
@@ -181,16 +185,18 @@ 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']
@@ -199,6 +205,7 @@ declare module 'vue' {
ToastModal: typeof import('./components/common/ToastModal.vue')['default']
Toggle: typeof import('./components/common/Toggle.vue')['default']
ViewerWithSource: typeof import('./components/LogViewer/ViewerWithSource.vue')['default']
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);
});
});
+3 -1
View File
@@ -51,7 +51,9 @@ watch([availableBars, bucketSize], () => {
changeCounter.value = 0;
});
// On data changes, only update the last bar unless a new bucket boundary is crossed
// 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(
+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 });
});
});
+11 -1
View File
@@ -1,5 +1,13 @@
<template>
<div class="flex flex-row items-center gap-2">
<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>
@@ -13,6 +21,8 @@
<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,
+12 -8
View File
@@ -75,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>
@@ -87,10 +87,10 @@
<tr
v-for="container in paginated"
:key="container.id"
v-memo="[container.id, statMode]"
v-memo="[container.id, statMode, isMobile]"
class="hover:bg-base-100/80!"
>
<td v-if="isVisible('name')" class="max-w-80 truncate">
<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>
@@ -142,6 +142,7 @@ const fields: Record<
string,
{
label: string;
mobileLabel?: string;
sortFunc: (a: Container, b: Container) => number;
mobileVisible: boolean;
customClass?: string;
@@ -149,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,
},
@@ -167,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",
},
};
@@ -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">
@@ -199,7 +199,7 @@ const clear = defineEmit();
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();
@@ -299,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");
@@ -308,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();
@@ -366,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>
+26 -1
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,
}));
@@ -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");
});
});
+136 -56
View File
@@ -9,11 +9,11 @@
tabindex="0"
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="onEnter"
@keydown.shift.enter.exact.prevent="runLogSearch"
@keydown.alt.enter="addColumn(data[selectedIndex].item)"
@keydown.alt.enter.exact.prevent="onPin"
v-model="query"
:placeholder="placeholderCopy"
/>
@@ -29,51 +29,80 @@
<!-- Body: results + log search CTA. Only renders when there is something
to show keeps the empty modal compact. -->
<div v-if="results.length || logSearchVisible" class="border-base-content/10 border-t">
<!-- Containers section -->
<template v-if="results.length">
<div class="text-base-content/40 px-4 pt-3 pb-1.5 text-xs font-semibold tracking-wider uppercase">
{{ $t("cloud-search.containers-section") }} · {{ data.length }}
</div>
<ul class="pb-1">
<li v-for="(result, index) in data" ref="listItems">
<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="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"
<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)"
>
<ic:sharp-keyboard-return v-if="index === selectedIndex" class="size-4" />
<cil:columns v-else-if="result.item.type === 'container'" class="size-4" />
</span>
</a>
</li>
</ul>
</template>
<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)"
>
<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"
@@ -130,7 +159,7 @@
<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="results.length" class="flex items-center gap-1.5">
<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">
@@ -164,6 +193,7 @@ 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();
@@ -176,9 +206,15 @@ const route = useRoute();
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);
// 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);
@@ -268,6 +304,20 @@ const { results: fuseResults } = 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) {
@@ -284,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) {
@@ -305,17 +367,35 @@ function selected(item: Item) {
close();
}
async function runCommand(command: Command) {
close();
await command.perform();
}
function onEnter() {
// Plain Enter prefers a container match if one is selected. With no
// container matches (cloud-only query like "OOM"), fall back to log search
// so the user isn't stuck on a popup that does nothing.
if (data.value.length > 0) {
selected(data.value[selectedIndex.value].item);
// 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();
+22 -17
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,6 +81,8 @@ 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 container.cpuLimit;
+8 -8
View File
@@ -54,10 +54,11 @@
<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" />
{{ host.name }}
<span class="truncate">{{ host.name }}</span>
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
</a>
</li>
@@ -89,10 +90,11 @@
<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" />
{{ host.name }}
<span class="truncate">{{ host.name }}</span>
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
</a>
</li>
@@ -103,10 +105,11 @@
<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" />
{{ host.name }}
<span class="truncate">{{ host.name }}</span>
<span class="badge badge-error badge-xs p-1.5" v-if="!host.available">offline</span>
</a>
</li>
@@ -147,12 +150,12 @@
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
v-else
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="item.state"
></div>
<div class="truncate">
@@ -186,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();
+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>
+1 -1
View File
@@ -23,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();
+8 -3
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";
@@ -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>
@@ -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,57 +1,87 @@
<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
ref="cpuMonitorRef"
: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
ref="memoryMonitorRef"
: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";
import StatMonitor from "@/components/LogViewer/StatMonitor.vue";
// @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 cpuMonitorRef = ref<InstanceType<typeof StatMonitor> | null>(null);
const memoryMonitorRef = ref<InstanceType<typeof StatMonitor> | null>(null);
const totalStat = ref<Stat>({ cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 });
const { 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));
@@ -68,37 +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(() => {
cpuMonitorRef.value?.recalculate();
memoryMonitorRef.value?.recalculate();
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)) {
@@ -110,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,
})),
);
+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,75 +0,0 @@
<template>
<div
class="relative"
@mouseenter="mouseOver = true"
@mouseleave="
mouseOver = false;
hoveredValue = null;
"
:class="textClass"
>
<div class="overflow-hidden rounded-xs border px-px pt-1 pb-px max-md:hidden" :class="containerClass">
<BarChart
ref="barChartRef"
:chart-data="chartData"
:bar-class="`${barClass} opacity-70 hover:opacity-100`"
class="h-8 w-44"
@hover-value="(value: number) => (hoveredValue = value)"
/>
</div>
<div class="bg-base-200 flex gap-1 rounded-sm p-px text-xs md:absolute md:-top-2 md:-left-0.5">
<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";
import BarChart from "@/components/BarChart.vue";
const {
data,
icon,
statValue,
limit = -1,
containerClass = "border-primary",
textClass = "",
barClass = "bg-primary",
formatter,
} = defineProps<{
data: Point<number>[];
icon: Component;
statValue: string | number;
limit?: string | number;
containerClass?: string;
textClass?: string;
barClass?: string;
formatter?: (value: number) => string;
}>();
const chartData = computed(() =>
data.map((point) => ({
percent: point.y ?? 0,
value: point.value ?? point.y ?? 0,
})),
);
const barChartRef = ref<InstanceType<typeof BarChart> | null>(null);
const mouseOver = ref(false);
const hoveredValue = ref<number | null>(null);
defineExpose({ recalculate: () => barChartRef.value?.recalculate() });
const displayValue = computed(() => {
if (mouseOver.value && hoveredValue.value !== null) {
if (formatter) {
return formatter(hoveredValue.value);
}
return hoveredValue.value.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-->
+1 -1
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>
+18 -11
View File
@@ -6,13 +6,13 @@
>
<div class="card-body gap-4 p-5">
<!-- Header -->
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<h4 class="flex items-center gap-2 text-lg font-semibold">
<mdi:chart-line v-if="alert.metricExpression" class="text-info" />
<mdi:bell-ring-outline v-else-if="alert.eventExpression" class="text-info" />
<mdi:text-box-outline v-else class="text-info" />
<span>{{ alert.name }}</span> <span class="text-sm font-light"></span>
<div class="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"
@@ -48,7 +48,12 @@
</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 -->
@@ -78,8 +83,10 @@
</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>
@@ -90,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>
@@ -17,7 +17,7 @@
<p class="text-base-content/50 mt-1 text-xs">
{{
$t("notifications.alert-form.event-fields-hint", {
fields: "name (start, stop, die, restart, health_status), attributes (exitCode, signal, etc.)",
fields: "name (start, stop, die, restart, health_status), attributes (healthStatus, exitCode, signal, etc.)",
})
}}
</p>
+2 -1
View File
@@ -2,7 +2,8 @@
<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>
+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);
});
});
+2 -7
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
@@ -40,6 +34,7 @@ const { searchQueryFilter, showSearch, resetSearch, isValidQuery, inverseFilter,
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 -2
View File
@@ -1,8 +1,8 @@
<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>
+70 -27
View File
@@ -1,7 +1,7 @@
/**
* @vitest-environment jsdom
*/
import { mount } from "@vue/test-utils";
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";
@@ -21,7 +21,27 @@ const i18n = createI18n({
fallbackLocale: "en",
missingWarn: false,
fallbackWarn: false,
messages: { en: { cloud: { welcome: { "default-alert-name": "Container exited with error" } } } },
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() {
@@ -50,7 +70,15 @@ describe("<WelcomeModal /> Create First Alert", () => {
vi.restoreAllMocks();
});
test("POSTs default rule with cloud dispatcher id and routes to /notifications?highlight=<id>", async () => {
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")) {
@@ -64,31 +92,50 @@ describe("<WelcomeModal /> Create First Alert", () => {
vi.stubGlobal("fetch", fetchMock);
const wrapper = mountModal();
const vm = wrapper.vm as unknown as { step: "step1" | "step2" } & Record<string, unknown>;
// jump to step2 to expose the CTA
vm.step = "step2";
await wrapper.vm.$nextTick();
await openAndAdvance(wrapper);
const cta = wrapper.findAll("button").find((b) => b.text().toLowerCase().includes("create"));
const cta = wrapper.findAll("button").find((b) => b.text().toLowerCase().includes("turn on"));
expect(cta).toBeDefined();
await cta!.trigger("click");
// allow async fetch chain to settle
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
await wrapper.vm.$nextTick();
await flushPromises();
const postCall = fetchMock.mock.calls.find((c) => String(c[0]).includes("/api/notifications/rules"));
expect(postCall).toBeDefined();
const body = JSON.parse((postCall![1] as RequestInit).body as string);
expect(body).toMatchObject({
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,
eventExpression: 'name == "die" && attributes["exitCode"] != "0"',
cooldown: 0,
sampleWindow: 0,
cooldown: 3600,
sampleWindow: 60,
containerExpression: "true",
eventExpression: "",
});
expect(pushSpy).toHaveBeenCalledWith({ path: "/notifications", query: { highlight: "42" } });
// 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 () => {
@@ -105,15 +152,11 @@ describe("<WelcomeModal /> Create First Alert", () => {
vi.stubGlobal("fetch", fetchMock);
const wrapper = mountModal();
const vm = wrapper.vm as unknown as { step: "step1" | "step2" } & Record<string, unknown>;
vm.step = "step2";
await wrapper.vm.$nextTick();
await openAndAdvance(wrapper);
const cta = wrapper.findAll("button").find((b) => b.text().toLowerCase().includes("create"));
const cta = wrapper.findAll("button").find((b) => b.text().toLowerCase().includes("turn on"));
await cta!.trigger("click");
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
await wrapper.vm.$nextTick();
await flushPromises();
expect(pushSpy).toHaveBeenCalledWith({ path: "/notifications", query: { action: "create-alert" } });
});
+144 -61
View File
@@ -42,39 +42,41 @@
</button>
</template>
<!-- Step 2: Onboarding Checklist -->
<!-- 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-1 text-sm">{{ $t("cloud.welcome.step2-subtitle") }}</p>
<p class="text-base-content/60 mt-2 text-sm">{{ $t("cloud.welcome.step2-body") }}</p>
<div class="mt-6 space-y-4">
<div v-for="(item, index) in checklistItems" :key="index" class="flex gap-3">
<div
class="flex size-7 shrink-0 items-center justify-center rounded-full text-xs font-bold"
:class="index === 0 ? 'bg-primary text-primary-content' : 'bg-base-200 text-base-content/60'"
>
{{ index + 1 }}
<div 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>
<div>
<component
:is="item.href ? 'a' : 'p'"
class="text-sm font-semibold"
:class="item.href ? 'link link-hover' : ''"
:href="item.href"
:target="item.href ? '_blank' : undefined"
:rel="item.href ? 'noreferrer noopener' : undefined"
>
{{ item.title }}
</component>
<p class="text-base-content/60 text-xs">{{ item.description }}</p>
</div>
</div>
</label>
</div>
<button class="btn btn-primary btn-block mt-6" @click="createFirstAlert">
{{ $t("cloud.welcome.create-alert") }}
<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" @click="close">
<button class="btn btn-ghost btn-block btn-sm mt-1" :disabled="creating" @click="close">
{{ $t("cloud.welcome.later") }}
</button>
</template>
@@ -86,7 +88,6 @@
</template>
<script lang="ts" setup>
const cloudUrl = __CLOUD_URL__;
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
@@ -97,34 +98,86 @@ 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: "multiple_hosts", label: t("cloud.welcome.chip-hosts") },
{ 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") },
];
const checklistItems = computed(() => [
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[]>(() => [
{
title: t("cloud.welcome.checklist-alert-title"),
description: t("cloud.welcome.checklist-alert-desc"),
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,
},
{
title: t("cloud.welcome.checklist-notify-title"),
description: t("cloud.welcome.checklist-notify-desc"),
href: `${cloudUrl}/channels`,
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,
},
{
title: t("cloud.welcome.checklist-agent-title"),
description: t("cloud.welcome.checklist-agent-desc"),
href: `${cloudUrl}/assistant`,
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)) {
@@ -160,7 +213,7 @@ async function submitFeedback() {
await postFeedback(false);
submitting.value = false;
if (onNotificationsPage.value) {
createFirstAlert();
await createDefaultAlerts();
} else {
step.value = "step2";
}
@@ -172,41 +225,64 @@ async function skipFeedback() {
await postFeedback(true);
submitting.value = false;
if (onNotificationsPage.value) {
createFirstAlert();
// 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";
}
}
async function createFirstAlert() {
close();
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"));
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");
const ruleRes = await fetch(withBase("/api/notifications/rules"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: t("cloud.welcome.default-alert-name"),
enabled: true,
dispatcherId: cloud.id,
logExpression: "",
containerExpression: "true",
eventExpression: 'name == "die" && attributes["exitCode"] != "0"',
metricExpression: "",
cooldown: 0,
sampleWindow: 0,
}),
});
if (!ruleRes.ok) throw new Error("rule POST failed");
const rule: { id: number } = await ruleRes.json();
// 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");
}),
),
);
router.push({ path: "/notifications", query: { highlight: String(rule.id) } });
} catch {
close();
router.push({ path: "/notifications" });
} catch (err) {
if ((err as Error)?.name === "AbortError") return;
close();
showToast(
{
type: "warning",
@@ -215,6 +291,8 @@ async function createFirstAlert() {
{ expire: 6000 },
);
router.push({ path: "/notifications", query: { action: "create-alert" } });
} finally {
creating.value = false;
}
}
@@ -222,6 +300,7 @@ 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();
}
@@ -230,6 +309,10 @@ function close() {
modal.value?.close();
}
onBeforeUnmount(() => {
abortController?.abort();
});
function onClose() {
if (step.value === "step1" && !feedbackSent) {
feedbackSent = true;
+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);
});
});
@@ -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");
});
});
+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 {
+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 };
}
+34 -6
View File
@@ -16,6 +16,7 @@ import { Service, Stack } from "@/models/Stack";
import { Container, GroupedContainers } from "@/models/Container";
import { parseMessage } from "@/composable/loadBetween";
import { useLogLoader } from "@/composable/logLoader";
import { parseEventData } from "@/utils/events";
const { isSearching, debouncedSearchFilter, inverseFilter } = useSearchFilter();
@@ -56,15 +57,23 @@ export function useServiceStream(service: Ref<Service>): LogStreamSource {
}
export function useNamespaceStream(namespace: Ref<{ name: string }>): LogStreamSource {
const labels = computed(() => `namespace:${namespace.value.name}`);
const labels = computed(() => `@k8s.namespace:${namespace.value.name}`);
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
}
export function useOwnerStream(owner: Ref<{ name: string; kind: string }>): LogStreamSource {
const labels = computed(() => `owner.kind:${owner.value.kind},owner.name:${owner.value.name}`);
export function useOwnerStream(owner: Ref<{ label: string }>): LogStreamSource {
const labels = computed(() => `${owner.value.label}:true`);
return useLogStream(computed(() => `/api/labels/${labels.value}/logs/stream`));
}
export type SearchStatus = {
active: boolean;
done: boolean;
matches: number;
scannedTo?: string;
reason?: "capped" | "exhausted";
};
export type LogStreamSource = ReturnType<typeof useLogStream>;
function useLogStream(url: Ref<string>, container?: Ref<Container>) {
@@ -73,6 +82,7 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
const opened = ref(false);
const loading = ref(true);
const error = ref(false);
const searchStatus = ref<SearchStatus>({ active: false, done: false, matches: 0 });
const { paused: scrollingPaused } = useScrollContext();
const { streamConfig, hasComplexLogs, levels, loadingMore, containers } = useLoggingContext();
let initial = true;
@@ -158,13 +168,14 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
loading.value = true;
error.value = false;
initial = true;
searchStatus.value = { active: isSearching.value, done: false, matches: 0 };
es = new EventSource(urlWithParams.value);
es.addEventListener("container-event", (e) => {
const event = JSON.parse((e as MessageEvent).data) as {
const event = parseEventData<{
actorId: string;
name: "container-stopped" | "container-started";
time: string;
};
}>(e);
const containerEvent = new ContainerEventLogEntry(
event.name == "container-started" ? "Container started" : "Container stopped",
event.actorId,
@@ -178,11 +189,27 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
});
es.addEventListener("logs-backfill", (e) => {
const data = JSON.parse((e as MessageEvent).data) as LogEvent[];
const data = parseEventData<LogEvent[]>(e);
const logs = data.map((e) => asLogEntry(e));
messages.value = [...logs, ...messages.value];
});
es.addEventListener("search-status", (e) => {
const data = parseEventData<{
scannedTo: string;
matches: number;
done: boolean;
reason?: "capped" | "exhausted";
}>(e);
searchStatus.value = {
active: !data.done,
done: data.done,
matches: data.matches,
scannedTo: data.scannedTo,
reason: data.reason,
};
});
es.onmessage = (e) => {
if (e.data) {
buffer.value = [...buffer.value, parseMessage(e.data)];
@@ -214,5 +241,6 @@ function useLogStream(url: Ref<string>, container?: Ref<Container>) {
opened,
error,
loading,
searchStatus,
};
}
+26 -7
View File
@@ -1,4 +1,4 @@
import type { Completion } from "@codemirror/autocomplete";
import type { Completion, CompletionContext, CompletionSource } from "@codemirror/autocomplete";
export interface ExprEditorOptions {
parent: HTMLElement;
@@ -44,9 +44,9 @@ export function createContainerHints(
{ label: '"healthy"', detail: "health value", type: "string" },
{ label: '"unhealthy"', detail: "health value", type: "string" },
{ label: '"none"', detail: "health value", type: "string" },
...containerNames.map((name) => ({ label: `"${name}"`, detail: "container name", type: "string" }) as Completion),
...imageNames.map((image) => ({ label: `"${image}"`, detail: "image name", type: "string" }) as Completion),
...hostNames.map((host) => ({ label: `"${host}"`, detail: "host name", type: "string" }) as Completion),
...containerNames.map((name): Completion => ({ label: `"${name}"`, detail: "container name", type: "string" })),
...imageNames.map((image): Completion => ({ label: `"${image}"`, detail: "image name", type: "string" })),
...hostNames.map((host): Completion => ({ label: `"${host}"`, detail: "host name", type: "string" })),
];
}
@@ -59,7 +59,7 @@ export function createLogHints(messageKeys?: string[]): Completion[] {
{ label: "timestamp", detail: "unix timestamp", type: "property" },
{ label: "id", detail: "log entry ID", type: "property" },
...(messageKeys ?? []).map(
(key) => ({ label: `message.${key}`, detail: "message field", type: "property" }) as Completion,
(key): Completion => ({ label: `message.${key}`, detail: "message field", type: "property" }),
),
...exprOperators,
{ label: '"error"', detail: "level value", type: "string" },
@@ -80,6 +80,11 @@ export function createMetricHints(): Completion[] {
{ label: "cpu", detail: "CPU usage percent", type: "property" },
{ label: "memory", detail: "memory usage percent", type: "property" },
{ label: "memoryUsage", detail: "memory usage bytes", type: "property" },
{ label: "mounts", detail: "list of container mounts with free-space info", type: "property" },
{ label: ".usedPercent", detail: "mount field: % of mount used", type: "property" },
{ label: ".availableBytes", detail: "mount field: free bytes on mount", type: "property" },
{ label: ".destination", detail: "mount field: in-container mount path", type: "property" },
{ label: "any(mounts, ...)", detail: "true if any mount matches the predicate", type: "keyword" },
...exprOperators,
{ label: ">", detail: "greater than", type: "operator" },
{ label: "<", detail: "less than", type: "operator" },
@@ -88,6 +93,12 @@ export function createMetricHints(): Completion[] {
{ label: "cpu > 80", detail: "CPU over 80%", type: "text", boost: 10 },
{ label: "memory > 90", detail: "memory over 90%", type: "text", boost: 10 },
{ label: "cpu > 80 || memory > 90", detail: "CPU or memory high", type: "text", boost: 10 },
{
label: "any(mounts, .usedPercent >= 85)",
detail: "alert when any mount is over 85% full",
type: "text",
boost: 10,
},
];
}
@@ -95,6 +106,8 @@ export function createEventHints(): Completion[] {
return [
{ label: "name", detail: "event name", type: "property" },
{ label: "attributes", detail: "event attributes map", type: "property" },
{ label: 'attributes["healthStatus"]', detail: "healthy or unhealthy (health_status events)", type: "property" },
{ label: 'attributes["exitCode"]', detail: "exit code (die events)", type: "property" },
...exprOperators,
{ label: '"start"', detail: "container started", type: "string" },
{ label: '"stop"', detail: "container stopped", type: "string" },
@@ -103,12 +116,18 @@ export function createEventHints(): Completion[] {
{ label: '"health_status"', detail: "health check changed", type: "string" },
{ label: 'name == "die"', detail: "match container death", type: "text", boost: 10 },
{ label: 'name == "health_status"', detail: "match health changes", type: "text", boost: 10 },
{
label: 'name == "health_status" && attributes["healthStatus"] == "unhealthy"',
detail: "match unhealthy containers",
type: "text",
boost: 10,
},
{ label: 'name in ["stop", "die"]', detail: "match stop or death", type: "text", boost: 10 },
];
}
function createAutocomplete(getHints: () => Completion[]) {
return (context: any) => {
function createAutocomplete(getHints: () => Completion[]): CompletionSource {
return (context: CompletionContext) => {
const word = context.matchBefore(/[\w"=!&|]+/);
if (!word && !context.explicit) return null;
+3
View File
@@ -8,6 +8,8 @@ export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalCon
const opened = ref(false);
const loading = ref(true);
const error = ref(false);
// Historical views are a fixed window around a log id, never a running search.
const searchStatus = ref<SearchStatus>({ active: false, done: false, matches: 0 });
const container = toRef(() => historicalContainer.value.container);
const { streamConfig, levels, loadingMore } = useLoggingContext();
@@ -125,5 +127,6 @@ export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalCon
opened,
error,
loading,
searchStatus,
};
}
+43
View File
@@ -0,0 +1,43 @@
/**
* @vitest-environment jsdom
*/
import { beforeEach, describe, expect, test } from "vitest";
import { useSearchFilter } from "./search";
describe("useSearchFilter", () => {
// State is a module-level singleton, so reset before each test.
beforeEach(() => {
useSearchFilter().resetSearch();
});
test("isValidQuery reflects regex validity", () => {
const { searchQueryFilter, isValidQuery } = useSearchFilter();
searchQueryFilter.value = "foo.*";
expect(isValidQuery.value).toBe(true);
searchQueryFilter.value = "[";
expect(isValidQuery.value).toBe(false);
});
test("toggleInverse flips the inverse flag", () => {
const { inverseFilter, toggleInverse } = useSearchFilter();
expect(inverseFilter.value).toBe(false);
toggleInverse();
expect(inverseFilter.value).toBe(true);
toggleInverse();
expect(inverseFilter.value).toBe(false);
});
test("resetSearch clears query, visibility and inverse", () => {
const { searchQueryFilter, showSearch, inverseFilter, toggleInverse, resetSearch } = useSearchFilter();
searchQueryFilter.value = "abc";
showSearch.value = true;
toggleInverse();
resetSearch();
expect(searchQueryFilter.value).toBe("");
expect(showSearch.value).toBe(false);
expect(inverseFilter.value).toBe(false);
});
});
+2 -3
View File
@@ -35,7 +35,7 @@
</label>
</div>
<dialog ref="modal" class="modal bg-base-300/50! items-start backdrop-blur-md transition-none!" @close="closeSearch">
<div class="modal-box max-w-2xl bg-transparent pt-20 shadow-none">
<div class="modal-box max-w-2xl overflow-visible! bg-transparent pt-20 shadow-none">
<FuzzySearchModal @close="closeSearch" v-if="open" />
</div>
<form method="dialog" class="modal-backdrop">
@@ -52,7 +52,6 @@
</template>
<script lang="ts" setup>
// @ts-ignore - splitpanes types are not available
import { Splitpanes, Pane } from "splitpanes";
import { collapseNav } from "@/stores/settings";
import SideDrawer from "@/components/common/SideDrawer.vue";
@@ -101,7 +100,7 @@ function onResized({ panes }: { panes: { size: number }[] }) {
@media screen and (max-width: 768px) {
.router-view {
padding-top: 75px;
padding-top: var(--mobile-nav-height);
}
}
</style>
+1 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

+59
View File
@@ -57,6 +57,24 @@
--color-error: var(--color-red);
--color-error-content: oklch(98% 0.01 30);
/* ANSI log palette tuned for legibility on the dark background */
--ansi-black: oklch(45% 0 0);
--ansi-red: oklch(65% 0.2 25);
--ansi-green: oklch(72% 0.16 150);
--ansi-yellow: oklch(80% 0.13 85);
--ansi-blue: oklch(70% 0.14 250);
--ansi-magenta: oklch(68% 0.19 330);
--ansi-cyan: oklch(75% 0.11 200);
--ansi-white: oklch(89% 0 0);
--ansi-bright-black: oklch(60% 0 0);
--ansi-bright-red: oklch(72% 0.2 25);
--ansi-bright-green: oklch(82% 0.17 150);
--ansi-bright-yellow: oklch(88% 0.14 95);
--ansi-bright-blue: oklch(78% 0.13 250);
--ansi-bright-magenta: oklch(75% 0.19 330);
--ansi-bright-cyan: oklch(85% 0.11 200);
--ansi-bright-white: oklch(100% 0 0);
--radius-selector: 1rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
@@ -97,6 +115,24 @@
--color-error: var(--color-red);
--color-error-content: oklch(98% 0.01 30);
/* ANSI log palette tuned for legibility on the light background */
--ansi-black: oklch(25% 0 0);
--ansi-red: oklch(52% 0.2 25);
--ansi-green: oklch(52% 0.15 150);
--ansi-yellow: oklch(60% 0.13 75);
--ansi-blue: oklch(50% 0.17 255);
--ansi-magenta: oklch(50% 0.2 330);
--ansi-cyan: oklch(55% 0.1 210);
--ansi-white: oklch(70% 0 0);
--ansi-bright-black: oklch(45% 0 0);
--ansi-bright-red: oklch(58% 0.21 25);
--ansi-bright-green: oklch(58% 0.16 150);
--ansi-bright-yellow: oklch(62% 0.13 85);
--ansi-bright-blue: oklch(55% 0.16 255);
--ansi-bright-magenta: oklch(56% 0.2 330);
--ansi-bright-cyan: oklch(60% 0.1 210);
--ansi-bright-white: oklch(85% 0 0);
--radius-selector: 1rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
@@ -120,6 +156,14 @@
}
}
/* daisyUI darkens the ghost button's border on hover, which reads as a
* near-black ring on the dark theme. Use a theme-aware base-content tint so the
* hover ring stays visible and legible in both themes (light on dark, dark on
* light) instead of a fixed near-black. */
.btn-ghost:hover {
border-color: color-mix(in oklab, var(--color-base-content) 40%, transparent) !important;
}
@utility menu-active {
&,
&:active {
@@ -143,6 +187,14 @@
}
}
/* Reserve the document scrollbar gutter so the layout width stays constant when
* a page-level scrollbar appears (e.g. once a container's logs load). Without
* this, the width jump makes the percentage-based splitpanes recompute and the
* sidebar visibly flicks. */
html {
scrollbar-gutter: stable;
}
body {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
@@ -150,6 +202,13 @@ body {
padding-right: env(safe-area-inset-right);
}
/* Height of the mobile top nav (logo row + border). The container status bar
* sticks right below it and the log content is padded by the same amount, so
* they stay locked together. The safe-area inset is already handled by body. */
:root {
--mobile-nav-height: 57px;
}
[class*="shadow-"] {
@apply shadow-base-content/8;
}
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, test, vi } from "vitest";
import { Container, emptyStat, type Stat } from "./Container";
vi.mock("@/stores/config", () => ({
__esModule: true,
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
withBase: (path: string) => path,
}));
function makeContainer(
overrides: { labels?: Record<string, string>; image?: string; command?: string; stats?: Stat[] } = {},
) {
return new Container(
"id-1",
new Date(),
new Date(),
new Date(),
overrides.image ?? "image",
"display-name",
overrides.command ?? "command",
"localhost",
overrides.labels ?? {},
"running",
0,
0,
overrides.stats ?? [],
);
}
function makeStat(partial: Partial<Stat> = {}): Stat {
return { ...emptyStat(), ...partial };
}
describe("Container.namespace", () => {
test("prefers dev.dozzle.group over all others", () => {
const c = makeContainer({
labels: {
"dev.dozzle.group": "g",
"coolify.projectName": "c",
"com.docker.stack.namespace": "s",
"com.docker.compose.project": "p",
},
});
expect(c.namespace).toBe("g");
});
test("falls back through coolify, stack, then compose", () => {
expect(makeContainer({ labels: { "coolify.projectName": "c" } }).namespace).toBe("c");
expect(makeContainer({ labels: { "com.docker.stack.namespace": "s" } }).namespace).toBe("s");
expect(makeContainer({ labels: { "com.docker.compose.project": "p" } }).namespace).toBe("p");
});
test("undefined when no grouping label is present", () => {
expect(makeContainer().namespace).toBeUndefined();
});
});
describe("Container.name", () => {
test("non-swarm returns the constructor name and respects the setter", () => {
const c = makeContainer();
expect(c.name).toBe("display-name");
c.name = "renamed";
expect(c.name).toBe("renamed");
});
test("swarm strips task id and node id from the task name", () => {
const c = makeContainer({
labels: {
"com.docker.swarm.service.id": "svc",
"com.docker.swarm.task.name": "api.1.t1n0d3",
"com.docker.swarm.task.id": "t1n0d3",
"com.docker.swarm.node.id": "node99",
},
});
expect(c.isSwarm).toBe(true);
expect(c.name).toBe("api.1");
expect(c.swarmId).toBe("t1n0d3");
});
});
describe("Container.storageKey", () => {
test("combines stripped image with command", () => {
expect(makeContainer({ image: "nginx:1.25", command: "run" }).storageKey).toBe("nginx:run");
});
});
describe("Container.hostLabel", () => {
test("resolves the host name from config", () => {
expect(makeContainer().hostLabel).toBe("localhost");
});
});
describe("Container stats history", () => {
test("pads history to 300 and seeds latest stat", () => {
const a = makeStat({ cpu: 1 });
const b = makeStat({ cpu: 2 });
const c = makeContainer({ stats: [a, b] });
expect(c.statsHistory).toHaveLength(300);
expect(c.statsHistory.at(-1)).toEqual(b);
expect(c.statsHistory.at(-2)).toEqual(a);
expect(c.stat).toEqual(b);
});
test("empty stats seed an empty stat", () => {
const c = makeContainer();
expect(c.statsHistory).toHaveLength(300);
expect(c.stat).toEqual(emptyStat());
});
});
describe("Container.updateStat", () => {
test("applies EMA (alpha 0.2) to cpu/memory and passes totals through", () => {
const c = makeContainer();
c.updateStat(makeStat({ cpu: 10, memory: 50, memoryUsage: 100, networkRxTotal: 5, diskWriteTotal: 8 }));
expect(c.stat.cpu).toBe(10);
expect(c.movingAverage.cpu).toBeCloseTo(2, 10);
expect(c.movingAverage.memory).toBeCloseTo(10, 10);
expect(c.movingAverage.memoryUsage).toBeCloseTo(20, 10);
// totals are not averaged
expect(c.movingAverage.networkRxTotal).toBe(5);
expect(c.movingAverage.diskWriteTotal).toBe(8);
});
test("EMA folds in the previous moving average on each update", () => {
const c = makeContainer();
c.updateStat(makeStat({ cpu: 10, memory: 50, memoryUsage: 100 }));
c.updateStat(makeStat({ cpu: 10, memory: 50, memoryUsage: 100 }));
expect(c.movingAverage.cpu).toBeCloseTo(3.6, 10); // 0.2*10 + 0.8*2
expect(c.movingAverage.memory).toBeCloseTo(18, 10); // 0.2*50 + 0.8*10
expect(c.movingAverage.memoryUsage).toBeCloseTo(36, 10); // 0.2*100 + 0.8*20
});
test("history stays capped at 300 with the newest stat last", () => {
const c = makeContainer();
const latest = makeStat({ cpu: 42 });
c.updateStat(latest);
expect(c.statsHistory).toHaveLength(300);
expect(c.statsHistory.at(-1)).toEqual(latest);
});
});
+35 -2
View File
@@ -1,8 +1,25 @@
import type { ContainerHealth, ContainerJson, ContainerStat, ContainerState } from "@/types/Container";
import type {
ContainerHealth,
ContainerJson,
ContainerMount,
ContainerStat,
ContainerState,
MountStat,
} from "@/types/Container";
import { Ref } from "vue";
export type Stat = Omit<ContainerStat, "id">;
export const emptyStat = (): Stat => ({
cpu: 0,
memory: 0,
memoryUsage: 0,
networkRxTotal: 0,
networkTxTotal: 0,
diskReadTotal: 0,
diskWriteTotal: 0,
});
const hosts = computed(() =>
config.hosts.reduce(
(acc, item) => {
@@ -33,6 +50,9 @@ export class Container {
private readonly _statsHistory: Ref<Stat[]>;
private readonly movingAverageStat: Ref<Stat>;
public mounts: ContainerMount[];
public mountStats: Record<string, MountStat>;
constructor(
public readonly id: string,
public readonly created: Date,
@@ -50,8 +70,12 @@ export class Container {
public readonly group?: string,
public health?: ContainerHealth,
public isNew: boolean = false,
mounts: ContainerMount[] = [],
mountStats: Record<string, MountStat> = {},
) {
const defaultStat = { cpu: 0, memory: 0, memoryUsage: 0, networkRxTotal: 0, networkTxTotal: 0 } as Stat;
this.mounts = mounts;
this.mountStats = mountStats;
const defaultStat = emptyStat();
this._stat = ref(stats.at(-1) || defaultStat);
const recentStats = stats.slice(-300);
const padding = Array(300 - recentStats.length).fill(defaultStat);
@@ -140,6 +164,8 @@ export class Container {
memoryUsage: alpha * stat.memoryUsage + (1 - alpha) * prev.memoryUsage,
networkRxTotal: stat.networkRxTotal,
networkTxTotal: stat.networkTxTotal,
diskReadTotal: stat.diskReadTotal,
diskWriteTotal: stat.diskWriteTotal,
};
if (isRef(this.movingAverageStat)) {
this.movingAverageStat.value = newEma;
@@ -148,6 +174,10 @@ export class Container {
}
}
public updateMountStats(mountStats: Record<string, MountStat>) {
this.mountStats = mountStats ?? {};
}
static fromJSON(c: ContainerJson): Container {
return new Container(
c.id,
@@ -165,6 +195,9 @@ export class Container {
c.stats ?? [],
c.group,
c.health,
false,
c.mounts ?? [],
c.mountStats ?? {},
);
}
}
+118
View File
@@ -0,0 +1,118 @@
/**
* @vitest-environment jsdom
*/
import { describe, expect, test } from "vitest";
import { ref } from "vue";
import {
asLogEntry,
ComplexLogEntry,
GroupedLogEntry,
SimpleLogEntry,
SkippedLogsEntry,
type LogEvent,
} from "./LogEntry";
function event(overrides: Partial<LogEvent> = {}): LogEvent {
return {
t: "single",
m: "hello",
ts: 1_700_000_000_000,
id: 1,
l: "info",
s: "stdout",
c: "container-1",
rm: "raw",
...overrides,
} as LogEvent;
}
describe("asLogEntry dispatch", () => {
test("single -> SimpleLogEntry", () => {
const entry = asLogEntry(event({ t: "single", m: "a line" }));
expect(entry).toBeInstanceOf(SimpleLogEntry);
expect(entry.message).toBe("a line");
});
test("group -> GroupedLogEntry with fragments mapped to strings", () => {
const entry = asLogEntry(event({ t: "group", m: [{ m: "line1" }, { m: "line2" }] }));
expect(entry).toBeInstanceOf(GroupedLogEntry);
expect(entry.message).toEqual(["line1", "line2"]);
});
test("complex -> ComplexLogEntry", () => {
const entry = asLogEntry(event({ t: "complex", m: { a: 1 } }));
expect(entry).toBeInstanceOf(ComplexLogEntry);
});
test("unknown type falls back to SimpleLogEntry", () => {
const entry = asLogEntry(event({ t: "mystery" as any, m: "x" }));
expect(entry).toBeInstanceOf(SimpleLogEntry);
});
test("carries id, container, level and date from the event", () => {
const entry = asLogEntry(event({ ts: 1_700_000_000_000, id: 7, c: "abc", l: "warn" }));
expect(entry.id).toBe(7);
expect(entry.containerID).toBe("abc");
expect(entry.level).toBe("warn");
expect(entry.date.getTime()).toBe(1_700_000_000_000);
});
});
describe("std normalization", () => {
test("unknown becomes stderr", () => {
expect(asLogEntry(event({ s: "unknown" })).std).toBe("stderr");
});
test("missing becomes stderr", () => {
expect(asLogEntry(event({ s: undefined as any })).std).toBe("stderr");
});
test("stdout and stderr are preserved", () => {
expect(asLogEntry(event({ s: "stdout" })).std).toBe("stdout");
expect(asLogEntry(event({ s: "stderr" })).std).toBe("stderr");
});
});
describe("ComplexLogEntry filtering", () => {
const message = { a: { b: 1 }, c: 2 };
test("empty visibleKeys returns the fully flattened object", () => {
const entry = new ComplexLogEntry(message, "c", 1, new Date(), "info", "stdout", "raw", ref(new Map()));
expect(entry.message).toEqual({ "a.b": 1, c: 2 });
expect(entry.unfilteredMessage).toEqual(message);
});
test("disabled keys are dropped and enabled keys come first", () => {
const visibleKeys = ref(
new Map<string[], boolean>([
[["c"], true],
[["a", "b"], false],
]),
);
const entry = new ComplexLogEntry(message, "c", 1, new Date(), "info", "stdout", "raw", visibleKeys);
expect(entry.message).toEqual({ c: 2 });
});
test("enabled keys are ordered before remaining keys", () => {
const visibleKeys = ref(new Map<string[], boolean>([[["a", "b"], true]]));
const entry = new ComplexLogEntry(message, "c", 1, new Date(), "info", "stdout", "raw", visibleKeys);
expect(Object.keys(entry.message)).toEqual(["a.b", "c"]);
});
});
describe("SkippedLogsEntry", () => {
function simple(id: number) {
return new SimpleLogEntry(`m${id}`, "c", id, new Date(), "info", "stdout", `m${id}`);
}
test("renders the running skipped count and accumulates more", () => {
const entry = new SkippedLogsEntry(new Date(), 3, simple(1), simple(2), async () => {});
expect(entry.message).toBe("Skipped 3 entries");
const newLast = simple(5);
entry.addSkippedEntries(2, newLast);
expect(entry.message).toBe("Skipped 5 entries");
expect(entry.totalSkipped).toBe(5);
expect(entry.lastSkippedLog).toBe(newLast);
});
});
+8 -1
View File
@@ -14,7 +14,14 @@ const { pinnedLogs } = storeToRefs(pinnedLogsStore);
const k8sStore = useK8sStore();
const { owners } = storeToRefs(k8sStore);
const owner = computed(() => owners.value.find((o) => o.name === route.params.name));
const ownerKey = computed(() => {
try {
return decodeURIComponent(String(route.params.name));
} catch {
return String(route.params.name);
}
});
const owner = computed(() => owners.value.find((o) => o.key === ownerKey.value));
watchEffect(() => {
if (ready.value) {
+16 -8
View File
@@ -3,6 +3,7 @@ import { Ref, UnwrapNestedRefs } from "vue";
import type { ContainerHealth, ContainerJson, ContainerStat } from "@/types/Container";
import { Container } from "@/models/Container";
import i18n from "@/modules/i18n";
import { parseEventData } from "@/utils/events";
import { Host } from "./hosts";
const { showToast, removeToast } = useToast();
@@ -54,11 +55,9 @@ export const useContainerStore = defineStore("container", () => {
}
});
es.addEventListener("containers-changed", (e: Event) =>
updateContainers(JSON.parse((e as MessageEvent).data) as ContainerJson[]),
);
es.addEventListener("containers-changed", (e) => updateContainers(parseEventData<ContainerJson[]>(e)));
es.addEventListener("container-stat", (e) => {
const stat = JSON.parse((e as MessageEvent).data) as ContainerStat;
const stat = parseEventData<ContainerStat>(e);
const container = allContainersById.value[stat.id] as unknown as UnwrapNestedRefs<Container>;
if (container) {
const { id, ...rest } = stat;
@@ -66,7 +65,7 @@ export const useContainerStore = defineStore("container", () => {
}
});
es.addEventListener("container-event", (e) => {
const event = JSON.parse((e as MessageEvent).data) as { actorId: string; name: string; time: string };
const event = parseEventData<{ actorId: string; name: string; time: string }>(e);
const container = allContainersById.value[event.actorId];
if (container) {
switch (event.name) {
@@ -77,12 +76,18 @@ export const useContainerStore = defineStore("container", () => {
case "destroy":
container.state = "deleted";
break;
case "pause":
container.state = "paused";
break;
case "unpause":
container.state = "running";
break;
}
}
});
es.addEventListener("container-updated", (e) => {
const container = JSON.parse((e as MessageEvent).data) as ContainerJson;
const container = parseEventData<ContainerJson>(e);
const existing = allContainersById.value[container.id];
if (existing) {
existing.name = container.name;
@@ -90,16 +95,19 @@ export const useContainerStore = defineStore("container", () => {
existing.health = container.health;
existing.startedAt = new Date(container.startedAt);
existing.finishedAt = new Date(container.finishedAt);
if (container.mountStats) {
existing.updateMountStats(container.mountStats);
}
}
});
es.addEventListener("update-host", (e) => {
const host = JSON.parse((e as MessageEvent).data) as Host;
const host = parseEventData<Host>(e);
updateHost(host);
});
es.addEventListener("container-health", (e) => {
const event = JSON.parse((e as MessageEvent).data) as { actorId: string; health: ContainerHealth };
const event = parseEventData<{ actorId: string; health: ContainerHealth }>(e);
const container = allContainersById.value[event.actorId];
if (container) {
container.health = event.health;
+1
View File
@@ -7,6 +7,7 @@ export type Host = {
endpoint: string;
available: boolean;
dockerVersion: string;
runtime?: "docker" | "podman";
agentVersion: string;
group?: string;
};
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, test, vi } from "vitest";
import { Container } from "@/models/Container";
import { getK8sOwnerRefs, groupK8sOwners, ownerMembershipLabel } from "./k8s";
vi.mock("@/stores/config", () => ({
__esModule: true,
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
withBase: (path: string) => path,
}));
function makeContainer(id: string, labels: Record<string, string>) {
return new Container(
id,
new Date(),
new Date(),
new Date(),
"image",
id,
"command",
"localhost",
labels,
"running",
0,
0,
[],
);
}
describe("getK8sOwnerRefs", () => {
test("parses indexed owner-chain labels", () => {
const container = makeContainer("api", {
namespace: "default",
"@k8s.owner.count": "2",
"@k8s.owner.0.kind": "ReplicaSet",
"@k8s.owner.0.namespace": "default",
"@k8s.owner.0.name": "api-6f88b977f4",
"@k8s.owner.0.key": "ReplicaSet~default~api-6f88b977f4",
"@k8s.owner.1.kind": "Deployment",
"@k8s.owner.1.namespace": "default",
"@k8s.owner.1.name": "api",
"@k8s.owner.1.key": "Deployment~default~api",
});
expect(getK8sOwnerRefs(container)).toEqual([
{
key: "ReplicaSet~default~api-6f88b977f4",
label: ownerMembershipLabel("ReplicaSet~default~api-6f88b977f4"),
kind: "ReplicaSet",
name: "api-6f88b977f4",
namespace: "default",
},
{
key: "Deployment~default~api",
label: ownerMembershipLabel("Deployment~default~api"),
kind: "Deployment",
name: "api",
namespace: "default",
},
]);
});
test("falls back to legacy immediate owner labels", () => {
const container = makeContainer("api", {
namespace: "default",
"owner.kind": "ReplicaSet",
"owner.name": "api-6f88b977f4",
});
expect(getK8sOwnerRefs(container)).toEqual([
{
key: "ReplicaSet~default~api-6f88b977f4",
label: ownerMembershipLabel("ReplicaSet~default~api-6f88b977f4"),
kind: "ReplicaSet",
name: "api-6f88b977f4",
namespace: "default",
},
]);
});
test("uses kind for display and type for CRD identity", () => {
const container = makeContainer("rollout-api", {
namespace: "default",
"@k8s.owner.count": "1",
"@k8s.owner.0.kind": "Rollout",
"@k8s.owner.0.namespace": "default",
"@k8s.owner.0.name": "api",
"@k8s.owner.0.key": "argoproj.io~v1alpha1~Rollout~default~api",
});
expect(getK8sOwnerRefs(container)).toEqual([
{
key: "argoproj.io~v1alpha1~Rollout~default~api",
label: ownerMembershipLabel("argoproj.io~v1alpha1~Rollout~default~api"),
kind: "Rollout",
name: "api",
namespace: "default",
},
]);
});
});
describe("groupK8sOwners", () => {
test("groups a container under every owner in its chain", () => {
const container = makeContainer("api", {
namespace: "default",
"@k8s.owner.count": "2",
"@k8s.owner.0.kind": "ReplicaSet",
"@k8s.owner.0.namespace": "default",
"@k8s.owner.0.name": "api-6f88b977f4",
"@k8s.owner.0.key": "ReplicaSet~default~api-6f88b977f4",
"@k8s.owner.1.kind": "Deployment",
"@k8s.owner.1.namespace": "default",
"@k8s.owner.1.name": "api",
"@k8s.owner.1.key": "Deployment~default~api",
});
const owners = groupK8sOwners([container]);
expect(owners.map((owner) => owner.key).sort()).toEqual([
"Deployment~default~api",
"ReplicaSet~default~api-6f88b977f4",
]);
expect(owners.every((owner) => owner.containers.length === 1)).toBe(true);
});
test("keeps same-name owners in different namespaces separate", () => {
const owners = groupK8sOwners([
makeContainer("default-api", {
namespace: "default",
"@k8s.owner.count": "1",
"@k8s.owner.0.kind": "Deployment",
"@k8s.owner.0.namespace": "default",
"@k8s.owner.0.name": "api",
"@k8s.owner.0.key": "Deployment~default~api",
}),
makeContainer("prod-api", {
namespace: "prod",
"@k8s.owner.count": "1",
"@k8s.owner.0.kind": "Deployment",
"@k8s.owner.0.namespace": "prod",
"@k8s.owner.0.name": "api",
"@k8s.owner.0.key": "Deployment~prod~api",
}),
]);
expect(owners.map((owner) => owner.key).sort()).toEqual(["Deployment~default~api", "Deployment~prod~api"]);
});
});
+65 -33
View File
@@ -2,6 +2,14 @@ import { acceptHMRUpdate, defineStore } from "pinia";
import { Container, GroupedContainers } from "@/models/Container";
export type K8sOwnerRef = {
key: string;
label: string;
kind: string;
name: string;
namespace?: string;
};
export class K8sNamespace {
constructor(
public readonly name: string,
@@ -22,6 +30,9 @@ export class K8sOwner {
constructor(
public readonly name: string,
public readonly kind: string,
public readonly namespaceName: string | undefined,
public readonly key: string,
public readonly label: string,
public readonly containers: Container[],
) {}
@@ -32,6 +43,54 @@ export class K8sOwner {
}
}
export function ownerMembershipLabel(key: string) {
const bytes = new TextEncoder().encode(key);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return `@k8s.owner.key.${btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
}
export function getK8sOwnerRefs(container: Container): K8sOwnerRef[] {
const count = Number(container.labels["@k8s.owner.count"] ?? 0);
if (count > 0) {
const owners: K8sOwnerRef[] = [];
for (let i = 0; i < count; i++) {
const prefix = `@k8s.owner.${i}.`;
const kind = container.labels[`${prefix}kind`];
const name = container.labels[`${prefix}name`];
const namespace = container.labels[`${prefix}namespace`];
if (!kind || !name) continue;
const key = container.labels[`${prefix}key`] ?? `${kind}~${namespace ?? ""}~${name}`;
owners.push({ key, label: ownerMembershipLabel(key), kind, name, namespace });
}
return owners;
}
// "~" matches the backend owner-key delimiter: URL-safe and invalid in Kubernetes names.
const kind = container.labels["owner.kind"];
const name = container.labels["owner.name"];
if (!kind || !name) return [];
const namespace = container.labels["namespace"];
const key = container.labels["owner.key"] ?? `${kind}~${namespace ?? ""}~${name}`;
return [{ key, label: ownerMembershipLabel(key), kind, name, namespace }];
}
export function groupK8sOwners(containers: Container[]) {
const ownerGroups: Record<string, { owner: K8sOwnerRef; containers: Container[] }> = {};
for (const container of containers) {
for (const owner of getK8sOwnerRefs(container)) {
ownerGroups[owner.key] ||= { owner, containers: [] };
ownerGroups[owner.key].containers.push(container);
}
}
return Object.values(ownerGroups).map(({ owner, containers }) => {
return new K8sOwner(owner.name, owner.kind, owner.namespace, owner.key, owner.label, containers);
});
}
export const useK8sStore = defineStore("k8s", () => {
const containerStore = useContainerStore();
const { containers } = storeToRefs(containerStore) as unknown as { containers: Ref<Container[]> };
@@ -50,24 +109,7 @@ export const useK8sStore = defineStore("k8s", () => {
const newNamespaces: K8sNamespace[] = [];
for (const [name, containers] of Object.entries(namespacedContainers)) {
const ownerGroups: Record<string, Container[]> = {};
for (const container of containers) {
const ownerKind = container.labels["owner.kind"];
const ownerName = container.labels["owner.name"];
if (ownerKind === undefined || ownerName === undefined) continue;
const key = `${ownerKind}:${ownerName}`;
ownerGroups[key] ||= [];
ownerGroups[key].push(container);
}
const newOwners: K8sOwner[] = [];
for (const [key, containers] of Object.entries(ownerGroups)) {
const [kind, name] = key.split(":");
newOwners.push(new K8sOwner(name, kind, containers));
}
const newOwners = groupK8sOwners(containers);
if (newOwners.length === 0) continue;
@@ -75,7 +117,7 @@ export const useK8sStore = defineStore("k8s", () => {
new K8sNamespace(
name,
containers,
newOwners.sort((a, b) => a.name.localeCompare(b.name)),
newOwners.sort((a, b) => a.key.localeCompare(b.key)),
),
);
}
@@ -83,33 +125,23 @@ export const useK8sStore = defineStore("k8s", () => {
});
const owners = computed(() => {
const ownerGroups: Record<string, Container[]> = {};
const containersWithoutNamespace: Container[] = [];
for (const container of runningContainers.value) {
const ownerKind = container.labels["owner.kind"];
const ownerName = container.labels["owner.name"];
const namespace = container.labels["namespace"];
if (ownerKind === undefined || ownerName === undefined) continue;
if (namespace) {
// Skip containers that are already part of a namespace
const hasNamespace = namespaces.value.some((ns) => ns.name === namespace);
if (hasNamespace) continue;
}
const key = `${ownerKind}:${ownerName}`;
ownerGroups[key] ||= [];
ownerGroups[key].push(container);
containersWithoutNamespace.push(container);
}
const ownersWithNamespace = namespaces.value.flatMap((ns) => ns.owners);
const ownersWithoutNamespace = Object.entries(ownerGroups).map(([key, containers]) => {
const [kind, name] = key.split(":");
return new K8sOwner(name, kind, containers);
});
const ownersWithoutNamespace = groupK8sOwners(containersWithoutNamespace);
return [...ownersWithNamespace, ...ownersWithoutNamespace].sort((a, b) => a.name.localeCompare(b.name));
return [...ownersWithNamespace, ...ownersWithoutNamespace].sort((a, b) => a.key.localeCompare(b.key));
});
const customGroups = computed(() => {
+38 -2
View File
@@ -20,8 +20,9 @@ import type {
declare module 'vue-router' {
interface TypesConfig {
ParamParsers:
| never
_ParamParsers: {}
RouteNamedMap: import('vue-router/auto-routes').RouteNamedMap
_RouteFileInfoMap: import('vue-router/auto-routes')._RouteFileInfoMap
}
}
@@ -167,102 +168,137 @@ declare module 'vue-router/auto-routes' {
| '/'
views:
| never
pathParamNames:
| never
}
'assets/pages/[...all].vue': {
routes:
| '/[...all]'
views:
| never
pathParamNames:
| 'all'
}
'assets/pages/cloud/search.vue': {
routes:
| '/cloud/search'
views:
| never
pathParamNames:
| never
}
'assets/pages/container/[id].vue': {
routes:
| '/container/[id]'
views:
| never
pathParamNames:
| 'id'
}
'assets/pages/container/[id].time.[datetime].vue': {
routes:
| '/container/[id].time.[datetime]'
views:
| never
pathParamNames:
| 'datetime'
| 'id'
}
'assets/pages/group/[name].vue': {
routes:
| '/group/[name]'
views:
| never
pathParamNames:
| 'name'
}
'assets/pages/host/[id].vue': {
routes:
| '/host/[id]'
views:
| never
pathParamNames:
| 'id'
}
'assets/pages/host-group/[name].vue': {
routes:
| '/host-group/[name]'
views:
| never
pathParamNames:
| 'name'
}
'assets/pages/login.vue': {
routes:
| '/login'
views:
| never
pathParamNames:
| never
}
'assets/pages/merged/[ids].vue': {
routes:
| '/merged/[ids]'
views:
| never
pathParamNames:
| 'ids'
}
'assets/pages/namespace/[name].vue': {
routes:
| '/namespace/[name]'
views:
| never
pathParamNames:
| 'name'
}
'assets/pages/notifications.vue': {
routes:
| '/notifications'
views:
| never
pathParamNames:
| never
}
'assets/pages/owner/[name].vue': {
routes:
| '/owner/[name]'
views:
| never
pathParamNames:
| 'name'
}
'assets/pages/service/[name].vue': {
routes:
| '/service/[name]'
views:
| never
pathParamNames:
| 'name'
}
'assets/pages/settings.vue': {
routes:
| '/settings'
views:
| never
pathParamNames:
| never
}
'assets/pages/show.vue': {
routes:
| '/show'
views:
| never
pathParamNames:
| never
}
'assets/pages/stack/[name].vue': {
routes:
| '/stack/[name]'
views:
| never
pathParamNames:
| 'name'
}
}
+20
View File
@@ -5,6 +5,24 @@ export interface ContainerStat {
readonly memoryUsage: number;
readonly networkRxTotal: number;
readonly networkTxTotal: number;
readonly diskReadTotal: number;
readonly diskWriteTotal: number;
}
export interface ContainerMount {
readonly type: string;
readonly source: string;
readonly destination: string;
readonly rw: boolean;
}
export interface MountStat {
readonly destination: string;
readonly total: number;
readonly free: number;
readonly used: number;
readonly available: boolean;
readonly lastChecked: string;
}
export type ContainerJson = {
@@ -22,6 +40,8 @@ export type ContainerJson = {
readonly memoryLimit: number;
readonly labels: Record<string, string>;
readonly stats: ContainerStat[];
readonly mounts?: ContainerMount[];
readonly mountStats?: Record<string, MountStat>;
readonly health?: ContainerHealth;
readonly group?: string;
};
+30
View File
@@ -0,0 +1,30 @@
import AnsiConvertor from "ansi-to-html";
// Theme-aware ANSI palette. Values are CSS variables defined per DaisyUI theme in main.css,
// so log colors stay legible on both light and dark backgrounds. The `colors` map merges over
// the library's 256-color defaults, so indices 16-255 keep their standard values.
const ansiConvertor = new AnsiConvertor({
escapeXML: false,
fg: "var(--color-base-content)",
bg: "var(--color-base-100)",
colors: {
0: "var(--ansi-black)",
1: "var(--ansi-red)",
2: "var(--ansi-green)",
3: "var(--ansi-yellow)",
4: "var(--ansi-blue)",
5: "var(--ansi-magenta)",
6: "var(--ansi-cyan)",
7: "var(--ansi-white)",
8: "var(--ansi-bright-black)",
9: "var(--ansi-bright-red)",
10: "var(--ansi-bright-green)",
11: "var(--ansi-bright-yellow)",
12: "var(--ansi-bright-blue)",
13: "var(--ansi-bright-magenta)",
14: "var(--ansi-bright-cyan)",
15: "var(--ansi-bright-white)",
},
});
export const colorize = (value: string): string => ansiConvertor.toHtml(value);
+48
View File
@@ -0,0 +1,48 @@
/**
* @vitest-environment jsdom
*/
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { formatDuration, toRelativeTime } from "./date";
describe("formatDuration", () => {
// Assertions stay tolerant so they pass whether Intl.DurationFormat is present
// (narrow style emits seconds) or the manual fallback is used (drops seconds).
test("hours and minutes", () => {
const result = formatDuration(3661, "en");
expect(result).toContain("1h");
expect(result).toContain("1m");
});
test("minutes and seconds", () => {
const result = formatDuration(90, "en");
expect(result).toContain("1m");
expect(result).toContain("30s");
});
test("seconds only", () => {
expect(formatDuration(45, "en")).toContain("45s");
});
});
describe("toRelativeTime", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-15T00:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
test("past days", () => {
expect(toRelativeTime(new Date("2026-01-13T00:00:00Z"), "en")).toBe("2 days ago");
});
test("past hour", () => {
expect(toRelativeTime(new Date("2026-01-14T23:00:00Z"), "en")).toBe("1 hour ago");
});
test("future", () => {
expect(toRelativeTime(new Date("2026-01-17T00:00:00Z"), "en")).toBe("in 2 days");
});
});
+8
View File
@@ -0,0 +1,8 @@
/**
* Parses the JSON payload of an SSE `MessageEvent`. EventSource listeners
* receive a `MessageEvent` whose `data` is an untyped string, so the parsed
* shape is provided via the type parameter at each call site.
*/
export function parseEventData<T>(event: Event): T {
return JSON.parse((event as MessageEvent).data) as T;
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, test } from "vitest";
import { formatBytes, stripVersion, hashCode } from "./format";
describe("formatBytes", () => {
test("zero bytes", () => {
expect(formatBytes(0)).toBe("0 Bytes");
expect(formatBytes(0, { short: true })).toBe("0B");
});
test("bytes under 1KB", () => {
expect(formatBytes(512)).toBe("512 Bytes");
expect(formatBytes(512, { short: true })).toBe("512B");
});
test("scales to KB/MB/GB", () => {
expect(formatBytes(1024)).toBe("1 KB");
expect(formatBytes(1024, { short: true })).toBe("1K");
expect(formatBytes(1024 * 1024)).toBe("1 MB");
expect(formatBytes(1024 * 1024, { short: true })).toBe("1M");
expect(formatBytes(1024 * 1024 * 1024)).toBe("1 GB");
});
test("honors decimals option", () => {
expect(formatBytes(1500)).toBe("1.46 KB");
expect(formatBytes(1500, { decimals: 0 })).toBe("1 KB");
expect(formatBytes(1500, { decimals: 1 })).toBe("1.5 KB");
});
test("negative decimals clamp to zero", () => {
expect(formatBytes(1500, { decimals: -3 })).toBe("1 KB");
});
});
describe("stripVersion", () => {
test("removes tag", () => {
expect(stripVersion("nginx:1.25")).toBe("nginx");
});
test("leaves untagged image unchanged", () => {
expect(stripVersion("nginx")).toBe("nginx");
});
test("splits on first colon", () => {
expect(stripVersion("registry:5000/img:tag")).toBe("registry");
});
});
describe("hashCode", () => {
test("empty string is zero", () => {
expect(hashCode("")).toBe(0);
});
test("is deterministic", () => {
expect(hashCode("dozzle")).toBe(hashCode("dozzle"));
});
test("differs for different input", () => {
expect(hashCode("a")).not.toBe(hashCode("b"));
});
});
+1
View File
@@ -2,3 +2,4 @@ export { formatDuration, toRelativeTime } from "./date";
export { getDeep, isObject, flattenJSON, flattenJSONToMap, arrayEquals } from "./object";
export { useExponentialMovingAverage, useSimpleRefHistory } from "./reactive";
export { formatBytes, stripVersion, hashCode } from "./format";
export { colorize } from "./ansi";
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, test } from "vitest";
import { flattenJSON, getDeep, isObject, arrayEquals } from "./object";
describe("flattenJSON", () => {
test("flattens nested objects to dot keys", () => {
expect(flattenJSON({ a: { b: 1 } })).toEqual({ "a.b": 1 });
expect(flattenJSON({ a: { b: { c: 2 } } })).toEqual({ "a.b.c": 2 });
});
test("keeps top-level primitives", () => {
expect(flattenJSON({ a: 1, b: "x" })).toEqual({ a: 1, b: "x" });
});
test("keeps arrays as values (not recursed)", () => {
expect(flattenJSON({ a: [1, 2] })).toEqual({ a: [1, 2] });
});
test("empty object", () => {
expect(flattenJSON({})).toEqual({});
});
});
describe("getDeep", () => {
test("reads a nested path", () => {
expect(getDeep({ a: { b: { c: 5 } } }, ["a", "b", "c"])).toBe(5);
});
test("returns undefined for a missing path without throwing", () => {
expect(getDeep({ a: {} }, ["a", "b", "c"])).toBeUndefined();
expect(getDeep({}, ["x"])).toBeUndefined();
});
});
describe("isObject", () => {
test("true for plain objects", () => {
expect(isObject({})).toBe(true);
});
test("false for arrays, null, and primitives", () => {
expect(isObject([])).toBe(false);
expect(isObject(null)).toBe(false);
expect(isObject(5)).toBe(false);
expect(isObject("s")).toBe(false);
});
});
describe("arrayEquals", () => {
test("equal arrays", () => {
expect(arrayEquals(["a", "b"], ["a", "b"])).toBe(true);
expect(arrayEquals([], [])).toBe(true);
});
test("different length or order", () => {
expect(arrayEquals(["a"], ["a", "b"])).toBe(false);
expect(arrayEquals(["a", "b"], ["b", "a"])).toBe(false);
});
});
+1 -1
View File
@@ -2,7 +2,7 @@ export function getDeep(obj: Record<string, any>, path: string[]) {
return path.reduce((acc, key) => acc?.[key], obj);
}
export function isObject(value: any): value is Record<string, any> {
export function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+52 -4
View File
@@ -1,3 +1,10 @@
x-dozzle-healthcheck: &dozzle-healthcheck
test: ["CMD", "/dozzle", "healthcheck"]
interval: 5s
retries: 5
start_period: 5s
start_interval: 5s
services:
custom_base:
container_name: custom_base
@@ -10,6 +17,7 @@ services:
- DOZZLE_HOSTNAME=custom name
ports:
- 8080:8080
healthcheck: *dozzle-healthcheck
build:
context: .
simple-auth:
@@ -20,6 +28,7 @@ services:
environment:
- DOZZLE_AUTH_PROVIDER=simple
- DOZZLE_NO_ANALYTICS=1
healthcheck: *dozzle-healthcheck
build:
context: .
dozzle:
@@ -33,8 +42,33 @@ services:
- DOZZLE_LEVEL=debug
ports:
- 7070:8080
# Deliberately no healthcheck: this container is the subject of the visual
# snapshots, and a healthcheck makes a health badge appear in the sidebar
# (HostMenu.vue), which shifts the reference images.
build:
context: .
# Dedicated instance + noisy container so log streaming can be asserted without
# perturbing the `name=dozzle` filter the other instances rely on.
logs-viewer:
container_name: logs-viewer
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- DOZZLE_FILTER=name=logspam
- DOZZLE_NO_ANALYTICS=1
- DOZZLE_HOSTNAME=logs
healthcheck: *dozzle-healthcheck
build:
context: .
depends_on:
- logspam
logspam:
container_name: logspam
image: alpine:3
command:
- sh
- -c
- 'i=0; while true; do i=$$((i+1)); echo "logspam line $$i"; sleep 1; done'
remote:
container_name: remote
environment:
@@ -43,6 +77,7 @@ services:
- DOZZLE_NO_ANALYTICS=1
ports:
- 5050:8080
healthcheck: *dozzle-healthcheck
build:
context: .
depends_on:
@@ -56,6 +91,7 @@ services:
- DOZZLE_LEVEL=debug
ports:
- 8082:8080
healthcheck: *dozzle-healthcheck
build:
context: .
depends_on:
@@ -85,6 +121,9 @@ services:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- CONTAINERS=1
# Dozzle calls /info on connect; without it the host never initializes and
# lists zero containers.
- INFO=1
healthcheck:
test: ["CMD", "nc", "-z", "127.0.0.1", "2375"]
interval: 5s
@@ -96,7 +135,7 @@ services:
playwright:
container_name: playwright
image: mcr.microsoft.com/playwright:v1.59.1-jammy
image: mcr.microsoft.com/playwright:v1.62.1-jammy
working_dir: /app
volumes:
- .:/app
@@ -105,6 +144,15 @@ services:
- PWTEST_SKIP_TEST_OUTPUT=1
- CI=1
depends_on:
- dozzle
- custom_base
- remote
dozzle:
condition: service_started
custom_base:
condition: service_healthy
remote:
condition: service_healthy
simple-auth:
condition: service_healthy
dozzle-with-agent:
condition: service_healthy
logs-viewer:
condition: service_healthy
+32 -7
View File
@@ -1,6 +1,26 @@
declare const data: { stars: number; pulls: number };
declare const data: {
stars: number;
pulls: number;
contributors: number;
release: { version: string; date: string } | null;
};
export { data };
// The contributors endpoint has no total count, so ask for one per page and read
// the last page number out of the Link header.
async function fetchContributorCount() {
const res = await fetch("https://api.github.com/repos/amir20/dozzle/contributors?per_page=1&anon=false");
const match = res.headers.get("link")?.match(/[?&]page=(\d+)>; rel="last"/);
return match ? parseInt(match[1], 10) : 0;
}
async function fetchLatestRelease() {
const res = await fetch("https://api.github.com/repos/amir20/dozzle/releases/latest");
const json = await res.json();
if (!json.tag_name) return null;
return { version: json.tag_name, date: json.published_at };
}
export default {
async load() {
const urls = [
@@ -8,13 +28,18 @@ export default {
"https://hub.docker.com/v2/namespaces/amir20/repositories/dozzle",
];
const responses = await Promise.all(urls.map((url) => fetch(url).then((res) => res.json())));
const [repo, hub, contributors, release] = await Promise.all([
...urls.map((url) => fetch(url).then((res) => res.json())),
// Never fail the build over a rate-limited or flaky API call.
fetchContributorCount().catch(() => 0),
fetchLatestRelease().catch(() => null),
]);
const data = {
stars: responses[0].stargazers_count,
pulls: responses[1].pull_count,
return {
stars: repo.stargazers_count,
pulls: hub.pull_count,
contributors,
release,
};
return data;
},
};
+8
View File
@@ -13,6 +13,14 @@ export default defineConfig({
cleanUrls: true,
head: [
["meta", { property: "og:type", content: "website" }],
["meta", { property: "og:site_name", content: "Dozzle" }],
["meta", { property: "og:url", content: "https://dozzle.dev/" }],
["meta", { property: "og:image", content: "https://dozzle.dev/og-image.png" }],
["meta", { property: "og:image:width", content: "1200" }],
["meta", { property: "og:image:height", content: "630" }],
["meta", { name: "twitter:card", content: "summary_large_image" }],
["meta", { name: "twitter:image", content: "https://dozzle.dev/og-image.png" }],
[
"script",
{
@@ -1,50 +0,0 @@
<template>
<div
class="banner fixed top-0 right-0 left-0 z-(--vp-z-index-layout-top) flex items-center overflow-hidden bg-[oklch(74%_0.16_232.661)] p-4 font-bold text-gray-800 dark:bg-[oklch(60%_0.126_221.723)] dark:text-white"
>
<div class="mx-auto flex items-center gap-2 lg:gap-4">
<span class="animate-bounce">🎉</span>Dozzle v10 is here! Alerts, webhooks, and Dozzle Cloud are now available.
<a href="/guide/alerts-and-webhooks" class="btn btn-sm btn-primary text-white!"> Learn more </a>
</div>
<button class="btn btn-circle btn-ghost btn-xs ml-auto" @click="dismiss">
<svg class="swap-on fill-current" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512">
<polygon
points="400 145.49 366.51 112 256 222.51 145.49 112 112 145.49 222.51 256 112 366.51 145.49 400 256 289.49 366.51 400 400 366.51 289.49 256 400 145.49"
/>
</svg>
</button>
</div>
</template>
<script setup>
import { onMounted } from "vue";
const dismiss = () => {
localStorage.setItem("banner-dismissed", (Date.now() + 8.64e7 * 1).toString());
document.documentElement.classList.add("banner-dismissed");
};
onMounted(() => {
if (new Date(+localStorage.getItem("banner-dismissed")) > Date.now()) {
document.documentElement.classList.add("banner-dismissed");
}
});
</script>
<style>
.banner-dismissed {
--vp-layout-top-height: 0px !important;
}
@media (min-width: 375px) {
html {
--vp-layout-top-height: 100px;
}
}
@media (min-width: 768px) {
html {
--vp-layout-top-height: 63px;
}
}
</style>
<style scoped>
.banner-dismissed .banner {
display: none;
}
</style>
@@ -0,0 +1,77 @@
<script lang="ts" setup>
import { withBase } from "vitepress";
import InstallCommand from "./InstallCommand.vue";
</script>
<template>
<section class="mx-auto mt-20 max-w-[1152px] px-6">
<div
class="flex flex-col items-center rounded-2xl border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) px-6 py-16"
>
<h2 class="m-0! text-center text-2xl font-semibold text-(--vp-c-text-1) md:text-3xl">
Start watching your containers
</h2>
<p class="mt-3! mb-0! max-w-xl text-center text-(--vp-c-text-2)">
One command, no agent to install, no account to create.
</p>
<div class="mt-8 w-full">
<InstallCommand :hint="false" dense />
</div>
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
<a class="cta-button cta-brand" :href="withBase('/guide/getting-started')">Get Started</a>
<a class="cta-button cta-alt" href="https://github.com/amir20/dozzle">
<Icon icon="simple-icons:github" class="size-4" />
View on GitHub
</a>
</div>
<p class="mt-8! mb-0! text-sm text-(--vp-c-text-2)">MIT licensed and free forever.</p>
</div>
</section>
</template>
<style scoped>
/* Mirror VitePress's own button tokens so these match the hero buttons in both themes. */
.cta-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.5rem;
border: 1px solid transparent;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.5;
text-decoration: none !important;
transition:
background-color 0.25s,
border-color 0.25s,
color 0.25s;
}
.cta-brand {
border-color: var(--vp-button-brand-border);
background-color: var(--vp-button-brand-bg);
color: var(--vp-button-brand-text);
}
.cta-brand:hover {
border-color: var(--vp-button-brand-hover-border);
background-color: var(--vp-button-brand-hover-bg);
color: var(--vp-button-brand-hover-text);
}
.cta-alt {
border-color: var(--vp-button-alt-border);
background-color: var(--vp-button-alt-bg);
color: var(--vp-button-alt-text);
}
.cta-alt:hover {
border-color: var(--vp-button-alt-hover-border);
background-color: var(--vp-button-alt-hover-bg);
color: var(--vp-button-alt-hover-text);
}
</style>
@@ -0,0 +1,42 @@
<script lang="ts" setup>
import { computed } from "vue";
import { data } from "../../activity.data";
const cues = [
{ icon: "mdi:scale-balance", label: "MIT licensed" },
{ icon: "mdi:package-variant-closed", label: "Single binary, no database" },
{ icon: "mdi:server-security", label: "Self-hosted, your logs stay on your network" },
];
const released = computed(() => {
if (!data.release) return null;
const days = Math.floor((Date.now() - new Date(data.release.date).getTime()) / 86_400_000);
if (days <= 0) return "released today";
if (days === 1) return "released yesterday";
if (days < 30) return `released ${days} days ago`;
const months = Math.round(days / 30);
return `released ${months} month${months === 1 ? "" : "s"} ago`;
});
</script>
<template>
<div class="mt-8 flex flex-col gap-2 text-sm text-(--vp-c-text-2)">
<ul class="m-0! flex list-none flex-wrap gap-x-5 gap-y-2 p-0!">
<li v-for="cue in cues" :key="cue.label" class="flex items-center gap-1.5">
<Icon :icon="cue.icon" class="size-4 opacity-70" />
{{ cue.label }}
</li>
</ul>
<a
v-if="data.release"
class="flex w-fit items-center gap-1.5 text-(--vp-c-text-3) no-underline! transition-colors hover:text-(--vp-c-brand)"
:href="`https://github.com/amir20/dozzle/releases/tag/${data.release.version}`"
>
<span class="relative flex size-2">
<span class="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60"></span>
<span class="relative inline-flex size-2 rounded-full bg-emerald-500"></span>
</span>
{{ data.release.version }} {{ released }}
</a>
</div>
</template>
+39 -26
View File
@@ -1,36 +1,49 @@
<script lang="ts" setup>
import { useMutationObserver } from "@vueuse/core";
import { onMounted, ref } from "vue";
import { usePreferredReducedMotion } from "@vueuse/core";
import { useData } from "vitepress";
import { computed, onMounted, ref } from "vue";
const isDark = ref(false);
onMounted(() => {
isDark.value = document.documentElement.classList.contains("dark");
useMutationObserver(
document.documentElement,
(mutations) => {
isDark.value = document.documentElement.classList.contains("dark");
},
{
attributes: true,
attributeFilter: ["class"],
},
);
});
import darkPoster from "../media/poster-dark.webp";
import lightPoster from "../media/poster-light.webp";
import darkVideo from "../media/dozzle-dark.mp4";
import lightVideo from "../media/dozzle-light.mp4";
const { isDark } = useData();
const reducedMotion = usePreferredReducedMotion();
// The posters are rendered on the server and toggled with CSS so the first paint
// is already the right theme. The video only mounts on the client, which keeps
// markup identical between SSR and hydration and avoids downloading both files.
const mounted = ref(false);
onMounted(() => (mounted.value = true));
const playVideo = computed(() => mounted.value && reducedMotion.value !== "reduce");
const video = computed(() => (isDark.value ? darkVideo : lightVideo));
const poster = computed(() => (isDark.value ? darkPoster : lightPoster));
const alt = "The Dozzle interface streaming container logs in real time";
</script>
<template>
<div
class="overflow-hidden rounded-md border border-solid border-gray-200 bg-[#eee] text-[red] drop-shadow-md dark:border-gray-900 dark:bg-[#222]"
class="relative overflow-hidden rounded-md border border-solid border-gray-200 bg-[#eee] drop-shadow-md dark:border-gray-900 dark:bg-[#222]"
>
<video muted loop autoplay playsinline poster="../media/poster-dark.png" v-if="isDark">
<source src="../media/dozzle-dark.mp4" type="video/mp4" />
<img src="../media/poster-dark.png" alt="" />
</video>
<video muted loop autoplay playsinline poster="../media/poster-light.png" v-else>
<source src="../media/dozzle-light.mp4" type="video/mp4" />
<img src="../media/poster-light.png" alt="" />
<!-- `!` is required: an unlayered `img { display: block }` reset outranks Tailwind's layered utilities. -->
<img :src="lightPoster" width="1280" height="760" :alt="alt" class="block! w-full dark:hidden!" />
<img :src="darkPoster" width="1280" height="760" :alt="alt" class="hidden! w-full dark:block!" />
<video
v-if="playVideo"
:key="video"
class="absolute inset-0 size-full"
muted
loop
autoplay
playsinline
preload="metadata"
aria-hidden="true"
:poster="poster"
>
<source :src="video" type="video/mp4" />
</video>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,40 @@
<script lang="ts" setup>
import { useClipboard } from "@vueuse/core";
import { withBase } from "vitepress";
const { hint = true, dense = false } = defineProps<{
hint?: boolean;
dense?: boolean;
}>();
const command =
"docker run -d -v /var/run/docker.sock:/var/run/docker.sock -v dozzle_data:/data -p 8080:8080 amir20/dozzle:latest";
const { copy, copied } = useClipboard({ source: command, copiedDuring: 2000 });
</script>
<template>
<div class="mx-auto flex max-w-[1152px] flex-col items-center gap-3 px-6" :class="dense ? 'mb-0' : 'mb-14'">
<div
class="flex w-full max-w-4xl items-start gap-3 rounded-lg border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) py-3 pr-3 pl-4"
>
<span class="shrink-0 font-mono text-xs leading-6 text-(--vp-c-text-3) select-none md:text-sm">$</span>
<code
class="min-w-0 flex-1 bg-transparent! p-0! font-mono text-xs leading-6 break-words whitespace-pre-wrap text-(--vp-c-text-1) md:text-sm"
>{{ command }}</code
>
<button
type="button"
class="shrink-0 cursor-pointer rounded-md border border-solid border-(--vp-c-divider) bg-(--vp-c-bg) px-3 py-1.5 text-xs font-medium text-(--vp-c-text-2) transition-colors hover:border-(--vp-c-brand) hover:text-(--vp-c-brand)"
:aria-label="copied ? 'Command copied' : 'Copy command to clipboard'"
@click="copy()"
>
{{ copied ? "Copied" : "Copy" }}
</button>
</div>
<p v-if="hint" class="m-0! text-sm text-(--vp-c-text-2)">
Then open <span class="font-mono">http://localhost:8080</span>. Prefer Compose, Swarm, or Kubernetes? See the
<a :href="withBase('/guide/getting-started')">install guide</a>.
</p>
</div>
</template>
@@ -0,0 +1,15 @@
<script lang="ts" setup>
import { withBase } from "vitepress";
</script>
<template>
<div class="mt-16 mb-4 flex items-center justify-center px-6">
<a
class="flex items-center gap-2 text-sm text-(--vp-c-text-2) no-underline! transition-colors hover:text-(--vp-c-brand)"
href="https://www.docker.com/blog/docker-sponsored-open-source-program/"
>
<img :src="withBase('/icons/docker-icon.svg')" width="20" height="20" alt="" />
Open source and part of Docker's sponsored OSS program
</a>
</div>
</template>
+46 -14
View File
@@ -1,21 +1,53 @@
<template>
<div class="stats stats-horizontal mt-10">
<div class="stat w-40 border-0!">
<div class="stat-title text-(--vp-c-text-1)">Github Stars</div>
<div class="stat-value">
<Counter :start="0" :end="data.stars" :duration="1000" :formatter="(value) => (value / 1e3).toFixed(1) + 'K'" />
</div>
</div>
<div class="stat">
<div class="stat-title text-(--vp-c-text-1)">Docker Pulls</div>
<div class="stat-value">
<Counter :start="0" :end="data.pulls" :duration="1000" :formatter="(value) => (value / 1e6).toFixed(0) + 'M'" />
</div>
</div>
<div class="mt-10 flex flex-wrap gap-x-14 gap-y-8">
<a
v-for="stat in stats"
:key="stat.label"
class="flex flex-col no-underline! transition-colors hover:text-(--vp-c-brand)"
:href="stat.href"
>
<span class="flex items-center gap-2 text-sm text-(--vp-c-text-2)">
<Icon :icon="stat.icon" class="size-4 opacity-70" />
{{ stat.label }}
</span>
<span class="mt-2.5 text-4xl leading-none font-semibold tracking-tight text-(--vp-c-text-1) tabular-nums">
<Counter :start="0" :end="stat.value" :duration="1000" :formatter="compact" />
</span>
</a>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import { data } from "../../activity.data";
import Counter from "./Counter.vue";
const stats = computed(() =>
[
{
icon: "simple-icons:github",
label: "Github Stars",
value: data.stars,
href: "https://github.com/amir20/dozzle/stargazers",
},
{
icon: "simple-icons:docker",
label: "Docker Pulls",
value: data.pulls,
href: "https://hub.docker.com/r/amir20/dozzle",
},
{
icon: "mdi:account-group-outline",
label: "Contributors",
value: data.contributors,
href: "https://github.com/amir20/dozzle/graphs/contributors",
},
].filter((stat) => stat.value),
);
const compact = (value: number) => {
if (value >= 1e9) return (value / 1e9).toFixed(1) + "B";
if (value >= 1e6) return Math.round(value / 1e6) + "M";
if (value >= 1e3) return (value / 1e3).toFixed(1) + "K";
return Math.round(value).toString();
};
</script>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
<script lang="ts" setup>
// Real quotes only. Drop in verbatim text with a link back to the source
// (GitHub issue, HN thread, r/selfhosted post) so anyone can check it.
// The section hides itself while this is empty.
type Testimonial = { quote: string; author: string; source: string; href: string };
const testimonials: Testimonial[] = [];
</script>
<template>
<section v-if="testimonials.length" class="mx-auto mt-20 max-w-[1152px] px-6">
<h2 class="m-0! text-center text-2xl font-semibold text-(--vp-c-text-1) md:text-3xl">What people say</h2>
<div class="mt-10 grid gap-4 md:grid-cols-3">
<figure
v-for="testimonial in testimonials"
:key="testimonial.href"
class="m-0! flex flex-col rounded-xl border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) p-6"
>
<Icon icon="mdi:format-quote-close" class="size-6 text-(--vp-c-text-3) opacity-60" />
<blockquote class="mt-3! mb-0! border-0! p-0! text-(--vp-c-text-1)">{{ testimonial.quote }}</blockquote>
<figcaption class="mt-4! text-sm text-(--vp-c-text-2)">
{{ testimonial.author }} on
<a :href="testimonial.href">{{ testimonial.source }}</a>
</figcaption>
</figure>
</div>
</section>
</template>
@@ -0,0 +1,63 @@
<script lang="ts" setup>
const rows = [
{
cli: "One container at a time, per host.",
dozzle: "Follow many containers side by side, merged across every host you connect.",
},
{
cli: "Pipe to grep and start over when you get it wrong.",
dozzle: "Search, filter by level, and jump to a timestamp while the logs keep streaming.",
},
{
cli: "JSON and stack traces arrive as a wall of text.",
dozzle: "Structured logs get parsed into fields, multi-line traces stay grouped.",
},
{
cli: "Separate docker stats window for CPU and memory.",
dozzle: "Live CPU, memory, and network charts on the same screen as the logs.",
},
{
cli: "SSH into each machine to look around.",
dozzle: "One browser tab over Docker, Swarm, and Kubernetes, with a shell when you need it.",
},
];
</script>
<template>
<section class="mx-auto mt-20 max-w-[1152px] px-6">
<h2 class="m-0! text-center text-2xl font-semibold text-(--vp-c-text-1) md:text-3xl">
Why not just <code class="bg-transparent! p-0! font-mono">docker logs</code>?
</h2>
<p class="mx-auto mt-3! mb-0! max-w-2xl text-center text-(--vp-c-text-2)">
It works fine until you are tailing three containers on two hosts at 2am.
</p>
<div class="mt-10 grid gap-4 md:grid-cols-2">
<div class="rounded-xl border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) p-6">
<div class="flex items-center gap-2 text-sm font-semibold tracking-wide text-(--vp-c-text-3) uppercase">
<Icon icon="mdi:console" class="size-4" />
docker logs
</div>
<ul class="mt-4! mb-0! flex list-none flex-col gap-3 p-0!">
<li v-for="row in rows" :key="row.cli" class="flex gap-2.5 text-(--vp-c-text-2)">
<Icon icon="mdi:close-circle-outline" class="mt-1 size-4 shrink-0 text-(--vp-c-text-3)" />
<span>{{ row.cli }}</span>
</li>
</ul>
</div>
<div class="rounded-xl border border-solid border-(--vp-c-brand-dimm) bg-(--vp-c-brand-dimm) p-6">
<div class="flex items-center gap-2 text-sm font-semibold tracking-wide text-(--vp-c-brand) uppercase">
<Icon icon="mdi:television-play" class="size-4" />
Dozzle
</div>
<ul class="mt-4! mb-0! flex list-none flex-col gap-3 p-0!">
<li v-for="row in rows" :key="row.dozzle" class="flex gap-2.5 text-(--vp-c-text-1)">
<Icon icon="mdi:check-circle-outline" class="mt-1 size-4 shrink-0 text-(--vp-c-brand)" />
<span>{{ row.dozzle }}</span>
</li>
</ul>
</div>
</div>
</section>
</template>
+9 -4
View File
@@ -8,8 +8,13 @@ import "./style.css";
import HeroVideo from "./components/HeroVideo.vue";
import BuyMeCoffee from "./components/BuyMeCoffee.vue";
import Stats from "./components/Stats.vue";
import Banner from "./components/Banner.vue";
import HeroTrust from "./components/HeroTrust.vue";
import Supported from "./components/Supported.vue";
import InstallCommand from "./components/InstallCommand.vue";
import WhyDozzle from "./components/WhyDozzle.vue";
import Testimonials from "./components/Testimonials.vue";
import FinalCta from "./components/FinalCta.vue";
import SponsoredBy from "./components/SponsoredBy.vue";
export default {
...DefaultTheme,
@@ -17,9 +22,9 @@ export default {
return h(DefaultTheme.Layout, null, {
"home-hero-image": () => h(HeroVideo),
"sidebar-nav-after": () => h(BuyMeCoffee),
"home-hero-actions-after": () => h(Stats),
"layout-top": () => h(Banner),
"home-hero-after": () => h(Supported),
"home-hero-actions-after": () => [h(Stats), h(HeroTrust)],
"home-hero-after": () => [h(InstallCommand), h(Supported)],
"home-features-after": () => [h(WhyDozzle), h(Testimonials), h(FinalCta), h(SponsoredBy)],
});
},
enhanceApp(ctx) {

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