Compare commits

..

93 Commits

Author SHA1 Message Date
Amir Raminfar 6f28474d46 chore: release v10.6.15 2026-08-07 10:01:10 -07:00
Amir Raminfar a25c379771 chore: updates d files 2026-08-07 10:00:54 -07:00
Amir Raminfar 7aa449d77c chore: updates d files 2026-08-07 10:00:34 -07:00
Piotr Lasota e1e2959856 fix: disambiguate containers with duplicate names across groups (#4893) 2026-08-07 09:59:07 -07:00
Amir Raminfar 8e39e35127 fix(cloud): stream logs from the container start (#4895) 2026-08-07 16:43:38 +00:00
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
156 changed files with 6300 additions and 2180 deletions
+7 -1
View File
@@ -3,5 +3,11 @@
"extends": ["config:recommended", "schedule:weekly", "group:allNonMajor", ":automergeMinor"],
"rangeStrategy": "bump",
"minimumReleaseAge": "24 hours",
"ignoreDeps": []
"ignoreDeps": [],
"packageRules": [
{
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"],
"groupName": "playwright"
}
]
}
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 1
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
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.16.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.4
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.16.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@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to DockerHub
uses: docker/login-action@v4.2.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.2.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.2.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@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to DockerHub
uses: docker/login-action@v4.2.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.2.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.2.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.16.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.16.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.16.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.16.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.4"
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.4"
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.16.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.16.0
node-version: latest
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
+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"
+20 -1
View File
@@ -1,15 +1,19 @@
/* eslint-disable */
/* prettier-ignore */
/* oxlint-disable */
/* oxfmt-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// 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 +21,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 +71,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 +124,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 +221,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
@@ -244,6 +253,7 @@ declare global {
const useElementBounding: typeof import('@vueuse/core').useElementBounding
const useElementByPoint: typeof import('@vueuse/core').useElementByPoint
const useElementHover: typeof import('@vueuse/core').useElementHover
const useElementOverflow: typeof import('@vueuse/core').useElementOverflow
const useElementSize: typeof import('@vueuse/core').useElementSize
const useElementVisibility: typeof import('@vueuse/core').useElementVisibility
const useEventBus: typeof import('@vueuse/core').useEventBus
@@ -417,6 +427,9 @@ 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
@@ -435,7 +448,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 +471,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 +520,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 +572,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 +666,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']>
@@ -680,6 +698,7 @@ declare module 'vue' {
readonly useElementBounding: UnwrapRef<typeof import('@vueuse/core')['useElementBounding']>
readonly useElementByPoint: UnwrapRef<typeof import('@vueuse/core')['useElementByPoint']>
readonly useElementHover: UnwrapRef<typeof import('@vueuse/core')['useElementHover']>
readonly useElementOverflow: UnwrapRef<typeof import('@vueuse/core')['useElementOverflow']>
readonly useElementSize: UnwrapRef<typeof import('@vueuse/core')['useElementSize']>
readonly useElementVisibility: UnwrapRef<typeof import('@vueuse/core')['useElementVisibility']>
readonly useEventBus: UnwrapRef<typeof import('@vueuse/core')['useEventBus']>
+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);
});
+3
View File
@@ -105,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']
@@ -169,6 +171,7 @@ declare module 'vue' {
'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']
+5 -1
View File
@@ -94,6 +94,9 @@
<router-link :to="{ name: '/container/[id]', params: { id: container.id } }" :title="container.name">
{{ container.name }}
</router-link>
<div v-if="container.customGroup" class="text-base-content/50 truncate text-xs">
{{ container.customGroup }}
</div>
</td>
<td v-if="isVisible('host')">{{ container.hostLabel }}</td>
<td v-if="isVisible('state')">{{ container.state }}</td>
@@ -151,7 +154,8 @@ const fields: Record<
name: {
label: "label.container-name",
mobileLabel: "label.name",
sortFunc: (a: Container, b: Container) => a.name.localeCompare(b.name) * direction.value,
sortFunc: (a: Container, b: Container) =>
(a.name.localeCompare(b.name) || (a.customGroup ?? "").localeCompare(b.customGroup ?? "")) * direction.value,
mobileVisible: true,
},
host: {
@@ -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();
@@ -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>
@@ -32,6 +32,9 @@
></div>
<div v-if="other.isSwarm">{{ other.swarmId }}</div>
<div v-else>{{ other.name }}</div>
<div v-if="other.hostLabel !== container.hostLabel" class="text-base-content/50 text-xs">
{{ other.hostLabel }}
</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 />
@@ -46,8 +49,20 @@
</div>
<ContainerHealth :health="container.health" v-if="container.health" />
<VolumeWarning :container="container" />
<Tag class="hidden! font-mono @xl:block!" size="small">
{{ container.image.replace(/@sha.*/, "") }}
<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>
@@ -56,6 +71,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) => {
@@ -71,7 +101,7 @@ const { containers: allContainers } = storeToRefs(store);
const otherContainers = computed(() =>
allContainers.value
.filter((c) => c.name === container.name && c.id !== container.id)
.filter((c) => c.name === container.name && c.id !== container.id && c.customGroup === container.customGroup)
.sort((a, b) => +b.created - +a.created),
);
</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();
+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" />
@@ -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>
+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() {
+71 -27
View File
@@ -48,6 +48,25 @@
}}</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>
<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>
@@ -148,33 +167,9 @@ onMounted(async () => {
await db.registerFileBuffer("logs.json", arrayBuffer);
state.value = "initializing";
// Uniform JSON messages flatten into a STRUCT and unnest() expands them into columns.
// Mixed message shapes make DuckDB infer `m` as JSON, which unnest() rejects, so fall
// back to flattening the union of object keys by hand.
const source = `read_json('logs.json', ignore_errors = true, format = 'newline_delimited', map_inference_threshold = -1)`;
try {
await conn.query(`CREATE TABLE logs AS SELECT unnest(m) FROM ${source}`);
} catch {
await conn.query(`DROP TABLE IF EXISTS logs`);
await conn.query(`CREATE TABLE raw_logs AS SELECT m::JSON AS m FROM ${source}`);
const keyRows = await conn.query<{ key: any }>(
`SELECT DISTINCT unnest(json_keys(m)) AS key FROM raw_logs WHERE json_type(m) = 'OBJECT'`,
);
const keys = keyRows.toArray().map((row) => String(row.key));
if (keys.length) {
const projection = keys
.map((key) => {
const ident = key.replace(/"/g, '""');
const path = `$."${ident}"`.replace(/'/g, "''");
return `json_extract_string(m, '${path}') AS "${ident}"`;
})
.join(", ");
await conn.query(`CREATE TABLE logs AS SELECT ${projection} FROM raw_logs WHERE json_type(m) = 'OBJECT'`);
} else {
await conn.query(`CREATE TABLE logs AS SELECT m FROM raw_logs`);
}
}
await conn.query(
`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) => ({
@@ -262,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>
@@ -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>
@@ -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-->
@@ -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>
+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 };
}
+3 -3
View File
@@ -57,12 +57,12 @@ 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`));
}
+8
View File
@@ -106,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" },
@@ -114,6 +116,12 @@ 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 },
];
}
+2 -2
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">
@@ -100,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>
+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;
}
+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) {
+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(() => {
+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);
+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";
+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.60.0-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",
{
@@ -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 -2
View File
@@ -8,7 +8,13 @@ import "./style.css";
import HeroVideo from "./components/HeroVideo.vue";
import BuyMeCoffee from "./components/BuyMeCoffee.vue";
import Stats from "./components/Stats.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,
@@ -16,8 +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),
"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) {
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+5 -1
View File
@@ -154,8 +154,12 @@
}
}
.VPHomeHero .text {
@apply text-3xl font-normal md:text-5xl;
}
.VPHomeHero .tagline {
@apply font-sans text-4xl font-light;
@apply font-sans text-lg font-light md:text-2xl;
color: var(--tag-line-color);
}
+6
View File
@@ -13,10 +13,16 @@ declare module 'vue' {
export interface GlobalComponents {
BuyMeCoffee: typeof import('./.vitepress/theme/components/BuyMeCoffee.vue')['default']
Counter: typeof import('./.vitepress/theme/components/Counter.vue')['default']
FinalCta: typeof import('./.vitepress/theme/components/FinalCta.vue')['default']
HeroTrust: typeof import('./.vitepress/theme/components/HeroTrust.vue')['default']
HeroVideo: typeof import('./.vitepress/theme/components/HeroVideo.vue')['default']
InstallCommand: typeof import('./.vitepress/theme/components/InstallCommand.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SponsoredBy: typeof import('./.vitepress/theme/components/SponsoredBy.vue')['default']
Stats: typeof import('./.vitepress/theme/components/Stats.vue')['default']
Supported: typeof import('./.vitepress/theme/components/Supported.vue')['default']
Testimonials: typeof import('./.vitepress/theme/components/Testimonials.vue')['default']
WhyDozzle: typeof import('./.vitepress/theme/components/WhyDozzle.vue')['default']
}
}
+3 -1
View File
@@ -227,6 +227,8 @@ Available properties:
Common Docker event names include `start`, `stop`, `die`, `kill`, `oom`, `restart`, `destroy`, and `health_status`.
For `health_status` events, Dozzle exposes the current state as `attributes["healthStatus"]` (`healthy` or `unhealthy`).
### Event Examples
**Alert when any production container dies:**
@@ -247,7 +249,7 @@ Event: name == "oom"
```
Container: true
Event: name == "health_status" && attributes["health_status"] == "unhealthy"
Event: name == "health_status" && attributes["healthStatus"] == "unhealthy"
```
**Alert on unexpected exits (ignoring clean and graceful shutdowns):**
+9 -1
View File
@@ -16,7 +16,7 @@ Dozzle has access to `docker.sock`, which — unless restricted — is equivalen
- **Keep [actions](/guide/actions) and [shell access](/guide/shell) disabled** unless you need them. They allow starting, stopping, recreating, and executing arbitrary commands inside containers.
- **Restrict users with [roles](#setting-specific-roles-for-users) and [filters](#setting-specific-filters-for-users)** in multi-user mode. Without explicit roles, a user can see every container the Dozzle instance can.
- **Run TLS at the reverse proxy**. See [Reverse Proxy & Base Path](/guide/changing-base) for Nginx / Traefik / Caddy examples.
- **Avoid mounting `docker.sock` read-write** if you don't need actions. A read-only mount (`/var/run/docker.sock:/var/run/docker.sock:ro`) still exposes most of the API but blocks container create/delete/update.
- **Restrict `docker.sock` access with a proxy** if you don't need actions. Note that a read-only mount (`/var/run/docker.sock:/var/run/docker.sock:ro`) does _not_ limit the API: the `:ro` flag only marks the socket file read-only on disk, while API calls still pass through the socket normally, so create/delete/update remain possible. To actually restrict operations, put a socket proxy like [`tecnativa/docker-socket-proxy`](https://github.com/Tecnativa/docker-socket-proxy) in front of the daemon.
## <Icon icon="mdi:account-cog-outline" inline /> File-Based User Management
@@ -189,6 +189,14 @@ docker run -it --rm amir20/dozzle generate admin --password password --email tes
In this example, `admin` is the username. Email and name are optional but recommended to display accurate avatars. `docker run -it --rm amir20/dozzle generate --help` displays all options. The `--user-filter` flag is a comma-separated list of filters. The `--user-roles` flag is a comma-separated list of roles.
If you omit `--password`, Dozzle prompts for it on stdin so the password never lands in your shell history. This requires an interactive terminal, so keep the `-it` flags:
```sh
docker run -it --rm amir20/dozzle generate admin --email test@email.net --name "John Doe" > users.yml
```
The prompt is written to stderr, so redirecting stdout to `users.yml` still works. You can also pipe the password in, for example `echo "$PASSWORD" | docker run -i --rm amir20/dozzle generate admin > users.yml`.
## <Icon icon="mdi:swap-horizontal" inline /> Forward Proxy
Dozzle can be configured to read proxy headers by setting `--auth-provider` to `forward-proxy`.
+6
View File
@@ -158,6 +158,12 @@ If you need a more opinionated experience with richer provider integrations (e.g
For setting up webhooks with your preferred service, see the [Alerts & Webhooks](/guide/alerts-and-webhooks) guide — it includes built-in payload templates for Slack, Discord, and ntfy that you can use as-is or customize.
## Why does Dozzle keep dockerd and containerd at slightly elevated CPU when no browser is connected?
Dozzle keeps container stats streaming for up to 6 hours (2 hours on Kubernetes) after the last browser disconnects, then shuts the stats collector down on its own. This is intentional. Stats are streamed continuously so that when you reopen the UI you see historical CPU and memory history instead of a blank chart. If streaming stopped the moment you closed the tab, there would be no history to show.
The cost is a small, steady amount of CPU in dockerd and containerd, since Docker's stats API is polling based. Restarting the Dozzle container resets the timer immediately, which is why a restart drops the host back to idle. This is not configurable by design. A small timeout would break other functionality that assumes stats are still streaming, so lowering it defeats the purpose of the stats history.
## My Dozzle instances are timing out in Swarm Mode or I'm not seeing all my Swarm nodes when behind a load balancer. How do I fix it?
In Swarm Mode, Dozzle instances may require their own overlay network. If you see inconsistent behavior when connecting to different Dozzle nodes, consider adding a separate overlay network which only contains the Dozzle instances, as shown below:
+6
View File
@@ -26,6 +26,12 @@ rules:
- apiGroups: [""]
resources: ["pods", "pods/log", "nodes"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "replicasets", "daemonsets", "statefulsets"]
verbs: ["get"]
- apiGroups: ["batch"]
resources: ["jobs", "cronjobs"]
verbs: ["get"]
- apiGroups: ["metrics.k8s.io"]
resources: ["pods"]
verbs: ["get", "list"]
+1
View File
@@ -39,6 +39,7 @@ All tools are **read-only** and do not modify containers.
| ---------------------- | ------------------------------------------------------------------------------------ |
| `list_containers` | List all containers across all hosts. Supports optional `state` filter. |
| `get_container_logs` | Fetch structured logs with detected levels, JSON parsing, and multi-line grouping. |
| `search_container_logs`| Search container logs for a keyword or phrase. Returns only matching entries. |
| `list_hosts` | List all connected Docker hosts. |
| `get_container_stats` | Get CPU and memory usage history for a container. |
+28 -12
View File
@@ -2,10 +2,12 @@
# https://vitepress.dev/reference/default-theme-home-page
layout: home
title: Home
titleTemplate: Real-time Docker Log Viewer
description: Dozzle is a lightweight, open-source log viewer for Docker, Swarm, and Kubernetes. Stream logs, watch live stats, and debug containers from your browser.
hero:
name: "Dozzle"
text: "See what your containers are doing"
tagline: Real-time Docker logs, stats, and debugging — in your browser.
actions:
- theme: brand
@@ -22,12 +24,16 @@ features:
src: /icons/document.svg
width: 36
height: 36
link: /guide/what-is-dozzle#advanced-log-handling
linkText: Learn More
- title: Live Stats & Metrics
details: Watch CPU, memory, and network usage update in real time, with rolling history charts on every container.
icon:
src: /icons/chart-line-data.svg
width: 36
height: 36
link: /guide/what-is-dozzle#real-time-monitoring
linkText: Learn More
- title: SQL Log Analysis
details: Query your logs with DuckDB and WebAssembly — full SQL, running entirely in the browser.
icon:
@@ -52,12 +58,6 @@ features:
height: 36
link: /guide/remote-hosts
linkText: Learn More
- title: Self-hosted & Private
details: Runs in your own infrastructure. Your logs never leave your network.
icon:
src: /icons/locked.svg
width: 36
height: 36
- title: Shell & Exec Access
details: Attach to running containers or exec commands directly from the browser when you need to dig deeper.
icon:
@@ -66,12 +66,28 @@ features:
height: 36
link: /guide/shell
linkText: Learn More
- title: Officially Sponsored by Docker
details: Open source and part of Docker's sponsored OSS program.
- title: dtop in Your Terminal
details: A command-line companion that gives you a live view of your containers, then jumps straight into Dozzle.
icon:
src: /icons/docker-icon.svg
src: /icons/terminal-command.svg
width: 36
height: 36
link: https://hub.docker.com/r/amir20/dozzle
linkText: Docker Hub
link: /guide/dtop
linkText: Learn More
- title: MCP for AI Assistants
details: Expose containers, logs, and stats over the Model Context Protocol so your coding agent can debug alongside you.
icon:
src: /icons/ai.svg
width: 36
height: 36
link: /guide/mcp
linkText: Learn More
- title: Self-hosted & Private
details: Runs in your own infrastructure with simple or forward-proxy auth. Your logs never leave your network.
icon:
src: /icons/locked.svg
width: 36
height: 36
link: /guide/authentication
linkText: Learn More
---
+1 -1
View File
@@ -6,6 +6,6 @@
"dozzle": "workspace:*"
},
"dependencies": {
"@fontsource-variable/playfair-display": "^5.2.8"
"@fontsource-variable/playfair-display": "^5.3.0"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32"><path fill="#646cff" d="m14 3l2.4 6.6L23 12l-6.6 2.4L14 21l-2.4-6.6L5 12l6.6-2.4z"/><path fill="#646cff" d="m24 18l1.4 3.6L29 23l-3.6 1.4L24 28l-1.4-3.6L19 23l3.6-1.4z"/></svg>

After

Width:  |  Height:  |  Size: 262 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32"><path fill="#646cff" d="M26 4.01H6a2 2 0 0 0-2 2v20a2 2 0 0 0 2 2h20a2 2 0 0 0 2-2v-20a2 2 0 0 0-2-2m0 2v4H6v-4Zm-20 20v-14h20v14Z"/><path fill="#646cff" d="M9 18h3v6H9zm5.5-3h3v9h-3zM20 20h3v4h-3z"/></svg>

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig({
plugins: [
tailwindcss(),
Components({
dirs: [path.resolve(__dirname, ".vitepress/theme/components")],
dirs: [path.resolve(import.meta.dirname, ".vitepress/theme/components")],
extensions: ["vue", "md"],
include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
dts: true,
+1
View File
@@ -1,5 +1,6 @@
import { test, expect } from "@playwright/test";
// Covers DOZZLE_REMOTE_AGENT (gRPC agent). See remote-host.spec.ts for DOZZLE_REMOTE_HOST.
test.beforeEach(async ({ page }) => {
await page.goto("http://dozzle-with-agent:8080/");
});
-15
View File
@@ -1,15 +0,0 @@
import { test, expect } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.goto("http://remote:8080/");
});
test("has right title", async ({ page }) => {
await expect(page).toHaveTitle(/.* - Dozzle/);
});
test("select running container", async ({ page }) => {
await page.getByTestId("side-menu").getByRole("link", { name: "dozzle" }).click();
await expect(page).toHaveURL(/\/container/);
await expect(page.getByText("Accepting connections")).toBeVisible();
});
+36 -1
View File
@@ -1,7 +1,10 @@
import { test, expect } from "@playwright/test";
const origin = "http://custom_base:8080";
const base = "/foobarbase";
test.beforeEach(async ({ page }) => {
await page.goto("http://custom_base:8080/foobarbase");
await page.goto(origin + base);
});
test("has right title", async ({ page }) => {
@@ -11,3 +14,35 @@ test("has right title", async ({ page }) => {
test("url should have custom base", async ({ page }) => {
await expect(page).toHaveURL(/foobarbase/);
});
test("static assets resolve under the custom base", async ({ page }) => {
const requested: string[] = [];
const broken: string[] = [];
const isAsset = (url: string) => new URL(url).pathname.includes("/assets/");
page.on("request", (request) => {
if (isAsset(request.url())) requested.push(new URL(request.url()).pathname);
});
page.on("response", (response) => {
if (isAsset(response.url()) && response.status() >= 400) {
broken.push(`${response.status()} ${response.url()}`);
}
});
await page.reload();
await expect(page).toHaveTitle(/.* - Dozzle/);
// fonts are only fetched when a glyph needs them, so force the @font-face src to resolve.
// swallow the rejection on a bad url so the assertions below report which path was wrong
await page.evaluate(() =>
document.fonts.load('400 1rem "JetBrains Mono"').then(
() => {},
() => {},
),
);
// the css lives at <base>/assets/, so a relative url() in it must resolve back under <base>
expect(requested.some((path) => path.endsWith(".woff2"))).toBe(true);
expect(requested.filter((path) => !path.startsWith(`${base}/assets/`))).toEqual([]);
expect(broken).toEqual([]);
});
+34
View File
@@ -0,0 +1,34 @@
import { test, expect } from "@playwright/test";
// Targets a dedicated instance filtered to the `logspam` container, which prints a
// numbered line every second. That makes "logs are actually streaming" assertable
// without depending on incidental output from Dozzle itself.
test.beforeEach(async ({ page }) => {
await page.goto("http://logs-viewer:8080/");
await page.getByTestId("side-menu").getByRole("link", { name: "logspam" }).click();
await expect(page).toHaveURL(/\/container\//);
});
test("renders historical logs on load", async ({ page }) => {
await expect(page.locator("ul[data-logs] > li").first()).toBeVisible();
await expect(page.getByTestId("no-logs")).toBeHidden();
await expect(page.getByText(/logspam line \d+/).first()).toBeVisible();
});
test("streams new logs over SSE without a reload", async ({ page }) => {
const entries = page.locator("ul[data-logs] > li");
await expect(entries.first()).toBeVisible();
const initial = await entries.count();
// logspam emits ~1 line/sec, so a few new entries must arrive on the open stream.
await expect.poll(() => entries.count(), { timeout: 20_000 }).toBeGreaterThan(initial);
});
test("keeps the newest log entry in view while streaming", async ({ page }) => {
const entries = page.locator("ul[data-logs] > li");
await expect(entries.first()).toBeVisible();
const initial = await entries.count();
await expect.poll(() => entries.count(), { timeout: 20_000 }).toBeGreaterThan(initial);
await expect(entries.last()).toBeInViewport();
});
+30
View File
@@ -0,0 +1,30 @@
import { test, expect, devices } from "@playwright/test";
test.use({ ...devices["Pixel 5"] });
test.describe("mobile stats", () => {
test.beforeEach(async ({ page }) => {
await page.goto("http://dozzle:8080/");
});
test("shows CPU and memory stats for containers on the dashboard", async ({ page }) => {
const row = page.locator("tr").filter({ has: page.getByTitle("dozzle") });
await expect(row.getByText(/^\d+%$/)).toBeVisible();
await expect(row.getByText(/^[\d.]+ ?[KMGT]?B$/)).toBeVisible();
});
test("locks the container stats header directly under the mobile nav, with no gap or overlap", async ({ page }) => {
const row = page.locator("tr").filter({ has: page.getByTitle("dozzle") });
await row.getByTitle("dozzle").click();
await expect(page).toHaveURL(/\/container\//);
const nav = await page.getByTestId("navigation").boundingBox();
const header = await page.getByTestId("scrollable-header").boundingBox();
expect(nav).not.toBeNull();
expect(header).not.toBeNull();
// The stats header must sit flush against the bottom of the fixed nav bar:
// any gap exposes empty space, any overlap clips the CPU/MEM chips underneath the nav.
expect(Math.abs(header!.y - (nav!.y + nav!.height))).toBeLessThanOrEqual(1);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { test, expect } from "@playwright/test";
// Covers DOZZLE_REMOTE_HOST (tcp:// through the socket proxy), a different code path
// than DOZZLE_REMOTE_AGENT. See agent.spec.ts for that one.
test.beforeEach(async ({ page }) => {
await page.goto("http://remote:8080/");
});
test("has right title", async ({ page }) => {
await expect(page).toHaveTitle(/.* - Dozzle/);
});
test("shows the labeled remote host", async ({ page }) => {
// The host column on the dashboard, rather than a bare getByText: the label also
// appears in the sidebar and the merge link, and which of them exist depends on
// whether containers have loaded yet.
await expect(page.getByRole("cell", { name: "remote-host" }).first()).toBeVisible();
});
test("select running container", async ({ page }) => {
await page.getByTestId("side-menu").getByRole("link", { name: "dozzle" }).click();
await expect(page).toHaveURL(/\/container/);
await expect(page.getByText("Accepting connections")).toBeVisible();
});
+52 -2
View File
@@ -1,9 +1,59 @@
import { test, expect } from "@playwright/test";
test("simple authentication", async ({ page }) => {
await page.goto("http://simple-auth:8080/");
const BASE = "http://simple-auth:8080";
async function login(page: import("@playwright/test").Page) {
await page.locator('input[name="username"]').fill("admin");
await page.locator('input[name="password"]').fill("password");
await page.locator('button[type="submit"]').click();
}
test("simple authentication", async ({ page }) => {
await page.goto(BASE + "/");
await login(page);
await expect(page.getByTestId("settings")).toBeVisible();
});
test("unauthenticated request redirects to the login page", async ({ page }) => {
await page.goto(BASE + "/");
await expect(page).toHaveURL(/\/login/);
await expect(page.locator('input[name="username"]')).toBeVisible();
});
test("preserves the requested url as redirectUrl", async ({ page }) => {
await page.goto(BASE + "/settings");
await expect(page).toHaveURL(/\/login\?redirectUrl=/);
});
test("rejects a wrong password", async ({ page }) => {
await page.goto(BASE + "/");
await page.locator('input[name="username"]').fill("admin");
await page.locator('input[name="password"]').fill("not-the-password");
await page.locator('button[type="submit"]').click();
await expect(page.getByText("Username or password are not valid")).toBeVisible();
await expect(page.getByTestId("settings")).toBeHidden();
});
test("api rejects unauthenticated requests with 401", async ({ request }) => {
// A regression here is a data leak, not a UI bug: /api must never serve an anonymous caller.
for (const path of ["/api/version", "/api/events/stream", "/api/notifications/rules"]) {
const response = await request.get(BASE + path, { maxRedirects: 0 });
expect(response.status(), `${path} should be unauthorized`).toBe(401);
}
});
test("logout clears the session", async ({ page }) => {
await page.goto(BASE + "/");
await login(page);
await expect(page.getByTestId("settings")).toBeVisible();
await page.getByTestId("user-menu").locator("label").click();
await page.getByRole("button", { name: "Logout" }).click();
await expect(page.locator('input[name="username"]')).toBeVisible();
// The cookie must be gone server-side, not just visually logged out.
const response = await page.request.get(BASE + "/api/version", { maxRedirects: 0 });
expect(response.status()).toBe(401);
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

+6
View File
@@ -13,6 +13,12 @@ rules:
- apiGroups: [""]
resources: ["pods", "pods/log", "nodes"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "replicasets", "daemonsets", "statefulsets"]
verbs: ["get"]
- apiGroups: ["batch"]
resources: ["jobs", "cronjobs"]
verbs: ["get"]
- apiGroups: ["metrics.k8s.io"]
resources: ["pods"]
verbs: ["get", "list"]
+33 -31
View File
@@ -13,52 +13,55 @@ require (
github.com/spf13/afero v1.15.0
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1
golang.org/x/net v0.54.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.47.0 // indirect
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/PuerkitoBio/goquery v1.12.0
github.com/andybalholm/brotli v1.2.1
github.com/compose-spec/compose-go/v2 v2.11.0
github.com/andybalholm/brotli v1.2.2
github.com/compose-spec/compose-go/v2 v2.14.0
github.com/expr-lang/expr v1.17.8
github.com/go-chi/chi/v5 v5.3.0
github.com/go-chi/chi/v5 v5.3.1
github.com/go-chi/jwtauth/v5 v5.4.0
github.com/go-faker/faker/v4 v4.7.0
github.com/go-git/go-git/v5 v5.19.1
github.com/go-faker/faker/v4 v4.10.1
github.com/go-git/go-git/v5 v5.19.2
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/moby/docker-image-spec v1.3.1
github.com/moby/moby/api v1.54.2
github.com/moby/moby/client v0.4.1
github.com/moby/moby/api v1.55.0
github.com/moby/moby/client v0.5.1
github.com/puzpuzpuz/xsync/v4 v4.5.0
github.com/rs/zerolog v1.35.1
github.com/samber/lo v1.53.0
github.com/wk8/go-ordered-map/v2 v2.1.8
github.com/yuin/goldmark v1.8.2
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/crypto v0.52.0
golang.org/x/sync v0.20.0
google.golang.org/grpc v1.81.1
github.com/yuin/goldmark v1.8.5
go.yaml.in/yaml/v3 v3.0.5
golang.org/x/crypto v0.54.0
golang.org/x/sync v0.22.0
google.golang.org/grpc v1.83.0
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af
k8s.io/api v0.36.1
k8s.io/apimachinery v0.36.1
k8s.io/client-go v0.36.1
k8s.io/metrics v0.36.1
k8s.io/api v0.36.3
k8s.io/apimachinery v0.36.3
k8s.io/client-go v0.36.3
k8s.io/metrics v0.36.3
)
require github.com/modelcontextprotocol/go-sdk v1.6.1
require (
github.com/modelcontextprotocol/go-sdk v1.7.0
golang.org/x/term v0.45.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/air-verse/air v1.65.3 // indirect
github.com/air-verse/air v1.67.4 // indirect
github.com/alexflint/go-scalar v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bep/godartsass/v2 v2.5.0 // indirect
github.com/bep/golibsass v1.2.0 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bits-and-blooms/bitset v1.24.5 // indirect
github.com/buger/jsonparser v1.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
@@ -96,7 +99,7 @@ require (
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/gohugoio/hashstructure v0.6.0 // indirect
github.com/gohugoio/hugo v0.161.0 // indirect
github.com/gohugoio/hugo v0.164.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
@@ -122,7 +125,7 @@ require (
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
@@ -141,23 +144,22 @@ require (
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0 // indirect
k8s.io/streaming v0.36.1 // indirect
k8s.io/streaming v0.36.3 // indirect
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
@@ -165,7 +167,7 @@ require (
sigs.k8s.io/yaml v1.6.0 // indirect
)
go 1.26.4
go 1.26.5
tool (
github.com/air-verse/air
+89 -71
View File
@@ -13,18 +13,20 @@ github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/air-verse/air v1.65.1 h1:G4hy4YemKrIcGAvYMor+86PhxdtBEq0n9ebJ4RoYy8w=
github.com/air-verse/air v1.65.1/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/air-verse/air v1.65.2/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/air-verse/air v1.65.3 h1:7NUAqofZuSf5vZbVoZpdTr/4LuKkOCj1JTiMrOdUs7A=
github.com/air-verse/air v1.65.3/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/air-verse/air v1.67.1/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/air-verse/air v1.67.2/go.mod h1:XumHRkXbIg8CU5FqhQHgQC4oxQW/OQLwUMQAWln8cZs=
github.com/air-verse/air v1.67.3/go.mod h1:XumHRkXbIg8CU5FqhQHgQC4oxQW/OQLwUMQAWln8cZs=
github.com/air-verse/air v1.67.4/go.mod h1:XumHRkXbIg8CU5FqhQHgQC4oxQW/OQLwUMQAWln8cZs=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alexflint/go-arg v1.6.1 h1:uZogJ6VDBjcuosydKgvYYRhh9sRCusjOvoOLZopBlnA=
github.com/alexflint/go-arg v1.6.1/go.mod h1:nQ0LFYftLJ6njcaee0sU+G0iS2+2XJQfA8I062D0LGc=
github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
@@ -69,6 +71,7 @@ github.com/bep/tmc v0.6.0 h1:5zWy4L+3gS+Kk8czzLC4g7ETaC3wkX9ZtTRdAdL8V4s=
github.com/bep/tmc v0.6.0/go.mod h1:SNHxc3o2WSNMAYqJcAO0rxFY+pbhZzMwjIHe5xaAue0=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.24.5/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g=
github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -81,10 +84,10 @@ github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoK
github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/compose-spec/compose-go/v2 v2.10.2 h1:USa1NUbDcl/cjb8T9iwnuFsnO79H+2ho2L5SjFKz3uI=
github.com/compose-spec/compose-go/v2 v2.10.2/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg=
github.com/compose-spec/compose-go/v2 v2.11.0 h1:xoq/ootgIL6TsHmbJHrkuh7+bzjhPV3NHftHRPPyVXM=
github.com/compose-spec/compose-go/v2 v2.11.0/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg=
github.com/compose-spec/compose-go/v2 v2.13.0 h1:2+2oS3v4SrtAOBdZRAZYBsBy47D571p5EXMSCppmTtE=
github.com/compose-spec/compose-go/v2 v2.13.0/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg=
github.com/compose-spec/compose-go/v2 v2.14.0 h1:uaJeo5B3+OVlu+Rx2qLBcAdXPEUUzm5nQrRiGJafRAQ=
github.com/compose-spec/compose-go/v2 v2.14.0/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
@@ -132,24 +135,26 @@ github.com/getkin/kin-openapi v0.137.0 h1:Q3HhawNQV0GfvO2mIYMUBUSEFrDsVlzcYz4Vyd
github.com/getkin/kin-openapi v0.137.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
github.com/go-faker/faker/v4 v4.7.0 h1:VboC02cXHl/NuQh5lM2W8b87yp4iFXIu59x4w0RZi4E=
github.com/go-faker/faker/v4 v4.7.0/go.mod h1:u1dIRP5neLB6kTzgyVjdBOV5R1uP7BdxkcWk7tiKQXk=
github.com/go-faker/faker/v4 v4.9.0 h1:a4HXLwueuTCtgF93VpUsl8Zd2nG1VH2SgNWDPVEBg5U=
github.com/go-faker/faker/v4 v4.9.0/go.mod h1:u1dIRP5neLB6kTzgyVjdBOV5R1uP7BdxkcWk7tiKQXk=
github.com/go-faker/faker/v4 v4.10.0 h1:rHTZVwG1x8aN4zXDYJUFucOGDQojuxOby5MTdC/M2Fw=
github.com/go-faker/faker/v4 v4.10.0/go.mod h1:X+KzPB4JZ82GY4MYr7NV7zmp+i/K0SG5EDKqIg5zd1k=
github.com/go-faker/faker/v4 v4.10.1 h1:mGH3y0X8AfrXsfgPsPuTkWBYIebomvdkWEN1uDLoFFg=
github.com/go-faker/faker/v4 v4.10.1/go.mod h1:VFIEwWDd16EdYDLF6NJ5gAAzEp7vz5LgKgJ2iZ17Tdg=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.19.0 h1:+WkVUQZSy/F1Gb13udrMKjIM2PrzsNfDKFSfo5tkMtc=
github.com/go-git/go-git/v5 v5.19.0/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY=
github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -211,6 +216,7 @@ github.com/gohugoio/httpcache v0.8.0 h1:hNdsmGSELztetYCsPVgjA960zSa4dfEqqF/Sfico
github.com/gohugoio/httpcache v0.8.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
github.com/gohugoio/hugo v0.161.0 h1:RnVIwSeTVDbhXbSwa+5C8QFkQVa634IdmekvSjLVMmI=
github.com/gohugoio/hugo v0.161.0/go.mod h1:ZJStxHMZXnnhvCfOAy6FCLbWf90zTpH/cnvWAcmoyiE=
github.com/gohugoio/hugo v0.164.0/go.mod h1:QPBCN6Gntmm6YrYJEnTasaZjnMLRkv8YoyCOw7mo9JE=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0 h1:I/n6v7VImJ3aISLnn73JAHXyjcQsMVvbguQPTk9Ehus=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0/go.mod h1:9LJNfKWFmhEJ7HW0in5znezMwH+FYMBIhNZ3VWtRcRs=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 h1:p13Q0DBCrBRpJGtbtlgkYNCs4TnIlZJh8vHgnAiofrI=
@@ -297,16 +303,18 @@ github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y=
github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY=
github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -345,6 +353,7 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
@@ -416,8 +425,10 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -426,18 +437,26 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8V
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -447,10 +466,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
@@ -460,8 +477,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -472,8 +489,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -483,8 +500,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -500,10 +517,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -513,8 +528,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -525,8 +540,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -535,19 +550,22 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1 h1:/WILD1UcXj/ujCxgoL/DvRgt2CP3txG8+FwkUbb9110=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1/go.mod h1:YNKnb2OAApgYn2oYY47Rn7alMr1zWjb2U8Q0aoGWiNc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 h1:rgSNvqscFZ1JgV/4wH5GOsZFSFkR2Eua9As3KIr2LlM=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2/go.mod h1:iMEtFwDlAhjDU9L5mY6U1XLwlIId/G3h+QcBHDIvrJ8=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
@@ -568,30 +586,30 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80=
k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34=
k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY=
k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo=
k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ=
k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc=
k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA=
k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8=
k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c=
k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y=
k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0=
k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU=
k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY=
k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg=
k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w=
k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg=
k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ=
k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4=
k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM=
k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE=
k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI=
k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0=
k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg=
k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0 h1:1h+/yvsq5zm1mP/1wxmkRjTdrGpNDmumj+lsiJgwWTQ=
k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY=
k8s.io/metrics v0.36.0 h1:VF41Mv9ZWKKQ4jEiJ0n3Tp6jdyO+oM6dbKcJn6Y/DVg=
k8s.io/metrics v0.36.0/go.mod h1:FY1dgPJZqnSfnOYbVdBEdRNUdy0n1nUCU6yxSMUrVG4=
k8s.io/metrics v0.36.1 h1:MQPb+G4RhrKEpt8NETPssbW8QgGUc4Jbqu1jx+kPqGk=
k8s.io/metrics v0.36.1/go.mod h1:xqS8XcWLjDzo6E7DJm/GfjKpRKdN5/MtJAQFuV6nLUc=
k8s.io/streaming v0.36.0 h1:agnTxU+NFulUrtYzXUGKO3ndEa8jKwht1Kwn9nu9x+4=
k8s.io/streaming v0.36.0/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
k8s.io/streaming v0.36.1 h1:L+K68n4Gg940BGNNYtUBvL1WTLL0YnKT3s+P1MNAmR4=
k8s.io/streaming v0.36.1/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
k8s.io/metrics v0.36.2 h1:yfUIe2Vwx2cQAIpVYcin1JXdabrRz98oTxP2HJTxHj8=
k8s.io/metrics v0.36.2/go.mod h1:Q/dNyLLzgSxPu0/e+996Du4pjutfEyyHOKgK0lkncp0=
k8s.io/metrics v0.36.3 h1:NDKceAgWS8CJCdDtM5kFACkBOa9Lxia1jUiibJfvUgQ=
k8s.io/metrics v0.36.3/go.mod h1:NTLS8ybwn+zYGwKqYublWPvmnNp8N4pV3etjtx7XWaM=
k8s.io/streaming v0.36.2 h1:NSKthPPg9UFSKsRauVJUVGH2Dvn8fhKmY4qrMkw/p98=
k8s.io/streaming v0.36.2/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
k8s.io/streaming v0.36.3 h1:9rAaqBk0C0Pc7+/fqGekj07NV+/Xrew58p647A0JT8w=
k8s.io/streaming v0.36.3/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc=
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
+7 -1
View File
@@ -15,6 +15,12 @@ type contextKey string
const remoteUser contextKey = "remoteUser"
// WithUser returns a copy of ctx carrying user so downstream handlers can
// resolve it via UserFromContext.
func WithUser(ctx context.Context, user User) context.Context {
return context.WithValue(ctx, remoteUser, user)
}
type proxyAuthContext struct {
headerUser string
headerEmail string
@@ -55,7 +61,7 @@ func (p *proxyAuthContext) AuthMiddleware(next http.Handler) http.Handler {
userRoles = ParseRole(r.Header.Get(p.headerRoles))
}
user := newUser(r.Header.Get(p.headerUser), r.Header.Get(p.headerEmail), r.Header.Get(p.headerName), containerFilter, userRoles)
ctx := context.WithValue(r.Context(), remoteUser, user)
ctx := WithUser(r.Context(), user)
next.ServeHTTP(w, r.WithContext(ctx))
} else {
next.ServeHTTP(w, r)
+9 -2
View File
@@ -3,7 +3,9 @@ package auth
import (
"crypto/sha256"
"errors"
"maps"
"net/http"
"slices"
"time"
"github.com/go-chi/jwtauth/v5"
@@ -18,8 +20,13 @@ type simpleAuthContext struct {
var ErrInvalidCredentials = errors.New("invalid credentials")
func NewSimpleAuth(userDatabase UserDatabase, ttl time.Duration) *simpleAuthContext {
// Hash the users in a stable order. Ranging over the map directly makes the
// digest depend on Go's randomized map iteration order, so any users.yml with
// more than one user derives a different signing key on every start and
// silently invalidates every session on restart.
h := sha256.New()
for _, user := range userDatabase.Users {
for _, username := range slices.Sorted(maps.Keys(userDatabase.Users)) {
user := userDatabase.Users[username]
h.Write([]byte(user.Password))
h.Write([]byte(user.RolesConfigured))
}
@@ -39,7 +46,7 @@ func (a *simpleAuthContext) CreateToken(username, password string) (string, erro
return "", ErrInvalidCredentials
}
claims := map[string]interface{}{"username": user.Username, "email": user.Email, "name": user.Name, "filter": user.Filter, "roles": user.Roles}
claims := map[string]any{"username": user.Username, "email": user.Email, "name": user.Name, "filter": user.Filter, "roles": user.Roles}
jwtauth.SetIssuedNow(claims)
if a.ttl > 0 {
+50
View File
@@ -0,0 +1,50 @@
package auth
import (
"testing"
"github.com/stretchr/testify/require"
)
// The JWT signing key is derived from the users in users.yml so that a token
// stays valid across restarts and is only invalidated when a password or role
// actually changes. Deriving it by ranging over the user map made the digest
// depend on Go's randomized map iteration order, so every restart of a
// multi-user setup rolled the key and logged everyone out.
func TestSimpleAuthSigningKeyIsStableAcrossRestarts(t *testing.T) {
users := UserDatabase{
Users: map[string]*User{
"alice": {Username: "alice", Password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", RolesConfigured: "all"},
"bob": {Username: "bob", Password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", RolesConfigured: "shell"},
"carol": {Username: "carol", Password: "$2a$11$cccccccccccccccccccccccccccccccccccccccccccccccccccccc", RolesConfigured: "actions"},
"dave": {Username: "dave", Password: "$2a$11$dddddddddddddddddddddddddddddddddddddddddddddddddddddd", RolesConfigured: "download"},
},
}
_, token, err := NewSimpleAuth(users, 0).tokenAuth.Encode(map[string]any{"username": "alice"})
require.NoError(t, err)
// Each iteration stands in for a Dozzle restart against an unchanged users.yml.
for i := range 50 {
if _, err := NewSimpleAuth(users, 0).tokenAuth.Decode(token); err != nil {
t.Fatalf("token issued before restart %d was rejected: %v", i+1, err)
}
}
}
// Changing a password or a role must still roll the key so old tokens stop working.
func TestSimpleAuthSigningKeyChangesWhenCredentialsChange(t *testing.T) {
users := UserDatabase{
Users: map[string]*User{
"alice": {Username: "alice", Password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", RolesConfigured: "all"},
"bob": {Username: "bob", Password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", RolesConfigured: "shell"},
},
}
_, token, err := NewSimpleAuth(users, 0).tokenAuth.Encode(map[string]any{"username": "alice"})
require.NoError(t, err)
users.Users["bob"].RolesConfigured = "shell,actions"
_, err = NewSimpleAuth(users, 0).tokenAuth.Decode(token)
require.Error(t, err)
}
+2 -4
View File
@@ -162,9 +162,7 @@ func (ls *logStreamer) startReader(parent context.Context, c container.Container
return
}
ls.wg.Add(1)
go func() {
defer ls.wg.Done()
ls.wg.Go(func() {
defer func() {
ls.mu.Lock()
delete(ls.readers, key)
@@ -172,7 +170,7 @@ func (ls *logStreamer) startReader(parent context.Context, c container.Container
cancel()
}()
ls.runReader(readerCtx, cs, minRank)
}()
})
}
// runReader follows logs from a single container and pushes batches directly
+13 -1
View File
@@ -84,9 +84,21 @@ func executeStreamLogs(ctx context.Context, requestID string, argsJSON string, d
events := make(chan *container.LogEvent, 100)
// Both backends already cap a follow stream at the last N lines (Docker
// Tail: 100, k8s TailLines: 500), so `from` only decides how many of those
// survive the since-filter. now-30s threw nearly all of them away, which
// left a quiet container showing an empty pane until it happened to log
// something. Anchoring at the container's start lets the tail cap do the
// work: last N lines, then live. Same value the web UI streams from, see
// streamLogs in internal/web/logs.go.
from := cs.Container.StartedAt
if from.IsZero() {
from = time.Now().Add(-30 * time.Second)
}
go func() {
defer close(events)
if err := cs.StreamLogs(ctx, time.Now().Add(-30*time.Second), container.STDOUT|container.STDERR, events); err != nil {
if err := cs.StreamLogs(ctx, from, container.STDOUT|container.STDERR, events); err != nil {
log.Debug().Err(err).Str("container", cs.Container.Name).Msg("StreamLogs ended with error")
}
}()
+52
View File
@@ -414,3 +414,55 @@ func TestExecuteStreamLogs_BatchingAt50(t *testing.T) {
assert.Len(t, firstBatch.Entries, 50)
assert.False(t, responses[0].GetCallTool().EndStream)
}
func TestExecuteStreamLogs_StreamsFromContainerStart(t *testing.T) {
// Docker caps the follow stream at the last 100 lines and k8s at 500, so
// `from` is what decides how many of them survive the since-filter. Passing
// a recent timestamp discarded the backlog and left the cloud dashboard on
// an empty pane until the container next logged. Anchor at StartedAt so the
// backend's tail cap delivers the tail it already fetched.
startedAt := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
var gotFrom time.Time
mockClient := &StreamMockClientService{}
mockClient.streamFunc = func(_ context.Context, _ container.Container, from time.Time, _ container.StdType, events chan<- *container.LogEvent) error {
gotFrom = from
events <- &container.LogEvent{RawMessage: "backlog", Level: "info", Stream: "stdout", Timestamp: 1000}
return nil
}
c := container.Container{ID: "abc123", Name: "test-container", Host: "host1", StartedAt: startedAt}
mockHost := &MockHostService{}
withResolver(mockHost, c)
mockHost.On("FindContainer", "host1", "abc123", container.ContainerLabels(nil)).
Return(container_support.NewContainerService(mockClient, c), nil)
err := executeStreamLogs(context.Background(), "req1", `{"container_id":"abc123","host_id":"host1"}`,
ToolDeps{HostService: mockHost}, func(*pb.ToolResponse) error { return nil })
assert.NoError(t, err)
assert.Equal(t, startedAt, gotFrom)
}
func TestExecuteStreamLogs_FallsBackWhenStartedAtUnknown(t *testing.T) {
// A container with no StartedAt (agent gaps, odd backends) must still get a
// usable window rather than the zero time, which would ask the daemon for
// every line it has ever held.
var gotFrom time.Time
mockClient := &StreamMockClientService{}
mockClient.streamFunc = func(_ context.Context, _ container.Container, from time.Time, _ container.StdType, _ chan<- *container.LogEvent) error {
gotFrom = from
return nil
}
c := container.Container{ID: "abc123", Name: "test-container", Host: "host1"}
mockHost := &MockHostService{}
withResolver(mockHost, c)
mockHost.On("FindContainer", "host1", "abc123", container.ContainerLabels(nil)).
Return(container_support.NewContainerService(mockClient, c), nil)
err := executeStreamLogs(context.Background(), "req1", `{"container_id":"abc123","host_id":"host1"}`,
ToolDeps{HostService: mockHost}, func(*pb.ToolResponse) error { return nil })
assert.NoError(t, err)
assert.False(t, gotFrom.IsZero())
assert.WithinDuration(t, time.Now(), gotFrom, time.Minute)
}
+13 -6
View File
@@ -448,14 +448,21 @@ func (s *ContainerStore) init() {
case "rename":
s.containers.Compute(event.ActorID, func(c *Container, loaded bool) (*Container, xsync.ComputeOp) {
if loaded {
log.Debug().Str("id", event.ActorID).Str("name", event.ActorAttributes["name"]).Msg("container renamed")
copy := *c
copy.Name = event.ActorAttributes["name"]
return &copy, xsync.UpdateOp
} else {
if !loaded {
return c, xsync.CancelOp
}
// A dev.dozzle.name or coolify.serviceName label pins a custom
// display name (see newContainer). That name must survive a
// docker-level rename, so only follow the rename when the name
// actually comes from Docker.
if c.Labels["dev.dozzle.name"] != "" || c.Labels["coolify.serviceName"] != "" {
log.Debug().Str("id", event.ActorID).Msg("ignoring rename: container has a custom name label")
return c, xsync.CancelOp
}
log.Debug().Str("id", event.ActorID).Str("name", event.ActorAttributes["name"]).Msg("container renamed")
copy := *c
copy.Name = event.ActorAttributes["name"]
return &copy, xsync.UpdateOp
})
}
s.subscribers.Range(func(c context.Context, events chan<- ContainerEvent) bool {
@@ -119,6 +119,80 @@ func TestContainerStore_die(t *testing.T) {
assert.Equal(t, containers[0].State, "exited")
}
func TestContainerStore_rename(t *testing.T) {
run := func(t *testing.T, initial Container, attributes map[string]string) Container {
client := new(mockedClient)
client.On("ListContainers", mock.Anything, mock.Anything).Return([]Container{initial}, nil)
client.On("FindContainer", mock.Anything, initial.ID).Return(initial, nil)
client.On("Host").Return(Host{ID: "localhost"})
ready := make(chan struct{})
client.On("ContainerEvents", mock.Anything, mock.AnythingOfType("chan<- container.ContainerEvent")).Return(nil).
Run(func(args mock.Arguments) {
ctx := args.Get(0).(context.Context)
events := args.Get(1).(chan<- ContainerEvent)
<-ready
events <- ContainerEvent{
Name: "rename",
ActorID: initial.ID,
Host: "localhost",
ActorAttributes: attributes,
}
<-ctx.Done()
})
store := NewContainerStore(t.Context(), client, &fakeStatsCollector{}, ContainerLabels{})
events := make(chan ContainerEvent)
store.SubscribeEvents(t.Context(), events)
close(ready)
<-events
containers, err := store.ListContainers(ContainerLabels{})
assert.NoError(t, err)
assert.Len(t, containers, 1)
return containers[0]
}
t.Run("keeps custom name from dev.dozzle.name label", func(t *testing.T) {
initial := Container{
ID: "1234",
Name: "custom-name",
State: "running",
FullyLoaded: true,
Labels: map[string]string{"dev.dozzle.name": "custom-name"},
Stats: utils.NewRingBuffer[ContainerStat](300),
}
result := run(t, initial, map[string]string{"name": "new-docker-name"})
assert.Equal(t, "custom-name", result.Name)
})
t.Run("keeps custom name from coolify.serviceName label", func(t *testing.T) {
initial := Container{
ID: "1234",
Name: "coolify-name",
State: "running",
FullyLoaded: true,
Labels: map[string]string{"coolify.serviceName": "coolify-name"},
Stats: utils.NewRingBuffer[ContainerStat](300),
}
result := run(t, initial, map[string]string{"name": "new-docker-name"})
assert.Equal(t, "coolify-name", result.Name)
})
t.Run("follows rename when name comes from docker", func(t *testing.T) {
initial := Container{
ID: "1234",
Name: "old-docker-name",
State: "running",
FullyLoaded: true,
Stats: utils.NewRingBuffer[ContainerStat](300),
}
result := run(t, initial, map[string]string{"name": "new-docker-name"})
assert.Equal(t, "new-docker-name", result.Name)
})
}
type fakeStatsCollector struct{}
func (f *fakeStatsCollector) Subscribe(_ context.Context, _ chan<- ContainerStat) {}
+1 -2
View File
@@ -117,8 +117,7 @@ func TestEventGenerator_doesNotStallOnSustainedLevellessStream(t *testing.T) {
// maxGroupTimeDelta, every line looks like an orphaned continuation, so the
// skip loop buffers forever and the UI shows "no logs". The generator must
// give up and emit instead.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ctx := t.Context()
reader := &steadyLevellessReader{ctx: ctx, base: time.Now(), step: time.Millisecond, delay: time.Millisecond}
// startedAt far in the past so the near-start short-circuit cannot fire
+1 -1
View File
@@ -15,7 +15,7 @@ var SupportedLogLevels map[string]struct{}
var logLevels = [][]string{
{"error", "err"},
{"warn", "warning", "wrn"},
{"info", "inf"},
{"info", "inf", "information"},
{"debug", "dbg"},
{"trace", "verbose", "ver", "vbs"},
{"fatal", "sev", "severe", "crit", "critical"},
+14
View File
@@ -115,6 +115,20 @@ func TestGuessLogLevel(t *testing.T) {
{"WARN: connection error: retrying", "warn"},
// Symmetric: an ERROR prefix still wins over a later info word.
{"ERROR: handler failed, info: will retry", "error"},
// .NET / Serilog / Microsoft.Extensions.Logging spell out "Information".
{"Information: service started", "info"},
{"[Information] service started", "info"},
{"2024-12-30T17:43:16Z Information starting up", "info"},
{orderedmap.New[string, string](
orderedmap.WithInitialData(
orderedmap.Pair[string, string]{Key: "level", Value: "Information"},
),
), "info"},
{orderedmap.New[string, any](
orderedmap.WithInitialData(
orderedmap.Pair[string, any]{Key: "@l", Value: "Information"},
),
), "info"},
// Equal confidence between two different levels -> unknown (don't guess).
{"saw info: here and error: there", "unknown"},
{"[INFO] [DEBUG] both bracketed", "unknown"},
+19 -1
View File
@@ -2,6 +2,8 @@ package container
import (
"errors"
"strconv"
"strings"
orderedmap "github.com/wk8/go-ordered-map/v2"
)
@@ -36,7 +38,7 @@ func ParseLogFmt(log string) (*orderedmap.OrderedMap[string, string], error) {
} else if char == '\\' {
escaping = true
} else if char == '"' {
value = log[start:i]
value = unescapeQuoted(log[start-1 : i+1])
result.Set(key, value)
inQuotes = false
isKey = true
@@ -77,3 +79,19 @@ func ParseLogFmt(log string) (*orderedmap.OrderedMap[string, string], error) {
return result, nil
}
// unescapeQuoted decodes a quoted logfmt value (including the surrounding
// quotes) so escape sequences like \" and \\ produced by logfmt encoders
// (logrus, go-kit, go-logfmt) yield the original text instead of leaking
// backslashes into the parsed value. If the sequence is not decodable, the
// raw content between the quotes is returned unchanged.
func unescapeQuoted(quoted string) string {
raw := quoted[1 : len(quoted)-1]
if !strings.ContainsRune(raw, '\\') {
return raw
}
if unquoted, err := strconv.Unquote(quoted); err == nil {
return unquoted
}
return raw
}
+22
View File
@@ -67,6 +67,28 @@ func TestParseLog(t *testing.T) {
),
wantErr: false,
},
{
name: "Escaped quotes and backslashes in quoted values",
log: `level=info msg="failed to open \"config.yml\"" path="C:\\temp"`,
want: orderedmap.New[string, string](
orderedmap.WithInitialData(
orderedmap.Pair[string, string]{Key: "level", Value: "info"},
orderedmap.Pair[string, string]{Key: "msg", Value: `failed to open "config.yml"`},
orderedmap.Pair[string, string]{Key: "path", Value: `C:\temp`},
),
),
wantErr: false,
},
{
name: "Quoted value with undecodable escape is kept as-is",
log: `msg="foo \q bar"`,
want: orderedmap.New[string, string](
orderedmap.WithInitialData(
orderedmap.Pair[string, string]{Key: "msg", Value: `foo \q bar`},
),
),
wantErr: false,
},
{
name: "Broken format with unexpected quotes",
log: `key1=value"1"= key2="value2"`,
+32 -6
View File
@@ -2,20 +2,46 @@ package container
import "strings"
// Highlight markers that Dozzle injects into log messages for HTML rendering
// (see internal/support/web: MarkerStart/End = U+E000/U+E001 for search hits,
// URLMarkerStart/End = U+E002/U+E003 for URLs). They live in the Unicode
// private-use area and must never appear in copied or downloaded plain-text
// logs, where they show up as invisible garbage characters.
const (
markerSearchStart = ""
markerSearchEnd = ""
markerURLStart = ""
markerURLEnd = ""
)
var highlightMarkerStripper = strings.NewReplacer(
markerSearchStart, "",
markerSearchEnd, "",
markerURLStart, "",
markerURLEnd, "",
)
// StripHighlightMarkers removes the internal search/URL highlight markers that
// Dozzle injects for HTML rendering. Paths that emit raw log text (clipboard
// copy, downloads) must call it so the markers don't leak into the output.
func StripHighlightMarkers(s string) string {
return highlightMarkerStripper.Replace(s)
}
// PlainText renders a log event as plain text for clipboard copy, stripping
// ANSI escape sequences. Grouped events carry their lines in fragments and
// have an empty RawMessage, so they are expanded one line per fragment;
// otherwise every grouped multi-line entry collapses to a single blank line on
// copy.
// ANSI escape sequences and the internal highlight markers. Grouped events
// carry their lines in fragments and have an empty RawMessage, so they are
// expanded one line per fragment; otherwise every grouped multi-line entry
// collapses to a single blank line on copy.
func (e *LogEvent) PlainText() string {
if e.Type == LogTypeGroup {
if fragments, ok := e.Message.([]LogFragment); ok {
lines := make([]string, len(fragments))
for i, fragment := range fragments {
lines[i] = StripANSI(fragment.Message)
lines[i] = StripHighlightMarkers(StripANSI(fragment.Message))
}
return strings.Join(lines, "\n")
}
}
return StripANSI(e.RawMessage)
return StripHighlightMarkers(StripANSI(e.RawMessage))
}
+39
View File
@@ -38,6 +38,25 @@ func TestLogEventPlainText(t *testing.T) {
},
expected: "red\nplain",
},
{
// A regex filter marks search hits with U+E000/U+E001 before the
// event is rendered; those internal markers must not leak into the
// copied text.
name: "grouped fragments strip search-highlight markers",
event: &LogEvent{
Type: LogTypeGroup,
Message: []LogFragment{
{Message: " at com.\ue000example\ue001.Main(Main.java:10)"},
{Message: " at com.\ue000example\ue001.Foo(Foo.java:20)"},
},
},
expected: " at com.example.Main(Main.java:10)\n at com.example.Foo(Foo.java:20)",
},
{
name: "single event strips search-highlight markers",
event: &LogEvent{Type: LogTypeSingle, RawMessage: "found \ue000error\ue001 here"},
expected: "found error here",
},
}
for _, tt := range tests {
@@ -48,3 +67,23 @@ func TestLogEventPlainText(t *testing.T) {
})
}
}
func TestStripHighlightMarkers(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"no markers", "plain text", "plain text"},
{"search markers", "a \ue000b\ue001 c", "a b c"},
{"url markers", "see \ue002http://example.com\ue003 now", "see http://example.com now"},
{"mixed search and url markers", "\ue002http://x/\ue000hit\ue001\ue003", "http://x/hit"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := StripHighlightMarkers(tt.input); got != tt.expected {
t.Errorf("StripHighlightMarkers(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
+31 -31
View File
@@ -99,19 +99,19 @@ func (container Container) ToProto() pb.Container {
}
return pb.Container{
Id: container.ID,
Name: container.Name,
Image: container.Image,
Created: timestamppb.New(container.Created),
State: container.State,
Health: container.Health,
Host: container.Host,
Tty: container.Tty,
Labels: container.Labels,
Group: container.Group,
Started: timestamppb.New(container.StartedAt),
Finished: timestamppb.New(container.FinishedAt),
Stats: pbStats,
Id: container.ID,
Name: container.Name,
Image: container.Image,
Created: timestamppb.New(container.Created),
State: container.State,
Health: container.Health,
Host: container.Host,
Tty: container.Tty,
Labels: container.Labels,
Group: container.Group,
Started: timestamppb.New(container.StartedAt),
Finished: timestamppb.New(container.FinishedAt),
Stats: pbStats,
Command: container.Command,
MemoryLimit: container.MemoryLimit,
CpuLimit: container.CPULimit,
@@ -176,20 +176,20 @@ func FromProto(c *pb.Container) Container {
}
return Container{
ID: c.Id,
Name: c.Name,
Image: c.Image,
Labels: labels,
Group: c.Group,
Created: c.Created.AsTime(),
State: c.State,
Health: c.Health,
Host: c.Host,
Tty: c.Tty,
Command: c.Command,
StartedAt: c.Started.AsTime(),
FinishedAt: c.Finished.AsTime(),
Stats: utils.RingBufferFrom(300, stats),
ID: c.Id,
Name: c.Name,
Image: c.Image,
Labels: labels,
Group: c.Group,
Created: c.Created.AsTime(),
State: c.State,
Health: c.Health,
Host: c.Host,
Tty: c.Tty,
Command: c.Command,
StartedAt: c.Started.AsTime(),
FinishedAt: c.Finished.AsTime(),
Stats: utils.RingBufferFrom(300, stats),
MemoryLimit: c.MemoryLimit,
CPULimit: c.CpuLimit,
FullyLoaded: c.FullyLoaded,
@@ -233,12 +233,12 @@ func ParseContainerFilter(commaValues string) (ContainerLabels, error) {
}
for val := range strings.SplitSeq(commaValues, ",") {
pos := strings.Index(val, "=")
if pos == -1 {
before, after, ok := strings.Cut(val, "=")
if !ok {
return nil, fmt.Errorf("invalid filter: %s", filter)
}
key := val[:pos]
val := val[pos+1:]
key := before
val := after
filter[key] = append(filter[key], val)
}
+1 -1
View File
@@ -46,7 +46,7 @@ func newVolumeMonitor(store *ContainerStore) *volumeMonitor {
}
func (v *volumeMonitor) start(ctx context.Context) {
for i := 0; i < volumeWorkerCount; i++ {
for range volumeWorkerCount {
go v.worker(ctx)
}
}

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