Compare commits

...

65 Commits

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 19:07:39 -07:00
renovate[bot] cedb384dc4 fix(deps): update all non-major dependencies (#4865)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-26 16:55:29 +00:00
Amir Raminfar 163452a398 chore: release v10.6.12 2026-07-26 09:47:54 -07:00
Amir Raminfar f348b57721 feat: Command palette in Cmd+K search (#4861)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:45:26 -07:00
Amir Raminfar df5a60479c fix(security): scope MCP read tools to the requesting user's filter (GHSA-p66q-2gfp-8v55) (#4864)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:43:21 -07:00
Amir Raminfar 9c665d42a9 fix(security): require auth for /cloud/callback (GHSA-p66q-2gfp-8v55) (#4863)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:42:29 -07:00
Baiyi Liao 49f625a1db feat(ui): small improvements and polishments (#4862) 2026-07-24 07:11:13 -07:00
Amir Raminfar af38e3a0c0 chore: type-check on tsgo via typescript-native-bridge (#4860)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:47:13 -07:00
renovate[bot] 104749063d chore(deps): update all non-major dependencies (#4859)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-23 16:33:07 +00:00
renovate[bot] d7b896e3d8 chore(deps): update dependency bumpp to v12 (#4855)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-22 14:00:00 -07:00
renovate[bot] 5693bcc4e4 chore(deps): update all non-major dependencies (#4854)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-22 16:37:27 +00:00
renovate[bot] de70cbe389 fix(deps): update all non-major dependencies (#4850)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Amir Raminfar <findamir@gmail.com>
2026-07-22 00:14:19 +00:00
Amir Raminfar 73099aa67a fix: prompt for generate password on stdin when --password omitted (#4852)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:09:20 -07:00
renovate[bot] 9266f89776 chore(deps): update dependency c8 to v12 (#4851)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-20 10:29:31 -07:00
Amir Raminfar f88d7a1066 chore: release v10.6.11 2026-07-19 18:40:23 -07:00
TowyTowy f3e61606bd fix: deliver last log line when it has no trailing newline (#4846)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-19 18:38:46 -07:00
Amir Raminfar 95794779d6 feat: Add theme-aware ANSI color palette for log viewer (#4849)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-19 18:38:36 -07:00
TowyTowy e3f4566f6f fix: unescape quoted logfmt values (#4844)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-16 17:20:56 -07:00
Amir Raminfar b86831d2ba chore: updates pinia (#4843) 2026-07-16 08:24:38 -07:00
renovate[bot] 4917984385 chore(deps): update actions/setup-go action to v7 (#4842)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 15:24:03 +00:00
renovate[bot] c3b1c76151 fix(deps): update all non-major dependencies (#4841)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 15:23:21 +00:00
renovate[bot] 7897a1555e chore(deps): update dependency @pinia/testing to v2 (#4838)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 08:17:28 -07:00
TowyTowy 523ac9f35e fix(utils): prevent panic when pushing to a zero-size ring buffer (#4840)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-15 13:04:37 -07:00
Amir Raminfar 6e65b33161 chore: release v10.6.10 2026-07-15 08:03:04 -07:00
renovate[bot] be2a9e0047 chore(deps): update actions/setup-node action to v7 (#4837)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-15 15:00:25 +00:00
renovate[bot] fad2ea7763 fix(deps): update all non-major dependencies (#4836)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-15 14:59:55 +00:00
Amir Raminfar 4e7b6eea7b refactor(mcp): dedupe log fetch/collect between log tools (#4835)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:56:42 +00:00
morcth a0704d5c7b feat: add search_container_logs MCP tool (#4832) 2026-07-15 07:41:23 -07:00
TowyTowy 63fa7c9e04 fix(level-guesser): recognize "Information" as info level (#4833) 2026-07-15 06:01:05 -07:00
Amir Raminfar 237e916390 ci: install pnpm via corepack, update pnpm to 11.12.0 (#4830)
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:31:02 -07:00
renovate[bot] 6a16e35eb1 fix(deps): update all non-major dependencies (#4828)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Amir Raminfar <findamir@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:21:17 +00:00
Amir Raminfar 15b6abf843 feat(web): "see in context" for level-filtered logs (#4825)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-07-13 02:18:30 +00:00
TowyTowy 540ace4e84 fix(container): keep custom name label on docker rename (#4827) 2026-07-12 14:10:21 -07:00
Amir Raminfar c0a38587e2 docs: complete missing translation keys across all locales (#4826)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:16:16 +00:00
Amir Raminfar 2b1d9e889e chore: release v10.6.9
Push container / Push branches and PRs (push) Has been cancelled
Deploy VitePress site to Pages / build (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
2026-07-10 11:18:45 -07:00
renovate[bot] 5cd63429a9 fix(deps): update all non-major dependencies (#4820)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Amir Raminfar <findamir@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 18:12:24 +00:00
TowyTowy 820831b03d fix(web): strip highlight markers from copied and downloaded logs (#4823)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:05:09 -07:00
moduvoice 8cde0e7e48 docs: complete missing Korean (ko) translation keys (#4818)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
2026-07-09 17:02:41 -07:00
Amir Raminfar bd4651fb0b chore: apply go fix modernizations (#4822)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 10:51:26 -07:00
renovate[bot] 8d810b7a0b chore(deps): update mcr.microsoft.com/playwright docker tag to v1.61.1 (#4819)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-08 23:50:47 +00:00
128 changed files with 4694 additions and 1832 deletions
+22 -17
View File
@@ -13,15 +13,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
name: Install pnpm
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
name: Install Node
with:
node-version: 24.18.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,9 +34,9 @@ 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@v7
@@ -51,15 +54,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
name: Install pnpm
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
name: Install Node
with:
node-version: 24.18.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
@@ -95,12 +101,12 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to DockerHub
uses: docker/login-action@v4.4.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.4.0
uses: docker/login-action@v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -127,6 +133,7 @@ jobs:
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 }}
@@ -145,10 +152,8 @@ jobs:
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 }}
+2 -2
View File
@@ -20,12 +20,12 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to DockerHub
uses: docker/login-action@v4.4.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.4.0
uses: docker/login-action@v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+10 -4
View File
@@ -27,11 +27,17 @@ jobs:
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.18.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
+21 -20
View File
@@ -16,17 +16,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
name: Install pnpm
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
name: Install Node
with:
node-version: 24.18.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.18.0
node-version: latest
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
@@ -38,13 +37,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
name: Install pnpm
- 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.18.0
node-version: latest
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
@@ -56,9 +58,9 @@ 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@v7
@@ -79,9 +81,9 @@ jobs:
- name: Checkout code
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
@@ -95,17 +97,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
name: Install pnpm
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
name: Install Node
with:
node-version: 24.18.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.18.0
node-version: latest
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
+9
View File
@@ -6,10 +6,12 @@
// biome-ignore lint: disable
export {}
declare global {
const DEFAULT_MENU_WIDTH: typeof import('./stores/settings').DEFAULT_MENU_WIDTH
const DEFAULT_SETTINGS: typeof import('./stores/settings').DEFAULT_SETTINGS
const EffectScope: typeof import('vue').EffectScope
const K8sNamespace: typeof import('./stores/k8s').K8sNamespace
const K8sOwner: typeof import('./stores/k8s').K8sOwner
const MIN_MENU_WIDTH: typeof import('./stores/settings').MIN_MENU_WIDTH
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const allLevels: typeof import('./composable/logContext').allLevels
const arrayEquals: typeof import('./utils/index').arrayEquals
@@ -17,6 +19,7 @@ declare global {
const autoResetRef: typeof import('@vueuse/core').autoResetRef
const automaticRedirect: typeof import('./stores/settings').automaticRedirect
const collapseNav: typeof import('./stores/settings').collapseNav
const colorize: typeof import('./utils/index').colorize
const compact: typeof import('./stores/settings').compact
const computed: typeof import('vue').computed
const computedAsync: typeof import('@vueuse/core').computedAsync
@@ -216,6 +219,7 @@ declare global {
const useCloudConfig: typeof import('./composable/cloudConfig').useCloudConfig
const useCloudLogSearch: typeof import('./composable/cloudLogSearch').useCloudLogSearch
const useColorMode: typeof import('@vueuse/core').useColorMode
const useCommands: typeof import('./composable/commands').useCommands
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
const useContainerActions: typeof import('./composable/containerActions').useContainerActions
const useContainerStore: typeof import('./stores/container').useContainerStore
@@ -420,6 +424,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
@@ -461,6 +468,7 @@ declare module 'vue' {
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly automaticRedirect: UnwrapRef<typeof import('./stores/settings')['automaticRedirect']>
readonly collapseNav: UnwrapRef<typeof import('./stores/settings')['collapseNav']>
readonly colorize: UnwrapRef<typeof import('./utils/index')['colorize']>
readonly compact: UnwrapRef<typeof import('./stores/settings')['compact']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
@@ -655,6 +663,7 @@ declare module 'vue' {
readonly useCloudConfig: UnwrapRef<typeof import('./composable/cloudConfig')['useCloudConfig']>
readonly useCloudLogSearch: UnwrapRef<typeof import('./composable/cloudLogSearch')['useCloudLogSearch']>
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
readonly useCommands: UnwrapRef<typeof import('./composable/commands')['useCommands']>
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
readonly useContainerActions: UnwrapRef<typeof import('./composable/containerActions')['useContainerActions']>
readonly useContainerStore: UnwrapRef<typeof import('./stores/container')['useContainerStore']>
+24
View File
@@ -0,0 +1,24 @@
import { build } from "vite";
import { describe, expect, it } from "vitest";
describe("production build", () => {
it("emits font URLs relative to the generated stylesheet", async () => {
const result = await build({
configFile: "vite.config.ts",
logLevel: "silent",
build: { write: false },
});
const outputs = Array.isArray(result) ? result : [result];
const stylesheet = outputs
.flatMap((output) => ("output" in output ? output.output : []))
.find((item) => item.type === "asset" && item.fileName.endsWith(".css"));
expect(stylesheet).toBeDefined();
if (!stylesheet || stylesheet.type !== "asset") {
throw new Error("Production build did not emit a stylesheet");
}
const css = String(stylesheet.source);
expect(css).toMatch(/url\(\.\/jetbrains-mono/);
expect(css).not.toMatch(/url\(\/assets\/jetbrains-mono/);
}, 15_000);
});
+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']
@@ -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>
@@ -46,8 +46,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 +68,21 @@
import { Container } from "@/models/Container";
const { container } = defineProps<{ container: Container }>();
const { t } = useI18n();
const { copy, copied, isSupported } = useClipboard({ legacy: true });
const { showToast } = useToast();
const imageTag = computed(() => container.image.replace(/@sha.*/, ""));
async function copyImage() {
if (!isSupported.value) return;
await copy(imageTag.value);
if (copied.value) {
showToast({ title: t("toasts.copied.title"), message: t("toasts.copied.message"), type: "info" }, { expire: 2000 });
}
}
const pinned = computed({
get: () => pinnedContainers.value.has(container.name),
set: (value) => {
+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();
+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() {
@@ -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>
@@ -238,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-->
+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 };
}
+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;
}
+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.61.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']
}
}
+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`.
+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

+31 -29
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.55.0 // indirect
golang.org/x/sys v0.46.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.2
github.com/compose-spec/compose-go/v2 v2.13.0
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.9.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.55.0
github.com/moby/moby/client v0.5.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.53.0
golang.org/x/sync v0.21.0
google.golang.org/grpc v1.82.0
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.2
k8s.io/apimachinery v0.36.2
k8s.io/client-go v0.36.2
k8s.io/metrics v0.36.2
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.44.0 // indirect
golang.org/x/text v0.38.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.2 // 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
+67 -87
View File
@@ -13,18 +13,18 @@ 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=
@@ -71,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=
@@ -83,14 +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.12.1 h1:+xBZNxcgSus4atQJwXPEdhHRgCEyZmj/BuqN5m33Ou0=
github.com/compose-spec/compose-go/v2 v2.12.1/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=
@@ -138,28 +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.8.0 h1:QAKmb4TyAMxIgf3a8fXubQXwFFJviBGL2/IDa34N6JQ=
github.com/go-faker/faker/v4 v4.8.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=
@@ -221,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=
@@ -307,20 +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/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.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/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=
@@ -359,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=
@@ -430,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=
@@ -440,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=
@@ -461,12 +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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
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=
@@ -476,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=
@@ -488,10 +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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
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=
@@ -501,10 +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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.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=
@@ -520,12 +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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.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=
@@ -535,10 +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.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
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=
@@ -549,10 +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.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
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=
@@ -561,21 +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/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/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/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=
@@ -596,40 +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/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY=
k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg=
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/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/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/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/metrics v0.36.2 h1:yfUIe2Vwx2cQAIpVYcin1JXdabrRz98oTxP2HJTxHj8=
k8s.io/metrics v0.36.2/go.mod h1:Q/dNyLLzgSxPu0/e+996Du4pjutfEyyHOKgK0lkncp0=
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.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 -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)
}
}
+10 -5
View File
@@ -32,7 +32,7 @@ func NewLogReader(r io.Reader, tty bool) *LogReader {
reader: bufio.NewReader(r),
tty: tty,
pool: &sync.Pool{
New: func() interface{} {
New: func() any {
return bytes.NewBuffer(make([]byte, 0, 4096))
},
},
@@ -41,9 +41,6 @@ func NewLogReader(r io.Reader, tty bool) *LogReader {
func (d *LogReader) Read() (string, container.StdType, error) {
message, stdType, err := d.readEvent()
if err != nil {
return "", 0, err
}
var std container.StdType
switch stdType {
@@ -53,10 +50,18 @@ func (d *LogReader) Read() (string, container.StdType, error) {
std = container.STDERR
}
// A final line without a trailing newline (e.g. a container's last write
// before exiting) arrives together with EOF. Return the partial message
// with the error instead of dropping it; the event generator emits the
// message before handling the error.
if err != nil {
return message, std, err
}
for !strings.HasSuffix(message, "\n") {
tail, _, err := d.readEvent()
if err != nil {
return "", std, err
return message, std, err
}
_, after, _ := strings.Cut(tail, " ")
+84
View File
@@ -0,0 +1,84 @@
package docker
import (
"bytes"
"encoding/binary"
"io"
"strings"
"testing"
"github.com/amir20/dozzle/internal/container"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func writeFrame(buf *bytes.Buffer, streamType byte, payload string) {
header := make([]byte, 8)
header[0] = streamType
binary.BigEndian.PutUint32(header[4:], uint32(len(payload)))
buf.Write(header)
buf.WriteString(payload)
}
func TestRead_multiplexed(t *testing.T) {
buf := &bytes.Buffer{}
writeFrame(buf, 1, "2024-01-01T00:00:00.000000000Z out\n")
writeFrame(buf, 2, "2024-01-01T00:00:01.000000000Z err\n")
reader := NewLogReader(buf, false)
message, std, err := reader.Read()
require.NoError(t, err)
assert.Equal(t, "2024-01-01T00:00:00.000000000Z out\n", message)
assert.Equal(t, container.STDOUT, std)
message, std, err = reader.Read()
require.NoError(t, err)
assert.Equal(t, "2024-01-01T00:00:01.000000000Z err\n", message)
assert.Equal(t, container.STDERR, std)
_, _, err = reader.Read()
assert.Equal(t, io.EOF, err)
}
func TestRead_lastLineWithoutNewline(t *testing.T) {
buf := &bytes.Buffer{}
writeFrame(buf, 1, "2024-01-01T00:00:00.000000000Z first\n")
writeFrame(buf, 1, "2024-01-01T00:00:01.000000000Z last line without newline")
reader := NewLogReader(buf, false)
message, _, err := reader.Read()
require.NoError(t, err)
assert.Equal(t, "2024-01-01T00:00:00.000000000Z first\n", message)
message, std, err := reader.Read()
assert.Equal(t, io.EOF, err)
assert.Equal(t, container.STDOUT, std)
assert.Equal(t, "2024-01-01T00:00:01.000000000Z last line without newline", message)
}
func TestRead_lastLineWithoutNewlineTTY(t *testing.T) {
input := "2024-01-01T00:00:00.000000000Z first\n2024-01-01T00:00:01.000000000Z last line without newline"
reader := NewLogReader(strings.NewReader(input), true)
message, _, err := reader.Read()
require.NoError(t, err)
assert.Equal(t, "2024-01-01T00:00:00.000000000Z first\n", message)
message, _, err = reader.Read()
assert.Equal(t, io.EOF, err)
assert.Equal(t, "2024-01-01T00:00:01.000000000Z last line without newline", message)
}
func TestRead_continuedFrames(t *testing.T) {
buf := &bytes.Buffer{}
writeFrame(buf, 1, "2024-01-01T00:00:00.000000000Z part one ")
writeFrame(buf, 1, "2024-01-01T00:00:00.000000000Z and part two\n")
reader := NewLogReader(buf, false)
message, _, err := reader.Read()
require.NoError(t, err)
assert.Equal(t, "2024-01-01T00:00:00.000000000Z part one and part two\n", message)
}
+4 -10
View File
@@ -6,7 +6,9 @@ import (
"errors"
"fmt"
"io"
"maps"
"regexp"
"slices"
"strings"
"sync"
"time"
@@ -154,9 +156,7 @@ func (k *K8sClient) podToContainers(ctx context.Context, pod *corev1.Pod) []cont
// Build labels map with pod labels, namespace, and owner reference
labels := make(map[string]string)
for k, v := range pod.Labels {
labels[k] = v
}
maps.Copy(labels, pod.Labels)
labels["namespace"] = pod.Namespace
labels["@k8s.namespace"] = pod.Namespace
@@ -444,13 +444,7 @@ func matchesContainerLabels(labels map[string]string, filters container.Containe
if !ok {
return false
}
matched := false
for _, expected := range values {
if value == expected {
matched = true
break
}
}
matched := slices.Contains(values, value)
if !matched {
return false
}
+4 -5
View File
@@ -18,10 +18,9 @@ func NewLogReader(reader io.ReadCloser) *LogReader {
}
func (r *LogReader) Read() (string, container.StdType, error) {
// A final line without a trailing newline arrives together with EOF.
// Return the partial line with the error instead of dropping it; the
// event generator emits the message before handling the error.
line, err := r.reader.ReadString('\n')
if err != nil {
return "", 0, err
}
return line, container.STDOUT, nil
return line, container.STDOUT, err
}
+23
View File
@@ -0,0 +1,23 @@
package k8s
import (
"io"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRead_lastLineWithoutNewline(t *testing.T) {
input := "2024-01-01T00:00:00.000000000Z first\n2024-01-01T00:00:01.000000000Z last line without newline"
reader := NewLogReader(io.NopCloser(strings.NewReader(input)))
message, _, err := reader.Read()
require.NoError(t, err)
assert.Equal(t, "2024-01-01T00:00:00.000000000Z first\n", message)
message, _, err = reader.Read()
assert.Equal(t, io.EOF, err)
assert.Equal(t, "2024-01-01T00:00:01.000000000Z last line without newline", message)
}
+210 -82
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/amir20/dozzle/internal/auth"
"github.com/amir20/dozzle/internal/container"
container_support "github.com/amir20/dozzle/internal/support/container"
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -48,6 +49,19 @@ func NewServer(hostService HostService, labels container.ContainerLabels, versio
return s
}
// resolveLabels returns the container label filter to apply for the requesting
// user. When authentication is enabled the user's own filter is carried on the
// request context; using it (instead of the server-global labels) keeps MCP
// reads scoped to the same containers the user can see through the normal log
// and stats paths. Falls back to the server-global labels when no per-user
// filter is present (e.g. auth disabled).
func (s *Server) resolveLabels(ctx context.Context) container.ContainerLabels {
if user := auth.UserFromContext(ctx); user != nil && user.ContainerLabels.Exists() {
return user.ContainerLabels
}
return s.labels
}
// Handler returns an http.Handler for the MCP streamable HTTP transport.
func (s *Server) Handler() http.Handler {
return mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
@@ -68,6 +82,15 @@ type getContainerLogsParams struct {
Stream *string `json:"stream,omitempty" jsonschema:"Which output stream to read: stdout, stderr, or all. Defaults to all."`
}
type searchContainerLogsParams struct {
Host string `json:"host" jsonschema:"The host ID where the container is running. Use list_containers to find this."`
ContainerID string `json:"container_id" jsonschema:"The container ID (or short ID) to search logs from. Use list_containers to find this."`
Query string `json:"query" jsonschema:"The search string to look for in log messages. Case-insensitive by default."`
SinceMinutes *int `json:"since_minutes,omitempty" jsonschema:"Search logs from the last N minutes. Defaults to 5."`
Stream *string `json:"stream,omitempty" jsonschema:"Which output stream to search: stdout, stderr, or all. Defaults to all."`
CaseSensitive *bool `json:"case_sensitive,omitempty" jsonschema:"Whether to perform a case-sensitive search. Defaults to false."`
}
type getContainerStatsParams struct {
Host string `json:"host" jsonschema:"The host ID where the container is running. Use list_containers to find this."`
ContainerID string `json:"container_id" jsonschema:"The container ID to get stats for. Use list_containers to find this."`
@@ -86,6 +109,12 @@ func (s *Server) registerTools() {
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
}, s.handleGetContainerLogs)
mcp.AddTool(s.mcpServer, &mcp.Tool{
Name: "search_container_logs",
Description: "Search container logs for a keyword or phrase. Returns only matching log entries, making it efficient for finding specific errors or events without downloading large volumes of logs.",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
}, s.handleSearchContainerLogs)
mcp.AddTool(s.mcpServer, &mcp.Tool{
Name: "list_hosts",
Description: "List all Docker hosts connected to Dozzle.",
@@ -102,7 +131,7 @@ func (s *Server) registerTools() {
// --- Tool Handlers ---
func (s *Server) handleListContainers(ctx context.Context, _ *mcp.CallToolRequest, params *listContainersParams) (*mcp.CallToolResult, any, error) {
containers, errs := s.hostService.ListAllContainers(s.labels)
containers, errs := s.hostService.ListAllContainers(s.resolveLabels(ctx))
for _, err := range errs {
if err != nil {
log.Warn().Err(err).Msg("partial failure listing containers from a host")
@@ -154,95 +183,119 @@ func (s *Server) handleListContainers(ctx context.Context, _ *mcp.CallToolReques
}, nil, nil
}
func (s *Server) handleGetContainerLogs(ctx context.Context, _ *mcp.CallToolRequest, params *getContainerLogsParams) (*mcp.CallToolResult, any, error) {
if params.Host == "" || params.ContainerID == "" {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "host and container_id are required"}},
IsError: true,
}, nil, nil
}
const maxLogSize = 1024 * 1024 // 1MB limit
containerSvc, err := s.hostService.FindContainer(params.Host, params.ContainerID, s.labels)
if err != nil {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("container not found: %v", err)}},
IsError: true,
}, nil, nil
}
// mcpLogEntry is the JSON shape returned by the log tools.
type mcpLogEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level,omitempty"`
Stream string `json:"stream,omitempty"`
Type string `json:"type"`
Message any `json:"message"`
}
stream := ""
if params.Stream != nil {
stream = *params.Stream
func errorResult(text string) *mcp.CallToolResult {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: text}},
IsError: true,
}
var stdType container.StdType
switch stream {
}
func textResult(text string) *mcp.CallToolResult {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: text}},
}
}
// parseStream resolves the optional stream parameter into a StdType. It returns
// a non-nil error result when the value is invalid.
func parseStream(stream *string) (container.StdType, *mcp.CallToolResult) {
value := ""
if stream != nil {
value = *stream
}
switch value {
case "", "all":
stdType = container.STDALL
return container.STDALL, nil
case "stdout":
stdType = container.STDOUT
return container.STDOUT, nil
case "stderr":
stdType = container.STDERR
return container.STDERR, nil
default:
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("invalid stream %q: must be stdout, stderr, or all", stream)}},
IsError: true,
}, nil, nil
return container.STDALL, errorResult(fmt.Sprintf("invalid stream %q: must be stdout, stderr, or all", value))
}
}
// fetchLogs finds the container and returns its log events for the last
// sinceMinutes (defaulting to 5). On a user-facing failure it returns a non-nil
// error result; otherwise the caller must call cancel when done.
func (s *Server) fetchLogs(ctx context.Context, host, containerID string, stream *string, sinceMinutes *int) (<-chan *container.LogEvent, context.CancelFunc, *mcp.CallToolResult) {
containerSvc, err := s.hostService.FindContainer(host, containerID, s.resolveLabels(ctx))
if err != nil {
return nil, nil, errorResult(fmt.Sprintf("container not found: %v", err))
}
sinceMinutes := 5
if params.SinceMinutes != nil && *params.SinceMinutes > 0 {
sinceMinutes = *params.SinceMinutes
stdType, errResult := parseStream(stream)
if errResult != nil {
return nil, nil, errResult
}
minutes := 5
if sinceMinutes != nil && *sinceMinutes > 0 {
minutes = *sinceMinutes
}
logCtx, cancel := context.WithCancel(ctx)
defer cancel()
since := time.Now().Add(-time.Duration(sinceMinutes) * time.Minute)
since := time.Now().Add(-time.Duration(minutes) * time.Minute)
events, err := containerSvc.LogsBetweenDates(logCtx, since, time.Now(), stdType)
if err != nil {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("failed to read logs: %v", err)}},
IsError: true,
}, nil, nil
cancel()
return nil, nil, errorResult(fmt.Sprintf("failed to read logs: %v", err))
}
type logEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level,omitempty"`
Stream string `json:"stream,omitempty"`
Type string `json:"type"`
Message any `json:"message"`
}
return events, cancel, nil
}
var entries []logEntry
totalSize := 0
const maxSize = 1024 * 1024 // 1MB limit
for event := range events {
var msg any
switch event.Type {
case container.LogTypeGroup:
if fragments, ok := event.Message.([]container.LogFragment); ok {
lines := make([]string, len(fragments))
for i, f := range fragments {
lines[i] = f.Message
}
msg = lines
} else {
msg = event.RawMessage
// eventMessage extracts the JSON-encodable message payload for a log event.
func eventMessage(event *container.LogEvent) any {
switch event.Type {
case container.LogTypeGroup:
if fragments, ok := event.Message.([]container.LogFragment); ok {
lines := make([]string, len(fragments))
for i, f := range fragments {
lines[i] = f.Message
}
case container.LogTypeComplex:
msg = event.Message
default:
msg = event.RawMessage
return lines
}
return event.RawMessage
case container.LogTypeComplex:
return event.Message
default:
return event.RawMessage
}
}
entry := logEntry{
Timestamp: time.UnixMilli(event.Timestamp).UTC().Format(time.RFC3339Nano),
Level: event.Level,
Stream: event.Stream,
Type: string(event.Type),
Message: msg,
func newLogEntry(event *container.LogEvent) mcpLogEntry {
return mcpLogEntry{
Timestamp: time.UnixMilli(event.Timestamp).UTC().Format(time.RFC3339Nano),
Level: event.Level,
Stream: event.Stream,
Type: string(event.Type),
Message: eventMessage(event),
}
}
// collectLogEntries drains events into JSON-encodable entries. keep, when
// non-nil, filters which entries are included. Collection stops once the encoded
// size would exceed maxLogSize, in which case truncated is true. scanned counts
// every event pulled from the channel, matched or not.
func collectLogEntries(events <-chan *container.LogEvent, keep func(mcpLogEntry) bool) (entries []mcpLogEntry, scanned int, truncated bool) {
totalSize := 0
for event := range events {
scanned++
entry := newLogEntry(event)
if keep != nil && !keep(entry) {
continue
}
line, err := json.Marshal(entry)
@@ -251,30 +304,105 @@ func (s *Server) handleGetContainerLogs(ctx context.Context, _ *mcp.CallToolRequ
}
totalSize += len(line) + 1
if totalSize > maxSize {
if totalSize > maxLogSize {
truncated = true
break
}
entries = append(entries, entry)
}
return entries, scanned, truncated
}
if len(entries) == 0 {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "(no logs in the specified time range)"}},
}, nil, nil
}
// encodeLogEntries renders entries as newline-delimited JSON.
func encodeLogEntries(entries []mcpLogEntry) (string, error) {
var sb strings.Builder
encoder := json.NewEncoder(&sb)
for _, entry := range entries {
if err := encoder.Encode(entry); err != nil {
return nil, nil, fmt.Errorf("failed to encode log entry: %w", err)
return "", fmt.Errorf("failed to encode log entry: %w", err)
}
}
return strings.TrimRight(sb.String(), "\n"), nil
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: strings.TrimRight(sb.String(), "\n")}},
}, nil, nil
func (s *Server) handleGetContainerLogs(ctx context.Context, _ *mcp.CallToolRequest, params *getContainerLogsParams) (*mcp.CallToolResult, any, error) {
if params.Host == "" || params.ContainerID == "" {
return errorResult("host and container_id are required"), nil, nil
}
events, cancel, errResult := s.fetchLogs(ctx, params.Host, params.ContainerID, params.Stream, params.SinceMinutes)
if errResult != nil {
return errResult, nil, nil
}
defer cancel()
entries, _, _ := collectLogEntries(events, nil)
if len(entries) == 0 {
return textResult("(no logs in the specified time range)"), nil, nil
}
text, err := encodeLogEntries(entries)
if err != nil {
return nil, nil, err
}
return textResult(text), nil, nil
}
func (s *Server) handleSearchContainerLogs(ctx context.Context, _ *mcp.CallToolRequest, params *searchContainerLogsParams) (*mcp.CallToolResult, any, error) {
if params.Host == "" || params.ContainerID == "" || params.Query == "" {
return errorResult("host, container_id, and query are required"), nil, nil
}
caseSensitive := params.CaseSensitive != nil && *params.CaseSensitive
query := params.Query
if !caseSensitive {
query = strings.ToLower(query)
}
events, cancel, errResult := s.fetchLogs(ctx, params.Host, params.ContainerID, params.Stream, params.SinceMinutes)
if errResult != nil {
return errResult, nil, nil
}
defer cancel()
keep := func(entry mcpLogEntry) bool {
haystack := messageToSearchString(entry.Message)
if !caseSensitive {
haystack = strings.ToLower(haystack)
}
return strings.Contains(haystack, query)
}
entries, scanned, truncated := collectLogEntries(events, keep)
if len(entries) == 0 {
return textResult(fmt.Sprintf("(no matches for %q in %d log entries scanned)", params.Query, scanned)), nil, nil
}
body, err := encodeLogEntries(entries)
if err != nil {
return nil, nil, err
}
var sb strings.Builder
fmt.Fprintf(&sb, "Found %d matches for %q (scanned %d entries):\n", len(entries), params.Query, scanned)
sb.WriteString(body)
if truncated {
sb.WriteString("\n(results truncated at 1MB; narrow your query or time range to see more)")
}
return textResult(sb.String()), nil, nil
}
func messageToSearchString(msg any) string {
switch v := msg.(type) {
case string:
return v
case []string:
return strings.Join(v, "\n")
default:
return fmt.Sprintf("%v", v)
}
}
func (s *Server) handleListHosts(ctx context.Context, _ *mcp.CallToolRequest, _ *struct{}) (*mcp.CallToolResult, any, error) {
@@ -321,7 +449,7 @@ func (s *Server) handleGetContainerStats(ctx context.Context, _ *mcp.CallToolReq
}, nil, nil
}
containerSvc, err := s.hostService.FindContainer(params.Host, params.ContainerID, s.labels)
containerSvc, err := s.hostService.FindContainer(params.Host, params.ContainerID, s.resolveLabels(ctx))
if err != nil {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("container not found: %v", err)}},
+211 -1
View File
@@ -4,9 +4,11 @@ import (
"context"
"fmt"
"io"
"strings"
"testing"
"time"
"github.com/amir20/dozzle/internal/auth"
"github.com/amir20/dozzle/internal/container"
container_support "github.com/amir20/dozzle/internal/support/container"
"github.com/amir20/dozzle/internal/utils"
@@ -22,6 +24,10 @@ type mockHostService struct {
listErrs []error
logEvents []*container.LogEvent
logErr error
// gotLabels records the last label filter passed to a lookup so tests can
// assert the requesting user's filter is applied instead of the global one.
gotLabels container.ContainerLabels
}
type stubClientService struct {
@@ -53,6 +59,7 @@ func (s *stubClientService) RawLogs(context.Context, container.Container, time.T
}
func (m *mockHostService) FindContainer(host string, id string, labels container.ContainerLabels) (*container_support.ContainerService, error) {
m.gotLabels = labels
if m.findErr != nil {
return nil, m.findErr
}
@@ -66,6 +73,7 @@ func (m *mockHostService) FindContainer(host string, id string, labels container
}
func (m *mockHostService) ListAllContainers(labels container.ContainerLabels) ([]container.Container, []error) {
m.gotLabels = labels
return m.containers, m.listErrs
}
@@ -73,6 +81,35 @@ func (m *mockHostService) Hosts() []container.Host {
return m.hosts
}
func TestReadToolsUseRequestingUsersFilter(t *testing.T) {
svc := &mockHostService{
containers: []container.Container{{ID: "abc123", Name: "web", Host: "local"}},
}
// Server-global filter. A restricted user must NOT read through this.
global := container.ContainerLabels{"com.example.scope": {"admin"}}
s := NewServer(svc, global, "test")
userLabels := container.ContainerLabels{"com.example.scope": {"tenant-a"}}
ctx := auth.WithUser(context.Background(), auth.User{ContainerLabels: userLabels})
// list_containers scopes to the user's filter.
_, _, err := s.handleListContainers(ctx, nil, &listContainersParams{})
require.NoError(t, err)
assert.Equal(t, userLabels, svc.gotLabels, "list_containers should use the requesting user's filter")
// get_container_stats (FindContainer path) scopes to the user's filter.
svc.gotLabels = nil
_, _, err = s.handleGetContainerStats(ctx, nil, &getContainerStatsParams{Host: "local", ContainerID: "abc123"})
require.NoError(t, err)
assert.Equal(t, userLabels, svc.gotLabels, "get_container_stats should use the requesting user's filter")
// With no user on the context (e.g. auth disabled) it falls back to the global filter.
svc.gotLabels = nil
_, _, err = s.handleListContainers(context.Background(), nil, &listContainersParams{})
require.NoError(t, err)
assert.Equal(t, global, svc.gotLabels, "falls back to server-global filter when unauthenticated")
}
func TestListContainers(t *testing.T) {
svc := &mockHostService{
containers: []container.Container{
@@ -292,9 +329,10 @@ func TestNewServerRegistersTools(t *testing.T) {
assert.Contains(t, toolNames, "list_containers")
assert.Contains(t, toolNames, "get_container_logs")
assert.Contains(t, toolNames, "search_container_logs")
assert.Contains(t, toolNames, "list_hosts")
assert.Contains(t, toolNames, "get_container_stats")
assert.Len(t, tools.Tools, 4)
assert.Len(t, tools.Tools, 5)
}
func TestGetContainerLogs(t *testing.T) {
@@ -362,3 +400,175 @@ func TestGetContainerLogsInvalidStream(t *testing.T) {
text := result.Content[0].(*mcp.TextContent).Text
assert.Contains(t, text, "invalid stream")
}
func TestSearchContainerLogs(t *testing.T) {
now := time.Now()
svc := &mockHostService{
containers: []container.Container{
{ID: "abc123", Name: "web", Host: "local"},
},
logEvents: []*container.LogEvent{
{Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "Request received for /home"},
{Timestamp: now.UnixMilli(), Level: "error", Stream: "stderr", Type: container.LogTypeSingle, RawMessage: "Failed to process payment"},
{Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "Request received for /about"},
{Timestamp: now.UnixMilli(), Level: "error", Stream: "stderr", Type: container.LogTypeSingle, RawMessage: "payment gateway timeout"},
},
}
s := NewServer(svc, nil, "test")
ctx := context.Background()
ct, st := mcp.NewInMemoryTransports()
_, err := s.mcpServer.Connect(ctx, st, nil)
require.NoError(t, err)
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil)
session, err := client.Connect(ctx, ct, nil)
require.NoError(t, err)
defer session.Close()
result, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "search_container_logs",
Arguments: map[string]any{"host": "local", "container_id": "abc123", "query": "payment"},
})
require.NoError(t, err)
assert.False(t, result.IsError)
text := result.Content[0].(*mcp.TextContent).Text
assert.Contains(t, text, "Failed to process payment")
assert.Contains(t, text, "payment gateway timeout")
assert.NotContains(t, text, "Request received for /home")
assert.Contains(t, text, "Found 2 matches")
}
func TestSearchContainerLogsCaseSensitive(t *testing.T) {
now := time.Now()
svc := &mockHostService{
containers: []container.Container{
{ID: "abc123", Name: "web", Host: "local"},
},
logEvents: []*container.LogEvent{
{Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "Error: something went wrong"},
{Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "error: lowercase message"},
},
}
s := NewServer(svc, nil, "test")
ctx := context.Background()
ct, st := mcp.NewInMemoryTransports()
_, err := s.mcpServer.Connect(ctx, st, nil)
require.NoError(t, err)
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil)
session, err := client.Connect(ctx, ct, nil)
require.NoError(t, err)
defer session.Close()
result, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "search_container_logs",
Arguments: map[string]any{"host": "local", "container_id": "abc123", "query": "Error", "case_sensitive": true},
})
require.NoError(t, err)
assert.False(t, result.IsError)
text := result.Content[0].(*mcp.TextContent).Text
assert.Contains(t, text, "Error: something went wrong")
assert.NotContains(t, text, "error: lowercase message")
assert.Contains(t, text, "Found 1 match")
}
func TestSearchContainerLogsNoMatches(t *testing.T) {
now := time.Now()
svc := &mockHostService{
containers: []container.Container{
{ID: "abc123", Name: "web", Host: "local"},
},
logEvents: []*container.LogEvent{
{Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "everything is fine"},
{Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "all good here"},
},
}
s := NewServer(svc, nil, "test")
ctx := context.Background()
ct, st := mcp.NewInMemoryTransports()
_, err := s.mcpServer.Connect(ctx, st, nil)
require.NoError(t, err)
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil)
session, err := client.Connect(ctx, ct, nil)
require.NoError(t, err)
defer session.Close()
result, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "search_container_logs",
Arguments: map[string]any{"host": "local", "container_id": "abc123", "query": "error"},
})
require.NoError(t, err)
assert.False(t, result.IsError)
text := result.Content[0].(*mcp.TextContent).Text
assert.Contains(t, text, "no matches")
assert.Contains(t, text, "2 log entries scanned")
}
func TestSearchContainerLogsRequiredParams(t *testing.T) {
svc := &mockHostService{}
s := NewServer(svc, nil, "test")
ctx := context.Background()
ct, st := mcp.NewInMemoryTransports()
_, err := s.mcpServer.Connect(ctx, st, nil)
require.NoError(t, err)
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil)
session, err := client.Connect(ctx, ct, nil)
require.NoError(t, err)
defer session.Close()
result, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "search_container_logs",
Arguments: map[string]any{},
})
require.NoError(t, err)
assert.True(t, result.IsError)
}
func TestSearchContainerLogsTruncates(t *testing.T) {
now := time.Now()
big := strings.Repeat("match ", 200) // ~1.2KB per entry
events := make([]*container.LogEvent, 2000)
for i := range events {
events[i] = &container.LogEvent{Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: big}
}
svc := &mockHostService{
containers: []container.Container{{ID: "abc123", Name: "web", Host: "local"}},
logEvents: events,
}
s := NewServer(svc, nil, "test")
ctx := context.Background()
ct, st := mcp.NewInMemoryTransports()
_, err := s.mcpServer.Connect(ctx, st, nil)
require.NoError(t, err)
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil)
session, err := client.Connect(ctx, ct, nil)
require.NoError(t, err)
defer session.Close()
result, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "search_container_logs",
Arguments: map[string]any{"host": "local", "container_id": "abc123", "query": "match"},
})
require.NoError(t, err)
assert.False(t, result.IsError)
text := result.Content[0].(*mcp.TextContent).Text
assert.Contains(t, text, "results truncated")
}
@@ -10,6 +10,7 @@ import (
"net"
"net/http"
"net/url"
"slices"
"strings"
"text/template"
"time"
@@ -30,6 +31,16 @@ var errBlockedAddress = errors.New("webhook target resolves to a blocked address
var zeroNetV4 = &net.IPNet{IP: net.IP{0, 0, 0, 0}, Mask: net.CIDRMask(8, 32)}
func isBlockedIP(ip net.IP) bool {
if isBlockedBaseIP(ip) {
return true
}
// IPv6 transition mechanisms (6to4, NAT64, Teredo, IPv4-compatible) embed an
// arbitrary IPv4 address that none of the checks above look at. Unwrap and
// re-check the embedded address so 2002:7f00:1::1 is treated as 127.0.0.1.
return slices.ContainsFunc(embeddedIPv4(ip), isBlockedBaseIP)
}
func isBlockedBaseIP(ip net.IP) bool {
if ip.IsLoopback() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
@@ -50,6 +61,59 @@ func isBlockedIP(ip net.IP) bool {
return false
}
// embeddedIPv4 returns the IPv4 addresses carried inside an IPv6 transition
// address, or nil when the address carries none. Teredo yields two: the relay
// server and the (obfuscated) client.
func embeddedIPv4(ip net.IP) []net.IP {
if ip.To4() != nil {
return nil
}
ip16 := ip.To16()
if ip16 == nil {
return nil
}
switch {
// 6to4 — RFC 3056, 2002::/16, IPv4 in bytes 2-6
case ip16[0] == 0x20 && ip16[1] == 0x02:
return []net.IP{net.IPv4(ip16[2], ip16[3], ip16[4], ip16[5])}
// NAT64 well-known prefix — RFC 6052, 64:ff9b::/96, IPv4 in the low 32 bits
case ip16[0] == 0x00 && ip16[1] == 0x64 && ip16[2] == 0xff && ip16[3] == 0x9b &&
isZeros(ip16[4:12]):
return []net.IP{net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15])}
// NAT64 local-use prefix — RFC 8215, 64:ff9b:1::/48. The embedded IPv4 sits
// at a position that depends on the operator's prefix length, so block the
// whole range rather than guess.
case ip16[0] == 0x00 && ip16[1] == 0x64 && ip16[2] == 0xff && ip16[3] == 0x9b && ip16[4] == 0x00 && ip16[5] == 0x01:
return []net.IP{net.IPv4zero}
// Teredo — RFC 4380, 2001::/32. Server IPv4 in bytes 4-8, client IPv4 in
// bytes 12-16 obfuscated by XOR with 0xff.
case ip16[0] == 0x20 && ip16[1] == 0x01 && ip16[2] == 0x00 && ip16[3] == 0x00:
return []net.IP{
net.IPv4(ip16[4], ip16[5], ip16[6], ip16[7]),
net.IPv4(ip16[12]^0xff, ip16[13]^0xff, ip16[14]^0xff, ip16[15]^0xff),
}
// IPv4-compatible — deprecated ::a.b.c.d, not unwrapped by net.IP.To4
case isZeros(ip16[0:12]):
return []net.IP{net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15])}
}
return nil
}
func isZeros(b []byte) bool {
for _, x := range b {
if x != 0 {
return false
}
}
return true
}
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
@@ -226,9 +226,21 @@ func TestIsBlockedIP(t *testing.T) {
"fe80::1",
"224.0.0.1",
"0.0.0.0",
"0.1.2.3", // 0.0.0.0/8 — routes to localhost on Linux
"0.255.255.255", // top of 0.0.0.0/8
"0.1.2.3", // 0.0.0.0/8 — routes to localhost on Linux
"0.255.255.255", // top of 0.0.0.0/8
"255.255.255.255", // limited broadcast
// IPv6 transition addresses embedding a blocked IPv4
"2002:7f00:0001::1", // 6to4 -> 127.0.0.1
"2002:a9fe:a9fe::1", // 6to4 -> 169.254.169.254
"2002:0000:0001::1", // 6to4 -> 0.0.0.1
"64:ff9b::7f00:1", // NAT64 WKP -> 127.0.0.1
"64:ff9b::a9fe:a9fe", // NAT64 WKP -> 169.254.169.254
"64:ff9b:1::7f00:1", // NAT64 local-use prefix
"2001:0000:dead:beef:0000:0000:80ff:fffe", // Teredo -> client 127.0.0.1
"2001:0000:7f00:0001::1", // Teredo -> server 127.0.0.1
"::7f00:1", // IPv4-compatible -> 127.0.0.1
"::a9fe:a9fe", // IPv4-compatible -> 169.254.169.254
}
for _, s := range blocked {
ip := net.ParseIP(s)
@@ -242,6 +254,9 @@ func TestIsBlockedIP(t *testing.T) {
"172.16.5.10",
"8.8.8.8",
"2606:4700:4700::1111",
"2002:0808:0808::1", // 6to4 -> 8.8.8.8
"64:ff9b::808:808", // NAT64 WKP -> 8.8.8.8
"2001:0:808:808::1", // Teredo with public server/client
}
for _, s := range allowed {
ip := net.ParseIP(s)
+7 -7
View File
@@ -13,8 +13,8 @@ import (
)
const (
profileFilename = "profile.json"
DefaultUsername = "__default__"
profileFilename = "profile.json"
DefaultUsername = "__default__"
)
var errMissingProfileErr = errors.New("Profile file does not exist")
@@ -39,11 +39,11 @@ type Settings struct {
}
type Profile struct {
Settings *Settings `json:"settings,omitempty"`
Pinned []string `json:"pinned"`
VisibleKeys []interface{} `json:"visibleKeys,omitempty"`
ReleaseSeen string `json:"releaseSeen,omitempty"`
CollapsedGroups []string `json:"collapsedGroups"`
Settings *Settings `json:"settings,omitempty"`
Pinned []string `json:"pinned"`
VisibleKeys []any `json:"visibleKeys,omitempty"`
ReleaseSeen string `json:"releaseSeen,omitempty"`
CollapsedGroups []string `json:"collapsedGroups"`
}
var dataPath string
+1 -1
View File
@@ -54,7 +54,7 @@ func (Args) Version() string {
return Version
}
func ParseArgs() (Args, interface{}) {
func ParseArgs() (Args, any) {
var args Args
parser := arg.MustParse(&args)
+41 -3
View File
@@ -1,13 +1,16 @@
package cli
import (
"bufio"
"embed"
"fmt"
"os"
"strings"
"github.com/amir20/dozzle/internal/auth"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"golang.org/x/term"
)
type GenerateCmd struct {
@@ -23,13 +26,24 @@ func (g *GenerateCmd) Run(args Args, embeddedCerts embed.FS) error {
writer := zerolog.NewConsoleWriter()
log.Logger = log.Output(writer)
StartEvent(args, "", nil, "generate")
if args.Generate.Username == "" || args.Generate.Password == "" {
return fmt.Errorf("username and password are required")
if args.Generate.Username == "" {
return fmt.Errorf("username is required")
}
password := args.Generate.Password
if password == "" {
var err error
if password, err = readPassword(); err != nil {
return err
}
}
if password == "" {
return fmt.Errorf("password is required")
}
buffer := auth.GenerateUsers(auth.User{
Username: args.Generate.Username,
Password: args.Generate.Password,
Password: password,
Name: args.Generate.Name,
Email: args.Generate.Email,
Filter: args.Generate.Filter,
@@ -42,3 +56,27 @@ func (g *GenerateCmd) Run(args Args, embeddedCerts embed.FS) error {
return nil
}
// readPassword reads a password from stdin. Prompts are written to stderr so
// they don't pollute stdout (which is commonly redirected to users.yml). When
// stdin is a terminal the input is read without echo; otherwise a single line
// is read (supports piping, e.g. `echo secret | dozzle generate ...`).
func readPassword() (string, error) {
fd := int(os.Stdin.Fd())
if term.IsTerminal(fd) {
fmt.Fprint(os.Stderr, "Password: ")
bytePassword, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr)
if err != nil {
return "", fmt.Errorf("failed to read password: %w", err)
}
return strings.TrimRight(string(bytePassword), "\r\n"), nil
}
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil && line == "" {
return "", fmt.Errorf("failed to read password from stdin: %w", err)
}
return strings.TrimRight(line, "\r\n"), nil
}
+5 -6
View File
@@ -8,16 +8,15 @@ import (
"github.com/rs/zerolog/log"
)
func ValidateEnvVars(types ...interface{}) {
func ValidateEnvVars(types ...any) {
expectedEnvs := make(map[string]bool)
for _, t := range types {
typ := reflect.TypeOf(t)
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
for _, tag := range strings.Split(field.Tag.Get("arg"), ",") {
if strings.HasPrefix(tag, "env:") {
expectedEnvs[strings.TrimPrefix(tag, "env:")] = true
for field := range typ.Fields() {
for tag := range strings.SplitSeq(field.Tag.Get("arg"), ",") {
if after, ok := strings.CutPrefix(tag, "env:"); ok {
expectedEnvs[after] = true
}
}
}
+40
View File
@@ -0,0 +1,40 @@
package support_web
import (
"strings"
"testing"
"github.com/amir20/dozzle/internal/container"
)
// A regex filter marks matches with private-use markers as a side effect of
// Search. When a grouped (multi-line) log is then copied as plain text, those
// markers must not leak into the output.
func TestSearchedGroupedLogCopyHasNoMarkers(t *testing.T) {
event := &container.LogEvent{
Type: container.LogTypeGroup,
Message: []container.LogFragment{
{Message: "Exception in thread main"},
{Message: " at com.example.Main(Main.java:10)"},
{Message: " at com.example.Foo(Foo.java:20)"},
},
}
regex, err := ParseRegex("example")
if err != nil {
t.Fatal(err)
}
// This mirrors the copy-logs path: filter via Search (mutating), then render.
if !Search(regex, event) {
t.Fatal("expected search to match the grouped log")
}
out := event.PlainText()
if strings.ContainsAny(out, MarkerStart+MarkerEnd+URLMarkerStart+URLMarkerEnd) {
t.Fatalf("copied text leaked highlight markers: %q", out)
}
if !strings.Contains(out, "at com.example.Main(Main.java:10)") {
t.Fatalf("expected grouped lines preserved, got %q", out)
}
}
+9 -9
View File
@@ -34,7 +34,7 @@ func EscapeHTMLValues(logEvent *container.LogEvent) {
case *orderedmap.OrderedMap[string, string]:
escapeStringMap(value)
case map[string]interface{}:
case map[string]any:
panic("not implemented")
case map[string]string:
@@ -66,11 +66,11 @@ func escapeAnyMap(orderedMap *orderedmap.OrderedMap[string, any]) {
escapeAnyMap(value)
case *orderedmap.OrderedMap[string, string]:
escapeStringMap(value)
case map[string]interface{}:
case map[string]any:
escapeMapStringInterface(value)
case map[string]string:
escapeStringMapString(value)
case []interface{}:
case []any:
escapeSlice(value)
orderedMap.Set(pair.Key, value)
default:
@@ -85,16 +85,16 @@ func escapeStringMap(orderedMap *orderedmap.OrderedMap[string, string]) {
}
}
func escapeMapStringInterface(value map[string]interface{}) {
func escapeMapStringInterface(value map[string]any) {
for key, val := range value {
switch val := val.(type) {
case string:
value[key] = escapeAndProcessMarkers(val)
case map[string]interface{}:
case map[string]any:
escapeMapStringInterface(val)
case map[string]string:
escapeStringMapString(val)
case []interface{}:
case []any:
escapeSlice(val)
}
}
@@ -106,7 +106,7 @@ func escapeStringMapString(value map[string]string) {
}
}
func escapeSlice(slice []interface{}) {
func escapeSlice(slice []any) {
for i, val := range slice {
switch val := val.(type) {
case string:
@@ -115,11 +115,11 @@ func escapeSlice(slice []interface{}) {
escapeAnyMap(val)
case *orderedmap.OrderedMap[string, string]:
escapeStringMap(val)
case map[string]interface{}:
case map[string]any:
escapeMapStringInterface(val)
case map[string]string:
escapeStringMapString(val)
case []interface{}:
case []any:
escapeSlice(val)
}
}
+5 -5
View File
@@ -67,7 +67,7 @@ func (pm *PatternMatcher) MarkInLogEvent(logEvent *container.LogEvent) bool {
case *orderedmap.OrderedMap[string, string]:
return pm.markMapString(value)
case map[string]interface{}:
case map[string]any:
return pm.markMap(value)
case map[string]string:
@@ -105,7 +105,7 @@ func (pm *PatternMatcher) markMapAny(orderedMap *orderedmap.OrderedMap[string, a
found = true
}
case map[string]interface{}:
case map[string]any:
if pm.markMap(value) {
found = true
}
@@ -125,7 +125,7 @@ func (pm *PatternMatcher) markMapAny(orderedMap *orderedmap.OrderedMap[string, a
return found
}
func (pm *PatternMatcher) markMap(data map[string]interface{}) bool {
func (pm *PatternMatcher) markMap(data map[string]any) bool {
found := false
for key, value := range data {
switch value := value.(type) {
@@ -139,7 +139,7 @@ func (pm *PatternMatcher) markMap(data map[string]interface{}) bool {
found = true
}
case map[string]interface{}:
case map[string]any:
if pm.markMap(value) {
found = true
}
@@ -188,7 +188,7 @@ func (pm *PatternMatcher) markArray(data []any) bool {
if pm.markArray(value) {
found = true
}
case map[string]interface{}:
case map[string]any:
if pm.markMap(value) {
found = true
}
+5
View File
@@ -37,6 +37,11 @@ func RingBufferFrom[T any](size int, data []T) *RingBuffer[T] {
func (r *RingBuffer[T]) Push(data T) {
r.mutex.Lock()
defer r.mutex.Unlock()
if r.Size <= 0 {
// A zero (or negative) capacity buffer holds nothing. Bail out before
// indexing r.data or taking a modulo by r.Size, both of which panic.
return
}
if len(r.data) == r.Size {
r.data[r.start] = data
r.start = (r.start + 1) % r.Size
+18
View File
@@ -44,6 +44,24 @@ func TestRingBuffer_MarshalJSON(t *testing.T) {
}
}
func TestRingBuffer_ZeroSize(t *testing.T) {
// A zero-capacity buffer must hold nothing rather than panic. This is
// reachable from the logs endpoint (?min=0), which builds NewRingBuffer(0).
rb := NewRingBuffer[int](0)
rb.Push(1)
rb.Push(2)
if rb.Len() != 0 {
t.Errorf("Expected len to be 0, got %d", rb.Len())
}
data := rb.Data()
if len(data) != 0 {
t.Errorf("Expected data to be empty, got %v", data)
}
}
func TestRingBuffer_Clear(t *testing.T) {
rb := NewRingBuffer[int](3)

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